rvlib-shipkit 0.34.0 → 0.35.1

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.
@@ -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 = {
@@ -44,12 +50,26 @@ export type CheckOpts = {
44
50
  * spends a third-party API call per publish. Undeclared checks are still
45
51
  * collected, because an unclassified check stays fatal. */
46
52
  onlyBlocking?: boolean
53
+ /** the caller is the GATE (`shipkit ci`), so checks that declared
54
+ * `export const gate = false` are skipped. `shipkit check` leaves it unset
55
+ * and runs everything: the health surface is where a human todo list
56
+ * belongs, and the gate is not. */
57
+ gateOnly?: boolean
47
58
  /** the caller narrowed the run to these ids (`ci --only`). Empty or absent
48
59
  * means everything. A `--only` that narrowed TASKS while every check still
49
60
  * ran made the flag actively harmful: `ci --only cycles`, a 0.03s task,
50
61
  * probed every third-party service the repo knows about. One flag, one
51
62
  * meaning, across all three surfaces. */
52
63
  only?: string[]
64
+ /** the run's ledger, when the caller already opened one (runCi does). A
65
+ * standalone `shipkit check` opens its own. */
66
+ ledger?: Ledger | null
67
+ /** refresh the motherhome registry row. runCi passes false and does it
68
+ * ITSELF, after the ledger has committed: that row is a TRACKED file under
69
+ * `wiki/`, so writing it before the post-run re-hash makes the gate look
70
+ * like an agent editing the tree and throws away every result the run just
71
+ * measured. Two reports per gate run was duplication anyway. */
72
+ reportHome?: boolean
53
73
  }
54
74
 
55
75
  /** does this rule or check answer to one of the `--only` ids? Matched against
@@ -83,7 +103,61 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
83
103
  const prevFrozen = process.env.SHIPKIT_FROZEN
84
104
  if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
85
105
  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 })
106
+ // the caller's ledger, or this run's own. `shipkit check` is a full run
107
+ // of every rule and check, so it deserves the same replay the gate gets.
108
+ let ledger: Ledger | null = o.ledger ?? null
109
+ if (ledger === null && o.ledger === undefined) {
110
+ try {
111
+ ledger = Ledger.open(root, gateUnits(root, c), { cliEntry: new URL('../cli.ts', import.meta.url).pathname, fresh: o.fresh })
112
+ } catch { ledger = null }
113
+ }
114
+ const replayed = new Set<string>()
115
+ const unitIdByName = new Map<string, string>()
116
+ /** what a TTL'd answer is remembered under, beside its rotation key. A TTL
117
+ * says how long the WORLD keeps; it cannot say that the probe was
118
+ * rewritten, or that a different shipkit measured it. Without these two a
119
+ * fixed check replayed its old verdict for the rest of its TTL. */
120
+ const worldKey = (unitId: string, rotation?: string): string => {
121
+ let code = ''
122
+ try { code = ledger?.layer.keyFor(unitId).codeKey ?? '' } catch { code = '' }
123
+ return `tc=${ledger?.layer.toolchainId ?? ''}\0code=${code}\0${rotation ?? ''}`
124
+ }
125
+ const suite = new CheckSuite({
126
+ name: c.name,
127
+ storeDir: storeRoot(root),
128
+ onResult: (name, r) => {
129
+ o.onResult?.(name, r)
130
+ // a replay measured nothing, and a red is never stored. A check
131
+ // that cannot block had its FAIL rewritten to a warn before this
132
+ // fires, so the demotion list is what says it failed: a content
133
+ // key has no TTL, so storing that red would pin it until an
134
+ // unrelated edit moved the tree, and "the box is off" is fixed by
135
+ // turning the box on, which changes no file.
136
+ if (ledger == null || r.cached || replayed.has(name) || r.status === 'skip' || r.status === 'fail') return
137
+ if (advisoryFailures.includes(name)) return
138
+ const id = unitIdByName.get(name)
139
+ if (id == null) return
140
+ ledger.record(id, { status: r.status, lines: r.lines, measuredMs: r.ms })
141
+ },
142
+ })
143
+ /** a stored answer, re-emitted as a runner that does no work. Keeping it in
144
+ * the suite rather than splicing lines afterwards is what preserves
145
+ * declaration order, which the wire contract is frozen on. */
146
+ const addReplay = (unitId: string, name: string, blocking: boolean): boolean => {
147
+ const hit = ledger?.replay(unitId)
148
+ if (hit == null) return false
149
+ replayed.add(name)
150
+ unitIdByName.set(name, unitId)
151
+ suite.addCheck({
152
+ blocking,
153
+ name,
154
+ logic: (t) => {
155
+ for (const l of stampReplayed(hit.lines, hit.measuredMs)) t.lines.push(l)
156
+ if (t.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn')) t.warned = true
157
+ },
158
+ })
159
+ return true
160
+ }
87
161
  // built-in SK rules (rules/rule-registry.ts) THEN the repo's own
88
162
  // (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
89
163
  // (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
@@ -104,11 +178,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
104
178
  for (const rule of [...RULES, ...(c.rules ?? [])]) {
105
179
  if (!matchesOnly(o.only, rule.id, rule.title)) continue
106
180
  if (o.onlyBlocking === true && !rule.blocking) continue
181
+ unitIdByName.set(rule.title, rule.id)
182
+ if (addReplay(rule.id, rule.title, rule.blocking)) continue
107
183
  suite.addCheck({
108
184
  blocking: rule.blocking,
109
185
  name: rule.title,
110
186
  remainValidFor: rule.ttl,
111
- cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
187
+ cacheExtra: () => worldKey(rule.id, rule.cacheKey?.({ root, config: c })),
112
188
  logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
113
189
  })
114
190
  }
@@ -123,22 +199,33 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
123
199
  const childEnv: Record<string, string | undefined> = {
124
200
  ...process.env,
125
201
  [SHARE_ENV]: sharePath,
126
- ...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
202
+ ...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}),
127
203
  }
