rvlib-shipkit 1.0.1 → 1.1.1

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 (64) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +30 -4
  4. package/src/check.ts +6 -0
  5. package/src/checks/SK005-rvlib-versions.check.ts +18 -3
  6. package/src/checks/SK021-github-login.check.ts +123 -0
  7. package/src/cli/cmd_Asks.ts +1 -1
  8. package/src/cli/cmd_Check.ts +1 -1
  9. package/src/cli/cmd_Ci.ts +1 -1
  10. package/src/cli/cmd_Fleet.ts +5 -57
  11. package/src/cli.ts +2 -3
  12. package/src/collections/checks.ts +12 -2
  13. package/src/collections/collection-types.ts +34 -18
  14. package/src/collections/comfy.ts +1 -0
  15. package/src/collections/posts.ts +1 -0
  16. package/src/collections/providers.ts +71 -43
  17. package/src/collections/reflections.ts +1 -0
  18. package/src/collections/state-rules.ts +29 -13
  19. package/src/collections/tests.ts +1 -0
  20. package/src/collections/wikis.ts +1 -0
  21. package/src/core/budget.ts +60 -0
  22. package/src/core/check-ctx.ts +2 -1
  23. package/src/core/check-runner.ts +43 -6
  24. package/src/core/fd-table.ts +37 -0
  25. package/src/core/git-root.ts +26 -2
  26. package/src/core/lib-version.ts +16 -0
  27. package/src/core/migrate-layout.ts +2 -1
  28. package/src/core/motherhome.ts +2 -4
  29. package/src/core/package-manager.ts +4 -2
  30. package/src/core/raw-access.ts +11 -3
  31. package/src/core/reexec.ts +26 -0
  32. package/src/core/run-check.ts +36 -16
  33. package/src/core/run-ci.ts +29 -24
  34. package/src/core/self-update.ts +59 -24
  35. package/src/core/update-policy.ts +25 -0
  36. package/src/fleet/scan-fleet.ts +4 -15
  37. package/src/fleet/update-fleet.ts +85 -0
  38. package/src/ledger/gate-checks.ts +19 -5
  39. package/src/ledger/git-key.ts +10 -5
  40. package/src/ledger/git-scan.ts +29 -17
  41. package/src/ledger/ledger.ts +14 -6
  42. package/src/ledger/repo-view.ts +8 -6
  43. package/src/ledger/run-log.ts +10 -4
  44. package/src/ledger/run-timeline.ts +21 -9
  45. package/src/problems/problem-board.ts +13 -2
  46. package/src/problems/problem-line.ts +9 -0
  47. package/src/repo-config.ts +22 -1
  48. package/src/serve/hub-app/check-view.tsx +12 -11
  49. package/src/serve/hub-app/cols-svc.ts +16 -9
  50. package/src/serve/hub-app/dur.ts +10 -1
  51. package/src/serve/hub-app/header-bar.tsx +11 -0
  52. package/src/serve/hub-app/help.tsx +1 -0
  53. package/src/serve/hub-app/hub-st.ts +3 -4
  54. package/src/serve/hub-app/hub-types.ts +5 -0
  55. package/src/serve/hub-app/hub.css +3 -0
  56. package/src/serve/hub-app/timeline-view.tsx +4 -4
  57. package/src/serve/hub-app/tree-build.ts +1 -2
  58. package/src/serve/hub-problems.ts +35 -0
  59. package/src/serve/project-gutter.ts +3 -2
  60. package/src/serve/run-serve.ts +51 -12
  61. package/src/serve/serve-hub.ts +10 -3
  62. package/src/serve/stale-restart.ts +77 -0
  63. package/src/suite/suite-cli.ts +14 -10
  64. package/src/ts/source-graph.ts +3 -2
@@ -22,6 +22,7 @@ import { discoverChecks } from '../core/discover-checks.ts'
22
22
  import { checkCacheExtra, describeFirst, type TaskDef, type CheckFrom } from '../check.ts'
23
23
  import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/check-runner.ts'
24
24
  import { parseDuration } from '../core/wire.ts'
25
+ import { budgetOf } from '../core/budget.ts'
25
26
  import { whenOfLegacyCheck, whenOfDisabledSlot, whenOfRule, whenOfTask, whenOfCheck, type WhenDecl } from '../core/when.ts'
26
27
  import type { CheckDecl } from './git-key.ts'
27
28
  import { populationOf } from '../problems/problem-line.ts'
@@ -34,9 +35,9 @@ import { declaredTestUnits, inputsOutsideUnits, isTestFile } from '../suite/test
34
35
  /** why an undeclared `run` check cannot be keyed on its reads, or null when it
35
36
  * can. A built-in is walked inside the toolkit, a repo check inside the repo
36
37
  * with the toolkit left out (the toolchain id covers it) */
