rvlib-shipkit 0.34.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0
4
+
5
+ - **`shipkit pb collection` mints imports of a package that exists.** The pocketbase data layer and the Mantine shell are one package now, `rvlib-pb`: the generated `columns.tsx`, `grid.tsx` and `index.ts` import `Column`, `LiveGrid` and `defineCollectionUI` from `rvlib-pb/ui`, and `LiveList` from `rvlib-pb`. The two tiers are different subpaths on purpose, so a generated grid that took its type from the ui subpath would typecheck and would put `@mantine/core` in the tsconfig of anything that reads it. This generator ships inside the tarball and writes into repos it never sees, so a stale specifier here keeps producing broken scaffolds long after a rename lands: the suite now asserts every import string the generator emits, which nothing did before.
6
+
3
7
  ## 0.34.0
4
8
 
5
9
  - **SK013's autofix was manufacturing the FAIL its own lint then raised.** It wired a PreToolUse hook at `node_modules/rvlib-shipkit/src/hooks/one-way-in-guard.ts` whether or not that path existed, so any repo that runs shipkit through the global shim without installing the package came out of a repair pass permanently red. A machine-clearable red that a machine just created is a design bug. The hook is still declared in every case, so it starts working the moment the dependency lands, with no second fix run: what changed is the verdict. A guard under `node_modules/` that is not there yet is an uninstalled dependency, so it WARNs and names `bun install`, with the deny list standing as the whole floor meanwhile. A guard path the repo chose and got wrong still FAILs, because that one never resolves on its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli/cmd_Ci.ts CHANGED
@@ -16,6 +16,7 @@ export class CiCommand extends Command {
16
16
  ['leave repo config files alone', 'shipkit ci --no-fix-rules'],
17
17
  ['stop at the first failure', 'shipkit ci --bail'],
18
18
  ['one task + its dep closure', 'shipkit ci --only typecheck'],
19
+ ['re-measure everything, replay nothing', 'shipkit ci --fresh'],
19
20
  ['include an opt-in task', 'shipkit ci --with up:itch'],
20
21
  ],
21
22
  })
@@ -28,6 +29,7 @@ export class CiCommand extends Command {
28
29
  skip = Option.Array('--skip', [], { description: 'skip a task group (repeatable)' })
29
30
  withIds = Option.Array('--with', [], { description: 'include an opt-in task (repeatable)' })
30
31
  only = Option.Array('--only', [], { description: 'run only this task + its dep closure (repeatable)' })
32
+ fresh = Option.Boolean('--fresh', false, { description: 'measure everything: no unit replays from the ledger, and what this run measures is stored fresh' })
31
33
  full = Option.Boolean('--full', false, { description: `run the FULL test tier: every suite the repo declares, the slow ones it skips while you work included. What the release path always runs. Same as SHIPKIT_TEST_FULL=1` })
32
34
 
33
35
  async execute(): Promise<number> {
@@ -45,6 +47,7 @@ export class CiCommand extends Command {
45
47
  skipGroups: this.skip,
46
48
  withIds: this.withIds,
47
49
  onlyIds: this.only,
50
+ fresh: this.fresh,
48
51
  ...(this.full ? { tests: 'full' as const } : {}),
49
52
  print: (l) => this.context.stdout.write(`${l}\n`),
50
53
  })
@@ -8,7 +8,7 @@
8
8
  import { createHash } from 'crypto'
9
9
  import { Command, Option } from 'clipanion'
10
10
  import { loadRepoConfig } from '../repo-config.ts'
11
- import { gateUnits } from '../ledger/gate-units.ts'
11
+ import { envTier, FULL_TESTS_ENV, gateUnits, tieredTasks } from '../ledger/gate-units.ts'
12
12
  import { KeyLayer } from '../ledger/git-key.ts'
13
13
 
14
14
  export class KeysCommand extends Command {
@@ -22,6 +22,7 @@ export class KeysCommand extends Command {
22
22
  ['only the ones nothing can cache yet', 'shipkit keys --burn-down'],
23
23
  ['why this key is what it is', 'shipkit keys --why typecheck'],
24
24
  ['re-hash after the fact and report drift', 'shipkit keys --rescan'],
25
+ ['the keys a --full gate asks for', 'shipkit keys --full'],
25
26
  ['machine-readable', 'shipkit keys --json'],
26
27
  ],
27
28
  })
