rvlib-shipkit 1.5.0 → 1.7.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 (94) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +12 -8
  4. package/src/assets/server/studio-server.ts +14 -2
  5. package/src/check.ts +5 -8
  6. package/src/checks/SK001-package-json-shape.check.ts +5 -4
  7. package/src/checks/SK003-claude-md-shape.check.ts +2 -2
  8. package/src/checks/SK030-cache-audit.check.ts +34 -0
  9. package/src/checks/SK031-trace-audit.check.ts +39 -0
  10. package/src/checks/SK032-ignored-inputs.check.ts +79 -0
  11. package/src/cli/cmd_Assets.ts +10 -6
  12. package/src/cli/cmd_CacheAudit.ts +43 -0
  13. package/src/cli/cmd_CrossRepoActivity.ts +2 -1
  14. package/src/cli/cmd_GlobalProcesses.ts +4 -3
  15. package/src/cli/cmd_Home.ts +3 -2
  16. package/src/cli/cmd_Insights.ts +78 -0
  17. package/src/cli/cmd_Keys.ts +12 -1
  18. package/src/cli/cmd_Overview.ts +1 -1
  19. package/src/cli/cmd_Serve.ts +2 -1
  20. package/src/cli/cmd_TraceAudit.ts +62 -0
  21. package/src/cli.ts +6 -0
  22. package/src/collections/collection-types.ts +3 -0
  23. package/src/collections/providers.ts +5 -3
  24. package/src/core/app-icon.ts +154 -0
  25. package/src/core/audit-report.ts +21 -0
  26. package/src/core/cache-audit.ts +72 -0
  27. package/src/core/canonical-scripts.ts +1 -1
  28. package/src/core/check-ctx.ts +14 -5
  29. package/src/core/check-runner.ts +6 -3
  30. package/src/core/floor-resolve.ts +5 -7
  31. package/src/core/git-root.ts +13 -0
  32. package/src/core/ignored-block.ts +94 -0
  33. package/src/core/ignored-inputs.ts +47 -0
  34. package/src/core/jobs.ts +9 -2
  35. package/src/core/one-lib.ts +59 -4
  36. package/src/core/read-set.ts +97 -9
  37. package/src/core/run-ci.ts +6 -1
  38. package/src/core/run-fixers.ts +8 -2
  39. package/src/core/trace-audit.ts +129 -0
  40. package/src/home/gui-app/ShipkitGui.swift +125 -41
  41. package/src/home/gui-app.ts +24 -27
  42. package/src/home/home-routes.ts +15 -0
  43. package/src/home/home-start.ts +11 -5
  44. package/src/home/insights-shape.ts +124 -0
  45. package/src/home/insights.ts +90 -0
  46. package/src/home/open-app.ts +2 -2
  47. package/src/home/serve-web.ts +3 -3
  48. package/src/home/via-hub.ts +14 -0
  49. package/src/home/widgets.ts +12 -2
  50. package/src/ledger/db.ts +2 -13
  51. package/src/ledger/gate-checks.ts +21 -3
  52. package/src/ledger/git-key.ts +148 -30
  53. package/src/ledger/git-scan.ts +5 -2
  54. package/src/ledger/ledger.ts +131 -16
  55. package/src/ledger/store.ts +14 -0
  56. package/src/ledger/toolchain-identity.ts +210 -25
  57. package/src/problems/extract.ts +3 -0
  58. package/src/repo-config.ts +18 -1
  59. package/src/serve/hub-app/admin-svc.ts +36 -3
  60. package/src/serve/hub-app/admin-view.tsx +33 -1
  61. package/src/serve/hub-app/col-card.tsx +1 -44
  62. package/src/serve/hub-app/col-tables-view.tsx +71 -5
  63. package/src/serve/hub-app/col-tables.ts +27 -0
  64. package/src/serve/hub-app/home-st.ts +13 -6
  65. package/src/serve/hub-app/home-strip.css +7 -9
  66. package/src/serve/hub-app/home-strip.tsx +8 -17
  67. package/src/serve/hub-app/home.html +1 -0
  68. package/src/serve/hub-app/hub-app.tsx +20 -4
  69. package/src/serve/hub-app/hub.css +24 -20
  70. package/src/serve/hub-app/icons.tsx +1 -0
  71. package/src/serve/hub-app/index.html +1 -0
  72. package/src/serve/hub-app/insights-st.ts +111 -0
  73. package/src/serve/hub-app/insights-view.tsx +112 -0
  74. package/src/serve/hub-app/insights.css +24 -0
  75. package/src/serve/hub-app/keys.ts +11 -3
  76. package/src/serve/hub-app/machine-st.ts +23 -0
  77. package/src/serve/hub-app/palette-st.ts +2 -1
  78. package/src/serve/hub-app/processes-view.tsx +16 -9
  79. package/src/serve/hub-app/project-list.css +4 -1
  80. package/src/serve/hub-app/project-list.tsx +9 -5
  81. package/src/serve/hub-app/projects-svc.ts +11 -1
  82. package/src/serve/hub-app/repo-sections.ts +31 -17
  83. package/src/serve/hub-app/repo-st.ts +25 -6
  84. package/src/serve/hub-app/sidebar.tsx +5 -5
  85. package/src/serve/hub-app/tree-build.ts +2 -0
  86. package/src/serve/hub-app/tree-row.tsx +18 -5
  87. package/src/serve/hub-app/tree-svc.ts +27 -7
  88. package/src/serve/hub-runtime.ts +4 -2
  89. package/src/serve/project-gutter-route.ts +22 -1
  90. package/src/shipkit-files.ts +3 -2
  91. package/src/suite/judge-suites.ts +6 -0
  92. package/src/trace/interpose.c +655 -0
  93. package/src/trace/read-trace.ts +485 -0
  94. package/src/trace/tracer.ts +282 -0