128
204
  for (const d of discoverChecks(root)) {
129
205
  const rel = relative(root, d.path)
130
206
  // file wins, then the repo default, then nothing: an unclassified
131
207
  // check is an error, never a guess
132
208
  if (!matchesOnly(o.only, d.name, rel)) continue
209
+ // `ci` answers "is my change good". A check that answers something else,
210
+ // and that can sit red for months with nothing wrong, is not part of
211
+ // that answer at any interval. `shipkit check` still runs it, and
212
+ // naming it in `--only` still runs it, so opting out is never a way to
213
+ // make a check unreachable.
214
+ if (o.gateOnly === true && !d.gate && o.only == null) continue
133
215
  const blocking = d.blocking ?? c.checkDefaults?.blocking
134
216
  if (blocking == null) undeclared.push(rel)
135
217
  if (o.onlyBlocking === true && blocking !== true) continue
218
+ unitIdByName.set(d.name, d.name)
219
+ if (addReplay(d.name, d.name, blocking ?? true)) continue
136
220
  suite.addCheck({
137
221
  // a check about the WORLD may remember a measured negative; one
138
222
  // about this repo never does (check-suite.ts, the rule)
139
223
  blocking: blocking ?? true,
140
224
  name: d.name,
141
- remainValidFor: d.selfCached ? undefined : DISCOVERED_TTL,
225
+ // a WORLD check says how long its answer keeps; everything else is
226
+ // measured every run rather than blanket-cached by this layer
227
+ remainValidFor: d.ttl ?? undefined,
228
+ cacheExtra: () => worldKey(d.name),
142
229
  logic: async (t) => {
143
230
  const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
144
231
  for (const l of parseWireOutput(r.out)) t.lines.push(l)
@@ -159,7 +246,11 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
159
246
  if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
160
247
  // frozen = a release-shaped run: reporting would re-dirty the tree the
161
248
  // caller is about to demand clean (field wart 2026-08-05, second head)
162
- if (o.frozen !== true) reportToMotherhome(root, c)
249
+ if (o.frozen !== true && o.reportHome !== false) reportToMotherhome(root, c)
250
+ // the caller that OPENED the ledger owns committing it: runCi commits once
251
+ // after its tasks and its checks, so a task and a check in one run share
252
+ // one post-run re-hash
253
+ if (o.ledger === undefined && ledger != null) r.lines.push(...ledger.header(ledger.commit()))
163
254
  return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
164
255
  }
165
256
 
@@ -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, gateOnly: true })
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 {