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.
Files changed (50) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +15 -2
  4. package/src/cli/cmd_Asks.ts +2 -1
  5. package/src/cli/cmd_Ci.ts +3 -0
  6. package/src/cli/cmd_Keys.ts +113 -0
  7. package/src/cli.ts +2 -0
  8. package/src/core/check-suite.ts +54 -47
  9. package/src/core/discover-checks.ts +30 -7
  10. package/src/core/floor.ts +34 -6
  11. package/src/core/locked-write.ts +170 -1
  12. package/src/core/motherhome.ts +54 -1
  13. package/src/core/one-way-in-policy.ts +8 -8
  14. package/src/core/run-check.ts +92 -12
  15. package/src/core/run-ci.ts +115 -17
  16. package/src/core/run-fixers.ts +61 -18
  17. package/src/core/task-runner.ts +62 -6
  18. package/src/hooks/run-hooks.ts +23 -2
  19. package/src/ledger/gate-units.ts +84 -0
  20. package/src/ledger/git-key.ts +381 -0
  21. package/src/ledger/git-scan.ts +327 -0
  22. package/src/ledger/ledger.ts +202 -0
  23. package/src/ledger/store.ts +122 -0
  24. package/src/ledger/toolchain-identity.ts +45 -8
  25. package/src/pb/collection-scaffold.ts +4 -4
  26. package/src/release/run-bump.ts +50 -5
  27. package/src/release/run-release.ts +5 -0
  28. package/src/release/run-releases.ts +7 -1
  29. package/src/release/unit-gate.ts +17 -4
  30. package/src/repo-config.ts +26 -4
  31. package/src/rules/SK001-package-json-shape.ts +8 -2
  32. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  33. package/src/rules/SK003-claude-md-shape.ts +7 -2
  34. package/src/rules/SK004-gitignore-policy.ts +12 -4
  35. package/src/rules/SK005-rvlib-versions.ts +28 -1
  36. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  37. package/src/rules/SK008-reviewer-agent.ts +6 -2
  38. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  39. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  40. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  41. package/src/rules/SK012-config-barrel-import.ts +6 -2
  42. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  43. package/src/rules/rule.ts +22 -1
  44. package/src/rules/run-fix.ts +5 -3
  45. package/src/scaffold/files/verify-skill.md +1 -1
  46. package/src/ts/config.ts +66 -6
  47. package/src/ts/cycles.ts +7 -92
  48. package/src/ts/source-graph.ts +158 -0
  49. package/src/ts/tsconfig-projects.ts +101 -0
  50. package/src/ts/typecheck-cli.ts +46 -0