@@ -9,15 +9,17 @@
9
9
  //
10
10
  // 1. DEFAULT DENY. A check that declared nothing is never replayed and never
11
11
  // stored. Forgetting to declare costs speed, never correctness, which is
12
- // what makes the burn-down a to-do rather than a hazard.
12
+ // what makes the burn-down a to-do rather than a hazard. A task that
13
+ // declares nothing is keyed on the work tree instead (gate-checks.ts).
13
14
  // 2. POST RUN RE HASH. Results are BUFFERED while the gate runs and written
14
15
  // only after the inputs are hashed a second time. A check whose inputs
15
16
  // moved between its run and that second hash answered about a tree that no
16
17
  // longer exists, so its result is discarded rather than stored under a key
17
18
  // that describes the file as it was BEFORE the edit. Three agents in one
18
19
  // checkout is the normal case here, so that window is not theoretical.
19
- // 3. THE GATE BINARY IS IN THE KEY. Editing shipkit moves every key in every
20
- // repo (ledger/toolchain-identity.ts content-hashes the cli tree).
20
+ // 3. THE GATE CODE IS IN THE KEY. Editing the shipkit code that runs a check
21
+ // or stores its answer moves that key in every repo, and an edit no answer
22
+ // depends on moves none (ledger/toolchain-identity.ts).
21
23
  // 4. RELEASE NEVER READS IT. `bump` and `release` pass read:false, not
22
24
  // configurably. Every remaining hole is then capped at "an agent saw a
23
25
  // stale local green", never "a stale green authorised a publish".
@@ -41,7 +43,7 @@
41
43
  import { join } from 'path'
42
44
  import type { RvtConfig } from '../ts/config.ts'
43
45
  import type { Line } from '../core/wire.ts'
44
- import { KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
46
+ import { classify, KeyLayer, type CheckDecl, type CheckKey } from './git-key.ts'
45
47
  import { shipkitRoot } from './db.ts'
46
48
  import { Answers, answersDb, FRESH_ENV, type AnswerRef, type AnswerWrite } from './store.ts'
47
49
  import type { Problem } from '../problems/problem-line.ts'
@@ -81,8 +83,20 @@ export type CheckOutcome = {
81
83
  measuredMs: number
82
84
  }
83
85
 
84
- /** what a READS answer is stored as: the verdict and what it read */
85
- type Stored = CheckOutcome & { reads?: ReadSet }
86
+ /** what an answer is stored as: the verdict, what it read (READS), and the
87
+ * key components it was stored under, so a later miss can say what moved */
88
+ type Stored = CheckOutcome & { reads?: ReadSet; components?: string[] }
89
+
90
+ /** why a check did not come from the cache this run, one per check that ran
91
+ * or cannot be cached. The verdict prints them grouped, so a re-run is never
92
+ * unexplained */
93
+ export type RanWhy = {
94
+ /** `moved`: its key or a read changed. `first`: nothing stored for it.
95
+ * `not kept`: it ran and its answer cannot be stored. `never`: no key can
96
+ * describe it. `off`: this run reads no cache */
97
+ group: 'moved' | 'first' | 'not kept' | 'never' | 'off'
98
+ why: string
99
+ }
86
100
 
87
101
  export type LedgerOpts = {
88
102
  /** the cli entry driving this run (toolchain identity needs it) */
@@ -128,6 +142,8 @@ export class Ledger {
128
142
  private misses = 0
129
143
  private unkeyed = 0
130
144
  private joined = 0
145
+ private readonly ranWhy = new Map<string, RanWhy>()
146
+ private fresh = false
131
147
 
132
148
  private constructor(
133
149
  readonly layer: KeyLayer,
@@ -144,7 +160,9 @@ export class Ledger {
144
160
  const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg, scope: o.scope })
145
161
  const fresh = o.fresh === true || process.env[FRESH_ENV] != null
146
162
  const reading = (o.read ?? ledgerReadEnabled()) && !fresh
147
- return new Ledger(layer, new Answers(answersDb(root)), join(shipkitRoot(root), 'inflight'), reading, o.now ?? Date.now)
163
+ const l = new Ledger(layer, new Answers(answersDb(root)), join(shipkitRoot(root), 'inflight'), reading, o.now ?? Date.now)
164
+ l.fresh = fresh
165
+ return l
148
166
  }
149
167
 
150
168
  /** the key for a check, computed once per run and remembered: `keyFor`
@@ -168,13 +186,24 @@ export class Ledger {
168
186
 
169
187
  /** the stored answer for this check, or null when it must run. Counts what
170
188
  * it was asked, so the run can report hit/miss/uncacheable honestly. */
189
+ /** drop the stored answer for a check's CURRENT key: the cache audit found
190
+ * that a fresh run disagrees with it, and a fresh failure is never stored
191
+ * in its place, so the stale green would keep being served */
192
+ forget(id: string): void {
193
+ const k = this.key(id)
194
+ if (k.key != null) this.answers.drop(NS, k.id, k.key)
195
+ }
196
+
171
197
  replay(id: string): (CheckOutcome & { at: number }) | null {
172
198
  const k = this.key(id)
173
- if (k.key == null) { this.unkeyed++; return null }
174
- if (!this.reading) { this.misses++; return null }
199
+ if (k.key == null) { this.unkeyed++; this.ranWhy.set(id, { group: 'never', why: this.neverWhy(k) }); return null }
200
+ if (!this.reading) { this.misses++; this.ranWhy.set(id, { group: 'off', why: this.fresh ? '--fresh' : 'cache reads off' }); return null }
175
201
  const e = this.read(k)
176
- if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
177
- if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, () => this.view()) != null)) { this.misses++; return null }
202
+ if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; this.ranWhy.set(id, this.movedWhy(k)); return null }
203
+ if (k.population === 'reads') {
204
+ const drift = e.payload.reads == null ? 'its read set was not stored' : this.driftOf(e.payload.reads, () => this.view())
205
+ if (drift != null) { this.misses++; this.ranWhy.set(id, { group: 'moved', why: drift }); return null }
206
+ }
178
207
  this.hits++
