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
@@ -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}`)
@@ -24,6 +24,58 @@ type Row = { name: string; root: string; version: string; mode: string; seen: st
24
24
 
25
25
  const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
26
26
 
27
+ const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
28
+ const r = Bun.spawnSync(['git', ...args], { cwd })
29
+ return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
30
+ }
31
+
32
+ /**
33
+ * Commit the row we just wrote, and ONLY that file.
34
+ *
35
+ * This page is machine written and rewrites whenever a repo's shipkit version
36
+ * or consumption mode changes, so left alone it dirties the docs repo from a
37
+ * gate run nobody associates with it, and the dirt then rides into an unrelated
38
+ * commit or blocks a release. The row is not worth a human's attention in
39
+ * either direction, so the machine that writes it also lands it.
40
+ *
41
+ * Scoped hard, because several agents share this checkout:
42
+ * - `git commit -- <path>` takes the WORKING TREE version of that one path and
43
+ * never reads the index, so a neighbour's staged work cannot ride along.
44
+ * - it refuses mid merge, mid rebase, on a detached HEAD, and before the first
45
+ * commit, where an automatic commit is never what anyone wants.
46
+ * - it never pushes. Visibility is the human's call.
47
+ * - the hooks run, as they do for any commit. They are text checks of about a
48
+ * second, and this fires only when the row's DATA changed, not every gate.
49
+ *
50
+ * Never throws and never fails the calling command: a registry row is not worth
51
+ * a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
52
+ */
53
+ function autoCommitRow(page: string, name: string): string {
54
+ if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return ''
55
+ try {
56
+ const dir = resolve(page, '..')
57
+ const top = git(dir, ['rev-parse', '--show-toplevel'])
58
+ if (!top.ok) return '' // the docs tree is not a git repo, nothing to land
59
+ const root = top.out
60
+ // a detached HEAD or a repo with no commit yet has nowhere sane to land
61
+ if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return ''
62
+ if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return ''
63
+ for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD'])
64
+ if (existsSync(join(root, '.git', f))) return ''
65
+ const rel = page.startsWith(`${root}/`) ? page.slice(root.length + 1) : page
66
+ // one retry: a neighbour committing right now holds index.lock
67
+ for (let attempt = 0; attempt < 2; attempt++) {
68
+ const r = git(root, ['commit', '-m', `chore(machine): refresh local-projects row (${name})`, '--', rel])
69
+ if (r.ok) return ' + committed'
70
+ if (!r.out.includes('index.lock')) return ''
71
+ Bun.sleepSync(300)
72
+ }
73
+ return ''
74
+ } catch {
75
+ return ''
76
+ }
77
+ }
78
+
27
79
  const parseRow = (line: string): Row | null => {
28
80
  const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
29
81
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
@@ -90,7 +142,8 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
90
142
  // prune rows for repos that no longer exist at their recorded path
91
143
  const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
92
144
  atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
93
- return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})` }
145
+ const committed = autoCommitRow(page, me.name)
146
+ return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
94
147
  })
95
148
  // lock lost to a racing reporter — self-heals on the next run
96
149
  return r.ok ? r.value : { wrote: false }
@@ -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' },
@@ -1,11 +1,20 @@
1
1
  // periodic health — contracts + every *.check.ts in the repo. Discovery IS
2
2
  // the registration (core/discover-checks.ts); each check runs as a
3
- // subprocess: wire lines parsed back, exit code is truth. A check that runs
4
- // its own CheckSuite keeps its own TTL cache and is never double-cached
5
- // here; plain checks get a short orchestrator TTL. `--fresh` busts both
6
- // layers (SHIPKIT_FRESH=1 + RV_CHECK_FRESH=1 travel to children).
3
+ // subprocess: wire lines parsed back, exit code is truth.
4
+ //
5
+ // NOTHING here decides what may be remembered any more. A rule or a check is
6
+ // replayed when it DECLARED how ("inputs" makes it content-keyed and the
7
+ // ledger owns it, "ttl" makes it a world probe and CheckSuite owns it), and
8
+ // otherwise it is measured every run. The blanket 30-minute orchestrator TTL
9
+ // that used to cover every plain check died with that: it cached by default,
10
+ // on top of whatever the check cached for itself, so one question had two
11
+ // staleness windows and neither was declared by anyone. `--fresh`
12
+ // (SHIPKIT_FRESH=1, which travels to children) busts the one that is left.
7
13
  import { join, relative } from 'path'
