rvlib-shipkit 0.33.0 → 0.35.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 (50) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Ci.ts +3 -0
  6. package/src/cli/cmd_Keys.ts +113 -0
  7. package/src/cli.ts +2 -0
  8. package/src/core/check-suite.ts +54 -47
  9. package/src/core/discover-checks.ts +30 -7
  10. package/src/core/floor.ts +34 -6
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/motherhome.ts +54 -1
  13. package/src/core/one-way-in-policy.ts +8 -8
  14. package/src/core/run-check.ts +92 -12
  15. package/src/core/run-ci.ts +115 -17
  16. package/src/core/run-fixers.ts +61 -18
  17. package/src/core/task-runner.ts +62 -6
  18. package/src/hooks/run-hooks.ts +23 -2
  19. package/src/ledger/gate-units.ts +84 -0
  20. package/src/ledger/git-key.ts +381 -0
  21. package/src/ledger/git-scan.ts +327 -0
  22. package/src/ledger/ledger.ts +202 -0
  23. package/src/ledger/store.ts +122 -0
  24. package/src/ledger/toolchain-identity.ts +45 -8
  25. package/src/pb/collection-scaffold.ts +4 -4
  26. package/src/release/run-bump.ts +50 -5
  27. package/src/release/run-release.ts +5 -0
  28. package/src/release/run-releases.ts +7 -1
  29. package/src/release/unit-gate.ts +17 -4
  30. package/src/repo-config.ts +26 -4
  31. package/src/rules/SK001-package-json-shape.ts +8 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  33. package/src/rules/SK003-claude-md-shape.ts +7 -2
  34. package/src/rules/SK004-gitignore-policy.ts +12 -4
  35. package/src/rules/SK005-rvlib-versions.ts +28 -1
  36. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  37. package/src/rules/SK008-reviewer-agent.ts +6 -2
  38. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  39. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  40. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  41. package/src/rules/SK012-config-barrel-import.ts +6 -2
  42. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  43. package/src/rules/rule.ts +22 -1
  44. package/src/rules/run-fix.ts +5 -3
  45. package/src/scaffold/files/verify-skill.md +1 -1
  46. package/src/ts/config.ts +66 -6
  47. package/src/ts/cycles.ts +7 -92
  48. package/src/ts/source-graph.ts +158 -0
  49. package/src/ts/tsconfig-projects.ts +101 -0
  50. package/src/ts/typecheck-cli.ts +46 -0
@@ -11,13 +11,15 @@ import { reportToMotherhome } from './motherhome.ts'
11
11
  import { collectChecks } from './run-check.ts'
12
12
  import { runFixPass } from './run-fixers.ts'
13
13
  import { formatLine } from './wire.ts'
14
- import { runTasks, type TaskResult } from './task-runner.ts'
14
+ import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
16
  import { rmSync } from 'fs'
17
17
 
18
18
  import { SHARE_ENV } from '../release/releases-share.ts'
19
19
  import { collectReports } from '../release/run-releases.ts'
20
20
  import { withContractsScope } from '../contracts/contracts-once.ts'
21
+ import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
22
+ import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
21
23
 
