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 +4 -0
- package/package.json +1 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +10 -4
- package/src/core/check-suite.ts +37 -92
- package/src/core/discover-checks.ts +1 -7
- package/src/core/floor.ts +13 -4
- package/src/core/motherhome.ts +54 -1
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +103 -29
- package/src/core/task-runner.ts +7 -0
- package/src/ledger/gate-units.ts +29 -3
- package/src/ledger/git-key.ts +56 -5
- package/src/ledger/git-scan.ts +15 -2
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +30 -0
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/repo-config.ts +26 -4
- package/src/rules/SK004-gitignore-policy.ts +1 -2
package/src/core/run-ci.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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 ?? (
|
|
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
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
187
|
-
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
|
|
194
|
-
|
|
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
|
package/src/core/task-runner.ts
CHANGED
|
@@ -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'
|
package/src/ledger/gate-units.ts
CHANGED
|
@@ -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
|
-
/**
|
|
16
|
-
|
|
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',
|
package/src/ledger/git-key.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
package/src/ledger/git-scan.ts
CHANGED
|
@@ -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
|
+
}
|