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
@@ -0,0 +1,106 @@
1
+ // discovery for the pre-commit gate: import ONLY the check files that run a
2
+ // body at commit. Full discovery imports every check file to read one field,
3
+ // and a repo file that imports heavy modules at its top level then costs every
4
+ // commit, seconds once its sources moved since the last transpile.
5
+ //
6
+ // A table beside the per worktree git dir remembers, per file, whether it
7
+ // declares `onPreCommit`, keyed on the file's mtime and size. A file whose
8
+ // stamp still matches and that runs nothing at commit is never imported. A
9
+ // file that moved is imported again, and every import is timed, so a slow
10
+ // one is named on the commit that pays for it.
11
+ //
12
+ // The stamp is the file's own: a check that takes its `runs` from another
13
+ // module is re-read when its own file moves, or when the table is dropped.
14
+ import { mkdirSync, readFileSync, statSync } from 'fs'
15
+ import { dirname, join } from 'path'
16
+ import { gitRoot } from './git-root.ts'
17
+ import { atomicWrite } from './locked-write.ts'
18
+ import { BUILTIN_CHECK_DIR, discoverChecks, type DiscoveredCheck } from './discover-checks.ts'
19
+
20
+ /** a repo check file whose import takes longer than this, warm, is named */
21
+ export const SLOW_IMPORT_MS = 30
22
+
23
+ const TABLE_VERSION = 1
24
+
25
+ type Row = { stamp: string; preCommit: boolean }
26
+ type Table = { v: number; files: Record<string, Row> }
27
+
28
+ export const declarationsTable = (root: string): string | null => {
29
+ const dir = gitRoot(root)?.gitDir
30
+ return dir == null ? null : join(dir, 'shipkit', 'check-declarations.json')
31
+ }
32
+
33
+ function readTable(path: string | null): Table {
34
+ if (path == null) return { v: TABLE_VERSION, files: {} }
35
+ try {
36
+ const t = JSON.parse(readFileSync(path, 'utf8')) as Table
37
+ if (t.v === TABLE_VERSION && t.files != null && typeof t.files === 'object') return t
38
+ } catch {}
39
+ return { v: TABLE_VERSION, files: {} }
40
+ }
41
+
42
+ const stampOf = (path: string): string | null => {
43
+ try {
44
+ const s = statSync(path)
45
+ return `${s.mtimeMs}:${s.size}`
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ export type PreCommitDiscovery = {
52
+ /** what discovery returns, minus the files the table skipped */
53
+ checks: DiscoveredCheck[]
54
+ /** repo check files whose import took longer than SLOW_IMPORT_MS */
55
+ slow: { path: string; ms: number }[]
56
+ /** how many files were imported, and how many the table skipped */
57
+ imported: number
58
+ skipped: number
59
+ }
60
+
61
+ export async function discoverPreCommit(root: string): Promise<PreCommitDiscovery> {
62
+ const path = declarationsTable(root)
63
+ const table = readTable(path)
64
+ const stamps = new Map<string, string | null>()
65
+ const stamp = (f: string): string | null => {
66
+ if (!stamps.has(f)) stamps.set(f, stampOf(f))
67
+ return stamps.get(f) ?? null
68
+ }
69
+ let changed = false
70
+ let skipped = 0
71
+ let imported = 0
72
+ const slow: { path: string; ms: number }[] = []
73
+ const checks = await discoverChecks(root, {
74
+ skip: (f) => {
75
+ const row = table.files[f]
76
+ const hit = row != null && !row.preCommit && row.stamp === stamp(f)
77
+ if (hit) skipped++
78
+ return hit
79
+ },
80
+ imported: (f, ms, def, loaded) => {
81
+ imported++
82
+ if (!f.startsWith(`${BUILTIN_CHECK_DIR}/`) && ms > SLOW_IMPORT_MS) slow.push({ path: f, ms })
83
+ const s = stamp(f)
84
+ // a file that did not load is asked again next time: its error is
85
+ // the gate's to show, never the table's to remember
86
+ if (!loaded || s == null) return
87
+ const row: Row = { stamp: s, preCommit: def != null && def.runs.onPreCommit !== false }
88
+ const prev = table.files[f]
89
+ if (prev?.stamp === row.stamp && prev.preCommit === row.preCommit) return
90
+ table.files[f] = row
91
+ changed = true
92
+ },
93
+ })
94
+ if (changed && path != null) {
95
+ // a row whose file is gone is dropped, so the table never outgrows the repo
96
+ for (const f of Object.keys(table.files)) if (stamp(f) == null) delete table.files[f]
97
+ try {
98
+ mkdirSync(dirname(path), { recursive: true })
99
+ atomicWrite(path, JSON.stringify(table))
100
+ } catch {
101
+ // a table that cannot be written costs the next commit the full
102
+ // import, never its answer
103
+ }
104
+ }
105
+ return { checks, slow, imported, skipped }
106
+ }
@@ -2,6 +2,8 @@
2
2
  // id is never reused, so a doc or a changelog may still name one, and
3
3
  // discovery refuses a built-in that takes one again.
4
4
  export const RETIRED_CHECK_IDS: Readonly<Record<string, string>> = {
5
+ SK009: 'the AI attribution guard is gone: no rule polices commit trailers',
6
+ SK014: 'the STE prose rule is gone: no rule polices prose style',
5
7
  SK021: "the gh login is a machine fact: the house repo's github-login check runs src/core/gh-login.ts",
6
8
  SK023: "the store caps are the machine daemon's health: the house repo's stores-under-cap check",
7
9
  }
@@ -137,18 +137,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
137
137
  // what the reader never acts on: kept for STATUS.md and --verbose, never
138
138
  // printed on a default run, where every line costs a reader tokens
139
139
  const detail = (l: string): void => { collected.push(l); if (o.verbose === true) print(l) }
140
+ // a check that answered green says nothing a reader acts on: the verdict
141
+ // counts it. What needs a reader (a WARN, a FAIL and its fix, a wait for a
142
+ // machine slot) always prints
143
+ const checkLine = (l: string): void => (/^(ok|skip): /.test(l) && !/^ok: waiting\b/.test(l) ? detail : sink)(l)
140
144
  let c = o.config ?? (await loadRepoConfig(root))
141
145
  // mechanical failures are not information, so they never cost a round trip
142
146
  let fixFailed = false
143
- // a NARROWED run repairs nothing unless --fix asks, and even then only the
144
- // mechanical slots. `bun run test` routes here and must answer the one
147
+ // a NARROWED run repairs nothing unless --fix asks, and even then only
148
+ // what the named checks own: their own ci repair and the fixer slot that
149
+ // governs one of them. `bun run test` routes here and must answer the one
145
150
  // question its name promises without leaving files in the agent's diff
146
- // that it never wrote: in a shared tree the formatter rewrites every other
147
- // agent's files too. The structural rule fixers rewrite package.json and
148
- // repo config, and arming rewrites hooks/: the full gate's price alone
151
+ // that it never wrote: in a shared tree a tree wide fixer rewrites every
152
+ // other agent's files too. Arming rewrites hooks/: the full gate's price alone
149
153
  const narrowed = (o.onlyIds ?? []).length > 0
150
154
  if (fixPassRuns(o)) {
151
- const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail })
155
+ const pass = await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail, ...(narrowed ? { only: o.onlyIds } : {}) })
152
156
  fixFailed = pass.failed
