rvlib-shipkit 1.9.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/check.ts +5 -2
  4. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  5. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  6. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  7. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  8. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  9. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  10. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  11. package/src/cli/cmd_Ci.ts +3 -3
  12. package/src/cli/cmd_Commands.ts +21 -5
  13. package/src/cli/cmd_Hooks.ts +6 -2
  14. package/src/cli/cmd_Init.ts +1 -1
  15. package/src/cli/cmd_Serve.ts +1 -2
  16. package/src/cli/cmd_Start.ts +1 -1
  17. package/src/cli.ts +2 -4
  18. package/src/core/budget.ts +6 -8
  19. package/src/core/bun-builtins.ts +36 -0
  20. package/src/core/bun-tool-cli.ts +23 -0
  21. package/src/core/canonical-scripts.ts +6 -4
  22. package/src/core/check-ctx.ts +104 -10
  23. package/src/core/check-file-names.ts +2 -3
  24. package/src/core/check-pass.ts +17 -1
  25. package/src/core/check-runner.ts +19 -6
  26. package/src/core/config-rewrite.ts +27 -6
  27. package/src/core/discover-checks.ts +30 -10
  28. package/src/core/floor-resolve.ts +16 -7
  29. package/src/core/job-run.ts +2 -2
  30. package/src/core/layout.ts +4 -1
  31. package/src/core/legacy-shapes.ts +40 -0
  32. package/src/core/one-lib.ts +1 -1
  33. package/src/core/one-way-in-policy.ts +106 -13
  34. package/src/core/pre-commit-discovery.ts +106 -0
  35. package/src/core/retired-checks.ts +2 -0
  36. package/src/core/run-ci.ts +16 -10
  37. package/src/core/run-fix.ts +9 -0
  38. package/src/core/run-fixers.ts +91 -35
  39. package/src/core/runs-matrix.ts +7 -0
  40. package/src/core/slots.ts +1 -1
  41. package/src/core/vendored-read.ts +39 -0
  42. package/src/core/vendored.ts +82 -0
  43. package/src/core/waivers.ts +125 -0
  44. package/src/core/when-block.ts +25 -185
  45. package/src/core/when.ts +30 -13
  46. package/src/core/wire.ts +17 -1
  47. package/src/fleet/update-fleet.ts +11 -2
  48. package/src/git-bootstrap.ts +1 -1
  49. package/src/home/attach.ts +3 -5
  50. package/src/home/insights.ts +6 -1
  51. package/src/home/pm-config.ts +2 -2
  52. package/src/home/run-daemon.ts +2 -2
  53. package/src/home/run-unit-action.ts +2 -2
  54. package/src/hooks/commit-message.ts +2 -14
  55. package/src/hooks/hook-gates.ts +38 -0
  56. package/src/hooks/leak-guard.ts +8 -3
  57. package/src/hooks/one-way-in-guard.ts +8 -2
  58. package/src/hooks/root-identity.ts +9 -6
  59. package/src/hooks/run-hooks.ts +72 -41
  60. package/src/hooks/staged.ts +49 -0
  61. package/src/hooks/tree-shrink.ts +41 -0
  62. package/src/init-repo.ts +0 -42
  63. package/src/ledger/check-table.ts +1 -1
  64. package/src/ledger/gate-checks.ts +45 -9
  65. package/src/ledger/ledger.ts +6 -1
  66. package/src/ledger/repo-view.ts +14 -4
  67. package/src/ledger/toolchain-identity.ts +4 -2
  68. package/src/mv/mv-md.ts +6 -1
  69. package/src/problems/extract.ts +27 -5
  70. package/src/problems/problem-line.ts +2 -3
  71. package/src/release/run-release.ts +13 -8
  72. package/src/repo-config.ts +44 -27
  73. package/src/scaffold/files/bunfig.toml +11 -0
  74. package/src/scaffold/files/vscode-settings.json +1 -2
  75. package/src/scaffold/jsonc-spans.ts +16 -0
  76. package/src/scaffold/project-scaffold.ts +43 -77
  77. package/src/serve/hub-app/check-view.tsx +4 -4
  78. package/src/serve/hub-app/check-words.ts +1 -0
  79. package/src/serve/hub-app/checks-svc.ts +1 -2
  80. package/src/serve/hub-app/home.html +0 -1
  81. package/src/serve/hub-app/hub-types.ts +0 -2
  82. package/src/serve/hub-app/index.html +0 -1
  83. package/src/serve/hub-app/project-list.css +1 -2
  84. package/src/serve/hub-app/tree-build.ts +3 -2
  85. package/src/serve/hub-app/tree-svc.ts +2 -2
  86. package/src/serve/hub-port.ts +3 -3
  87. package/src/serve/hub-runtime.ts +0 -3
  88. package/src/serve/serve-hub.ts +1 -16
  89. package/src/serve/tracked-watch.ts +1 -1
  90. package/src/start-repo.ts +15 -3
  91. package/src/suite/judge-suites.ts +29 -28
  92. package/src/suite/select.ts +2 -0
  93. package/src/suite/suite-cli.ts +6 -3
  94. package/src/suite/suite-cpu-preload.ts +7 -11
  95. package/src/suite/test-units.ts +7 -4
  96. package/src/ts/config.ts +12 -5
  97. package/src/ts/imports.ts +3 -1
  98. package/src/tui/build-steps-panel.ts +1 -1
  99. package/src/wiki/scan-md.ts +16 -9
  100. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  101. package/src/checks/SK014-prose-ste.check.ts +0 -78
  102. package/src/cli/cmd_Prose.ts +0 -74
  103. package/src/prose/prose-view.ts +0 -90
  104. package/src/prose/run-prose.ts +0 -187
  105. package/src/prose/ste.ts +0 -552
  106. package/src/scaffold/files/vscode-tasks.json +0 -24
  107. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  108. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  109. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -83,9 +83,8 @@ export type LegacyCheck = {
83
83
 
84
84
  // read without importing: checks run as subprocesses, and discovery must stay
85
85
  // pure-fs. `export const blocking = true` (or false), nothing cleverer.
86
- // the tail is the gate's OWN formatter: oxfmt terminates every statement, and
87
- // the fix pass runs it, so a reader that refuses a semicolon refuses the shape
88
- // the gate itself just wrote.
86
+ // the tail takes what a formatter on its defaults writes: it terminates every
87
+ // statement, so a reader that refuses a semicolon refuses a formatted file.
89
88
  const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
90
89
  const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
91
90
  // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
@@ -12,9 +12,10 @@ import { importClosure } from '../ts/source-graph.ts'
12
12
  import { LIB_ROOT } from './lib-root.ts'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
14
  import type { Scan } from '../scan.ts'
15
+ import { parseDirectives, type Directive } from './waivers.ts'
15
16
 
16
17
  /** a scan's context, and what it recorded while the scan ran */
17
- type ScanRecorder = ScanCtx & { readonly reads: Reads; readonly untracked: string | null }
18
+ type ScanRecorder = ScanCtx & { readonly reads: Reads; readonly untracked: string | null; readonly marked: ReadonlyMap<string, string> }
18
19
 
19
20
  /** the config file every check that consults `c.config` depends on */
20
21
  export const CONFIG_FILE = 'repo.config.ts'
@@ -27,6 +28,7 @@ export class CheckPass {
27
28
  private readonly scans = new Map<string, { value: Promise<unknown>; ctx: ScanRecorder }>()
28
29
  private viewMemo: RepoView | null = null
29
30
  private configMemo: { files: Map<string, string | null>; untracked: string | null } | null = null
31
+ private readonly directiveMemo = new Map<string, { text: string; directives: Directive[] }>()
30
32
 
31
33
  constructor(
32
34
  readonly root: string,
@@ -34,6 +36,10 @@ export class CheckPass {
34
36
  /** where the file list comes from: the run's ledger shares its own,
35
37
  * so a listing and its validation read one snapshot */
36
38
  private readonly openView: () => RepoView = () => RepoView.open(root),
39
+ /** false for a pass whose answers nothing stores (the pre-commit
40
+ * gate): listing what the config imports is then pure cost, measured
41
+ * at 12 to 70ms on the first check that reads `c.config` */
42
+ readonly recordsConfig = true,
37
43
  ) {
38
44
  this.configOid = oidOnDisk(join(root, CONFIG_FILE))
39
45
  }
@@ -61,6 +67,16 @@ export class CheckPass {
61
67
  return this.configMemo
62
68
  }
63
69
 
70
+ /** the directives of a file as read, parsed once per pass however many
71
+ * checks read it */
72
+ directives(path: string, text: string): Directive[] {
73
+ const hit = this.directiveMemo.get(path)
74
+ if (hit != null && hit.text === text) return hit.directives
75
+ const directives = parseDirectives(path, text)
76
+ this.directiveMemo.set(path, { text, directives })
77
+ return directives
78
+ }
79
+
64
80
  view(): RepoView {
65
81
  this.viewMemo ??= this.openView()
66
82
  return this.viewMemo
@@ -450,6 +450,8 @@ type Live = RunnableCheck & CheckResult & {
450
450
  reads: ReadSet | null
451
451
  /** why its answer cannot be stored on its reads, in its own words */
452
452
  untracked: string | null
453
+ /** the files whose in-file directives its answer depends on */
454
+ waiverFiles: string[]
453
455
  /** it printed nothing of its own, so its one line is the headline below */
454
456
  synthetic: boolean
455
457
  /** what the headline carries after the id: a failure's last line, or the
@@ -506,7 +508,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
506
508
  const runDir = openRunDir(o.logDir)
507
509
  const checks: Live[] = sel.selected.map((u) => ({
508
510
  ...u,
509
- status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, synthetic: false, tail: '', extra: [],
511
+ status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, untracked: null, waiverFiles: [], synthetic: false, tail: '', extra: [],
510
512
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
511
513
  }))
512
514
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -599,8 +601,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
599
601
  : [ranHere(composed[0], u.ms), ...composed.slice(1)]
600
602
  // a caller printing its own verdict wants one line per green check and
601
603
  // only the warnings of a warned one; a failure prints whole
604
+ // a waived site is detail whatever the verdict, and its count never is
602
605
  const quiet = (l: Line, i: number): boolean =>
603
- o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
606
+ o.detail != null && l.waiver !== 'count' && (l.waiver === 'site' || (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false))
604
607
  for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
605
608
  if (u.status === 'fail' && !u.advisory && u.logPath != null)
606
609
  emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
@@ -641,6 +644,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
641
644
  await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
642
645
  u.reads = t.untracked == null ? t.reads.snapshot() : null
643
646
  u.untracked = t.untracked
647
+ u.waiverFiles = t.waiverFiles()
644
648
  u.lines = t.lines
645
649
  u.problems = findings(u, t.lines)
646
650
  u.unmeasured = t.unmeasured
@@ -718,6 +722,13 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
718
722
  let i = parsed.length
719
723
  while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
720
724
  u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
725
+ // a tool that speaks no wire (a linter, tsc) ends on a recap, and
726
+ // its errors are the findings its output parsed into
727
+ if (!u.extra.some((l) => l.kind === 'fail'))
728
+ u.extra = u.problems
729
+ .filter((p) => p.severity === 'error' && !u.lastLine.includes(p.message))
730
+ .slice(0, MAX_EXTRA)
731
+ .map((p) => ({ kind: 'fail', text: `${p.file}:${p.line}:${p.col}: ${p.message}` }))
721
732
  }
722
733
  }
723
734
 
@@ -732,9 +743,9 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
732
743
 
733
744
  /** what an attached asker adopts: the verdict and the lines BEFORE this
734
745
  * run's own demotion, which the asker applies from its own declaration */
735
- type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail'> & { measuredMs: number }
746
+ type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail' | 'waiverFiles'> & { measuredMs: number }
736
747
  const answerOf = (u: Live): Answer => ({
737
- status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath,
748
+ status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath, waiverFiles: u.waiverFiles,
738
749
  synthetic: u.synthetic, tail: u.tail, measuredMs: (u.endedAt > 0 ? u.endedAt : clock()) - u.startedAt,
739
750
  })
740
751
  // only a GREEN is ever stored. A pass is one whatever the check blocks. A
@@ -777,7 +788,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
777
788
  u.lines = head == null ? [] : [{ ...head, text: `${head.text}${stamp}` }, ...a.lines.slice(1)]
778
789
  u.problems = feeds(u) ? a.problems ?? [] : []
779
790
  if (o.ledger != null && storable(u))
780
- o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs })
791
+ o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs }, undefined, undefined, a.waiverFiles)
781
792
  }