37
- function rawAccessOf(root: string, u: DiscoveredCheck, configs: Map<string, RvtConfig>): string | null {
38
+ function rawAccessOf(root: string, u: DiscoveredCheck, configs: Map<string, RvtConfig>, texts: Map<string, string>): string | null {
38
39
  try {
39
- const r = u.rel == null ? rawAccessInClosure(LIB_ROOT, u.path, { configs }) : rawAccessInClosure(root, u.path, { skip: LIB_ROOT, configs })
40
+ const r = u.rel == null ? rawAccessInClosure(LIB_ROOT, u.path, { configs, texts }) : rawAccessInClosure(root, u.path, { skip: LIB_ROOT, configs, texts })
40
41
  return r == null ? null : rawAccessLine(r)
41
42
  } catch (e) {
42
43
  return `the modules ${u.rel ?? u.path} imports could not be listed (${String(e).slice(0, 120)}): declare inputs or never`
@@ -103,6 +104,9 @@ export type CheckRow = {
103
104
  /** the wall clock ceiling it DECLARED, null when it named none and takes
104
105
  * the default for its kind of body */
105
106
  timeoutMs: number | null
107
+ /** what a healthy run takes, RESOLVED: the repo's `budgets`, else what
108
+ * the check declared, else the default for its kind (core/budget.ts) */
109
+ budgetMs: number
106
110
  /** excluded from a default run unless named with `--with` */
107
111
  optIn: boolean
108
112
  /** an idempotent repair exists: `shipkit check --fix <id>` applies it,
@@ -150,7 +154,7 @@ const ttlMsOf = (ttl: string | undefined): number => (ttl == null || ttl === ''
150
154
 
151
155
  /** the runnable half of a row: everything the runner needs, and nothing it
152
156
  * has to look up again */
153
- function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?: string | null } = {}): RunnableCheck {
157
+ function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?: string | null; unbudgeted?: boolean } = {}): RunnableCheck {
154
158
  return {
155
159
  id: row.decl.id,
156
160
  title: row.title,
@@ -161,6 +165,7 @@ function runnableOf(row: CheckRow, body: CheckBody, o: { fix?: unknown; pointer?
161
165
  deps: row.deps ?? [],
162
166
  group: row.group,
163
167
  timeoutMs: row.timeoutMs ?? (row.build ? BUILD_TIMEOUT_MS : CHECK_TIMEOUT_MS),
168
+ ...(o.unbudgeted === true ? {} : { budgetMs: row.budgetMs }),
164
169
  ttlMs: ttlMsOf(row.decl.ttl),
165
170
  rotation: row.decl.extra,
166
171
  writes: row.decl.writes === true,
@@ -251,6 +256,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
251
256
  owner: t.cmd == null ? null : t.cmd.join(' '),
252
257
  group: t.group ?? null,
253
258
  timeoutMs: t.timeoutMs ?? null,
259
+ budgetMs: budgetOf(t.id, undefined, { build: true, cmd: t.cmd != null, world: t.ttl != null }, c.budgets),
254
260
  optIn: t.optIn === true,
255
261
  // a build step runs a TOOL, and repairing what a tool reports is
256
262
  // that tool's own job (a formatter, a codemod), never the gate's
@@ -259,7 +265,10 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
259
265
  deps: t.deps ?? null,
260
266
  from: taskFrom(t.id),
261
267
  }
262
- out.push({ row, runnable: runnableOf(row, taskBody(t)) })
268
+ // at the full tier a build step runs every slow suite on purpose, and
269
+ // its env (and so its cache key) differs from the quick run, so its
270
+ // time is not judged and its answer never stands in for a quick one
271
+ out.push({ row, runnable: runnableOf(row, taskBody(t), { unbudgeted: full }) })
263
272
  }
264
273
  // a slot `defaults: { <id>: false }` turned off never resolves to a task,
265
274
  // so without this it had no row at all: no `runs in` value, no reason, and
@@ -277,6 +286,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
277
286
  owner: null,
278
287
  group: null,
279
288
  timeoutMs: null,
289
+ budgetMs: budgetOf(id, undefined, { build: true, cmd: false, world: false }, c.budgets),
280
290
  optIn: false,
281
291
  fixable: false,
282
292
  when: whenOfDisabledSlot(id),
@@ -290,6 +300,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
290
300
  }
291
301
  // every `*.check.ts` this repo and the toolkit declare
292
302
  const configs = new Map<string, RvtConfig>()
303
+ const texts = new Map<string, string>()
293
304
  for (const u of checks) {
294
305
  const w = whenOfCheck(c, u)
295
306
  // an in process body declaring nothing is keyed on what it reads
@@ -299,7 +310,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
299
310
  const undeclared = u.def.run != null && u.def.inputs == null && u.def.ttl == null && u.def.never == null
300
311
  // conservative: any module its body can reach that touches the disk or
301
312
  // a process around `c` keeps it off its reads (core/raw-access.ts)
302
- const raw = undeclared ? rawAccessOf(root, u, configs) : null
313
+ const raw = undeclared ? rawAccessOf(root, u, configs, texts) : null
303
314
  const reads = undeclared && raw == null
304
315
  const extra = checkCacheExtra({ id: u.id, cacheKey: u.def.cacheKey }, env, w)
305
316
  const row: CheckRow = {
@@ -323,6 +334,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
323
334
  owner: u.owner,
324
335
  group: u.def.group ?? null,
325
336
  timeoutMs: u.def.timeoutMs ?? null,
337
+ budgetMs: budgetOf(u.id, u.def.budgetMs, { build: u.def.build === true, cmd: u.def.cmd != null, world: u.def.ttl != null }, c.budgets),
326
338
  optIn: false,
327
339
  fixable: u.def.fix != null,
328
340
  when: w,
@@ -358,6 +370,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
358
370
  owner: r.owner,
359
371
  group: null,
360
372
  timeoutMs: null,
373
+ budgetMs: budgetOf(r.id, undefined, { build: false, cmd: false, world: r.ttl != null }, c.budgets),
361
374
  optIn: false,
362
375
  fixable: r.fix != null,
363
376
  when: whenOfRule(c, r.id),
@@ -401,6 +414,7 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
401
414
  owner: rel,
402
415
  group: task?.group ?? null,
403
416
  timeoutMs: task?.timeoutMs ?? null,
417
+ budgetMs: budgetOf(d.name, undefined, { build: false, cmd: true, world: d.ttl != null }, c.budgets),
404
418
  optIn: false,
405
419
  // the reader is a source scanner, so it cannot carry a function
406
420
  fixable: false,
@@ -173,6 +173,9 @@ export type KeyLayerOpts = {
173
173
  cliEntry: string
174
174
  /** module-graph config; loaded from the repo when omitted */
175
175
  cfg?: RvtConfig
176
+ /** the checks this run executes. Only they are scanned and hashed, and any
177
+ * other id gets no key. Omitted = every check */
178
+ scope?: ReadonlySet<string>
176
179
  }
177
180
 
178
181
  /**
@@ -191,6 +194,7 @@ export class KeyLayer {
191
194
  private readonly codeOids: Map<string, string>,
192
195
  private readonly toolchain: { id: string; uncacheable: string[] },
193
196
  private readonly scanPathsUsed: string[],
197
+ private readonly scope: ReadonlySet<string> | null,
194
198
  /** wall time of the WHOLE layer: toolchain identity, the module-graph
195
199
  * closures, the git scan and the closure hashing. Reported rather
196
200
  * than the scan alone, because the scan alone is the flattering
@@ -237,7 +241,8 @@ export class KeyLayer {
237
241
  const closureByCheck = new Map<string, string[]>()
238
242
  // computed for EVERY check with code, not only the content ones: a
239
243
  // world check's TTL cache must break when the probe itself is rewritten
240
- for (const d of decls) {
244
+ const scoped = o.scope == null ? decls : decls.filter((d) => o.scope!.has(d.id))
245
+ for (const d of scoped) {
241
246
  const entries = d.code ?? []
242
247
  if (entries.length > 0) closureByCheck.set(d.id, importClosure(cfg, entries))
243
248
  }
@@ -246,14 +251,13 @@ export class KeyLayer {
246
251
  // are mostly directories, mostly unchanged, and git's stat cache
247
252
  // answers them without reading a byte.
248
253
  const paths = new Set<string>()
249
- for (const d of decls) {
254
+ for (const d of scoped) {
250
255
  if (classify(d).population !== 'content') continue
251
256
  for (const p of d.inputs ?? []) paths.add(p)
252
257
  }
253
258
 
254
259
  // the closure is INDIVIDUAL FILES belonging to no declared path, so it
255
- // goes through one `hash-object --stdin-paths` instead of one `git
256
- // add` spawn per file (measured: 15ms for the whole set)
260
+ // is hashed in process (hashFiles) instead of staged one `git add` each
257
261
  const closureFiles = new Set<string>()
258
262
  for (const files of closureByCheck.values()) for (const f of files) closureFiles.add(f)
259
263
 
@@ -268,7 +272,7 @@ export class KeyLayer {
268
272
  // downstream may key on it. Loud, and the whole run goes uncached.
269
273
  scanError = e instanceof GitScanError ? e.message : String(e)
270
274
  }
271
- return new KeyLayer(root, decls, registration, scan, scanError, closureByCheck, codeOids, toolchain, [...paths].sort(), performance.now() - started)
275
+ return new KeyLayer(root, decls, registration, scan, scanError, closureByCheck, codeOids, toolchain, [...paths].sort(), o.scope ?? null, performance.now() - started)
272
276
  }
273
277
 
274
278
  /** the key for one check, or the reason there is none. */
@@ -284,6 +288,7 @@ export class KeyLayer {
284
288
  codeKey: own.length === 0 || !ownReadable ? null : createHash('sha256').update(codeLines.join('\n')).digest('hex').slice(0, 16),
285
289
  }
286
290
  if (c.error != null) return { ...base, reason: c.error }
291
+ if (this.scope != null && !this.scope.has(d.id)) return { ...base, reason: 'not one of the checks this run executes, so nothing was scanned for it' }
287
292
  if (c.population === 'world') return { ...base, reason: `world check — TTL ${d.ttl}, no content key` }
288
293
  if (c.population === 'none') return { ...base, reason: c.reason ?? 'undeclared' }
289
294
  if (this.scanError != null) return { ...base, reason: `git scan failed, nothing may be keyed: ${this.scanError}` }
@@ -35,6 +35,7 @@ import { isAbsolute, join, normalize, resolve } from 'path'
35
35
  import { probeGitRoot } from '../core/git-root.ts'
36
36
  import { treePrefix } from '../core/nesting.ts'
37
37
  import { gitBin } from '../core/git-bin.ts'
38
+ import { blobOid } from '../core/read-set.ts'
38
39
 
39
40
  /** what a scan measured. Every OID is a git object id of the CURRENT disk
40
41
  * content, tracked or not. */
@@ -59,6 +60,14 @@ export type ScanResult = {
59
60
 
60
61
  export class GitScanError extends Error {}
61
62
 
63
+ export type ScanOpts = {
64
+ /** the caller reads the whole tree's id and nothing else, so git is never
65
+ * asked what it ignores: that answer only feeds force-adds and the
66
+ * excluded list, and `git add -- .` leaves ignored files out by itself.
67
+ * `excluded` and `ignored` come back empty */
68
+ treeOnly?: boolean
69
+ }
70
+
62
71
  /** the scratch index FORMAT. Bump it whenever what the scan stages changes.
63
72
  * The index is persistent, and anything staged into it stays there until
64
73
  * something stages over it: a run that force-added a path a later version no
@@ -208,10 +217,15 @@ const underNodeModules = (p: string): boolean => p.split('/').includes('node_mod
208
217
  * stale describes files nobody has, and a key built on it is a false green
209
218
  * waiting for its first hit.
210
219
  */
211
- export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult): ScanResult {
220
+ export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult, o: ScanOpts = {}): ScanResult {
212
221
  const started = performance.now()
222
+ if (o.treeOnly === true && (rawPaths.length !== 1 || normalizeInput(rawPaths[0]!) !== WHOLE_TREE))
223
+ throw new GitScanError(`treeOnly scans the whole tree and nothing else, asked for: ${rawPaths.join(', ')}`)
213
224
  const { scratch: indexFile, ignored: ignoredIndex, real: realIndex } = indexPaths(root)
214
225
  const wanted = [...new Set(rawPaths.map(normalizeInput))].sort()
226
+ // nothing asked for: the repo is still validated above, and no tree is
227
+ // written that nobody reads (a run of read checks declares no path)
228
+ if (wanted.length === 0) return { treeOid: '', oidByPath: new Map(), missing: [], excluded: new Map(), ignored: [], ignoredTreeOid: null, ms: performance.now() - started }
215
229
  const missing = wanted.filter((p) => !existsSync(join(root, p)))
216
230
  const present = wanted.filter((p) => existsSync(join(root, p)))
217
231
  const toAdd = dropCovered(present)
@@ -227,7 +241,9 @@ export function scanPaths(root: string, rawPaths: string[], prior?: ScanResult):
227
241
  // A path that became ignored in between makes its plain add fail, which
228
242
  // throws, and a throw stores nothing: the one failure mode stays loud.
229
243
  const sameMissing = prior != null && prior.missing.join('\n') === [...new Set(missing)].sort().join('\n')
230
- const ignored = prior != null && sameMissing
244
+ const ignored = o.treeOnly === true
245
+ ? []
246
+ : prior != null && sameMissing
231
247
  ? prior.ignored
232
248
  : toAdd.length === 0
233
249
  ? []
@@ -363,14 +379,16 @@ function excludedBy(wanted: string[], ignored: string[], forced: Set<string>): M
363
379
  }
364
380
 
365
381
  /**
366
- * blob ids for a list of FILES, one process, no index, no lock.
382
+ * blob ids for a list of FILES, hashed in process: no git, no index, no lock.
367
383
  *
368
384
  * The scratch index is the right tool for a declared input, which is usually a
369
385
  * directory and usually unchanged (git's stat cache answers without reading).
370
386
  * It is the wrong tool for the module-graph closure: those are individual
371
- * files, they belong to no declared path, and staging each one would cost a
372
- * `git add` spawn per file. `hash-object --stdin-paths` reads them all in one
373
- * call and returns exactly what a blob of that content would be.
387
+ * files, they belong to no declared path, and a check's code is a handful of
388
+ * small sources that cost less to read than one spawn. The id is the blob id
389
+ * of the bytes on disk, the same one the read recorder uses (`blobOid`). Git
390
+ * would apply clean filters (eol, lfs) first, so this id is only ever STRICTER:
391
+ * a filter-only change is a miss, never a false hit.
374
392
  *
375
393
  * A file that is not on disk is skipped and reported, never guessed at.
376
394
  */
@@ -379,16 +397,10 @@ export function hashFiles(root: string, files: string[]): { oids: Map<string, st
379
397
  const wanted = [...new Set(files.map(normalizeInput))].sort()
380
398
  const missing = wanted.filter((f) => !existsSync(join(root, f)))
381
399
  const present = wanted.filter((f) => existsSync(join(root, f)))
382
- if (present.length === 0) return { oids, missing }
383
- const p = Bun.spawnSync([gitBin(), 'hash-object', '--stdin-paths'], {
384
- cwd: root,
385
- stdin: Buffer.from(`${present.join('\n')}\n`),
386
- stdout: 'pipe',
387
- stderr: 'pipe',
388
- })
389
- if (p.exitCode !== 0) throw new GitScanError(`git hash-object exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n').slice(0, 3).join(' / ')}`)
390
- const lines = p.stdout.toString().split('\n').filter((l) => l !== '')
391
- if (lines.length !== present.length) throw new GitScanError(`git hash-object answered ${lines.length} of ${present.length} files`)
392
- present.forEach((f, i) => oids.set(f, lines[i]!.trim()))
400
+ for (const f of present) {
401
+ try { oids.set(f, blobOid(readFileSync(join(root, f)))) } catch (e) {
402
+ throw new GitScanError(`cannot read ${f} to hash it: ${e instanceof Error ? e.message : String(e)}`)
403
+ }
404
+ }
393
405
  return { oids, missing }
394
406
  }
@@ -92,6 +92,8 @@ export type LedgerOpts = {
92
92
  fresh?: boolean
93
93
  /** module-graph config, loaded from the repo when omitted */
94
94
  cfg?: RvtConfig
95
+ /** the checks this run executes (KeyLayerOpts.scope) */
96
+ scope?: ReadonlySet<string>
95
97
  now?: () => number
96
98
  }
97
99
 
@@ -133,7 +135,7 @@ export class Ledger {
133
135
  ) {}
134
136
 
135
137
  static open(root: string, decls: CheckDecl[], o: LedgerOpts): Ledger {
136
- const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg })
138
+ const layer = KeyLayer.open(root, decls, { cliEntry: o.cliEntry, cfg: o.cfg, scope: o.scope })
137
139
  const fresh = o.fresh === true || process.env[FRESH_ENV] != null
138
140
  const reading = (o.read ?? ledgerReadEnabled()) && !fresh
139
141
  return new Ledger(layer, storeRoot(root), reading, o.now ?? Date.now)
@@ -166,7 +168,7 @@ export class Ledger {
166
168
  if (!this.reading) { this.misses++; return null }
167
169
  const e = readStored<Stored>(this.path(k), k.key)
168
170
  if (e == null || !Array.isArray(e.payload.lines)) { this.misses++; return null }
169
- if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, this.view()) != null)) { this.misses++; return null }
171
+ if (k.population === 'reads' && (e.payload.reads == null || this.driftOf(e.payload.reads, () => this.view()) != null)) { this.misses++; return null }
170
172
  this.hits++
171
173
  return e.payload
172
174
  }