153
157
  // a fixer that rewrote the config or moved a check file leaves the
154
158
  // config above describing a repo that is gone, so the gate reads the
@@ -179,7 +183,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
179
183
  // the harness refused it the raw runner, so the line says what to add
180
184
  // rather than leaving it with `unknown check id` and no next move
181
185
  if ((o.onlyIds ?? []).includes(u.id))
182
- sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
186
+ sink(u.id === 'lint'
187
+ ? ` fix: add a [lint] section to bunfig.toml, then \`shipkit ci --only lint\` runs \`bun lint\` (build/typescript.md)`
188
+ : ` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
183
189
  }
184
190
  // the run record: one run id for every check of this gate, so a breakdown
185
191
  // of the wall time is reconstructable. It is never read here, only written
@@ -252,7 +258,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
252
258
  runLog,
253
259
  onCheck: o.onCheck,
254
260
  reportHome: false,
255
- print: sink,
261
+ print: checkLine,
256
262
  detail,
257
263
  })
258
264
  // what the suite ACTUALLY ran, from its own headline. A flag that was set
@@ -289,10 +295,10 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
289
295
  for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
290
296
  // why every check that did not replay ran, grouped: a re-run nobody
291
297
  // can explain costs a reader more than the line does
292
- for (const { line, quiet } of ledger.explain()) (quiet ? detail : sink)(formatLine(line, false))
298
+ for (const { line } of ledger.explain()) detail(formatLine(line, false))
293
299
  }
294
300
  // one write for the whole gate, after everything has finished
295
- for (const l of runLog.flush()) sink(formatLine(l, false))
301
+ for (const l of runLog.flush()) checkLine(formatLine(l, false))
296
302
  const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
297
303
  // THE VERDICT, one line and only on a pass: a red run already said so in
298
304
  // its FAIL lines. The whole gate adds the tier and the cache counts, which
@@ -15,6 +15,10 @@ import type { RepoConfig } from '../repo-config.ts'
15
15
 
16
16
  export type FixOpts = {
17
17
  ids?: string[]
18
+ /** the gate's `--only` names: of the ci repairs, only the ones these
19
+ * name, by check id or by group, and silently none for a name that has
20
+ * no repair */
21
+ scope?: string[]
18
22
  /** drop the closing advisory: inside the ci fix pass it is noise, and it is
19
23
  * not wire-shaped, which the frozen output contract forbids */
20
24
  quiet?: boolean
@@ -55,6 +59,11 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
55
59
 
56
60
  // no ids: the repairs declared for the gate. A manual one runs by name
57
61
  let targets = all.filter((r) => r.fix != null && r.when === 'ci')
62
+ if (o.scope != null) {
63
+ const named = new Set(o.scope)
64
+ const groups = new Map((await gateInventory(root, config)).map((r) => [r.decl.id, r.group]))
65
+ targets = targets.filter((r) => named.has(r.id) || named.has(groups.get(r.id) ?? ''))
66
+ }
58
67
  if (o.ids != null && o.ids.length > 0) {
59
68
  for (const id of o.ids) {
60
69
  const r = all.find((x) => x.id === id)
@@ -1,20 +1,20 @@
1
1
  // what `shipkit ci` repairs before it gates.
2
2
  //
3
- // mechanical slots only (formatter, import canonicaliser): they rewrite the
4
- // files the agent is already editing and their output is total. structural
5
- // SK rule fixers rewrite repo config, so they stay behind --fix-rules.
3
+ // mechanical slots only (the import canonicaliser): it rewrites the files the
4
+ // agent is already editing and its output is total. structural SK rule fixers
5
+ // rewrite repo config, so they stay behind --fix-rules. no formatter runs
6
+ // here: formatting is a manual `bun fmt`, never a gate step.
6
7
  //
7
8
  // under CI the same fixers run in verify mode instead: a gate that silently
8
9
  // rewrites the tree it checks can turn red green with no commit. one
9
- // mechanism, two modes, rather than a formatter plus a format:check that
10
- // drift apart.
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
10
+ // mechanism, two modes, rather than a fixer plus a check that drift apart.
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
12
12
  import { dirname, isAbsolute, join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from './run-fix.ts'
17
- import { routesToShipkit } from './canonical-scripts.ts'
17
+ import { repoOff } from './when.ts'
18
18
  import { discoverLegacyChecks } from './discover-legacy-checks.ts'
19
19
  import { repoCheckFiles } from './discover-checks.ts'
20
20
  import { mutateLease, withMutateLease } from './locked-write.ts'
@@ -24,6 +24,8 @@ import { spawnWithTimeout } from './spawn-timeout.ts'
24
24
  import { gitBin } from './git-bin.ts'
25
25
  import { LAYOUT } from './layout.ts'
26
26
  import { gateBinary, toolchainIdentity } from '../ledger/toolchain-identity.ts'
27
+ import { isVendored } from './vendored.ts'
28
+ import { readVendored } from './vendored-read.ts'
27
29
 
28
30
  const FIXER_TIMEOUT_MS = 120_000
29
31
 
@@ -35,8 +37,6 @@ export type FixerSlot = {
35
37
 
36
38
  export type FixerResolution = {
37
39
  slots: FixerSlot[]
38
- /** slots nothing could fill, printed, never silent */
39
- unfilled: { id: string; reason: string }[]
40
40
  /** slots the repo turned off, printed with the same vocabulary the task
41
41
  * side uses, so a disable is never invisible in the output */
42
42
  disabled: { id: string; reason: string }[]
@@ -47,10 +47,6 @@ export type FixerResolution = {
47
47
  // anyway is the config saying one thing and the tool doing another.
48
48
  const GOVERNED_BY: Record<string, string> = { 'imports:fix': 'imports:check' }
49
49
 
50
- // one fixer per mechanical concern. `fmt` accepts the two names the house
51
- // vocabulary allows so a repo that calls it `format` is not silently unfixed.
52
- const FMT_SCRIPTS = ['fmt', 'format'] as const
53
-
54
50
  export function resolveFixers(root: string, config?: RepoConfig): FixerResolution {
55
51
  const pkgPath = join(root, 'package.json')
56
52
  let scripts: Record<string, string> = {}
@@ -59,31 +55,23 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
59
55
  catch (e) { throw new Error(`package.json unparseable — fixers cannot register: ${String(e).slice(0, 120)}`) }
60
56
  }
61
57
  const slots: FixerSlot[] = []
62
- const unfilled: { id: string; reason: string }[] = []
63
58
  const disabled: { id: string; reason: string }[] = []
64
59
  const offByConfig = (id: string): boolean => {
65
60
  const governing = GOVERNED_BY[id]
66
61
  if (governing == null) return false
67
- if (config?.defaults?.[governing] !== false) return false
68
- disabled.push({ id, reason: `skipped-by-config (defaults: { '${governing}': false })` })
62
+ const off = config == null ? null : repoOff(config, governing)
63
+ if (off == null) return false
64
+ disabled.push({ id, reason: `skipped-by-config (${governing} is off: ${off.reason})` })
69
65
  return true
70
66
  }
71
67
 
72
- // `fmt` is the one slot the fix pass invokes BY SCRIPT, so it is the one
73
- // name ONE WAY IN deliberately leaves raw: a routed body here would make
74
- // the fixer spawn the gate that spawns the fixer
75
- const fmtName = FMT_SCRIPTS.find((n) => scripts[n] != null && !routesToShipkit(scripts[n]!))
76
- if (fmtName != null) slots.push({ id: 'fmt', cmd: ['bun', 'run', fmtName], source: 'package.json script' })
77
- else if (FMT_SCRIPTS.some((n) => scripts[n] != null)) unfilled.push({ id: 'fmt', reason: `the 'fmt' script routes back to shipkit — the fix pass runs it, so it holds the formatter itself` })
78
- else unfilled.push({ id: 'fmt', reason: `no 'fmt' or 'format' script — formatting stays a manual step` })
79
-
80
68
  if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
81
69
  else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
82
70
  else {
83
71
  const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
84
72
  slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
85
73
  }
86
- return { slots, unfilled, disabled }
74
+ return { slots, disabled }
87
75
  }
88
76
 
89
77
  /** worktree fingerprint: every dirty path mapped to its CONTENT hash, or null
@@ -101,10 +89,9 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
101
89
  * bytes attributes only what a fixer can actually have done.
102
90
  *
103
91
  * It watches the WHOLE tree on purpose. A narrower porcelain was tried and
104
- * deleted: the only fixer whose targets are declarable is the formatter, and
105
- * the same pass also runs the rule fixers, which rewrite package.json,
106
- * .gitignore, CLAUDE.md and .claude/, none of them inside a formatter's
107
- * scope. Narrowing to the formatter's paths therefore hid real rewrites from
92
+ * deleted: the same pass also runs the rule fixers, which rewrite
93
+ * package.json, .gitignore, CLAUDE.md and .claude/, none of them inside a
94
+ * source fixer's scope. Narrowing to the fixers' paths therefore hid real rewrites from
108
95
  * the verify mode, which is a silent green on a tree a fixer changed. What
109
96
  * actually stops another agent's work being reported here is the lease plus
110
97
  * the byte comparison, not a path list. */
@@ -125,6 +112,50 @@ function treeState(root: string): Map<string, string> | null {
125
112
  return m
126
113
  }
127
114
 
115
+ /** the vendored files whose work tree differs from the index, each with its
116
+ * bytes (null when deleted). One pathspec limited diff, paths relative to
117
+ * `root` even in a nested repo, so a repo with no vendored dir costs nothing */
118
+ function vendoredState(root: string, dirs: readonly string[]): Map<string, Buffer | null> {
119
+ const m = new Map<string, Buffer | null>()
120
+ if (dirs.length === 0) return m
121
+ const p = Bun.spawnSync([gitBin(), 'diff', '--name-only', '--relative', '-z', '--', ...dirs], { cwd: root, stdout: 'pipe', stderr: 'ignore' })
122
+ if (p.exitCode !== 0) return m
123
+ for (const path of p.stdout.toString().split('\0')) {
124
+ if (path === '' || !isVendored(path, dirs)) continue
125
+ try { m.set(path, readFileSync(join(root, path))) } catch { m.set(path, null) }
126
+ }
127
+ return m
128
+ }
129
+
130
+ /** put back every vendored file a fixer changed since `before`: its bytes from
131
+ * before when it differed from the index then, the index's otherwise */
132
+ function restoreVendored(root: string, dirs: readonly string[], before: Map<string, Buffer | null>): { restored: string[]; failed: string[] } {
133
+ const restored: string[] = []
134
+ const failed: string[] = []
135
+ const fromIndex: string[] = []
136
+ const now = vendoredState(root, dirs)
137
+ const putBack = (path: string, was: Buffer | null): void => {
138
+ try {
139
+ if (was === null) rmSync(join(root, path), { force: true })
140
+ else writeFileSync(join(root, path), was)
141
+ restored.push(path)
142
+ } catch { failed.push(path) }
143
+ }
144
+ for (const [path, bytes] of now) {
145
+ const was = before.get(path)
146
+ if (was === undefined) fromIndex.push(path)
147
+ else if (was === null ? bytes !== null : bytes === null || !was.equals(bytes)) putBack(path, was)
148
+ }
149
+ // differed from the index before and matches it now: a fixer undid an edit
150
+ for (const [path, was] of before) if (!now.has(path)) putBack(path, was)
151
+ if (fromIndex.length > 0) {
152
+ const p = Bun.spawnSync([gitBin(), 'checkout', '--', ...fromIndex], { cwd: root, stdout: 'ignore', stderr: 'ignore' })
153
+ if (p.exitCode === 0) restored.push(...fromIndex)
154
+ else failed.push(...fromIndex)
155
+ }
156
+ return { restored: restored.sort(), failed: failed.sort() }
157
+ }
158
+
128
159
  /** where the key of the last clean fix pass is kept, per checkout */
129
160
  const memoPath = (root: string): string => join(root, LAYOUT.tmp, 'fix-pass.key')
130
161
 
@@ -173,7 +204,11 @@ export type FixPassOpts = {
173
204
  * mutate the tree they are about to ship) */
174
205
  armHooks?: boolean
175
206
  print: (line: string) => void
176
- /** where the lines nobody acts on go (a clean pass, an unfilled slot).
207
+ /** a narrowed pass (`sk ci --only <ids> --fix`): only the repairs these
208
+ * ids own, their own ci repair and the fixer slot that governs one of
209
+ * them */
210
+ only?: string[]
211
+ /** where the lines nobody acts on go (a clean pass, a disabled slot).
177
212
  * Default: print. `shipkit ci` shows them only under --verbose */
178
213
  detail?: (line: string) => void
179
214
  }
@@ -194,7 +229,7 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
194
229
  // not clean, and the gate says so instead of quietly repairing it
195
230
  const verify = process.env.CI != null && process.env.CI !== ''
196
231
  // EVERY write this function makes sits inside one lease. Three agents in
197
- // one checkout is the normal case, and two formatters rewriting the same
232
+ // one checkout is the normal case, and two fixers rewriting the same
198
233
  // file at once, or two runs arming hooks and rewriting package.json
199
234
  // together, is the one hazard a snapshot diff cannot even detect. The
200
235
  // loser waits rather than skipping, because a skipped repair never
@@ -260,17 +295,34 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
260
295
  else if (armed.state === 'failed') o.print(`WARN hooks could not arm: ${armed.detail}`)
261
296
  }
262
297
  const detail = o.detail ?? o.print
263
- const { slots, unfilled, disabled } = resolveFixers(root, config)
264
- for (const u of unfilled) detail(`skip: ${u.id} (${u.reason})`)
298
+ const resolution = resolveFixers(root, config)
299
+ const { disabled } = resolution
300
+ const named = o.only == null ? null : new Set(o.only)
301
+ const slots = named == null ? resolution.slots : resolution.slots.filter((s) => named.has(GOVERNED_BY[s.id] ?? ''))
302
+ if (named != null) for (const s of resolution.slots) if (!slots.includes(s)) detail(`skip: ${s.id} (--only ${o.only!.join(', ')} names no check it repairs)`)
265
303
  for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
266
304
  const before = treeState(root)
267
305
  // the fixers already ran clean over exactly this tree (HEAD, every dirty
268
306
  // path's bytes, the toolchain, the commands): running them again rewrites
269
- // nothing and costs a whole formatter pass. CI never skips: there the
307
+ // nothing and costs a whole fixer pass. CI never skips: there the
270
308
  // pass IS the check
271
309
  const memo = verify || before == null ? null : fixMemo(root, before, slots.map((s) => s.cmd))
272
310
  const skip = memo != null && readMemo(root) === memo
273
311
  let fixersOk = true
312
+ // third party code stays byte identical: whatever a fixer wrote under a
313
+ // vendored dir goes back at once, and the fixer is named so the repo
314
+ // scopes it (it walked those files for nothing)
315
+ let vendored: string[] = []
316
+ try { vendored = readVendored(root) } catch (e) { o.print(`WARN vendored dirs unreadable, nothing guards them this pass: ${String((e as Error)?.message ?? e).slice(0, 160)}`) }
317
+ let guard = vendoredState(root, vendored)
318
+ const putBack = (who: string): void => {
319
+ if (vendored.length === 0) return
320
+ const { restored, failed } = restoreVendored(root, vendored, guard)
321
+ if (restored.length > 0) o.print(`WARN ${who} rewrote ${restored.length} vendored file(s), put back byte for byte: ${restored.slice(0, 4).join(' ')}${restored.length > 4 ? ` … +${restored.length - 4}` : ''}`)
322
+ if (restored.length > 0) o.print(` fix: scope ${who} to leave ${vendored.join(', ')} out (repo.config.ts \`vendored\`)`)
323
+ if (failed.length > 0) o.print(`WARN ${who} rewrote vendored file(s) that could not be put back: ${failed.slice(0, 4).join(' ')} — restore them with git checkout`)
324
+ guard = vendoredState(root, vendored)
325
+ }
274
326
  if (skip) detail(`ok: fixers skipped, the tree is unchanged since they last ran clean (${slots.length} fixer(s))`)
