rvlib-shipkit 1.8.0 → 2.0.0

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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. 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
  }
@@ -10,6 +10,7 @@ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
10
10
  import { reportToMotherhome } from './motherhome.ts'
11
11
  import { passScope, runCheckPass, type CheckOpts } from './run-check.ts'
12
12
  import { poolLines } from './slots.ts'
13
+ import { fixPassRuns } from './fix-scope.ts'
13
14
  import { runFixPass } from './run-fixers.ts'
14
15
  import { formatLine } from './wire.ts'
15
16
  import { outcomeCounts } from './outcome-words.ts'
@@ -29,8 +30,9 @@ export type CiOpts = {
29
30
  onlyIds?: string[]
30
31
  /** refresh STATUS.md after the run (default true — O4: every repo gets it) */
31
32
  status?: boolean
32
- /** repair the mechanical slots before gating. default on locally, verify
33
- * mode under CI. core/run-fixers.ts owns the scope. */
33
+ /** repair the mechanical slots before gating, verify mode under CI.
34
+ * Unset: on for a whole gate, off for a narrowed one (core/fix-scope.ts).
35
+ * core/run-fixers.ts owns the scope. */
34
36
  fix?: boolean
35
37
  /** also apply the repairs of the checks declaring `fix: 'ci'` during the
36
38
  * fix pass */
@@ -135,19 +137,22 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
135
137
  // what the reader never acts on: kept for STATUS.md and --verbose, never
136
138
  // printed on a default run, where every line costs a reader tokens
137
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)
138
144
  let c = o.config ?? (await loadRepoConfig(root))
139
145
  // mechanical failures are not information, so they never cost a round trip
140
146
  let fixFailed = false
141
- // a NARROWED run repairs only the mechanical slots. The structural rule
142
- // fixers rewrite package.json and repo config, and arming rewrites hooks/:
143
- // that is the right price for the full gate and the wrong one for
144
- // `bun run test`, which routes here and must answer the one question its
145
- // name promises without leaving files in the agent's diff that it never
146
- // wrote. Under CI it is worse than surprising: a formatting rewrite comes
147
- // back as a test failure.
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
150
+ // question its name promises without leaving files in the agent's diff
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
148
153
  const narrowed = (o.onlyIds ?? []).length > 0
149
- if (o.fix !== false) {
150
- const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail })
154
+ if (fixPassRuns(o)) {
155
+ const pass = await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink, detail, ...(narrowed ? { only: o.onlyIds } : {}) })
151
156
  fixFailed = pass.failed
152
157
  // a fixer that rewrote the config or moved a check file leaves the
153
158
  // config above describing a repo that is gone, so the gate reads the
@@ -158,7 +163,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
158
163
  return 1
159
164
  }
160
165
  }
161
- } else detail('skip: fix pass (--no-fix)')
166
+ } else detail(o.fix === false ? 'skip: fix pass (--no-fix)' : 'skip: fix pass (a --only run leaves the tree alone, --fix repairs first)')
162
167
  const tier: TestTier = o.tests ?? envTier()
163
168
  // announced BEFORE the run (under --verbose, and in every release log),
164
169
  // so a log that died mid-way still says which tier it was asking for
@@ -178,7 +183,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
178
183
  // the harness refused it the raw runner, so the line says what to add
179
184
  // rather than leaving it with `unknown check id` and no next move
180
185
  if ((o.onlyIds ?? []).includes(u.id))
