rvlib-shipkit 0.33.0 → 0.34.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 (41) hide show
  1. package/CHANGELOG.md +19 -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_Keys.ts +107 -0
  6. package/src/cli.ts +2 -0
  7. package/src/core/check-suite.ts +95 -33
  8. package/src/core/discover-checks.ts +30 -1
  9. package/src/core/floor.ts +21 -2
  10. package/src/core/locked-write.ts +170 -1
  11. package/src/core/one-way-in-policy.ts +8 -8
  12. package/src/core/run-ci.ts +25 -1
  13. package/src/core/run-fixers.ts +61 -18
  14. package/src/core/task-runner.ts +55 -6
  15. package/src/hooks/run-hooks.ts +23 -2
  16. package/src/ledger/gate-units.ts +58 -0
  17. package/src/ledger/git-key.ts +330 -0
  18. package/src/ledger/git-scan.ts +314 -0
  19. package/src/ledger/toolchain-identity.ts +15 -8
  20. package/src/release/run-bump.ts +50 -5
  21. package/src/release/unit-gate.ts +17 -4
  22. package/src/rules/SK001-package-json-shape.ts +8 -2
  23. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  24. package/src/rules/SK003-claude-md-shape.ts +7 -2
  25. package/src/rules/SK004-gitignore-policy.ts +11 -2
  26. package/src/rules/SK005-rvlib-versions.ts +28 -1
  27. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  28. package/src/rules/SK008-reviewer-agent.ts +6 -2
  29. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  30. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  31. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  32. package/src/rules/SK012-config-barrel-import.ts +6 -2
  33. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  34. package/src/rules/rule.ts +22 -1
  35. package/src/rules/run-fix.ts +5 -3
  36. package/src/scaffold/files/verify-skill.md +1 -1
  37. package/src/ts/config.ts +66 -6
  38. package/src/ts/cycles.ts +7 -92
  39. package/src/ts/source-graph.ts +158 -0
  40. package/src/ts/tsconfig-projects.ts +101 -0
  41. package/src/ts/typecheck-cli.ts +46 -0
@@ -3,7 +3,7 @@
3
3
  // whole read-modify-write, the write lands via tmp + rename so a killed
4
4
  // process can never leave a torn file, and the loser of a race SKIPS —
5
5
  // never corrupts, never blocks.
6
- import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
6
+ import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
7
7
  import { basename, dirname, join } from 'path'
8
8
 
9
9
  // a writer that died without unlocking must not wedge the file forever
@@ -37,3 +37,172 @@ export function atomicWrite(path: string, content: string): void {
37
37
  writeFileSync(tmpPath, content)
38
38
  renameSync(tmpPath, path)
39
39
  }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // the MUTATE LEASE — the second concurrency shape, and the only other one.