275
327
  else for (const s of slots) {
276
328
  const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
@@ -280,9 +332,13 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
280
332
  fixersOk = false
281
333
  o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
282
334
  }
335
+ putBack(`fix ${s.id} (${s.cmd.join(' ').slice(0, 60)})`)
283
336
  }
284
337
  // structural repo-config repair is never implicit, and never in verify mode
285
- if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, detail: o.detail, config })
338
+ if (o.rules === true && !verify) {
339
+ await runFix(root, { quiet: true, print: o.print, detail: o.detail, config, ...(o.only == null ? {} : { scope: o.only }) })
340
+ putBack('the rule repairs')
341
+ }
286
342
 
287
343
  const after = treeState(root)
288
344
  const reshaped = shapeStamp(root) !== shapeBefore
@@ -5,6 +5,9 @@
5
5
  import type { CheckRow } from '../ledger/gate-checks.ts'
6
6
  import { cellText, RUNS_KEYS, type RunsAxis, type RunsSource } from './runs.ts'
7
7
 
8
+ /** the commit column's words for a commit gate (hooks/hook-gates.ts HookName) */
9
+ const HOOK_WORDS: readonly string[] = ['pre-commit', 'commit-msg', 'both']
10
+
8
11
  export type MatrixAxis = RunsAxis | 'fix'
