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/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}`)
|
package/src/core/motherhome.ts
CHANGED
|
@@ -24,6 +24,58 @@ type Row = { name: string; root: string; version: string; mode: string; seen: st
|
|
|
24
24
|
|
|
25
25
|
const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
|
|
26
26
|
|
|
27
|
+
const git = (cwd: string, args: string[]): { ok: boolean; out: string } => {
|
|
28
|
+
const r = Bun.spawnSync(['git', ...args], { cwd })
|
|
29
|
+
return { ok: r.exitCode === 0, out: `${r.stdout.toString()}${r.stderr.toString()}`.trim() }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Commit the row we just wrote, and ONLY that file.
|
|
34
|
+
*
|
|
35
|
+
* This page is machine written and rewrites whenever a repo's shipkit version
|
|
36
|
+
* or consumption mode changes, so left alone it dirties the docs repo from a
|
|
37
|
+
* gate run nobody associates with it, and the dirt then rides into an unrelated
|
|
38
|
+
* commit or blocks a release. The row is not worth a human's attention in
|
|
39
|
+
* either direction, so the machine that writes it also lands it.
|
|
40
|
+
*
|
|
41
|
+
* Scoped hard, because several agents share this checkout:
|
|
42
|
+
* - `git commit -- <path>` takes the WORKING TREE version of that one path and
|
|
43
|
+
* never reads the index, so a neighbour's staged work cannot ride along.
|
|
44
|
+
* - it refuses mid merge, mid rebase, on a detached HEAD, and before the first
|
|
45
|
+
* commit, where an automatic commit is never what anyone wants.
|
|
46
|
+
* - it never pushes. Visibility is the human's call.
|
|
47
|
+
* - the hooks run, as they do for any commit. They are text checks of about a
|
|
48
|
+
* second, and this fires only when the row's DATA changed, not every gate.
|
|
49
|
+
*
|
|
50
|
+
* Never throws and never fails the calling command: a registry row is not worth
|
|
51
|
+
* a red gate. `SHIPKIT_NO_AUTOCOMMIT=1` turns it off.
|
|
52
|
+
*/
|
|
53
|
+
function autoCommitRow(page: string, name: string): string {
|
|
54
|
+
if (process.env['SHIPKIT_NO_AUTOCOMMIT'] === '1') return ''
|
|
55
|
+
try {
|
|
56
|
+
const dir = resolve(page, '..')
|
|
57
|
+
const top = git(dir, ['rev-parse', '--show-toplevel'])
|
|
58
|
+
if (!top.ok) return '' // the docs tree is not a git repo, nothing to land
|
|
59
|
+
const root = top.out
|
|
60
|
+
// a detached HEAD or a repo with no commit yet has nowhere sane to land
|
|
61
|
+
if (!git(root, ['symbolic-ref', '--quiet', 'HEAD']).ok) return ''
|
|
62
|
+
if (!git(root, ['rev-parse', '--verify', 'HEAD']).ok) return ''
|
|
63
|
+
for (const f of ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD'])
|
|
64
|
+
if (existsSync(join(root, '.git', f))) return ''
|
|
65
|
+
const rel = page.startsWith(`${root}/`) ? page.slice(root.length + 1) : page
|
|
66
|
+
// one retry: a neighbour committing right now holds index.lock
|
|
67
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
68
|
+
const r = git(root, ['commit', '-m', `chore(machine): refresh local-projects row (${name})`, '--', rel])
|
|
69
|
+
if (r.ok) return ' + committed'
|
|
70
|
+
if (!r.out.includes('index.lock')) return ''
|
|
71
|
+
Bun.sleepSync(300)
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
} catch {
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
27
79
|
const parseRow = (line: string): Row | null => {
|
|
28
80
|
const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
|
|
29
81
|
return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
|
|
@@ -90,7 +142,8 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
|
|
|
90
142
|
// prune rows for repos that no longer exist at their recorded path
|
|
91
143
|
const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
|
|
92
144
|
atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
|
|
93
|
-
|
|
145
|
+
const committed = autoCommitRow(page, me.name)
|
|
146
|
+
return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
|
|
94
147
|
})
|
|
95
148
|
// lock lost to a racing reporter — self-heals on the next run
|
|
96
149
|
return r.ok ? r.value : { wrote: false }
|
|
@@ -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-check.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
// periodic health — contracts + every *.check.ts in the repo. Discovery IS
|
|
2
2
|
// the registration (core/discover-checks.ts); each check runs as a
|
|
3
|
-
// subprocess: wire lines parsed back, exit code is truth.
|
|
4
|
-
//
|
|
5
|
-
// here
|
|
6
|
-
//
|
|
3
|
+
// subprocess: wire lines parsed back, exit code is truth.
|
|
4
|
+
//
|
|
5
|
+
// NOTHING here decides what may be remembered any more. A rule or a check is
|
|
6
|
+
// replayed when it DECLARED how ("inputs" makes it content-keyed and the
|
|
7
|
+
// ledger owns it, "ttl" makes it a world probe and CheckSuite owns it), and
|
|
8
|
+
// otherwise it is measured every run. The blanket 30-minute orchestrator TTL
|
|
9
|
+
// that used to cover every plain check died with that: it cached by default,
|
|
10
|
+
// on top of whatever the check cached for itself, so one question had two
|
|
11
|
+
// staleness windows and neither was declared by anyone. `--fresh`
|
|
12
|
+
// (SHIPKIT_FRESH=1, which travels to children) busts the one that is left.
|
|
7
13
|
import { join, relative } from 'path'
|
|
8
14
|
import { CheckSuite, type CheckOutcome } from './check-suite.ts'
|
|
15
|
+
import { gateUnits } from '../ledger/gate-units.ts'
|
|
16
|
+
import { Ledger, stampReplayed } from '../ledger/ledger.ts'
|
|
17
|
+
import { storeRoot } from '../ledger/store.ts'
|
|
9
18
|
import { CheckCtx } from './check-ctx.ts'
|
|
10
19
|
import { discoverChecks } from './discover-checks.ts'
|
|
11
20
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
@@ -18,9 +27,6 @@ import { writeStatus } from './write-status.ts'
|
|
|
18
27
|
import { SHARE_ENV } from '../release/releases-share.ts'
|
|
19
28
|
import { rmSync } from 'fs'
|
|
20
29
|
|
|
21
|
-
// orchestrator cache for checks WITHOUT their own suite — short on purpose:
|
|
22
|
-
// this layer only absorbs back-to-back runs, real TTLs live in the checks
|
|
23
|
-
const DISCOVERED_TTL = '30m'
|
|
24
30
|
const CHECK_TIMEOUT_MS = 120_000
|
|
25
31
|
|
|
26
32
|
export type CheckOpts = {
|
|
@@ -50,6 +56,15 @@ export type CheckOpts = {
|
|
|
50
56
|
* probed every third-party service the repo knows about. One flag, one
|
|
51
57
|
* meaning, across all three surfaces. */
|
|
52
58
|
only?: string[]
|
|
59
|
+
/** the run's ledger, when the caller already opened one (runCi does). A
|
|
60
|
+
* standalone `shipkit check` opens its own. */
|
|
61
|
+
ledger?: Ledger | null
|
|
62
|
+
/** refresh the motherhome registry row. runCi passes false and does it
|
|
63
|
+
* ITSELF, after the ledger has committed: that row is a TRACKED file under
|
|
64
|
+
* `wiki/`, so writing it before the post-run re-hash makes the gate look
|
|
65
|
+
* like an agent editing the tree and throws away every result the run just
|
|
66
|
+
* measured. Two reports per gate run was duplication anyway. */
|
|
67
|
+
reportHome?: boolean
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/** does this rule or check answer to one of the `--only` ids? Matched against
|
|
@@ -83,7 +98,61 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
83
98
|
const prevFrozen = process.env.SHIPKIT_FROZEN
|
|
84
99
|
if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
|
|
85
100
|
if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
|
|
86
|
-
|
|
101
|
+
// the caller's ledger, or this run's own. `shipkit check` is a full run
|
|
102
|
+
// of every rule and check, so it deserves the same replay the gate gets.
|
|
103
|
+
let ledger: Ledger | null = o.ledger ?? null
|
|
104
|
+
if (ledger === null && o.ledger === undefined) {
|
|
105
|
+
try {
|
|
106
|
+
ledger = Ledger.open(root, gateUnits(root, c), { cliEntry: new URL('../cli.ts', import.meta.url).pathname, fresh: o.fresh })
|
|
107
|
+
} catch { ledger = null }
|
|
108
|
+
}
|
|
109
|
+
const replayed = new Set<string>()
|
|
110
|
+
const unitIdByName = new Map<string, string>()
|
|
111
|
+
/** what a TTL'd answer is remembered under, beside its rotation key. A TTL
|
|
112
|
+
* says how long the WORLD keeps; it cannot say that the probe was
|
|
113
|
+
* rewritten, or that a different shipkit measured it. Without these two a
|
|
114
|
+
* fixed check replayed its old verdict for the rest of its TTL. */
|
|
115
|
+
const worldKey = (unitId: string, rotation?: string): string => {
|
|
116
|
+
let code = ''
|
|
117
|
+
try { code = ledger?.layer.keyFor(unitId).codeKey ?? '' } catch { code = '' }
|
|
118
|
+
return `tc=${ledger?.layer.toolchainId ?? ''}\0code=${code}\0${rotation ?? ''}`
|
|
119
|
+
}
|
|
120
|
+
const suite = new CheckSuite({
|
|
121
|
+
name: c.name,
|
|
122
|
+
storeDir: storeRoot(root),
|
|
123
|
+
onResult: (name, r) => {
|
|
124
|
+
o.onResult?.(name, r)
|
|
125
|
+
// a replay measured nothing, and a red is never stored. A check
|
|
126
|
+
// that cannot block had its FAIL rewritten to a warn before this
|
|
127
|
+
// fires, so the demotion list is what says it failed: a content
|
|
128
|
+
// key has no TTL, so storing that red would pin it until an
|
|
129
|
+
// unrelated edit moved the tree, and "the box is off" is fixed by
|
|
130
|
+
// turning the box on, which changes no file.
|
|
131
|
+
if (ledger == null || r.cached || replayed.has(name) || r.status === 'skip' || r.status === 'fail') return
|
|
132
|
+
if (advisoryFailures.includes(name)) return
|
|
133
|
+
const id = unitIdByName.get(name)
|
|
134
|
+
if (id == null) return
|
|
135
|
+
ledger.record(id, { status: r.status, lines: r.lines, measuredMs: r.ms })
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
/** a stored answer, re-emitted as a runner that does no work. Keeping it in
|
|
139
|
+
* the suite rather than splicing lines afterwards is what preserves
|
|
140
|
+
* declaration order, which the wire contract is frozen on. */
|
|
141
|
+
const addReplay = (unitId: string, name: string, blocking: boolean): boolean => {
|
|
142
|
+
const hit = ledger?.replay(unitId)
|
|
143
|
+
if (hit == null) return false
|
|
144
|
+
replayed.add(name)
|
|
145
|
+
unitIdByName.set(name, unitId)
|
|
146
|
+
suite.addCheck({
|
|
147
|
+
blocking,
|
|
148
|
+
name,
|
|
149
|
+
logic: (t) => {
|
|
150
|
+
for (const l of stampReplayed(hit.lines, hit.measuredMs)) t.lines.push(l)
|
|
151
|
+
if (t.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn')) t.warned = true
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
87
156
|
// built-in SK rules (rules/rule-registry.ts) THEN the repo's own
|
|
88
157
|
// (repo.config rules: []), id-tagged, crash-safe, fix-pointer floor
|
|
89
158
|
// (rules/rule.ts runRuleLint owns the semantics). `shipkit ci` runs this
|
|
@@ -104,11 +173,13 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
104
173
|
for (const rule of [...RULES, ...(c.rules ?? [])]) {
|
|
105
174
|
if (!matchesOnly(o.only, rule.id, rule.title)) continue
|
|
106
175
|
if (o.onlyBlocking === true && !rule.blocking) continue
|
|
176
|
+
unitIdByName.set(rule.title, rule.id)
|
|
177
|
+
if (addReplay(rule.id, rule.title, rule.blocking)) continue
|
|
107
178
|
suite.addCheck({
|
|
108
179
|
blocking: rule.blocking,
|
|
109
180
|
name: rule.title,
|
|
110
181
|
remainValidFor: rule.ttl,
|
|
111
|
-
cacheExtra:
|
|
182
|
+
cacheExtra: () => worldKey(rule.id, rule.cacheKey?.({ root, config: c })),
|
|
112
183
|
logic: async (t) => { await runRuleLint(rule, t, { root, config: c }); demote(t, rule.blocking, rule.title) },
|
|
113
184
|
})
|
|
114
185
|
}
|
|
@@ -123,7 +194,7 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
123
194
|
const childEnv: Record<string, string | undefined> = {
|
|
124
195
|
...process.env,
|
|
125
196
|
[SHARE_ENV]: sharePath,
|
|
126
|
-
...(o.fresh === true ? { SHIPKIT_FRESH: '1'
|
|
197
|
+
...(o.fresh === true ? { SHIPKIT_FRESH: '1' } : {}),
|
|
127
198
|
}
|
|
128
199
|
for (const d of discoverChecks(root)) {
|
|
129
200
|
const rel = relative(root, d.path)
|
|
@@ -133,12 +204,17 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
133
204
|
const blocking = d.blocking ?? c.checkDefaults?.blocking
|
|
134
205
|
if (blocking == null) undeclared.push(rel)
|
|
135
206
|
if (o.onlyBlocking === true && blocking !== true) continue
|
|
207
|
+
unitIdByName.set(d.name, d.name)
|
|
208
|
+
if (addReplay(d.name, d.name, blocking ?? true)) continue
|
|
136
209
|
suite.addCheck({
|
|
137
210
|
// a check about the WORLD may remember a measured negative; one
|
|
138
211
|
// about this repo never does (check-suite.ts, the rule)
|
|
139
212
|
blocking: blocking ?? true,
|
|
140
213
|
name: d.name,
|
|
141
|
-
|
|
214
|
+
// a WORLD check says how long its answer keeps; everything else is
|
|
215
|
+
// measured every run rather than blanket-cached by this layer
|
|
216
|
+
remainValidFor: d.ttl ?? undefined,
|
|
217
|
+
cacheExtra: () => worldKey(d.name),
|
|
142
218
|
logic: async (t) => {
|
|
143
219
|
const r = await spawnWithTimeout(['bun', d.path], { cwd: root, timeoutMs: CHECK_TIMEOUT_MS, env: childEnv })
|
|
144
220
|
for (const l of parseWireOutput(r.out)) t.lines.push(l)
|
|
@@ -159,7 +235,11 @@ export async function collectChecks(root: string, o: CheckOpts = {}): Promise<Ch
|
|
|
159
235
|
if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
|
|
160
236
|
// frozen = a release-shaped run: reporting would re-dirty the tree the
|
|
161
237
|
// caller is about to demand clean (field wart 2026-08-05, second head)
|
|
162
|
-
if (o.frozen !== true) reportToMotherhome(root, c)
|
|
238
|
+
if (o.frozen !== true && o.reportHome !== false) reportToMotherhome(root, c)
|
|
239
|
+
// the caller that OPENED the ledger owns committing it: runCi commits once
|
|
240
|
+
// after its tasks and its checks, so a task and a check in one run share
|
|
241
|
+
// one post-run re-hash
|
|
242
|
+
if (o.ledger === undefined && ledger != null) r.lines.push(...ledger.header(ledger.commit()))
|
|
163
243
|
return { lines: r.lines, blocked: r.exitCode !== 0, advisoryFailures, undeclared }
|
|
164
244
|
}
|
|
165
245
|
|