43
+ //
44
+ // withFileLock guards ONE file and the loser SKIPS: right for a render that
45
+ // re-renders next run. The lease guards a PHASE that rewrites the worktree
46
+ // (the fix pass, SK005's `bun update --latest`, hooks arming, the asks
47
+ // archive), where skipping means the repair never happens and overlapping
48
+ // means two processes rewrite the same file at once. So the loser WAITS.
49
+ //
50
+ // TWO PRIMITIVES, both atomic, and both were measured before they were
51
+ // trusted. `writeFileSync(..., 'wx')` creates the NAME first and writes the
52
+ // body second, so a racer that reads in between sees an empty file, calls the
53
+ // lock corrupt, and steals a lease whose holder is very much alive: 5 of 15
54
+ // synchronized starts overlapped. So the body is written to a private name and
55
+ // `linkSync`d into place, one step that fails when the name exists and never
56
+ // exposes a half lock. And a steal of `read, rmSync, grab` is three steps, so
57
+ // every racer wins it: 31 of 40 synchronized starts overlapped after a crash.
58
+ // So a steal is a single `renameSync` of the lock out of the way, which
59
+ // exactly one racer can perform; everyone else gets ENOENT and goes back to
60
+ // grabbing.
61
+ //
62
+ // Bounded in every direction, because a gate that hangs is worse than a gate
63
+ // that overlaps. A holder is stale when its pid is not running OR when the
64
+ // lock has not been touched for LOCK_STALE_MS, so a pid reused by a long
65
+ // lived process cannot wedge a repo forever. A holder that is genuinely still
66
+ // working HEARTBEATS the lock while it holds it, so the age rule only ever
67
+ // fires on a run that is gone or wedged. A waiter gives up after maxWaitMs and
68
+ // says so by name, path and escape hatch. Every line it prints carries a
69
+ // FROZEN wire prefix (wire.ts), so a lease that waits or steals inside
70
+ // `shipkit ci` cannot break that contract.
71
+
72
+ const LEASE_POLL_MS = 100
73
+ const LEASE_ANNOUNCE_MS = 1_000
74
+ const LEASE_REANNOUNCE_MS = 15_000
75
+ const LEASE_HEARTBEAT_MS = 20_000
76
+ /** shorter than the shortest caller timeout (a discovered check is killed at
77
+ * 120s), so a waiter always gets to PRINT its refusal rather than being
78
+ * SIGTERMed mid wait with no cause on screen */
79
+ const LEASE_MAX_WAIT_MS = 60_000
80
+
81
+ type Holder = { pid: number; at: number; label: string }
82
+
83
+ let leaseSeq = 0
84
+
85
+ const readHolder = (lockPath: string): Holder | null => {
86
+ try {
87
+ const h = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<Holder>
88
+ return typeof h.pid === 'number' ? { pid: h.pid, at: typeof h.at === 'number' ? h.at : 0, label: String(h.label ?? '?') } : null
89
+ } catch { return null }
90
+ }
91
+
92
+ /** is that pid still running? signal 0 asks without delivering anything.
93
+ * EPERM means alive and owned by another user, which still counts as alive. */
94
+ const alive = (pid: number): boolean => {
95
+ if (!Number.isInteger(pid) || pid <= 0) return false
96
+ try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
97
+ }
98
+
99
+ /** publish a COMPLETE lock under a name that did not exist a moment ago */
100
+ const grabLease = (lockPath: string, label: string): boolean => {
101
+ const tmp = `${lockPath}.tmp-${process.pid}-${leaseSeq++}`
102
+ try {
103
+ writeFileSync(tmp, JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies Holder))
104
+ linkSync(tmp, lockPath)
105
+ return true
106
+ } catch { return false }
107
+ finally { rmSync(tmp, { force: true }) }
108
+ }
109
+
110
+ const sameHolder = (a: Holder | null, b: Holder | null): boolean =>
111
+ a == null ? b == null : b != null && a.pid === b.pid && a.at === b.at
112
+
113
+ /** move the lock aside, and ONLY the lock we judged dead. Exactly one racer
114
+ * can win the rename; the rest see ENOENT and go back to grabbing.
115
+ *
116
+ * The identity check on both sides is the whole point, and it is a measured
117
+ * bug, not a precaution. Reading the holder and stat'ing the file are two
118
+ * syscalls, so a racer that read the DEAD holder, then lost the steal, then
119
+ * stat'ed the WINNER's fresh lock, judged a live holder dead and threw its
120
+ * lease away: 1 in 40 four-process starts against a stale lock. So the holder
121
+ * is re-read immediately before the rename, and the moved file is checked
122
+ * after it. A lock that turns out to belong to somebody else goes straight
123
+ * back and this round is lost. */
124
+ const stealLease = (lockPath: string, expect: Holder | null): boolean => {
125
+ if (!sameHolder(readHolder(lockPath), expect)) return false
126
+ const grave = `${lockPath}.dead-${process.pid}-${leaseSeq++}`
127
+ try { renameSync(lockPath, grave) } catch { return false }
128
+ if (sameHolder(readHolder(grave), expect)) { rmSync(grave, { force: true }); return true }
129
+ // it was not the lock we judged: put it back before the name can be taken
130
+ try { linkSync(grave, lockPath) } catch { /* taken in the gap: the new holder owns it now */ }
131
+ rmSync(grave, { force: true })
132
+ return false
133
+ }
134
+
135
+ /** drop the lock, but only while it is still OURS: a lease that was stolen
136
+ * belongs to whoever stole it, and deleting it would hand a third process a
137
+ * lease two others believe they hold */
138
+ const releaseLease = (lockPath: string): void => {
139
+ const h = readHolder(lockPath)
140
+ if (h == null || h.pid === process.pid) rmSync(lockPath, { force: true })
141
+ }
142
+
143
+ export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: false; warn: string }
144
+
145
+ /** hold `<path>.lock` for the duration of fn. Blocking, stolen from a holder
146
+ * that is gone or wedged, refused (never hung) past maxWaitMs. */
147
+ export async function withMutateLease<T>(
148
+ path: string,
149
+ o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string },
150
+ fn: () => T | Promise<T>,
151
+ ): Promise<LeaseOutcome<T>> {
152
+ const lockPath = `${path}.lock`
153
+ const print = o.print ?? ((l: string) => console.log(l))
154
+ const maxWait = o.maxWaitMs ?? LEASE_MAX_WAIT_MS
155
+ const start = Date.now()
156
+ // the first announcement is due at LEASE_ANNOUNCE_MS, every later one a
157
+ // re-announce interval after it
158
+ let announced = -LEASE_REANNOUNCE_MS
159
+ mkdirSync(dirname(lockPath), { recursive: true })
160
+
161
+ for (;;) {
162
+ if (grabLease(lockPath, o.label)) break
163
+ const holder = readHolder(lockPath)
164
+ let ageMs = 0
165
+ // the lock vanished between grab and stat: the holder just finished,
166
+ // so go straight back and take it
167
+ try { ageMs = Date.now() - statSync(lockPath).mtimeMs } catch { continue }
168
+ // gone (no such process), or wedged (nothing has touched it for the
169
+ // stale window, and a live holder heartbeats), or a body no reader can
170
+ // make sense of that is old enough not to be a write in flight
171
+ const dead = holder == null ? ageMs > 2_000 : !alive(holder.pid) || ageMs > LOCK_STALE_MS
172
+ if (dead) {
173
+ if (stealLease(lockPath, holder)) print(`ok: stole the ${o.label} lease from a holder that is gone (pid ${holder?.pid ?? '?'}, untouched ${Math.round(ageMs / 1000)}s) — a run died mid phase`)
174
+ continue
175
+ }
176
+ const waited = Date.now() - start
177
+ if (waited > maxWait)
178
+ return {
179
+ ok: false,
180
+ warn: `${o.label} lease held by ${holder == null ? 'an unreadable lock' : `pid ${holder.pid} (${holder.label})`} for ${Math.round(ageMs / 1000)}s — gave up after ${Math.round(waited / 1000)}s rather than run two of these at once. Lock: ${lockPath}${o.escape == null ? '' : `; skip this phase with ${o.escape}`}`,
181
+ }
182
+ if (waited >= LEASE_ANNOUNCE_MS && waited - announced >= LEASE_REANNOUNCE_MS) {
183
+ announced = waited
184
+ print(`ok: waiting ${Math.round(waited / 1000)}s for the ${o.label} lease — ${holder == null ? 'an unreadable lock holds it' : `pid ${holder.pid} holds it (${holder.label})`}, lock ${lockPath}`)
185
+ }
186
+ await Bun.sleep(LEASE_POLL_MS)
187
+ }
188
+
189
+ // a phase that legitimately outlives the stale window must keep proving it
190
+ // is working, or the age rule steals from it. unref'd, so a held lease
191
+ // never keeps a process alive on its own
192
+ const beat = setInterval(() => { try { utimesSync(lockPath, new Date(), new Date()) } catch { /* stolen or gone: the finally sorts it out */ } }, LEASE_HEARTBEAT_MS)
193
+ beat.unref()
194
+ try {
195
+ return { ok: true, value: await fn(), waitedMs: Date.now() - start }
196
+ } finally {
197
+ clearInterval(beat)
198
+ releaseLease(lockPath)
199
+ }
200
+ }
201
+
202
+ /** where a repo's mutate leases live. Under the gitignored tmp/ dir so a lease
203
+ * file can never enter a commit, and per WORKTREE, because the phases it
204
+ * guards rewrite worktree-local files. The NAME is the concern: two phases
205
+ * that cannot corrupt each other take different leases, so `asks` never
206
+ * blocks a `fix`. `git config` is the one shared-across-worktrees write and
207
+ * carries its own retry (hooks/run-hooks.ts). */
208
+ export const mutateLease = (root: string, name: string): string => join(root, 'tmp', `shipkit-${name}`)
@@ -29,8 +29,8 @@ export type RawRunner = {
29
29
  use: string
30
30
  /** the narrower way in, when there is one: iterating on a single repro
31
31
  * test while you fix a bug is not a verification and stays normal. A
32
- * FILTER, not a path: `bun run` quotes what it forwards, and bun reads a
33
- * quoted path as a name filter that then matches nothing */
32
+ * FILE filter (measured): bun's positional argument matches the test FILE
33
+ * path, and a test NAME needs `-- -t "<name>"` forwarded through bun run */
34
34
  iterate?: string
35
35
  }
