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.
@@ -11,15 +11,15 @@ import { reportToMotherhome } from './motherhome.ts'
11
11
  import { collectChecks } from './run-check.ts'
12
12
  import { runFixPass } from './run-fixers.ts'
13
13
  import { formatLine } from './wire.ts'
14
- import { runTasks, type TaskResult } from './task-runner.ts'
14
+ import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
16
  import { rmSync } from 'fs'
17
17
 
18
18
  import { SHARE_ENV } from '../release/releases-share.ts'
19
19
  import { collectReports } from '../release/run-releases.ts'
20
20
  import { withContractsScope } from '../contracts/contracts-once.ts'
21
- import { gateUnits } from '../ledger/gate-units.ts'
22
- import { validateRegistration } from '../ledger/git-key.ts'
21
+ import { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
22
+ import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
23
23
 
24
24
  export type CiOpts = {
25
25
  bail?: boolean
@@ -61,6 +61,14 @@ export type CiOpts = {
61
61
  * loads of repo.config.ts can disagree, and the release path classifies
62
62
  * rule-vs-probe severity from the config it holds. */
63
63
  config?: RepoConfig
64
+ /** re-measure everything: no unit replays from the ledger, and what this
65
+ * run measures is stored fresh. Travels to child checks as SHIPKIT_FRESH. */
66
+ fresh?: boolean
67
+ /** read the ledger at all. The release path passes false, unconditionally
68
+ * and not configurably: every remaining hole in the key is then capped at
69
+ * "an agent saw a stale local green", never "a stale green authorised a
70
+ * publish" (ledger/ledger.ts rule 4). */
71
+ ledgerRead?: boolean
64
72
  /** which test tier the run asks the repo's suite for. 'quick' is the
65
73
  * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
66
74
  * a suite reads to include the slow, real-world tiers it otherwise skips.
@@ -70,12 +78,8 @@ export type CiOpts = {
70
78
  tests?: TestTier
71
79
  }
72
80
 
73
- export type TestTier = 'quick' | 'full'
74
-
75
- /** the env var a repo's own suite reads to widen itself. Named here because
76
- * the gate is what sets it, and a suite that invents its own name is a tier
77
- * the release path cannot reach. */
78
- export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
81
+ export type { TestTier } from '../ledger/gate-units.ts'
82
+ export { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
79
83
 
80
84
  /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
81
85
  * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
@@ -118,7 +122,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
118
122
  const all = allTasks(c, root)
119
123
  const { disabled, unfilled } = all
120
124
  const skipIds = new Set(o.skipTaskIds ?? [])
121
- const tier: TestTier = o.tests ?? (process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick')
125
+ const tier: TestTier = o.tests ?? envTier()
122
126
  const tasks = all.tasks
123
127
  .filter((t) => !skipIds.has(t.id))
124
128
  // the whole run carries the flag rather than one task id: a repo names
@@ -132,27 +136,37 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
132
136
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
133
137
  for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
134
138
  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.
139
+ // THE LEDGER, opened once per run and AFTER the fix pass: the fixers
140
+ // rewrite source, and a scan taken before them would have every unit
141
+ // discarded by its own gate's repair work.
142
+ //
143
+ // Registration is validated on the way in, and it is loud in the gate
144
+ // rather than only in `shipkit keys`, because a unit declaring two
145
+ // contradictory populations is a bug in what binds every repo. The
146
+ // burn-down is one line, never a wall.
140
147
  let registrationFailed = false
148
+ let ledger: Ledger | null = null
141
149
  try {
142
- const reg = validateRegistration(gateUnits(root, c))
150
+ ledger = Ledger.open(root, gateUnits(root, c, tasks), {
151
+ cliEntry: new URL('../cli.ts', import.meta.url).pathname,
152
+ read: o.ledgerRead,
153
+ fresh: o.fresh,
154
+ })
155
+ const reg = ledger.layer.registration
143
156
  for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
144
157
  if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
145
158
  // both counts, always. Printing only the burn-down made `never:` the
146
159
  // cheap way to shrink the visible number, and that field carries no
147
160
  // 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.
161
+ // visible as a shrinking undeclared one.
151
162
  const decided = reg.units - reg.burnDown.length - reg.declared
152
163
  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)`)
164
+ sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared, so they are measured every run (shipkit keys --burn-down)`)
165
+ if (ledger.layer.failure != null)
166
+ sink(`WARN the git scan failed, so NOTHING replays and nothing is stored this run — ${ledger.layer.failure}`)
154
167
  } catch (e) {
155
- sink(`WARN cache declarations unreadable (${String(e).slice(0, 160)}) — nothing is cached, so the gate is unaffected`)
168
+ ledger = null
169
+ sink(`WARN ledger unavailable (${String(e).slice(0, 160)}) — every unit is measured this run, so the gate is correct and slower`)
156
170
  }
157
171
  // a floor slot nothing could fill is VISIBLE — a thin gate must never
158
172
  // look like a full one (the fresh-repo floor gap)
@@ -176,22 +190,77 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
176
190
  // paying a 4s network survey to answer it is the same bug --only was
177
191
  // fixed for
178
192
  const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
179
- const survey = willRunChecks ? collectReports(root, c).catch(() => null) : null
180
- const summary = await runTasks({
181
- tasks,
193
+ // REPLAY, before the DAG sees the list. A replayed task is removed from
194
+ // the run entirely, which is also what makes its dependents correct: the
195
+ // runner treats a filtered dep as satisfied, and only a GREEN is ever
196
+ // stored, so a dependent can never start on the back of a red nobody saw.
197
+ const selectable = new Set(selectTasks({ tasks, logDir: '', skipGroups: o.skipGroups ?? [], withIds: o.withIds ?? [], onlyIds: o.onlyIds ?? [] }).selected.map((t) => t.id))
198
+ const replayedTasks = new Map<string, UnitOutcome>()
199
+ const toRun = tasks
200
+ .filter((t) => {
201
+ if (ledger == null || !selectable.has(t.id)) return true
202
+ const hit = ledger.replay(t.id)
203
+ if (hit == null) return true
204
+ replayedTasks.set(t.id, hit)
205
+ for (const l of stampReplayed(hit.lines, hit.measuredMs)) sink(formatLine(l, false))
206
+ return false
207
+ })
208
+ // a dep ON a replayed task is DROPPED, not left dangling. The runner
209
+ // validates every dep against the list it was handed and dies with
210
+ // `unknown dep` otherwise, and dropping is the honest edge here: only a
211
+ // GREEN is ever replayed, so the dependent's precondition is met and
212
+ // this is the same "a filtered dep counts as satisfied" the runner
213
+ // already applies to --skip and opt-in tasks.
214
+ .map((t) => (t.deps?.some((d) => replayedTasks.has(d)) === true ? { ...t, deps: t.deps.filter((d) => !replayedTasks.has(d)) } : t))
215
+ // the pre-warm exists to hide the survey behind the TASKS. When every task
216
+ // replayed there is nothing to hide it behind, and starting it anyway is a
217
+ // 4s network call the run may never need: the five checks that want it
218
+ // fetch it themselves, and when they replay too, nobody pays for it at all.
219
+ const survey = willRunChecks && toRun.length > 0 ? collectReports(root, c).catch(() => null) : null
220
+ // a NAMED task that replayed leaves the list, so its name has to leave the
221
+ // filters with it: the runner validates `--only`/`--with` against the list
222
+ // it was handed and answers `unknown task id`. `bun run typecheck` and
223
+ // `bun run test` route through `--only`, so this is the reflex verb every
224
+ // agent in every wired repo types, going red on a warm store.
225
+ const onlyIds = (o.onlyIds ?? []).filter((id) => !replayedTasks.has(id))
226
+ const withIds = (o.withIds ?? []).filter((id) => !replayedTasks.has(id))
227
+ // narrowed to nothing is NOT the same as "no filter": an empty --only would
228
+ // widen the run to every task
229
+ const nothingToRun = replayedTasks.size > 0 && (toRun.length === 0 || ((o.onlyIds ?? []).length > 0 && onlyIds.length === 0))
230
+ // every asked-for task replayed: the DAG has nothing to schedule, and
231
+ // running it anyway would print `all 0 tasks green`, which reads as a gate
232
+ // that measured nothing rather than one that already knew the answer
233
+ const summary = nothingToRun
234
+ ? { ok: true, results: [], interrupted: false }
235
+ : await runTasks({
236
+ tasks: toRun,
182
237
  logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
183
238
  cwd: root,
184
239
  bail: o.bail ?? false,
185
240
  skipGroups: o.skipGroups ?? [],
186
- withIds: o.withIds ?? [],
187
- onlyIds: o.onlyIds ?? [],
241
+ withIds,
242
+ onlyIds,
188
243
  print: sink,
189
244
  onTaskFinish: o.onTaskFinish,
190
245
  })
246
+ if (nothingToRun) sink(`ok: all ${replayedTasks.size} task(s) asked for replayed, none measured`)
191
247
  // what the suite ACTUALLY ran, from its own summary line. A flag that was
192
248
  // set proves the intent; the count proves the tests
193
- const ranTests = summary.results
194
- .map((r) => /Ran (\d+) tests?/.exec(r.lastLine ?? '')?.[1])
249
+ for (const r of summary.results) {
250
+ if (ledger == null || r.status !== 'pass') continue
251
+ const surfaced = r.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
252
+ ledger.record(r.id, {
253
+ status: surfaced.some((l) => l.kind === 'warn' || l.kind === 'fix-warn') ? 'warn' : 'pass',
254
+ // the headline is REBUILT rather than captured: the runner renders
255
+ // the wall time into it, and a duration baked into a stored line
256
+ // would be replayed as if this run had measured it
257
+ lines: [{ kind: 'ok', text: r.id }, ...surfaced],
258
+ lastLine: r.lastLine,
259
+ measuredMs: r.durationMs,
260
+ })
261
+ }
262
+ const ranTests = [...summary.results.map((r) => r.lastLine ?? ''), ...[...replayedTasks.values()].map((o) => o.lastLine ?? '')]
263
+ .map((l) => /Ran (\d+) tests?/.exec(l)?.[1])
195
264
  .find((n) => n != null)
196
265
  if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
197
266
  // the checks, in the SAME command. there is no second surface: a check
@@ -202,7 +271,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
202
271
  if (survey != null) await survey
203
272
  const checks = o.skipChecks === true
204
273
  ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
205
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds })
274
+ : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false })
206
275
  if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
207
276
  for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
208
277
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
@@ -219,6 +288,11 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
219
288
  sink(' fix: shipkit check')
220
289
  } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
