rvlib-shipkit 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -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_Keys.ts +107 -0
  6. package/src/cli.ts +2 -0
  7. package/src/core/check-suite.ts +95 -33
  8. package/src/core/discover-checks.ts +30 -1
  9. package/src/core/floor.ts +21 -2
  10. package/src/core/locked-write.ts +170 -1
  11. package/src/core/one-way-in-policy.ts +8 -8
  12. package/src/core/run-ci.ts +25 -1
  13. package/src/core/run-fixers.ts +61 -18
  14. package/src/core/task-runner.ts +55 -6
  15. package/src/hooks/run-hooks.ts +23 -2
  16. package/src/ledger/gate-units.ts +58 -0
  17. package/src/ledger/git-key.ts +330 -0
  18. package/src/ledger/git-scan.ts +314 -0
  19. package/src/ledger/toolchain-identity.ts +15 -8
  20. package/src/release/run-bump.ts +50 -5
  21. package/src/release/unit-gate.ts +17 -4
  22. package/src/rules/SK001-package-json-shape.ts +8 -2
  23. package/src/rules/SK002-contracts-fulfillness.ts +4 -0
  24. package/src/rules/SK003-claude-md-shape.ts +7 -2
  25. package/src/rules/SK004-gitignore-policy.ts +11 -2
  26. package/src/rules/SK005-rvlib-versions.ts +28 -1
  27. package/src/rules/SK006-npm-publish-safety.ts +3 -0
  28. package/src/rules/SK008-reviewer-agent.ts +6 -2
  29. package/src/rules/SK009-commit-ai-tells.ts +3 -0
  30. package/src/rules/SK010-undeclared-release-unit.ts +2 -0
  31. package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
  32. package/src/rules/SK012-config-barrel-import.ts +6 -2
  33. package/src/rules/SK013-one-way-in-policy.ts +24 -4
  34. package/src/rules/rule.ts +22 -1
  35. package/src/rules/run-fix.ts +5 -3
  36. package/src/scaffold/files/verify-skill.md +1 -1
  37. package/src/ts/config.ts +66 -6
  38. package/src/ts/cycles.ts +7 -92
  39. package/src/ts/source-graph.ts +158 -0
  40. package/src/ts/tsconfig-projects.ts +101 -0
  41. package/src/ts/typecheck-cli.ts +46 -0