181
- 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)`)
182
189
  }
183
190
  // the run record: one run id for every check of this gate, so a breakdown
184
191
  // of the wall time is reconstructable. It is never read here, only written
@@ -251,7 +258,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
251
258
  runLog,
252
259
  onCheck: o.onCheck,
253
260
  reportHome: false,
254
- print: sink,
261
+ print: checkLine,
255
262
  detail,
256
263
  })
257
264
  // what the suite ACTUALLY ran, from its own headline. A flag that was set
@@ -288,10 +295,10 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
288
295
  for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
289
296
  // why every check that did not replay ran, grouped: a re-run nobody
290
297
  // can explain costs a reader more than the line does
291
- for (const { line, quiet } of ledger.explain()) (quiet ? detail : sink)(formatLine(line, false))
298
+ for (const { line } of ledger.explain()) detail(formatLine(line, false))
292
299
  }
293
300
  // one write for the whole gate, after everything has finished
294
- for (const l of runLog.flush()) sink(formatLine(l, false))
301
+ for (const l of runLog.flush()) checkLine(formatLine(l, false))
295
302
  const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
296
303
  // THE VERDICT, one line and only on a pass: a red run already said so in
297
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
@@ -4,6 +4,11 @@
4
4
  // mutate lease's non blocking form (locked-write.ts tryLease): a slot whose
5
5
  // holder died is free at once, and a live holder heartbeats it.
6
6
  //
7
+ // A slot is a check, and a check is often a whole multi core tool (bun test
8
+ // workers, linter threads, tsc), so slots alone never bound the CPU. Past the
9
+ // cores the pool admits fewer slots in proportion to the 1 minute load, never
10
+ // fewer than one (admittedSlots). A slot already held is never taken back.
11
+ //
7
12
  // The one constraint a reader breaks first: a process started BY a check
8
13
  // (SHIPKIT_SLOT_HELD=1 in its environment) takes no slot. It already runs on
9
14
  // its parent's, and a parent waiting on a child that waits for a slot the
@@ -12,6 +17,8 @@ import { mkdirSync, readdirSync } from 'fs'
12
17
  import { homedir } from 'os'
13
18
  import { join } from 'path'
14
19
  import { leaseHolder, tryLease } from './locked-write.ts'
20
+ import { machineLoad } from './budget.ts'
21
+ import type { MachineLoad } from '../suite/judge-suites.ts'
15
22
  import { PARALLEL_ENV, parallelCap, parallelCapInfo } from './parallel.ts'
16
23
 
17
24
  /** where the slots live, for a test that must not touch the machine's pool */
@@ -19,7 +26,9 @@ export const SLOTS_DIR_ENV = 'SHIPKIT_SLOTS_DIR'
19
26
  /** set for every process a check spawns: it runs on its parent's slot */
20
27
  export const SLOT_HELD_ENV = 'SHIPKIT_SLOT_HELD'
21
28
 
22
- export type SlotPool = { dir: string; size: number }
29
+ /** `load`: the reading that scales what the pool admits. Omitted: the whole
30
+ * size, whatever the machine runs (a test's own pool) */
31
+ export type SlotPool = { dir: string; size: number; load?: () => MachineLoad }
23
32
 
24
33
  export type SlotHolder = { slot: number; pid: number; repo: string; check: string; since: number }
25
34
 
@@ -37,9 +46,18 @@ export const autoGatePool = (): SlotPool => ({ dir: `${poolDir()}-auto-gate`, si
37
46
 
38
47
  /** this process's pool, or null when it runs inside a check that holds one */
39
48
  export function machinePool(): SlotPool | null {
40
- return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap() }
49
+ return process.env[SLOT_HELD_ENV] === '1' ? null : { dir: poolDir(), size: parallelCap(), load: machineLoad }
41
50
  }
42
51
 
52
+ /** how many of `size` slots a machine at this load admits: all of them up to
53
+ * a load of one per core, then size × cores / load, never below one. At 20
54
+ * on 8 cores a pool of 8 admits 3, at 81 it admits 1 */
55
+ export const admittedSlots = (size: number, m: MachineLoad): number =>
56
+ Math.max(1, Math.min(size, Math.floor((size * m.cores) / Math.max(m.cores, m.load1))))
57
+
58
+ const admitWords = (size: number, admitted: number, m: MachineLoad): string =>
59
+ `the pool admits ${admitted} of its ${size} slots at load ${m.load1.toFixed(1)} on ${m.cores} cores`
60
+
43
61
  /** who holds which slot right now, dead holders left out. Every slot file in
44
62
  * the dir is read, so a slot taken under a larger size still shows. */
45
63
  export function poolOccupancy(pool: SlotPool = { dir: poolDir(), size: parallelCap() }): { size: number; dir: string; held: SlotHolder[] } {
@@ -64,25 +82,37 @@ export function poolLines(): string[] {
64
82
  ...(info.problem == null ? [] : [`WARN ${info.problem}`]),
65
83
  pool == null
66
84
  ? `ok: parallel: inside a check that holds a machine slot, so this run takes none (up to ${info.cap} at once in this process)`
67
- : `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})`,
85
+ : `ok: parallel: one machine pool of ${info.cap} slot(s) shared by every shipkit process, ${poolOccupancy(pool).held.length} held now (${why})${loadNote(pool)}`,
68
86
  ]
69
87
  }
70
88
 
89
+ const loadNote = (pool: SlotPool): string => {
90
+ const m = pool.load?.()
91
+ if (m == null) return ''
92
+ const admitted = admittedSlots(pool.size, m)
93
+ return admitted < pool.size ? `; ${admitWords(pool.size, admitted, m)}` : ''
94
+ }
95
+
71
96
  /** wait for a free slot and hold it until release(). null = abort said stop. */
72
97
  export async function takeSlot(
73
98
  pool: SlotPool,
74
99
  who: { repo: string; check: string },
75
- o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[]) => void } = {},
100
+ o: { abort?: () => boolean; pollMs?: number; onWait?: (held: SlotHolder[], why: string) => void } = {},
76
101
  ): Promise<{ slot: number; release: () => void } | null> {
77
102
  mkdirSync(pool.dir, { recursive: true })
78
103
  let heard = false
79
104
  for (;;) {
80
- for (let slot = 0; slot < pool.size; slot++) {
105
+ const m = pool.load?.() ?? null
106
+ const admitted = m == null ? pool.size : admittedSlots(pool.size, m)
107
+ for (let slot = 0; slot < admitted; slot++) {
81
108
  const lease = tryLease(join(pool.dir, `slot-${slot}`), who.check, { repo: who.repo, check: who.check })
82
109
  if (lease != null) return { slot, release: lease.release }
83
110
  }
84
111
  if (o.abort?.() === true) return null
85
- if (!heard) { heard = true; o.onWait?.(poolOccupancy(pool).held) }
112
+ if (!heard) {
113
+ heard = true
114
+ o.onWait?.(poolOccupancy(pool).held, m != null && admitted < pool.size ? admitWords(pool.size, admitted, m) : `all ${pool.size} slots are held`)
115
+ }
86
116
  await Bun.sleep(o.pollMs ?? SLOT_POLL_MS)
87
117
  }
88
118
  }
@@ -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
+ }