rvlib-shipkit 0.33.0 → 0.34.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 +19 -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_Keys.ts +107 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +95 -33
- package/src/core/discover-checks.ts +30 -1
- package/src/core/floor.ts +21 -2
- package/src/core/locked-write.ts +170 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-ci.ts +25 -1
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +55 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +58 -0
- package/src/ledger/git-key.ts +330 -0
- package/src/ledger/git-scan.ts +314 -0
- package/src/ledger/toolchain-identity.ts +15 -8
- package/src/release/run-bump.ts +50 -5
- package/src/release/unit-gate.ts +17 -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 +11 -2
- 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/locked-write.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// whole read-modify-write, the write lands via tmp + rename so a killed
|
|
4
4
|
// process can never leave a torn file, and the loser of a race SKIPS —
|
|
5
5
|
// never corrupts, never blocks.
|
|
6
|
-
import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
6
|
+
import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs'
|
|
7
7
|
import { basename, dirname, join } from 'path'
|
|
8
8
|
|
|
9
9
|
// a writer that died without unlocking must not wedge the file forever
|
|
@@ -37,3 +37,172 @@ export function atomicWrite(path: string, content: string): void {
|
|
|
37
37
|
writeFileSync(tmpPath, content)
|
|
38
38
|
renameSync(tmpPath, path)
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// the MUTATE LEASE — the second concurrency shape, and the only other one.
|
|
43
|
+
//
|
|
44
|
+
// withFileLock guards ONE file and the loser SKIPS: right for a render that
|
|
45
|
+
// re-renders next run. The lease guards a PHASE that rewrites the worktree
|
|
46
|
+
// (the fix pass, SK005's `bun update --latest`, hooks arming, the asks
|
|
47
|
+
// archive), where skipping means the repair never happens and overlapping
|
|
48
|
+
// means two processes rewrite the same file at once. So the loser WAITS.
|
|
49
|
+
//
|
|
50
|
+
// TWO PRIMITIVES, both atomic, and both were measured before they were
|
|
51
|
+
// trusted. `writeFileSync(..., 'wx')` creates the NAME first and writes the
|
|
52
|
+
// body second, so a racer that reads in between sees an empty file, calls the
|
|
53
|
+
// lock corrupt, and steals a lease whose holder is very much alive: 5 of 15
|
|
54
|
+
// synchronized starts overlapped. So the body is written to a private name and
|
|
55
|
+
// `linkSync`d into place, one step that fails when the name exists and never
|
|
56
|
+
// exposes a half lock. And a steal of `read, rmSync, grab` is three steps, so
|
|
57
|
+
// every racer wins it: 31 of 40 synchronized starts overlapped after a crash.
|
|
58
|
+
// So a steal is a single `renameSync` of the lock out of the way, which
|
|
59
|
+
// exactly one racer can perform; everyone else gets ENOENT and goes back to
|
|
60
|
+
// grabbing.
|
|
61
|
+
//
|
|
62
|
+
// Bounded in every direction, because a gate that hangs is worse than a gate
|
|
63
|
+
// that overlaps. A holder is stale when its pid is not running OR when the
|
|
64
|
+
// lock has not been touched for LOCK_STALE_MS, so a pid reused by a long
|
|
65
|
+
// lived process cannot wedge a repo forever. A holder that is genuinely still
|
|
66
|
+
// working HEARTBEATS the lock while it holds it, so the age rule only ever
|
|
67
|
+
// fires on a run that is gone or wedged. A waiter gives up after maxWaitMs and
|
|
68
|
+
// says so by name, path and escape hatch. Every line it prints carries a
|
|
69
|
+
// FROZEN wire prefix (wire.ts), so a lease that waits or steals inside
|
|
70
|
+
// `shipkit ci` cannot break that contract.
|
|
71
|
+
|
|
72
|
+
const LEASE_POLL_MS = 100
|
|
73
|
+
const LEASE_ANNOUNCE_MS = 1_000
|
|
74
|
+
const LEASE_REANNOUNCE_MS = 15_000
|
|
75
|
+
const LEASE_HEARTBEAT_MS = 20_000
|
|
76
|
+
/** shorter than the shortest caller timeout (a discovered check is killed at
|
|
77
|
+
* 120s), so a waiter always gets to PRINT its refusal rather than being
|
|
78
|
+
* SIGTERMed mid wait with no cause on screen */
|
|
79
|
+
const LEASE_MAX_WAIT_MS = 60_000
|
|
80
|
+
|
|
81
|
+
type Holder = { pid: number; at: number; label: string }
|
|
82
|
+
|
|
83
|
+
let leaseSeq = 0
|
|
84
|
+
|
|
85
|
+
const readHolder = (lockPath: string): Holder | null => {
|
|
86
|
+
try {
|
|
87
|
+
const h = JSON.parse(readFileSync(lockPath, 'utf8')) as Partial<Holder>
|
|
88
|
+
return typeof h.pid === 'number' ? { pid: h.pid, at: typeof h.at === 'number' ? h.at : 0, label: String(h.label ?? '?') } : null
|
|
89
|
+
} catch { return null }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** is that pid still running? signal 0 asks without delivering anything.
|
|
93
|
+
* EPERM means alive and owned by another user, which still counts as alive. */
|
|
94
|
+
const alive = (pid: number): boolean => {
|
|
95
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
96
|
+
try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** publish a COMPLETE lock under a name that did not exist a moment ago */
|
|
100
|
+
const grabLease = (lockPath: string, label: string): boolean => {
|
|
101
|
+
const tmp = `${lockPath}.tmp-${process.pid}-${leaseSeq++}`
|
|
102
|
+
try {
|
|
103
|
+
writeFileSync(tmp, JSON.stringify({ pid: process.pid, at: Date.now(), label } satisfies Holder))
|
|
104
|
+
linkSync(tmp, lockPath)
|
|
105
|
+
return true
|
|
106
|
+
} catch { return false }
|
|
107
|
+
finally { rmSync(tmp, { force: true }) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sameHolder = (a: Holder | null, b: Holder | null): boolean =>
|
|
111
|
+
a == null ? b == null : b != null && a.pid === b.pid && a.at === b.at
|
|
112
|
+
|
|
113
|
+
/** move the lock aside, and ONLY the lock we judged dead. Exactly one racer
|
|
114
|
+
* can win the rename; the rest see ENOENT and go back to grabbing.
|
|
115
|
+
*
|
|
116
|
+
* The identity check on both sides is the whole point, and it is a measured
|
|
117
|
+
* bug, not a precaution. Reading the holder and stat'ing the file are two
|
|
118
|
+
* syscalls, so a racer that read the DEAD holder, then lost the steal, then
|
|
119
|
+
* stat'ed the WINNER's fresh lock, judged a live holder dead and threw its
|
|
120
|
+
* lease away: 1 in 40 four-process starts against a stale lock. So the holder
|
|
121
|
+
* is re-read immediately before the rename, and the moved file is checked
|
|
122
|
+
* after it. A lock that turns out to belong to somebody else goes straight
|
|
123
|
+
* back and this round is lost. */
|
|
124
|
+
const stealLease = (lockPath: string, expect: Holder | null): boolean => {
|
|
125
|
+
if (!sameHolder(readHolder(lockPath), expect)) return false
|
|
126
|
+
const grave = `${lockPath}.dead-${process.pid}-${leaseSeq++}`
|
|
127
|
+
try { renameSync(lockPath, grave) } catch { return false }
|
|
128
|
+
if (sameHolder(readHolder(grave), expect)) { rmSync(grave, { force: true }); return true }
|
|
129
|
+
// it was not the lock we judged: put it back before the name can be taken
|
|
130
|
+
try { linkSync(grave, lockPath) } catch { /* taken in the gap: the new holder owns it now */ }
|
|
131
|
+
rmSync(grave, { force: true })
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** drop the lock, but only while it is still OURS: a lease that was stolen
|
|
136
|
+
* belongs to whoever stole it, and deleting it would hand a third process a
|
|
137
|
+
* lease two others believe they hold */
|
|
138
|
+
const releaseLease = (lockPath: string): void => {
|
|
139
|
+
const h = readHolder(lockPath)
|
|
140
|
+
if (h == null || h.pid === process.pid) rmSync(lockPath, { force: true })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type LeaseOutcome<T> = { ok: true; value: T; waitedMs: number } | { ok: false; warn: string }
|
|
144
|
+
|
|
145
|
+
/** hold `<path>.lock` for the duration of fn. Blocking, stolen from a holder
|
|
146
|
+
* that is gone or wedged, refused (never hung) past maxWaitMs. */
|
|
147
|
+
export async function withMutateLease<T>(
|
|
148
|
+
path: string,
|
|
149
|
+
o: { label: string; print?: (line: string) => void; maxWaitMs?: number; escape?: string },
|
|
150
|
+
fn: () => T | Promise<T>,
|
|
151
|
+
): Promise<LeaseOutcome<T>> {
|
|
152
|
+
const lockPath = `${path}.lock`
|
|
153
|
+
const print = o.print ?? ((l: string) => console.log(l))
|
|
154
|
+
const maxWait = o.maxWaitMs ?? LEASE_MAX_WAIT_MS
|
|
155
|
+
const start = Date.now()
|
|
156
|
+
// the first announcement is due at LEASE_ANNOUNCE_MS, every later one a
|
|
157
|
+
// re-announce interval after it
|
|
158
|
+
let announced = -LEASE_REANNOUNCE_MS
|
|
159
|
+
mkdirSync(dirname(lockPath), { recursive: true })
|
|
160
|
+
|
|
161
|
+
for (;;) {
|
|
162
|
+
if (grabLease(lockPath, o.label)) break
|
|
163
|
+
const holder = readHolder(lockPath)
|
|
164
|
+
let ageMs = 0
|
|
165
|
+
// the lock vanished between grab and stat: the holder just finished,
|
|
166
|
+
// so go straight back and take it
|
|
167
|
+
try { ageMs = Date.now() - statSync(lockPath).mtimeMs } catch { continue }
|
|
168
|
+
// gone (no such process), or wedged (nothing has touched it for the
|
|
169
|
+
// stale window, and a live holder heartbeats), or a body no reader can
|
|
170
|
+
// make sense of that is old enough not to be a write in flight
|
|
171
|
+
const dead = holder == null ? ageMs > 2_000 : !alive(holder.pid) || ageMs > LOCK_STALE_MS
|
|
172
|
+
if (dead) {
|
|
173
|
+
if (stealLease(lockPath, holder)) print(`ok: stole the ${o.label} lease from a holder that is gone (pid ${holder?.pid ?? '?'}, untouched ${Math.round(ageMs / 1000)}s) — a run died mid phase`)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
const waited = Date.now() - start
|
|
177
|
+
if (waited > maxWait)
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
warn: `${o.label} lease held by ${holder == null ? 'an unreadable lock' : `pid ${holder.pid} (${holder.label})`} for ${Math.round(ageMs / 1000)}s — gave up after ${Math.round(waited / 1000)}s rather than run two of these at once. Lock: ${lockPath}${o.escape == null ? '' : `; skip this phase with ${o.escape}`}`,
|
|
181
|
+
}
|
|
182
|
+
if (waited >= LEASE_ANNOUNCE_MS && waited - announced >= LEASE_REANNOUNCE_MS) {
|
|
183
|
+
announced = waited
|
|
184
|
+
print(`ok: waiting ${Math.round(waited / 1000)}s for the ${o.label} lease — ${holder == null ? 'an unreadable lock holds it' : `pid ${holder.pid} holds it (${holder.label})`}, lock ${lockPath}`)
|
|
185
|
+
}
|
|
186
|
+
await Bun.sleep(LEASE_POLL_MS)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// a phase that legitimately outlives the stale window must keep proving it
|
|
190
|
+
// is working, or the age rule steals from it. unref'd, so a held lease
|
|
191
|
+
// never keeps a process alive on its own
|
|
192
|
+
const beat = setInterval(() => { try { utimesSync(lockPath, new Date(), new Date()) } catch { /* stolen or gone: the finally sorts it out */ } }, LEASE_HEARTBEAT_MS)
|
|
193
|
+
beat.unref()
|
|
194
|
+
try {
|
|
195
|
+
return { ok: true, value: await fn(), waitedMs: Date.now() - start }
|
|
196
|
+
} finally {
|
|
197
|
+
clearInterval(beat)
|
|
198
|
+
releaseLease(lockPath)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** where a repo's mutate leases live. Under the gitignored tmp/ dir so a lease
|
|
203
|
+
* file can never enter a commit, and per WORKTREE, because the phases it
|
|
204
|
+
* guards rewrite worktree-local files. The NAME is the concern: two phases
|
|
205
|
+
* that cannot corrupt each other take different leases, so `asks` never
|
|
206
|
+
* blocks a `fix`. `git config` is the one shared-across-worktrees write and
|
|
207
|
+
* carries its own retry (hooks/run-hooks.ts). */
|
|
208
|
+
export const mutateLease = (root: string, name: string): string => join(root, 'tmp', `shipkit-${name}`)
|
|
@@ -29,8 +29,8 @@ export type RawRunner = {
|
|
|
29
29
|
use: string
|
|
30
30
|
/** the narrower way in, when there is one: iterating on a single repro
|
|
31
31
|
* test while you fix a bug is not a verification and stays normal. A
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* FILE filter (measured): bun's positional argument matches the test FILE
|
|
33
|
+
* path, and a test NAME needs `-- -t "<name>"` forwarded through bun run */
|
|
34
34
|
iterate?: string
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -38,12 +38,12 @@ export type RawRunner = {
|
|
|
38
38
|
// carrying. Kept tight on purpose: a deny an agent hits for no good reason
|
|
39
39
|
// teaches it to route around the whole file.
|
|
40
40
|
export const RAW_RUNNERS: readonly RawRunner[] = [
|
|
41
|
-
{ head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <
|
|
42
|
-
{ head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <
|
|
43
|
-
{ head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <
|
|
44
|
-
{ head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <
|
|
45
|
-
{ head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <
|
|
46
|
-
{ head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <
|
|
41
|
+
{ head: 'bun test', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
|
|
42
|
+
{ head: 'bunx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
|
|
43
|
+
{ head: 'npx vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
|
|
44
|
+
{ head: 'vitest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
|
|
45
|
+
{ head: 'npx jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
|
|
46
|
+
{ head: 'jest', slot: 'test', use: 'shipkit ci --only test', iterate: 'bun run test:raw <test file>' },
|
|
47
47
|
{ head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
48
48
|
{ head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
49
49
|
{ head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
package/src/core/run-ci.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { rmSync } from 'fs'
|
|
|
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
23
|
|
|
22
24
|
export type CiOpts = {
|
|
23
25
|
bail?: boolean
|
|
@@ -130,6 +132,28 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
130
132
|
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
131
133
|
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
134
|
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.
|
|
140
|
+
let registrationFailed = false
|
|
141
|
+
try {
|
|
142
|
+
const reg = validateRegistration(gateUnits(root, c))
|
|
143
|
+
for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
|
|
144
|
+
if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
|
|
145
|
+
// both counts, always. Printing only the burn-down made `never:` the
|
|
146
|
+
// cheap way to shrink the visible number, and that field carries no
|
|
147
|
+
// 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.
|
|
151
|
+
const decided = reg.units - reg.burnDown.length - reg.declared
|
|
152
|
+
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)`)
|
|
154
|
+
} catch (e) {
|
|
155
|
+
sink(`WARN cache declarations unreadable (${String(e).slice(0, 160)}) — nothing is cached, so the gate is unaffected`)
|
|
156
|
+
}
|
|
133
157
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
134
158
|
// look like a full one (the fresh-repo floor gap)
|
|
135
159
|
for (const u of unfilled) {
|
|
@@ -195,7 +219,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
195
219
|
sink(' fix: shipkit check')
|
|
196
220
|
} else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
|
|
197
221
|
}
|
|
198
|
-
const code = summary.ok && !fixFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
|
|
222
|
+
const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
|
|
199
223
|
// motherhome row (docsBase wiki on this disk) — never affects the gate,
|
|
200
224
|
// and never fires under frozen: a bump/release gate must not re-dirty
|
|
201
225
|
// 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,6 +30,19 @@ 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
|
|
|
35
48
|
export type TaskResult = {
|
|
@@ -113,6 +126,43 @@ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[]
|
|
|
113
126
|
return { selected, errors }
|
|
114
127
|
}
|
|
115
128
|
|
|
129
|
+
/** THIS run's log dir. It used to be `logDir` itself, wiped with rmSync at the
|
|
130
|
+
* top of every run: a second agent starting a gate deleted the log files the
|
|
131
|
+
* first was still streaming into, so the surviving FAIL line pointed at a
|
|
132
|
+
* path that no longer existed. Now each run owns `run-<pid>-<started>/` and
|
|
133
|
+
* writes nowhere else.
|
|
134
|
+
*
|
|
135
|
+
* Old run dirs are swept on the way in, and only ones nothing can be using:
|
|
136
|
+
* the pid must not be running AND the dir must be older than the grace
|
|
137
|
+
* window, so a recycled pid cannot cost a live run its logs. */
|
|
138
|
+
function openRunDir(base: string): string {
|
|
139
|
+
// the pid plus the millisecond separates two PROCESSES; the counter
|
|
140
|
+
// separates two runTasks calls inside one (the TUI can start a second
|
|
141
|
+
// before the clock ticks), so no run ever writes into another's dir
|
|
142
|
+
const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
|
|
143
|
+
mkdirSync(dir, { recursive: true })
|
|
144
|
+
const cutoff = Date.now() - RUN_DIR_KEEP_MS
|
|
145
|
+
try {
|
|
146
|
+
for (const name of readdirSync(base)) {
|
|
147
|
+
// run dirs AND the per-run release survey files, which a killed
|
|
148
|
+
// run leaves behind: both carry the pid and the start time, so one
|
|
149
|
+
// sweep covers both rather than one path leaking forever
|
|
150
|
+
const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
|
|
151
|
+
if (m == null || name === basename(dir)) continue
|
|
152
|
+
if (Number(m[2]) > cutoff) continue
|
|
153
|
+
try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
|
|
154
|
+
rmSync(join(base, name), { recursive: true, force: true })
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// an unreadable log base is a housekeeping miss, never a gate failure
|
|
158
|
+
}
|
|
159
|
+
return dir
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** how long a finished run's logs stay readable, and the pid-reuse guard */
|
|
163
|
+
const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
|
|
164
|
+
let runSeq = 0
|
|
165
|
+
|
|
116
166
|
export async function runTasks(o: RunOpts): Promise<RunSummary> {
|
|
117
167
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
118
168
|
const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
|
|
@@ -122,13 +172,12 @@ export async function runTasks(o: RunOpts): Promise<RunSummary> {
|
|
|
122
172
|
return { ok: false, results: [], interrupted: false }
|
|
123
173
|
}
|
|
124
174
|
const selectedIds = new Set(sel.selected.map((t) => t.id))
|
|
125
|
-
|
|
126
|
-
mkdirSync(o.logDir, { recursive: true })
|
|
175
|
+
const runDir = openRunDir(o.logDir)
|
|
127
176
|
|
|
128
177
|
const tasks: Live[] = sel.selected.map((t) => ({
|
|
129
178
|
...t,
|
|
130
179
|
status: 'skipped', exitCode: null, durationMs: 0, startedAt: 0,
|
|
131
|
-
logPath: t.cmd != null ? join(
|
|
180
|
+
logPath: t.cmd != null ? join(runDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
|
|
132
181
|
lastLine: '', note: '', lines: [],
|
|
133
182
|
}))
|
|
134
183
|
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,58 @@
|
|
|
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 { discoverChecks } from '../core/discover-checks.ts'
|
|
12
|
+
import { RULES } from '../rules/rule-registry.ts'
|
|
13
|
+
import type { UnitDecl } from './git-key.ts'
|
|
14
|
+
|
|
15
|
+
/** every task, rule and discovered check, in the order the gate meets them. */
|
|
16
|
+
export function gateUnits(root: string, c: RepoConfig): UnitDecl[] {
|
|
17
|
+
const out: UnitDecl[] = []
|
|
18
|
+
for (const t of allTasks(c, root).tasks) {
|
|
19
|
+
out.push({
|
|
20
|
+
id: t.id,
|
|
21
|
+
kind: 'task',
|
|
22
|
+
inputs: t.inputs,
|
|
23
|
+
ttl: t.ttl,
|
|
24
|
+
never: t.never,
|
|
25
|
+
argv: t.cmd,
|
|
26
|
+
// the env a task SETS is part of what it runs, not something read
|
|
27
|
+
// off the ambient environment, so it rides as an identity string
|
|
28
|
+
extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
for (const r of [...RULES, ...(c.rules ?? [])]) {
|
|
32
|
+
out.push({
|
|
33
|
+
id: r.id,
|
|
34
|
+
kind: 'rule',
|
|
35
|
+
inputs: r.inputs,
|
|
36
|
+
code: r.code,
|
|
37
|
+
ttl: r.ttl,
|
|
38
|
+
never: r.never,
|
|
39
|
+
extra: r.cacheKey?.({ root, config: c }),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
for (const d of discoverChecks(root)) {
|
|
43
|
+
const rel = relative(root, d.path).replace(/\\/g, '/')
|
|
44
|
+
out.push({
|
|
45
|
+
id: d.name,
|
|
46
|
+
kind: 'check',
|
|
47
|
+
inputs: d.inputs ?? undefined,
|
|
48
|
+
ttl: d.ttl ?? undefined,
|
|
49
|
+
never: d.never ?? undefined,
|
|
50
|
+
// a check IS a file, so its own code needs no declaration: the
|
|
51
|
+
// module graph walks from here through every helper it imports,
|
|
52
|
+
// which is what makes fixing a shared helper move the check's key
|
|
53
|
+
code: [rel],
|
|
54
|
+
argv: ['bun', rel],
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|