@@ -29,19 +30,24 @@ export class KeysCommand extends Command {
29
30
  json = Option.Boolean('--json', false, { description: 'machine-readable { units: UnitKey[], burnDown, errors, scanMs }' })
30
31
  burnDown = Option.Boolean('--burn-down', false, { description: 'list only the units nothing can cache yet, and why' })
31
32
  why = Option.String('--why', { required: false, description: 'print every component that went into ONE unit\'s key' })
33
+ full = Option.Boolean('--full', false, { description: `render the keys a FULL-tier run asks for (same as ${FULL_TESTS_ENV}=1). The tier is in every task's key` })
32
34
  rescan = Option.Boolean('--rescan', false, { description: 'run the post-run re-hash and name every input that moved since the scan' })
33
35
 
34
36
  async execute(): Promise<number> {
35
37
  const root = process.cwd()
36
38
  const c = await loadRepoConfig(root)
37
- const decls = gateUnits(root, c)
39
+ // the TIER the gate would run, applied here too: `--full` rewrites
40
+ // every task's env, which is in the key, so a surface that renders keys
41
+ // without it renders a tier nobody asked for
42
+ const tier = this.full ? 'full' : envTier()
43
+ const decls = gateUnits(root, c, tieredTasks(c, root, tier))
38
44
  const layer = KeyLayer.open(root, decls, { cliEntry: new URL('../cli.ts', import.meta.url).pathname })
39
45
  const keys = layer.keys()
40
46
  const out = (l: string): void => { this.context.stdout.write(`${l}\n`) }
41
47
 
42
48
  if (this.json) {
43
49
  const drift = this.rescan ? layer.rescan() : null
44
- out(JSON.stringify({ units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
50
+ out(JSON.stringify({ tier, units: keys, burnDown: layer.registration.burnDown, errors: layer.registration.errors, layerMs: Math.round(layer.openMs), scanMs: Math.round(layer.scanMs), failure: layer.failure, drift }))
45
51
  return layer.registration.errors.length > 0 || layer.failure != null ? 1 : 0
46
52
  }
47
53
 
@@ -90,7 +96,7 @@ export class KeysCommand extends Command {
90
96
  const world = keys.filter((k) => k.population === 'world').length
91
97
  const none = keys.filter((k) => k.population === 'none').length
92
98
  const todo = layer.registration.burnDown.length
93
- out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
99
+ out(`\n${keys.length} units — ${content} content, ${world} world, ${none} uncacheable (${none - todo} decided, ${todo} still undeclared) · ${tier} test tier · key layer ${Math.round(layer.openMs)}ms, of which ${Math.round(layer.scanMs)}ms of git`)
94
100
  if (todo > 0 && !this.burnDown) out(` the burn-down: shipkit keys --burn-down`)
95
101
 
96
102
  if (this.rescan) {
@@ -6,56 +6,23 @@
6
6
  // WARN/FAIL evict. Busts: version bump, secret rotation (key hashes the
7
7
  // secret value), `<freshEnv>=1`.
8
8
  //
9
- // STORAGE: one immutable file per (check, version, cache key), under
10
- // `<cacheDir>/<suite>/`. Three agents in one checkout is the normal case, so
11
- // there is no shared file to lose a write to: a run with a different key
12
- // writes a DIFFERENT file, a run with the same key replaces its own by tmp +
13
- // rename (whole answer or nothing, never half), and nothing is pruned,
14
- // because pruning by "the checks I know about" deletes the entries of a
15
- // runner that narrowed itself with --only. It replaced one aggregate JSON
16
- // read at the top of the run and rewritten at the bottom, which lost every
17
- // entry another runner wrote in between and served a torn read as an empty
18
- // cache.
9
+ // STORAGE is not this module's concern any more: ledger/store.ts owns the
10
+ // one store, its shape, and its housekeeping (one immutable file per key,
11
+ // tmp + rename, age-based sweep). This is the WORLD half of the three
12
+ // populations — an answer nothing on disk describes, remembered for a TTL and
13
+ // busted by rotation. The CONTENT half is keyed instead of timed and lives in
14
+ // ledger/ledger.ts. Same store, different key.
19
15
  //
20
16
  // Secret VALUES never enter this module's state beyond the injected
21
17
  // getSecret call + a truncated hash for cache-busting.
22
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
23
- import { dirname, join } from 'path'
18
+ import { existsSync } from 'fs'
24
19
  import { CheckCtx, RateLimited, ToolUnresolved } from './check-ctx.ts'
25
- import { atomicWrite } from './locked-write.ts'
20
+ import { dropStored, entryPath, FRESH_ENV, readStored, sweepStore, writeStored } from '../ledger/store.ts'
26
21
  import { fmtStamp, formatLine, parseDuration, type Line } from './wire.ts'
27
22
 
28
23
  type Logic = (t: CheckCtx) => unknown
29
24
  type Prep = { skip?: string; cacheExtra?: string }
30
25
  type Runner = { name: string; ttlMs: number; version: number; prep?: () => Prep; logic: Logic; blocking: boolean }
31
- type CacheEntry = { version: number; key: string; at: number; lines: Line[] }
32
-
33
- /** a missing or half-written file reads as "no answer", which is the safe
34
- * side: the check runs. tmp + rename means half-written cannot happen from a
35
- * shipkit write, so this only ever catches a truncated file from something
36
- * else, and treats it the same way. */
37
- const readEntry = (path: string): CacheEntry | null => {
38
- try {
39
- const e = JSON.parse(readFileSync(path, 'utf8')) as Partial<CacheEntry>
40
- return Array.isArray(e.lines) && typeof e.at === 'number' && typeof e.version === 'number' && typeof e.key === 'string' ? (e as CacheEntry) : null
41
- } catch { return null }
42
- }
43
-
44
- const writeEntry = (path: string, e: CacheEntry): void => {
45
- try {
46
- mkdirSync(dirname(path), { recursive: true })
47
- atomicWrite(path, JSON.stringify(e))
48
- } catch {
49
- // a cache that cannot be written is slow, never wrong: the answer was
50
- // already printed, and the next run measures again
51
- }
52
- }
53
-
54
- const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
55
-
56
- /** how long a superseded key's file survives. Far past the longest TTL any
57
- * check declares, so a live entry is never swept out from under a replay. */
58
- const SUPERSEDED_KEEP_MS = 30 * 24 * 60 * 60 * 1000
59
26
 
60
27
  export type GetSecret = (name: string, field: 'get' | 'account') => string | null
61
28
 
@@ -66,15 +33,19 @@ export type SecretSpec = {
66
33
  skipHint: string
67
34
  }
68
35
 
69
- export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean }
36
+ export type CheckOutcome = { lines: Line[]; status: 'pass' | 'fail' | 'warn' | 'skip'; cached: boolean; /** wall time of the LOGIC, ms — 0 on a replay, since a replay measured nothing */ ms: number }
70
37
 
71
38
  export type CheckSuiteOpts = {
72
39
  name: string
73
- cacheDir: string
40
+ /** the ONE store root (ledger/store.ts storeRoot). Injected rather than
41
+ * resolved here so a test can point a suite at a scratch dir. */
42
+ storeDir: string
74
43
  now?: () => number
75
44
  /** secret backend — consumers inject their own store; default = nothing stored, every secret check skips */
76
45
  getSecret?: GetSecret
77
- /** env var that busts the cache when set (consumer names it) */
46
+ /** TEST SEAM: the env var that busts this suite's replays. Production is
47
+ * always the one house-wide flag (ledger/store.ts FRESH_ENV); a test
48
+ * overrides it so busting one suite cannot poison a neighbour's run. */
78
49
  freshEnv?: string
79
50
  /** per-check completion tap (the TUI streams results in) — cached replays
80
51
  * report cached:true, prep-skips (unstored secret) report status 'skip' */
@@ -85,7 +56,7 @@ export class CheckSuite {
85
56
  readonly name: string
86
57
  private runners: Runner[] = []
87
58
  private toolPaths = new Map<string, string>()
88
- private cacheDir: string
59
+ private storeDir: string
89
60
  private now: () => number
90
61
  private getSecret: GetSecret
91
62
  private freshEnv: string
@@ -93,10 +64,10 @@ export class CheckSuite {
93
64
 
94
65
  constructor(opts: CheckSuiteOpts) {
95
66
  this.name = opts.name
96
- this.cacheDir = opts.cacheDir
67
+ this.storeDir = opts.storeDir
97
68
  this.now = opts.now ?? Date.now
98
69
  this.getSecret = opts.getSecret ?? (() => null)
99
- this.freshEnv = opts.freshEnv ?? 'SHIPKIT_FRESH'
70
+ this.freshEnv = opts.freshEnv ?? FRESH_ENV
100
71
  this.onResult = opts.onResult
101
72
  }
102
73
 
@@ -166,32 +137,12 @@ export class CheckSuite {
166
137
  })
167
138
  }
168
139
 
169
- /** the key is in the file name, so a check whose key moves leaves its old
170
- * file behind forever. A rule keyed on installed versions writes one per
171
- * upgrade, so the dir accretes without bound in a repo nobody clears.
172
- * Sweeping by AGE needs no knowledge of the current check set, which is
173
- * what made the old prune wrong: a run narrowed by --only knows about one
174
- * check and would have deleted the other twenty four. */
175
- private sweepSuperseded(): void {
176
- const dir = join(this.cacheDir, slug(this.name))
177
- const cutoff = this.now() - SUPERSEDED_KEEP_MS
178
- try {
179
- for (const f of readdirSync(dir)) {
180
- if (!f.endsWith('.json')) continue
181
- const p = join(dir, f)
182
- try { if (statSync(p).mtimeMs < cutoff) rmSync(p, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
183
- }
184
- } catch {
185
- // no dir yet, or unreadable: housekeeping never fails a run
186
- }
187
- }
188
-
189
- /** where this check's answer for THIS key lives. The key is in the file
190
- * NAME, so a run whose key moved writes a new file rather than rewriting
191
- * the old one, and two runners with different keys never collide. */
192
- private entryPath(r: Runner, key: string): string {
193
- const h = new Bun.CryptoHasher('sha256').update(`${r.name}\0${r.version}\0${key}`).digest('hex').slice(0, 12)
194
- return join(this.cacheDir, slug(this.name), `${slug(r.name)}.${h}.json`)
140
+ /** the key a world answer is remembered under: the check's own version
141
+ * plus whatever the prep declared (a secret's value hash, a rule's
142
+ * cacheKey). A version bump and a rotation both move it, so neither can
143
+ * replay across the change. */
144
+ private cacheKey(r: Runner, extra: string): string {
145
+ return `v${r.version}\0${extra}`
195
146
  }
196
147
 
197
148
  // test seam — run() prints + exits; execute() returns the structured result
@@ -201,15 +152,14 @@ export class CheckSuite {
201
152
  private async runOne(r: Runner): Promise<{ lines: Line[]; skip: string | null; ran: boolean; failed: boolean }> {
202
153
  const prep = r.prep?.() ?? {}
203
154
  if (prep.skip != null) {
204
- this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false })
155
+ this.onResult?.(r.name, { lines: [{ kind: 'skip', text: prep.skip }], status: 'skip', cached: false, ms: 0 })
205
156
  return { lines: [], skip: prep.skip, ran: false, failed: false }
206
157
  }
207
- const key = prep.cacheExtra ?? ''
208
- const entryPath = this.entryPath(r, key)
209
- const entry = readEntry(entryPath)
158
+ const key = this.cacheKey(r, prep.cacheExtra ?? '')
159
+ const path = entryPath(this.storeDir, this.name, r.name, key)
160
+ const entry = readStored<Line[]>(path, key)
210
161
  const fresh = r.ttlMs > 0 && process.env[this.freshEnv] == null
211
- && entry != null && entry.version === r.version && entry.key === key
212
- && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
162
+ && entry != null && this.now() - entry.at < r.ttlMs && this.now() >= entry.at
213
163
  if (fresh) {
214
164
  // the stamp was baked in when the check RAN (below): a replay
215
165
  // prints the stored lines VERBATIM, so the live run and every
@@ -219,11 +169,12 @@ export class CheckSuite {
219
169
  // alternation, so neither side annotates
220
170
  // a replayed WORLD negative keeps its severity: it is still the
221
171
  // answer, and demoting it to a pass would hide a real finding
222
- const replayedWarn = entry.lines.some((l) => l.kind === 'warn' || l.kind === 'fail')
223
- this.onResult?.(r.name, { lines: entry.lines, status: replayedWarn ? 'warn' : 'pass', cached: true })
224
- return { lines: entry.lines, skip: null, ran: true, failed: false }
172
+ const replayedWarn = entry.payload.some((l) => l.kind === 'warn' || l.kind === 'fail')
173
+ this.onResult?.(r.name, { lines: entry.payload, status: replayedWarn ? 'warn' : 'pass', cached: true, ms: 0 })
174
+ return { lines: entry.payload, skip: null, ran: true, failed: false }
225
175
  }
226
176
  const t = new CheckCtx(this.toolPaths)
177
+ const started = performance.now()
227
178
  try {
228
179
  await r.logic(t)
229
180
  } catch (e) {
@@ -260,29 +211,23 @@ export class CheckSuite {
260
211
  const lines = cacheable
261
212
  ? t.lines.map((l, i): Line => (i === 0 ? { ...l, text: `${l.text} (${clean ? 'cached' : 'measured'} at ${fmtStamp(this.now())} until ${fmtStamp(this.now() + r.ttlMs)})` } : l))
262
213
  : t.lines
263
- this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
214
+ this.onResult?.(r.name, { lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false, ms: performance.now() - started })
264
215
  // this check writes its OWN file, and only its own: nothing here reads
265
216
  // or rewrites a neighbour's entry, so a racing runner can lose nothing
266
217
  if (r.ttlMs > 0) {
267
- if (cacheable) writeEntry(entryPath, { version: r.version, key, at: this.now(), lines })
218
+ if (cacheable) writeStored(path, key, lines, this.now())
268
219
  // an unmeasured run evicts whatever was stored: a later run must
269
220
  // re-probe rather than replay an answer recorded before the outage.
270
221
  // A racing runner may have written a good answer into that same
271
222
  // file a moment ago and lose it here, which costs one re-probe and
272
223
  // can never produce a stale green, so it is the safe direction.
273
- else if (entry != null) rmSync(entryPath, { force: true })
224
+ else if (entry != null) dropStored(path)
274
225
  }
275
226
  return { lines, skip: null, ran: true, failed: t.failed }
276
227
  }
277
228
 
278
229
  async execute(): Promise<{ lines: Line[]; exitCode: number }> {
279
- // the aggregate file this replaced. Deleting it here is the migration:
280
- // nothing reads it any more, and leaving it would have a future reader
281
- // believe there are two cache layers. DIES when every consumer repo is
282
- // past rvlib-shipkit 0.34.0, since after that no tree can still carry
283
- // one; it costs an unlink of a path that is not there.
284
- rmSync(join(this.cacheDir, `${this.name}.json`), { force: true })
285
- this.sweepSuperseded()
230
+ sweepStore(this.storeDir, this.name, this.now())
286
231
 
287
232
  // CONCURRENT, bounded. Checks are independent by contract: each answers
288
233
  // one question about the world or the repo and shares nothing with its
@@ -17,8 +17,6 @@ export type DiscoveredCheck = {
17
17
  path: string
18
18
  /** display + cache name — file base, disambiguated by relative path on collision */
19
19
  name: string
20
- /** file runs its own CheckSuite (own TTL cache) — the orchestrator must not double-cache it */
21
- selfCached: boolean
22
20
  /** `export const blocking = true | false`, read statically. null = the file
23
21
  * never said, which is an error: a check that silently does not block is
24
22
  * the failure nobody sees. */
@@ -67,16 +65,12 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
67
65
  return files.map((f) => {
68
66
  const base = baseOf(f)
69
67
  const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
70
- // a check built on CheckSuite/PageValidator replays from its OWN
71
- // TTL cache — caching it again up here would double the staleness
72
- let selfCached = false
73
68
  let blocking: boolean | null = null
74
69
  let inputs: string[] | null = null
75
70
  let ttl: string | null = null
76
71
  let never: string | null = null
77
72
  try {
78
73
  const src = readFileSync(f, 'utf8')
79
- selfCached = /PageValidator|CheckSuite/.test(src)
80
74
  const m = src.match(BLOCKING_RE)
81
75
  blocking = m == null ? null : m[1] === 'true'
82
76
  const im = src.match(INPUTS_RE)
@@ -87,6 +81,6 @@ export function discoverChecks(root: string): DiscoveredCheck[] {
87
81
  ttl = src.match(TTL_RE)?.[1] ?? null
88
82
  never = src.match(NEVER_RE)?.[1] ?? null
89
83
  } catch {}
90
- return { path: f, name, selfCached, blocking, inputs, ttl, never }
84
+ return { path: f, name, blocking, inputs, ttl, never }
91
85
  })
92
86
  }
package/src/core/floor.ts CHANGED
@@ -84,18 +84,27 @@ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
84
84
  for (const id of FLOOR_IDS) {
85
85
  const override = c.defaults?.[id]
86
86
  if (override === false) { disabled.push(id); continue }
87
- if (override != null) { tasks.push(override); sources[id] = 'repo.config override'; continue }
87
+ // a FULL override brings its own body and replaces the slot. One that
88
+ // brings none DECORATES whatever fills it, which is how a repo declares
89
+ // what its `test` script reads without restating the command: the read
90
+ // set is repo knowledge, the command is not.
91
+ if (override != null && (override.cmd != null || override.logic != null)) {
92
+ tasks.push({ ...override, id: override.id ?? id } as TaskDef)
93
+ sources[id] = 'repo.config override'
94
+ continue
95
+ }
88
96
  // ONE WAY IN: `<id>` routes to `shipkit ci --only <id>`, so the TOOL
89
97
  // body lives at `<id>:raw` and that is what the gate runs. Running the
90
98
  // routed script here would spawn the gate from inside itself, forever
99
+ const decorate = (t: TaskDef): TaskDef => (override == null ? t : { ...t, ...override, id })
91
100
  const raw = scripts[rawName(id)]
92
- if (raw != null) { tasks.push({ id, cmd: ['bun', 'run', rawName(id)] }); sources[id] = 'package.json script'; continue }
93
- if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push({ id, cmd: ['bun', 'run', id] }); sources[id] = 'package.json script'; continue }
101
+ if (raw != null) { tasks.push(decorate({ id, cmd: ['bun', 'run', rawName(id)] })); sources[id] = 'package.json script'; continue }
102
+ if (scripts[id] != null && !routesToShipkit(scripts[id]!)) { tasks.push(decorate({ id, cmd: ['bun', 'run', id] })); sources[id] = 'package.json script'; continue }
94
103
  const b = builtinFloorTask(id, root)
95
104
  // a routed script with nothing behind it measures NOTHING, and the
96
105
  // reason must say that rather than `no '<id>' script`, which is false
97
106
  if (typeof b === 'string') unfilled.push({ id, reason: scripts[id] != null ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
98
- else { tasks.push(b.task); sources[id] = b.source }
107
+ else { tasks.push(decorate(b.task)); sources[id] = b.source }
99
108
  }
100
109
  return { tasks, disabled, unfilled, sources }
101
110
  }
@@ -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 }
@@ -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