rvlib-shipkit 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +12 -8
- package/src/assets/server/studio-server.ts +14 -2
- package/src/check.ts +5 -8
- package/src/checks/SK001-package-json-shape.check.ts +5 -4
- package/src/checks/SK003-claude-md-shape.check.ts +2 -2
- package/src/checks/SK030-cache-audit.check.ts +34 -0
- package/src/checks/SK031-trace-audit.check.ts +39 -0
- package/src/checks/SK032-ignored-inputs.check.ts +79 -0
- package/src/cli/cmd_Assets.ts +10 -6
- package/src/cli/cmd_CacheAudit.ts +43 -0
- package/src/cli/cmd_CrossRepoActivity.ts +2 -1
- package/src/cli/cmd_GlobalProcesses.ts +4 -3
- package/src/cli/cmd_Home.ts +3 -2
- package/src/cli/cmd_Keys.ts +12 -1
- package/src/cli/cmd_Overview.ts +1 -1
- package/src/cli/cmd_Serve.ts +2 -1
- package/src/cli/cmd_TraceAudit.ts +62 -0
- package/src/cli.ts +4 -0
- package/src/collections/collection-types.ts +3 -0
- package/src/collections/providers.ts +5 -3
- package/src/core/app-icon.ts +154 -0
- package/src/core/audit-report.ts +21 -0
- package/src/core/cache-audit.ts +72 -0
- package/src/core/canonical-scripts.ts +1 -1
- package/src/core/check-ctx.ts +14 -5
- package/src/core/check-runner.ts +6 -3
- package/src/core/floor-resolve.ts +5 -7
- package/src/core/git-root.ts +13 -0
- package/src/core/ignored-block.ts +94 -0
- package/src/core/ignored-inputs.ts +47 -0
- package/src/core/jobs.ts +9 -2
- package/src/core/one-lib.ts +59 -4
- package/src/core/read-set.ts +97 -9
- package/src/core/run-ci.ts +6 -1
- package/src/core/run-fixers.ts +8 -2
- package/src/core/trace-audit.ts +120 -0
- package/src/home/gui-app.ts +24 -27
- package/src/home/home-start.ts +11 -5
- package/src/home/open-app.ts +2 -2
- package/src/home/via-hub.ts +14 -0
- package/src/ledger/db.ts +2 -13
- package/src/ledger/gate-checks.ts +21 -3
- package/src/ledger/git-key.ts +148 -30
- package/src/ledger/git-scan.ts +5 -2
- package/src/ledger/ledger.ts +131 -16
- package/src/ledger/store.ts +14 -0
- package/src/ledger/toolchain-identity.ts +210 -25
- package/src/problems/extract.ts +3 -0
- package/src/repo-config.ts +18 -1
- package/src/serve/hub-app/admin-svc.ts +36 -3
- package/src/serve/hub-app/admin-view.tsx +33 -1
- package/src/serve/hub-app/col-card.tsx +1 -44
- package/src/serve/hub-app/col-tables-view.tsx +71 -5
- package/src/serve/hub-app/col-tables.ts +27 -0
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +5 -9
- package/src/serve/hub-app/home-strip.tsx +4 -17
- package/src/serve/hub-app/hub-app.tsx +18 -4
- package/src/serve/hub-app/hub.css +24 -20
- package/src/serve/hub-app/keys.ts +10 -2
- package/src/serve/hub-app/processes-view.tsx +16 -9
- package/src/serve/hub-app/project-list.css +4 -1
- package/src/serve/hub-app/project-list.tsx +6 -3
- package/src/serve/hub-app/projects-svc.ts +10 -0
- package/src/serve/hub-app/repo-sections.ts +31 -17
- package/src/serve/hub-app/repo-st.ts +24 -5
- package/src/serve/hub-app/sidebar.tsx +5 -5
- package/src/serve/hub-app/tree-build.ts +2 -0
- package/src/serve/hub-app/tree-row.tsx +18 -5
- package/src/serve/hub-app/tree-svc.ts +27 -7
- package/src/serve/hub-runtime.ts +4 -2
- package/src/serve/project-gutter-route.ts +22 -1
- package/src/suite/judge-suites.ts +6 -0
- package/src/trace/interpose.c +655 -0
- package/src/trace/read-trace.ts +485 -0
- package/src/trace/tracer.ts +282 -0
package/src/ledger/ledger.ts
CHANGED
|
@@ -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
|
|
20
|
-
// repo
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
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'
|
|
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 {
|
package/src/ledger/store.ts
CHANGED
|
@@ -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
|
|
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
|
|
20
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
const
|
|
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
|
-
//
|
|
137
|
-
//
|
|
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
|
|
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.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
184
|
-
|
|
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
|
|
379
|
+
return h.digest('hex').slice(0, 16)
|
|
195
380
|
}
|
package/src/problems/extract.ts
CHANGED
|
@@ -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)
|
package/src/repo-config.ts
CHANGED
|
@@ -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))
|