782
793
 
783
794
  /** ONE measurement per in flight question: an asker whose exact key
@@ -835,12 +846,14 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
835
846
  u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
836
847
  lines: u.lines,
837
848
  clean,
849
+ // a waiver count is a skip line: a world key cannot see the
850
+ // waiving file, so a waived world answer is never kept
838
851
  unmeasured: u.unmeasured || u.lines.some((l) => l.kind === 'skip'),
839
852
  keepNegative: u.blocking === false,
840
853
  })
841
854
  }
842
855
  if (o.ledger != null && keep)
843
- o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked)
856
+ o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads, u.untracked, u.waiverFiles)
844
857
  }
845
858
 
846
859
  const start = (u: Live): void => {
@@ -13,7 +13,7 @@
13
13
  import { readFileSync } from 'fs'
14
14
  import { join } from 'path'
15
15
  import { atomicWrite } from './locked-write.ts'
16
- import { dropConfigKey } from './legacy-shapes.ts'
16
+ import { dropConfigEntry, dropConfigKey } from './legacy-shapes.ts'
17
17
  import { writeGateBlock } from './when-block.ts'
18
18
  import { loadRepoConfig } from '../repo-config.ts'
19
19
 
@@ -25,6 +25,12 @@ export type ConfigPlan = {
25
25
  drop: { key: string; why: string; phase?: 'first' | 'afterOff' }[]
26
26
  /** the config's `name` before the rewrite: what the read back must still say */
