rvlib-shipkit 0.32.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 +27 -0
- package/package.json +4 -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/canonical-scripts.ts +3 -1
- 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 +183 -0
- package/src/core/run-ci.ts +42 -3
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +55 -6
- package/src/hooks/one-way-in-guard.ts +39 -0
- package/src/hooks/run-hooks.ts +23 -2
- package/src/init-repo.ts +19 -1
- 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 +10 -3
- 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 +156 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/claude-settings-local.json +0 -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}`)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ONE WAY IN, the harness half (build/shipkit.md). The package.json half
|
|
2
|
+
// (rule SK001) routes `bun run test` into shipkit, but `bun test` is a bun
|
|
3
|
+
// BUILTIN: no script can intercept it, so a package.json alone leaves the
|
|
4
|
+
// reflex wide open. Claude Code permission rules can close it.
|
|
5
|
+
//
|
|
6
|
+
// ONE table, two renderings, because two hand-kept lists drift:
|
|
7
|
+
// - `permissions.deny` in .claude/settings.json — declarative, unbypassable
|
|
8
|
+
// (deny beats allow at every settings level, and bypassPermissions mode
|
|
9
|
+
// does not lift it), and it survives a missing bun.
|
|
10
|
+
// - a PreToolUse hook — the only thing that can put a REASON in front of the
|
|
11
|
+
// model. A bare deny says "Permission to use Bash with command bun test
|
|
12
|
+
// has been denied." and nothing else, so the agent goes looking for
|
|
13
|
+
// another way in, which is the failure this whole file exists to prevent.
|
|
14
|
+
// The deny is the floor and the hook is the messenger. When the hook cannot
|
|
15
|
+
// run, enforcement degrades to terse rather than to nothing.
|
|
16
|
+
//
|
|
17
|
+
// Subprocesses are NOT intercepted (verified): shipkit spawning oxlint or
|
|
18
|
+
// `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
|
|
19
|
+
// these rules, which is exactly what makes the gate itself still work.
|
|
20
|
+
|
|
21
|
+
export type RawRunner = {
|
|
22
|
+
/** the command head as an agent types it — the deny rule is this + `*` */
|
|
23
|
+
head: string
|
|
24
|
+
/** the floor slot it answers, so ONE exemption governs the script name and
|
|
25
|
+
* the shell command together: a repo that keeps `test` raw is not then
|
|
26
|
+
* refused `bun test` */
|
|
27
|
+
slot: 'test' | 'lint' | 'typecheck'
|
|
28
|
+
/** the shipkit command that answers the same question, cache and all */
|
|
29
|
+
use: string
|
|
30
|
+
/** the narrower way in, when there is one: iterating on a single repro
|
|
31
|
+
* test while you fix a bug is not a verification and stays normal. A
|
|
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
|
+
iterate?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// the house stack, plus the foreign runners a wired repo is actually found
|
|
38
|
+
// carrying. Kept tight on purpose: a deny an agent hits for no good reason
|
|
39
|
+
// teaches it to route around the whole file.
|
|
40
|
+
export const RAW_RUNNERS: readonly RawRunner[] = [
|
|
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
|
+
{ head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
48
|
+
{ head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
49
|
+
{ head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
|
|
50
|
+
{ head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
51
|
+
{ head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
52
|
+
{ head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
53
|
+
{ head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
54
|
+
{ head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
// the FORMATTER is deliberately absent, and that is not an oversight. It
|
|
58
|
+
// answers no cached question and produces no green anyone could mistake for a
|
|
59
|
+
// verification, shipkit's own fix pass runs it BY SCRIPT (`bun run fmt`), and
|
|
60
|
+
// denying the bare binary while that stays open would be theatre. `shipkit ci`
|
|
61
|
+
// formats before it gates, which is the only reason the reflex matters at all.
|
|
62
|
+
|
|
63
|
+
/** the `permissions.deny` entries, in the syntax Claude Code matches:
|
|
64
|
+
* `Bash(<head>*)` is a prefix match, and a compound command is checked one
|
|
65
|
+
* segment at a time, so `cd x && bun test` is caught too */
|
|
66
|
+
export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
|
|
67
|
+
const off = new Set(exemptSlots)
|
|
68
|
+
return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** what a refusal says. Never just the ban: a refusal with no replacement
|
|
72
|
+
* sends the agent hunting for another runner. `segment` is the part of the
|
|
73
|
+
* command that offended, shown only when it says more than the head already
|
|
74
|
+
* does: a long compound refused on `CI=1 bun test --timeout 5` otherwise
|
|
75
|
+
* leaves the reader guessing which of its clauses was the problem */
|
|
76
|
+
export function refusal(r: RawRunner, segment?: string): string {
|
|
77
|
+
const alt = r.iterate == null ? '' : `, or \`${r.iterate}\` to iterate on ONE suite while you fix a bug`
|
|
78
|
+
const which = segment == null || segment.trim() === '' || segment.trim() === r.head ? '' : ` (refused on \`${segment.trim().slice(0, 80)}\`)`
|
|
79
|
+
return `\`${r.head}\`${which} bypasses shipkit, so nothing is cached and no other agent's run can be attached to. Use \`${r.use}\`${alt}. Lifting this for good: delete the "Bash(${r.head}*)" line from .claude/settings.json permissions.deny (build/shipkit.md, ONE WAY IN).`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** shell wrappers that do not change what is being run */
|
|
83
|
+
const WRAPPERS = new Set(['time', 'timeout', 'nice', 'nohup', 'stdbuf', 'command', 'builtin', 'sudo', 'env'])
|
|
84
|
+
|
|
85
|
+
/** the heads a settings file actually denies. The guard asks THE FILE rather
|
|
86
|
+
* than the table, so deleting a `Bash(...)` line lifts the deny AND the
|
|
87
|
+
* hook's refusal in one edit: two mechanisms an agent has to find and lift
|
|
88
|
+
* separately is the deny it cannot escape, which is worse than the disease */
|
|
89
|
+
export function deniedHeads(settings: unknown): Set<string> {
|
|
90
|
+
const deny = (settings as { permissions?: { deny?: unknown } } | null)?.permissions?.deny
|
|
91
|
+
const out = new Set<string>()
|
|
92
|
+
if (!Array.isArray(deny)) return out
|
|
93
|
+
for (const rule of deny) {
|
|
94
|
+
const m = typeof rule === 'string' ? /^Bash\((.+?)\*?\)$/.exec(rule) : null
|
|
95
|
+
if (m != null) out.add(m[1]!.trim())
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** a word that could be the name of a program. Anything carrying a quote, a
|
|
101
|
+
* parenthesis or a bracket is prose or code, not an invocation, and matching
|
|
102
|
+
* it is how a guard earns a reputation for crying wolf */
|
|
103
|
+
const PROGRAM_WORD = /^[\w./@+-]+$/
|
|
104
|
+
|
|
105
|
+
/** split a command line on the shell operators, at QUOTE DEPTH ZERO only.
|
|
106
|
+
* Without that, `git commit -m "why cd x && bun test is refused"` splits
|
|
107
|
+
* inside the message and the second half reads as a command. House commit
|
|
108
|
+
* bodies are dense and multi-line, so this is not a corner case here, it is
|
|
109
|
+
* the most common shape of command an agent runs. */
|
|
110
|
+
export function shellSegments(command: string): string[] {
|
|
111
|
+
// everything after a heredoc opener is the document, never a command
|
|
112
|
+
const shell = command.split(/<<-?\s*['"]?\w+/)[0] ?? command
|
|
113
|
+
const out: string[] = []
|
|
114
|
+
let cur = ''
|
|
115
|
+
let quote: '"' | "'" | null = null
|
|
116
|
+
for (let i = 0; i < shell.length; i++) {
|
|
117
|
+
const ch = shell[i]!
|
|
118
|
+
if (quote != null) {
|
|
119
|
+
cur += ch
|
|
120
|
+
if (ch === '\\' && quote === '"') { cur += shell[++i] ?? ''; continue }
|
|
121
|
+
if (ch === quote) quote = null
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue }
|
|
125
|
+
if (ch === '\n' || ch === ';' || ch === '|' || ch === '&') {
|
|
126
|
+
// `&&` and `||` are one operator, and a lone `&` backgrounds
|
|
127
|
+
if ((ch === '&' || ch === '|') && shell[i + 1] === ch) i++
|
|
128
|
+
out.push(cur)
|
|
129
|
+
cur = ''
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
cur += ch
|
|
133
|
+
}
|
|
134
|
+
out.push(cur)
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** the raw runner this shell command reaches for, plus the segment that
|
|
139
|
+
* offended, or null.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately conservative in BOTH directions. It must catch the forms a
|
|
142
|
+
* prefix deny rule misses (a leading `CI=1`, `./node_modules/.bin/tsc`), and
|
|
143
|
+
* it must never fire on a command that merely CONTAINS the words: a heredoc
|
|
144
|
+
* body is data and so is a quoted commit message, and a false refusal teaches
|
|
145
|
+
* an agent to route around the whole file. So: the heredoc body is dropped,
|
|
146
|
+
* segments are cut at quote depth zero, each is reduced to its first program
|
|
147
|
+
* word, and that word must match a head exactly or as the basename of a path.
|
|
148
|
+
* Longest head first, so `bunx tsc` is never reported as `tsc`. `only`
|
|
149
|
+
* narrows to the heads this repo really denies. */
|
|
150
|
+
export function rawRunnerIn(command: string, only?: ReadonlySet<string>): RawRunner | null {
|
|
151
|
+
return rawRunnerHit(command, only)?.runner ?? null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function rawRunnerHit(command: string, only?: ReadonlySet<string>): { runner: RawRunner; segment: string } | null {
|
|
155
|
+
const heads = [...RAW_RUNNERS]
|
|
156
|
+
.filter((r) => only == null || only.has(r.head))
|
|
157
|
+
.sort((a, b) => b.head.length - a.head.length)
|
|
158
|
+
for (const seg of shellSegments(command)) {
|
|
159
|
+
let s = seg.trim().replace(/^[(){}\s]+/, '')
|
|
160
|
+
// leading VAR=value assignments, then a wrapper word, repeatedly. A
|
|
161
|
+
// wrapper's own flags and durations go with it (`timeout 60 bun test`)
|
|
162
|
+
let wrapped = false
|
|
163
|
+
for (;;) {
|
|
164
|
+
const next = s.replace(/^\w+=(?:"[^"]*"|'[^']*'|\S*)\s+/, '')
|
|
165
|
+
if (next !== s) { s = next; continue }
|
|
166
|
+
const w = /^(\S+)\s+/.exec(s)
|
|
167
|
+
if (w != null && WRAPPERS.has(w[1]!.split('/').pop() ?? '')) { s = s.slice(w[0].length); wrapped = true; continue }
|
|
168
|
+
if (wrapped && w != null && /^(?:-\S*|\d+[smhd]?)$/.test(w[1]!)) { s = s.slice(w[0].length); continue }
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
const words = s.split(/\s+/).filter((w) => w !== '')
|
|
172
|
+
const first = words[0]
|
|
173
|
+
if (first == null || !PROGRAM_WORD.test(first)) continue
|
|
174
|
+
// a path-qualified binary counts: ./node_modules/.bin/tsc IS tsc
|
|
175
|
+
const program = first.split('/').pop() ?? first
|
|
176
|
+
for (const r of heads) {
|
|
177
|
+
const parts = r.head.split(' ')
|
|
178
|
+
if (parts[0] !== program) continue
|
|
179
|
+
if (parts.length === 1 || words[1] === parts[1]) return { runner: r, segment: seg }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
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
|
|
@@ -102,8 +104,16 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
102
104
|
const c = o.config ?? (await loadRepoConfig(root))
|
|
103
105
|
// mechanical failures are not information, so they never cost a round trip
|
|
104
106
|
let fixFailed = false
|
|
107
|
+
// a NARROWED run repairs only the mechanical slots. The structural rule
|
|
108
|
+
// fixers rewrite package.json and repo config, and arming rewrites hooks/:
|
|
109
|
+
// that is the right price for the full gate and the wrong one for
|
|
110
|
+
// `bun run test`, which routes here and must answer the one question its
|
|
111
|
+
// name promises without leaving files in the agent's diff that it never
|
|
112
|
+
// wrote. Under CI it is worse than surprising, a formatting rewrite comes
|
|
113
|
+
// back as a test failure.
|
|
114
|
+
const narrowed = (o.onlyIds ?? []).length > 0
|
|
105
115
|
if (o.fix !== false)
|
|
106
|
-
fixFailed = (await runFixPass(root, c, { rules: o.fixRules !== false, armHooks: o.armHooks === true, print: sink })).failed
|
|
116
|
+
fixFailed = (await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })).failed
|
|
107
117
|
else sink('skip: fix pass (--no-fix)')
|
|
108
118
|
const all = allTasks(c, root)
|
|
109
119
|
const { disabled, unfilled } = all
|
|
@@ -122,9 +132,38 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
122
132
|
: `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
|
|
123
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)`)
|
|
124
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
|
+
}
|
|
125
157
|
// a floor slot nothing could fill is VISIBLE — a thin gate must never
|
|
126
158
|
// look like a full one (the fresh-repo floor gap)
|
|
127
|
-
for (const u of unfilled)
|
|
159
|
+
for (const u of unfilled) {
|
|
160
|
+
sink(`skip: ${u.id} (${u.reason})`)
|
|
161
|
+
// asked for BY NAME and unfillable: this is where an agent lands after
|
|
162
|
+
// the harness refused it the raw runner, so the line says what to add
|
|
163
|
+
// rather than leaving it with `unknown task id` and no next move
|
|
164
|
+
if ((o.onlyIds ?? []).includes(u.id))
|
|
165
|
+
sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
|
|
166
|
+
}
|
|
128
167
|
// the release survey is ~4s of network that five discovered checks need
|
|
129
168
|
// and the TASKS do not. Started HERE it overlaps lint/typecheck/test and
|
|
130
169
|
// costs the run nothing; started inside the first check that wants it, it
|
|
@@ -180,7 +219,7 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
180
219
|
sink(' fix: shipkit check')
|
|
181
220
|
} else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
|
|
182
221
|
}
|
|
183
|
-
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
|
|
184
223
|
// motherhome row (docsBase wiki on this disk) — never affects the gate,
|
|
185
224
|
// and never fires under frozen: a bump/release gate must not re-dirty
|
|
186
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) {
|