179
208
  this.replayed.push({ ns: NS, name: k.id, key: k.key })
180
209
  return { ...e.payload, at: e.at }
@@ -212,23 +241,70 @@ export class Ledger {
212
241
 
213
242
  /** a check that missed the store and then adopted another run's in
214
243
  * flight answer: counted apart, so the header never claims it measured */
215
- noteAttached(): void {
244
+ noteAttached(id: string): void {
216
245
  if (this.misses > 0) this.misses--
217
246
  this.joined++
247
+ this.ranWhy.delete(id)
248
+ }
249
+
250
+ /** the lines that say why every check that did not replay ran, one per
251
+ * reason, so a re-run is never unexplained. A run that reads no cache
252
+ * says so in its header instead. `quiet`: a check its author decided is
253
+ * never cached, which nobody acts on (--verbose prints it) */
254
+ explain(): { line: Line; quiet: boolean }[] {
255
+ const order: RanWhy['group'][] = ['moved', 'first', 'not kept', 'never']
256
+ const groups = new Map<string, { w: RanWhy; ids: string[] }>()
257
+ for (const [id, w] of this.ranWhy) {
258
+ if (w.group === 'off') continue
259
+ const k = `${order.indexOf(w.group)}\0${w.why}`
260
+ const g = groups.get(k) ?? { w, ids: [] }
261
+ g.ids.push(id)
262
+ groups.set(k, g)
263
+ }
264
+ return [...groups.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([, { w, ids }]) => {
265
+ const who = listShort(ids, 5)
266
+ const text = w.group === 'never' ? `never cached: ${w.why} (${who})`
267
+ : w.group === 'not kept' ? `ran, not kept: ${w.why} (${who})`
268
+ : `ran: ${w.why} (${who})`
269
+ return { line: { kind: 'ok' as const, text }, quiet: w.group === 'never' && w.why.startsWith('never: ') }
270
+ })
218
271
  }
219
272
 
220
273
  /** buffer what a check just measured. Nothing reaches the store until
221
274
  * commit() has re-hashed the inputs (rule 2). A READS check with no read
222
275
  * set (it reached the world) is never stored (rule 7). */
223
- record(id: string, outcome: CheckOutcome, reads?: ReadSet | null): void {
276
+ record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null): void {
224
277
  const k = this.key(id)
225
278
  if (k.key == null) return
226
279
  if (k.population === 'reads') {
227
- if (reads == null) return
228
- this.buffered.set(id, { ...outcome, reads })
280
+ if (reads == null) { this.ranWhy.set(id, { group: 'not kept', why: notKept ?? 'it read outside what the cache can see' }); return }
281
+ this.buffered.set(id, { ...outcome, reads, components: k.components })
229
282
  return
230
283
  }
231
- this.buffered.set(id, outcome)
284
+ this.buffered.set(id, { ...outcome, components: k.components })
285
+ }
286
+
287
+ /** why a check has no key: its own words when it decided `never`, the
288
+ * key layer's otherwise */
289
+ private neverWhy(k: CheckKey): string {
290
+ const d = this.layer.decls.find((x) => x.id === k.id)
291
+ return d != null && classify(d).decided === true ? `never: ${d.never}` : k.reason ?? 'undeclared'
292
+ }
293
+
294
+ /** what moved since this check's last stored answer, by diffing the key
295
+ * components it was stored under against this run's */
296
+ private movedWhy(k: CheckKey): RanWhy {
297
+ const prev = this.answers.latest<Stored>(NS, k.id)
298
+ if (prev == null) return { group: 'first', why: 'nothing stored for it yet' }
299
+ const before = prev.payload.components
300
+ if (before == null) return { group: 'moved', why: 'its key changed' }
301
+ const moved = movedParts(before, k.components)
302
+ return moved.length === 0 ? { group: 'first', why: 'nothing stored for this key yet' } : { group: 'moved', why: `${listShort(moved, 3)} changed` }
303
+ }
304
+
305
+ /** every check that did not come from the cache, and why */
306
+ get why(): ReadonlyMap<string, RanWhy> {
307
+ return this.ranWhy
232
308
  }
233
309
 
234
310
  /** re-hash, then write what still describes the tree on disk. */
@@ -295,6 +371,45 @@ export class Ledger {
295
371
  }
296
372
  }
297
373
 
374
+ /** the key components that differ between two keys of one check, in words */
375
+ export function movedParts(before: string[], after: string[]): string[] {
376
+ const split = (c: string): [string, string] => {
377
+ // several excluded paths share the name: each one is its own component
378
+ if (c.startsWith('excluded=')) return [c, '']
379
+ const i = c.indexOf('=')
380
+ return i < 0 ? [c, ''] : [c.slice(0, i), c.slice(i + 1)]
381
+ }
382
+ const a = new Map(before.map(split))
383
+ const b = new Map(after.map(split))
384
+ const names = [...new Set([...a.keys(), ...b.keys()])].filter((n) => a.get(n) !== b.get(n))
385
+ return [...new Set(names.map(componentWords))]
386
+ }
387
+
388
+ /** one key component's name as a reader says it */
389
+ function componentWords(name: string): string {
390
+ if (name === 'v') return "shipkit's key format"
391
+ if (name === 'bun') return 'the bun version'
392
+ if (name === 'frozen') return 'frozen mode'
393
+ if (name === 'toolchain') return "shipkit's gate code"
394
+ if (name === 'install') return 'node_modules'
395
+ if (name === 'argv') return 'its command'
396
+ if (name === 'lib') return 'its shipkit code'
397
+ if (name === 'libused') return 'the shipkit modules this repo imports'
398
+ if (name === 'extra') return 'its settings'
399
+ if (name === 'node') return 'the node version'
400
+ if (name.startsWith('names:')) return `the names under ${name.slice(6)}`
401
+ if (name.startsWith('env:')) return `$${name.slice(4)}`
402
+ if (name === 'in:.') return 'the work tree'
403
+ if (name.startsWith('in:') || name.startsWith('code:')) return name.slice(name.indexOf(':') + 1)
404
+ if (name.startsWith('excluded')) return 'ignored files under its inputs'
405
+ return name
406
+ }
407
+
408
+ /** `a, b, c`, and `a, b, +3 more` past `max` */
409
+ function listShort(items: string[], max: number): string {
410
+ return items.length <= max ? items.join(', ') : `${items.slice(0, max - 1).join(', ')}, +${items.length - max + 1} more`
411
+ }
412
+
298
413
  /** where askers of one question meet while one of them measures it: a file
299
414
  * name per (ns, id, key), hashed so any id is a safe name */
300
415
  export function inflightSlot(dir: string, ns: string, id: string, key: string): string {
@@ -164,6 +164,20 @@ export class Answers {
164
164
  })
165
165
  }
