rvlib-shipkit 1.0.0 → 1.1.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 (72) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +30 -4
  4. package/src/assets/gen/derived.ts +1 -1
  5. package/src/check.ts +6 -0
  6. package/src/checks/SK004-gitignore-policy.check.ts +4 -4
  7. package/src/checks/SK005-rvlib-versions.check.ts +25 -7
  8. package/src/checks/SK021-github-login.check.ts +123 -0
  9. package/src/cli/cmd_Asks.ts +1 -1
  10. package/src/cli/cmd_Check.ts +1 -1
  11. package/src/cli/cmd_Ci.ts +1 -1
  12. package/src/cli/cmd_Fleet.ts +4 -47
  13. package/src/cli.ts +2 -3
  14. package/src/collections/checks.ts +12 -3
  15. package/src/collections/collection-types.ts +34 -18
  16. package/src/collections/comfy.ts +1 -0
  17. package/src/collections/posts.ts +1 -0
  18. package/src/collections/providers.ts +71 -43
  19. package/src/collections/reflections.ts +1 -0
  20. package/src/collections/state-rules.ts +29 -13
  21. package/src/collections/tests.ts +1 -0
  22. package/src/collections/wikis.ts +1 -0
  23. package/src/core/budget.ts +60 -0
  24. package/src/core/check-ctx.ts +2 -1
  25. package/src/core/check-runner.ts +43 -6
  26. package/src/core/fd-table.ts +37 -0
  27. package/src/core/floor-resolve.ts +2 -2
  28. package/src/core/git-root.ts +26 -2
  29. package/src/core/lib-version.ts +16 -0
  30. package/src/core/migrate-layout.ts +69 -9
  31. package/src/core/motherhome.ts +2 -4
  32. package/src/core/package-manager.ts +20 -16
  33. package/src/core/raw-access.ts +11 -3
  34. package/src/core/reexec.ts +26 -0
  35. package/src/core/run-check.ts +36 -16
  36. package/src/core/run-ci.ts +31 -27
  37. package/src/core/run-fix.ts +4 -1
  38. package/src/core/run-fixers.ts +19 -11
  39. package/src/core/self-update.ts +63 -26
  40. package/src/core/update-policy.ts +25 -0
  41. package/src/fleet/scan-fleet.ts +20 -15
  42. package/src/fleet/update-fleet.ts +85 -0
  43. package/src/hooks/leak-guard.ts +10 -1
  44. package/src/hooks/run-hooks.ts +3 -3
  45. package/src/ledger/gate-checks.ts +19 -5
  46. package/src/ledger/git-key.ts +10 -5
  47. package/src/ledger/git-scan.ts +29 -17
  48. package/src/ledger/ledger.ts +14 -6
  49. package/src/ledger/repo-view.ts +8 -6
  50. package/src/ledger/run-log.ts +10 -4
  51. package/src/ledger/run-timeline.ts +21 -9
  52. package/src/problems/problem-board.ts +13 -2
  53. package/src/problems/problem-line.ts +9 -0
  54. package/src/repo-config.ts +24 -4
  55. package/src/serve/hub-app/check-view.tsx +12 -11
  56. package/src/serve/hub-app/cols-svc.ts +16 -9
  57. package/src/serve/hub-app/dur.ts +10 -1
  58. package/src/serve/hub-app/header-bar.tsx +11 -0
  59. package/src/serve/hub-app/help.tsx +1 -0
  60. package/src/serve/hub-app/hub-st.ts +3 -4
  61. package/src/serve/hub-app/hub-types.ts +5 -0
  62. package/src/serve/hub-app/hub.css +3 -0
  63. package/src/serve/hub-app/timeline-view.tsx +4 -4
  64. package/src/serve/hub-app/tree-build.ts +1 -2
  65. package/src/serve/hub-problems.ts +35 -0
  66. package/src/serve/run-serve.ts +51 -12
  67. package/src/serve/serve-hub.ts +10 -3
  68. package/src/serve/stale-restart.ts +77 -0
  69. package/src/smoke/run-smoke.ts +6 -2
  70. package/src/suite/suite-cli.ts +14 -10
  71. package/src/ts/source-graph.ts +3 -2
  72. package/src/wiki/scan-md.ts +1 -1