22
24
  export type CiOpts = {
23
25
  bail?: boolean
@@ -59,6 +61,14 @@ export type CiOpts = {
59
61
  * loads of repo.config.ts can disagree, and the release path classifies
60
62
  * rule-vs-probe severity from the config it holds. */
61
63
  config?: RepoConfig
64
+ /** re-measure everything: no unit replays from the ledger, and what this
65
+ * run measures is stored fresh. Travels to child checks as SHIPKIT_FRESH. */
66
+ fresh?: boolean
67
+ /** read the ledger at all. The release path passes false, unconditionally
68
+ * and not configurably: every remaining hole in the key is then capped at
69
+ * "an agent saw a stale local green", never "a stale green authorised a
70
+ * publish" (ledger/ledger.ts rule 4). */
71
+ ledgerRead?: boolean
62
72
  /** which test tier the run asks the repo's suite for. 'quick' is the
63
73
  * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
64
74
  * a suite reads to include the slow, real-world tiers it otherwise skips.
@@ -68,12 +78,8 @@ export type CiOpts = {
68
78
  tests?: TestTier
69
79
  }
70
80
 
71
- export type TestTier = 'quick' | 'full'
72
-
73
- /** the env var a repo's own suite reads to widen itself. Named here because
74
- * the gate is what sets it, and a suite that invents its own name is a tier
75
- * the release path cannot reach. */
76
- export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
81
+ export type { TestTier } from '../ledger/gate-units.ts'
82
+ export { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
77
83
 
78
84
  /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
79
85
  * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
@@ -116,7 +122,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
116
122
  const all = allTasks(c, root)
117
123
  const { disabled, unfilled } = all
118
124
  const skipIds = new Set(o.skipTaskIds ?? [])
119
- const tier: TestTier = o.tests ?? (process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick')
125
+ const tier: TestTier = o.tests ?? envTier()
120
126
  const tasks = all.tasks
121
127
  .filter((t) => !skipIds.has(t.id))
122
128
  // the whole run carries the flag rather than one task id: a repo names
@@ -130,6 +136,38 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
130
136
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
131
137
  for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
132
138
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
139
+ // THE LEDGER, opened once per run and AFTER the fix pass: the fixers
140
+ // rewrite source, and a scan taken before them would have every unit
141
+ // discarded by its own gate's repair work.
142
+ //
143
+ // Registration is validated on the way in, and it is loud in the gate
144
+ // rather than only in `shipkit keys`, because a unit declaring two
145
+ // contradictory populations is a bug in what binds every repo. The
146
+ // burn-down is one line, never a wall.
147
+ let registrationFailed = false
148
+ let ledger: Ledger | null = null
149
+ try {
150
+ ledger = Ledger.open(root, gateUnits(root, c, tasks), {
151
+ cliEntry: new URL('../cli.ts', import.meta.url).pathname,
152
+ read: o.ledgerRead,
153
+ fresh: o.fresh,
154
+ })
155
+ const reg = ledger.layer.registration
156
+ for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
157
+ if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
158
+ // both counts, always. Printing only the burn-down made `never:` the
159
+ // cheap way to shrink the visible number, and that field carries no
160
+ // verification at all: a growing decided population has to be as
161
+ // visible as a shrinking undeclared one.
162
+ const decided = reg.units - reg.burnDown.length - reg.declared
163
+ if (reg.burnDown.length > 0 || decided > 0)
164
+ sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared, so they are measured every run (shipkit keys --burn-down)`)
165
+ if (ledger.layer.failure != null)
166
+ sink(`WARN the git scan failed, so NOTHING replays and nothing is stored this run — ${ledger.layer.failure}`)
167
+ } catch (e) {
168
+ ledger = null
169
+ sink(`WARN ledger unavailable (${String(e).slice(0, 160)}) — every unit is measured this run, so the gate is correct and slower`)
170
+ }
133
171
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
134
172
  // look like a full one (the fresh-repo floor gap)
135
173
  for (const u of unfilled) {
@@ -152,22 +190,77 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
152
190
  // paying a 4s network survey to answer it is the same bug --only was
153
191
  // fixed for
154
192
  const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
155
- const survey = willRunChecks ? collectReports(root, c).catch(() => null) : null
156
- const summary = await runTasks({
157
- tasks,
193
+ // REPLAY, before the DAG sees the list. A replayed task is removed from
194
+ // the run entirely, which is also what makes its dependents correct: the
195
+ // runner treats a filtered dep as satisfied, and only a GREEN is ever
196
+ // stored, so a dependent can never start on the back of a red nobody saw.
197
+ const selectable = new Set(selectTasks({ tasks, logDir: '', skipGroups: o.skipGroups ?? [], withIds: o.withIds ?? [], onlyIds: o.onlyIds ?? [] }).selected.map((t) => t.id))
198
+ const replayedTasks = new Map<string, UnitOutcome>()
199
+ const toRun = tasks
200
+ .filter((t) => {
201
+ if (ledger == null || !selectable.has(t.id)) return true
202
+ const hit = ledger.replay(t.id)
203
+ if (hit == null) return true
204
+ replayedTasks.set(t.id, hit)
205
+ for (const l of stampReplayed(hit.lines, hit.measuredMs)) sink(formatLine(l, false))
206
+ return false
207
+ })
208
+ // a dep ON a replayed task is DROPPED, not left dangling. The runner
209
+ // validates every dep against the list it was handed and dies with
210
+ // `unknown dep` otherwise, and dropping is the honest edge here: only a
211
+ // GREEN is ever replayed, so the dependent's precondition is met and
212
+ // this is the same "a filtered dep counts as satisfied" the runner
213
+ // already applies to --skip and opt-in tasks.
214
+ .map((t) => (t.deps?.some((d) => replayedTasks.has(d)) === true ? { ...t, deps: t.deps.filter((d) => !replayedTasks.has(d)) } : t))
215
+ // the pre-warm exists to hide the survey behind the TASKS. When every task
216
+ // replayed there is nothing to hide it behind, and starting it anyway is a
217
+ // 4s network call the run may never need: the five checks that want it
218
+ // fetch it themselves, and when they replay too, nobody pays for it at all.
219
+ const survey = willRunChecks && toRun.length > 0 ? collectReports(root, c).catch(() => null) : null
220
+ // a NAMED task that replayed leaves the list, so its name has to leave the
221
+ // filters with it: the runner validates `--only`/`--with` against the list
222
+ // it was handed and answers `unknown task id`. `bun run typecheck` and
223
+ // `bun run test` route through `--only`, so this is the reflex verb every
224
+ // agent in every wired repo types, going red on a warm store.
225
+ const onlyIds = (o.onlyIds ?? []).filter((id) => !replayedTasks.has(id))
226
+ const withIds = (o.withIds ?? []).filter((id) => !replayedTasks.has(id))
227
+ // narrowed to nothing is NOT the same as "no filter": an empty --only would
228
+ // widen the run to every task
229
+ const nothingToRun = replayedTasks.size > 0 && (toRun.length === 0 || ((o.onlyIds ?? []).length > 0 && onlyIds.length === 0))
230
+ // every asked-for task replayed: the DAG has nothing to schedule, and
231
+ // running it anyway would print `all 0 tasks green`, which reads as a gate
232
+ // that measured nothing rather than one that already knew the answer
233
+ const summary = nothingToRun
234
+ ? { ok: true, results: [], interrupted: false }
235
+ : await runTasks({
236
+ tasks: toRun,
158
237
  logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
159
238
  cwd: root,
160
239
  bail: o.bail ?? false,
161
240
  skipGroups: o.skipGroups ?? [],
162
- withIds: o.withIds ?? [],
163
- onlyIds: o.onlyIds ?? [],
241
+ withIds,
242
+ onlyIds,
164
243
  print: sink,
165
244
  onTaskFinish: o.onTaskFinish,
166
245
  })
246
+ if (nothingToRun) sink(`ok: all ${replayedTasks.size} task(s) asked for replayed, none measured`)
167
247
  // what the suite ACTUALLY ran, from its own summary line. A flag that was
168
248
  // set proves the intent; the count proves the tests
169
- const ranTests = summary.results
170
- .map((r) => /Ran (\d+) tests?/.exec(r.lastLine ?? '')?.[1])
249
+ for (const r of summary.results) {
250
+ if (ledger == null || r.status !== 'pass') continue
251
+ const surfaced = r.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
252
+ ledger.record(r.id, {
253
+ status: surfaced.some((l) => l.kind === 'warn' || l.kind === 'fix-warn') ? 'warn' : 'pass',
254
+ // the headline is REBUILT rather than captured: the runner renders
255
+ // the wall time into it, and a duration baked into a stored line
256
+ // would be replayed as if this run had measured it
257
+ lines: [{ kind: 'ok', text: r.id }, ...surfaced],
258
+ lastLine: r.lastLine,
259
+ measuredMs: r.durationMs,
260
+ })
261
+ }
262
+ const ranTests = [...summary.results.map((r) => r.lastLine ?? ''), ...[...replayedTasks.values()].map((o) => o.lastLine ?? '')]
263
+ .map((l) => /Ran (\d+) tests?/.exec(l)?.[1])
171
264
  .find((n) => n != null)
172
265
  if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
173
266
  // the checks, in the SAME command. there is no second surface: a check
@@ -178,7 +271,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
178
271
  if (survey != null) await survey
179
272
  const checks = o.skipChecks === true
180
273
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
181
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds })
274
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false })
182
275
  if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
183
276
  for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
184
277
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
@@ -195,7 +288,12 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
195
288
  sink(' fix: shipkit check')
196
289
  } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
197
290
  }
198
- const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
291
+ // the post-run re-hash, then the store. NOTHING was written while the gate
292
+ // ran: a unit whose inputs moved under it answered about a tree that no
293
+ // longer exists, and this is where that result is thrown away instead of
294
+ // stored under a key that describes the file as it was before the edit.
295
+ if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
296
+ const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
199
297
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
200
298
  // and never fires under frozen: a bump/release gate must not re-dirty
201
299
  // the tree it just proved clean (field wart 2026-08-05, second head)
@@ -15,6 +15,7 @@ import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import type { RepoConfig } from '../repo-config.ts'
16
16
  import { runFix } from '../rules/run-fix.ts'
17
17
  import { routesToShipkit } from './canonical-scripts.ts'
18
+ import { mutateLease, withMutateLease } from './locked-write.ts'
18
19
  import { spawnWithTimeout } from './spawn-timeout.ts'
19
20
 
20
21
  const FIXER_TIMEOUT_MS = 120_000
@@ -72,37 +73,47 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
72
73
  if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
73
74
  else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
74
75
  else {
75
- let cli: string | null = null
76
- cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
76
+ const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
77
77
  slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
78
78
  }
79
79
  return { slots, unfilled, disabled }
80
80
  }
81
81
 
82
- /** worktree fingerprint: every dirty path mapped to `<status>:<content hash>`,
83
- * or null outside a git repo. The fix pass diffs this before and after so the
84
- * report names real files instead of asserting "fixed" and hoping.
82
+ /** worktree fingerprint: every dirty path mapped to its CONTENT hash, or null
83
+ * outside a git repo. The fix pass diffs this before and after so the report
84
+ * names real files instead of asserting "fixed" and hoping.
85
85
  *
86
- * The hash is load-bearing, not decoration. Status alone is blind to the most
87
- * common case there is: a file the agent already edited (` M`) that a fixer
88
- * then reformats stays ` M`, so a status-only compare reports "nothing to
89
- * rewrite" while the tree changed under it, and in verify mode that is a
90
- * silent green on unformatted code. Hashing is confined to the dirty set,
91
- * which is small by construction. */
86
+ * The hash is the whole comparison, and the porcelain STATUS is deliberately
87
+ * not part of it. Status alone is blind to the commonest case there is: a
88
+ * file the agent already edited (` M`) that a fixer then reformats stays
89
+ * ` M`, so a status-only compare reports "nothing to rewrite" while the tree
90
+ * changed under it, and in verify mode that is a silent green on unformatted
91
+ * code. The reverse is just as wrong with three agents in one checkout: a
92
+ * second agent staging or committing a file during the pass moves its status,
93
+ * or drops it out of porcelain entirely, with the bytes untouched. Comparing
94
+ * bytes attributes only what a fixer can actually have done.
95
+ *
96
+ * It watches the WHOLE tree on purpose. A narrower porcelain was tried and
97
+ * deleted: the only fixer whose targets are declarable is the formatter, and
98
+ * the same pass also runs the rule fixers, which rewrite package.json,
99
+ * .gitignore, CLAUDE.md and .claude/, none of them inside a formatter's
100
+ * scope. Narrowing to the formatter's paths therefore hid real rewrites from
101
+ * the verify mode, which is a silent green on a tree a fixer changed. What
102
+ * actually stops another agent's work being reported here is the lease plus
103
+ * the byte comparison, not a path list. */
92
104
  function treeState(root: string): Map<string, string> | null {
93
105
  const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
94
106
  if (p.exitCode !== 0) return null
95
107
  const m = new Map<string, string>()
96
108
  for (const l of p.stdout.toString().split('\n')) {
97
109
  if (l.trim() === '') continue
98
- const status = l.slice(0, 2)
99
110
  // rename entries read `R old -> new`; the new path is what exists
100
111
  const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
101
112
  if (path === '') continue
102
113
  const abs = join(root, path.replace(/^"|"$/g, ''))
103
114
  let hash = 'absent'
104
115
  try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
105
- m.set(path, `${status}:${hash}`)
116
+ m.set(path, hash)
106
117
  }
107
118
  return m
108
119
  }
@@ -119,7 +130,7 @@ export type FixPassOpts = {
119
130
 
120
131
  export type FixPassResult = {
121
132
  ran: boolean
122
- /** paths whose git status changed across the pass */
133
+ /** paths whose CONTENT changed across the pass */
123
134
  touched: string[]
124
135
  /** verify mode only: a fixer had to rewrite something, so the commit is not clean */
125
136
  failed: boolean
@@ -129,6 +140,30 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
129
140
  // verify mode: same fixers, but rewriting anything means the COMMIT was
130
141
  // not clean, and the gate says so instead of quietly repairing it
131
142
  const verify = process.env.CI != null && process.env.CI !== ''
143
+ // EVERY write this function makes sits inside one lease. Three agents in
144
+ // one checkout is the normal case, and two formatters rewriting the same
145
+ // file at once, or two runs arming hooks and rewriting package.json
146
+ // together, is the one hazard a snapshot diff cannot even detect. The
147
+ // loser waits rather than skipping, because a skipped repair never
148
+ // happens; a live holder that outlasts the cap is REFUSED by name rather
149
+ // than overlapped, and the gate below still runs, unrepaired and saying so.
150
+ const lease = await withMutateLease(mutateLease(root, 'fix'), { label: 'fix pass', print: o.print, escape: '--no-fix' }, () => fixPassBody(root, config, o, verify))
151
+ if (lease.ok) return lease.value
152
+ // in VERIFY mode the fix pass is the whole check: reporting "not run" and
153
+ // exiting 0 would pass a commit nobody verified, which is the one failure
154
+ // this gate exists to prevent. Locally it is a WARN, because the tasks and
155
+ // checks below still ran and still mean what they say.
156
+ if (verify) {
157
+ o.print(`FAIL: fixers never ran — ${lease.warn}`)
158
+ o.print(' fix: nothing was verified. re-run once that process finishes, or pass --no-fix to state that on purpose')
159
+ return { ran: false, touched: [], failed: true }
160
+ }
161
+ o.print(`WARN fix pass skipped — ${lease.warn}`)
162
+ o.print(' fix: nothing was repaired this run; the gate below reports the tree as it stands')
163
+ return { ran: false, touched: [], failed: false }
164
+ }
165
+
166
+ async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
132
167
  // commit gates arm themselves on the interactive gate: a repo.config with
133
168
  // dark hooks is the half-wired state that let a whole drill session
134
169
  // commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
@@ -157,10 +192,18 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
157
192
  const after = treeState(root)
158
193
  const touched: string[] = []
159
194
  if (before != null && after != null) {
160
- for (const [path, st] of after) if (before.get(path) !== st) touched.push(path)
161
- // a file the fixer restored to its committed state disappears from
162
- // porcelain entirely, that is still a change worth naming
163
- for (const path of before.keys()) if (!after.has(path)) touched.push(path)
195
+ for (const [path, hash] of after) if (before.get(path) !== hash) touched.push(path)
196
+ // a path that left porcelain either got RESTORED to its committed
197
+ // bytes by a fixer, which is a rewrite worth naming, or was committed
198
+ // by another agent mid-pass, which is not this pass's doing. Only the
199
+ // bytes tell the two apart, so read them.
200
+ for (const [path, hash] of before) {
201
+ if (after.has(path)) continue
202
+ const abs = join(root, path.replace(/^"|"$/g, ''))
203
+ let now = 'absent'
204
+ try { if (existsSync(abs)) now = String(Bun.hash(readFileSync(abs))) } catch { now = 'unreadable' }
205
+ if (now !== hash) touched.push(path)
206
+ }
164
207
  }
165
208
  const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
166
209
  if (before == null) {
@@ -4,7 +4,7 @@
4
4
  // - a dep must PASS to unblock; a filtered-out dep counts as satisfied;
5
5
  // a failed/skipped dep skips dependents transitively (note says why).
6
6
  // Deps double as serialization guards (document racy edges at the def).
7
- // - subprocess tasks: full log streamed to <logDir>/<id>.log, last non-empty
7
+ // - subprocess tasks: full log streamed to <logDir>/run-<pid>-<ts>/<id>.log, last non-empty
8
8
  // line kept for the FAIL summary; SIGTERM at timeoutMs, SIGKILL +2s.
9
9
  // - logic tasks run in-process against a CheckCtx: WARN never flips the
10
10
  // exit code, FAIL does. Their lines print under the task line.
@@ -13,8 +13,8 @@
13
13
  // - sharp edge, by design: `onlyIds` on a task whose race-guard dep is
14
14
  // optIn/skipped-group drops that dep (filtered = satisfied) — explicit
15
15
  // filters win over guards.
16
- import { createWriteStream, mkdirSync, rmSync, type WriteStream } from 'fs'
17
- import { join } from 'path'
16
+ import { createWriteStream, mkdirSync, readdirSync, rmSync, type WriteStream } from 'fs'
17
+ import { basename, join } from 'path'
18
18
  import { CheckCtx } from './check-ctx.ts'
19
19
  import { spawnWithTimeout } from './spawn-timeout.ts'
20
20
  import { formatLine, type Line } from './wire.ts'
@@ -30,8 +30,28 @@ export type TaskDef = {
30
30
  /** excluded unless listed in RunOpts.withIds */
31
31
  optIn?: boolean
32
32
  timeoutMs?: number
33
+ /** CONTENT population: the repo-relative paths this task reads. Declaring
34
+ * them is what makes the task keyable (ledger/git-key.ts). Undeclared is
35
+ * the DEFAULT and means never cached, so forgetting costs speed and never
36
+ * correctness. Declaring both this and `ttl` is a registration failure. */
37
+ inputs?: string[]
38
+ /** WORLD population: this task reads the network, a keychain, $PATH or the
39
+ * clock, so nothing on disk describes its answer and a clean pass is
40
+ * remembered for this long (e.g. '24h'). */
41
+ ttl?: string
42
+ /** a DECIDED never-cacheable, with the reason. Says somebody looked and
43
+ * nothing can describe this task, which takes it off the burn-down list
44
+ * that undeclared units land on. */
45
+ never?: string
33
46
  }
34
47
 
48
+ /** every field a `TaskDef` may carry. Declared as a value so `defineRepo` can
49
+ * refuse a typo inside a `defaults` slot: a slot with no `cmd` DECORATES the
50
+ * resolved task, so a misspelled field is not a type error at the call site
51
+ * and would land as a task that is quietly never cached. Add a field above,
52
+ * add it here. */
53
+ export const TASK_DEF_KEYS = ['id', 'cmd', 'logic', 'deps', 'env', 'cwd', 'group', 'optIn', 'timeoutMs', 'inputs', 'ttl', 'never'] as const
54
+
35
55
  export type TaskResult = {
36
56
  id: string
37
57
  status: 'pass' | 'fail' | 'skipped'
@@ -113,6 +133,43 @@ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[]
113
133
  return { selected, errors }
114
134
  }
115
135
 
136
+ /** THIS run's log dir. It used to be `logDir` itself, wiped with rmSync at the
137
+ * top of every run: a second agent starting a gate deleted the log files the
138
+ * first was still streaming into, so the surviving FAIL line pointed at a
139
+ * path that no longer existed. Now each run owns `run-<pid>-<started>/` and
140
+ * writes nowhere else.
141
+ *
142
+ * Old run dirs are swept on the way in, and only ones nothing can be using:
143
+ * the pid must not be running AND the dir must be older than the grace
144
+ * window, so a recycled pid cannot cost a live run its logs. */
145
+ function openRunDir(base: string): string {
146
+ // the pid plus the millisecond separates two PROCESSES; the counter
147
+ // separates two runTasks calls inside one (the TUI can start a second
148
+ // before the clock ticks), so no run ever writes into another's dir
149
+ const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
150
+ mkdirSync(dir, { recursive: true })
151
+ const cutoff = Date.now() - RUN_DIR_KEEP_MS
152
+ try {
153
+ for (const name of readdirSync(base)) {
154
+ // run dirs AND the per-run release survey files, which a killed
155
+ // run leaves behind: both carry the pid and the start time, so one
156
+ // sweep covers both rather than one path leaking forever
157
+ const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
158
+ if (m == null || name === basename(dir)) continue
159
+ if (Number(m[2]) > cutoff) continue
160
+ try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
161
+ rmSync(join(base, name), { recursive: true, force: true })
162
+ }
163
+ } catch {
164
+ // an unreadable log base is a housekeeping miss, never a gate failure
165
+ }
166
+ return dir
167
+ }
168
+
169
+ /** how long a finished run's logs stay readable, and the pid-reuse guard */
170
+ const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
171
+ let runSeq = 0
172
+
116
173
  export async function runTasks(o: RunOpts): Promise<RunSummary> {
117
174
  const print = o.print ?? ((l: string) => console.log(l))
118
175
  const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
@@ -122,13 +179,12 @@ export async function runTasks(o: RunOpts): Promise<RunSummary> {
122
179
  return { ok: false, results: [], interrupted: false }
123
180
  }
124
181
  const selectedIds = new Set(sel.selected.map((t) => t.id))
125
- rmSync(o.logDir, { recursive: true, force: true })
126
- mkdirSync(o.logDir, { recursive: true })
182
+ const runDir = openRunDir(o.logDir)
127
183
 
128
184
  const tasks: Live[] = sel.selected.map((t) => ({
129
185
  ...t,
130
186
  status: 'skipped', exitCode: null, durationMs: 0, startedAt: 0,
131
- logPath: t.cmd != null ? join(o.logDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
187
+ logPath: t.cmd != null ? join(runDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
132
188
  lastLine: '', note: '', lines: [],
133
189
  }))
134
190
  const byId = new Map(tasks.map((t) => [t.id, t]))
@@ -75,6 +75,27 @@ export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
75
75
  return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
76
76
  }
77
77
 
78
+ /** `git config` takes `.git/config.lock` for the write, and a linked worktree
79
+ * shares that file with its main checkout: two agents arming at the same
80
+ * moment, which is the normal case here, make one of them exit non zero and
81
+ * report "hooks will not fire" on a repo whose hooks are perfectly fine. The
82
+ * contention window is one file write, so a short backoff clears it. The
83
+ * retries are bounded and the last failure is reported with git's own words,
84
+ * never swallowed. */
85
+ function setHooksPath(root: string): { code: number; detail: string } {
86
+ let last = { code: -1, detail: '' }
87
+ for (let attempt = 0; attempt < 5; attempt++) {
88
+ if (attempt > 0) Bun.sleepSync(20 * 2 ** (attempt - 1))
89
+ const p = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
90
+ if (p.exitCode === 0) return { code: 0, detail: '' }
91
+ last = { code: p.exitCode, detail: p.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? '' }
92
+ // only lock contention is worth retrying; anything else is a real
93
+ // refusal and retrying it just makes the failure slower
94
+ if (!/could not lock|file exists|another git process/i.test(last.detail)) return last
95
+ }
96
+ return last
97
+ }
98
+
78
99
  /** wire `core.hooksPath` and write the two hook bodies. Idempotent.
79
100
  * `keywordFile` = the repo's configured leak-guard file, seeding target. */
80
101
  export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
@@ -89,12 +110,12 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
89
110
  const prev = existsSync(path) ? readFileSync(path, 'utf8') : ''
90
111
  if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
91
112
  }
92
- const cfg = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
113
+ const cfg = setHooksPath(root)
93
114
  const seeded = seedKeywordFile(root, keywordFile)
94
115
  if (seeded != null) written.push(seeded)
95
116
  return {
96
117
  written,
97
- note: cfg.exitCode === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.exitCode}) — hooks will not fire`,
118
+ note: cfg.code === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.code}) — hooks will not fire${cfg.detail === '' ? '' : `: ${cfg.detail}`}`,
98
119
  }
99
120
  }
100
121
 
@@ -0,0 +1,84 @@
1
+ // EVERY UNIT THE GATE RUNS, with what it declared about its own cacheability.
2
+ //
3
+ // Three registries feed one list, because a key layer that can only see tasks
4
+ // would leave the 25 checks and the 32 rules uncached forever, and those are
5
+ // most of the wall time. The declaration lives with the unit (a `TaskDef`
6
+ // field, a `Rule` field, an `export const inputs` in a check file), never in a
7
+ // table over here: a table beside the registry is a second source of truth
8
+ // that drifts the first time somebody adds a check.
9
+ import { relative } from 'path'
10
+ import { allTasks, type RepoConfig } from '../repo-config.ts'
11
+ import type { TaskDef } from '../core/task-runner.ts'
12
+ import { discoverChecks } from '../core/discover-checks.ts'
13
+ import { RULES } from '../rules/rule-registry.ts'
14
+ import type { UnitDecl } from './git-key.ts'
15
+
16
+ /** the env var a repo's own suite reads to widen itself. It lives HERE because
17
+ * it rewrites every task's env, which is part of the key: a run that asked for
18
+ * the quick tier and one that asked for the full tier are different questions,
19
+ * and a surface that renders keys without applying it renders a tier nobody
20
+ * ran. */
21
+ export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
22
+
23
+ export type TestTier = 'quick' | 'full'
24
+
25
+ /** the tier this process is asking for, absent an explicit choice */
26
+ export function envTier(): TestTier {
27
+ return process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
28
+ }
29
+
30
+ /** the repo's tasks as the gate will actually run them at `tier`. */
31
+ export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDef[] {
32
+ const tasks = allTasks(c, root).tasks
33
+ return tier === 'full' ? tasks.map((t) => ({ ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } })) : tasks
34
+ }
35
+
36
+ /** every task, rule and discovered check, in the order the gate meets them.
37
+ *
38
+ * `tasks` overrides what the repo resolves to, and the gate MUST pass what it
39
+ * is actually about to run: `--full` rewrites every task's env, and a key
40
+ * built from the unmodified list would let a quick run replay into a full one
41
+ * under one identity. */
42
+ export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
43
+ const out: UnitDecl[] = []
44
+ for (const t of tasks ?? allTasks(c, root).tasks) {
45
+ out.push({
46
+ id: t.id,
47
+ kind: 'task',
48
+ inputs: t.inputs,
49
+ ttl: t.ttl,
50
+ never: t.never,
51
+ argv: t.cmd,
52
+ // the env a task SETS is part of what it runs, not something read
53
+ // off the ambient environment, so it rides as an identity string
54
+ extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
55
+ })
56
+ }
57
+ for (const r of [...RULES, ...(c.rules ?? [])]) {
58
+ out.push({
59
+ id: r.id,
60
+ kind: 'rule',
61
+ inputs: r.inputs,
62
+ code: r.code,
63
+ ttl: r.ttl,
64
+ never: r.never,
65
+ extra: r.cacheKey?.({ root, config: c }),
66
+ })
67
+ }
68
+ for (const d of discoverChecks(root)) {
69
+ const rel = relative(root, d.path).replace(/\\/g, '/')
70
+ out.push({
71
+ id: d.name,
72
+ kind: 'check',
73
+ inputs: d.inputs ?? undefined,
74
+ ttl: d.ttl ?? undefined,
75
+ never: d.never ?? undefined,
76
+ // a check IS a file, so its own code needs no declaration: the
77
+ // module graph walks from here through every helper it imports,
78
+ // which is what makes fixing a shared helper move the check's key
79
+ code: [rel],
80
+ argv: ['bun', rel],
81
+ })
82
+ }
83
+ return out
84
+ }