27
27
  name: string
28
+ /** entries of an object key to lift into `off` (`hooks.disable`, a
29
+ * `defaults` slot set to false), dropped once the block took them. The
30
+ * comment written right above an entry becomes the reason of every id it
31
+ * carries, since it is the author's own words; `reason` is the honest
32
+ * fallback when there is none */
33
+ lift?: { parent: string; entry: string; value: string; ids: string[]; reason: Record<string, string> }[]
28
34
  }
29
35
 
30
36
  export type ConfigRewrite = {
@@ -48,17 +54,23 @@ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<Con
48
54
  let first = before
49
55
  for (const d of plan.drop.filter((x) => x.phase === 'first')) first = drop(first, d)
50
56
  if (first !== before) atomicWrite(path, first)
57
+ // each lifted entry's reason, read off the text before anything moves
58
+ const off = { ...plan.off }
59
+ for (const l of plan.lift ?? []) {
60
+ const seen = dropConfigEntry(first, l.parent, l.entry, l.value)
61
+ for (const id of l.ids) off[id] ??= seen?.comment ?? l.reason[id] ?? ''
62
+ }
51
63
  let offWritten = false
52
- if (Object.keys(plan.off).length > 0) {
64
+ if (Object.keys(off).length > 0) {
53
65
  try {
54
- await writeGateBlock(root, { off: plan.off })
66
+ await writeGateBlock(root, { off })
55
67
  offWritten = true
56
- did.push(`${Object.keys(plan.off).length} off entr(ies) written into the managed block`)
68
+ did.push(`${Object.keys(off).length} off entr(ies) written into the managed block`)
57
69
  } catch (e) {
58
70
  // a key dropped first held these entries: without the block they
59
71
  // would be gone, so the file goes back as it was
60
- if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(plan.off)}: ${String(e).slice(0, 160)}` } }
61
- hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(plan.off)} into it by hand`)
72
+ if (first !== before) { atomicWrite(path, before); return { did: [], hand, restored: `the managed block refused off ${JSON.stringify(off)}: ${String(e).slice(0, 160)}` } }
73
+ hand.push(`the managed block refused the rewrite (${String(e).slice(0, 160)}) — write off: ${JSON.stringify(off)} into it by hand`)
62
74
  }
63
75
  }