8
14
  import { CheckSuite, type CheckOutcome } from './check-suite.ts'
15
+ import { gateUnits } from '../ledger/gate-units.ts'
16
+ import { Ledger, stampReplayed } from '../ledger/ledger.ts'
17
+ import { storeRoot } from '../ledger/store.ts'
9
18
  import { CheckCtx } from './check-ctx.ts'
10
19
  import { discoverChecks } from './discover-checks.ts'
11
20
  import { spawnWithTimeout } from './spawn-timeout.ts'
@@ -18,9 +27,6 @@ import { writeStatus } from './write-status.ts'
18
27
  import { SHARE_ENV } from '../release/releases-share.ts'
19
28
  import { rmSync } from 'fs'
20
29
 
21
- // orchestrator cache for checks WITHOUT their own suite — short on purpose:
22
- // this layer only absorbs back-to-back runs, real TTLs live in the checks
23
- const DISCOVERED_TTL = '30m'
24
30
  const CHECK_TIMEOUT_MS = 120_000
25
31
 
26
32
  export type CheckOpts = {
@@ -50,6 +56,15 @@ export type CheckOpts = {
50
56
  * probed every third-party service the repo knows about. One flag, one
51
57
  * meaning, across all three surfaces. */
52
58
  only?: string[]
59
+ /** the run's ledger, when the caller already opened one (runCi does). A
60
+ * standalone `shipkit check` opens its own. */
61
+ ledger?: Ledger | null
62
+ /** refresh the motherhome registry row. runCi passes false and does it
63
+ * ITSELF, after the ledger has committed: that row is a TRACKED file under
64
+ * `wiki/`, so writing it before the post-run re-hash makes the gate look
65
+ * like an agent editing the tree and throws away every result the run just
66
+ * measured. Two reports per gate run was duplication anyway. */
67
+ reportHome?: boolean
53
68
  }
54
69
 
55
70
  /** does this rule or check answer to one of the `--only` ids? Matched against
@@ -83,7 +98,61 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
83
98
  const prevFrozen = process.env.SHIPKIT_FROZEN
84
99
  if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
85
100
  if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
86
- const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
101
+ // the caller's ledger, or this run's own. `shipkit check` is a full run
102
+ // of every rule and check, so it deserves the same replay the gate gets.
103
+ let ledger: Ledger | null = o.ledger ?? null
104
+ if (ledger === null && o.ledger === undefined) {
105
+ try {
106
+ ledger = Ledger.open(root, gateUnits(root, c), { cliEntry: new URL('../cli.ts', import.meta.url).pathname, fresh: o.fresh })
107
+ } catch { ledger = null }
108
+ }
109
+ const replayed = new Set<string>()
110
+ const unitIdByName = new Map<string, string>()
111
+ /** what a TTL'd answer is remembered under, beside its rotation key. A TTL
112
+ * says how long the WORLD keeps; it cannot say that the probe was
113
+ * rewritten, or that a different shipkit measured it. Without these two a
114
+ * fixed check replayed its old verdict for the rest of its TTL. */
115
+ const worldKey = (unitId: string, rotation?: string): string => {
116
+ let code = ''
117
+ try { code = ledger?.layer.keyFor(unitId).codeKey ?? '' } catch { code = '' }
118
+ return `tc=${ledger?.layer.toolchainId ?? ''}\0code=${code}\0${rotation ?? ''}`
119
+ }
120
+ const suite = new CheckSuite({
121
+ name: c.name,
122
+ storeDir: storeRoot(root),
123
+ onResult: (name, r) => {
124
+ o.onResult?.(name, r)
125
+ // a replay measured nothing, and a red is never stored. A check
126
+ // that cannot block had its FAIL rewritten to a warn before this
127
+ // fires, so the demotion list is what says it failed: a content
128
+ // key has no TTL, so storing that red would pin it until an
129
+ // unrelated edit moved the tree, and "the box is off" is fixed by
130
+ // turning the box on, which changes no file.
131
+ if (ledger == null || r.cached || replayed.has(name) || r.status === 'skip' || r.status === 'fail') return
132
+ if (advisoryFailures.includes(name)) return
133
+ const id = unitIdByName.get(name)
134
+ if (id == null) return
135
+ ledger.record(id, { status: r.status, lines: r.lines, measuredMs: r.ms })
136
+ },
137
+ })
138
+ /** a stored answer, re-emitted as a runner that does no work. Keeping it in
139
+ * the suite rather than splicing lines afterwards is what preserves
140
+ * declaration order, which the wire contract is frozen on. */
141
+ const addReplay = (unitId: string, name: string, blocking: boolean): boolean => {
142
+ const hit = ledger?.replay(unitId)
143
+ if (hit == null) return false
144
+ replayed.add(name)
145
+ unitIdByName.set(name, unitId)
146
+ suite.addCheck({
147
+ blocking,
148
+ name,
149
+ logic: (t) => {
150
+ for (const l of stampReplayed(hit.lines, hit.measuredMs)) t.lines.push(l)
151
+ if (t.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn')) t.warned = true
152
+ },
153
+ })
154
+ return true
155
+ }
87
156
  // built-in SK rules (rules/rule-registry.ts) THEN the repo's own
88
157
  // (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
89
158
  // (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
@@ -104,11 +173,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
104
173
  for (const rule of [...RULES, ...(c.rules ?? [])]) {
105
174
  if (!matchesOnly(o.only, rule.id, rule.title)) continue
106
175
  if (o.onlyBlocking === true && !rule.blocking) continue
176
+ unitIdByName.set(rule.title, rule.id)
177
+ if (addReplay(rule.id, rule.title, rule.blocking)) continue
107
178
  suite.addCheck({
108
179
  blocking: rule.blocking,
109
180
  name: rule.title,
110
181
  remainValidFor: rule.ttl,
111
- cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
182
+ cacheExtra: () => worldKey(rule.id, rule.cacheKey?.({ root, config: c })),
112
183
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
113
184
  })
114
185
  }
@@ -123,7 +194,7 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
123
194
  const childEnv: Record<string, string | undefined> = {
124
195
  ...process.env,
125
196
  [SHARE_ENV]: sharePath,
126
- ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
197
+ ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}),
127
198
  }
