rvlib-shipkit 1.2.0 → 1.4.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 (173) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +15 -10
  7. package/src/check.ts +26 -10
  8. package/src/checks/SK001-package-json-shape.check.ts +34 -5
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  10. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  11. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  12. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  13. package/src/checks/SK007-github-visibility.check.ts +9 -9
  14. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  15. package/src/checks/SK009-commit-ai-tells.check.ts +103 -61
  16. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  17. package/src/checks/SK014-prose-ste.check.ts +16 -6
  18. package/src/checks/SK015-nested-repo.check.ts +5 -1
  19. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  20. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  21. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -2
  22. package/src/checks/SK024-identity-colors.check.ts +5 -0
  23. package/src/checks/SK025-release-drift.check.ts +58 -0
  24. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  25. package/src/checks/SK027-agent-wiki-links.check.ts +50 -0
  26. package/src/checks/SK028-readme.check.ts +83 -0
  27. package/src/cli/cmd_Assets.ts +1 -1
  28. package/src/cli/cmd_Ci.ts +5 -0
  29. package/src/cli/cmd_CrossRepoActivity.ts +119 -0
  30. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  31. package/src/cli/cmd_Keys.ts +17 -3
  32. package/src/cli/cmd_Overview.ts +98 -0
  33. package/src/cli/cmd_Planned.ts +117 -0
  34. package/src/cli.ts +9 -1
  35. package/src/collections/checks.ts +3 -2
  36. package/src/collections/collection-types.ts +15 -0
  37. package/src/collections/providers.ts +7 -3
  38. package/src/collections/test-time.ts +10 -1
  39. package/src/contracts/apply-contracts.ts +9 -8
  40. package/src/contracts/contract-types.ts +8 -2
  41. package/src/contracts/contracts-engine.ts +34 -13
  42. package/src/contracts/manifests/identity.contract.ts +6 -5
  43. package/src/contracts/manifests/social.contract.ts +4 -2
  44. package/src/contracts/manifests/steam.contract.ts +7 -3
  45. package/src/contracts/web-surface.ts +59 -0
  46. package/src/core/agent-spawn.ts +46 -0
  47. package/src/core/archived.ts +82 -0
  48. package/src/core/budget.ts +19 -9
  49. package/src/core/canonical-scripts.ts +12 -3
  50. package/src/core/check-runner.ts +66 -17
  51. package/src/core/discover-checks.ts +10 -5
  52. package/src/core/file-stamp.ts +20 -0
  53. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  54. package/src/core/identity-color.ts +1 -1
  55. package/src/core/layout.ts +2 -0
  56. package/src/core/motherhome.ts +88 -66
  57. package/src/core/repo-facts.ts +14 -0
  58. package/src/core/retention.ts +14 -5
  59. package/src/core/retired-checks.ts +7 -0
  60. package/src/core/run-check.ts +8 -7
  61. package/src/core/run-ci.ts +46 -15
  62. package/src/core/run-fix.ts +7 -2
  63. package/src/core/run-fixers.ts +10 -6
  64. package/src/core/wire.ts +49 -9
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +138 -0
  67. package/src/home/activity.ts +256 -0
  68. package/src/home/global-units.ts +3 -2
  69. package/src/home/gui-app/ShipkitGui.swift +144 -25
  70. package/src/home/gui-app.ts +93 -30
  71. package/src/home/home-launchd.ts +10 -2
  72. package/src/home/home-routes.ts +34 -6
  73. package/src/home/home-start.ts +33 -5
  74. package/src/home/machine-stores.ts +4 -2
  75. package/src/home/open-app.ts +23 -10
  76. package/src/home/pm-config.ts +15 -11
  77. package/src/home/pm-inputs.ts +76 -0
  78. package/src/home/pm.ts +40 -7
  79. package/src/home/process-rows.ts +222 -0
  80. package/src/home/resources.ts +99 -20
  81. package/src/home/run-daemon.ts +12 -7
  82. package/src/home/run-pm.ts +30 -5
  83. package/src/home/serve-web.ts +6 -3
  84. package/src/home/supervise.ts +16 -1
  85. package/src/home/widget-text.ts +119 -0
  86. package/src/home/widgets.ts +32 -3
  87. package/src/hooks/commit-message.ts +2 -3
  88. package/src/hooks/run-hooks.ts +32 -5
  89. package/src/ledger/db.ts +1 -1
  90. package/src/ledger/gate-checks.ts +20 -6
  91. package/src/ledger/house-runs.ts +43 -0
  92. package/src/ledger/live-timeline.ts +4 -19
  93. package/src/ledger/run-log.ts +39 -10
  94. package/src/ledger/store.ts +30 -2
  95. package/src/ledger/world-cache.ts +63 -14
  96. package/src/plan/cadence.ts +21 -5
  97. package/src/plan/catch-up.ts +37 -0
  98. package/src/plan/plan-timeline.ts +99 -0
  99. package/src/plan/planner.ts +138 -13
  100. package/src/plan/repo-source.ts +120 -8
  101. package/src/release/run-release.ts +9 -2
  102. package/src/release/unit-resolve.ts +8 -12
  103. package/src/repo-config.ts +17 -4
  104. package/src/serve/house-runs.ts +53 -0
  105. package/src/serve/hub-app/activity-st.ts +168 -0
  106. package/src/serve/hub-app/activity-view.tsx +218 -0
  107. package/src/serve/hub-app/activity.css +78 -0
  108. package/src/serve/hub-app/admin-svc.ts +41 -0
  109. package/src/serve/hub-app/admin-view.tsx +42 -0
  110. package/src/serve/hub-app/chart-view.ts +12 -5
  111. package/src/serve/hub-app/check-words.ts +2 -0
  112. package/src/serve/hub-app/checks-svc.ts +2 -1
  113. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  114. package/src/serve/hub-app/col-tables.ts +115 -0
  115. package/src/serve/hub-app/cols-svc.ts +28 -0
  116. package/src/serve/hub-app/controls.css +32 -0
  117. package/src/serve/hub-app/cycle-button.tsx +36 -0
  118. package/src/serve/hub-app/dock-anim.ts +25 -0
  119. package/src/serve/hub-app/dock-st.ts +329 -0
  120. package/src/serve/hub-app/dock-view.tsx +68 -0
  121. package/src/serve/hub-app/dock.css +35 -0
  122. package/src/serve/hub-app/hash-route.ts +7 -1
  123. package/src/serve/hub-app/header-bar.tsx +79 -104
  124. package/src/serve/hub-app/home-st.ts +50 -13
  125. package/src/serve/hub-app/home-strip.css +81 -23
  126. package/src/serve/hub-app/home-strip.tsx +243 -102
  127. package/src/serve/hub-app/home.html +4 -0
  128. package/src/serve/hub-app/hub-app.tsx +103 -161
  129. package/src/serve/hub-app/hub-types.ts +6 -3
  130. package/src/serve/hub-app/hub.css +101 -39
  131. package/src/serve/hub-app/icons.tsx +27 -3
  132. package/src/serve/hub-app/index.html +4 -0
  133. package/src/serve/hub-app/keys.ts +44 -9
  134. package/src/serve/hub-app/live-svc.ts +24 -64
  135. package/src/serve/hub-app/machine-st.ts +65 -13
  136. package/src/serve/hub-app/md.ts +38 -5
  137. package/src/serve/hub-app/next-run.ts +1 -1
  138. package/src/serve/hub-app/palette-st.ts +13 -9
  139. package/src/serve/hub-app/palette-view.tsx +47 -0
  140. package/src/serve/hub-app/pill-row.tsx +65 -0
  141. package/src/serve/hub-app/planned-st.ts +21 -85
  142. package/src/serve/hub-app/planned-view.tsx +12 -7
  143. package/src/serve/hub-app/planned.css +5 -2
  144. package/src/serve/hub-app/project-list.css +43 -21
  145. package/src/serve/hub-app/project-list.tsx +122 -23
  146. package/src/serve/hub-app/projects-svc.ts +120 -12
  147. package/src/serve/hub-app/readme-svc.ts +38 -0
  148. package/src/serve/hub-app/readme-view.tsx +19 -0
  149. package/src/serve/hub-app/readme.css +11 -0
  150. package/src/serve/hub-app/repo-sections.tsx +61 -0
  151. package/src/serve/hub-app/repo-st.ts +26 -7
  152. package/src/serve/hub-app/runs-tabs.ts +1 -1
  153. package/src/serve/hub-app/sidebar.tsx +84 -0
  154. package/src/serve/hub-app/tests-view.tsx +31 -4
  155. package/src/serve/hub-app/timeline-chart.tsx +31 -27
  156. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  157. package/src/serve/hub-app/timeline-svc.ts +2 -2
  158. package/src/serve/hub-app/timeline-view.tsx +29 -9
  159. package/src/serve/hub-runtime.ts +17 -9
  160. package/src/serve/other-repos.ts +8 -45
  161. package/src/serve/project-gutter-route.ts +100 -20
  162. package/src/serve/project-gutter.ts +34 -13
  163. package/src/serve/readme-route.ts +57 -0
  164. package/src/serve/serve-hub.ts +13 -8
  165. package/src/serve/tracked-watch.ts +33 -1
  166. package/src/serve/warm-cell.ts +49 -16
  167. package/src/shipkit-files.ts +20 -4
  168. package/src/suite/judge-suites.ts +42 -17
  169. package/src/suite/suite-cli.ts +5 -8
  170. package/src/wiki/lint-md.ts +2 -0
  171. package/src/wiki/scan-md.ts +37 -10
  172. package/src/checks/SK023-stores-under-cap.check.ts +0 -49
  173. package/src/checks/contracts.check.ts +0 -20