64
76
  const mid = readFileSync(path, 'utf8')
@@ -67,11 +79,20 @@ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<Con
67
79
  if (d.phase === 'afterOff' && !offWritten) continue
68
80
  text = drop(text, d)
69
81
  }
82
+ // a lifted entry goes only once the block holds it, so it is never lost
83
+ for (const l of offWritten ? plan.lift ?? [] : []) {
84
+ const next = dropConfigEntry(text, l.parent, l.entry, l.value)
85
+ if (next == null) { hand.push(`repo.config.ts \`${l.parent}.${l.entry}\` is \`off\` now, and the entry could not be read mechanically — delete it by hand`); continue }
86
+ did.push(`repo.config.ts drops \`${l.parent}.${l.entry}\``)
87
+ text = next.text
88
+ }
70
89
  if (text !== mid) atomicWrite(path, text)
71
90
  if (text !== before) {
72
91
  try {
73
92
  const after = await loadRepoConfig(root, { fresh: true })
74
93
  if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
94
+ const lost = Object.keys(off).filter((id) => after.off?.[id] == null)
95
+ if (offWritten && lost.length > 0) throw new Error(`the config no longer turns ${lost.join(', ')} off`)
75
96
  } catch (e) {
76
97
  atomicWrite(path, before)
77
98
  return { did, hand, restored: String(e).slice(0, 160) }
@@ -16,7 +16,7 @@
16
16
  // are told apart by reading the source first (`isLegacyCheckSource`).
17
17
  //
18
18
  // Skipped dirs: node_modules, dist, tmp, references (templates for other
19
- // repos), and every dot-dir.
19
+ // repos), every dot-dir, and the repo's vendored dirs (core/vendored.ts).
20
20
  import { RETIRED_CHECK_IDS } from './retired-checks.ts'
21
21
  import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
22
22
  import { basename, join, relative } from 'path'
@@ -24,6 +24,7 @@ import { check, type CheckDef, type CheckFrom } from '../check.ts'
24
24
 
25
25
  import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS, UNIT_SUFFIX, unitBase } from './check-file-names.ts'
26
26
  import { unitKind } from '../shipkit-files.ts'
27
+ import { readVendored } from './vendored-read.ts'
27
28
 
28
29
  export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
29
30
 
@@ -56,23 +57,27 @@ export type DiscoveredCheck = {
56
57
  from: CheckFrom
57
58
  }
58
59
 
59
- function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
60
+ function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[], prune?: ReadonlySet<string>): void {
60
61
  dirs?.push(dir)
61
62
  let entries
62
63
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
63
64
  for (const e of entries) {
64
65
  if (e.isDirectory()) {
65
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
66
+ const sub = join(dir, e.name)
67
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.') && prune?.has(sub) !== true) walk(sub, out, suffix, dirs, prune)
66
68
  } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
67
69
  }
68
70
  }
69
71
 
72
+ /** the absolute vendored dirs of a repo, which no repo walk enters */
73
+ const vendoredPrune = (root: string): ReadonlySet<string> => new Set(readVendored(root).map((d) => join(root, d)))
74
+
70
75
  /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
71
76
  * `dirs` collects every dir the walk read: a file added to one moves its
72
77
  * mtime, which is how a reader tells the list moved without walking again */
73
78
  export function repoUnitFiles(root: string, dirs?: string[]): string[] {
74
79
  const out: string[] = []
75
- walk(root, out, UNIT_SUFFIX, dirs)
80
+ walk(root, out, UNIT_SUFFIX, dirs, vendoredPrune(root))
76
81
  return out.sort()
77
82
  }
78
83
 
