rvlib-shipkit 1.0.1 → 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 (63) hide show
  1. package/CHANGELOG.md +53 -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/run-serve.ts +51 -12
  60. package/src/serve/serve-hub.ts +10 -3
  61. package/src/serve/stale-restart.ts +77 -0
  62. package/src/suite/suite-cli.ts +14 -10
  63. package/src/ts/source-graph.ts +3 -2
@@ -58,6 +58,7 @@ export function socialPostsCollection(root: string): Collection {
58
58
  return {
59
59
  id: 'posts',
60
60
  title: 'posts',
61
+ kind: 'post',
61
62
  media: 'text',
62
63
  list: async () => {
63
64
  const bySlug = new Map<string, PostFile[]>()
@@ -25,11 +25,13 @@ import { activeContracts, contractsConfigFrom } from '../contracts/apply-contrac
25
25
  import type { CheckFacts, Collection, Instance } from './collection-types.ts'
26
26
  import { mdBody } from './md-body.ts'
27
27
  import { socialPostsCollection } from './posts.ts'
28
- import { assetState, secretState, type SecretProbe } from './state-rules.ts'
28
+ import { assetState, secretState, type SecretLevel, type SecretProbe } from './state-rules.ts'
29
29
  import { reflectionsCollection } from './reflections.ts'
30
30
  import { testsCollection } from './tests.ts'
31
31
  import { checksCollection } from './checks.ts'
32
32
  import { LAYOUT } from '../core/layout.ts'
33
+ import { isBudgetLine } from '../core/budget.ts'
34
+ import { GH_LOGIN_CRED } from '../checks/SK021-github-login.check.ts'
33
35
 
34
36
  const askDirOf = (root: string, c: RepoConfig): string => join(root, c.asks?.dir ?? LAYOUT.asks)
35
37
  // read path must not mint an asks/ dir (listAsks mkdirs on read)
@@ -101,10 +103,9 @@ export function probeOf(name: string, probedBy: string | undefined, verdicts: Ma
101
103
  const why = lines.find((l) => l.kind === 'fail') ?? lines.find((l) => l.kind === 'warn')
102
104
  return { probe: 'fail', words: `its probe ${probedBy} failed${why != null ? `: ${why.text}` : ''}`, md }
103
105
  }
104
- if (v.status === 'warn') {
105
- const why = lines.find((l) => l.kind === 'warn')
106
- return { probe: 'warn', words: `its probe ${probedBy} warned${why != null ? `: ${why.text}` : ''}`, md }
107
- }
106
+ // a probe that only ran slow still proved the secret works
107
+ const why = lines.find((l) => l.kind === 'warn' && !isBudgetLine(l.text))
108
+ if (v.status === 'warn' && why != null) return { probe: 'warn', words: `its probe ${probedBy} warned: ${why.text}`, md }
108
109
  const at = v.measuredAt ?? v.lastAt ?? now
109
110
  if (v.keepsUntil != null && now > v.keepsUntil)
110
111
  return { probe: 'stale', words: `last checked ${ago(now - at)} by ${probedBy}, older than its ${v.ttl} ttl`, md }
@@ -122,6 +123,7 @@ export function asksCollection(root: string, c: RepoConfig): Collection {
122
123
  return {
123
124
  id: 'asks',
124
125
  title: 'asks',
126
+ kind: 'ask',
125
127
  media: 'text',
126
128
  list: async () => {
127
129
  const rel = c.asks?.dir ?? LAYOUT.asks
@@ -183,9 +185,10 @@ const named = (name: string, service: string | undefined): string => (service !=
183
185
 
184
186
  /** repo-declared secrets (repo.config `creds:`) plus every secret a check
185
187
  * proved missing. The tile carries the mint artifact as data. EVERY declared
186
- * cred is listed. Missing + unclaimed = red with the set verb + artifact;
187
- * missing + ask-covered = STILL red (never hidden), no verbs: the text
188
- * points at the ask that owns the action. The line that proved it missing
188
+ * cred is listed, coloured by its level (state-rules.ts secretState).
189
+ * Missing + unclaimed = the set verb + artifact; missing + ask-covered =
190
+ * STILL listed (never hidden), no verbs: the text points at the ask that
191
+ * owns the action. The line that proved it missing
189
192
  * (+ its fix: pointers) rides the instance's md, never a health line.
190
193
  * A present secret wears the verdict of the check that probes it
191
194
  * (`probedBy`), read from the run record (state-rules.ts secretState) */
@@ -193,6 +196,7 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
193
196
  return {
194
197
  id: 'creds',
195
198
  title: 'secrets',
199
+ kind: 'secret',
196
200
  media: 'pointer',
197
201
  list: async () => {
198
202
  const rel = c.asks?.dir ?? LAYOUT.asks
@@ -203,19 +207,27 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
203
207
  const now = (o.now ?? Date.now)()
204
208
  const missing = missingFrom(verdicts)
205
209
  const byId = new Map(verdicts.map((v) => [v.checkId, v]))
210
+ // a toolkit credential joins the repo's own once its probe ran here
211
+ const builtins = BUILTIN_CREDS.filter((b) => byId.get(b.probedBy)?.status != null &&!(c.creds ?? []).some((d) => d.name === b.name))
212
+ const declared = [...builtins, ...(c.creds ?? [])]
206
213
  const out: Instance[] = []
207
214
  for (const [name, line] of missing) {
208
- const decl = (c.creds ?? []).find((d) => d.name === name)
215
+ const builtin = builtins.find((b) => b.name === name)
216
+ if (builtin != null) {
217
+ out.push({ ...builtinMissing(builtin, line.fixes[0]), md: wireMd([`FAIL: ${line.text}`, ...line.fixes.map((f) => `fix: ${f}`)]) })
218
+ continue
219
+ }
220
+ const decl = declared.find((d) => d.name === name)
209
221
  const md = wireMd([`FAIL: ${line.text}`, ...line.fixes.map((f) => `fix: ${f}`)])
210
222
  const ask = coveringAsk(name)
211
223
  if (ask != null)
212
224
  // dedup = acted-on once, never hidden: the ask keeps the
213
- // verbs and the ping; the cred still shows RED here
225
+ // verbs and the ping; the cred still shows here
214
226
  out.push({
215
227
  id: `cred:${name}`,
216
- text: `${named(name, decl?.service)} missing, ask ${rel}/${ask.file} owns this`,
217
- ...credPlace(name, true),
218
- severity: 'red',
228
+ text: `${named(name, decl?.service)} missing, ask ${rel}/${ask.file} owns this${minorWord(decl?.level)}`,
229
+ ...credPlace(name, true, decl?.level),
230
+ ...secretState({ missing: true, probe: 'none', level: decl?.level }),
219
231
  md,
220
232
  askOwned: true,
221
233
  })
@@ -224,14 +236,14 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
224
236
  // every declared cred not proven missing is present: no copy
225
237
  // command (nothing to run), the mint pointer when one is declared,
226
238
  // and the colour of what its probe last proved
227
- for (const d of c.creds ?? []) {
239
+ for (const d of declared) {
228
240
  if (missing.has(d.name)) continue
229
241
  const p = probeOf(d.name, d.probedBy, byId, now)
230
242
  out.push({
231
243
  id: `cred:${d.name}`,
232
- text: `${named(d.name, d.service)} present, ${p.words}`,
233
- ...credPlace(d.name, false),
234
- ...secretState({ missing: false, probe: p.probe }),
244
+ text: `${named(d.name, d.service)} present, ${p.words}${minorWord(d.level)}`,
245
+ ...credPlace(d.name, false, d.level),
246
+ ...secretState({ missing: false, probe: p.probe, level: d.level }),
235
247
  md: p.md,
236
248
  artifact: d.mintUrl != null ? { url: d.mintUrl } : undefined,
237
249
  })
@@ -245,15 +257,35 @@ export function credsCollection(root: string, c: RepoConfig, o: CredsOpts = {}):
245
257
  // never names any particular secret manager
246
258
  // a secret has no file: the name is already slash shaped, so a virtual root
247
259
  // groups it by vendor for free
248
- const credPlace = (name: string, missing: boolean): Pick<Instance, 'type' | 'path' | 'virtual' | 'label' | 'slot'> =>
249
- // the tree row is the NAME, and present or missing is the slot beside it
250
- ({ type: 'secret', path: `secrets/${name}`, virtual: true, label: name, slot: missing ? 'missing' : 'present' })
260
+ const credPlace = (name: string, missing: boolean, level?: SecretLevel): Pick<Instance, 'type' | 'path' | 'virtual' | 'label' | 'slot'> =>
261
+ // the tree row is the NAME, and present or missing is the slot beside
262
+ // it. A minor secret nobody minted says minor there: missing is the
263
+ // expected state of a key nothing uses yet
264
+ ({ type: 'secret', path: `secrets/${name}`, virtual: true, label: name, slot: missing ? (level === 'minor' ? 'minor' : 'missing') : 'present' })
265
+
266
+ /** a minor secret says so in its sentence, since its colour says nothing */
267
+ const minorWord = (level: SecretLevel | undefined): string => (level === 'minor' ? ', minor' : '')
268
+
269
+ /** credentials the toolkit itself runs on, which no secrets manager holds
270
+ * (gh keeps its own login). The repo's `creds` entry of the same name wins */
271
+ const BUILTIN_CREDS = [GH_LOGIN_CRED]
272
+ type BuiltinCred = (typeof BUILTIN_CREDS)[number]
251
273
 
252
- const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { service?: string; mintUrl?: string; steps?: string[] }): Instance => ({
274
+ /** a missing toolkit credential: its fix is a command to run, never a value
275
+ * to store, so it carries no `set` verb and copies the probe's own fix */
276
+ const builtinMissing = (b: BuiltinCred, fix: string | undefined): Instance => ({
277
+ id: `cred:${b.name}`,
278
+ text: `${named(b.name, b.service)} missing`,
279
+ ...credPlace(b.name, true, b.level),
280
+ ...secretState({ missing: true, probe: 'none', level: b.level }),
281
+ artifact: { copy: fix != null && !/\s(then|and)\s/.test(fix) ? fix : b.command, url: b.mintUrl },
282
+ })
283
+
284
+ const credInstance = (name: string, cmdTemplate: string | undefined, decl?: { service?: string; mintUrl?: string; steps?: string[]; level?: SecretLevel }): Instance => ({
253
285
  id: `cred:${name}`,
254
- text: `${named(name, decl?.service)} missing`,
255
- ...credPlace(name, true),
256
- severity: 'red',
286
+ text: `${named(name, decl?.service)} missing${minorWord(decl?.level)}`,
287
+ ...credPlace(name, true, decl?.level),
288
+ ...secretState({ missing: true, probe: 'none', level: decl?.level }),
257
289
  artifact: { copy: cmdTemplate?.replaceAll('{name}', name), url: decl?.mintUrl, steps: decl?.steps },
258
290
  verbs: ['set'],
259
291
  })
@@ -269,6 +301,7 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
269
301
  return {
270
302
  id: 'media',
271
303
  title: 'assets',
304
+ kind: 'asset',
272
305
  media: 'image',
273
306
  list: async () => {
274
307
  const idn = await heal(root)
@@ -332,30 +365,25 @@ const assetNotes = (root: string): Map<string, string[]> => {
332
365
  return map
333
366
  }
334
367
 
335
- /** repo health — what belongs to no check and no other collection: contract
336
- * FAIL/WARN lines and hub process staleness. Every check, rule and task of
337
- * the gate is its own resource now (collections/checks.ts), so re-parsing
338
- * STATUS.md into one row per wire line would state the same verdicts a
339
- * second time, under ids that are whole sentences, and the two would
340
- * disagree the moment one run narrowed. Cred probe FAIL lines (+ their
341
- * fixes) stay routed to the secrets collection. */
342
- export function repoHealthCollection(root: string, c: RepoConfig, o?: { bootVersion?: string; libVersion?: () => string }): Collection {
368
+ /** the release contracts: one row per FAIL/WARN line of a venue contract.
369
+ * Every check, rule and task of the gate is its own resource
370
+ * (collections/checks.ts), so STATUS.md is never re-parsed into rows here.
371
+ * Cred probe FAIL lines (+ their fixes) stay routed to the secrets
372
+ * collection. The hub's own state (a stale process) is no row at all: the
373
+ * hub restarts itself (serve/stale-restart.ts) */
374
+ export function contractsCollection(root: string, c: RepoConfig): Collection {
343
375
  return {
344
- id: 'health',
345
- title: 'repo health',
376
+ id: 'contracts',
377
+ title: 'release contracts',
378
+ kind: 'contract',
346
379
  media: 'health',
347
380
  list: async () => {
348
381
  const out: Instance[] = []
349
- if (o?.bootVersion != null && o.libVersion != null) {
350
- const now = o.libVersion()
351
- if (now !== o.bootVersion)
352
- out.push({ id: 'health:stale-hub', type: 'health', path: 'health/stale-hub', virtual: true, severity: 'red', text: `hub is running shipkit ${o.bootVersion} but ${now} is on disk (stale process, stale pages, old studio code). Restart the 🚀 shipkit task` })
353
- }
354
382
  const disk = CheckCtx.open({ root, config: c })
355
383
  const r = await evaluateContracts(activeContracts(c), contractsConfigFrom(c, disk), disk)
356
384
  for (const l of r.lines) {
357
- if (l.kind === 'fail') out.push({ id: `health:contract:${l.text}`, type: 'contract', path: `health/contracts/${slug(l.text)}`, virtual: true, severity: 'red', text: l.text })
358
- else if (l.kind === 'warn') out.push({ id: `health:contractwarn:${l.text}`, type: 'contract', path: `health/contracts/${slug(l.text)}`, virtual: true, severity: 'yellow', text: l.text })
385
+ if (l.kind === 'fail') out.push({ id: `contract:${l.text}`, type: 'contract', path: `contracts/${slug(l.text)}`, virtual: true, severity: 'red', text: l.text })
386
+ else if (l.kind === 'warn') out.push({ id: `contractwarn:${l.text}`, type: 'contract', path: `contracts/${slug(l.text)}`, virtual: true, severity: 'yellow', text: l.text })
359
387
  }
360
388
  return out
361
389
  },
@@ -372,11 +400,11 @@ const sharedList = (col: Collection): Collection => {
372
400
  }
373
401
  }
374
402
 
375
- export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null, o?: { bootVersion?: string; libVersion?: () => string }): Collection[] {
403
+ export function buildCollections(root: string, c: RepoConfig, kit: ResolvedKit | null): Collection[] {
376
404
  const checks = sharedList(checksCollection(root, c))
377
405
  return [
378
406
  checks,
379
- repoHealthCollection(root, c, o),
407
+ contractsCollection(root, c),
380
408
  asksCollection(root, c),
381
409
  credsCollection(root, c, { verdicts: () => verdictsOf(checks) }),
382
410
  mediaKitCollection(root, kit),
@@ -12,6 +12,7 @@ export function reflectionsCollection(root: string): Collection {
12
12
  return {
13
13
  id: 'reflections',
14
14
  title: 'reflections',
15
+ kind: 'reflection',
15
16
  media: 'text',
16
17
  list: async () =>
17
18
  listReflections(root).map((r): Instance => {
@@ -36,19 +36,35 @@ export const assetState = (f: AssetFacts): StateRule => {
36
36
  * none no check probes this secret */
37
37
  export type SecretProbe = 'pass' | 'fail' | 'warn' | 'stale' | 'unmeasured' | 'none'
38
38
 
39
- /** THE secret rule. Being there is half the answer and working is the other
40
- * half, so present plus a probe that passed is the only green. A value that
41
- * is there and fails its probe is red on the tree, and its feed line stays
42
- * yellow: the probing check does not block, and its own row already carries
43
- * the same failure. A pass past its ttl, or a probe that never measured, is
44
- * plain: nothing is known to be wrong. A secret no check probes is dim,
45
- * because there is no verdict to wait for. Missing while a page requires it
46
- * is the broken case, whatever any probe said */
47
- export const secretState = (f: { missing: boolean; probe: SecretProbe }): StateRule => {
48
- if (f.missing) return { severity: 'red' }
49
- if (f.probe === 'pass') return { state: 'ok' }
50
- if (f.probe === 'fail') return { state: 'red', severity: 'yellow' }
51
- if (f.probe === 'warn') return { severity: 'yellow' }
39
+ /** how much breaks without a secret, declared once per secret like
40
+ * `blocking` for a check. `core`: the toolkit's own features stop (a push,
41
+ * creating a repo, a release). `used`: a feature this repo uses today.
42
+ * `minor`: proactive, for something nothing uses yet */
43
+ export type SecretLevel = 'core' | 'used' | 'minor'
44
+
45
+ /** a secret that declares no level */
46
+ export const DEFAULT_SECRET_LEVEL: SecretLevel = 'used'
47
+
48
+ /** THE secret rule. Red means this repo is broken, so only a `core` secret
49
+ * goes red: missing, or present with a probe that failed. Only a missing
50
+ * one is red in the feed, which pings once: a failed probe is often the
51
+ * service, and its own check row already carries the failure. A `used` one
52
+ * is orange in both cases, a feed line that never pings. A `minor` one is dim
53
+ * and stays out of the feed, whatever its probe said, and its row carries
54
+ * the words. Present plus a probe that passed is green at every level. A
55
+ * pass past its ttl, or a probe that never measured, is plain: nothing is
56
+ * known to be wrong. A secret no check probes is dim, because there is no
57
+ * verdict to wait for */
58
+ export const secretState = (f: { missing: boolean; probe: SecretProbe; level?: SecretLevel }): StateRule => {
59
+ const level = f.level ?? DEFAULT_SECRET_LEVEL
60
+ const broken = f.missing || f.probe === 'fail'
61
+ if (!f.missing && f.probe === 'pass') return { state: 'ok' }
62
+ if (broken || f.probe === 'warn') {
63
+ if (level === 'minor') return { state: 'quiet' }
64
+ if (level === 'core' && f.missing) return { severity: 'red' }
65
+ if (level === 'core' && broken) return { state: 'red', severity: 'yellow' }
66
+ return { severity: 'yellow' }
67
+ }
52
68
  if (f.probe === 'none') return { state: 'quiet' }
53
69
  return { state: 'idle' }
54
70
  }
@@ -63,6 +63,7 @@ export function testsCollection(root: string, c: RepoConfig, o: TestsOpts = {}):
63
63
  return {
64
64
  id: 'tests',
65
65
  title: 'tests',
66
+ kind: 'test',
66
67
  media: 'health',
67
68
  list: async () => {
68
69
  let files = o.files
@@ -25,6 +25,7 @@ export function wikisCollection(mounts: GraphMount[], root: string | null = null
25
25
  return {
26
26
  id: 'wikis',
27
27
  title: 'wikis',
28
+ kind: 'wiki',
28
29
  media: 'data',
29
30
  list: async () =>
30
31
  mounts.map((m): Instance => {
@@ -0,0 +1,60 @@
1
+ // A CHECK'S TIME BUDGET: what a healthy run of it takes, distinct from its
2
+ // ceiling (`timeoutMs`), which is where it is killed. A check that PASSES over
3
+ // its budget is a warn, never a failure: slow is a defect to fix, not a broken
4
+ // repo. Judged on the wall time of a run that measured, at the quick tier
5
+ // only, and the one owner of the defaults, the line and the load reading.
6
+ import { availableParallelism, loadavg } from 'os'
7
+ import type { MachineLoad } from '../suite/judge-suites.ts'
8
+
9
+ /** an in process body, a rule or a page check. Read off a run record of 64
10
+ * such checks: the median rule answers in under 50ms, 55 of them stay under
11
+ * 2s at their 90th percentile, and the few past it shell out to a tool */
12
+ export const IN_PROCESS_BUDGET_MS = 2_000
13
+
14
+ /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe.
15
+ * Read off the same record: every typecheck stays under 6s at its 90th
16
+ * percentile, and the quick test suites under 6s but one */
17
+ export const SUBPROCESS_BUDGET_MS = 10_000
18
+
19
+ /** what a check declares about itself that picks its default */
20
+ export type BudgetKind = { build: boolean; cmd: boolean; world: boolean }
21
+
22
+ export const defaultBudget = (k: BudgetKind): number => (k.build || k.cmd || k.world ? SUBPROCESS_BUDGET_MS : IN_PROCESS_BUDGET_MS)
23
+
24
+ /** the budget one check runs under: the repo's `budgets` entry, then what the
25
+ * check file declares, then the default for its kind */
26
+ export const budgetOf = (id: string, declared: number | undefined, kind: BudgetKind, budgets: Record<string, number> | undefined): number =>
27
+ budgets?.[id] ?? declared ?? defaultBudget(kind)
28
+
29
+ /** the `budgets` ids of a repo config that name no check it has */
30
+ export const unknownBudgetIds = (ids: readonly string[], budgets: Record<string, number> | undefined): string[] => {
31
+ const known = new Set(ids)
32
+ return Object.keys(budgets ?? {}).filter((id) => !known.has(id))
33
+ }
34
+
35
+ /** what a budget value must be, or null when it is one */
36
+ export const budgetValueProblem = (v: unknown): string | null =>
37
+ typeof v === 'number' && Number.isInteger(v) && v > 0 ? null : `must be a whole number of milliseconds above 0, got ${JSON.stringify(v)}`
38
+
39
+ export const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
40
+
41
+ /** `10s` for a whole second count, `150ms` otherwise */
42
+ export const fmtBudget = (ms: number): string => (ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`)
43
+
44
+ /** a line budgetLine wrote, read back off the wire: a slow pass says
45
+ * nothing about what the check checks (a secret, a service), so a reader of
46
+ * the verdict tells the two apart */
47
+ export const isBudgetLine = (text: string): boolean => /^\S+ (passed|warned) in \d+\.\ds,(?: under load [^,]+,)? over its \d+m?s budget: /.test(text.replace(/^\[[^\]]+\] /, ''))
48
+
49
+ /** a test check sends the reader to the page that breaks its time down */
50
+ const isTestCheck = (id: string): boolean => id === 'test' || id.startsWith('test:')
51
+
52
+ /** the WARN a pass over its budget prints, or null inside it. A loaded
53
+ * machine is named, because wall time is what the reader waits on and load
54
+ * is the first thing to rule out */
55
+ export function budgetLine(id: string, ms: number, budgetMs: number, load: MachineLoad, verb: 'passed' | 'warned' = 'passed'): string | null {
56
+ if (ms <= budgetMs) return null
57
+ const loaded = load.load1 >= load.cores ? ` under load ${load.load1.toFixed(1)} on ${load.cores} cores,` : ''
58
+ const where = isTestCheck(id) ? 'see the tests page for where the time goes' : 'its page in the hub shows every run'
59
+ return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s,${loaded} over its ${fmtBudget(budgetMs)} budget: ${where}`
60
+ }
@@ -230,7 +230,8 @@ export class CheckCtx {
230
230
  * global never shadows the one a repo pins */
231
231
  tool(name: string, candidates: string[] = []): string | null {
232
232
  this.world('tool')
233
- return candidates.find((p) => existsSync(p)) ?? Bun.which(name)
233
+ // the PATH as it stands NOW: Bun.which alone reads the one the process started with
234
+ return candidates.find((p) => existsSync(p)) ?? Bun.which(name, { PATH: process.env['PATH'] ?? '' })
234
235
  }
235
236
 
236
237
  // fetch with a default timeout; 429 → RateLimited → WARN upstream
@@ -28,8 +28,8 @@
28
28
  // you failed" and a page can still show the red.
29
29
  // - ONE tap fires per check, with what the wire lines cannot say: the real
30
30
  // status, whether it replayed, whether it can block, what it cost.
31
- import { closeSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'fs'
32
- import { basename, join } from 'path'
31
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'fs'
32
+ import { basename, isAbsolute, join, relative } from 'path'
33
33
  import { shareRun } from './attach.ts'
34
34
  import { CheckCtx } from './check-ctx.ts'
35
35
  import { CheckPass } from './check-pass.ts'
@@ -37,6 +37,7 @@ import type { ReadSet } from './read-set.ts'
37
37
  import { RepoView } from '../ledger/repo-view.ts'
38
38
  import { parallelCap } from './parallel.ts'
39
39
  import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
+ import { budgetLine, machineLoad } from './budget.ts'
40
41
  import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
41
42
  import { formatLine, parseWireOutput, type Line } from './wire.ts'
42
43
  import { offLine, type WhenDecl } from './when.ts'
@@ -81,6 +82,10 @@ export type RunnableCheck = {
81
82
  deps: string[]
82
83
  group: string | null
83
84
  timeoutMs: number
85
+ /** what a healthy run takes, in ms (core/budget.ts): a run that passes
86
+ * over it warns. Absent: never judged, which is what a build step gets
87
+ * at the full tier, where it runs every slow suite on purpose */
88
+ budgetMs?: number
84
89
  /** the declared world TTL in ms, 0 when this check is not a world probe */
85
90
  ttlMs: number
86
91
  /** the rotation half of a world key (a secret hash, a config switch) */
@@ -638,6 +643,28 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
638
643
  // unrelated edit moved the tree
639
644
  const storable = (u: Live): u is Live & { status: 'pass' | 'warn' } => u.status === 'pass' || (u.status === 'warn' && u.blocking !== false)
640
645
 
646
+ /** a check that passed or warned past its budget says so in a warn line,
647
+ * judged on the wall time of the run that just measured */
648
+ const judgeBudget = (u: Live): void => {
649
+ if (u.budgetMs == null || (u.status !== 'pass' && u.status !== 'warn')) return
650
+ const text = budgetLine(u.id, u.endedAt - u.startedAt, u.budgetMs, machineLoad(), u.status === 'pass' ? 'passed' : 'warned')
651
+ if (text == null) return
652
+ u.lines = [...u.lines, { kind: 'warn', text }]
653
+ u.status = 'warn'
654
+ if (feeds(u)) u.problems = [...u.problems, { file: budgetAnchor(u), line: 1, col: 1, severity: 'warning', check: u.id, message: text }]
655
+ }
656
+
657
+ /** where a budget warning sits in the problems pane: the check's own
658
+ * file, else the package its command runs in, else the repo config that
659
+ * declares the repo's checks */
660
+ const budgetAnchor = (u: Live): string => {
661
+ const cwd = u.body.cwd == null ? null : relative(o.root, u.body.cwd)
662
+ const candidates = [u.owner, cwd == null || cwd === '' ? null : join(cwd, 'package.json')]
663
+ for (const f of candidates)
664
+ if (f != null && !isAbsolute(f) && !f.startsWith('..') && existsSync(join(o.root, f))) return f.replace(/\\/g, '/')
665
+ return 'repo.config.ts'
666
+ }
667
+
641
668
  const adopt = (u: Live, a: Answer, pid: number): void => {
642
669
  const stamp = ` ${attachedStamp(pid, a.measuredMs)}`
643
670
  u.status = a.status
@@ -695,11 +722,16 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
695
722
  // whose result is stored empty replays as a line nobody can tell
696
723
  // from a measurement
697
724
  if (u.lines.length === 0) headlineOf(u)
725
+ // what may be kept is decided on the verdict BEFORE the budget line:
726
+ // slow is not a negative about the world, so a slow pass keeps as long
727
+ // as a quick one, and replays with the warn it earned
728
+ const keep = storable(u)
729
+ const clean = u.status === 'pass'
730
+ judgeBudget(u)
698
731
  // what may be remembered, and for how long, is ONE policy
699
732
  // (ledger/world-cache.ts): a clean answer keeps for the declared
700
733
  // TTL, a measured negative only long enough to stop a re-ask storm
701
734
  if (o.cache != null && u.ttlMs > 0) {
702
- const clean = u.status === 'pass'
703
735
  u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
704
736
  lines: u.lines,
705
737
  clean,
@@ -707,8 +739,8 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
707
739
  keepNegative: u.blocking === false,
708
740
  })
709
741
  }
710
- if (o.ledger != null && storable(u))
711
- o.ledger.record(u.id, { status: u.status, lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
742
+ if (o.ledger != null && keep)
743
+ o.ledger.record(u.id, { status: u.status as 'pass' | 'warn', lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
712
744
  }
713
745
 
714
746
  const start = (u: Live): void => {
@@ -783,7 +815,12 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
783
815
  const skipped = results.filter((r) => r.status === 'skip' && r.note !== '' && r.note !== 'off')
784
816
  if (blocked.length > 0) emit({ kind: 'fail', text: `${blocked.length}/${results.length} failed: ${blocked.map((r) => r.id).join(', ')}` })
785
817
  if (skipped.length > 0) emit({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id} (${r.note})`).join(', ')}` })
786
- if (blocked.length === 0 && !interrupted && results.length > 0) emit({ kind: 'ok', text: `all ${results.filter((r) => r.status !== 'skip').length} check(s) green` })
818
+ // a warn is not green: a gate whose every check passed but some warned
819
+ // says which, so the summary never reads cleaner than the lines above it
820
+ const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
821
+ const ran = results.filter((r) => r.status !== 'skip').length
822
+ if (blocked.length === 0 && !interrupted && results.length > 0)
823
+ emit({ kind: 'ok', text: warned.length === 0 ? `all ${ran} check(s) green` : `all ${ran} check(s) passed, ${warned.length} with a warning: ${warned.map((r) => r.id).join(', ')}` })
787
824
  return {
788
825
  ok: blocked.length === 0 && !interrupted,
789
826
  interrupted,
@@ -0,0 +1,37 @@
1
+ // how full this process's file table is. macOS posix_spawn refuses any fd at
2
+ // or past OPEN_MAX (10240) with EBADF, and a new pipe takes the lowest free
3
+ // number, so a table that full fails every spawn that pipes. bun 1.4.2
4
+ // `--watch` opens each watched file without close on exec, so every in place
5
+ // restart keeps the previous image's copies (fixed in 1.4.3). A clean re-exec
6
+ // (process.execve) carries none of them, which is how a long lived process
7
+ // sheds the leak. This heal dies once every machine runs bun 1.4.3 or later.
8
+ import { readdirSync } from 'fs'
9
+
10
+ /** past this many open files a process is leaking, not working: a hub on a
11
+ * large repo holds about 1000 */
12
+ export const FD_LEAK_LIMIT = 4096
13
+ /** set by a restart that sheds a leak: when it comes back still over the
14
+ * limit, a restart is not what the table needs */
15
+ export const FD_HEALED_ENV = 'SHIPKIT_FD_HEALED_AT'
16
+ const HEAL_GRACE_MS = 60_000
17
+
18
+ /** open fds of this process, null where the platform lists none */
19
+ export const openFdCount = (): number | null => {
20
+ try { return readdirSync('/dev/fd').length } catch { return null }
21
+ }
22
+
23
+ /** why this process's file table is leaking, null while it is not */
24
+ export function fdTableLeak(o: { count?: () => number | null; limit?: number } = {}): string | null {
25
+ const n = (o.count ?? openFdCount)()
26
+ const limit = o.limit ?? FD_LEAK_LIMIT
27
+ if (n == null || n <= limit) return null
28
+ return `this process holds ${n} open files, past the ${limit} it ever needs (a bun --watch restart leaks them), and at 10240 every spawn fails with EBADF`
29
+ }
30
+
31
+ /** throws when a restart already shed a leak moments ago and the table is
32
+ * full again: that is a need, not a leak, and restarting would loop */
33
+ export function assertLeakHealable(env: Record<string, string | undefined> = process.env, now = Date.now()): void {
34
+ const at = Number(env[FD_HEALED_ENV])
35
+ if (Number.isFinite(at) && at > 0 && now - at < HEAL_GRACE_MS)
36
+ throw new Error(`a clean restart ${Math.round((now - at) / 1000)}s ago came back still over the limit, so a restart does not shed it`)
37
+ }
@@ -2,9 +2,10 @@
2
2
  // root and its prefix inside it. One rev-parse answers all four, and the answer
3
3
  // is remembered per directory, because one gate asks it from a dozen places
4
4
  // (store, run record, scan, nesting, three rules) and each ask is a process
5
- // spawn. A directory with no `.git` in reach is answered without asking. Every remembered answer is re-validated with a few stats on each hit,
5
+ // spawn. A directory with no `.git` in reach is answered without asking, and
6
+ // so is a plain repository root (plainRoot). Every remembered answer is re-validated with a few stats on each hit,
6
7
  // so a `git init` or a deleted repo is seen at once in a long-lived process.
7
- import { existsSync, realpathSync, statSync } from 'fs'
8
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'fs'
8
9
  import { dirname, join, resolve } from 'path'
9
10
  import { gitBin } from './git-bin.ts'
10
11
 
@@ -78,8 +79,31 @@ function nothingInReach(dir: string): boolean {
78
79
  }
79
80
  }
80
81
 
82
+ /** the common case answered from the disk: `.git` is a plain directory right
83
+ * at `dir`, which git describes as exactly that. Anything unusual (a locating
84
+ * variable, a gitfile or a symlinked `.git`, a shared common dir, a config
85
+ * that relocates the work tree, marks it bare or includes another file, a
86
+ * `.git` owned by someone else) is left to git */
87
+ function plainRoot(dir: string): GitRoot | null {
88
+ if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return null
89
+ try {
90
+ const top = realpathSync(dir)
91
+ const gitDir = join(top, '.git')
92
+ const st = lstatSync(gitDir)
93
+ if (!st.isDirectory() || (process.getuid != null && st.uid !== process.getuid())) return null
94
+ if (!existsSync(join(gitDir, 'HEAD')) || existsSync(join(gitDir, 'commondir'))) return null
95
+ const cfg = readFileSync(join(gitDir, 'config'), 'utf8')
96
+ if (/^\s*(worktree\s*=|bare\s*=\s*true|\[include)/im.test(cfg)) return null
97
+ return { gitDir, commonDir: gitDir, top, prefix: '' }
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
81
103
  function ask(dir: string): Probe {
82
104
  if (nothingInReach(dir)) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
105
+ const plain = plainRoot(dir)
106
+ if (plain != null) return { root: plain, error: '' }
83
107
  let p: ReturnType<typeof Bun.spawnSync>
84
108
  try {
85
109
  p = Bun.spawnSync([gitBin(), 'rev-parse', '--path-format=absolute', '--absolute-git-dir', '--git-common-dir', '--show-toplevel', '--show-prefix'], {
@@ -0,0 +1,16 @@
1
+ // the version of this lib: what the running process booted with, and what
2
+ // the package.json on disk says now. The boot version is a MODULE import on
3
+ // purpose: `bun --watch` follows the module graph only, so a version bump
4
+ // (which touches nothing but package.json) restarts a watched hub. Read with
5
+ // fs, the file sits outside the graph and a watched hub never learns of it.
6
+ import { readFileSync } from 'fs'
7
+ import pkg from '../../package.json' with { type: 'json' }
8
+
9
+ /** the version this process booted with */
10
+ export const LIB_VERSION: string = pkg.version
11
+
12
+ /** what package.json says at this moment, `?` when it is unreadable. Only a
13
+ * process NOT under a watch can see it differ from LIB_VERSION */
14
+ export const diskLibVersion = (): string => {
15
+ try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
16
+ }
@@ -373,9 +373,10 @@ export function legacyHooksOurs(root: string): boolean {
373
373
  * still has core.hooksPath on the vanished `hooks/`, and git runs no hook
374
374
  * from a folder that is not there: the commit gates go dark in silence */
375
375
  function repointStaleHooks(root: string): void {
376
+ // the disk decides first: git is asked only when the move has happened
377
+ if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
376
378
  const v = git(root, ['config', 'core.hooksPath']).out.trim().replace(/\/+$/, '')
377
379
  if (v !== 'hooks' && v !== join(root, 'hooks')) return
378
- if (existsSync(join(root, 'hooks')) || !existsSync(join(root, LAYOUT.hooks, 'pre-commit'))) return
379
380
  git(root, ['config', 'core.hooksPath', LAYOUT.hooks])
380
381
  }
381
382
 
@@ -17,6 +17,7 @@ import type { RepoConfig } from '../repo-config.ts'
17
17
  import { atomicWrite, withFileLock } from './locked-write.ts'
18
18
  import { isLinkedWorktree } from './git-root.ts'
19
19
  import { machineCommit } from './machine-commit.ts'
20
+ import { LIB_VERSION } from './lib-version.ts'
20
21
 
21
22
  const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
22
23
  const END = '<!-- <<< shipkit local-projects -->'
@@ -53,9 +54,6 @@ const parseRow = (line: string): Row | null => {
53
54
  return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
54
55
  }
55
56
 
56
- function libVersion(): string {
57
- try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
58
- }
59
57
 
60
58
  // how this repo consumes the lib — `?` on an unreadable package.json (a
61
59
  // read failure must never be rendered as the fact 'path')
@@ -91,7 +89,7 @@ export function reportToMotherhome(root: string, config: RepoConfig, today: stri
91
89
  const mode = consumptionMode(root)
92
90
  // source-consumed code is not the published release its version
93
91
  // string names — disclose it
94
- const version = mode === 'npm' ? libVersion() : `${libVersion()}+src`
92
+ const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
95
93
  const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
96
94
 
97
95
  const r = withFileLock(page, (): MotherhomeOutcome => {