@@ -0,0 +1,59 @@
1
+ // What a repo serves on the web, read from how it deploys: a GitHub Pages
2
+ // workflow, a wrangler config, or deploy.sh. The web root is where the
3
+ // identity outputs (favicon, logo, OG image) must land to be served, so it
4
+ // comes from the deploy, never a hardcoded `public/`.
5
+ // pure over the disk it is handed: SK002 is cached on what it reads through it
6
+ import type { ScanCtx } from '../core/check-ctx.ts'
7
+
8
+ export type WebDisk = Pick<ScanCtx, 'read' | 'exists' | 'entries'>
9
+ /** `source`: the file that says the repo deploys; `root`: the served dir, null when that file does not name one */
10
+ export type WebDeploy = { source: string; root: string | null }
11
+
12
+ const WORKFLOWS = '.github/workflows'
13
+ const PAGES = /actions\/(?:deploy-pages|upload-pages-artifact)|peaceiris\/actions-gh-pages|cloudflare\/(?:pages|wrangler)-action|wrangler\s+pages\s+deploy/
14
+ const WRANGLER = ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc']
15
+ export const DEFAULT_WEB_ROOT = 'public'
16
+
17
+ const clean = (dir: string): string => dir.replace(/^["']|["']$/g, '').replace(/^\.\//, '').replace(/\/+$/, '') || '.'
18
+
19
+ /** the served dir a deploy file names, null when it names none */
20
+ function rootIn(text: string): string | null {
21
+ const pick = (re: RegExp): string | null => {
22
+ const m = text.match(re)
23
+ return m?.[1] != null ? clean(m[1]) : null
24
+ }
25
+ return (
26
+ pick(/upload-pages-artifact@[^\n]*\n(?:[ \t]+[^\n]*\n)*?[ \t]+path:\s*(\S+)/) ??
27
+ pick(/publish_dir:\s*(\S+)/) ??
28
+ pick(/wrangler\s+pages\s+deploy\s+([^\s-]\S*)/) ??
29
+ pick(/pages_build_output_dir["']?\s*[:=]\s*["']([^"']+)["']/) ??
30
+ pick(/\[assets\][^[]*?directory\s*=\s*["']([^"']+)["']/) ??
31
+ pick(/"assets"\s*:\s*\{[^}]*"directory"\s*:\s*"([^"]+)"/) ??
32
+ pick(/\[site\][^[]*?bucket\s*=\s*["']([^"']+)["']/)
33
+ )
34
+ }
35
+
36
+ /** every deploy this repo declares, in the order a web root is read from */
37
+ export function webDeploys(d: WebDisk): WebDeploy[] {
38
+ const out: WebDeploy[] = []
39
+ for (const e of d.entries(WORKFLOWS)) {
40
+ if (e.kind !== 'file' || !/\.ya?ml$/.test(e.name)) continue
41
+ const text = d.read(`${WORKFLOWS}/${e.name}`) ?? ''
42
+ if (PAGES.test(text)) out.push({ source: `${WORKFLOWS}/${e.name}`, root: rootIn(text) })
43
+ }
44
+ for (const f of WRANGLER) {
45
+ const text = d.exists(f, 'file') ? d.read(f) : null
46
+ if (text != null) out.push({ source: f, root: rootIn(text) })
47
+ }
48
+ const sh = d.exists('deploy.sh', 'file') ? d.read('deploy.sh') : null
49
+ if (sh != null) out.push({ source: 'deploy.sh', root: rootIn(sh) })
50
+ return out
51
+ }
52
+
53
+ /** where the identity outputs go: the config's `contracts.webRoot`, else the
54
+ * first dir a deploy names, else `public` */
55
+ export function webRootOf(configured: string | undefined, deploys: WebDeploy[]): string {
56
+ if (configured != null) return clean(configured)
57
+ return deploys.find((x) => x.root != null)?.root ?? DEFAULT_WEB_ROOT
58
+ }
59
+
@@ -0,0 +1,46 @@
1
+ // AGENT SPAWN, found statically: code that starts a Claude or agent process on
2
+ // its own (a spawn or exec of `claude`, `claude -p`, the agent sdk). No tool
3
+ // here starts an agent unless its owner asks in person, so the sweep tests
4
+ // (tests/no-agent-spawn.test.ts, and the house repo's own) fail on any hit.
5
+ //
6
+ // a string that only MENTIONS claude (a url, `~/.claude/projects`, a help
7
+ // line naming the product) is no spawn, and comments are skipped.
8
+
9
+ export type AgentSpawn = { line: number; what: string }
10
+
11
+ const SHAPES: { re: RegExp; what: string }[] = [
12
+ // the sdks that run an agent loop in process or spawn the cli
13
+ { re: /(['"`])@anthropic-ai\/(?:claude-agent-sdk|claude-code)(?:\/[^'"`]*)?\1/, what: 'imports the agent sdk' },
14
+ // an argv whose command is claude: spawn(['claude', …]), c.exec(['claude'])
15
+ { re: /\[\s*(['"`])(?:[^'"`\s]*\/)?claude\1/, what: 'an argv that runs claude' },
16
+ // spawn('claude', …), execSync('claude -p …'), execFile('claude')
17
+ { re: /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync|execa|execaSync|fork)\s*\(\s*(['"`])(?:[^'"`\s]*\/)?claude(?:\s[^'"`]*)?\1/, what: 'a process call that runs claude' },
18
+ // the bun shell: $`claude -p …`
19
+ { re: /\$\s*`\s*(?:[^`\s]*\/)?claude(?:\s|`)/, what: 'a bun shell line that runs claude' },
20
+ // the headless form, wherever the string goes
21
+ { re: /(['"`])(?:[^'"`\s]*\/)?claude\s+(?:-p|--print)\b/, what: 'a claude -p command line' },
22
+ ]
23
+
24
+ /** the first agent spawn in one source text, 1-based line, comments skipped */
25
+ export function agentSpawnIn(src: string): AgentSpawn | null {
26
+ const lines = src.split('\n')
27
+ let inBlock = false
28
+ for (let i = 0; i < lines.length; i++) {
29
+ let l = lines[i]!
30
+ if (inBlock) {
31
+ const end = l.indexOf('*/')
32
+ if (end < 0) continue
33
+ l = l.slice(end + 2)
34
+ inBlock = false
35
+ }
36
+ l = l.replace(/\/\*.*?\*\//g, '')
37
+ const open = l.indexOf('/*')
38
+ if (open >= 0) {
39
+ inBlock = true
40
+ l = l.slice(0, open)
41
+ }
42
+ l = l.replace(/(^|\s)\/\/.*$/, '$1')
43
+ for (const s of SHAPES) if (s.re.test(l)) return { line: i + 1, what: s.what }
44
+ }
45
+ return null
46
+ }
@@ -0,0 +1,82 @@
1
+ // `archived: true` in repo.config.ts: the repo stays known and listed, and
2
+ // nothing runs there (no hub load, so no watch, no plan rows, no gates, no
3
+ // processes, no widgets, no identity fill). Read and written as TEXT, never by
4
+ // loading the config: the daemon and the page rows read many configs per poll,
5
+ // and the toggle moves that one key and no other byte.
6
+ import { readFileSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { atomicWrite } from './locked-write.ts'
9
+ import { configDepths } from './identity-color.ts'
10
+
11
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)archived\1\s*:\s*(true|false)\b/gm
12
+
13
+ /** every top level `archived` key of `defineRepo({…})` with its value and the
14
+ * offsets of the whole `archived: <value>` token */
15
+ function archivedKeys(text: string): { on: boolean; start: number; end: number }[] {
16
+ const depth = configDepths(text)
17
+ const out: { on: boolean; start: number; end: number }[] = []
18
+ for (const m of text.matchAll(KEY_RE)) {
19
+ const lead = /^[{,]/.test(m[0]) ? 1 : 0
20
+ const sep = lead === 1 ? m.index : m.index - 1
21
+ if (sep < 0 || depth[sep] !== 1) continue
22
+ const start = m.index + lead + /^[{,]?(\s*)/.exec(m[0])![1]!.length
23
+ out.push({ on: m[2] === 'true', start, end: m.index + m[0].length })
24
+ }
25
+ return out
26
+ }
27
+
28
+ /** does the config text archive the repo */
29
+ export const archivedOfText = (text: string): boolean => archivedKeys(text).some((k) => k.on)
30
+
31
+ /** the config text with the repo archived or not. On: the key reads true, a
32
+ * missing one lands as the first key of `defineRepo({`. Off: the key goes,
33
+ * so a round trip gives the text back byte for byte */
34
+ export function setArchivedText(text: string, on: boolean): string {
35
+ const keys = archivedKeys(text)
36
+ if (keys.length > 1) throw new Error('repo.config.ts declares `archived` more than once, set it by hand')
37
+ const k = keys[0]
38
+ if (on) {
39
+ if (k != null) return k.on ? text : `${text.slice(0, k.start)}archived: true${text.slice(k.end)}`
40
+ const lines = text.split('\n')
41
+ const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
42
+ if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `archived` into, set it by hand')
43
+ const line = lines[open]!
44
+ if (/defineRepo\(\s*\{\s*$/.test(line)) {
45
+ const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
46
+ const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
47
+ return [...lines.slice(0, open + 1), `${pad}archived: true,`, ...lines.slice(open + 1)].join('\n')
48
+ }
49
+ lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}archived: true, `)
50
+ return lines.join('\n')
51
+ }
52
+ if (k == null) return text
53
+ // a line of its own goes whole, an inline key goes with its comma
54
+ const lineStart = text.lastIndexOf('\n', k.start - 1) + 1
55
+ const lineEnd = text.indexOf('\n', k.end)
56
+ const rest = text.slice(k.end, lineEnd === -1 ? text.length : lineEnd)
57
+ if (text.slice(lineStart, k.start).trim() === '' && /^\s*,?\s*$/.test(rest)) return text.slice(0, lineStart) + text.slice(lineEnd === -1 ? text.length : lineEnd + 1)
58
+ const after = /^\s*,\s*/.exec(text.slice(k.end))
59
+ if (after != null) return text.slice(0, k.start) + text.slice(k.end + after[0].length)
60
+ const before = /,\s*$/.exec(text.slice(0, k.start))
61
+ return before != null ? text.slice(0, k.start - before[0].length) + text.slice(k.end) : text.slice(0, k.start) + text.slice(k.end)
62
+ }
63
+
64
+ const CONFIG_FILE = 'repo.config.ts'
65
+
66
+ /** is this repo archived; false when it has no readable config */
67
+ export function isArchived(root: string): boolean {
68
+ try {
69
+ return archivedOfText(readFileSync(join(root, CONFIG_FILE), 'utf8'))
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ /** archive the repo or bring it back: only that key of its config moves */
76
+ export function writeArchived(root: string, on: boolean): boolean {
77
+ const path = join(root, CONFIG_FILE)
78
+ const text = readFileSync(path, 'utf8')
79
+ const out = setArchivedText(text, on)
80
+ if (out !== text) atomicWrite(path, out)
81
+ return on
82
+ }
@@ -11,12 +11,14 @@ import type { MachineLoad } from '../suite/judge-suites.ts'
11
11
  * 2s at their 90th percentile, and the few past it shell out to a tool */
12
12
  export const IN_PROCESS_BUDGET_MS = 2_000
13
13
 
14
- /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe.
14
+ /** a subprocess (a build step, a test suite, a `cmd` check) or a world probe
15
+ * (a `ttl` check, or a `never:` one, whose answer lives outside the repo).
15
16
  * Read off the same record: every typecheck stays under 6s at its 90th
16
17
  * percentile, and the quick test suites under 6s but one */
17
18
  export const SUBPROCESS_BUDGET_MS = 10_000
18
19
 
19
- /** what a check declares about itself that picks its default */
20
+ /** what a check declares about itself that picks its default. `world`: it
21
+ * declares `ttl` or `never`, so it reads the world rather than the repo */
20
22
  export type BudgetKind = { build: boolean; cmd: boolean; world: boolean }
21
23
 
22
24
  export const defaultBudget = (k: BudgetKind): number => (k.build || k.cmd || k.world ? SUBPROCESS_BUDGET_MS : IN_PROCESS_BUDGET_MS)
@@ -36,7 +38,15 @@ export const unknownBudgetIds = (ids: readonly string[], budgets: Record<string,
36
38
  export const budgetValueProblem = (v: unknown): string | null =>
37
39
  typeof v === 'number' && Number.isInteger(v) && v > 0 ? null : `must be a whole number of milliseconds above 0, got ${JSON.stringify(v)}`
38
40
 
39
- export const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
41
+ /** `<load1>/<cores>` in this variable pins the reading: a test of a budget
42
+ * verdict must not depend on what else the machine runs */
43
+ export const MACHINE_LOAD_ENV = 'SHIPKIT_MACHINE_LOAD'
44
+
45
+ export const machineLoad = (): MachineLoad => {
46
+ const pinned = process.env[MACHINE_LOAD_ENV]?.match(/^(\d+(?:\.\d+)?)\/(\d+)$/)
47
+ if (pinned != null) return { load1: Number(pinned[1]), cores: Number(pinned[2]) }
48
+ return { load1: loadavg()[0] ?? 0, cores: availableParallelism() }
49
+ }
40
50
 
41
51
  /** `10s` for a whole second count, `150ms` otherwise */
42
52
  export const fmtBudget = (ms: number): string => (ms % 1000 === 0 ? `${ms / 1000}s` : `${ms}ms`)
@@ -49,12 +59,12 @@ export const isBudgetLine = (text: string): boolean => /^\S+ (passed|warned) in
49
59
  /** a test check sends the reader to the page that breaks its time down */
50
60
  const isTestCheck = (id: string): boolean => id === 'test' || id.startsWith('test:')
51
61
 
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 */
62
+ /** the WARN a pass over its budget prints, or null inside it. Null too with
63
+ * the load at or past the core count: the budget judges wall time, and on a
64
+ * saturated machine the wall is the machine's, not the check's (a suite's
65
+ * own verdict judges CPU instead, suite/judge-suites.ts) */
55
66
  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,` : ''
67
+ if (ms <= budgetMs || load.load1 >= load.cores) return null
58
68
  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}`
69
+ return `${id} ${verb} in ${(ms / 1000).toFixed(1)}s, over its ${fmtBudget(budgetMs)} budget: ${where}`
60
70
  }
@@ -65,12 +65,21 @@ export function routesToShipkit(body: string): boolean {
65
65
  return /\bshipkit\b\S*(?:\s+\S+)*?\s+ci(?:\s|$)/.test(body.split(/&&|\|\||;/)[0] ?? body)
66
66
  }
67
67
 
68
- // recover the consumption style from existing scripts: any shipkit command
69
- // already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
68
+ /** is this path shipkit's own cli: a file named exactly `cli.ts` under a
69
+ * directory whose name says shipkit. A repo's own cli (`src/cli.ts`,
70
+ * `src/cli/comfy-ts-cli.ts`) is not, and taking it for shipkit writes
71
+ * scripts that call a command that cli does not have */
72
+ export function isShipkitCli(path: string): boolean {
73
+ const parts = path.split('/')
74
+ return parts.at(-1) === 'cli.ts' && parts.slice(0, -1).some((d) => /shipkit/.test(d))
75
+ }
76
+
77
+ // recover the consumption style from existing scripts: any script already
78
+ // running shipkit's cli by path fixes the style for new bodies
70
79
  export function inferBin(scripts: Record<string, string>): string | undefined {
71
80
  for (const body of Object.values(scripts)) {
72
81
  const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
73
- if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
82
+ if (m != null && isShipkitCli(m[1]!) && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
74
83
  }
75
84
  return undefined
76
85
  }
@@ -39,7 +39,7 @@ import { parallelCap } from './parallel.ts'
39
39
  import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
40
  import { budgetLine, machineLoad } from './budget.ts'
41
41
  import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
42
- import { formatLine, parseWireOutput, type Line } from './wire.ts'
42
+ import { FOREVER_MS, formatLine, parseWireOutput, type Line } from './wire.ts'
43
43
  import { offLine, type WhenDecl } from './when.ts'
44
44
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
45
45
  import { Ledger, stampCached } from '../ledger/ledger.ts'
@@ -47,7 +47,7 @@ import { attachedStamp } from './outcome-words.ts'
47
47
  import type { RunLog, RunStatus } from '../ledger/run-log.ts'
48
48
  import { liveStateOf } from '../ledger/live-runs.ts'
49
49
  import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
50
- import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
50
+ import { rememberWorld, replayWorld, worldKeyOf, type WorldCache } from '../ledger/world-cache.ts'
51
51
  import { extractProblems } from '../problems/extract.ts'
52
52
  import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
53
53
 
@@ -56,6 +56,10 @@ import { feedsProblemsPane, type Population, type Problem } from '../problems/pr
56
56
  * about the world. */
57
57
  export const CHECK_TIMEOUT_MS = 120_000
58
58
 
59
+ /** the most findings a failed build step keeps beside its headline: a tool
60
+ * that prints a wall of them is read in its log */
61
+ const MAX_EXTRA = 20
62
+
59
63
  /** epoch ms that never goes backwards inside one process, whole numbers so a
60
64
  * row's `at - ms` is its start exactly: the wall clock can step back (an NTP
61
65
  * correction), and a check stamped before an earlier one ended makes the
@@ -191,6 +195,9 @@ export type RunChecksOpts = {
191
195
  * gate the whole repo, since the check's own gate already ran. */
192
196
  exclude?: string[]
193
197
  print?: (line: string) => void
198
+ /** where the green summary goes when the caller prints its own verdict
199
+ * (`shipkit ci` does). Default: print */
200
+ detail?: (line: string) => void
194
201
  color?: boolean
195
202
  onCheck?: (e: CheckEvent) => void
196
203
  }
@@ -227,11 +234,21 @@ function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
227
234
  return keep
228
235
  }
229
236
 
237
+ /** how many tests a suite ran, read from the headline it prints last
238
+ * (`Ran 920 tests across 12 files.`), null when it printed none */
239
+ export function testsRan(lastLine: string): number | null {
240
+ const m = /Ran (\d+) tests?/.exec(lastLine)
241
+ return m == null ? null : Number(m[1])
242
+ }
243
+
230
244
  export type Selection = {
231
245
  /** what this run measures */
232
246
  selected: RunnableCheck[]
233
247
  /** turned off by name, with the reason: never run, always reported */
234
248
  off: RunnableCheck[]
249
+ /** off, and outside what `--only` asked about: reported where the caller
250
+ * keeps what nobody acts on */
251
+ offElsewhere?: RunnableCheck[]
235
252
  /** not asked about by this run, and why */
236
253
  parked: { id: string; text: string }[]
237
254
  /** asked for, and it never said whether it blocks */
@@ -337,6 +354,8 @@ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'ful
337
354
  if (o.onlyIds != null && o.onlyIds.length > 0) {
338
355
  const keep = closure(o.checks, onlyIds)
339
356
  selected = selected.filter((u) => keep.has(u.id))
357
+ // a narrowed run says nothing about the checks it never asked about
358
+ return { selected, off: off.filter((u) => keep.has(u.id)), offElsewhere: off.filter((u) => !keep.has(u.id)), parked, undeclared, errors }
340
359
  }
341
360
  return { selected, off, parked, undeclared, errors }
342
361
  }
@@ -416,6 +435,9 @@ type Live = RunnableCheck & CheckResult & {
416
435
  /** what the headline carries after the id: a failure's last line, or the
417
436
  * reason it never ran */
418
437
  tail: string
438
+ /** a failed build step's other findings, the wire lines its tool printed
439
+ * beside the one the headline carries */
440
+ extra: Line[]
419
441
  }
420
442
 
421
443
  export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
@@ -439,15 +461,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
439
461
  blocking: u.blocking, advisory: r.advisory, lines: r.lines, problems: r.problems, logPath: r.logPath, lastLine: r.lastLine, note: r.note, attached,
440
462
  })
441
463
  // an attached check is filed as a replay: it measured nothing, and a
442
- // row carrying its wait would read as a check running in this process
443
- o.log?.add({ id: u.id, status: r.status, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
464
+ // row carrying its wait would read as a check running in this process.
465
+ // A non-blocking failure is filed as the warning the gate printed, so
466
+ // no surface reading the record paints red what the gate called orange
467
+ const filed = r.status === 'fail' && r.advisory ? 'warn' : r.status
468
+ o.log?.add({ id: u.id, status: filed, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
444
469
  }
445
470
 
446
471
  // OFF, in ONE place: skipped by name, never run, never stored, and the
447
472
  // reason printed where the verdict would have been
448
- for (const u of sel.off) {
473
+ for (const u of [...sel.off, ...(sel.offElsewhere ?? [])]) {
449
474
  const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
450
- emit(line)
475
+ if (sel.off.includes(u)) emit(line); else (o.detail ?? print)(formatLine(line, color))
451
476
  report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
452
477
  }
453
478
  // `--only` that leaves nothing to run asked a question nobody answers, and
@@ -461,7 +486,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
461
486
  const runDir = openRunDir(o.logDir)
462
487
  const checks: Live[] = sel.selected.map((u) => ({
463
488
  ...u,
464
- status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '',
489
+ status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '', extra: [],
465
490
  logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
466
491
  }))
467
492
  const selectedIds = new Set(checks.map((u) => u.id))
@@ -499,10 +524,15 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
499
524
  * or that a different shipkit measured it. */
500
525
  const worldKey = (u: RunnableCheck): string => {
501
526
  let code = ''
502
- try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
503
- // a check that writes answers a different question when frozen
504
- const frozen = u.writes === true ? `\0frozen=${process.env['SHIPKIT_FROZEN'] === '1'}` : ''
505
- return `tc=${o.ledger?.layer.toolchainId ?? ''}\0code=${code}\0${u.rotation ?? ''}${frozen}`
527
+ if (u.ttlMs < FOREVER_MS) try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
528
+ return worldKeyOf({
529
+ ttlMs: u.ttlMs,
530
+ toolchain: u.ttlMs < FOREVER_MS ? o.ledger?.layer.toolchainId ?? '' : '',
531
+ code,
532
+ rotation: u.rotation ?? '',
533
+ // a check that writes answers a different question when frozen
534
+ frozen: u.writes === true ? process.env['SHIPKIT_FROZEN'] === '1' : null,
535
+ })
506
536
  }
507
537
 
508
538
  // a non-blocking failure becomes a WARNING here, at the source: printing
@@ -532,10 +562,18 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
532
562
  // baked into a remembered line comes back on a replay as if this run
533
563
  // had measured it (ledger/ledger.ts rule 5)
534
564
  const head = u.lines[0]
565
+ // a suite's count rides on its own line: `test 920 tests (1.4s)`
566
+ const ran = u.status === 'pass' || u.status === 'warn' ? testsRan(u.lastLine) : null
567
+ const counted = ran != null && head != null && head.kind === 'ok' && (head.text === u.id || head.text.startsWith(`${u.id} (`))
568
+ const id = counted ? `${u.id} ${ran} test${ran === 1 ? '' : 's'}` : u.id
535
569
  const shown = u.synthetic && !u.replayed && head != null
536
- ? [{ ...head, text: `${u.id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
537
- : u.lines
538
- for (const l of shown) emit(l)
570
+ ? [{ ...head, text: `${id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
571
+ : counted ? [{ ...head, text: `${id}${head.text.slice(u.id.length)}` }, ...u.lines.slice(1)] : u.lines
572
+ // a caller printing its own verdict wants one line per green check and
573
+ // only the warnings of a warned one; a failure prints whole
574
+ const quiet = (l: Line, i: number): boolean =>
575
+ o.detail != null && (u.status === 'pass' ? i > 0 : u.status === 'warn' || u.advisory ? l.kind === 'ok' || l.kind === 'note' || l.kind === 'skip' : false)
576
+ for (const [i, l] of shown.entries()) if (quiet(l, i)) o.detail!(formatLine(l, color)); else emit(l)
539
577
  if (u.status === 'fail' && !u.advisory && u.logPath != null)
540
578
  emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
541
579
  report(u, { id: u.id, status: u.status, advisory: u.advisory, replayed: u.replayed, ms: u.ms, exitCode: u.exitCode, lines: u.lines, problems: u.problems, lastLine: u.lastLine, note: u.note, logPath: u.logPath }, finishedAt, u.attached)
@@ -642,14 +680,25 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
642
680
  u.problems = findings(u, u.lines, r.out)
643
681
  const lastFail = u.lines.filter((l) => l.kind === 'fail').at(-1)
644
682
  if (lastFail != null) u.lastLine = lastFail.text.slice(0, 200)
683
+ // a tool that speaks the wire (the suite runner) ends on its FAIL
684
+ // lines, and the headline carries only the last one. Only that closing
685
+ // block is its report: a wire line earlier in the log is some test's
686
+ // own output
687
+ if (u.build && u.status === 'fail') {
688
+ const parsed = parseWireOutput(r.out)
689
+ let i = parsed.length
690
+ while (i > 0 && (parsed[i - 1]!.kind === 'fail' || parsed[i - 1]!.kind === 'fix-fail')) i--
691
+ u.extra = parsed.slice(i).filter((l) => !(l.kind === 'fail' && `FAIL: ${l.text}` === u.lastLine)).slice(-MAX_EXTRA)
692
+ }
645
693
  }
646
694
 
647
695
  /** a check that printed nothing of its own still has to report that it
648
696
  * ran: the verdict, its name, and (at print time) what it cost */
649
697
  const headlineOf = (u: Live): void => {
650
698
  u.synthetic = true
651
- u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
652
- u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }]
699
+ const more = u.extra.filter((l) => l.kind === 'fail').length
700
+ u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}${more > 0 ? ` (+${more} more below)` : ''}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
701
+ u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }, ...u.extra]
653
702
  }
654
703
 
655
704
  /** what an attached asker adopts: the verdict and the lines BEFORE this
@@ -842,7 +891,7 @@ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
842
891
  const warned = results.filter((r) => r.status === 'warn' || (r.status === 'fail' && r.advisory))
843
892
  const ran = results.filter((r) => r.status !== 'skip').length
844
893
  if (blocked.length === 0 && !interrupted && results.length > 0)
845
- 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(', ')}` })
894
+ (o.detail ?? print)(formatLine({ 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(', ')}` }, color))
846
895
  return {
847
896
  ok: blocked.length === 0 && !interrupted,
848
897
  interrupted,
@@ -17,6 +17,7 @@
17
17
  //
18
18
  // Skipped dirs: node_modules, dist, tmp, references (templates for other
19
19
  // repos), and every dot-dir.
20
+ import { RETIRED_CHECK_IDS } from './retired-checks.ts'
20
21
  import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
21
22
  import { basename, join, relative } from 'path'
22
23
  import { check, type CheckDef, type CheckFrom } from '../check.ts'
@@ -55,20 +56,23 @@ export type DiscoveredCheck = {
55
56
  from: CheckFrom
56
57
  }
57
58
 
58
- function walk(dir: string, out: string[], suffix = SUFFIX): void {
59
+ function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
60
+ dirs?.push(dir)
59
61
  let entries
60
62
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
61
63
  for (const e of entries) {
62
64
  if (e.isDirectory()) {
63
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
65
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
64
66
  } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
65
67
  }
66
68
  }
67
69
 
68
- /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted */
69
- export function repoUnitFiles(root: string): string[] {
70
+ /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
71
+ * `dirs` collects every dir the walk read: a file added to one moves its
72
+ * mtime, which is how a reader tells the list moved without walking again */
73
+ export function repoUnitFiles(root: string, dirs?: string[]): string[] {
70
74
  const out: string[] = []
71
- walk(root, out, UNIT_SUFFIX)
75
+ walk(root, out, UNIT_SUFFIX, dirs)
72
76
  return out.sort()
73
77
  }
74
78
 
@@ -176,6 +180,7 @@ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
176
180
  if (isBuiltin) {
177
181
  if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
178
182
  if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
183
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`)
179
184
  } else if (id.startsWith('SK')) {
180
185
  throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
181
186
  }
@@ -0,0 +1,20 @@
1
+ // the content of a file no tree id reaches (outside the repo, gitignored, an
2
+ // install), as one short hash for a check's `cacheKey`. `absent` when nothing
3
+ // stands there, so a file appearing or vanishing moves the key too.
4
+ import { createHash } from 'crypto'
5
+ import { readFileSync } from 'fs'
6
+ import { homedir } from 'os'
7
+ import { isAbsolute, join } from 'path'
8
+
9
+ export function fileStamp(abs: string): string {
10
+ try {
11
+ return createHash('sha256').update(readFileSync(abs)).digest('hex').slice(0, 16)
12
+ } catch {
13
+ return 'absent'
14
+ }
15
+ }
16
+
17
+ /** a path as a config declares it (`~/...`, absolute, or repo relative), absolute */
18
+ export function configPath(root: string, p: string): string {
19
+ return p.startsWith('~/') ? join(homedir(), p.slice(2)) : isAbsolute(p) ? p : join(root, p)
20
+ }