@@ -97,7 +102,7 @@ export async function importFresh(path: string): Promise<unknown> {
97
102
  * Sorted. */
98
103
  export function repoCheckFiles(root: string): string[] {
99
104
  const out: string[] = []
100
- walk(root, out)
105
+ walk(root, out, SUFFIX, undefined, vendoredPrune(root))
101
106
  return out.sort()
102
107
  }
103
108
 
@@ -175,13 +180,24 @@ const BUILTIN_OWNER = 'build/shipkit/built-in-checks.md'
175
180
  * on a registration nobody can act on (a duplicate id, a repo check squatting
176
181
  * the reserved prefix, a built-in with no owner page), so a broken
177
182
  * declaration dies at load rather than half way through a gate. A broken
178
- * built-in is that only in the toolkit's own repo: elsewhere it warns.
179
- * `builtinDir` is the toolkit's checks dir, a parameter for tests only */
180
- export async function discoverChecks(root: string, builtinDir: string = BUILTIN_CHECK_DIR): Promise<DiscoveredCheck[]> {
183
+ * built-in is that only in the toolkit's own repo: elsewhere it warns. */
184
+ export type DiscoverOpts = {
185
+ /** a file not to import at all: the pre-commit gate skips the files its
186
+ * declarations table already knows run no onPreCommit body */
187
+ skip?: (path: string) => boolean
188
+ /** every import, timed: `def` is null for a unit file of another kind,
189
+ * and `loaded` is false for a file that did not load */
190
+ imported?: (path: string, ms: number, def: CheckDef | null, loaded: boolean) => void
191
+ /** the toolkit's checks dir, a parameter for tests only */
192
+ builtinDir?: string
193
+ }
194
+
195
+ export async function discoverChecks(root: string, o: DiscoverOpts = {}): Promise<DiscoveredCheck[]> {
196
+ const builtinDir = o.builtinDir ?? BUILTIN_CHECK_DIR
181
197
  const builtins: string[] = []
182
198
  walk(builtinDir, builtins)
183
199
  const found: string[] = []
184
- walk(root, found)
200
+ walk(root, found, SUFFIX, undefined, vendoredPrune(root))
185
201
  const units = repoUnitFiles(root)
186
202
  // the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
187
203
  // discovered twice is a duplicate id rather than a second check. Real paths,
@@ -195,18 +211,22 @@ export async function discoverChecks(root: string, builtinDir: string = BUILTIN_
195
211
  builtins.sort()
196
212
  const names = fallbackNames(root, all)
197
213
  // a legacy script is left to its own reader, and never imported here
198
- const own = all.filter((f) => !isLegacyCheckFile(f))
214
+ const own = all.filter((f) => o.skip?.(f) === true || !isLegacyCheckFile(f))
199
215
  const base = (f: string): string => basename(f).slice(0, -SUFFIX.length)
200
216
 
201
217
  const out: DiscoveredCheck[] = []
202
218
  const ids = new Set<string>()
203
219
  for (const f of [...builtins, ...own, ...units]) {
204
220
  const isBuiltin = builtins.includes(f)
221
+ if (o.skip?.(f) === true) continue
205
222
  let def: CheckDef | null
206
223
  let loadError: string | null = null
224
+ const t = performance.now()
207
225
  try {
208
226
  def = await load(f)
227
+ o.imported?.(f, performance.now() - t, def, true)
209
228
  } catch (e) {
229
+ o.imported?.(f, performance.now() - t, null, false)
210
230
  // a file that does not load is ONE red row, never a dead inventory:
211
231
  // every other check still runs, and this one says why in every run
212
232
  loadError = (e as Error).message
@@ -25,6 +25,9 @@ import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
25
25
  import { declaredTestUnits, siblingUnits, testCheckId, unitIgnores, unitTestBody, unitTsconfig, type TestUnitDecl } from '../suite/test-units.ts'
26
26
  import { describeFirst, type TaskDef } from '../check.ts'
27
27
  import type { RepoConfig } from '../repo-config.ts'
28
+ import { repoOff } from './when.ts'
29
+ import { bunfigDeclares, bunLintCmd } from './bun-builtins.ts'
30
+ import { normalizeVendored } from './vendored.ts'
28
31
 
29
32
  /** how the floor reads the repo, by repo-relative path, plus the one lookup
30
33
  * outside it: a binary on PATH */
@@ -65,10 +68,10 @@ export const unitTestDescription = (u: { id: string; rel: string }): string =>
65
68
  export const unitTypecheckDescription = (u: { id: string; rel: string }): string =>
66
69
  describeFirst(`tsc finds no type error under ${u.rel}`, `tsc finds no type error in unit ${u.id}`)
67
70
 
68
- export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'unit'
71
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'built-in (bun)' | 'unit'
69
72
  export type FloorResolution = {
70
73
  tasks: TaskDef[]
71
- /** slots disabled by `defaults: { x: false }` — always printed */
74
+ /** slots the repo turned off (core/when.ts repoOff), always printed */
72
75
  disabled: string[]
73
76
  /** slots nothing could fill — always printed, never silent */
74
77
  unfilled: { id: string; reason: string }[]
@@ -161,9 +164,14 @@ function tsconfigChain(rel: string, io: FloorReader): string[] {
161
164
  }
162
165
 
163
166
  // zero-config floor (O6): with no script, imports:check/cycles resolve to
164
- // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
165
- // the task, or the reason the slot stays empty.
166
- function builtinFloorTask(id: string, root: string, io: FloorReader): { task: TaskDef; source: FloorSource } | string {
167
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc, lint to
168
+ // `bun lint` once bunfig.toml declares [lint]. Returns the task, or the
169
+ // reason the slot stays empty.
170
+ function builtinFloorTask(id: string, root: string, io: FloorReader, vendored: readonly string[]): { task: TaskDef; source: FloorSource } | string {
171
+ if (id === 'lint') {
172
+ if (!bunfigDeclares(io.text('bunfig.toml'), 'lint')) return `no 'lint' script and no [lint] section in bunfig.toml`
173
+ return { task: { id, cmd: bunLintCmd(vendored) }, source: 'built-in (bun)' }
174
+ }
167
175
  if (id === 'imports:check' || id === 'cycles') {
168
176
  let cli: string
169
177
  // shipkit's OWN module, resolved from this file: no dependency to be
@@ -234,7 +242,8 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
234
242
  const notes: string[] = []
235
243
  const subs = declaredTestUnits(c.units)
236
244
  for (const id of FLOOR_IDS) {
237
- const override = c.defaults?.[id]
245
+ // a slot the repo turned off resolves to no task, whichever spelling said so
246
+ const override = repoOff(c, id) != null ? false : c.defaults?.[id]
238
247
  const description = slotDescription(id, subs.length > 0)
239
248
  const slot = slotBody(id, override, scripts)
240
249
  if (slot.kind === 'off') { disabled.push(id); continue }
@@ -250,7 +259,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
250
259
  return override == null || override === false ? t : { ...t, ...override, id }
251
260
  }
252
261
  if (slot.kind === 'script') { tasks.push(decorate({ id, cmd: scriptCmd(c, id, slot.name, slot.body, notes, subs) })); sources[id] = 'package.json script'; continue }
253
- const b = builtinFloorTask(id, root, io)
262
+ const b = builtinFloorTask(id, root, io, (c.vendored ?? []).map(normalizeVendored))
254
263
  // a routed script with nothing behind it measures NOTHING, and the
255
264
  // reason must say that rather than `no '<id>' script`, which is false
256
265
  if (typeof b === 'string') unfilled.push({ id, reason: slot.kind === 'routed-empty' ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
@@ -4,14 +4,14 @@
4
4
  // the machine's one background turn here, so the turn outlives a daemon
5
5
  // reload the same as the run does.
6
6
  import { basename } from 'path'
7
- import { useOneLib } from './one-lib.ts'
7
+ import { installOneLib } from './one-lib.ts'
8
8
  import { appendJobCheck, type JobKind, type JobOpts } from './jobs.ts'
9
9
  import { autoGatePool, takeSlot } from './slots.ts'
10
10
  import type { CheckEvent } from './check-runner.ts'
11
11
 
12
12
  export async function runJob(root: string, id: string, kind: JobKind, job: JobOpts, print: (l: string) => void): Promise<number> {
13
13
  // before the repo config loads: it imports this copy of the lib, never its own install
14
- if (job.oneLib === true) useOneLib()
14
+ if (job.oneLib === true) installOneLib()
15
15
  const turn = job.turn === true ? await takeSlot(autoGatePool(), { repo: basename(root), check: 'auto gate' }, { pollMs: 250 }) : null
16
16
  try {
17
17
  const onCheck = (e: CheckEvent): void => appendJobCheck(root, id, e)
@@ -19,6 +19,9 @@ export const LAYOUT = {
19
19
  identity: sk('identity'),
20
20
  assets: sk('assets'),
21
21
  comfy: sk('comfy'),
22
+ // a bug's repro test once its fix lands: kept for the record, never run
23
+ // (bun test skips dot dirs), moved into the suite only when it earns its wait
24
+ repros: sk('repros'),
22
25
  // local: drafts, private notes, venue listings until they go live
23
26
  private: sk('private'),
24
27
  reflections: sk('reflections'),
@@ -41,7 +44,7 @@ export const LAYOUT = {
41
44
  } as const
42
45
 
43
46
  /** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
44
- export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy] as const
47
+ export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros] as const
45
48
 
46
49
  /** the machine-global private folder, under $HOME: one keyword list and one
47
50
  * allowlist per machine, never copied into a repo */
@@ -221,3 +221,43 @@ export function dropConfigKey(text: string, key: string): string | null {
221
221
  while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
222
222
  return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
223
223
  }
224
+
225
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
226
+
227
+ /** the config text without one entry of a top level object key, as in
228
+ * `disable` inside `hooks` or `lint: false` inside `defaults`, plus the words
229
+ * of the comment written right above the entry, which are the closest thing
230
+ * to a reason the entry ever had. `value` is the entry's value as a regex
231
+ * source, so only the shape the caller expects is touched. The parent goes
232
+ * too once nothing is left in it. Null when the parent or the entry is not
233
+ * there exactly once, or sits in a shape this reader does not follow */
234
+ export function dropConfigEntry(text: string, parent: string, entry: string, value: string): { text: string; comment: string | null } | null {
235
+ const lines = text.split('\n')
236
+ const at = lines.map((l, i) => (new RegExp(`^\\s*${esc(parent)}\\s*:`).test(l) ? i : -1)).filter((i) => i !== -1)
237
+ if (at.length !== 1) return null
238
+ const start = at[0]!
239
+ const end = statementEnd(lines, start)
240
+ const key = `(['"]?)${esc(entry)}\\1\\s*:\\s*(?:${value})`
241
+ if (start === end) {
242
+ // one line: `hooks: { blockedPaths: [...], disable: ['leak-guard'] },`
243
+ const line = lines[start]!
244
+ const hits = line.match(new RegExp(`(?<![\\w$])${key}`, 'g')) ?? []
245
+ if (hits.length !== 1) return null
246
+ let next = line.replace(new RegExp(`,\\s*${key}`), '')
247
+ if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}\\s*,\\s*`), '')
248
+ if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}`), '')
249
+ const out = [...lines.slice(0, start), next, ...lines.slice(start + 1)].join('\n')
250
+ return { text: /:\s*\{\s*\}\s*,?\s*(\/\/.*)?$/.test(next) ? (dropConfigKey(out, parent) ?? out) : out, comment: null }
251
+ }
252
+ const inner = lines.map((l, i) => (i > start && i <= end && new RegExp(`^\\s*${key}\\s*,?\\s*$`).test(l) ? i : -1)).filter((i) => i !== -1)
253
+ if (inner.length !== 1) return null
254
+ const i = inner[0]!
255
+ let from = i
256
+ while (from - 1 > start && lines[from - 1]!.trim().startsWith('//')) from--
257
+ const comment = lines.slice(from, i).map((l) => l.trim().replace(/^\/\/\s?/, '')).join(' ').trim()
258
+ const kept = [...lines.slice(0, from), ...lines.slice(i + 1)]
259
+ const body = kept.slice(start + 1, end - (i - from + 1))
260
+ const empty = body.every((l) => l.trim() === '' || l.trim().startsWith('//'))
261
+ const out = kept.join('\n')
262
+ return { text: empty ? (dropConfigKey(out, parent) ?? out) : out, comment: comment === '' ? null : comment }
263
+ }
@@ -102,7 +102,7 @@ const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.ts
102
102
  let installed = false
103
103
 
104
104
  /** route every import of the package to this process's copy. Idempotent */
105
- export function useOneLib(root: string = LIB_ROOT): void {
105
+ export function installOneLib(root: string = LIB_ROOT): void {
106
106
  if (installed) return
107
107
  installed = true
108
108
  const exports = readExports(root)
@@ -14,7 +14,7 @@
14
14
  // The deny is the floor and the hook is the messenger. When the hook cannot
15
15
  // run, enforcement degrades to terse rather than to nothing.
16
16
  //
17
- // Subprocesses are NOT intercepted (verified): shipkit spawning oxlint or
17
+ // Subprocesses are NOT intercepted (verified): shipkit spawning tsc or
18
18
  // `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
19
19
  // these rules, which is exactly what makes the gate itself still work.
20
20
 
@@ -24,7 +24,7 @@ export type RawRunner = {
24
24
  /** the floor slot it answers, so ONE exemption governs the script name and
25
25
  * the shell command together: a repo that keeps `test` raw is not then
26
26
  * refused `bun test` */
27
- slot: 'test' | 'lint' | 'typecheck'
27
+ slot: 'test' | 'typecheck'
28
28
  /** the shipkit command that answers the same question, cache and all */
29
29
  use: string
30
30
  /** the narrower way in, when there is one: iterating on a single repro
@@ -47,25 +47,118 @@ export const RAW_RUNNERS: readonly RawRunner[] = [
47
47
  { head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
48
48
  { head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
49
49
  { head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
50
- { head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
51
- { head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
52
- { head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
53
- { head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
54
- { head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
55
50
  ]
56
51
 
57
- // the FORMATTER is deliberately absent, and that is not an oversight. It
58
- // answers no cached question and produces no green anyone could mistake for a
59
- // verification, shipkit's own fix pass runs it BY SCRIPT (`bun run fmt`), and
60
- // denying the bare binary while that stays open would be theatre. `shipkit ci`
61
- // formats before it gates, which is the only reason the reflex matters at all.
52
+ // the LINTER and the FORMATTER are deliberately absent. `bun lint` and
53
+ // `bun fmt` are the house way in: bun's built-ins cover a whole repo in well
54
+ // under a second, so a cache has nothing to save, and the formatter is a
55
+ // manual tool the gate never runs.
62
56
 
63
57
  /** the `permissions.deny` entries, in the syntax Claude Code matches:
64
58
  * `Bash(<head>*)` is a prefix match, and a compound command is checked one
65
59
  * segment at a time, so `cd x && bun test` is caught too */
66
60
  export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
67
61
  const off = new Set(exemptSlots)
68
- return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
62
+ return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`), GIT_STASH_RULE]
63
+ }
64
+
65
+ // `git stash` in a tree that other agents share takes every agent's
66
+ // uncommitted work, not just the caller's (identity/how-we-commit.md: never
67
+ // stash as a side effect). The deny row is the floor. Claude Code matches it
68
+ // per subcommand of a compound and inside `$(...)`, and never inside
69
+ // `bash -c '...'`, after git's own global options (`git -C dir stash`), or
70
+ // through `eval`: the guard closes those (stashCallIn).
71
+ export const GIT_STASH_HEAD = 'git stash'
72
+ export const GIT_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
73
+
74
+ /** the stash subcommands that only read, which the guard lets through */
75
+ const STASH_READS = new Set(['list', 'show'])
76
+
77
+ /** git's global options that take their value as the NEXT word */
78
+ const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'])
79
+
80
+ const unquote = (w: string): string => w.replace(/^(['"])(.*)\1$/, '$2')
81
+
82
+ /** a segment's words, a quoted run kept as one word, subshell and group
83
+ * brackets dropped */
84
+ const quotedWords = (seg: string): string[] =>
85
+ (seg.match(/(?:'[^']*'|"(?:[^"\\]|\\.)*"|[^\s'"])+/g) ?? []).map((w) => w.replace(/^[({]+|[)}]+$/g, '')).filter((w) => w !== '')
86
+
87
+ /** the bodies of `$(...)` and backtick substitutions outside single quotes,
88
+ * where a shell runs them */
89
+ function substitutions(seg: string): string[] {
90
+ const out: string[] = []
91
+ let single = false
92
+ for (let i = 0; i < seg.length; i++) {
93
+ const ch = seg[i]!
94
+ if (ch === "'") { single = !single; continue }
95
+ if (single) continue
96
+ if (ch === '`') {
97
+ const end = seg.indexOf('`', i + 1)
98
+ if (end < 0) break
99
+ out.push(seg.slice(i + 1, end))
100
+ i = end
101
+ } else if (ch === '$' && seg[i + 1] === '(') {
102
+ let depth = 0
103
+ let j = i + 1
104
+ for (; j < seg.length; j++) {
105
+ if (seg[j] === '(') depth++
106
+ else if (seg[j] === ')' && --depth === 0) break
107
+ }
108
+ out.push(seg.slice(i + 2, j))
109
+ i = j
110
+ }
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** the script a segment hands to another shell: `bash -c '<script>'`,
116
+ * `sh -c`, `zsh -c`, or `eval <words>` */
117
+ function innerScript(words: string[], seg: string): string | null {
118
+ const program = words[0]?.split('/').pop() ?? ''
119
+ if (program === 'eval') {
120
+ const all = quotedWords(seg)
121
+ return all.slice(all.indexOf(words[0]!) + 1).map(unquote).join(' ')
122
+ }
123
+ if (!/^(?:ba|z|da|k)?sh$/.test(program)) return null
124
+ const m = /\s-[a-z]*c[a-z]*\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+))/.exec(seg)
125
+ return m == null ? null : (m[1] ?? m[2] ?? m[3] ?? null)
126
+ }
127
+
128
+ /** the segment of a command that runs a stash which is not a read, or null.
129
+ * Like rawRunnerHit it reads only what a shell would RUN: a heredoc body and
130
+ * a quoted argument are data, so a commit message that mentions the command
131
+ * is never refused. */
132
+ export function stashCallIn(command: string, depth = 0): string | null {
133
+ if (depth > 3) return null
134
+ for (const seg of shellSegments(command)) {
135
+ for (const inner of substitutions(seg)) {
136
+ const hit = stashCallIn(inner, depth + 1)
137
+ if (hit != null) return hit
138
+ }
139
+ const words = programWords(seg)
140
+ const script = innerScript(words, seg)
141
+ if (script != null) {
142
+ const hit = stashCallIn(script, depth + 1)
143
+ if (hit != null) return hit
144
+ continue
145
+ }
146
+ if ((words[0]?.split('/').pop() ?? '') !== 'git') continue
147
+ // re-split with quotes kept whole: `git -C "a dir" stash`
148
+ const all = quotedWords(seg)
149
+ const g = all.findIndex((w) => w.split('/').pop() === 'git')
150
+ const args = all.slice(g + 1)
151
+ let i = 0
152
+ while (i < args.length && args[i]!.startsWith('-')) i += GIT_VALUE_OPTS.has(args[i]!) ? 2 : 1
153
+ if (unquote(args[i] ?? '') !== 'stash') continue
154
+ if (!STASH_READS.has(unquote(args[i + 1] ?? ''))) return seg.trim()
155
+ }
156
+ return null
157
+ }
158
+
159
+ /** what a stash refusal says: why, what to do instead, how to lift it */
160
+ export function stashRefusal(segment: string): string {
161
+ return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`. Lifting this: delete the "${GIT_STASH_RULE}" line from .claude/settings.json permissions.deny (identity/how-we-commit.md).`
69
162
  }
70
163
 
71
164
  /** what a refusal says. Never just the ban: a refusal with no replacement