rvlib-shipkit 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +113 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +54 -47
- package/src/core/discover-checks.ts +30 -7
- package/src/core/floor.ts +34 -6
- package/src/core/locked-write.ts +170 -1
- package/src/core/motherhome.ts +54 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +115 -17
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +62 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +84 -0
- package/src/ledger/git-key.ts +381 -0
- package/src/ledger/git-scan.ts +327 -0
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +45 -8
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-bump.ts +50 -5
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/release/unit-gate.ts +17 -4
- package/src/repo-config.ts +26 -4
- package/src/rules/SK001-package-json-shape.ts +8 -2
- package/src/rules/SK002-contracts-fulfillness.ts +4 -0
- package/src/rules/SK003-claude-md-shape.ts +7 -2
- package/src/rules/SK004-gitignore-policy.ts +12 -4
- package/src/rules/SK005-rvlib-versions.ts +28 -1
- package/src/rules/SK006-npm-publish-safety.ts +3 -0
- package/src/rules/SK008-reviewer-agent.ts +6 -2
- package/src/rules/SK009-commit-ai-tells.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +2 -0
- package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
- package/src/rules/SK012-config-barrel-import.ts +6 -2
- package/src/rules/SK013-one-way-in-policy.ts +24 -4
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/ts/config.ts +66 -6
- package/src/ts/cycles.ts +7 -92
- package/src/ts/source-graph.ts +158 -0
- package/src/ts/tsconfig-projects.ts +101 -0
- package/src/ts/typecheck-cli.ts +46 -0
package/src/core/run-ci.ts
CHANGED
|
@@ -11,13 +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 { envTier, FULL_TESTS_ENV, gateUnits, type TestTier } from '../ledger/gate-units.ts'
|
|
22
|
+
import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
|
|
21
23
|
|
|
22
24
|
export type CiOpts = {
|
|
23
25
|
bail?: boolean
|
|
@@ -59,6 +61,14 @@ export type CiOpts = {
|
|
|
59
61
|
* loads of repo.config.ts can disagree, and the release path classifies
|
|
60
62
|
* rule-vs-probe severity from the config it holds. */
|
|
61
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
|
|
62
72
|
/** which test tier the run asks the repo's suite for. 'quick' is the
|
|
63
73
|
* working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
|
|
64
74
|
* a suite reads to include the slow, real-world tiers it otherwise skips.
|
|
@@ -68,12 +78,8 @@ export type CiOpts = {
|
|
|
68
78
|
tests?: TestTier
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
export type TestTier
|
|
72
|
-
|
|
73
|
-
/** the env var a repo's own suite reads to widen itself. Named here because
|
|
74
|
-
* the gate is what sets it, and a suite that invents its own name is a tier
|
|
75
|
-
* the release path cannot reach. */
|
|
76
|
-
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'
|
|
77
83
|
|
|
78
84
|
/** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
|
|
79
85
|
* the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
|
|
@@ -116,7 +122,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
116
122
|
const all = allTasks(c, root)
|
|
117
123
|
const { disabled, unfilled } = all
|
|
118
124
|
const skipIds = new Set(o.skipTaskIds ?? [])
|
|
119
|
-
const tier: TestTier = o.tests ?? (
|
|
125
|
+
const tier: TestTier = o.tests ?? envTier()
|
|
120
126
|
const tasks = all.tasks
|
|
121
127
|
.filter((t) => !skipIds.has(t.id))
|
|
122
128
|
// the whole run carries the flag rather than one task id: a repo names
|
|
@@ -130,6 +136,38 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
130
136
|
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
131
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)`)
|
|
132
138
|
for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
|
|
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.
|
|
147
|
+
let registrationFailed = false
|
|
148
|
+
let ledger: Ledger | null = null
|
|
149
|
+
try {
|
|
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
|
|
156
|
+
for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
|
|
157
|
+
if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
|
|
158
|
+
// both counts, always. Printing only the burn-down made `never:` the
|
|
159
|
+
// cheap way to shrink the visible number, and that field carries no
|
|
160
|
+
// verification at all: a growing decided population has to be as
|
|
161
|
+
// visible as a shrinking undeclared one.
|
|
162
|
+
const decided = reg.units - reg.burnDown.length - reg.declared
|
|
163
|
+
if (reg.burnDown.length > 0 || decided > 0)
|
|
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}`)
|
|
167
|
+
} catch (e) {
|
|
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`)
|
|
170
|
+
}
|
|
133
171
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
134
172
|
// look like a full one (the fresh-repo floor gap)
|
|
135
173
|
for (const u of unfilled) {
|
|
@@ -152,22 +190,77 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
152
190
|
// paying a 4s network survey to answer it is the same bug --only was
|
|
153
191
|
// fixed for
|
|
154
192
|
const willRunChecks = o.skipChecks !== true && (o.onlyIds ?? []).length === 0
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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,
|
|
158
237
|
logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
|
|
159
238
|
cwd: root,
|
|
160
239
|
bail: o.bail ?? false,
|
|
161
240
|
skipGroups: o.skipGroups ?? [],
|
|
162
|
-
withIds
|
|
163
|
-
onlyIds
|
|
241
|
+
withIds,
|
|
242
|
+
onlyIds,
|
|
164
243
|
print: sink,
|
|
165
244
|
onTaskFinish: o.onTaskFinish,
|
|
166
245
|
})
|
|
246
|
+
if (nothingToRun) sink(`ok: all ${replayedTasks.size} task(s) asked for replayed, none measured`)
|
|
167
247
|
// what the suite ACTUALLY ran, from its own summary line. A flag that was
|
|
168
248
|
// set proves the intent; the count proves the tests
|
|
169
|
-
const
|
|
170
|
-
|
|
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])
|
|
171
264
|
.find((n) => n != null)
|
|
172
265
|
if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
|
|
173
266
|
// the checks, in the SAME command. there is no second surface: a check
|
|
@@ -178,7 +271,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
178
271
|
if (survey != null) await survey
|
|
179
272
|
const checks = o.skipChecks === true
|
|
180
273
|
? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
|
|
181
|
-
: 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 })
|
|
182
275
|
if (priorShare == null) delete process.env[SHARE_ENV]; else process.env[SHARE_ENV] = priorShare
|
|
183
276
|
for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
|
|
184
277
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
@@ -195,7 +288,12 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
195
288
|
sink(' fix: shipkit check')
|
|
196
289
|
} else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
|
|
197
290
|
}
|
|
198
|
-
|
|
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))
|
|
296
|
+
const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
|
|
199
297
|
// motherhome row (docsBase wiki on this disk) — never affects the gate,
|
|
200
298
|
// and never fires under frozen: a bump/release gate must not re-dirty
|
|
201
299
|
// the tree it just proved clean (field wart 2026-08-05, second head)
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { armHooks } from '../hooks/run-hooks.ts'
|
|
|
15
15
|
import type { RepoConfig } from '../repo-config.ts'
|
|
16
16
|
import { runFix } from '../rules/run-fix.ts'
|
|
17
17
|
import { routesToShipkit } from './canonical-scripts.ts'
|
|
18
|
+
import { mutateLease, withMutateLease } from './locked-write.ts'
|
|
18
19
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
19
20
|
|
|
20
21
|
const FIXER_TIMEOUT_MS = 120_000
|
|
@@ -72,37 +73,47 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
|
|
|
72
73
|
if (offByConfig('imports:fix')) { /* the repo said no, in the one place that governs both directions */ }
|
|
73
74
|
else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
|
|
74
75
|
else {
|
|
75
|
-
|
|
76
|
-
cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
|
|
76
|
+
const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
|
|
77
77
|
slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
|
|
78
78
|
}
|
|
79
79
|
return { slots, unfilled, disabled }
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/** worktree fingerprint: every dirty path mapped to
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
/** worktree fingerprint: every dirty path mapped to its CONTENT hash, or null
|
|
83
|
+
* outside a git repo. The fix pass diffs this before and after so the report
|
|
84
|
+
* names real files instead of asserting "fixed" and hoping.
|
|
85
85
|
*
|
|
86
|
-
* The hash is
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
86
|
+
* The hash is the whole comparison, and the porcelain STATUS is deliberately
|
|
87
|
+
* not part of it. Status alone is blind to the commonest case there is: a
|
|
88
|
+
* file the agent already edited (` M`) that a fixer then reformats stays
|
|
89
|
+
* ` M`, so a status-only compare reports "nothing to rewrite" while the tree
|
|
90
|
+
* changed under it, and in verify mode that is a silent green on unformatted
|
|
91
|
+
* code. The reverse is just as wrong with three agents in one checkout: a
|
|
92
|
+
* second agent staging or committing a file during the pass moves its status,
|
|
93
|
+
* or drops it out of porcelain entirely, with the bytes untouched. Comparing
|
|
94
|
+
* bytes attributes only what a fixer can actually have done.
|
|
95
|
+
*
|
|
96
|
+
* It watches the WHOLE tree on purpose. A narrower porcelain was tried and
|
|
97
|
+
* deleted: the only fixer whose targets are declarable is the formatter, and
|
|
98
|
+
* the same pass also runs the rule fixers, which rewrite package.json,
|
|
99
|
+
* .gitignore, CLAUDE.md and .claude/, none of them inside a formatter's
|
|
100
|
+
* scope. Narrowing to the formatter's paths therefore hid real rewrites from
|
|
101
|
+
* the verify mode, which is a silent green on a tree a fixer changed. What
|
|
102
|
+
* actually stops another agent's work being reported here is the lease plus
|
|
103
|
+
* the byte comparison, not a path list. */
|
|
92
104
|
function treeState(root: string): Map<string, string> | null {
|
|
93
105
|
const p = Bun.spawnSync(['git', 'status', '--porcelain'], { cwd: root })
|
|
94
106
|
if (p.exitCode !== 0) return null
|
|
95
107
|
const m = new Map<string, string>()
|
|
96
108
|
for (const l of p.stdout.toString().split('\n')) {
|
|
97
109
|
if (l.trim() === '') continue
|
|
98
|
-
const status = l.slice(0, 2)
|
|
99
110
|
// rename entries read `R old -> new`; the new path is what exists
|
|
100
111
|
const path = l.slice(3).trim().split(' -> ').at(-1) ?? ''
|
|
101
112
|
if (path === '') continue
|
|
102
113
|
const abs = join(root, path.replace(/^"|"$/g, ''))
|
|
103
114
|
let hash = 'absent'
|
|
104
115
|
try { if (existsSync(abs)) hash = String(Bun.hash(readFileSync(abs))) } catch { hash = 'unreadable' }
|
|
105
|
-
m.set(path,
|
|
116
|
+
m.set(path, hash)
|
|
106
117
|
}
|
|
107
118
|
return m
|
|
108
119
|
}
|
|
@@ -119,7 +130,7 @@ export type FixPassOpts = {
|
|
|
119
130
|
|
|
120
131
|
export type FixPassResult = {
|
|
121
132
|
ran: boolean
|
|
122
|
-
/** paths whose
|
|
133
|
+
/** paths whose CONTENT changed across the pass */
|
|
123
134
|
touched: string[]
|
|
124
135
|
/** verify mode only: a fixer had to rewrite something, so the commit is not clean */
|
|
125
136
|
failed: boolean
|
|
@@ -129,6 +140,30 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
129
140
|
// verify mode: same fixers, but rewriting anything means the COMMIT was
|
|
130
141
|
// not clean, and the gate says so instead of quietly repairing it
|
|
131
142
|
const verify = process.env.CI != null && process.env.CI !== ''
|
|
143
|
+
// EVERY write this function makes sits inside one lease. Three agents in
|
|
144
|
+
// one checkout is the normal case, and two formatters rewriting the same
|
|
145
|
+
// file at once, or two runs arming hooks and rewriting package.json
|
|
146
|
+
// together, is the one hazard a snapshot diff cannot even detect. The
|
|
147
|
+
// loser waits rather than skipping, because a skipped repair never
|
|
148
|
+
// happens; a live holder that outlasts the cap is REFUSED by name rather
|
|
149
|
+
// than overlapped, and the gate below still runs, unrepaired and saying so.
|
|
150
|
+
const lease = await withMutateLease(mutateLease(root, 'fix'), { label: 'fix pass', print: o.print, escape: '--no-fix' }, () => fixPassBody(root, config, o, verify))
|
|
151
|
+
if (lease.ok) return lease.value
|
|
152
|
+
// in VERIFY mode the fix pass is the whole check: reporting "not run" and
|
|
153
|
+
// exiting 0 would pass a commit nobody verified, which is the one failure
|
|
154
|
+
// this gate exists to prevent. Locally it is a WARN, because the tasks and
|
|
155
|
+
// checks below still ran and still mean what they say.
|
|
156
|
+
if (verify) {
|
|
157
|
+
o.print(`FAIL: fixers never ran — ${lease.warn}`)
|
|
158
|
+
o.print(' fix: nothing was verified. re-run once that process finishes, or pass --no-fix to state that on purpose')
|
|
159
|
+
return { ran: false, touched: [], failed: true }
|
|
160
|
+
}
|
|
161
|
+
o.print(`WARN fix pass skipped — ${lease.warn}`)
|
|
162
|
+
o.print(' fix: nothing was repaired this run; the gate below reports the tree as it stands')
|
|
163
|
+
return { ran: false, touched: [], failed: false }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function fixPassBody(root: string, config: RepoConfig, o: FixPassOpts, verify: boolean): Promise<FixPassResult> {
|
|
132
167
|
// commit gates arm themselves on the interactive gate: a repo.config with
|
|
133
168
|
// dark hooks is the half-wired state that let a whole drill session
|
|
134
169
|
// commit ungated (2026-08-04). Runs BEFORE the tree snapshot: arming is
|
|
@@ -157,10 +192,18 @@ export async function runFixPass(root: string, config: RepoConfig, o: FixPassOpt
|
|
|
157
192
|
const after = treeState(root)
|
|
158
193
|
const touched: string[] = []
|
|
159
194
|
if (before != null && after != null) {
|
|
160
|
-
for (const [path,
|
|
161
|
-
// a
|
|
162
|
-
//
|
|
163
|
-
|
|
195
|
+
for (const [path, hash] of after) if (before.get(path) !== hash) touched.push(path)
|
|
196
|
+
// a path that left porcelain either got RESTORED to its committed
|
|
197
|
+
// bytes by a fixer, which is a rewrite worth naming, or was committed
|
|
198
|
+
// by another agent mid-pass, which is not this pass's doing. Only the
|
|
199
|
+
// bytes tell the two apart, so read them.
|
|
200
|
+
for (const [path, hash] of before) {
|
|
201
|
+
if (after.has(path)) continue
|
|
202
|
+
const abs = join(root, path.replace(/^"|"$/g, ''))
|
|
203
|
+
let now = 'absent'
|
|
204
|
+
try { if (existsSync(abs)) now = String(Bun.hash(readFileSync(abs))) } catch { now = 'unreadable' }
|
|
205
|
+
if (now !== hash) touched.push(path)
|
|
206
|
+
}
|
|
164
207
|
}
|
|
165
208
|
const list = `${touched.slice(0, 8).join(' ')}${touched.length > 8 ? ` … +${touched.length - 8}` : ''}`
|
|
166
209
|
if (before == null) {
|
package/src/core/task-runner.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - a dep must PASS to unblock; a filtered-out dep counts as satisfied;
|
|
5
5
|
// a failed/skipped dep skips dependents transitively (note says why).
|
|
6
6
|
// Deps double as serialization guards (document racy edges at the def).
|
|
7
|
-
// - subprocess tasks: full log streamed to <logDir>/<id>.log, last non-empty
|
|
7
|
+
// - subprocess tasks: full log streamed to <logDir>/run-<pid>-<ts>/<id>.log, last non-empty
|
|
8
8
|
// line kept for the FAIL summary; SIGTERM at timeoutMs, SIGKILL +2s.
|
|
9
9
|
// - logic tasks run in-process against a CheckCtx: WARN never flips the
|
|
10
10
|
// exit code, FAIL does. Their lines print under the task line.
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// - sharp edge, by design: `onlyIds` on a task whose race-guard dep is
|
|
14
14
|
// optIn/skipped-group drops that dep (filtered = satisfied) — explicit
|
|
15
15
|
// filters win over guards.
|
|
16
|
-
import { createWriteStream, mkdirSync, rmSync, type WriteStream } from 'fs'
|
|
17
|
-
import { join } from 'path'
|
|
16
|
+
import { createWriteStream, mkdirSync, readdirSync, rmSync, type WriteStream } from 'fs'
|
|
17
|
+
import { basename, join } from 'path'
|
|
18
18
|
import { CheckCtx } from './check-ctx.ts'
|
|
19
19
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
20
20
|
import { formatLine, type Line } from './wire.ts'
|
|
@@ -30,8 +30,28 @@ export type TaskDef = {
|
|
|
30
30
|
/** excluded unless listed in RunOpts.withIds */
|
|
31
31
|
optIn?: boolean
|
|
32
32
|
timeoutMs?: number
|
|
33
|
+
/** CONTENT population: the repo-relative paths this task reads. Declaring
|
|
34
|
+
* them is what makes the task keyable (ledger/git-key.ts). Undeclared is
|
|
35
|
+
* the DEFAULT and means never cached, so forgetting costs speed and never
|
|
36
|
+
* correctness. Declaring both this and `ttl` is a registration failure. */
|
|
37
|
+
inputs?: string[]
|
|
38
|
+
/** WORLD population: this task reads the network, a keychain, $PATH or the
|
|
39
|
+
* clock, so nothing on disk describes its answer and a clean pass is
|
|
40
|
+
* remembered for this long (e.g. '24h'). */
|
|
41
|
+
ttl?: string
|
|
42
|
+
/** a DECIDED never-cacheable, with the reason. Says somebody looked and
|
|
43
|
+
* nothing can describe this task, which takes it off the burn-down list
|
|
44
|
+
* that undeclared units land on. */
|
|
45
|
+
never?: string
|
|
33
46
|
}
|
|
34
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
|
+
|
|
35
55
|
export type TaskResult = {
|
|
36
56
|
id: string
|
|
37
57
|
status: 'pass' | 'fail' | 'skipped'
|
|
@@ -113,6 +133,43 @@ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[]
|
|
|
113
133
|
return { selected, errors }
|
|
114
134
|
}
|
|
115
135
|
|
|
136
|
+
/** THIS run's log dir. It used to be `logDir` itself, wiped with rmSync at the
|
|
137
|
+
* top of every run: a second agent starting a gate deleted the log files the
|
|
138
|
+
* first was still streaming into, so the surviving FAIL line pointed at a
|
|
139
|
+
* path that no longer existed. Now each run owns `run-<pid>-<started>/` and
|
|
140
|
+
* writes nowhere else.
|
|
141
|
+
*
|
|
142
|
+
* Old run dirs are swept on the way in, and only ones nothing can be using:
|
|
143
|
+
* the pid must not be running AND the dir must be older than the grace
|
|
144
|
+
* window, so a recycled pid cannot cost a live run its logs. */
|
|
145
|
+
function openRunDir(base: string): string {
|
|
146
|
+
// the pid plus the millisecond separates two PROCESSES; the counter
|
|
147
|
+
// separates two runTasks calls inside one (the TUI can start a second
|
|
148
|
+
// before the clock ticks), so no run ever writes into another's dir
|
|
149
|
+
const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
|
|
150
|
+
mkdirSync(dir, { recursive: true })
|
|
151
|
+
const cutoff = Date.now() - RUN_DIR_KEEP_MS
|
|
152
|
+
try {
|
|
153
|
+
for (const name of readdirSync(base)) {
|
|
154
|
+
// run dirs AND the per-run release survey files, which a killed
|
|
155
|
+
// run leaves behind: both carry the pid and the start time, so one
|
|
156
|
+
// sweep covers both rather than one path leaking forever
|
|
157
|
+
const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
|
|
158
|
+
if (m == null || name === basename(dir)) continue
|
|
159
|
+
if (Number(m[2]) > cutoff) continue
|
|
160
|
+
try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
|
|
161
|
+
rmSync(join(base, name), { recursive: true, force: true })
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// an unreadable log base is a housekeeping miss, never a gate failure
|
|
165
|
+
}
|
|
166
|
+
return dir
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** how long a finished run's logs stay readable, and the pid-reuse guard */
|
|
170
|
+
const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
|
|
171
|
+
let runSeq = 0
|
|
172
|
+
|
|
116
173
|
export async function runTasks(o: RunOpts): Promise<RunSummary> {
|
|
117
174
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
118
175
|
const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
|
|
@@ -122,13 +179,12 @@ export async function runTasks(o: RunOpts): Promise<RunSummary> {
|
|
|
122
179
|
return { ok: false, results: [], interrupted: false }
|
|
123
180
|
}
|
|
124
181
|
const selectedIds = new Set(sel.selected.map((t) => t.id))
|
|
125
|
-
|
|
126
|
-
mkdirSync(o.logDir, { recursive: true })
|
|
182
|
+
const runDir = openRunDir(o.logDir)
|
|
127
183
|
|
|
128
184
|
const tasks: Live[] = sel.selected.map((t) => ({
|
|
129
185
|
...t,
|
|
130
186
|
status: 'skipped', exitCode: null, durationMs: 0, startedAt: 0,
|
|
131
|
-
logPath: t.cmd != null ? join(
|
|
187
|
+
logPath: t.cmd != null ? join(runDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
132
188
|
lastLine: '', note: '', lines: [],
|
|
133
189
|
}))
|
|
134
190
|
const byId = new Map(tasks.map((t) => [t.id, t]))
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -75,6 +75,27 @@ export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
|
|
|
75
75
|
return { state: 'armed', detail: `wrote ${r.written.join(', ') || 'nothing'}, ${r.note}` }
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** `git config` takes `.git/config.lock` for the write, and a linked worktree
|
|
79
|
+
* shares that file with its main checkout: two agents arming at the same
|
|
80
|
+
* moment, which is the normal case here, make one of them exit non zero and
|
|
81
|
+
* report "hooks will not fire" on a repo whose hooks are perfectly fine. The
|
|
82
|
+
* contention window is one file write, so a short backoff clears it. The
|
|
83
|
+
* retries are bounded and the last failure is reported with git's own words,
|
|
84
|
+
* never swallowed. */
|
|
85
|
+
function setHooksPath(root: string): { code: number; detail: string } {
|
|
86
|
+
let last = { code: -1, detail: '' }
|
|
87
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
88
|
+
if (attempt > 0) Bun.sleepSync(20 * 2 ** (attempt - 1))
|
|
89
|
+
const p = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
|
|
90
|
+
if (p.exitCode === 0) return { code: 0, detail: '' }
|
|
91
|
+
last = { code: p.exitCode, detail: p.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? '' }
|
|
92
|
+
// only lock contention is worth retrying; anything else is a real
|
|
93
|
+
// refusal and retrying it just makes the failure slower
|
|
94
|
+
if (!/could not lock|file exists|another git process/i.test(last.detail)) return last
|
|
95
|
+
}
|
|
96
|
+
return last
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
/** wire `core.hooksPath` and write the two hook bodies. Idempotent.
|
|
79
100
|
* `keywordFile` = the repo's configured leak-guard file, seeding target. */
|
|
80
101
|
export function installHooks(root: string, binInvocation: string, keywordFile?: string): { written: string[]; note: string } {
|
|
@@ -89,12 +110,12 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
|
|
|
89
110
|
const prev = existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
90
111
|
if (prev !== body) { writeFileSync(path, body, { mode: 0o755 }); written.push(`${HOOK_DIR}/${h}`) }
|
|
91
112
|
}
|
|
92
|
-
const cfg =
|
|
113
|
+
const cfg = setHooksPath(root)
|
|
93
114
|
const seeded = seedKeywordFile(root, keywordFile)
|
|
94
115
|
if (seeded != null) written.push(seeded)
|
|
95
116
|
return {
|
|
96
117
|
written,
|
|
97
|
-
note: cfg.
|
|
118
|
+
note: cfg.code === 0 ? `core.hooksPath=${HOOK_DIR}` : `WARN could not set core.hooksPath (exit ${cfg.code}) — hooks will not fire${cfg.detail === '' ? '' : `: ${cfg.detail}`}`,
|
|
98
119
|
}
|
|
99
120
|
}
|
|
100
121
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// EVERY UNIT THE GATE RUNS, with what it declared about its own cacheability.
|
|
2
|
+
//
|
|
3
|
+
// Three registries feed one list, because a key layer that can only see tasks
|
|
4
|
+
// would leave the 25 checks and the 32 rules uncached forever, and those are
|
|
5
|
+
// most of the wall time. The declaration lives with the unit (a `TaskDef`
|
|
6
|
+
// field, a `Rule` field, an `export const inputs` in a check file), never in a
|
|
7
|
+
// table over here: a table beside the registry is a second source of truth
|
|
8
|
+
// that drifts the first time somebody adds a check.
|
|
9
|
+
import { relative } from 'path'
|
|
10
|
+
import { allTasks, type RepoConfig } from '../repo-config.ts'
|
|
11
|
+
import type { TaskDef } from '../core/task-runner.ts'
|
|
12
|
+
import { discoverChecks } from '../core/discover-checks.ts'
|
|
13
|
+
import { RULES } from '../rules/rule-registry.ts'
|
|
14
|
+
import type { UnitDecl } from './git-key.ts'
|
|
15
|
+
|
|
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[] {
|
|
43
|
+
const out: UnitDecl[] = []
|
|
44
|
+
for (const t of tasks ?? allTasks(c, root).tasks) {
|
|
45
|
+
out.push({
|
|
46
|
+
id: t.id,
|
|
47
|
+
kind: 'task',
|
|
48
|
+
inputs: t.inputs,
|
|
49
|
+
ttl: t.ttl,
|
|
50
|
+
never: t.never,
|
|
51
|
+
argv: t.cmd,
|
|
52
|
+
// the env a task SETS is part of what it runs, not something read
|
|
53
|
+
// off the ambient environment, so it rides as an identity string
|
|
54
|
+
extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
for (const r of [...RULES, ...(c.rules ?? [])]) {
|
|
58
|
+
out.push({
|
|
59
|
+
id: r.id,
|
|
60
|
+
kind: 'rule',
|
|
61
|
+
inputs: r.inputs,
|
|
62
|
+
code: r.code,
|
|
63
|
+
ttl: r.ttl,
|
|
64
|
+
never: r.never,
|
|
65
|
+
extra: r.cacheKey?.({ root, config: c }),
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
for (const d of discoverChecks(root)) {
|
|
69
|
+
const rel = relative(root, d.path).replace(/\\/g, '/')
|
|
70
|
+
out.push({
|
|
71
|
+
id: d.name,
|
|
72
|
+
kind: 'check',
|
|
73
|
+
inputs: d.inputs ?? undefined,
|
|
74
|
+
ttl: d.ttl ?? undefined,
|
|
75
|
+
never: d.never ?? undefined,
|
|
76
|
+
// a check IS a file, so its own code needs no declaration: the
|
|
77
|
+
// module graph walks from here through every helper it imports,
|
|
78
|
+
// which is what makes fixing a shared helper move the check's key
|
|
79
|
+
code: [rel],
|
|
80
|
+
argv: ['bun', rel],
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|