@@ -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}`
@@ -8,11 +8,10 @@
8
8
  // a slot that can't fill emits a visible reason, disabling via
9
9
  // `defaults: { x: false }` prints a skipped-by-config line — never silence.
10
10
  // The `contracts` task is always present when any contract applies.
11
- import { existsSync, readFileSync } from 'fs'
11
+ import { existsSync } from 'fs'
12
12
  import { isAbsolute, join, normalize } from 'path'
13
- import { fileURLToPath } from 'url'
14
13
  import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
15
- import { FLOOR_IDS, resolveFloor, type FloorResolution } from './core/floor.ts'
14
+ import { FLOOR_IDS } from './core/floor.ts'
16
15
  export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
17
16
  import type { ContractManifest } from './contracts/contract-types.ts'
18
17
  import type { ProseConfig } from './prose/run-prose.ts'
@@ -21,6 +20,7 @@ import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
21
20
  import { RULE_ID_RE, validateRules } from './rules/rule.ts'
22
21
  import { builtinCheckIds } from './core/discover-checks.ts'
23
22
  import { parseWhen } from './core/when.ts'
23
+ import { budgetValueProblem } from './core/budget.ts'
24
24
  import type { Rule } from './check.ts'
25
25
  import { leakAllowProblems } from './hooks/leak-guard.ts'
26
26
  import type { HooksConfig } from './hooks/run-hooks.ts'
@@ -105,6 +105,14 @@ export type RepoConfig = {
105
105
  * Its last recorded run, from a gate or a health pass, is the secret's
106
106
  * verdict. Absent: no check probes it, and the row says so */
107
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'
108
116
  }[]
109
117
  /** store-command template for a missing secret tile, `{name}` substituted, as in your secret manager's set command */
110
118
  credsCommand?: string
@@ -145,6 +153,12 @@ export type RepoConfig = {
145
153
  * reason, and on an id no check carries. The hub writes this
146
154
  * map through the managed block in this file (core/when-block.ts). */
147
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>
148
162
  /** DEPRECATED, read into `when` as `off:<reason>`: rule id → the reason
149
163
  * this repo breaks that invariant on purpose. An exempted rule does not
150
164
  * lint and prints a skip line carrying the reason. `defineRepo` throws on
@@ -242,7 +256,7 @@ export type RepoConfig = {
242
256
  logDir?: string
243
257
  }
244
258
 
245
- 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'])
246
260
 
247
261
  export function defineRepo(c: RepoConfig): RepoConfig {
248
262
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -320,6 +334,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
320
334
  if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
321
335
  throw new Error(`defineRepo: when.${id} names no rule, the ids are in \`shipkit checks\``)
322
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
+ }
323
343
  // an exemption silences a gate, so it names a rule that exists and says why
324
344
  for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
325
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
 
@@ -5,7 +5,7 @@
5
5
  // the card.
6
6
  import { makeAutoObservable, runInAction } from 'mobx'
7
7
  import type { HubSt } from './hub-st.ts'
8
- import type { Col, ColMeta, Resource, Schedule } from './hub-types.ts'
8
+ import type { Col, ColMeta, HubProblem, Resource, Schedule } from './hub-types.ts'
9
9
 
