rvlib-shipkit 1.8.0 → 2.0.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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -9,6 +9,7 @@ import type { ProcessRow, RowDot } from '../../home/process-rows.ts'
9
9
  import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
10
10
  import { runsWords } from '../../core/runs-matrix.ts'
11
11
  import { checkName } from './check-words.ts'
12
+ import type { WhenAt } from './hub-types.ts'
12
13
  import { durationTone, fmtMs, type DurTone } from './dur.ts'
13
14
  import type { HashSel } from './hash-route.ts'
14
15
  import { SECTION_LETTER, sectionLabel, type SectionId } from './keys.ts'
@@ -234,8 +235,8 @@ export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0)
234
235
  /** the line at the top of the checks page: how much of this repo runs on
235
236
  * every gate, and how much does not. Counts CHECKS only, because nothing
236
237
  * else declares a place to run */
237
- export function whenCounts(resources: Resource[]): Record<'always' | 'release' | 'health' | 'off', number> {
238
- const out = { always: 0, release: 0, health: 0, off: 0 }
238
+ export function whenCounts(resources: Resource[]): Record<WhenAt, number> {
239
+ const out: Record<WhenAt, number> = { always: 0, release: 0, health: 0, commit: 0, off: 0 }
239
240
  for (const r of resources) if (r.check != null) out[r.check.when]++
240
241
  return out
241
242
  }
@@ -95,9 +95,9 @@ export class TreeSvc {
95
95
  /** the line at the top of the checks page: what runs always and what does not */
96
96
  get whenLine(): string {
97
97
  const c = whenCounts(this.st.cols.resources)
98
- const total = c.always + c.release + c.health + c.off
98
+ const total = c.always + c.release + c.health + c.commit + c.off
99
99
  if (total === 0) return ''
100
- return [`${c.always} always`, `${c.release} release`, `${c.health} health`, `${c.off} off`].join(' · ')
100
+ return [`${c.always} always`, `${c.release} release`, `${c.health} health`, `${c.commit} commit`, `${c.off} off`].join(' · ')
101
101
  }
102
102
 
103
103
  get redCount(): number {
@@ -1,15 +1,15 @@
1
1
  // the hub's default port, alone in its own module ON PURPOSE.
2
2
  //
3
3
  // It is four lines of arithmetic that several places need BEFORE any server
4
- // exists: the repo scaffold writes it into .vscode/tasks.json, `init` prints
5
- // it, the config resolver defaults to it. Importing it from run-serve.ts drags
4
+ // exists: the repo scaffold writes it into agent/index.md, `init` prints it,
5
+ // the config resolver defaults to it. Importing it from run-serve.ts drags
6
6
  // the whole hub (react, the markdown renderer, the sanitiser, every panel)
7
7
  // into a caller that only wanted a number, which measured 393ms of module
8
8
  // load in the scaffold alone.
9
9
  //
10
10
  // A port derived from the repo NAME rather than assigned means two repos open
11
11
  // side by side almost never collide, and the same repo always reopens on the
12
- // same port, which is what makes a folder-open task able to advertise a URL.
12
+ // same port, which is what lets a page advertise a URL before the hub runs.
13
13
  export function defaultHubPort(name: string): number {
14
14
  let h = 0
15
15
  for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
@@ -27,8 +27,6 @@ import { wikisCollection } from '../collections/wikis.ts'
27
27
  import { pingTick } from '../collections/pings.ts'
28
28
  import { buildCollections } from '../collections/providers.ts'
29
29
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
30
- import { CheckCtx } from '../core/check-ctx.ts'
31
- import { proseView } from '../prose/prose-view.ts'
32
30
  import { allTasks, gateInventory } from '../ledger/gate-checks.ts'
33
31
  import { runCheck, type CheckEvent } from '../core/run-check.ts'
34
32
  import { runCi, type CiOpts } from '../core/run-ci.ts'
@@ -158,9 +156,10 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
158
156
  graphs.push({ id: 'agent', label: 'agent/', title: `${c.name} agent/`, load: () => scanMdGraph({ root: agentRoot, index: c.serve?.agentIndex }), lint: { root: agentRoot, index: c.serve?.agentIndex } })
159
157
  // ONE page for every repo: the studio ALWAYS
160
158
  // mounts — kit-less repos get an empty media section, never a second
161
- // page. A broken kit degrades to the same empty kit, loudly.
159
+ // page. A broken kit degrades to the same empty kit, loudly, and a repo
160
+ // whose own tool owns the picking turns the kit off here
162
161
  let kit: ResolvedKit | null = null
163
- if (existsSync(join(root, '.shipkit/assets/kit.ts'))) {
162
+ if (c.serve?.assets !== false && existsSync(join(root, '.shipkit/assets/kit.ts'))) {
164
163
  try {
165
164
  kit = await loadKit(root)
166
165
  } catch (e) {
@@ -390,7 +389,6 @@ export async function resolveHubDeps(root: string, boot: RepoConfig, io: ServeIo
390
389
  const input = { facts, runs: readRuns(record), suiteRuns: readSuiteRuns(record) }
391
390
  return check == null || check === '' ? shapeTestsRoot(input) : shapeTestCheck(check, input)
392
391
  },
393
- prose: async () => proseView(CheckCtx.open({ root, config: c }), c),
394
392
  needs: () => projectNeeds(allCollections(), hubProblems.collector),
395
393
  hubProblems,
396
394
  // a job outlives this image, so only an in process run holds a reload back
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
10
10
  import { basename, dirname, join, resolve, sep } from 'path'
11
11
  import { fileURLToPath } from 'url'
12
- import { gitRoot } from '../core/git-root.ts'
12
+ import { gitRoot, isLinkedWorktree } from '../core/git-root.ts'
13
13
  import { DB_FILE } from '../ledger/db.ts'
14
14
  import { defaultHubPort } from './hub-port.ts'
15
15
  import { LAYOUT } from '../core/layout.ts'
@@ -52,7 +52,9 @@ export const ICON_FILES = ['.shipkit/identity/icon.png', 'public/favicon.png', '
52
52
 
53
53
  export const ICON_TYPES: Record<string, string> = { png: 'image/png', svg: 'image/svg+xml', ico: 'image/x-icon' }
54
54
 
55
- /** every direct child of `parent` that includes `index`, sorted by name */
55
+ /** every direct child of `parent` that includes `index`, sorted by name. A
56
+ * linked worktree of one is that repo checked out again, never a repo of its
57
+ * own: its processes would collide with the main checkout's ids and ports */
56
58
  export function wiredRepos(parent: string, index: string, home: string): WiredRepo[] {
57
59
  let names: string[]
58
60
  try {
@@ -65,6 +67,7 @@ export function wiredRepos(parent: string, index: string, home: string): WiredRe
65
67
  if (name.startsWith('.')) continue
66
68
  const path = join(parent, name)
67
69
  if (!includeTargets(path, home).includes(index)) continue
70
+ if (isLinkedWorktree(path)) continue
68
71
  out.push({ name, path, icon: ICON_FILES.find((f) => existsSync(join(path, f))) ?? null })
69
72
  }
70
73
  return out
@@ -7,7 +7,8 @@
7
7
  // - a run that writes the tree (a repair pass) waits for every run ahead of
8
8
  // it, and every later run waits for it
9
9
  // - every other run starts at once: the machine slot pool each check body
10
- // takes bounds the CPU, never this queue
10
+ // takes bounds the CPU, never this queue. It admits fewer slots on a
11
+ // loaded machine (core/slots.ts), so a run past it waits and says why
11
12
  //
12
13
  // the one constraint a reader breaks first: the stamp is taken when a run
13
14
  // STARTS, before its body reads anything, so a tree that moves during the run
@@ -15,6 +16,7 @@
15
16
  import { createHash } from 'crypto'
16
17
  import { statSync } from 'fs'
17
18
  import { join } from 'path'
19
+ import { fixPassRuns } from '../core/fix-scope.ts'
18
20
  import { gitBin } from '../core/git-bin.ts'
19
21
 
20
22
  export type LaneAsk = {
@@ -106,8 +108,10 @@ export function runKey(kind: string, opts: Record<string, unknown>): string {
106
108
  return `${kind} ${JSON.stringify(sorted)}`
107
109
  }
108
110
 
109
- /** a run with its repair pass on: `fix` and `fixRules` default to on */
110
- export const writesTree = (opts: Record<string, unknown>): boolean => opts.fix !== false || opts.fixRules !== false
111
+ /** a run whose repair pass runs (core/fix-scope.ts): the structural fixers
112
+ * and the hooks only ever run inside it */
113
+ export const writesTree = (opts: Record<string, unknown>): boolean =>
114
+ fixPassRuns({ fix: typeof opts.fix === 'boolean' ? opts.fix : undefined, onlyIds: Array.isArray(opts.onlyIds) ? (opts.onlyIds as string[]) : undefined })
111
115
 
112
116
  /** HEAD, what git status lists, and the stat of each listed path, hashed:
113
117
  * equal stamps mean no tracked or untracked file moved in between. Null
@@ -16,7 +16,6 @@ import type { CheckEvent } from '../core/run-check.ts'
16
16
  import { configDirty, readGateBlock, writeGateBlock, type GateEdit } from '../core/when-block.ts'
17
17
  import type { InvokedBy } from '../ledger/run-log.ts'
18
18
  import type { GraphMount } from '../wiki/graph-mount.ts'
19
- import type { ProseView } from '../prose/prose-view.ts'
20
19
  import type { Timeline } from '../ledger/run-timeline.ts'
21
20
  import type { TestCheckView, TestsRootView } from '../collections/test-time.ts'
22
21
  import type { LiveView } from '../ledger/live-timeline.ts'
@@ -105,9 +104,6 @@ export type HubDeps = {
105
104
  /** the comfy panel; null when the repo declares no workflow module, which
106
105
  * is also when the comfy section is absent from the page */
107
106
  comfy: ComfyDeps | null
108
- /** rule SK014's hits, grouped by word, over the config on disk: the data
109
- * of the SK014 page. Absent = the endpoint answers 404 */
110
- prose?: () => Promise<ProseView>
111
107
  /** re-read repo.config.ts when its bytes moved. The switch WRITES that
112
108
  * file, so every surface built on the config asks for this first, or a
113
109
  * toggle reads as a toggle that did nothing until the next restart */
@@ -367,7 +363,7 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
367
363
  if (req.method === 'POST') {
368
364
  try {
369
365
  const body = (await req.json()) as GateEdit & { expect?: string }
370
- const b = await writeGateBlock(d.root, { off: body.off, slow: body.slow, slowRemove: body.slowRemove, words: body.words }, { expect: body.expect })
366
+ const b = await writeGateBlock(d.root, { off: body.off, slow: body.slow, slowRemove: body.slowRemove }, { expect: body.expect })
371
367
  await d.refresh?.()
372
368
  return Response.json({ ...b, dirty: configDirty(d.root) })
373
369
  } catch (e) {
@@ -375,17 +371,6 @@ export function hubHandler(d: HubDeps, o: { base?: string; projects?: Gutter } =
375
371
  }
376
372
  }
377
373
  }
378
- if (path === '/api/prose.json' && d.prose != null) {
379
- // every SK014 hit, grouped by word, noisiest first. The word
380
- // lists are written through /api/checks/when like every other
381
- // edit of repo.config.ts
382
- await d.refresh?.()
383
- try {
384
- return Response.json(await d.prose())
385
- } catch (e) {
386
- return new Response(`the prose check could not run: ${(e as Error).message}`, { status: 500 })
387
- }
388
- }
389
374
  if (path === '/api/check/run' && req.method === 'POST') {
390
375
  const body = (await req.json().catch(() => ({}))) as { only?: string[]; gate?: boolean }
391
376
  // NDJSON: one line per event, so the page colours a row the
@@ -48,7 +48,7 @@ export async function gitIgnored(root: string, paths: string[]): Promise<Set<str
48
48
  }
49
49
 
50
50
  /** a commit, a fetch or a tag moved a ref: the one write inside .git a check
51
- * keyed on the refs (SK009) must hear. Lock files are git mid write */
51
+ * keyed on the refs must hear. Lock files are git mid write */
52
52
  export function isRefMove(rel: string): boolean {
53
53
  if (rel.endsWith('.lock')) return false
54
54
  return rel === '.git/HEAD' || rel === '.git/packed-refs' || rel.startsWith('.git/refs/')
@@ -27,6 +27,10 @@ export type SmokeRoute = {
27
27
  * match's href after this route and judges the landed page as its own
28
28
  * row. How a detail page with a real id gets visited without knowing one. */
29
29
  open?: string
30
+ /** the visible text a settled route needs before it counts as rendered,
31
+ * default 40. A canvas app (a game) draws everything into one canvas and
32
+ * shows no text: `{ path: '/', waitFor: 'canvas', minChars: 0 }`. */
33
+ minChars?: number
30
34
  }
31
35
 
32
36
  export type SmokeLogin = {
@@ -63,8 +67,9 @@ export type SmokeConfig = {
63
67
  const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
64
68
  const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
65
69
  const FORM_KEYS = new Set(['email', 'password', 'submit'])
66
- const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
70
+ const ROUTE_KEYS = new Set(['path', 'waitFor', 'open', 'minChars'])
67
71
  export const DEFAULT_LOGIN_PATH = '/login'
72
+ export const DEFAULT_MIN_CHARS = 40
68
73
 
69
74
  export const DEFAULT_PACE_MS = 3000
70
75
  export const DEFAULT_SHOTS = `${LAYOUT.tmp}/smoke`
@@ -86,6 +91,8 @@ export function validateSmokeConfig(c: unknown): SmokeConfig {
86
91
  const v = (r as Record<string, unknown>)[k]
87
92
  if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
88
93
  }
94
+ const min = (r as Record<string, unknown>)['minChars']
95
+ if (min != null && (typeof min !== 'number' || !Number.isInteger(min) || min < 0)) throw new Error(`defineRepo: smoke.routes['${path}'].minChars must be a whole number of characters, 0 or more`)
89
96
  }
90
97
  }
91
98
  if (s['login'] != null) {
@@ -152,8 +159,8 @@ export type SmokeReport = {
152
159
  * nothing threw, an empty `waitFor` on a listing is exactly how a broken
153
160
  * migration looks, and a page that scrolls sideways is a layout defect only
154
161
  * a real browser can see (happy-dom does no layout). */
155
- export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
156
- const empty = r.chars < 40
162
+ export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; minChars?: number; overflowPx: number }): RouteReport {
163
+ const empty = r.chars < (r.minChars ?? DEFAULT_MIN_CHARS)
157
164
  const noMatch = r.waitFor != null && r.matches === 0
158
165
  const sideways = r.overflowPx > 1
159
166
  const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
@@ -368,7 +375,7 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
368
375
  }
369
376
 
370
377
  /** visit one url, settle it, judge it, screenshot it */
371
- const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
378
+ const visit = async (label: string, url: string, waitFor?: string, minChars?: number): Promise<RouteReport> => {
372
379
  // let the previous page's requests drain out of the concurrency
373
380
  // window BEFORE the label moves, or its late failures land on this row
374
381
  if (paceMs > 0) await page.waitForTimeout(paceMs)
@@ -387,11 +394,11 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
387
394
  return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
388
395
  }).catch(() => 0)
389
396
  await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
390
- return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
397
+ return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, minChars, overflowPx })
391
398
  }
392
399
 
393
400
  for (const route of routes) {
394
- reports.push(await visit(route.path, route.path, route.waitFor))
401
+ reports.push(await visit(route.path, route.path, route.waitFor, route.minChars))
395
402
  if (route.open == null) continue
396
403
  // a detail page with a real id: the first link the selector matches
397
404
  const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)
package/src/start-repo.ts CHANGED
@@ -13,19 +13,24 @@ import { join } from 'path'
13
13
  import { ensureGitRepo, ensureIgnoreBaseline, firstGhLine, isScratch, publishRepo, runner, type Run } from './git-bootstrap.ts'
14
14
  import { initRepo } from './init-repo.ts'
15
15
  import { AGENT_LINE } from './checks/SK003-claude-md-shape.check.ts'
16
- import { scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
16
+ import { adoptHouse, scaffoldProject, TOOLCHAIN } from './scaffold/project-scaffold.ts'
17
17
  import { defaultHubPort } from './serve/hub-port.ts'
18
18
 
19
19
  /** the one template: `shipkit start <name>` is the only thing anyone types,
20
- * and a repo without the corvion wiki in its context is not a started repo.
20
+ * and a repo without the house wiki in its context is not a started repo.
21
21
  * Base context first, the repo's own router last, so repo docs override.
22
22
  * A public repo swaps line 1 for the gitignored `@./CLAUDE.local.md`,
23
23
  * which is why that line satisfies the first include (SK003) */
24
+ // sk-disable-next-line RV003 -- the start template writes the house include line itself
24
25
  export const HOUSE_INCLUDE = ['@~/dev/corvion/wiki/index.md', '@./agent/index.md']
25
26
  export const HOUSE_ALSO_OK = ['@./CLAUDE.local.md']
26
27
  /** the day-job checkout: never wired, never given an include */
28
+ // sk-disable-next-line RV003 -- start refuses the day-job checkout by its name
27
29
  export const HOUSE_SKIP = ['monoloco']
28
30
 
31
+ /** a repo shipkit never touches: no CLAUDE.md, no git, no scaffold, no push */
32
+ export const isSkipped = (name: string, skip: readonly string[] = HOUSE_SKIP): boolean => skip.includes(name)
33
+
29
34
  /** where the first house line points, `~` resolved against `home` */
30
35
  export const housePath = (home: string): string => {
31
36
  const p = HOUSE_INCLUDE[0]!.replace(/^@/, '')
@@ -72,7 +77,7 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
72
77
  const house = opts.include ?? (houseOnDisk ? HOUSE_INCLUDE : [])
73
78
  if (opts.include == null && !houseOnDisk) console.error(`skip: no house wiki at ${housePath(home)} — CLAUDE.md gets the agent route only, and no .claude/ policy files`)
74
79
  const alt = opts.alsoOk ?? HOUSE_ALSO_OK
75
- const skipped = (opts.skip ?? HOUSE_SKIP).includes(name)
80
+ const skipped = isSkipped(name, opts.skip)
76
81
  if (skipped && house.length > 0) console.error(`skip: ${name} is include-exempt — CLAUDE.md untouched`)
77
82
  // start is the from-anywhere verb, and a STARTED repo comes out READY:
78
83
  // any dir with no commit history gets a minimal package.json, a first
@@ -146,6 +151,13 @@ export async function startRepo(name: string, opts: StartOpts = {}): Promise<num
146
151
  else console.error(`WARN dependency install failed: ${firstGhLine(r.out)}`)
147
152
  }
148
153
  }
154
+ // a repo with history that a house adopts: its own files win, and the
155
+ // house half of the day-one shape lands beside them, so the include
156
+ // lines below route at an agent wiki that exists
157
+ if (wired && !startable && house.length > 0) {
158
+ try { adoptHouse(dir, { name, print }) }
159
+ catch (e) { console.error(`WARN could not seed the house files in ${dir}: ${e instanceof Error ? e.message : String(e)} — the dir is still yours`) }
160
+ }
149
161
  let unscaffolded = false
150
162
  if (!existsSync(join(dir, 'repo.config.ts'))) {
151
163
  if (existsSync(join(dir, 'package.json'))) {
@@ -18,7 +18,7 @@ export type SerialSuite = {
18
18
  why: string
19
19
  }
20
20
  export type TestsConfig = {
21
- /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose CPU inside the parallel run, children included, plus all its worker burned before it stays under it skips that run, unless its wall is past that allowance or it ended with a child not yet reaped. The full tier fails nothing on time */
21
+ /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when the smaller of its CPU and its wall passes this, grown with the load share by up to half, or when its wall passes twice this, an allowance that grows with the square of the load share and has no end on a saturated machine. A suite whose own CPU inside the parallel run, children included, is inside that CPU allowance skips that run, unless its wall is past the wait allowance or it ended with a child not yet reaped. What earlier suites of its worker burned is never charged to it. The full tier fails nothing on time */
22
22
  budgetMs?: number
23
23
  /** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
24
24
  slow?: SlowSuite[]
@@ -41,7 +41,8 @@ export const DEFAULT_BUDGET_MS = 2000
41
41
  * 5s default while the preload said otherwise. */
42
42
  export const FULL_TIMEOUT_MS = 120_000
43
43
  /** the wall ceiling of one `bun test` phase. bun's per test timeout never
44
- * fires on a worker stuck in a SYNCHRONOUS call, and bun's spawnSync has been
44
+ * fires on a worker stuck in a SYNCHRONOUS call, or in `expect(p).resolves`
45
+ * or `.rejects` on a promise that never settles, and bun's spawnSync has been
45
46
  * seen spinning on a child that already exited (planPhases avoids the known
46
47
  * trigger), so without this a hang lasts until something outside kills it. The quick
47
48
  * tier answers in seconds, so two minutes is far from any real run. */
@@ -55,6 +56,10 @@ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceil
55
56
 
56
57
  export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
57
58
 
59
+ /** bun never discovers a file under a dot dir (`.shipkit/repros/` relies on
60
+ * it), so neither does the gate */
61
+ export const inDotDir = (file: string): boolean => normSuitePath(file).split('/').slice(0, -1).some((d) => d.startsWith('.') && d !== '.' && d !== '..')
62
+
58
63
  /** the arguments after `bun test`, or null when a shell would interpret the script
59
64
  * (cd, env prefix, pipes, quotes, substitution): that body stays the repo's own */
60
65
  export type BunfigTest = { root: string | null; ignores: string[]; error: string | null }
@@ -182,14 +187,16 @@ export function poolWorkers(p: { cores: number; size: number; held: number }): n
182
187
 
183
188
  type Timed = { file: string; ms: number }
184
189
 
185
- export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
190
+ export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full'; red?: string[] }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
186
191
  const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
187
192
  const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
193
+ const red = new Set((p.red ?? []).map(normSuitePath))
188
194
  const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
189
195
  return {
190
196
  // the budget guards the tier every edit waits on. The full tier is
191
- // where slow suites belong, and it only reports
192
- over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file)) : [],
197
+ // where slow suites belong, and it only reports. A red suite took
198
+ // the time of its failure (a timeout, a crash), which its red explains
199
+ over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file) && !red.has(t.file)) : [],
193
200
  // half the budget, so a declaration does not flap on timing noise
194
201
  noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
195
202
  slowest: timed.slice(0, 3),
@@ -253,6 +260,20 @@ export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>,
253
260
  return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
254
261
  }
255
262
 
263
+ /** a lone run over the budget on CPU runs once more. Under load the
264
+ * scheduler can spill a lone run onto the efficiency cores, where the same
265
+ * retired instructions cost up to 4x the CPU time (measured on Apple
266
+ * silicon), and a spill only ever raises the figure. A wait or a wall
267
+ * verdict is not CPU, so it keeps its one run */
268
+ export const retryAlone = (t: Timed, run: AloneRun, budgetMs: number, load: MachineLoad): boolean =>
269
+ confirmOver([t], { [t.file]: run }, budgetMs, load).over[0]?.over === 'cpu'
270
+
271
+ /** the run charged less, as confirmOver charges it: the smaller of CPU and wall */
272
+ export const smallerRun = (a: AloneRun, b: AloneRun): AloneRun => {
273
+ const charge = (r: AloneRun): number => Math.min(r.cpuMs ?? r.wallMs, r.wallMs)
274
+ return charge(b) < charge(a) ? b : a
275
+ }
276
+
256
277
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
257
278
 
258
279
  /** the line a confirmed suite fails with */
@@ -265,49 +286,46 @@ export const overWords = (c: Confirmed, load: MachineLoad, budgetMs: number = DE
265
286
  * (suite-cpu-preload.ts writes it, readPhaseCpu reads it) */
266
287
  export const SUITE_CPU_ENV = 'SHIPKIT_SUITE_CPU'
267
288
 
268
- /** one suite's figures from the parallel phase: its own CPU, children
269
- * included, and all its worker burned before it began (suite-cpu-preload.ts
270
- * says why the sum bounds a lone run) */
271
- export type PhaseCpu = { cpuMs: number; priorMs: number }
272
-
273
289
  /** what the workers wrote, keyed like the timings: relative to the run's cwd.
274
- * `ran` is every suite that finished in the phase, `cpu` the ones whose
275
- * figure can be trusted. A file bun ran twice keeps its larger bound, and
276
- * none at all once one of its runs could not be trusted */
277
- export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: Record<string, PhaseCpu> } {
290
+ * `ran` is every suite that finished in the phase, `cpu` the own CPU of the
291
+ * ones whose figure can be trusted (suite-cpu-preload.ts). A file bun ran
292
+ * twice keeps its larger figure, and none at all once one of its runs could
293
+ * not be trusted */
294
+ export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: Record<string, number> } {
278
295
  const ran = new Set<string>()
279
296
  const unsure = new Set<string>()
280
- const cpu: Record<string, PhaseCpu> = {}
297
+ const cpu: Record<string, number> = {}
281
298
  const base = cwd.replace(/\\/g, '/')
282
299
  for (const line of text.split('\n')) {
283
- const [file, ms, prior] = line.split('\t')
284
- if (file == null || file === '' || ms == null || prior == null) continue
300
+ const [file, ms] = line.split('\t')
301
+ if (file == null || file === '' || ms == null) continue
285
302
  const rel = normSuitePath(posix.relative(base, file.replace(/\\/g, '/')))
286
- if (ms === '-' && prior === '-') { ran.add(rel); unsure.add(rel); continue }
287
- if (!/^\d+$/.test(ms) || !/^\d+$/.test(prior)) continue
303
+ if (ms === '-') { ran.add(rel); unsure.add(rel); continue }
304
+ if (!/^\d+$/.test(ms)) continue
288
305
  ran.add(rel)
289
- const next = { cpuMs: Number(ms), priorMs: Number(prior) }
290
- const had = cpu[rel]
291
- if (had == null || next.cpuMs + next.priorMs > had.cpuMs + had.priorMs) cpu[rel] = next
306
+ cpu[rel] = Math.max(cpu[rel] ?? 0, Number(ms))
292
307
  }
293
308
  for (const f of unsure) delete cpu[f]
294
309
  return { ran: [...ran].sort(), cpu }
295
310
  }
296
311
 
297
- /** which suspects still need their alone run. A lone run can only need
298
- * what the worker computed by the suite's end, its whole CPU before the
299
- * suite (priorMs) plus what the suite burned, and sharing the machine only
300
- * raises CPU, so a suspect whose sum is inside the budget is inside it alone
301
- * too. A suspect with no figure (a child not yet reaped) runs alone. Only the
302
- * alone run can see a WAIT, so a suite whose wall is past its wait
303
- * allowance still gets it */
304
- export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, PhaseCpu>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
312
+ /** which suspects still need their alone run. A suspect is judged on its OWN
313
+ * CPU in the parallel phase, charged and allowed as a lone run is
314
+ * (confirmOver): the load it suffered there only raised that figure, so one
315
+ * inside the allowance is cleared. What the earlier files of its worker
316
+ * burned is never its cost, a module they loaded or a memo they filled
317
+ * included: the first suite of a worker that needs shared work pays for it.
318
+ * A suspect with no figure (a child not yet reaped) or over its allowance
319
+ * runs alone, and only the alone run can see a WAIT, so a suite whose wall
320
+ * is past its wait allowance still gets it */
321
+ export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, number>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
305
322
  const rerun: Timed[] = []
306
323
  const cleared: Confirmed[] = []
307
324
  for (const t of suspects) {
308
- const f = parallelCpu[t.file]
325
+ const cpuMs = parallelCpu[t.file]
309
326
  const mayWait = t.ms > waitAllowance(budgetMs, load)
310
- if (f != null && f.cpuMs + f.priorMs <= budgetMs && !mayWait) cleared.push({ file: t.file, ms: f.cpuMs, wallMs: t.ms, cpuMs: f.cpuMs, over: null })
327
+ const charged = cpuMs == null ? null : Math.min(cpuMs, t.ms)
328
+ if (charged != null && charged <= cpuAllowance(budgetMs, load) && !mayWait) cleared.push({ file: t.file, ms: charged, wallMs: t.ms, cpuMs: cpuMs ?? null, over: null })
311
329
  else rerun.push(t)
312
330
  }
313
331
  return { rerun, cleared }
@@ -358,6 +376,37 @@ export function failLines(output: string): Map<string, string> {
358
376
 
359
377
  const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
360
378
 
379
+ /** the clause the headline carries for the gate line: the command phase one
380
+ * ran, and the flags the runner added to the script's own. Per file ignores
381
+ * stay out, the slow and serial counts already say what they leave out */
382
+ export function ranAs(phase: string[], rawArgs: string[]): string {
383
+ const shown = phase.filter((a) => !a.startsWith('--path-ignore-patterns'))
384
+ const added = shown.filter((a) => !rawArgs.includes(a))
385
+ return `as \`${['bun', 'test', ...shown].join(' ')}\`${added.length > 0 ? ` (added ${added.join(' ')})` : ''}`
386
+ }
387
+
388
+ /** the suites bun lists under `Interrupted while still running:` when a
389
+ * phase is killed, one indented `<file> (<n>s)` line each */
390
+ export function interruptedSuites(output: string): string[] {
391
+ const out: string[] = []
392
+ let inBlock = false
393
+ for (const line of output.split('\n')) {
394
+ if (/^Interrupted while still running:\s*$/.test(line)) { inBlock = true; continue }
395
+ if (!inBlock) continue
396
+ const m = /^\s+(\S+)\s+\(\d+(?:\.\d+)?m?s\)\s*$/.exec(line)
397
+ if (m == null) { inBlock = false; continue }
398
+ out.push(normSuitePath(m[1]!))
399
+ }
400
+ return out
401
+ }
402
+
403
+ /** the line a phase killed at its ceiling fails with. Measured on bun 1.4.3:
404
+ * a plain await of a promise that never settles fails at the per test
405
+ * timeout, while `expect(p).resolves` or `.rejects` on one blocks the worker
406
+ * past it, as a synchronous loop does */
407
+ export const stallWords = (name: string, ceilingMs: number, files: string[]): string =>
408
+ `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test blocked its worker past the per test timeout, either a synchronous loop or expect(...).resolves or .rejects on a promise that never settles`
409
+
361
410
  /** the suites bun names as a crashed parallel worker */
362
411
  export function crashedWorkers(output: string): { file: string; signal: string }[] {
363
412
  return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
@@ -31,6 +31,7 @@ import { blobOid, listingHash } from '../core/read-set.ts'
31
31
  import { shipkitRoot } from '../ledger/db.ts'
32
32
  import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
33
33
  import { type RvtConfig } from '../ts/config.ts'
34
+ import { inDotDir } from './judge-suites.ts'
34
35
 
35
36
  export const SELECT_VERSION = 5
36
37
 
@@ -489,6 +490,7 @@ export function suiteFiles(top: string, cwdRel: string, testRoot: string | null,
489
490
  for (const f of listed) {
490
491
  if (f === '' || !isTest(f) || !existsSync(join(top, f))) continue
491
492
  const rel = cwdRel === '' || cwdRel === '.' ? f : f.slice(cwdRel.length + 1)
493
+ if (inDotDir(rel)) continue
492
494
  if (res.some((re) => re.test(rel))) continue
493
495
  out.push(rel)
494
496
  }