166
166
 
167
+ /** the newest answer of one check under ANY key: what a miss diffs its
168
+ * own key against, to say what moved */
169
+ latest<T>(ns: string, name: string): Stored<T> | null {
170
+ const row = this.with(READ_BUSY_MS, (h) => h.query('select v, key, at, keeps, payload from answers where ns = ? and name = ? order by at desc limit 1').get(ns, name) as (Row & { key: string }) | null)
171
+ if (row == null) return null
172
+ try {
173
+ const payload = JSON.parse(row.payload) as T
174
+ if (Number(row.v) !== STORE_VERSION || typeof row.at !== 'number' || payload == null) return null
175
+ return { v: Number(row.v), key: row.key, at: row.at, keeps: row.keeps ?? undefined, payload }
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
167
181
  drop(ns: string, name: string, key: string): void {
168
182
  this.with(BUSY_MS, (h) => h.query('delete from answers where ns = ? and name = ? and key = ?').run(ns, name, key))
169
183
  }
@@ -1,4 +1,4 @@
1
- // WHAT PROGRAM IS ABOUT TO RUN, as one hash.
1
+ // WHAT PROGRAM IS ABOUT TO RUN, as hashes.
2
2
  //
3
3
  // A cache key built from the repo's git tree answers "what source is being
4
4
  // gated". It cannot answer "what is doing the gating", and those two already
@@ -16,8 +16,12 @@
16
16
  // package.json files of exactly the same byte length, so only the parsed
17
17
  // `version` separates them.
18
18
  //
19
- // So the key has three parts: the runtime, the gate binary, and what is
20
- // actually installed.
19
+ // So there are three parts: the RUNNER (the runtime, the gate code that runs
20
+ // and stores every answer, the data files a check reads), the INSTALL (what is
21
+ // actually in node_modules), and the toolkit SOURCE, which each check is keyed
22
+ // on through its own module closure (LibSource), never as a whole. A whole
23
+ // tree hash made a css edit in the hub, or a new test file, move every key in
24
+ // every repo on the machine, and a repo that links the toolkit paid it twice.
21
25
  //
22
26
  // THE ONE DELIBERATE MISS: a human hand-editing a file inside `node_modules`
23
27
  // is accepted, not caught. Catching it means content-hashing the whole tree,
@@ -27,11 +31,21 @@
27
31
  // debugging on purpose can delete the ledger.
28
32
  import { createHash } from 'crypto'
29
33
  import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs'
30
- import { isAbsolute, join, relative, resolve } from 'path'
34
+ import { dirname, isAbsolute, join, posix, relative, resolve } from 'path'
35
+ import { ALWAYS_SKIP, type RvtConfig } from '../ts/config.ts'
36
+ import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
37
+ import { blobOid } from '../core/read-set.ts'
31
38
 
32
39
  export type Toolchain = {
33
- /** the identity, 16 hex chars, stable for identical programs */
40
+ /** the RUNNER, 16 hex chars: the runtime, the gate code that runs and
41
+ * stores every answer, and the data files a check reads. Every key
42
+ * carries it */
34
43
  id: string
44
+ /** what is installed, 16 hex chars. Only a key whose answer runs the
45
+ * repo's own tools carries it: a content or a world check */
46
+ install: string
47
+ /** the toolkit's source, for a check's own module closure */
48
+ lib: LibSource
35
49
  }
36
50
 
37
51
  /** the gate binary driving THIS process, which is what the key hashes when a
@@ -88,9 +102,173 @@ function hashTree(h: ReturnType<typeof createHash>, dir: string, tag: string): v
88
102
  h.update(`${tag}=${files.length}\n`)
89
103
  }
90
104
 
91
- /** the gate binary's own source, content-hashed. This is what makes editing
92
- * shipkit invalidate every key in every repo, in-repo copy or dependency. */
93
- const hashCliTree = (h: ReturnType<typeof createHash>, cli: string): void => hashTree(h, resolve(cli, '..'), 'clifiles')
105
+ /** a path with its links resolved, as given when it cannot be (a file not
106
+ * there yet): /var and /private/var must name one file */
107
+ const real = (p: string): string => { try { return realpathSync(p) } catch { return p } }
108
+
109
+ /** the modules that run a check and store its answer, relative to the
110
+ * toolkit's src. A gate binary without them (a test fixture) is keyed on its
111
+ * own entry's closure */
112
+ export const RUNNER_ENTRIES = ['core/run-ci.ts', 'core/run-check.ts']
113
+
114
+ /** file kinds under src that no check and no runner reads: the page the hub
115
+ * serves and the mac app's source. Every other non code file there is data a
116
+ * check can read (the scaffold templates), so it is in the runner id */
117
+ const NOT_READ_BY_CHECKS = /\.(css|html|swift)$/
118
+
119
+ /**
120
+ * the toolkit's source, keyed one module closure at a time. A check is keyed
121
+ * on the files its own module can reach, the runner on the files the runner
122
+ * can reach, and a file neither reaches (the hub page, a test) moves nothing.
123
+ * Each file is hashed once per process.
124
+ */
125
+ export class LibSource {
126
+ /** the package root, resolved: the dir holding the source dir */
127
+ readonly pkgRoot: string
128
+ /** the source dir, resolved: the gate binary's own dir */
129
+ readonly srcDir: string
130
+ private readonly cfg: RvtConfig
131
+ private readonly oids = new Map<string, string>()
132
+ private readonly closures = new Map<string, string[]>()
133
+ /** file → the files it imports directly, so a file shared by many
134
+ * closures is read and parsed once */
135
+ private readonly direct = new Map<string, string[]>()
136
+ private readonly probed = new Map<string, boolean>()
137
+ /** the package name a repo imports the toolkit by, null for a toolkit
138
+ * with no package.json (a test fixture) */
139
+ readonly name: string | null
140
+
141
+ constructor(cliEntry: string) {
142
+ this.srcDir = dirname(realpathSync(cliEntry))
143
+ this.pkgRoot = dirname(this.srcDir)
144
+ let name: string | null = null
145
+ try { name = (JSON.parse(readFileSync(join(this.pkgRoot, 'package.json'), 'utf8')) as { name?: string }).name ?? null } catch { name = null }
146
+ this.name = name
147
+ // the toolkit imports itself by its package name, and a closure that
148
+ // could not follow those would stop at the first such import
149
+ this.cfg = { root: this.pkgRoot, roots: [relative(this.pkgRoot, this.srcDir)], skip: [...ALWAYS_SKIP], packages: name == null ? [] : [{ name, dir: '.' }] }
150
+ }
151
+
152
+ /** the toolkit file a package specifier names (`rvlib-shipkit/src/x.ts`),
153
+ * or null when it names none */
154
+ fileOf(spec: string): string | null {
155
+ if (this.name == null || !spec.startsWith(`${this.name}/`)) return null
156
+ const abs = join(this.pkgRoot, spec.slice(this.name.length + 1))
157
+ return this.owns(abs) && existsSync(abs) ? abs : null
158
+ }
159
+
160
+ /** is this absolute path a source file of the toolkit? */
161
+ owns(abs: string): boolean {
162
+ const r = relative(this.srcDir, real(abs))
163
+ return r !== '' && !r.startsWith('..') && !isAbsolute(r) && /\.tsx?$/.test(abs)
164
+ }
165
+
166
+ /** package-relative files one entry can reach, dynamic imports included.
167
+ * The same walk as `importClosure`, with each file's imports kept */
168
+ closureOf(abs: string): string[] {
169
+ const rel = relative(this.pkgRoot, real(abs)).replace(/\\/g, '/')
170
+ let c = this.closures.get(rel)
171
+ if (c == null) {
172
+ const seen = new Set<string>([rel])
173
+ const queue = [rel]
174
+ while (queue.length > 0) for (const t of this.importsOf(queue.shift()!)) if (!seen.has(t)) { seen.add(t); queue.push(t) }
175
+ c = [...seen].sort()
176
+ this.closures.set(rel, c)
177
+ }
178
+ return c
179
+ }
180
+
181
+ private importsOf(file: string): string[] {
182
+ let d = this.direct.get(file)
183
+ if (d == null) {
184
+ const has = (p: string): boolean => {
185
+ let r = this.probed.get(p)
186
+ if (r == null) { r = /\.tsx?$/.test(p) && existsSync(join(this.pkgRoot, p)); this.probed.set(p, r) }
187
+ return r
188
+ }
189
+ let text = ''
190
+ // the entry itself must exist: a missing one throws below, when it is hashed
191
+ try { text = readFileSync(join(this.pkgRoot, file), 'utf8') } catch { text = '' }
192
+ const resolveHere = (spec: string): string | null => resolveSpec(this.cfg, file, spec, has)
193
+ const targets = [...extractSpecs(text, { dynamic: true }).map(resolveHere), ...pathLiterals(text, file, resolveHere)]
194
+ d = [...new Set(targets.filter((t): t is string => t != null).map((t) => posix.normalize(t)))]
195
+ this.direct.set(file, d)
196
+ }
197
+ return d
198
+ }
199
+
200
+ /** blob id of one package-relative file. A file that cannot be read
201
+ * THROWS: a closure only partly read must never be keyed */
202
+ private oid(rel: string): string {
203
+ let o = this.oids.get(rel)
204
+ if (o == null) { o = blobOid(readFileSync(join(this.pkgRoot, rel))); this.oids.set(rel, o) }
205
+ return o
206
+ }
207
+
208
+ /** the identity of what these entries can reach, 16 hex chars */
209
+ closureKey(absEntries: string[]): string {
210
+ const files = [...new Set(absEntries.flatMap((e) => this.closureOf(e)))].sort()
211
+ const h = createHash('sha256')
212
+ for (const f of files) h.update(`${f}=${this.oid(f)}\n`)
213
+ h.update(`n=${files.length}\n`)
214
+ return h.digest('hex').slice(0, 16)
215
+ }
216
+
217
+ /** the data files under src a check can read, as lines */
218
+ dataLines(): string[] {
219
+ const out: string[] = []
220
+ const walk = (dir: string, rel: string): void => {
221
+ for (const name of readdirSync(dir).sort()) {
222
+ if (name === 'node_modules' || name.startsWith('.')) continue
223
+ const abs = join(dir, name)
224
+ const r = `${rel}/${name}`
225
+ if (statSync(abs).isDirectory()) walk(abs, r)
226
+ else if (!/\.tsx?$/.test(name) && !NOT_READ_BY_CHECKS.test(name)) out.push(`data:${r}=${this.oid(r)}`)
227
+ }
228
+ }
229
+ walk(this.srcDir, relative(this.pkgRoot, this.srcDir))
230
+ // the version a check compares a repo's pin against
231
+ if (existsSync(join(this.pkgRoot, 'package.json'))) out.push(`data:package.json=${this.oid('package.json')}`)
232
+ return out
233
+ }
234
+ }
235
+
236
+ /** toolkit modules a file RUNS by path rather than imports: a preload handed
237
+ * to bun, a cli spawned through `c.exec`. `join(import.meta.dir, 'x.ts')`,
238
+ * `new URL('x.ts', import.meta.url)` and `import.meta.resolve('pkg/x.ts')`,
239
+ * resolved like an import. The gate's own `cli.ts` is left out: a module
240
+ * that names it spawns the whole program, and what of it produces an answer
241
+ * is the runner every key already carries */
242
+ function pathLiterals(text: string, file: string, resolveSpecifier: (spec: string) => string | null): (string | null)[] {
243
+ const out: (string | null)[] = []
244
+ const str = /'([^'\n]+)'|"([^"\n]+)"/g
245
+ const strings = (s: string): string[] => [...s.matchAll(str)].map((x) => x[1] ?? x[2]!)
246
+ const rel = (parts: string[]): string | null => {
247
+ const target = posix.normalize(posix.join(posix.dirname(file), ...parts))
248
+ if (target.endsWith('/cli.ts') || target === 'cli.ts') return null
249
+ return resolveSpecifier(`./${posix.relative(posix.dirname(file), target)}`)
250
+ }
251
+ for (const m of text.matchAll(/join\(\s*import\.meta\.dir\s*,((?:\s*(?:'[^'\n]*'|"[^"\n]*")\s*,?)+)\)/g)) {
252
+ const parts = strings(m[1]!)
253
+ if (parts.length > 0 && /\.tsx?$/.test(parts.at(-1)!)) out.push(rel(parts))
254
+ }
255
+ for (const m of text.matchAll(/new URL\(\s*('[^'\n]+'|"[^"\n]+")\s*,\s*import\.meta\.url\s*\)/g)) {
256
+ const p = strings(m[1]!)[0]!
257
+ if (/\.tsx?$/.test(p)) out.push(rel([p]))
258
+ }
259
+ for (const m of text.matchAll(/import\.meta\.resolve\(\s*('[^'\n]+'|"[^"\n]+")\s*\)/g)) {
260
+ const p = strings(m[1]!)[0]!
261
+ if (!p.endsWith('/cli.ts')) out.push(resolveSpecifier(p))
262
+ }
263
+ return out
264
+ }
265
+
266
+ /** the runner: the modules that run a check and store its answer, plus the
267
+ * data files a check reads */
268
+ function runnerLines(lib: LibSource, cliEntry: string): string[] {
269
+ const entries = RUNNER_ENTRIES.map((e) => join(lib.srcDir, e)).filter((e) => existsSync(e))
270
+ return [`runner=${lib.closureKey(entries.length > 0 ? entries : [realpathSync(cliEntry)])}`, ...lib.dataLines()]
271
+ }
94
272
 
95
273
  /**
96
274
  * what ONE install holds, as a hash: every top-level package's name, version
@@ -129,18 +307,14 @@ export function installIdentity(nodeModules: string): string {
129
307
  * moves an mtime without changing content produces no miss.
130
308
  */
131
309
  export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain {
132
- const h = createHash('sha256')
133
310
  // BOTH sides resolved, or the comparison is wrong on macOS: /var is a
134
311
  // symlink to /private/var, so an in-repo cli under a resolved path reads
135
312
  // as outside an unresolved root. That mistake is silent and expensive: it
136
- // content-hashes the gate binary on every single run, and folds absolute
137
- // paths into the id, which is what would stop two worktrees agreeing.
313
+ // folds absolute paths into the id, which is what would stop two
314
+ // worktrees agreeing.
138
315
  const root = ((): string => { try { return realpathSync(repoRoot) } catch { return resolve(repoRoot) } })()
139
316
 
140
- // the runtime. Free, and a bun upgrade genuinely changes behaviour
141
- h.update(`bun=${Bun.version}\n`)
142
-
143
- // the gate binary, ALWAYS content-hashed, and its path never folded in.
317
+ // the gate code, ALWAYS content-hashed, and its path never folded in.
144
318
  //
145
319
  // The earlier shape wrote `cli=in-repo` for a binary inside the repo and
146
320
  // leaned on the git tree key to cover it. Both halves were wrong, and both
@@ -149,19 +323,27 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
149
323
  // covered none of it. And between a checkout that HOSTS the binary and a
150
324
  // worktree that runs the same one through the global shim, one wrote
151
325
  // `in-repo` and the other wrote an absolute path, so two checkouts running
152
- // one identical program could never agree on a key. Content-hashing costs
153
- // 3.5 to 9.2ms on a 163-file tree, and it is the only form that is the
154
- // same value wherever the caller stands.
155
- hashCliTree(h, realpathSync(cliEntry))
326
+ // one identical program could never agree on a key. Package-relative
327
+ // content is the only form that is the same value wherever the caller
328
+ // stands.
329
+ const lib = new LibSource(cliEntry)
330
+ const r = createHash('sha256')
331
+ // the runtime. Free, and a bun upgrade genuinely changes behaviour
332
+ r.update(`bun=${Bun.version}\n`)
333
+ for (const l of runnerLines(lib, cliEntry)) r.update(`${l}\n`)
334
+ return { id: r.digest('hex').slice(0, 16), install: installOf(root, lib), lib }
335
+ }
156
336
 
157
- // what is installed. The lockfile states the intent; the tree states the fact
337
+ /** what is installed. The lockfile states the intent; the tree states the fact */
338
+ function installOf(root: string, lib: LibSource): string {
339
+ const h = createHash('sha256')
158
340
  const lock = join(root, 'bun.lock')
159
341
  h.update(`lock=${existsSync(lock) ? createHash('sha256').update(readFileSync(lock)).digest('hex') : 'none'}\n`)
160
342
  const nm = join(root, 'node_modules')
161
343
  if (!existsSync(nm)) {
162
344
  // a worktree nobody installed is a REAL difference, not a missing input
163
345
  h.update('nm=absent\n')
164
- return { id: h.digest('hex').slice(0, 16) }
346
+ return h.digest('hex').slice(0, 16)
165
347
  }
166
348
  const names = installedNames(nm)
167
349
  for (const n of names) {
@@ -180,16 +362,19 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
180
362
  // its files do, and so does what it has installed itself, since its
181
363
  // own imports resolve there. Outside the try on purpose: a linked tree
182
364
  // that cannot be read throws, and the key layer then keys nothing.
183
- // ~20ms for the whole rvlib-shipkit tree
184
- if (outside != null) {
365
+ //
366
+ // The toolkit running this gate is the one exception: each check is
367
+ // keyed on its own closure of it, and hashing it whole here put a css
368
+ // edit of the hub into every content key of every repo that links it
369
+ if (outside != null && outside !== lib.pkgRoot) {
185
370
  hashTree(h, outside, `linked:${n}`)
186
371
  h.update(`${installIdentity(join(outside, 'node_modules'))}\n`)
187
372
  }
188
373
  let version = 'MISSING'
189
374
  try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
190
- h.update(`${n}@${version}${link}\n`)
375
+ h.update(`${n}@${version}${link === '' || outside === lib.pkgRoot ? '' : link}\n`)
191
376
  }
192
377
  // the count catches a removal that leaves every survivor identical
193
378
  h.update(`n=${names.length}\n`)
194
- return { id: h.digest('hex').slice(0, 16) }
379
+ return h.digest('hex').slice(0, 16)
195
380
  }
@@ -10,6 +10,7 @@ import { existsSync } from 'fs'
10
10
  import { isAbsolute, relative, resolve } from 'path'
11
11
  import type { Line } from '../core/wire.ts'
12
12
  import type { Problem, Severity } from './problem-line.ts'
13
+ import { BUN_RECAP } from '../suite/judge-suites.ts'
13
14
 
14
15
  export type ExtractCtx = {
15
16
  /** the repo root every file is made relative to */
@@ -124,6 +125,8 @@ export function fromBunTest(text: string, ctx: ExtractCtx): Problem[] {
124
125
  for (const raw of stripAnsi(text).split('\n')) {
125
126
  const s = BUN_SUITE.exec(raw)
126
127
  if (s != null) { suite = repoFile(ctx, s[1]!, false); error = null; at = null; continue }
128
+ // every line after it repeats a failure already read above
129
+ if (BUN_RECAP.test(raw)) break
127
130
  const e = BUN_ERROR.exec(raw)
128
131
  if (e != null) { error = e[1]!.trim(); at = null; continue }
129
132
  const f = BUN_FRAME.exec(raw)
@@ -143,6 +143,15 @@ export type RepoConfig = {
143
143
  /** repo-contributed .gitignore lines, rendered INSIDE rule SK004's managed
144
144
  * block — for ecosystem-specific names the published lib may not carry */
145
145
  gitignore?: string[]
146
+ /** gitignored paths a task READS, by task id (`'*'` for every task),
147
+ * repo-relative: a path joins that task's key by content (generated
148
+ * types, a local fixture), and `dir/**` by the names under the dir (an
149
+ * output folder a suite lists). A task keyed on the whole work tree sees
150
+ * no gitignored file, and a declared list names none either, so both
151
+ * carry these. The traced audit (SK031) finds the missing ones and the
152
+ * fix pass writes them into their own managed block; an entry written
153
+ * outside it stops that repair */
154
+ ignoredInputs?: Record<string, string[]>
146
155
  /** untrack the paths shipkit generates on every run (STATUS.md and
147
156
  * friends) the first time it sees them tracked, in one commit. Default
148
157
  * ON: a generated file left in the index dirties every tree forever and
@@ -286,7 +295,7 @@ export type RepoConfig = {
286
295
  logDir?: string
287
296
  }
288
297
 
289
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
298
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
290
299
 
291
300
  export function defineRepo(c: RepoConfig): RepoConfig {
292
301
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -298,6 +307,14 @@ export function defineRepo(c: RepoConfig): RepoConfig {
298
307
  throw new Error(`defineRepo: reviewer needs { name: kebab-case, brief: path to the standing brief }`)
299
308
  if (c.serve != null && 'processes' in c.serve)
300
309
  throw new Error(`defineRepo: serve.processes was removed; declare each process in a <name>.shipkit.ts file as \`export default globalProcess({...})\``)
310
+ if (c.ignoredInputs != null && (typeof c.ignoredInputs !== 'object' || Array.isArray(c.ignoredInputs)))
311
+ throw new Error(`defineRepo: ignoredInputs is { <task id or '*'>: [paths] }`)
312
+ for (const [task, paths] of Object.entries(c.ignoredInputs ?? {})) {
313
+ if (task === '' || !Array.isArray(paths)) throw new Error(`defineRepo: ignoredInputs '${task}' must name a task (or '*') and list paths`)
314
+ for (const p of paths)
315
+ if (typeof p !== 'string' || p === '' || p.startsWith('/') || p.split('/').includes('..'))
316
+ throw new Error(`defineRepo: ignoredInputs '${String(p)}' must be a repo-relative path inside the repo`)
317
+ }
301
318
  // unknown keys die loud — a typo'd or removed key silently changing the
302
319
  // gate is exactly the never-silence failure mode (A23)
303
320
  for (const k of Object.keys(c))