10
10
  export class ColsSvc {
11
11
  /** exactly what the server last sent; `resources` is this plus the live
@@ -14,6 +14,10 @@ export class ColsSvc {
14
14
  colMeta: ColMeta[] = []
15
15
  /** the hub's daily health pass timer, as the last poll saw it */
16
16
  schedule: Schedule | null = null
17
+ /** what the server says is wrong with the hub itself */
18
+ hubProblems: HubProblem[] = []
19
+ /** the last poll failed: the page cannot reach its own server */
20
+ fetchErr: string | null = null
17
21
  actErr = ''
18
22
  remarkDraft = ''
19
23
  proofDraft = ''
@@ -37,20 +41,18 @@ export class ColsSvc {
37
41
  try {
38
42
  const r = await fetch('/api/resources.json')
39
43
  if (!r.ok) throw new Error(await r.text())
40
- const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null }
44
+ const j = (await r.json()) as { collections: ColMeta[]; resources: Resource[]; schedule?: Schedule | null; hub?: HubProblem[] }
41
45
  runInAction(() => {
42
46
  this.colMeta = j.collections
43
47
  this.fetched = j.resources
44
48
  this.schedule = j.schedule ?? null
49
+ this.hubProblems = j.hub ?? []
50
+ this.fetchErr = null
45
51
  })
46
52
  } catch (e) {
47
- // a dead server must SAY so on the tree, never render an empty
48
- // repo that reads like a healthy one
49
- const text = `resources fetch broke: ${(e as Error).message}`
50
- runInAction(() => {
51
- this.colMeta = [{ id: 'hub', title: 'hub', media: 'health' }]
52
- this.fetched = [{ id: 'hub:resources-broken', collection: 'hub', type: 'health', path: 'health/hub-fetch', virtual: true, title: text, text, state: 'red', severity: 'red' }]
53
- })
53
+ // a dead server must SAY so, in the header, never leave a tree
54
+ // that reads like a healthy repo. The last rows stay, dated by it
55
+ runInAction(() => { this.fetchErr = `the page cannot reach this hub: ${(e as Error).message}` })
54
56
  }
55
57
  }
56
58
 
@@ -62,6 +64,11 @@ export class ColsSvc {
62
64
  }
63
65
 
64
66
  /** the collections, as a grouping of the one array */
67
+ /** the header's red: the server's own problems, then an unreachable server */
68
+ get hubStatus(): HubProblem[] {
69
+ return [...this.hubProblems, ...(this.fetchErr == null ? [] : [{ id: 'fetch', text: this.fetchErr, severity: 'red' as const }])]
70
+ }
71
+
65
72
  get cols(): Col[] {
66
73
  return this.colMeta.map((m) => ({ ...m, instances: this.resources.filter((r) => r.collection === m.id) }))
67
74
  }