221
290
  }
291
+ // the post-run re-hash, then the store. NOTHING was written while the gate
292
+ // ran: a unit whose inputs moved under it answered about a tree that no
293
+ // longer exists, and this is where that result is thrown away instead of
294
+ // stored under a key that describes the file as it was before the edit.
295
+ if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
222
296
  const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
223
297
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
224
298
  // and never fires under frozen: a bump/release gate must not re-dirty
@@ -45,6 +45,13 @@ export type TaskDef = {
45
45
  never?: string
46
46
  }
47
47
 
48
+ /** every field a `TaskDef` may carry. Declared as a value so `defineRepo` can
49
+ * refuse a typo inside a `defaults` slot: a slot with no `cmd` DECORATES the
50
+ * resolved task, so a misspelled field is not a type error at the call site
51
+ * and would land as a task that is quietly never cached. Add a field above,
52
+ * add it here. */
53
+ export const TASK_DEF_KEYS = ['id', 'cmd', 'logic', 'deps', 'env', 'cwd', 'group', 'optIn', 'timeoutMs', 'inputs', 'ttl', 'never'] as const
54
+
48
55
  export type TaskResult = {
49
56
  id: string
50
57
  status: 'pass' | 'fail' | 'skipped'
@@ -8,14 +8,40 @@
8
8
  // that drifts the first time somebody adds a check.
9
9
  import { relative } from 'path'
10
10
  import { allTasks, type RepoConfig } from '../repo-config.ts'
11
+ import type { TaskDef } from '../core/task-runner.ts'
11
12
  import { discoverChecks } from '../core/discover-checks.ts'
12
13
  import { RULES } from '../rules/rule-registry.ts'
13
14
  import type { UnitDecl } from './git-key.ts'
14
15
 
15
- /** every task, rule and discovered check, in the order the gate meets them. */
16
- export function gateUnits(root: string, c: RepoConfig): UnitDecl[] {
16
+ /** the env var a repo's own suite reads to widen itself. It lives HERE because
17
+ * it rewrites every task's env, which is part of the key: a run that asked for
18
+ * the quick tier and one that asked for the full tier are different questions,
19
+ * and a surface that renders keys without applying it renders a tier nobody
20
+ * ran. */
21
+ export const FULL_TESTS_ENV = 'SHIPKIT_TEST_FULL'
22
+
23
+ export type TestTier = 'quick' | 'full'
24
+
25
+ /** the tier this process is asking for, absent an explicit choice */
26
+ export function envTier(): TestTier {
27
+ return process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
28
+ }
29
+
30
+ /** the repo's tasks as the gate will actually run them at `tier`. */
31
+ export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDef[] {
32
+ const tasks = allTasks(c, root).tasks
33
+ return tier === 'full' ? tasks.map((t) => ({ ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } })) : tasks
34
+ }
35
+
36
+ /** every task, rule and discovered check, in the order the gate meets them.
37
+ *
38
+ * `tasks` overrides what the repo resolves to, and the gate MUST pass what it
39
+ * is actually about to run: `--full` rewrites every task's env, and a key
40
+ * built from the unmodified list would let a quick run replay into a full one
41
+ * under one identity. */
42
+ export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
17
43
  const out: UnitDecl[] = []
18
- for (const t of allTasks(c, root).tasks) {
44
+ for (const t of tasks ?? allTasks(c, root).tasks) {
19
45
  out.push({
20
46
  id: t.id,
21
47
  kind: 'task',
@@ -28,14 +28,15 @@
28
28
  // differ from main by two top-level packages. That is what makes a shared
29
29
  // ledger safe rather than a stale green in a checkout nobody looked at.
30
30
  import { createHash } from 'crypto'
31
+ import { join } from 'path'
31
32
  import { loadConfig, type RvtConfig } from '../ts/config.ts'
32
33
  import { importClosure } from '../ts/source-graph.ts'
33
- import { GitScanError, hashFiles, normalizeInput, scanPaths, type ScanResult } from './git-scan.ts'
34
- import { toolchainIdentity } from './toolchain-identity.ts'
34
+ import { GitScanError, hashFiles, normalizeInput, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
35
+ import { installIdentity, toolchainIdentity } from './toolchain-identity.ts'
35
36
 
36
37
  /** the key format. Bumped when a component is added or its rendering changes,
37
38
  * so an old ledger can never answer a new question. */
38
- export const KEY_VERSION = 1
39
+ export const KEY_VERSION = 2
39
40
 
40
41
  export type Population = 'content' | 'world' | 'none'
41
42
 
@@ -176,6 +177,16 @@ export class KeyLayer {
176
177
  readonly openMs: number,
177
178
  ) {}
178
179
 
180
+ private readonly installIds = new Map<string, string>()
181
+
182
+ /** the identity of the program this run is driving: bun version, the gate
183
+ * binary's whole source, and every installed package. A WORLD unit needs
184
+ * it too, because a TTL'd answer measured by a different shipkit is a
185
+ * different answer, and the TTL alone cannot see that. */
186
+ get toolchainId(): string {
187
+ return this.toolchain.id
188
+ }
189
+
179
190
  /** wall time the GIT half alone cost, ms — `openMs` is what a run pays */
180
191
  get scanMs(): number {
181
192
  return this.scan?.ms ?? 0
@@ -276,6 +287,10 @@ export class KeyLayer {
276
287
  `v=${KEY_VERSION}`,
277
288
  `unit=${d.kind}:${d.id}`,
278
289
  `bun=${Bun.version}`,
290
+ // report-only mode changes what a self-maintaining rule DOES, and
291
+ // a TTL cannot see a mode. A frozen answer replayed into a normal
292
+ // run, or the reverse, is an answer to the other question.
293
+ `frozen=${process.env['SHIPKIT_FROZEN'] === '1'}`,
279
294
  `toolchain=${this.toolchain.id}`,
280
295
  `argv=${JSON.stringify(d.argv ?? [])}`,
281
296
  ...(d.env ?? []).slice().sort().map((n) => `env:${n}=${process.env[n] ?? '<unset>'}`),
@@ -284,17 +299,53 @@ export class KeyLayer {
284
299
  ...(d.extra != null ? [`extra=${d.extra}`] : []),
285
300
  // ignored content nobody keyed on is part of the key's MEANING:
286
301
  // two runs that excluded different things answered different
287
- // questions, and a silently narrowed key is the false green
288
- ...hidden.sort().map((p) => `excluded=${p}`),
302
+ // questions, and a silently narrowed key is the false green.
303
+ //
304
+ // An excluded INSTALL carries its contents rather than only its
305
+ // name. `test:libs` declares `wiki/build`, every sub-package's
306
+ // `node_modules` sits inside it and is ignored, and the toolchain
307
+ // component describes the ROOT install only: without this, a lib
308
+ // whose own dependencies moved replayed a green measured against
309
+ // the previous ones, and no path in the key could say so.
310
+ ...hidden.sort().map((p) => (p.split('/').at(-1) === 'node_modules' ? `excluded=${p}#${this.installOf(p)}` : `excluded=${p}`)),
289
311
  ]
290
312
  const key = createHash('sha256').update(components.join('\n')).digest('hex').slice(0, 32)
291
313
  return { ...base, key, components, inputOids }
292
314
  }
293
315
 
316
+ /** an excluded install's identity, once per layer: several units can name
317
+ * the same one, and hashing a `node_modules` twice in a run is pure cost. */
318
+ private installOf(rel: string): string {
319
+ let id = this.installIds.get(rel)
320
+ if (id == null) { id = installIdentity(join(this.root, rel)); this.installIds.set(rel, id) }
321
+ return id
322
+ }
323
+
294
324
  keys(): UnitKey[] {
295
325
  return this.decls.map((d) => this.keyFor(d.id))
296
326
  }
297
327
 
328
+ /** which units READ one of these paths. Answers the only question a
329
+ * post-run re-hash has: whose result now describes a tree that no longer
330
+ * exists. A unit declaring the whole work tree reads everything, so any
331
+ * drift at all touches it. */
332
+ unitsTouchedBy(drifted: string[]): Set<string> {
333
+ const hit = new Set<string>()
334
+ if (drifted.length === 0) return hit
335
+ for (const d of this.decls) {
336
+ // the unit's own source counts exactly like a declared input: an
337
+ // agent fixing a check WHILE it runs must not store the old
338
+ // verdict under the new code's key
339
+ if ((this.closureByUnit.get(d.id) ?? []).some((f) => drifted.includes(f))) { hit.add(d.id); continue }
340
+ for (const raw of d.inputs ?? []) {
341
+ let p: string
342
+ try { p = normalizeInput(raw) } catch { hit.add(d.id); break }
343
+ if (p === WHOLE_TREE || drifted.some((x) => x === p || x.startsWith(`${p}/`))) { hit.add(d.id); break }
344
+ }
345
+ }
346
+ return hit
347
+ }
348
+
298
349
  /**
299
350
  * POST RUN RE HASH. Re-scan exactly what this layer keyed on and report
300
351
  * every path whose object id moved while the units were running.
@@ -29,7 +29,7 @@
29
29
  // `git add` on one index file, 9 fail with `Unable to create …lock`. Three
30
30
  // agents in one checkout is the normal case here, so lock contention is
31
31
  // retried and only a NON lock failure is fatal.
32
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
32
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
33
33
  import { isAbsolute, join, normalize } from 'path'
34
34
 
35
35
  /** what a scan measured. Every OID is a git object id of the CURRENT disk
@@ -135,12 +135,25 @@ export function indexPaths(root: string): { scratch: string; ignored: string; re
135
135
  if (/^scan-\d+\.idx(\.tmp)?$/.test(f) && statSync(join(dir, f)).mtimeMs < cutoff) rmSync(join(dir, f), { force: true })
136
136
  } catch {}
137
137
  // tmp + rename: a half-copied index is a corrupt index, and git's error
138
- // for that is not the one the retry loop is meant to absorb
138
+ // for that is not the one the retry loop is meant to absorb.
139
+ //
140
+ // THE COPY KEEPS THE ORIGINAL'S MTIME, and that one line is the whole of a
141
+ // reproduced false green. Git decides an entry is "racily clean" by
142
+ // comparing the entry's cached mtime against the INDEX FILE's own mtime,
143
+ // and re-reads content whenever it cannot tell them apart. macOS git
144
+ // records mtimes at one-second granularity, so a file rewritten to the
145
+ // same SIZE inside the second it was staged is invisible to the stat cache
146
+ // and only that racy rule catches it. A fresh copy has mtime NOW, which
147
+ // makes every entry look comfortably older than the index and switches the
148
+ // rule off. Measured: 1 miss in 30 on a file edited between two scans, and
149
+ // a miss here is a replay of an answer about content nobody has any more.
139
150
  try {
140
151
  rmSync(scratch, { force: true })
141
152
  if (existsSync(real)) {
153
+ const st = statSync(real)
142
154
  const tmp = `${scratch}.tmp`
143
155
  copyFileSync(real, tmp)
156
+ utimesSync(tmp, st.atime, st.mtime)
144
157
  renameSync(tmp, scratch)
145
158
  }
146
159
  } catch {
@@ -0,0 +1,202 @@
1
+ // THE LEDGER — the CONTENT half of the three populations: a unit's answer,
2
+ // stored under the identity of the question it answered, replayed only when
3
+ // that identity is bit-for-bit the same question again.
4
+ //
5
+ // ledger/git-key.ts owns the identity, ledger/store.ts owns the file. This
6
+ // module owns the POLICY, and the policy is where a cache turns into a false
7
+ // green, so every rule below exists because a specific failure was reproduced
8
+ // during the design pass:
9
+ //
10
+ // 1. DEFAULT DENY. A unit that declared nothing is never replayed and never
11
+ // stored. Forgetting to declare costs speed, never correctness, which is
12
+ // what makes the burn-down a to-do rather than a hazard.
13
+ // 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
14
+ // only after the inputs are hashed a second time. A unit whose inputs
15
+ // moved between its run and that second hash answered about a tree that no
16
+ // longer exists, so its result is discarded rather than stored under a key
17
+ // that describes the file as it was BEFORE the edit. Three agents in one
18
+ // checkout is the normal case here, so that window is not theoretical.
19
+ // 3. THE GATE BINARY IS IN THE KEY. Editing shipkit moves every key in every
20
+ // repo (ledger/toolchain-identity.ts content-hashes the cli tree).
21
+ // 4. RELEASE NEVER READS IT. `bump` and `release` pass read:false, not
22
+ // configurably. Every remaining hole is then capped at "an agent saw a
23
+ // stale local green", never "a stale green authorised a publish".
24
+ // 5. NO SILENT REPLAY. Every replayed line is stamped with the word
25
+ // `replayed` and what the measured run cost, and the run prints hit, miss
26
+ // and uncacheable counts. A cached green that cannot be told from a
27
+ // measured green is the bug this whole design exists to not ship.
28
+ // 6. ONLY A GREEN REPLAYS. A red is the one outcome an agent acts on
29
+ // immediately, and acting on it changes content, which moves the key — so
30
+ // replaying a red buys almost nothing, while an undeclared flake would
31
+ // pin its red until something unrelated happened to change. The safe
32
+ // direction here is re-measuring, and it costs only the run that was
33
+ // already going red.
34
+ import type { RvtConfig } from '../ts/config.ts'
35
+ import type { Line } from '../core/wire.ts'
36
+ import { KeyLayer, type UnitDecl, type UnitKey } from './git-key.ts'
37
+ import { entryPath, FRESH_ENV, KEEP_MS, readStored, storeRoot, sweepStore, writeStored } from './store.ts'
38
+
39
+ /** the env var that turns the READ path on or off for a whole session.
40
+ * Writing is unconditional either way: an entry nobody reads breaks nothing. */
41
+ export const LEDGER_ENV = 'SHIPKIT_LEDGER'
42
+
43
+ /** where unit results live inside the one store */
44
+ const NS = 'units'
45
+
46
+ /** whether the read path is on by default, before any flag.
47
+ *
48
+ * It shipped OFF and ran that way while two other efforts gated in this
49
+ * checkout, because a bug here would have handed one of them a green nobody
50
+ * measured. It went ON once eleven scenarios pinned the failure modes, a race
51
+ * of five real gates on one checkout agreed on one verdict, and the dark run
52
+ * had turned up and fixed a false green in the key layer that predates it.
53
+ * `SHIPKIT_LEDGER=0` turns it off for a session, and this line is the revert. */
54
+ export const READ_DEFAULT = true
55
+
56
+ export type UnitOutcome = {
57
+ /** only a green is ever stored (rule 6 above) */
58
+ status: 'pass' | 'warn'
59
+ lines: Line[]
60
+ /** the task-runner headline, which the gate greps for the test count */
61
+ lastLine?: string
62
+ /** what the measured run cost, ms — printed on the replay so a hit says
63
+ * what it saved instead of pretending the work was free */
64
+ measuredMs: number
65
+ }
66
+
67
+ export type LedgerOpts = {
68
+ /** the cli entry driving this run (toolchain identity needs it) */
69
+ cliEntry: string
70
+ /** read the store at all. false = every unit is measured this run. */
71
+ read?: boolean
72
+ /** this run re-measures everything, and re-stores what it measured */
73
+ fresh?: boolean
74
+ /** module-graph config, loaded from the repo when omitted */
75
+ cfg?: RvtConfig
76
+ now?: () => number
77
+ }
78
+
79
+ export type CommitReport = {
80
+ /** unit ids written to the store */
81
+ stored: string[]
82
+ /** unit ids whose result was thrown away because an input moved */
83
+ discarded: string[]
84
+ /** the paths that moved */
85
+ drifted: string[]
86
+ /** the re-hash itself failed; nothing was stored */
87
+ error: string | null
88
+ ms: number
89
+ }
90
+
91
+ /** is the read path on for this process? A run may still turn it off. */
92
+ export function ledgerReadEnabled(): boolean {
93
+ const v = process.env[LEDGER_ENV]
94
+ if (v === '1' || v === 'true') return true
95
+ if (v === '0' || v === 'false') return false
96
+ return READ_DEFAULT
97
+ }
98
+
99
+ export class Ledger {
100
+ private readonly buffered = new Map<string, UnitOutcome>()
101
+ private readonly keys = new Map<string, UnitKey>()
102
+ private hits = 0
103
+ private misses = 0
104
+ private unkeyed = 0
105
+
106
+ private constructor(
107
+ readonly layer: KeyLayer,
108
+ private readonly store: string,
109
+ /** replays are allowed */
110
+ readonly reading: boolean,
111
+ private readonly now: () => number,
112
+ ) {}
113
+
114
+ static open(root: string, decls: UnitDecl[], o: LedgerOpts): Ledger {
115
+ const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
116
+ const fresh = o.fresh === true || process.env[FRESH_ENV] != null
117
+ const reading = (o.read ?? ledgerReadEnabled()) && !fresh
118
+ return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
119
+ }
120
+
121
+ /** the key for a unit, computed once per run and remembered: `keyFor`
122
+ * re-derives the component list every call, and the gate asks twice (once
123
+ * to replay, once to record). */
124
+ private key(id: string): UnitKey {
125
+ let k = this.keys.get(id)
126
+ if (k == null) { k = this.layer.keyFor(id); this.keys.set(id, k) }
127
+ return k
128
+ }
129
+
130
+ private path(k: UnitKey): string {
131
+ return entryPath(this.store, NS, `${k.kind}:${k.id}`, k.key!)
132
+ }
133
+
134
+ /** the stored answer for this unit, or null when it must run. Counts what
135
+ * it was asked, so the run can report hit/miss/uncacheable honestly. */
136
+ replay(id: string): UnitOutcome | null {
137
+ const k = this.key(id)
138
+ if (k.key == null) { this.unkeyed++; return null }
139
+ if (!this.reading) { this.misses++; return null }
140
+ const e = readStored<UnitOutcome>(this.path(k), k.key)
141
+ if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
142
+ this.hits++
143
+ return e.payload
144
+ }
145
+
146
+ /** buffer what a unit just measured. Nothing reaches the store until
147
+ * commit() has re-hashed the inputs (rule 2). */
148
+ record(id: string, outcome: UnitOutcome): void {
149
+ const k = this.key(id)
150
+ if (k.key == null) return
151
+ this.buffered.set(id, outcome)
152
+ }
153
+
154
+ /** re-hash, then write what still describes the tree on disk. */
155
+ commit(): CommitReport {
156
+ const empty: CommitReport = { stored: [], discarded: [], drifted: [], error: null, ms: 0 }
157
+ if (this.buffered.size === 0) return empty
158
+ const started = performance.now()
159
+ const d = this.layer.rescan()
160
+ if (d.error != null) return { ...empty, discarded: [...this.buffered.keys()].sort(), error: d.error, ms: performance.now() - started }
161
+ const touched = this.layer.unitsTouchedBy(d.drifted)
162
+ const stored: string[] = []
163
+ const discarded: string[] = []
164
+ for (const [id, outcome] of this.buffered) {
165
+ if (touched.has(id)) { discarded.push(id); continue }
166
+ const k = this.key(id)
167
+ writeStored(this.path(k), k.key!, outcome, this.now())
168
+ stored.push(id)
169
+ }
170
+ sweepStore(this.store, NS, this.now(), KEEP_MS)
171
+ return { stored: stored.sort(), discarded: discarded.sort(), drifted: d.drifted, error: null, ms: performance.now() - started }
172
+ }
173
+
174
+ get counts(): { hit: number; miss: number; uncacheable: number } {
175
+ return { hit: this.hits, miss: this.misses, uncacheable: this.unkeyed }
176
+ }
177
+
178
+ /** the one line a run prints about its own cache. Never silent: a run that
179
+ * replayed nothing says so, so a green is always attributable. */
180
+ header(commit: CommitReport): Line[] {
181
+ const c = this.counts
182
+ const asked = c.hit + c.miss + c.uncacheable
183
+ if (asked === 0) return []
184
+ const out: Line[] = []
185
+ // `skip:` and not a bare note: the ci wire prefixes are a frozen
186
+ // contract every agent and every parser reads, and these lines are
187
+ // exactly what the run did NOT measure
188
+ const mode = this.reading ? '' : this.layer.failure != null ? ', reads off because the git scan failed' : ', reads off'
189
+ out.push({ kind: 'skip', text: `ledger — ${c.hit} replayed, ${c.miss} measured, ${c.uncacheable} uncacheable${mode}` })
190
+ if (commit.error != null) out.push({ kind: 'warn', text: `ledger stored nothing this run — the post-run re-hash failed: ${commit.error}` })
191
+ else if (commit.discarded.length > 0)
192
+ out.push({ kind: 'skip', text: `ledger stored nothing for ${commit.discarded.length} unit(s) — ${commit.drifted.slice(0, 3).join(', ')}${commit.drifted.length > 3 ? ` +${commit.drifted.length - 3} more` : ''} moved while the gate ran` })
193
+ return out
194
+ }
195
+ }
196
+
197
+ /** stamp a replayed unit's lines. The FIRST line carries the word, because
198
+ * that is the line an agent reads, and a replay that renders identically to a
199
+ * measurement is the one thing this design refuses to ship. */
200
+ export function stampReplayed(lines: Line[], measuredMs: number): Line[] {
201
+ return lines.map((l, i) => (i === 0 ? { ...l, text: `${l.text} (replayed, ${(measuredMs / 1000).toFixed(1)}s saved)` } : l))
202
+ }