36
36
 
@@ -38,12 +38,12 @@ export type RawRunner = {
38
38
  // carrying. Kept tight on purpose: a deny an agent hits for no good reason
39
39
  // teaches it to route around the whole file.
40
40
  export const RAW_RUNNERS: readonly RawRunner[] = [
41
- { head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
42
- { head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
43
- { head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
44
- { head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
45
- { head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
46
- { head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <name filter>' },
41
+ { head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
42
+ { head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
43
+ { head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
44
+ { head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
45
+ { head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
46
+ { head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
47
47
  { head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
48
48
  { head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
49
49
  { head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
@@ -18,6 +18,8 @@ import { rmSync } from 'fs'
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 { gateUnits } from '../ledger/gate-units.ts'
22
+ import { validateRegistration } from '../ledger/git-key.ts'
21
23
 
22
24
  export type CiOpts = {
23
25
  bail?: boolean
@@ -130,6 +132,28 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
130
132
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
131
133
  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
134
  for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
135
+ // the cache-declaration state, once per run. NO git work happens here and
136
+ // nothing is cached yet: this is the REGISTRATION half, which is pure, and
137
+ // it has to be loud in the gate rather than only in `shipkit keys`,
138
+ // because a unit declaring two contradictory populations is a bug in what
139
+ // binds every repo. The burn-down is one line, never a wall.
140
+ let registrationFailed = false
141
+ try {
142
+ const reg = validateRegistration(gateUnits(root, c))
143
+ for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
144
+ if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
145
+ // both counts, always. Printing only the burn-down made `never:` the
146
+ // cheap way to shrink the visible number, and that field carries no
147
+ // verification at all: a growing decided population has to be as
148
+ // visible as a shrinking undeclared one. No causal clause either,
149
+ // because nothing is cached yet and "so nothing can replay them"
150
+ // implies the rest are being replayed.
151
+ const decided = reg.units - reg.burnDown.length - reg.declared
152
+ if (reg.burnDown.length > 0 || decided > 0)
153
+ sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared (shipkit keys --burn-down)`)
154
+ } catch (e) {
155
+ sink(`WARN cache declarations unreadable (${String(e).slice(0, 160)}) — nothing is cached, so the gate is unaffected`)
156
+ }
133
157
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
134
158
  // look like a full one (the fresh-repo floor gap)
135
159
  for (const u of unfilled) {
@@ -195,7 +219,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
195
219
  sink(' fix: shipkit check')
196
220
  } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
197
221
  }
198
- const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
222
+ const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
199
223
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
200
224
  // and never fires under frozen: a bump/release gate must not re-dirty
201
225
  // 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,6 +30,19 @@ 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
 
35
48
  export type TaskResult = {
@@ -113,6 +126,43 @@ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[]
113
126
  return { selected, errors }
114
127
  }
115
128
 
129
+ /** THIS run's log dir. It used to be `logDir` itself, wiped with rmSync at the
130
+ * top of every run: a second agent starting a gate deleted the log files the
131
+ * first was still streaming into, so the surviving FAIL line pointed at a
132
+ * path that no longer existed. Now each run owns `run-<pid>-<started>/` and
133
+ * writes nowhere else.
134
+ *
135
+ * Old run dirs are swept on the way in, and only ones nothing can be using:
136
+ * the pid must not be running AND the dir must be older than the grace
137
+ * window, so a recycled pid cannot cost a live run its logs. */
138
+ function openRunDir(base: string): string {
139
+ // the pid plus the millisecond separates two PROCESSES; the counter
140
+ // separates two runTasks calls inside one (the TUI can start a second
141
+ // before the clock ticks), so no run ever writes into another's dir
142
+ const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
143
+ mkdirSync(dir, { recursive: true })
144
+ const cutoff = Date.now() - RUN_DIR_KEEP_MS
145
+ try {
146
+ for (const name of readdirSync(base)) {
147
+ // run dirs AND the per-run release survey files, which a killed
148
+ // run leaves behind: both carry the pid and the start time, so one
149
+ // sweep covers both rather than one path leaking forever
150
+ const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
151
+ if (m == null || name === basename(dir)) continue
152
+ if (Number(m[2]) > cutoff) continue
153
+ try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
154
+ rmSync(join(base, name), { recursive: true, force: true })
155
+ }
156
+ } catch {
157
+ // an unreadable log base is a housekeeping miss, never a gate failure
158
+ }
159
+ return dir
160
+ }
161
+
162
+ /** how long a finished run's logs stay readable, and the pid-reuse guard */
163
+ const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
164
+ let runSeq = 0
165
+
116
166
  export async function runTasks(o: RunOpts): Promise<RunSummary> {
117
167
  const print = o.print ?? ((l: string) => console.log(l))
118
168
  const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
@@ -122,13 +172,12 @@ export async function runTasks(o: RunOpts): Promise<RunSummary> {
122
172
  return { ok: false, results: [], interrupted: false }
123
173
  }
124
174
  const selectedIds = new Set(sel.selected.map((t) => t.id))
125
- rmSync(o.logDir, { recursive: true, force: true })
126
- mkdirSync(o.logDir, { recursive: true })
175
+ const runDir = openRunDir(o.logDir)
127
176
 
128
177
  const tasks: Live[] = sel.selected.map((t) => ({
129
178
  ...t,
130
179
  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,
180
+ logPath: t.cmd != null ? join(runDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
132
181
  lastLine: '', note: '', lines: [],
133
182
  }))
134
183
  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,58 @@
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 { discoverChecks } from '../core/discover-checks.ts'
12
+ import { RULES } from '../rules/rule-registry.ts'
13
+ import type { UnitDecl } from './git-key.ts'
14
+
15
+ /** every task, rule and discovered check, in the order the gate meets them. */
16
+ export function gateUnits(root: string, c: RepoConfig): UnitDecl[] {
17
+ const out: UnitDecl[] = []
18
+ for (const t of allTasks(c, root).tasks) {
19
+ out.push({
20
+ id: t.id,
21
+ kind: 'task',
22
+ inputs: t.inputs,
23
+ ttl: t.ttl,
24
+ never: t.never,
25
+ argv: t.cmd,
26
+ // the env a task SETS is part of what it runs, not something read
27
+ // off the ambient environment, so it rides as an identity string
28
+ extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
29
+ })
30
+ }
31
+ for (const r of [...RULES, ...(c.rules ?? [])]) {
32
+ out.push({
33
+ id: r.id,
34
+ kind: 'rule',
35
+ inputs: r.inputs,
36
+ code: r.code,
37
+ ttl: r.ttl,
38
+ never: r.never,
39
+ extra: r.cacheKey?.({ root, config: c }),
40
+ })
41
+ }
42
+ for (const d of discoverChecks(root)) {
43
+ const rel = relative(root, d.path).replace(/\\/g, '/')
44
+ out.push({
45
+ id: d.name,
46
+ kind: 'check',
47
+ inputs: d.inputs ?? undefined,
48
+ ttl: d.ttl ?? undefined,
49
+ never: d.never ?? undefined,
50
+ // a check IS a file, so its own code needs no declaration: the
51
+ // module graph walks from here through every helper it imports,
52
+ // which is what makes fixing a shared helper move the check's key
53
+ code: [rel],
54
+ argv: ['bun', rel],
55
+ })
56
+ }
57
+ return out
58
+ }