9
12
 
10
13
  export const MATRIX_AXES: readonly MatrixAxis[] = [...RUNS_KEYS, 'fix']
@@ -103,6 +106,9 @@ export function runsCellsOf(row: CheckRow): RunsCells {
103
106
  const r = row.runs
104
107
  const cells = Object.fromEntries(MATRIX_AXES.map((k) => {
105
108
  const v = k === 'fix' ? r.fix : r[k]
109
+ // a commit gate runs in a git hook rather than as an onPreCommit body,
110
+ // and the commit column names which hook
111
+ if (k === 'onPreCommit' && row.hook != null) return [k, { text: row.hook, on: r.off == null, source: r.source[k] }]
106
112
  const on = r.off == null && v !== false && v !== 'none'
107
113
  return [k, { text: cellText(r, k), on, source: r.source[k] }]
108
114
  })) as Record<MatrixAxis, RunsCell>
@@ -127,6 +133,7 @@ export function runsWords(c: RunsCells): string {
127
133
  * prints short (yes, ·, moved, 24h); the legend names the declared value */
128
134
  export function cellMeaning(k: MatrixAxis | 'cache', text: string): string {
129
135
  const values = RUNS_LEGEND.find((a) => a.axis === k)!.values
136
+ if (k === 'onPreCommit' && HOOK_WORDS.includes(text)) return 'a commit gate: that git hook runs it on every commit, and `shipkit hooks list` shows it'
130
137
  const declared = k === 'cache'
131
138
  ? text.split(/[\s:]/)[0]!
132
139
  : text === 'yes' ? 'true'
package/src/core/slots.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // holder died is free at once, and a live holder heartbeats it.
6
6
  //
7
7
  // A slot is a check, and a check is often a whole multi core tool (bun test
8
- // workers, oxlint threads, tsc), so slots alone never bound the CPU. Past the
8
+ // workers, linter threads, tsc), so slots alone never bound the CPU. Past the
9
9
  // cores the pool admits fewer slots in proportion to the 1 minute load, never
10
10
  // fewer than one (admittedSlots). A slot already held is never taken back.
11
11
  //
@@ -0,0 +1,39 @@
1
+ // the disk half of core/vendored.ts: a repo's vendored dirs, read off its
2
+ // config text, remembered per root while the file holds still.
3
+ import { existsSync, readFileSync, statSync } from 'fs'
4
+ import { dirname, join, resolve } from 'path'
5
+ import { vendoredOfText } from './vendored.ts'
6
+
7
+ const CONFIG_FILE = 'repo.config.ts'
8
+
9
+ const memo = new Map<string, { stamp: string; dirs: string[] }>()
10
+
11
+ /** the vendored dirs of the repo at `root`, from its config text. Empty with
12
+ * no config. Remembered per root while the file's size and mtime hold */
13
+ export function readVendored(root: string): string[] {
14
+ const path = join(root, CONFIG_FILE)
15
+ let stamp: string
16
+ try {
17
+ const s = statSync(path)
18
+ stamp = `${s.size}:${s.mtimeMs}`
19
+ } catch {
20
+ return []
21
+ }
22
+ const hit = memo.get(root)
23
+ if (hit?.stamp === stamp) return hit.dirs
24
+ const dirs = vendoredOfText(readFileSync(path, 'utf8'))
25
+ memo.set(root, { stamp, dirs })
26
+ return dirs
27
+ }
28
+
29
+ /** the repo a dir sits in (the nearest dir up holding repo.config.ts) and its
30
+ * vendored dirs, null outside any shipkit repo */
31
+ export function vendoredAround(dir: string): { root: string; dirs: string[] } | null {
32
+ let cur = resolve(dir)
33
+ for (;;) {
34
+ if (existsSync(join(cur, CONFIG_FILE))) return { root: cur, dirs: readVendored(cur) }
35
+ const up = dirname(cur)
36
+ if (up === cur) return null
37
+ cur = up
38
+ }
39
+ }
@@ -0,0 +1,82 @@
1
+ // `vendored` in repo.config.ts: repo-relative dirs of THIRD PARTY code (a git
2
+ // subtree, a copied library) that no shipkit tool lints, formats, rewrites or
3
+ // analyses as the repo's own source. Read as TEXT, never by loading the
4
+ // config: the source-graph cli, the md scan and the file listings run where
5
+ // the config is never loaded, and a prefix test per path is the whole cost.
6
+ // loadRepoConfig refuses a config whose loaded value and text disagree.
7
+ // Pure, so a check's closure can import it: the disk half is vendored-read.ts.
8
+ //
9
+ // the one constraint a reader breaks first: cache keys and watchers still
10
+ // read vendored files. Vendored code is still an INPUT to builds and tests;
11
+ // only the tools that treat the tree as the repo's own source omit it.
12
+ import { configDepths } from './identity-color.ts'
13
+
14
+ const CONFIG_FILE = 'repo.config.ts'
15
+
16
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)vendored\1\s*:\s*/gm
17
+
18
+ /** an entry as every tool compares it: posix, no `./`, no trailing `/` */
19
+ export const normalizeVendored = (d: string): string => d.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '')
20
+
21
+ /** why an entry cannot be a vendored dir, null when it can */
22
+ export function vendoredProblem(entry: unknown): string | null {
23
+ if (typeof entry !== 'string' || entry.trim() === '') return 'is not a repo-relative dir path'
24
+ const d = normalizeVendored(entry)
25
+ if (d === '' || d === '.') return 'is the repo root: it would omit the whole repo from every tool'
26
+ if (entry.startsWith('/') || d.split('/').includes('..')) return 'leaves the repo: name a dir inside it'
27
+ if (/[*?[\]{}!]/.test(d)) return 'is a glob: name the dir itself, every path under it is omitted'
28
+ return null
29
+ }
30
+
31
+ /** the `vendored` entries the config text declares, normalized. Empty when
32
+ * the key is absent. Throws when the value is anything but a literal array
33
+ * of string literals, which a reader of the text cannot evaluate */
34
+ export function vendoredOfText(text: string): string[] {
35
+ const depth = configDepths(text)
36
+ const found: string[][] = []
37
+ for (const m of text.matchAll(KEY_RE)) {
38
+ const sep = /^[{,]/.test(m[0]) ? m.index : m.index - 1
39
+ const colon = m.index + m[0].lastIndexOf(':')
40
+ if (sep < 0 || depth[sep] !== 1 || depth[colon] !== 1) continue
41
+ const open = m.index + m[0].length
42
+ if (text[open] !== '[') throw new Error(`${CONFIG_FILE}: \`vendored\` must be a literal array of strings, the tools that never load the config read it as text`)
43
+ let close = open + 1
44
+ while (close < text.length && !(text[close] === ']' && depth[close] === 1)) close++
45
+ if (close >= text.length) throw new Error(`${CONFIG_FILE}: \`vendored\` has no closing \`]\``)
46
+ const body = text.slice(open + 1, close).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '')
47
+ const items: string[] = []
48
+ const rest = body.replace(/(['"])((?:\\.|(?!\1).)*)\1/g, (_, _q: string, s: string) => { items.push(s); return '' })
49
+ if (!/^[\s,]*$/.test(rest)) throw new Error(`${CONFIG_FILE}: \`vendored\` must list string literals only, the tools that never load the config read it as text`)
50
+ found.push(items.map(normalizeVendored))
51
+ }
52
+ if (found.length > 1) throw new Error(`${CONFIG_FILE} declares \`vendored\` more than once`)
53
+ return found[0] ?? []
54
+ }
55
+
56
+ /** is this repo-relative path a vendored dir or inside one */
57
+ export function isVendored(rel: string, dirs: readonly string[]): boolean {
58
+ if (dirs.length === 0) return false
59
+ const n = rel.replace(/\\/g, '/').replace(/^(\.\/)+/, '')
60
+ return dirs.some((d) => n === d || n.startsWith(`${d}/`))
61
+ }
62
+
63
+ /** the glob's fixed leading dirs, before its first wildcard */
64
+ const globBase = (glob: string): string => {
65
+ const segs = glob.replace(/^(\.\/)+/, '').split('/')
66
+ const fixed: string[] = []
67
+ for (const s of segs.slice(0, -1)) {
68
+ if (/[*?[\]{}!]/.test(s)) break
69
+ fixed.push(s)
70
+ }
71
+ return fixed.join('/')
72
+ }
73
+
74
+ /** a listing with the vendored files left out, unless the glob itself names a
75
+ * path inside a vendored dir: a check that asks for `vendor/lib/src/**`
76
+ * wants exactly those files */
77
+ export function listingWithoutVendored(files: readonly string[], glob: string, dirs: readonly string[]): string[] {
78
+ if (dirs.length === 0) return [...files]
79
+ const base = globBase(glob)
80
+ if (base !== '' && isVendored(base, dirs)) return [...files]
81
+ return files.filter((f) => !isVendored(f, dirs))
82
+ }