@@ -0,0 +1,381 @@
1
+ // THE KEY. What question did this unit answer, and is that still the same
2
+ // question? Nothing in this file caches anything: it produces the identity a
3
+ // later ledger will store results under, and it refuses to produce one
4
+ // whenever the answer would be a guess.
5
+ //
6
+ // THREE POPULATIONS, declared at registration, never inferred:
7
+ //
8
+ // CONTENT declares `inputs` — the paths it reads. Keyed on their git object
9
+ // ids, plus the unit's own code (module graph), plus the toolchain
10
+ // (which content-hashes the gate binary, so editing shipkit moves
11
+ // every key in every repo), plus argv and declared env. No TTL: a
12
+ // content key is either the same question or a different one.
13
+ // WORLD declares `ttl` — it reads the network, the keychain, $PATH, a git
14
+ // remote or the clock. Nothing on disk describes its answer, so it
15
+ // keeps the existing clean-pass TTL and its `cacheKey`, unchanged.
16
+ // NEITHER declares nothing. Never cached. This is the DEFAULT, and it is
17
+ // the whole safety story: forgetting to declare costs speed, never
18
+ // correctness, so the burn-down list below is a to-do rather than a
19
+ // hazard. Declaring BOTH is a registration failure, loud, because
20
+ // the two answers contradict each other.
21
+ //
22
+ // CROSS WORKTREE. Every component here is content addressed or a version
23
+ // string; not one of them is an absolute path. Two worktrees of one repo
24
+ // holding the same content and the same install therefore produce the same
25
+ // key, and a worktree whose node_modules differs produces a different one.
26
+ // Measured on this disk: main and two worktrees, main's install id
27
+ // e55a7fd4ae252e5a against fd0e2908d222fe2e shared by both worktrees, which
28
+ // differ from main by two top-level packages. That is what makes a shared
29
+ // ledger safe rather than a stale green in a checkout nobody looked at.
30
+ import { createHash } from 'crypto'
31
+ import { join } from 'path'
32
+ import { loadConfig, type RvtConfig } from '../ts/config.ts'
33
+ import { importClosure } from '../ts/source-graph.ts'
34
+ import { GitScanError, hashFiles, normalizeInput, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
35
+ import { installIdentity, toolchainIdentity } from './toolchain-identity.ts'
36
+
37
+ /** the key format. Bumped when a component is added or its rendering changes,
38
+ * so an old ledger can never answer a new question. */
39
+ export const KEY_VERSION = 2
40
+
41
+ export type Population = 'content' | 'world' | 'none'
42
+
43
+ /** what one gate unit (a task, a rule, a discovered check) declares about its
44
+ * own cacheability. `gate-units.ts` builds these from the live registries. */
45
+ export type UnitDecl = {
46
+ /** the id an operator types: a task id, a rule id, a check name */
47
+ id: string
48
+ kind: 'task' | 'rule' | 'check'
49
+ /** CONTENT: repo-relative paths this unit reads. Presence declares the population. */
50
+ inputs?: string[]
51
+ /** WORLD: clean-pass duration, e.g. '24h'. Presence declares the population. */
52
+ ttl?: string
53
+ /** repo-relative entry files of the unit's OWN code — the module-graph
54
+ * roots. A discovered check gets its own file for free. */
55
+ code?: string[]
56
+ /** the argv a subprocess unit runs */
57
+ argv?: string[]
58
+ /** env var NAMES whose values belong in the key */
59
+ env?: string[]
60
+ /** anything else that identifies the question (a rule's `cacheKey`) */
61
+ extra?: string
62
+ /** a DECIDED never-cacheable, with the reason. Not the same thing as an
63
+ * undeclared unit: this one has been looked at and cannot be described
64
+ * (it reads git history, a path outside the repo, the wall clock), so it
65
+ * leaves the burn-down list. Declaring it beside inputs or ttl is a
66
+ * registration failure, because those say the opposite. */
67
+ never?: string
68
+ }
69
+
70
+ export type UnitKey = {
71
+ id: string
72
+ kind: UnitDecl['kind']
73
+ population: Population
74
+ /** the content key, or null for world/none/refused */
75
+ key: string | null
76
+ /** why there is no content key. Always set when key is null. */
77
+ reason: string | null
78
+ /** every component that went into the key, in key order — this is what a
79
+ * human reads when a key moved and nobody knows why */
80
+ components: string[]
81
+ /** the input paths this unit was keyed on, with their object ids */
82
+ inputOids: [string, string][]
83
+ /** the identity of the unit's OWN code, module graph included. Set for
84
+ * every unit that has code, WORLD ones too: a TTL'd probe whose source
85
+ * changed must not replay its previous answer, and this is the value that
86
+ * busts it. null when the unit has no code entry or git could not hash it. */
87
+ codeKey: string | null
88
+ }
89
+
90
+ export type Registration = {
91
+ /** loud, blocking: a unit that declared two contradictory populations */
92
+ errors: string[]
93
+ /** the default-deny burn-down, one line per undeclared unit */
94
+ burnDown: string[]
95
+ /** every unit that was classified */
96
+ units: number
97
+ /** units in a cacheable population (content or world) */
98
+ declared: number
99
+ }
100
+
101
+ /** which population a declaration puts a unit in, before any git work.
102
+ * `inputs` on a `ttl` unit is a registration FAIL: a unit cannot both be
103
+ * described by files on disk and be un-describable by them. */
104
+ export function classify(d: UnitDecl): { population: Population; error?: string; reason?: string; decided?: boolean } {
105
+ const hasInputs = (d.inputs ?? []).length > 0
106
+ const hasTtl = d.ttl != null && d.ttl !== ''
107
+ const hasNever = d.never != null && d.never !== ''
108
+ if (hasInputs && hasTtl)
109
+ return {
110
+ population: 'none',
111
+ error: `${d.kind} '${d.id}' declares BOTH inputs (CONTENT) and ttl (WORLD) — a unit reads files or it reads the world, never both`,
112
+ }
113
+ if (hasNever && (hasInputs || hasTtl))
114
+ return {
115
+ population: 'none',
116
+ error: `${d.kind} '${d.id}' declares never:'${d.never}' AND ${hasInputs ? 'inputs' : 'ttl'} — never means nothing can describe this unit, which the other declaration contradicts`,
117
+ }
118
+ if (hasInputs) return { population: 'content' }
119
+ if (hasTtl) return { population: 'world' }
120
+ if (hasNever) return { population: 'none', reason: d.never, decided: true }
121
+ return { population: 'none', reason: 'declares neither inputs nor ttl' }
122
+ }
123
+
124
+ export function validateRegistration(decls: UnitDecl[]): Registration {
125
+ const errors: string[] = []
126
+ const burnDown: string[] = []
127
+ let declared = 0
128
+ for (const d of decls) {
129
+ const c = classify(d)
130
+ if (c.error != null) errors.push(c.error)
131
+ // a DECIDED never leaves the list: the burn-down is what nobody has
132
+ // looked at yet, and mixing the two turns it into wallpaper
133
+ else if (c.population !== 'none') declared++
134
+ else if (c.decided !== true) burnDown.push(`${d.kind} ${d.id} — ${c.reason ?? 'undeclared'}`)
135
+ // a declaration that cannot even be turned into a repo-relative path
136
+ // is a registration bug, not a cache miss: it would key the unit on a
137
+ // path in another repo, or on the repo root
138
+ for (const raw of [...(d.inputs ?? []), ...(d.code ?? [])]) {
139
+ try {
140
+ normalizeInput(raw)
141
+ } catch (e) {
142
+ errors.push(`${d.kind} '${d.id}': ${e instanceof Error ? e.message : String(e)}`)
143
+ }
144
+ }
145
+ }
146
+ return { errors, burnDown, units: decls.length, declared }
147
+ }
148
+
149
+ export type KeyLayerOpts = {
150
+ /** the cli entry actually driving this run — toolchain identity needs to
151
+ * know whether the gate binary lives inside the repo being gated */
152
+ cliEntry: string
153
+ /** module-graph config; loaded from the repo when omitted */
154
+ cfg?: RvtConfig
155
+ }
156
+
157
+ /**
158
+ * one scan, N keys. Build it once per run: the git work is shared across every
159
+ * unit, and building two of them in one run would pay for the scan twice and
160
+ * could straddle an edit.
161
+ */
162
+ export class KeyLayer {
163
+ private constructor(
164
+ readonly root: string,
165
+ readonly decls: UnitDecl[],
166
+ readonly registration: Registration,
167
+ private readonly scan: ScanResult | null,
168
+ private readonly scanError: string | null,
169
+ private readonly closureByUnit: Map<string, string[]>,
170
+ private readonly codeOids: Map<string, string>,
171
+ private readonly toolchain: { id: string; uncacheable: string[] },
172
+ private readonly scanPathsUsed: string[],
173
+ /** wall time of the WHOLE layer: toolchain identity, the module-graph
174
+ * closures, the git scan and the closure hashing. Reported rather
175
+ * than the scan alone, because the scan alone is the flattering
176
+ * number and the caller pays all of it. */
177
+ readonly openMs: number,
178
+ ) {}
179
+
180
+ private readonly installIds = new Map<string, string>()
181
+
182
+ /** the identity of the program this run is driving: bun version, the gate
183
+ * binary's whole source, and every installed package. A WORLD unit needs
184
+ * it too, because a TTL'd answer measured by a different shipkit is a
185
+ * different answer, and the TTL alone cannot see that. */
186
+ get toolchainId(): string {
187
+ return this.toolchain.id
188
+ }
189
+
190
+ /** wall time the GIT half alone cost, ms — `openMs` is what a run pays */
191
+ get scanMs(): number {
192
+ return this.scan?.ms ?? 0
193
+ }
194
+
195
+ get failure(): string | null {
196
+ return this.scanError
197
+ }
198
+
199
+ static open(root: string, decls: UnitDecl[], o: KeyLayerOpts): KeyLayer {
200
+ const started = performance.now()
201
+ const registration = validateRegistration(decls)
202
+ const cfg = o.cfg ?? loadConfig(root)
203
+ // a gate binary that cannot be resolved is not a crash, it is a run
204
+ // whose program nobody can name — and the only safe answer to that is
205
+ // no key at all
206
+ let toolchain: { id: string; uncacheable: string[] }
207
+ try {
208
+ toolchain = toolchainIdentity(root, o.cliEntry)
209
+ } catch (e) {
210
+ toolchain = { id: 'unresolved', uncacheable: [`gate binary unreadable at ${o.cliEntry}: ${e instanceof Error ? e.message : String(e)}`] }
211
+ }
212
+
213
+ // the unit's OWN code, resolved through the same module graph the
214
+ // `cycles` task walks. A check that imports a shared helper carries
215
+ // that helper, so fixing the helper moves the check's key.
216
+ const closureByUnit = new Map<string, string[]>()
217
+ // computed for EVERY unit with code, not only the content ones: a
218
+ // world unit's TTL cache must break when the probe itself is rewritten
219
+ for (const d of decls) {
220
+ const entries = d.code ?? []
221
+ if (entries.length > 0) closureByUnit.set(d.id, importClosure(cfg, entries))
222
+ }
223
+
224
+ // ONE scan for the declared inputs plus shipkit's own source: those
225
+ // are mostly directories, mostly unchanged, and git's stat cache
226
+ // answers them without reading a byte.
227
+ const paths = new Set<string>()
228
+ for (const d of decls) {
229
+ if (classify(d).population !== 'content') continue
230
+ for (const p of d.inputs ?? []) paths.add(p)
231
+ }
232
+
233
+ // the closure is INDIVIDUAL FILES belonging to no declared path, so it
234
+ // goes through one `hash-object --stdin-paths` instead of one `git
235
+ // add` spawn per file (measured: 15ms for the whole set)
236
+ const closureFiles = new Set<string>()
237
+ for (const files of closureByUnit.values()) for (const f of files) closureFiles.add(f)
238
+
239
+ let scan: ScanResult | null = null
240
+ let codeOids = new Map<string, string>()
241
+ let scanError: string | null = null
242
+ try {
243
+ scan = scanPaths(root, [...paths])
244
+ codeOids = hashFiles(root, [...closureFiles]).oids
245
+ } catch (e) {
246
+ // a scan that failed may have left a STALE tree behind, so nothing
247
+ // downstream may key on it. Loud, and the whole run goes uncached.
248
+ scanError = e instanceof GitScanError ? e.message : String(e)
249
+ }
250
+ return new KeyLayer(root, decls, registration, scan, scanError, closureByUnit, codeOids, toolchain, [...paths].sort(), performance.now() - started)
251
+ }
252
+
253
+ /** the key for one unit, or the reason there is none. */
254
+ keyFor(id: string): UnitKey {
255
+ const d = this.decls.find((u) => u.id === id)
256
+ if (d == null) throw new Error(`keyFor: no unit '${id}' in this layer`)
257
+ const c = classify(d)
258
+ const own = (this.closureByUnit.get(d.id) ?? []).slice().sort()
259
+ const ownReadable = own.every((p) => this.codeOids.get(p) != null)
260
+ const codeLines = own.map((p) => `code:${p}=${this.codeOids.get(p)!}`)
261
+ const base: UnitKey = {
262
+ id: d.id, kind: d.kind, population: c.population, key: null, reason: null, components: [], inputOids: [],
263
+ codeKey: own.length === 0 || !ownReadable ? null : createHash('sha256').update(codeLines.join('\n')).digest('hex').slice(0, 16),
264
+ }
265
+ if (c.error != null) return { ...base, reason: c.error }
266
+ if (c.population === 'world') return { ...base, reason: `world unit — TTL ${d.ttl}, no content key` }
267
+ if (c.population === 'none') return { ...base, reason: c.reason ?? 'undeclared' }
268
+ if (this.scanError != null) return { ...base, reason: `git scan failed, nothing may be keyed: ${this.scanError}` }
269
+ const scan = this.scan!
270
+ if (this.toolchain.uncacheable.length > 0)
271
+ return { ...base, reason: `toolchain not describable: ${this.toolchain.uncacheable.join('; ')}` }
272
+
273
+ let inputs: string[]
274
+ try {
275
+ inputs = (d.inputs ?? []).map(normalizeInput).sort()
276
+ } catch (e) {
277
+ return { ...base, reason: e instanceof Error ? e.message : String(e) }
278
+ }
279
+ const stale = inputs.filter((p) => scan.missing.includes(p))
280
+ if (stale.length > 0) return { ...base, reason: `declared input(s) not on disk: ${stale.join(', ')}` }
281
+ const hidden = inputs.flatMap((p) => scan.excluded.get(p) ?? [])
282
+ const inputOids: [string, string][] = inputs.map((p) => [p, scan.oidByPath.get(p) ?? ''])
283
+ if (inputOids.some(([, oid]) => oid === '')) return { ...base, reason: 'git could not name one of the declared inputs' }
284
+
285
+ if (!ownReadable) return { ...base, reason: `git could not hash this unit's own source: ${own.filter((p) => this.codeOids.get(p) == null).join(', ')}` }
286
+ const components: string[] = [
287
+ `v=${KEY_VERSION}`,
288
+ `unit=${d.kind}:${d.id}`,
289
+ `bun=${Bun.version}`,
290
+ // report-only mode changes what a self-maintaining rule DOES, and
291
+ // a TTL cannot see a mode. A frozen answer replayed into a normal
292
+ // run, or the reverse, is an answer to the other question.
293
+ `frozen=${process.env['SHIPKIT_FROZEN'] === '1'}`,
294
+ `toolchain=${this.toolchain.id}`,
295
+ `argv=${JSON.stringify(d.argv ?? [])}`,
296
+ ...(d.env ?? []).slice().sort().map((n) => `env:${n}=${process.env[n] ?? '<unset>'}`),
297
+ ...inputOids.map(([p, oid]) => `in:${p}=${oid}`),
298
+ ...codeLines,
299
+ ...(d.extra != null ? [`extra=${d.extra}`] : []),
300
+ // ignored content nobody keyed on is part of the key's MEANING:
301
+ // two runs that excluded different things answered different
302
+ // questions, and a silently narrowed key is the false green.
303
+ //
304
+ // An excluded INSTALL carries its contents rather than only its
305
+ // name. `test:libs` declares `wiki/build`, every sub-package's
306
+ // `node_modules` sits inside it and is ignored, and the toolchain
307
+ // component describes the ROOT install only: without this, a lib
308
+ // whose own dependencies moved replayed a green measured against
309
+ // the previous ones, and no path in the key could say so.
310
+ ...hidden.sort().map((p) => (p.split('/').at(-1) === 'node_modules' ? `excluded=${p}#${this.installOf(p)}` : `excluded=${p}`)),
311
+ ]
312
+ const key = createHash('sha256').update(components.join('\n')).digest('hex').slice(0, 32)
313
+ return { ...base, key, components, inputOids }
314
+ }
315
+
316
+ /** an excluded install's identity, once per layer: several units can name
317
+ * the same one, and hashing a `node_modules` twice in a run is pure cost. */
318
+ private installOf(rel: string): string {
319
+ let id = this.installIds.get(rel)
320
+ if (id == null) { id = installIdentity(join(this.root, rel)); this.installIds.set(rel, id) }
321
+ return id
322
+ }
323
+
324
+ keys(): UnitKey[] {
325
+ return this.decls.map((d) => this.keyFor(d.id))
326
+ }
327
+
328
+ /** which units READ one of these paths. Answers the only question a
329
+ * post-run re-hash has: whose result now describes a tree that no longer
330
+ * exists. A unit declaring the whole work tree reads everything, so any
331
+ * drift at all touches it. */
332
+ unitsTouchedBy(drifted: string[]): Set<string> {
333
+ const hit = new Set<string>()
334
+ if (drifted.length === 0) return hit
335
+ for (const d of this.decls) {
336
+ // the unit's own source counts exactly like a declared input: an
337
+ // agent fixing a check WHILE it runs must not store the old
338
+ // verdict under the new code's key
339
+ if ((this.closureByUnit.get(d.id) ?? []).some((f) => drifted.includes(f))) { hit.add(d.id); continue }
340
+ for (const raw of d.inputs ?? []) {
341
+ let p: string
342
+ try { p = normalizeInput(raw) } catch { hit.add(d.id); break }
343
+ if (p === WHOLE_TREE || drifted.some((x) => x === p || x.startsWith(`${p}/`))) { hit.add(d.id); break }
344
+ }
345
+ }
346
+ return hit
347
+ }
348
+
349
+ /**
350
+ * POST RUN RE HASH. Re-scan exactly what this layer keyed on and report
351
+ * every path whose object id moved while the units were running.
352
+ *
353
+ * This is the answer to the poisoning class: a unit reads a file, an agent
354
+ * in the next window edits it, and the result gets stored under a key that
355
+ * describes the file as it was BEFORE the edit. Any drift here means the
356
+ * run's results describe a tree that no longer exists, and the caller
357
+ * discards them rather than storing a lie. Measured at ~140ms, which is
358
+ * the price of never having to explain a hit that was wrong.
359
+ */
360
+ rescan(): { drifted: string[]; error: string | null; ms: number } {
361
+ if (this.scanError != null) return { drifted: [], error: this.scanError, ms: 0 }
362
+ const started = performance.now()
363
+ try {
364
+ const after = scanPaths(this.root, this.scanPathsUsed)
365
+ const drifted: string[] = []
366
+ for (const [p, oid] of this.scan!.oidByPath) if (after.oidByPath.get(p) !== oid) drifted.push(p)
367
+ for (const [p] of after.oidByPath) if (!this.scan!.oidByPath.has(p)) drifted.push(p)
368
+ // the unit's own code drifts the same way a declared input does: an
369
+ // agent fixing a check WHILE that check runs would otherwise store
370
+ // the old verdict under the new code's key
371
+ const files = [...this.codeOids.keys()]
372
+ if (files.length > 0) {
373
+ const now = hashFiles(this.root, files)
374
+ for (const [p, oid] of this.codeOids) if (now.oids.get(p) !== oid) drifted.push(p)
375
+ }
376
+ return { drifted: [...new Set(drifted)].sort(), error: null, ms: performance.now() - started }
377
+ } catch (e) {
378
+ return { drifted: [], error: e instanceof GitScanError ? e.message : String(e), ms: performance.now() - started }
379
+ }
380
+ }
381
+ }