@@ -31,9 +31,18 @@ const group = (n: number): string => {
31
31
 
32
32
  export const fmtMs = (ms: number | null | undefined): string => (ms == null ? '' : `${group(Math.round(ms))}ms`)
33
33
 
34
- export function durationTone(ms: number | null | undefined, ceilingMs?: number | null): DurTone | null {
34
+ /** the tone of one duration. A check's own BUDGET, when the caller knows it,
35
+ * replaces the table above it: over the budget is red, past half of it
36
+ * orange, and a suite well inside a generous budget never reads red because
37
+ * a rule would be slow at that length (core/budget.ts) */
38
+ export function durationTone(ms: number | null | undefined, ceilingMs?: number | null, budgetMs?: number | null): DurTone | null {
35
39
  if (ms == null) return null
36
40
  if (ceilingMs != null && ceilingMs > 0 && ms > ceilingMs * CEILING_SHARE) return 'bad'
41
+ if (budgetMs != null && budgetMs > 0) {
42
+ if (ms > budgetMs) return 'bad'
43
+ if (ms > budgetMs / 2) return 'slow'
44
+ return ms < FAST_MS ? 'fast' : 'plain'
45
+ }
37
46
  if (ms < FAST_MS) return 'fast'
38
47
  if (ms < PLAIN_MS) return 'plain'
39
48
  if (ms < SLOW_MS) return 'slow'
@@ -46,6 +46,16 @@ const RunState = observer(({ app }: { app: HubSt }) => {
46
46
  )
47
47
  })
48
48
 
49
+ /** what is wrong with the hub itself, one chip per problem. Nothing when all
50
+ * is well: the header states trouble, never health */
51
+ const HubStatus = observer(({ app }: { app: HubSt }) => (
52
+ <>
53
+ {app.cols.hubStatus.map((p) => (
54
+ <span key={p.id} className={`hstatus ${p.severity === 'red' ? 'red' : 'warn'}`} title={p.text}>{p.text}</span>
55
+ ))}
56
+ </>
57
+ ))
58
+
49
59
  export const HeaderBar = observer(({ app }: { app: HubSt }) => {
50
60
  const u = app.checks
51
61
  return (
@@ -57,6 +67,7 @@ export const HeaderBar = observer(({ app }: { app: HubSt }) => {
57
67
  ))}
58
68
  </nav>
59
69
  <span className="sp" />
70
+ <HubStatus app={app} />
60
71
  <RunState app={app} />
61
72
  <button className="primary" disabled={u.busy} onClick={() => void u.run()}>run the health pass</button>
62
73
  <button disabled={u.busy} onClick={() => void u.run(undefined, { gate: true, label: 'the gate' })}>run the gate</button>
@@ -14,6 +14,7 @@ export const TERMS: Record<string, string> = {
14
14
  advisory: 'it can fail without stopping anything: the gate reports it and goes on',
15
15
  blocking: 'its failure stops the gate, and stops you',
16
16
  ceiling: 'the time it is killed at: a check creeping toward its ceiling is visible before it trips',
17
+ budget: 'what a healthy run takes: a run that passes over it warns and reads yellow, and never blocks. Set it with budgetMs in the check file, or budgets in repo.config.ts',
17
18
  cached: 'it did not run: the answer came from the cache, because nothing it depends on moved since it last ran',
18
19
  ran: 'it ran and was timed, so the number is what it costs today',
19
20
  slowest: 'the worst run on record, reds included',
@@ -134,10 +134,9 @@ export class HubSt {
134
134
  // the runs page is a page, not a resource, so no collection lists it
135
135
  out.push({ id: 'page:runs', label: 'runs', section: 'page', run: () => this.cols.select('runs', RUNS_ID) })
136
136
  for (const col of this.cols.sidebarCols) {
137
- const health = col.id === 'health'
138
- out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => (health ? this.showStatus() : this.showCol(col.id)) })
137
+ out.push({ id: `col:${col.id}`, label: col.title, section: 'section', run: () => this.showCol(col.id) })
139
138
  for (const i of col.instances)
140
- out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => (health ? this.showStatus() : this.cols.select(col.id, i.id)) })
139
+ out.push({ id: `item:${i.id}`, label: i.text, section: col.title, run: () => this.cols.select(col.id, i.id) })
141
140
  }
142
141
  return out
143
142
  }
@@ -203,7 +202,7 @@ export class HubSt {
203
202
  this.cols.select(r.collection, r.id)
204
203
  }
205
204
 
206
- /** collection view — one card per instance (health has StatusCard instead) */
205
+ /** collection view — one card per instance */
207
206
  showCol(id: string): void {
208
207
  this.setSel({ t: 'col', id })
209
208
  }
@@ -105,6 +105,7 @@ export type CheckFacts = {
105
105
  ttl?: string
106
106
  never?: string
107
107
  timeoutMs: number
108
+ budgetMs: number
108
109
  status: CheckStatus | null
109
110
  replayed: boolean
110
111
  lastMs: number | null
@@ -121,6 +122,10 @@ export type CheckFacts = {
121
122
  recent?: RecentRun[]
122
123
  }
123
124
 
125
+ /** what is wrong with the hub itself, as the resources feed sends it
126
+ * (serve/hub-problems.ts): the header shows it, never the tree */
127
+ export type HubProblem = { id: string; text: string; severity: 'red' | 'yellow' }
128
+
124
129
  /** the hub's daily health pass timer, as the resources feed sends it
125
130
  * (serve/health-schedule.ts). null = this hub runs no timer */
126
131
  export type Schedule = { everyMs: number; lastPassAt: number | null; nextAt: number; running: boolean }