@@ -178,8 +180,14 @@ export class Ledger {
178
180
  return readStored<Stored>(this.path(k), k.key)?.payload.reads ?? null
179
181
  }
180
182
 
181
- private driftOf(reads: ReadSet, view: RepoView): string | null {
182
- return readSetDrift(this.layer.root, reads, (g) => (view.fallback != null ? null : view.list(g)))
183
+ /** the view is asked for only when a glob needs it: a read set with no
184
+ * listing is validated on file content alone, and a whole tree git scan
185
+ * per replay would buy nothing */
186
+ private driftOf(reads: ReadSet, view: () => RepoView): string | null {
187
+ return readSetDrift(this.layer.root, reads, (g) => {
188
+ const v = view()
189
+ return v.fallback != null ? null : v.list(g)
190
+ })
183
191
  }
184
192
 
185
193
  /** where askers of this check's exact question meet while one of them is
@@ -230,11 +238,11 @@ export class Ledger {
230
238
  // a fresh list, never this run's: the question is whether the tree
231
239
  // still is what the body read
232
240
  let fresh: RepoView | null = null
241
+ const freshView = (): RepoView => (fresh ??= RepoView.open(this.layer.root, this.viewMemo))
233
242
  for (const [id, outcome] of this.buffered) {
234
243
  if (touched.has(id)) { discarded.push(id); continue }
235
244
  if (outcome.reads != null) {
236
- fresh ??= RepoView.open(this.layer.root, this.viewMemo)
237
- const moved = this.driftOf(outcome.reads, fresh)
245
+ const moved = this.driftOf(outcome.reads, freshView)
238
246
  if (moved != null) { discarded.push(id); drifted.push(moved); continue }
239
247
  }
240
248
  const k = this.key(id)
@@ -23,12 +23,12 @@ export class RepoView {
23
23
  readonly scan: ScanResult | null = null,
24
24
  ) {}
25
25
 
26
- /** `prior`: an earlier view of the same root in this run. Its scan
27
- * answers git's ignore question again, and the tree is still staged and
28
- * hashed fresh, so the list is today's */
26
+ /** `prior`: an earlier view of the same root in this run. The tree is
27
+ * still staged and hashed fresh, so the list is today's, and an unchanged
28
+ * tree reuses the prior's answer */
29
29
  static open(root: string, prior?: RepoView | null): RepoView {
30
30
  try {
31
- const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined)
31
+ const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined, { treeOnly: true })
32
32
  const tree = scan.oidByPath.get(WHOLE_TREE)
33
33
  if (tree == null) throw new GitScanError('git could not name the work tree')
34
34
  return new RepoView(filesOf(root, tree), null, scan)
@@ -45,14 +45,16 @@ export class RepoView {
45
45
  }
46
46
 
47
47
  /** a tree id names its content, so its file list is a pure function of the
48
- * id and one listing serves every view of that tree */
48
+ * id and one listing serves every view of that tree. `--full-tree` keeps it
49
+ * so: without it git filters the listing by the cwd's path under the work
50
+ * tree, and a nested folder's own subtree lists nothing */
49
51
  const listed = new Map<string, string[]>()
50
52
  const LISTED_MAX = 64
51
53
 
52
54
  function filesOf(root: string, tree: string): string[] {
53
55
  const hit = listed.get(tree)
54
56
  if (hit != null) return hit
55
- const p = Bun.spawnSync([gitBin(), 'ls-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
57
+ const p = Bun.spawnSync([gitBin(), 'ls-tree', '--full-tree', '-r', '-z', '--name-only', tree], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
56
58
  if (p.exitCode !== 0) throw new GitScanError(`git ls-tree exited ${p.exitCode}: ${p.stderr.toString().trim()}`)
57
59
  const files = p.stdout.toString().split('\0').filter((f) => f !== '').sort()
58
60
  if (listed.size >= LISTED_MAX) listed.delete(listed.keys().next().value!)
@@ -21,7 +21,7 @@
21
21
  import { Database } from 'bun:sqlite'
22
22
  import { existsSync } from 'fs'
23
23
  import type { Line } from '../core/wire.ts'
24
- import { dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
24
+ import { BUSY_MS, dbPath, INSERT_RUN, openDb, UPSERT_INVOCATION, UPSERT_LAST, UPSERT_LIVE } from './db.ts'
25
25
  import { LIVE_BEAT_MS, liveRowDead, type LiveRow, type LiveState } from './live-runs.ts'
26
26
  import { KEEP_MS } from './store.ts'
27
27
 
@@ -187,10 +187,16 @@ export class RunLog {
187
187
  const hadLive = this.liveSent !== ''
188
188
  this.quiet()
189
189
  this.liveSent = ''
190
- try { this.liveDb?.close() } catch { /* already gone */ }
190
+ // the live row's handle writes the run too: an open and a close are
191
+ // most of what a write costs. It waits like a fresh one would
192
+ const live = this.liveDb
191
193
  this.liveDb = null
192
- if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) return []
193
- const opened = openDb(this.path, { busyMs: this.busyMs })
194
+ if (this.path === '' || (rows.length === 0 && last.length === 0 && !hadLive)) { try { live?.close() } catch { /* already gone */ } return [] }
195
+ let opened: { db: Database } | null = null
196
+ if (live != null) {
197
+ try { live.exec(`pragma busy_timeout = ${Math.max(0, Math.round(this.busyMs ?? BUSY_MS))}`); opened = { db: live } } catch { try { live.close() } catch { /* already gone */ } }
198
+ }
199
+ opened ??= openDb(this.path, { busyMs: this.busyMs })
194
200
  if (opened == null) return unwritable(this.path)
195
201
  try {
196
202
  const insert = opened.db.query(INSERT_RUN)
@@ -45,7 +45,9 @@ export type TimelineInvocation = {
45
45
  /** an invocation in a list: its numbers without its lanes */
46
46
  export type InvocationSummary = Omit<TimelineInvocation, 'bars'> & { checks: number }
47
47
 
48
- export type SlowCheck = { id: string; meanMs: number; worstMs: number; samples: number; ceilingMs: number | null; flag: 'over' | 'near' | null }
48
+ /** `over` its ceiling, `near` it (past half), or over its `budget` on the
49
+ * mean of its quick tier runs */
50
+ export type SlowCheck = { id: string; meanMs: number; worstMs: number; samples: number; ceilingMs: number | null; budgetMs: number | null; flag: 'over' | 'near' | 'budget' | null }
49
51
 
50
52
  export type SuiteToFix = {
51
53
  check: string; unit: string; file: string; at: number; ms: number; budgetMs: number; tier: 'quick' | 'full'; red: boolean; slow: boolean
@@ -72,6 +74,8 @@ export type TimelineInput = {
72
74
  suites: SuiteRow[]
73
75
  /** each check's kill time, ms */
74
76
  ceilings: Record<string, number>
77
+ /** each check's resolved budget (core/budget.ts), for the slowest list */
78
+ budgets?: Record<string, number>
75
79
  /** the checks the repo declares today. Given, `slowest` lists only these:
76
80
  * a deleted check's rows stay in the run history, and no edit can make it
77
81
  * faster */
@@ -120,23 +124,31 @@ export function invocationsOf(runs: RunRow[], invocations: InvocationRow[]): Tim
120
124
  }
121
125
 
122
126
  /** the checks that cost the most when they really run: mean and worst of the
123
- * MEASURED runs, flagged when the worst reached its ceiling or half of it */
124
- export function slowestChecks(runs: RunRow[], ceilings: Record<string, number>, size = TIMELINE_LIST, inventory?: readonly string[]): SlowCheck[] {
127
+ * MEASURED runs, flagged when the worst reached its ceiling or half of it,
128
+ * else when the mean of its QUICK tier runs is over its budget (the full
129
+ * tier runs every slow suite on purpose, and is never judged) */
130
+ export function slowestChecks(runs: RunRow[], ceilings: Record<string, number>, size = TIMELINE_LIST, inventory?: readonly string[], budgets: Record<string, number> = {}): SlowCheck[] {
125
131
  const live = inventory == null ? null : new Set(inventory)
126
132
  const ms = new Map<string, number[]>()
133
+ const quick = new Map<string, number[]>()
134
+ const add = (m: Map<string, number[]>, id: string, v: number): void => { m.set(id, [...(m.get(id) ?? []), v]) }
127
135
  for (const r of runs) {
128
136
  if (r.replayed || r.status === 'skip') continue
129
137
  if (live != null && !live.has(r.id)) continue
130
- const list = ms.get(r.id) ?? []
131
- list.push(r.ms)
132
- ms.set(r.id, list)
138
+ add(ms, r.id, r.ms)
139
+ if (r.full !== true) add(quick, r.id, r.ms)
133
140
  }
141
+ const mean = (list: number[]): number => Math.round(list.reduce((a, b) => a + b, 0) / list.length)
134
142
  const out: SlowCheck[] = []
135
143
  for (const [id, list] of ms) {
136
144
  const worstMs = Math.max(...list)
137
145
  const ceilingMs = ceilings[id] ?? null
138
- const flag = ceilingMs == null ? null : worstMs >= ceilingMs ? 'over' : worstMs > ceilingMs * NEAR_CEILING_SHARE ? 'near' : null
139
- out.push({ id, meanMs: Math.round(list.reduce((a, b) => a + b, 0) / list.length), worstMs, samples: list.length, ceilingMs, flag })
146
+ const budgetMs = budgets[id] ?? null
147
+ const q = quick.get(id) ?? []
148
+ const flag = ceilingMs != null && worstMs >= ceilingMs ? 'over'
149
+ : ceilingMs != null && worstMs > ceilingMs * NEAR_CEILING_SHARE ? 'near'
150
+ : budgetMs != null && q.length > 0 && mean(q) > budgetMs ? 'budget' : null
151
+ out.push({ id, meanMs: mean(list), worstMs, samples: list.length, ceilingMs, budgetMs, flag })
140
152
  }
141
153
  return out.sort((a, b) => b.meanMs - a.meanMs || a.id.localeCompare(b.id)).slice(0, size)
142
154
  }
@@ -164,7 +176,7 @@ export function shapeTimeline(o: TimelineInput): Timeline {
164
176
  const size = o.listSize ?? TIMELINE_LIST
165
177
  return {
166
178
  invocations: all.slice(0, o.keep ?? TIMELINE_INVOCATIONS),
167
- slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory),
179
+ slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory, o.budgets),
168
180
  longest: [...all].sort((a, b) => b.wallMs - a.wallMs || b.started - a.started).slice(0, size).map(({ bars, ...rest }) => ({ ...rest, checks: bars.length })),
169
181
  testFiles: testFilesToFix(o.suites, o.declaredSlow),
170
182
  suitesRecorded: o.suites.length > 0,
@@ -2,10 +2,21 @@
2
2
  // asked about three checks replaces those three and leaves the rest standing:
3
3
  // vscode clears the whole pane at every frame, so a narrowed run that printed
4
4
  // only its own findings would erase every other check's.
5
- import { frameLines, type Problem } from './problem-line.ts'
5
+ import type { HubProblem } from '../serve/hub-problems.ts'
6
+ import { frameLines, hubProblemLine, type Problem } from './problem-line.ts'
6
7
 
7
8
  export class ProblemBoard {
8
9
  private readonly byCheck = new Map<string, Problem[]>()
10
+ private hub: Problem[] = []
11
+
12
+ /** what is wrong with the hub itself (serve/hub-problems.ts), which no
13
+ * check owns. Returns whether the board moved */
14
+ setHub(problems: HubProblem[]): boolean {
15
+ const next = problems.map(hubProblemLine)
16
+ const moved = JSON.stringify(next) !== JSON.stringify(this.hub)
17
+ this.hub = next
18
+ return moved
19
+ }
9
20
 
10
21
  /** a check reached a verdict. What it carries has already passed the pane
11
22
  * rule (problem-line.ts feedsProblemsPane), so an empty list clears it */
@@ -17,7 +28,7 @@ export class ProblemBoard {
17
28
  /** every finding standing, sorted by file then line, so a frame reads the
18
29
  * same whatever order the checks finished in */
19
30
  all(): Problem[] {
20
- return [...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
31
+ return [...this.hub, ...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
21
32
  }
22
33
 
23
34
  /** one check's verdict, as the runner reports it. A skip is no verdict
@@ -39,6 +39,15 @@ export const ENDS_PATTERN = `^${FRAME_END_HEAD}\\b`
39
39
  * (`imports:check`) both split right */
40
40
  export const PROBLEM_PATTERN = '^(.+?):(\\d+):(\\d+): (error|warning) (\\S+?): (.*)$'
41
41
 
42
+ /** where a problem of the hub itself is anchored: it has no file, and the
43
+ * matcher needs one, so it sits on line 1 of the file that declares the
44
+ * hub. Check id `hub` */
45
+ export const HUB_ANCHOR = 'repo.config.ts'
46
+
47
+ export function hubProblemLine(p: { text: string; severity: 'red' | 'yellow' }): Problem {
48
+ return { file: HUB_ANCHOR, line: 1, col: 1, severity: p.severity === 'red' ? 'error' : 'warning', check: 'hub', message: p.text }
49
+ }
50
+
42
51
  export function formatProblem(p: Problem): string {
43
52
  const message = p.message.replace(/\s*\n\s*/g, ' ').trim()
44
53
  return `${p.file}:${p.line}:${p.col}: ${p.severity} ${p.check}: ${message}`
@@ -20,6 +20,7 @@ import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
20
20
  import { RULE_ID_RE, validateRules } from './rules/rule.ts'
21
21
  import { builtinCheckIds } from './core/discover-checks.ts'
22
22
  import { parseWhen } from './core/when.ts'
23
+ import { budgetValueProblem } from './core/budget.ts'
23
24
  import type { Rule } from './check.ts'
24
25
  import { leakAllowProblems } from './hooks/leak-guard.ts'
25
26
  import type { HooksConfig } from './hooks/run-hooks.ts'
@@ -104,6 +105,14 @@ export type RepoConfig = {
104
105
  * Its last recorded run, from a gate or a health pass, is the secret's
105
106
  * verdict. Absent: no check probes it, and the row says so */
106
107
  probedBy?: string
108
+ /** how much breaks without it, which decides its colour when it is
109
+ * missing or its probe fails. `core`: the toolkit's own features stop
110
+ * (a push, creating a repo, a release), so it is red, and a missing
111
+ * one reaches `shipkit needs` as red. `used`: a feature this repo uses today, so
112
+ * it is orange, a needs line that never pings. `minor`: proactive,
113
+ * for something nothing uses yet, so it is dim and stays out of
114
+ * `shipkit needs`. Default `used` */
115
+ level?: 'core' | 'used' | 'minor'
107
116
  }[]
108
117
  /** store-command template for a missing secret tile, `{name}` substituted, as in your secret manager's set command */
109
118
  credsCommand?: string
@@ -144,6 +153,12 @@ export type RepoConfig = {
144
153
  * reason, and on an id no check carries. The hub writes this
145
154
  * map through the managed block in this file (core/when-block.ts). */
146
155
  when?: Record<string, string>
156
+ /** WHAT A HEALTHY RUN OF EACH CHECK TAKES, in ms, keyed by check id. A
157
+ * check that passes over its budget prints a WARN and reads yellow, and
158
+ * never blocks. It wins over the check's own `budgetMs` and the default
159
+ * for its kind (core/budget.ts). An id no check carries fails the run,
160
+ * the same as a `when` entry. */
161
+ budgets?: Record<string, number>
147
162
  /** DEPRECATED, read into `when` as `off:<reason>`: rule id → the reason
148
163
  * this repo breaks that invariant on purpose. An exempted rule does not
149
164
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
@@ -241,7 +256,7 @@ export type RepoConfig = {
241
256
  logDir?: string
242
257
  }
243
258
 
244
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
259
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'untrackGenerated', 'nested', 'when', 'budgets', 'ruleExempt', 'visibility', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'logDir', 'prose', 'smoke', 'tests'])
245
260
 
246
261
  export function defineRepo(c: RepoConfig): RepoConfig {
247
262
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -319,6 +334,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
319
334
  if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
320
335
  throw new Error(`defineRepo: when.${id} names no rule, the ids are in \`shipkit checks\``)
321
336
  }
337
+ // a budget turns a slow pass yellow, so a value it cannot compare with dies here
338
+ for (const [id, v] of Object.entries(c.budgets ?? {})) {
339
+ if (id.trim() === '') throw new Error(`defineRepo: budgets has an entry with no check id`)
340
+ const bad = budgetValueProblem(v)
341
+ if (bad != null) throw new Error(`defineRepo: budgets.${id} ${bad}`)
342
+ }
322
343
  // an exemption silences a gate, so it names a rule that exists and says why
323
344
  for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
324
345
  if (!builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
@@ -47,10 +47,10 @@ const VERDICT_WORD: Record<'pass' | 'warn' | 'fail' | 'skip', string> = {
47
47
  // ── the pieces ───────────────────────────────────────────────────────────
48
48
 
49
49
  /** one duration, in the one unit, in the one tone */
50
- const Dur = ({ ms, ceiling }: { ms: number | null | undefined; ceiling?: number }) =>
50
+ const Dur = ({ ms, ceiling, budget }: { ms: number | null | undefined; ceiling?: number; budget?: number }) =>
51
51
  ms == null
52
52
  ? <span className="dim">not timed yet</span>
53
- : <span className={`dnum ${durationTone(ms, ceiling) ?? ''}`}>{fmtMs(ms)}</span>
53
+ : <span className={`dnum ${durationTone(ms, ceiling, budget) ?? ''}`}>{fmtMs(ms)}</span>
54
54
 
55
55
  const MdHelp = ({ text }: { text: string }) => <Help text={<span className="md" dangerouslySetInnerHTML={{ __html: renderMd(text) }} />} />
56
56
 
@@ -177,14 +177,14 @@ const RunWhen = observer(({ app, u }: { app: HubSt; u: CheckFacts }) => {
177
177
 
178
178
  /** the last N outcomes as a bar row: an average hides the shape, and the
179
179
  * reds it cannot even see */
180
- const Bars = ({ runs, ceiling }: { runs: RecentRun[]; ceiling: number }) => {
180
+ const Bars = ({ runs, ceiling, budget }: { runs: RecentRun[]; ceiling: number; budget: number }) => {
181
181
  if (runs.length === 0) return <div className="dim">no run on record yet</div>
182
182
  const top = Math.max(...runs.map((r) => r.ms), 1)
183
183
  return (
184
184
  <div className="bars">
185
185
  {runs.map((r, n) => (
186
186
  <i key={`${r.at}-${n}`}
187
- className={`bar ${r.replayed ? 'rep' : r.status === 'fail' ? 'red' : r.status === 'warn' ? 'warn' : r.status === 'skip' ? 'rep' : durationTone(r.ms, ceiling) ?? 'plain'}`}
187
+ className={`bar ${r.replayed ? 'rep' : r.status === 'fail' ? 'red' : r.status === 'warn' ? 'warn' : r.status === 'skip' ? 'rep' : durationTone(r.ms, ceiling, budget) ?? 'plain'}`}
188
188
  style={{ height: `${r.replayed ? 8 : Math.max(10, Math.round((r.ms / top) * 100))}%` }}
189
189
  title={`${stampWords(r.at)}: ${r.replayed ? CACHED : `${VERDICT_WORD[r.status]} in ${fmtMs(r.ms)}`}`} />
190
190
  ))}
@@ -200,22 +200,23 @@ export const LastRunValue = observer(({ app, u }: { app: HubSt; u: CheckFacts })
200
200
  <>
201
201
  <span className={`tl ${l.tone}`}>{l.word}</span>
202
202
  {u.lastAt == null ? null : <> <TimeAgo clock={app.clock} at={u.lastAt} /></>}
203
- {u.lastMs == null || u.replayed ? null : <> in <Dur ms={u.lastMs} ceiling={u.timeoutMs} /></>}
203
+ {u.lastMs == null || u.replayed ? null : <> in <Dur ms={u.lastMs} ceiling={u.timeoutMs} budget={u.budgetMs} /></>}
204
204
  {l.verdict == null ? null : <span className={`tl ${l.tone}`}>, {l.verdict}</span>}
205
205
  </>
206
206
  )
207
207
  })
208
208
 
209
209
  export const Timings = ({ u }: { u: CheckFacts }) => (
210
- <Row k="timings" wide help="what this check costs: its last run, the mean of the runs that really ran, the worst one on record, and the limit it is killed at">
210
+ <Row k="timings" wide help="what this check costs: its last run, the mean of the runs that really ran, the worst one on record, what a healthy run takes, and the limit it is killed at">
211
211
  <span className="ctimes">
212
- <span><span className="ck">last</span> <Dur ms={u.lastMs} ceiling={u.timeoutMs} /></span>
213
- <span><span className="ck">average</span> {u.samples === 0 ? <span className="dim">not timed yet</span> : <><Dur ms={u.avgMs} ceiling={u.timeoutMs} /> <span className="dim">over {u.samples}</span></>}</span>
214
- <span><span className="ck">slowest</span> <Dur ms={u.slowestMs} ceiling={u.timeoutMs} />{u.failures > 0 ? <span className="dim">, {u.failures} red</span> : null}</span>
215
- {/* a limit, never a measurement: it wears no tone of its own */}
212
+ <span><span className="ck">last</span> <Dur ms={u.lastMs} ceiling={u.timeoutMs} budget={u.budgetMs} /></span>
213
+ <span><span className="ck">average</span> {u.samples === 0 ? <span className="dim">not timed yet</span> : <><Dur ms={u.avgMs} ceiling={u.timeoutMs} budget={u.budgetMs} /> <span className="dim">over {u.samples}</span></>}</span>
214
+ <span><span className="ck">slowest</span> <Dur ms={u.slowestMs} ceiling={u.timeoutMs} budget={u.budgetMs} />{u.failures > 0 ? <span className="dim">, {u.failures} red</span> : null}</span>
215
+ {/* a budget and a limit, never measurements: they wear no tone of their own */}
216
+ <span><span className="ck">budget</span> <span className="dnum dim">{fmtMs(u.budgetMs)}</span> <Help term="budget" /></span>
216
217
  <span><span className="ck">limit</span> <span className="dnum dim">{fmtMs(u.timeoutMs)}</span> <Help term="ceiling" /></span>
217
218
  </span>
218
- <Bars runs={u.recent ?? []} ceiling={u.timeoutMs} />
219
+ <Bars runs={u.recent ?? []} ceiling={u.timeoutMs} budget={u.budgetMs} />
219
220
  </Row>
220
221
  )
221
222