@@ -0,0 +1,330 @@
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 { loadConfig, type RvtConfig } from '../ts/config.ts'
32
+ import { importClosure } from '../ts/source-graph.ts'
33
+ import { GitScanError, hashFiles, normalizeInput, scanPaths, type ScanResult } from './git-scan.ts'
34
+ import { toolchainIdentity } from './toolchain-identity.ts'
35
+
36
+ /** the key format. Bumped when a component is added or its rendering changes,
37
+ * so an old ledger can never answer a new question. */
38
+ export const KEY_VERSION = 1
39
+
40
+ export type Population = 'content' | 'world' | 'none'
41
+
42
+ /** what one gate unit (a task, a rule, a discovered check) declares about its
43
+ * own cacheability. `gate-units.ts` builds these from the live registries. */
44
+ export type UnitDecl = {
45
+ /** the id an operator types: a task id, a rule id, a check name */
46
+ id: string
47
+ kind: 'task' | 'rule' | 'check'
48
+ /** CONTENT: repo-relative paths this unit reads. Presence declares the population. */
49
+ inputs?: string[]
50
+ /** WORLD: clean-pass duration, e.g. '24h'. Presence declares the population. */
51
+ ttl?: string
52
+ /** repo-relative entry files of the unit's OWN code — the module-graph
53
+ * roots. A discovered check gets its own file for free. */
54
+ code?: string[]
55
+ /** the argv a subprocess unit runs */
56
+ argv?: string[]
57
+ /** env var NAMES whose values belong in the key */
58
+ env?: string[]
59
+ /** anything else that identifies the question (a rule's `cacheKey`) */
60
+ extra?: string
61
+ /** a DECIDED never-cacheable, with the reason. Not the same thing as an
62
+ * undeclared unit: this one has been looked at and cannot be described
63
+ * (it reads git history, a path outside the repo, the wall clock), so it
64
+ * leaves the burn-down list. Declaring it beside inputs or ttl is a
65
+ * registration failure, because those say the opposite. */
66
+ never?: string
67
+ }
68
+
69
+ export type UnitKey = {
70
+ id: string
71
+ kind: UnitDecl['kind']
72
+ population: Population
73
+ /** the content key, or null for world/none/refused */
74
+ key: string | null
75
+ /** why there is no content key. Always set when key is null. */
76
+ reason: string | null
77
+ /** every component that went into the key, in key order — this is what a
78
+ * human reads when a key moved and nobody knows why */
79
+ components: string[]
80
+ /** the input paths this unit was keyed on, with their object ids */
81
+ inputOids: [string, string][]
82
+ /** the identity of the unit's OWN code, module graph included. Set for
83
+ * every unit that has code, WORLD ones too: a TTL'd probe whose source
84
+ * changed must not replay its previous answer, and this is the value that
85
+ * busts it. null when the unit has no code entry or git could not hash it. */
86
+ codeKey: string | null
87
+ }
88
+
89
+ export type Registration = {
90
+ /** loud, blocking: a unit that declared two contradictory populations */
91
+ errors: string[]
92
+ /** the default-deny burn-down, one line per undeclared unit */
93
+ burnDown: string[]
94
+ /** every unit that was classified */
95
+ units: number
96
+ /** units in a cacheable population (content or world) */
97
+ declared: number
98
+ }
99
+
100
+ /** which population a declaration puts a unit in, before any git work.
101
+ * `inputs` on a `ttl` unit is a registration FAIL: a unit cannot both be
102
+ * described by files on disk and be un-describable by them. */
103
+ export function classify(d: UnitDecl): { population: Population; error?: string; reason?: string; decided?: boolean } {
104
+ const hasInputs = (d.inputs ?? []).length > 0
105
+ const hasTtl = d.ttl != null && d.ttl !== ''
106
+ const hasNever = d.never != null && d.never !== ''
107
+ if (hasInputs && hasTtl)
108
+ return {
109
+ population: 'none',
110
+ error: `${d.kind} '${d.id}' declares BOTH inputs (CONTENT) and ttl (WORLD) — a unit reads files or it reads the world, never both`,
111
+ }
112
+ if (hasNever && (hasInputs || hasTtl))
113
+ return {
114
+ population: 'none',
115
+ 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`,
116
+ }
117
+ if (hasInputs) return { population: 'content' }
118
+ if (hasTtl) return { population: 'world' }
119
+ if (hasNever) return { population: 'none', reason: d.never, decided: true }
120
+ return { population: 'none', reason: 'declares neither inputs nor ttl' }
121
+ }
122
+
123
+ export function validateRegistration(decls: UnitDecl[]): Registration {
124
+ const errors: string[] = []
125
+ const burnDown: string[] = []
126
+ let declared = 0
127
+ for (const d of decls) {
128
+ const c = classify(d)
129
+ if (c.error != null) errors.push(c.error)
130
+ // a DECIDED never leaves the list: the burn-down is what nobody has
131
+ // looked at yet, and mixing the two turns it into wallpaper
132
+ else if (c.population !== 'none') declared++
133
+ else if (c.decided !== true) burnDown.push(`${d.kind} ${d.id} — ${c.reason ?? 'undeclared'}`)
134
+ // a declaration that cannot even be turned into a repo-relative path
135
+ // is a registration bug, not a cache miss: it would key the unit on a
136
+ // path in another repo, or on the repo root
137
+ for (const raw of [...(d.inputs ?? []), ...(d.code ?? [])]) {
138
+ try {
139
+ normalizeInput(raw)
140
+ } catch (e) {
141
+ errors.push(`${d.kind} '${d.id}': ${e instanceof Error ? e.message : String(e)}`)
142
+ }
143
+ }
144
+ }
145
+ return { errors, burnDown, units: decls.length, declared }
146
+ }
147
+
148
+ export type KeyLayerOpts = {
149
+ /** the cli entry actually driving this run — toolchain identity needs to
150
+ * know whether the gate binary lives inside the repo being gated */
151
+ cliEntry: string
152
+ /** module-graph config; loaded from the repo when omitted */
153
+ cfg?: RvtConfig
154
+ }
155
+
156
+ /**
157
+ * one scan, N keys. Build it once per run: the git work is shared across every
158
+ * unit, and building two of them in one run would pay for the scan twice and
159
+ * could straddle an edit.
160
+ */
161
+ export class KeyLayer {
162
+ private constructor(
163
+ readonly root: string,
164
+ readonly decls: UnitDecl[],
165
+ readonly registration: Registration,
166
+ private readonly scan: ScanResult | null,
167
+ private readonly scanError: string | null,
168
+ private readonly closureByUnit: Map<string, string[]>,
169
+ private readonly codeOids: Map<string, string>,
170
+ private readonly toolchain: { id: string; uncacheable: string[] },
171
+ private readonly scanPathsUsed: string[],
172
+ /** wall time of the WHOLE layer: toolchain identity, the module-graph
173
+ * closures, the git scan and the closure hashing. Reported rather
174
+ * than the scan alone, because the scan alone is the flattering
175
+ * number and the caller pays all of it. */
176
+ readonly openMs: number,
177
+ ) {}
178
+
179
+ /** wall time the GIT half alone cost, ms — `openMs` is what a run pays */
180
+ get scanMs(): number {
181
+ return this.scan?.ms ?? 0
182
+ }
183
+
184
+ get failure(): string | null {
185
+ return this.scanError
186
+ }
187
+
188
+ static open(root: string, decls: UnitDecl[], o: KeyLayerOpts): KeyLayer {
189
+ const started = performance.now()
190
+ const registration = validateRegistration(decls)
191
+ const cfg = o.cfg ?? loadConfig(root)
192
+ // a gate binary that cannot be resolved is not a crash, it is a run
193
+ // whose program nobody can name — and the only safe answer to that is
194
+ // no key at all
195
+ let toolchain: { id: string; uncacheable: string[] }
196
+ try {
197
+ toolchain = toolchainIdentity(root, o.cliEntry)
198
+ } catch (e) {
199
+ toolchain = { id: 'unresolved', uncacheable: [`gate binary unreadable at ${o.cliEntry}: ${e instanceof Error ? e.message : String(e)}`] }
200
+ }
201
+
202
+ // the unit's OWN code, resolved through the same module graph the
203
+ // `cycles` task walks. A check that imports a shared helper carries
204
+ // that helper, so fixing the helper moves the check's key.
205
+ const closureByUnit = new Map<string, string[]>()
206
+ // computed for EVERY unit with code, not only the content ones: a
207
+ // world unit's TTL cache must break when the probe itself is rewritten
208
+ for (const d of decls) {
209
+ const entries = d.code ?? []
210
+ if (entries.length > 0) closureByUnit.set(d.id, importClosure(cfg, entries))
211
+ }
212
+
213
+ // ONE scan for the declared inputs plus shipkit's own source: those
214
+ // are mostly directories, mostly unchanged, and git's stat cache
215
+ // answers them without reading a byte.
216
+ const paths = new Set<string>()
217
+ for (const d of decls) {
218
+ if (classify(d).population !== 'content') continue
219
+ for (const p of d.inputs ?? []) paths.add(p)
220
+ }
221
+
222
+ // the closure is INDIVIDUAL FILES belonging to no declared path, so it
223
+ // goes through one `hash-object --stdin-paths` instead of one `git
224
+ // add` spawn per file (measured: 15ms for the whole set)
225
+ const closureFiles = new Set<string>()
226
+ for (const files of closureByUnit.values()) for (const f of files) closureFiles.add(f)
227
+
228
+ let scan: ScanResult | null = null
229
+ let codeOids = new Map<string, string>()
230
+ let scanError: string | null = null
231
+ try {
232
+ scan = scanPaths(root, [...paths])
233
+ codeOids = hashFiles(root, [...closureFiles]).oids
234
+ } catch (e) {
235
+ // a scan that failed may have left a STALE tree behind, so nothing
236
+ // downstream may key on it. Loud, and the whole run goes uncached.
237
+ scanError = e instanceof GitScanError ? e.message : String(e)
238
+ }
239
+ return new KeyLayer(root, decls, registration, scan, scanError, closureByUnit, codeOids, toolchain, [...paths].sort(), performance.now() - started)
240
+ }
241
+
242
+ /** the key for one unit, or the reason there is none. */
243
+ keyFor(id: string): UnitKey {
244
+ const d = this.decls.find((u) => u.id === id)
245
+ if (d == null) throw new Error(`keyFor: no unit '${id}' in this layer`)
246
+ const c = classify(d)
247
+ const own = (this.closureByUnit.get(d.id) ?? []).slice().sort()
248
+ const ownReadable = own.every((p) => this.codeOids.get(p) != null)
249
+ const codeLines = own.map((p) => `code:${p}=${this.codeOids.get(p)!}`)
250
+ const base: UnitKey = {
251
+ id: d.id, kind: d.kind, population: c.population, key: null, reason: null, components: [], inputOids: [],
252
+ codeKey: own.length === 0 || !ownReadable ? null : createHash('sha256').update(codeLines.join('\n')).digest('hex').slice(0, 16),
253
+ }
254
+ if (c.error != null) return { ...base, reason: c.error }
255
+ if (c.population === 'world') return { ...base, reason: `world unit — TTL ${d.ttl}, no content key` }
256
+ if (c.population === 'none') return { ...base, reason: c.reason ?? 'undeclared' }
257
+ if (this.scanError != null) return { ...base, reason: `git scan failed, nothing may be keyed: ${this.scanError}` }
258
+ const scan = this.scan!
259
+ if (this.toolchain.uncacheable.length > 0)
260
+ return { ...base, reason: `toolchain not describable: ${this.toolchain.uncacheable.join('; ')}` }
261
+
262
+ let inputs: string[]
263
+ try {
264
+ inputs = (d.inputs ?? []).map(normalizeInput).sort()
265
+ } catch (e) {
266
+ return { ...base, reason: e instanceof Error ? e.message : String(e) }
267
+ }
268
+ const stale = inputs.filter((p) => scan.missing.includes(p))
269
+ if (stale.length > 0) return { ...base, reason: `declared input(s) not on disk: ${stale.join(', ')}` }
270
+ const hidden = inputs.flatMap((p) => scan.excluded.get(p) ?? [])
271
+ const inputOids: [string, string][] = inputs.map((p) => [p, scan.oidByPath.get(p) ?? ''])
272
+ if (inputOids.some(([, oid]) => oid === '')) return { ...base, reason: 'git could not name one of the declared inputs' }
273
+
274
+ if (!ownReadable) return { ...base, reason: `git could not hash this unit's own source: ${own.filter((p) => this.codeOids.get(p) == null).join(', ')}` }
275
+ const components: string[] = [
276
+ `v=${KEY_VERSION}`,
277
+ `unit=${d.kind}:${d.id}`,
278
+ `bun=${Bun.version}`,
279
+ `toolchain=${this.toolchain.id}`,
280
+ `argv=${JSON.stringify(d.argv ?? [])}`,
281
+ ...(d.env ?? []).slice().sort().map((n) => `env:${n}=${process.env[n] ?? '<unset>'}`),
282
+ ...inputOids.map(([p, oid]) => `in:${p}=${oid}`),
283
+ ...codeLines,
284
+ ...(d.extra != null ? [`extra=${d.extra}`] : []),
285
+ // ignored content nobody keyed on is part of the key's MEANING:
286
+ // two runs that excluded different things answered different
287
+ // questions, and a silently narrowed key is the false green
288
+ ...hidden.sort().map((p) => `excluded=${p}`),
289
+ ]
290
+ const key = createHash('sha256').update(components.join('\n')).digest('hex').slice(0, 32)
291
+ return { ...base, key, components, inputOids }
292
+ }
293
+
294
+ keys(): UnitKey[] {
295
+ return this.decls.map((d) => this.keyFor(d.id))
296
+ }
297
+
298
+ /**
299
+ * POST RUN RE HASH. Re-scan exactly what this layer keyed on and report
300
+ * every path whose object id moved while the units were running.
301
+ *
302
+ * This is the answer to the poisoning class: a unit reads a file, an agent
303
+ * in the next window edits it, and the result gets stored under a key that
304
+ * describes the file as it was BEFORE the edit. Any drift here means the
305
+ * run's results describe a tree that no longer exists, and the caller
306
+ * discards them rather than storing a lie. Measured at ~140ms, which is
307
+ * the price of never having to explain a hit that was wrong.
308
+ */
309
+ rescan(): { drifted: string[]; error: string | null; ms: number } {
310
+ if (this.scanError != null) return { drifted: [], error: this.scanError, ms: 0 }
311
+ const started = performance.now()
312
+ try {
313
+ const after = scanPaths(this.root, this.scanPathsUsed)
314
+ const drifted: string[] = []
315
+ for (const [p, oid] of this.scan!.oidByPath) if (after.oidByPath.get(p) !== oid) drifted.push(p)
316
+ for (const [p] of after.oidByPath) if (!this.scan!.oidByPath.has(p)) drifted.push(p)
317
+ // the unit's own code drifts the same way a declared input does: an
318
+ // agent fixing a check WHILE that check runs would otherwise store
319
+ // the old verdict under the new code's key
320
+ const files = [...this.codeOids.keys()]
321
+ if (files.length > 0) {
322
+ const now = hashFiles(this.root, files)
323
+ for (const [p, oid] of this.codeOids) if (now.oids.get(p) !== oid) drifted.push(p)
324
+ }
325
+ return { drifted: [...new Set(drifted)].sort(), error: null, ms: performance.now() - started }
326
+ } catch (e) {
327
+ return { drifted: [], error: e instanceof GitScanError ? e.message : String(e), ms: performance.now() - started }
328
+ }
329
+ }
330
+ }
@@ -0,0 +1,314 @@
1
+ // WHAT IS ON DISK RIGHT NOW, as git object ids, using git's own index.
2
+ //
3
+ // A cache key needs the identity of the files a unit reads, INCLUDING files
4
+ // that are untracked or modified. `git hash-object` per file is O(files) and
5
+ // re-reads everything; git's index already carries a stat cache that answers
6
+ // "unchanged" without reading the blob. So: a scratch index of our own, per
7
+ // path adds, one `write-tree`, one `cat-file --batch-check`.
8
+ //
9
+ // FOUR THINGS THIS FILE EXISTS TO GET RIGHT, each reproduced before it was
10
+ // written:
11
+ //
12
+ // 1. PER PATH ADDS, EXIT CODE CHECKED. `git add -A -- a.txt missing/` exits
13
+ // 128, stages NOTHING, and `write-tree` then hands back the tree of the
14
+ // LAST successful write. Measured on a scratch repo: the batched form
15
+ // returned HEAD's tree while a.txt was modified on disk, so every key
16
+ // derived from it described a file nobody had. One add per path turns that
17
+ // into one loud failure on one path.
18
+ // 2. A MISSING DECLARED PATH IS NOT A GIT ERROR. A repo without `tests/` is
19
+ // normal, and hard-failing the gate on it would be wrong, so a path that
20
+ // does not exist never reaches `git add`: it comes back in `missing` and
21
+ // the caller refuses to cache that unit. That keeps rule 1's hard failure
22
+ // for what it is actually about, git going wrong on a path that IS there.
23
+ // 3. ONLY AN IGNORED PATH IS FORCE ADDED. `git add -Af` on a broad path force
24
+ // adds node_modules and costs 17.7s; the ignored paths that genuinely hold
25
+ // declared inputs here (.rv-social/patreon/, .rv-assets/out/) cost 76ms.
26
+ // One `ls-files --others -i --directory` names them, and node_modules is
27
+ // refused force-add at any depth.
28
+ // 4. THE SCRATCH INDEX IS SHARED, SO IT IS CONTENDED. Measured: 10 parallel
29
+ // `git add` on one index file, 9 fail with `Unable to create …lock`. Three
30
+ // agents in one checkout is the normal case here, so lock contention is
31
+ // retried and only a NON lock failure is fatal.
32
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
33
+ import { isAbsolute, join, normalize } from 'path'
34
+
35
+ /** what a scan measured. Every OID is a git object id of the CURRENT disk
36
+ * content, tracked or not. */
37
+ export type ScanResult = {
38
+ /** root tree of the scratch index after the adds */
39
+ treeOid: string
40
+ /** repo-relative path → git object id (tree for a dir, blob for a file) */
41
+ oidByPath: Map<string, string>
42
+ /** declared paths that are not on disk — the caller must not cache them */
43
+ missing: string[]
44
+ /** repo-relative path → ignored content under it that is NOT in the key
45
+ * (node_modules only, by policy: toolchain-identity.ts owns installs) */
46
+ excluded: Map<string, string[]>
47
+ /** wall time of the whole scan */
48
+ ms: number
49
+ }
50
+
51
+ export class GitScanError extends Error {}
52
+
53
+ /** the scratch index FORMAT. Bump it whenever what the scan stages changes.
54
+ * The index is persistent, and anything staged into it stays there until
55
+ * something stages over it: a run that force-added a path a later version no
56
+ * longer touches would keep serving that path's content forever, frozen at
57
+ * the moment of the bad run. A version stamp beside the file turns that into
58
+ * one discarded index instead of a permanently wrong key. */
59
+ export const SCAN_INDEX_VERSION = 2
60
+
61
+ const LOCK_RE = /Unable to create '.*\.lock'|index\.lock/i
62
+ const RETRIES = 12
63
+
64
+ const git = (root: string, args: string[], indexFile: string): { code: number; out: string } => {
65
+ const p = Bun.spawnSync(['git', ...args], {
66
+ cwd: root,
67
+ stdout: 'pipe',
68
+ stderr: 'pipe',
69
+ env: { ...process.env, GIT_INDEX_FILE: indexFile },
70
+ })
71
+ return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}` }
72
+ }
73
+
74
+ /** one git call that MUST succeed. Lock contention is retried (three agents in
75
+ * one checkout is normal); anything else throws with git's own words. */
76
+ function gitOrThrow(root: string, args: string[], indexFile: string): string {
77
+ let last = { code: -1, out: '' }
78
+ for (let attempt = 0; attempt < RETRIES; attempt++) {
79
+ last = git(root, args, indexFile)
80
+ if (last.code === 0) return last.out
81
+ if (!LOCK_RE.test(last.out)) break
82
+ Bun.sleepSync(10 + attempt * 10)
83
+ }
84
+ throw new GitScanError(`git ${args.join(' ')} exited ${last.code}: ${last.out.trim().split('\n').slice(0, 3).join(' / ')}`)
85
+ }
86
+
87
+ /** the THREE index files a scan uses, and why it is three and not one.
88
+ *
89
+ * SCRATCH is where the plain adds go, and it is COPIED FRESH from the repo's
90
+ * real index on every scan. Keeping it across runs was the obvious design and
91
+ * it was wrong twice, both measured. Anything staged into an index stays
92
+ * staged, so a path force-added by one run rode in every later run's tree,
93
+ * which made the whole-tree id a function of this machine's scan history and
94
+ * reproducible on no other checkout. And a file that stopped being tracked
95
+ * (`git rm --cached`) stayed in the scratch index, so the tree still carried
96
+ * it and a rule keyed on tracked-ness saw nothing change. The copy costs
97
+ * about 1ms and the real index is already warm, so the whole reset plus
98
+ * `git add -- .` plus `write-tree` measures 30 to 40ms, FASTER than the
99
+ * persistent index it replaces.
100
+ *
101
+ * IGNORED is a separate index for the force-adds, so a path one unit named
102
+ * can never leak into the whole-tree id another unit is keyed on. Without the
103
+ * split, one unit's key changed depending on which OTHER units were in the
104
+ * run, which is exactly the silently narrowed key the excluded list exists to
105
+ * prevent. It stays warm across runs: an entry left in it can only affect the
106
+ * path it belongs to, never the root tree.
107
+ *
108
+ * REAL is read, never written, and only to answer "does git ignore this".
109
+ * `ls-files --others` means UNTRACKED, so asking an index we force-add into
110
+ * reports a force-added path as no longer ignored, and the next run then
111
+ * plain-adds it, which exits 1 and takes the scan down. */
112
+ export function indexPaths(root: string): { scratch: string; ignored: string; real: string } {
113
+ const p = Bun.spawnSync(['git', 'rev-parse', '--absolute-git-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
114
+ if (p.exitCode !== 0) throw new GitScanError(`not a git repository: ${root} (${p.stderr.toString().trim()})`)
115
+ const gitDir = p.stdout.toString().trim()
116
+ const dir = join(gitDir, 'shipkit')
117
+ mkdirSync(dir, { recursive: true })
118
+ const real = join(gitDir, 'index')
119
+ const ignored = join(dir, 'scan-ignored.idx')
120
+ const stamp = join(dir, 'scan.idx.version')
121
+ const seen = ((): string => { try { return readFileSync(stamp, 'utf8').trim() } catch { return '' } })()
122
+ if (seen !== String(SCAN_INDEX_VERSION)) {
123
+ try { rmSync(ignored, { force: true }) } catch {}
124
+ try { writeFileSync(stamp, `${SCAN_INDEX_VERSION}\n`) } catch {}
125
+ }
126
+ // per-process scratch: it is rewritten from `real` every scan anyway, so
127
+ // sharing one file across concurrent runners buys no warmth and only adds
128
+ // lock contention
129
+ const scratch = join(dir, `scan-${process.pid}.idx`)
130
+ // a run that was killed leaves its file behind, and pids are reused, so
131
+ // sweep anything an hour old rather than trusting the pid to be free
132
+ try {
133
+ const cutoff = Date.now() - 3_600_000
134
+ for (const f of readdirSync(dir))
135
+ if (/^scan-\d+\.idx(\.tmp)?$/.test(f) && statSync(join(dir, f)).mtimeMs < cutoff) rmSync(join(dir, f), { force: true })
136
+ } catch {}
137
+ // tmp + rename: a half-copied index is a corrupt index, and git's error
138
+ // for that is not the one the retry loop is meant to absorb
139
+ try {
140
+ rmSync(scratch, { force: true })
141
+ if (existsSync(real)) {
142
+ const tmp = `${scratch}.tmp`
143
+ copyFileSync(real, tmp)
144
+ renameSync(tmp, scratch)
145
+ }
146
+ } catch {
147
+ // no seed is survivable: the adds are just colder
148
+ }
149
+ return { scratch, ignored, real }
150
+ }
151
+
152
+ /** the whole work tree as one input. A rule that walks the repo (SK001's test
153
+ * file probe, SK010's `git ls-files`) genuinely reads all of it, and saying so
154
+ * is more honest than naming three of the files it happens to open. Its object
155
+ * id is the scan's root tree, so it moves on any edit anywhere. */
156
+ export const WHOLE_TREE = '.'
157
+
158
+ /** repo-relative posix, no './', no trailing slash. Throws on anything that
159
+ * escapes the repo — a key component outside the tree is a key nobody else
160
+ * can reproduce. */
161
+ export function normalizeInput(p: string): string {
162
+ const n = normalize(p).replace(/\\/g, '/').replace(/\/+$/, '').replace(/^\.\//, '')
163
+ if (n === '.' || n === '') return WHOLE_TREE
164
+ if (isAbsolute(p) || n === '..' || n.startsWith('../'))
165
+ throw new GitScanError(`input path '${p}' is outside the repo — declare a path inside it, or '.' for the whole work tree`)
166
+ return n
167
+ }
168
+
169
+ /** drop paths already covered by an ancestor in the same set: adding `wiki`
170
+ * stages `wiki/build/...` too, and a second add of the child is pure cost. */
171
+ export function dropCovered(paths: string[]): string[] {
172
+ if (paths.includes(WHOLE_TREE)) return [WHOLE_TREE]
173
+ const sorted = [...new Set(paths)].sort()
174
+ const kept: string[] = []
175
+ for (const p of sorted) if (!kept.some((k) => p === k || p.startsWith(`${k}/`))) kept.push(p)
176
+ return kept
177
+ }
178
+
179
+ const underNodeModules = (p: string): boolean => p.split('/').includes('node_modules')
180
+
181
+ /**
182
+ * stage `paths` into the scratch index and read back one git object id each.
183
+ *
184
+ * Throws `GitScanError` on any git failure that is not lock contention. The
185
+ * caller's contract on a throw is absolute: produce NO key. A tree that may be
186
+ * stale describes files nobody has, and a key built on it is a false green
187
+ * waiting for its first hit.
188
+ */
189
+ export function scanPaths(root: string, rawPaths: string[]): ScanResult {
190
+ const started = performance.now()
191
+ const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
192
+ const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
193
+ const missing = wanted.filter((p) => !existsSync(join(root, p)))
194
+ const present = wanted.filter((p) => existsSync(join(root, p)))
195
+ const toAdd = dropCovered(present)
196
+ const named = wanted.filter((p) => p !== WHOLE_TREE)
197
+
198
+ // ignored content under what we are about to stage. One call, and it also
199
+ // answers "is this declared path itself ignored": `--directory` collapses
200
+ // a dir whose ENTIRE content is ignored, so the answer comes back as the
201
+ // dir with a trailing slash.
202
+ const ignored = toAdd.length === 0
203
+ ? []
204
+ : gitOrThrow(root, ['ls-files', '--others', '-i', '--exclude-standard', '--directory', '--', ...toAdd], realIndex)
205
+ .split('\n')
206
+ .map((l) => l.trim().replace(/\/+$/, ''))
207
+ .filter((l) => l !== '')
208
+ // ONLY a path a declaration NAMES is force-added. Ignored content that
209
+ // merely sits under a declared path is reported and left out.
210
+ //
211
+ // This is the whole of defense 3, and the wide reading of it is a disaster
212
+ // measured twice: with `.` declared (which every repo-walking rule
213
+ // declares), "ignored content under a declared path" is every ignored path
214
+ // in the repo, so one run force-staged two entire worktrees, the log dir,
215
+ // the private model dumps and the prompt corpora, for 680ms. Naming the
216
+ // path is what makes it an input.
217
+ //
218
+ // Installs are refused on top of that, at any depth AND as an ancestor:
219
+ // `--directory` collapses a dir whose entire content is ignored, so
220
+ // force-adding that one entry stages the node_modules inside it.
221
+ const declared = new Set(wanted)
222
+ const denied = ignored.filter(underNodeModules)
223
+ const forceAdd = ignored.filter((p) => declared.has(p) && !underNodeModules(p) && !denied.some((d) => d.startsWith(`${p}/`)))
224
+ // a path git itself calls ignored must NOT get the plain add: `git add --
225
+ // <ignored>` exits 1 and takes the whole scan down with it
226
+ const ignoredSet = new Set(ignored)
227
+ for (const p of toAdd) if (!ignoredSet.has(p)) gitOrThrow(root, ['add', '--', p], indexFile)
228
+ // force-adds land in their OWN index, so a path one unit named can never
229
+ // ride inside the whole-tree id another unit is keyed on
230
+ for (const p of forceAdd) gitOrThrow(root, ['add', '-Af', '--', p], ignoredIndex)
231
+
232
+ const treeOid = gitOrThrow(root, ['write-tree'], indexFile).trim()
233
+ const oidByPath = new Map<string, string>()
234
+ // the work tree's own id IS the tree the adds just produced — there is no
235
+ // `<tree>:` path to ask git for
236
+ if (wanted.includes(WHOLE_TREE)) oidByPath.set(WHOLE_TREE, treeOid)
237
+ const forceSet = new Set(forceAdd)
238
+ const plain = named.filter((p) => !forceSet.has(p))
239
+ for (const [tree, paths, index] of [[treeOid, plain, indexFile], [null, forceAdd, ignoredIndex]] as const) {
240
+ if (paths.length === 0) continue
241
+ const root_ = tree ?? gitOrThrow(root, ['write-tree'], index).trim()
242
+ const query = paths.map((p) => `${root_}:${p}`).join('\n')
243
+ const p = Bun.spawnSync(['git', 'cat-file', '--batch-check'], {
244
+ cwd: root,
245
+ stdin: Buffer.from(`${query}\n`),
246
+ stdout: 'pipe',
247
+ stderr: 'pipe',
248
+ env: { ...process.env, GIT_INDEX_FILE: index },
249
+ })
250
+ if (p.exitCode !== 0) throw new GitScanError(`git cat-file --batch-check exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
251
+ const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
252
+ if (lines.length !== paths.length)
253
+ throw new GitScanError(`git cat-file --batch-check answered ${lines.length} of ${paths.length} paths`)
254
+ paths.forEach((path, i) => {
255
+ const oid = lines[i]!.split(' ')[0] ?? ''
256
+ // 'missing' / 'ambiguous' come back in place of an id — a path git
257
+ // cannot name is a path we cannot key on
258
+ if (/^[0-9a-f]{40}$/.test(oid)) oidByPath.set(path, oid)
259
+ else if (!missing.includes(path)) missing.push(path)
260
+ })
261
+ }
262
+
263
+ const excluded = new Map<string, string[]>()
264
+ for (const d of ignored) {
265
+ // EVERY owner that covers it, not the first one found, and membership
266
+ // must not depend on what OTHER units declared. `.` sorts first and
267
+ // covers everything, so a `find` here handed the whole exclusion list
268
+ // to the whole-tree unit and left every narrower unit claiming it was
269
+ // blind to nothing. Filtering force-added paths out globally had the
270
+ // same shape: one unit naming `ign` deleted `excluded=ign` from a
271
+ // whole-tree unit that is still blind to it. Measured both ways, one
272
+ // unit producing two keys for one unchanged tree, and the component
273
+ // that vanished is the one that exists to stop a silently narrowed key.
274
+ for (const w of wanted) {
275
+ if (!(w === WHOLE_TREE || d === w || d.startsWith(`${w}/`))) continue
276
+ // the one owner that is NOT blind: it named the path itself, so it
277
+ // got the content through the force-add
278
+ if (d === w && forceSet.has(d)) continue
279
+ excluded.set(w, [...(excluded.get(w) ?? []), d])
280
+ }
281
+ }
282
+ return { treeOid, oidByPath, missing: [...new Set(missing)].sort(), excluded, ms: performance.now() - started }
283
+ }
284
+
285
+ /**
286
+ * blob ids for a list of FILES, one process, no index, no lock.
287
+ *
288
+ * The scratch index is the right tool for a declared input, which is usually a
289
+ * directory and usually unchanged (git's stat cache answers without reading).
290
+ * It is the wrong tool for the module-graph closure: those are individual
291
+ * files, they belong to no declared path, and staging each one would cost a
292
+ * `git add` spawn per file. `hash-object --stdin-paths` reads them all in one
293
+ * call and returns exactly what a blob of that content would be.
294
+ *
295
+ * A file that is not on disk is skipped and reported, never guessed at.
296
+ */
297
+ export function hashFiles(root: string, files: string[]): { oids: Map<string, string>; missing: string[] } {
298
+ const oids = new Map<string, string>()
299
+ const wanted = [...new Set(files.map(normalizeInput))].sort()
300
+ const missing = wanted.filter((f) => !existsSync(join(root, f)))
301
+ const present = wanted.filter((f) => existsSync(join(root, f)))
302
+ if (present.length === 0) return { oids, missing }
303
+ const p = Bun.spawnSync(['git', 'hash-object', '--stdin-paths'], {
304
+ cwd: root,
305
+ stdin: Buffer.from(`${present.join('\n')}\n`),
306
+ stdout: 'pipe',
307
+ stderr: 'pipe',
308
+ })
309
+ if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
310
+ const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
311
+ if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
312
+ present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
313
+ return { oids, missing }
314
+ }