128
199
  for (const d of discoverChecks(root)) {
129
200
  const rel = relative(root, d.path)
@@ -133,12 +204,17 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
133
204
  const blocking = d.blocking ?? c.checkDefaults?.blocking
134
205
  if (blocking == null) undeclared.push(rel)
135
206
  if (o.onlyBlocking === true && blocking !== true) continue
207
+ unitIdByName.set(d.name, d.name)
208
+ if (addReplay(d.name, d.name, blocking ?? true)) continue
136
209
  suite.addCheck({
137
210
  // a check about the WORLD may remember a measured negative; one
138
211
  // about this repo never does (check-suite.ts, the rule)
139
212
  blocking: blocking ?? true,
140
213
  name: d.name,
141
- remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
214
+ // a WORLD check says how long its answer keeps; everything else is
215
+ // measured every run rather than blanket-cached by this layer
216
+ remainValidFor: d.ttl ?? undefined,
217
+ cacheExtra: () => worldKey(d.name),
142
218
  logic: async (t) => {
143
219
  const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
144
220
  for (const l of parseWireOutput(r.out)) t.lines.push(l)
@@ -159,7 +235,11 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
159
235
  if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
160
236
  // frozen = a release-shaped run: reporting would re-dirty the tree the
161
237
  // caller is about to demand clean (field wart 2026-08-05, second head)
162
- if (o.frozen !== true) reportToMotherhome(root, c)
238
+ if (o.frozen !== true && o.reportHome !== false) reportToMotherhome(root, c)
239
+ // the caller that OPENED the ledger owns committing it: runCi commits once
240
+ // after its tasks and its checks, so a task and a check in one run share
241
+ // one post-run re-hash
242
+ if (o.ledger === undefined && ledger != null) r.lines.push(...ledger.header(ledger.commit()))
163
243
  return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
164
244
  }
165
245