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
@@ -20,7 +20,7 @@ import { SUITE_READS_ENV, SUITE_ROOT_ENV, SUITE_SELECT_ENV } from './reads-env.t
20
20
  import { changedKeys, dropStaticEdges, extras, isBunSuite, envFilesOf, foldReads, loadErrorModules, loadState, preloadsOf, saveState, selectStatePath, selectSuites, snapshot, suiteFiles, taintedSuites, walkGraph, type GraphEntry, type Selection, type SelectState } from './select.ts'
21
21
  import { gitRoot } from '../core/git-root.ts'
22
22
  import { loadConfig } from '../ts/config.ts'
23
- import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, overWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
23
+ import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
24
24
 
25
25
  type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
26
26
 
@@ -57,7 +57,10 @@ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: n
57
57
  if (echo) sink.write(text)
58
58
  }
59
59
  }
60
- await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
60
+ // bun reports on stderr, and it is echoed on stdout: two pipes reach the
61
+ // gate in either order, so bun's `Ran 0 tests` could land after the FAIL
62
+ // printed below and become the headline. One stream keeps the order
63
+ await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stdout)])
61
64
  const code = await proc.exited
62
65
  clearTimeout(ceiling)
63
66
  clearTimeout(killer)
@@ -194,7 +197,9 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
194
197
  const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
195
198
  const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
196
199
  const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
197
- const judged = judgeSuites({ timings, tests, tier })
200
+ const crashed = crashedWorkers(p1.out)
201
+ const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashed.map((c) => c.file)])
202
+ const judged = judgeSuites({ timings, tests, tier, red: [...red] })
198
203
  const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
199
204
 
200
205
  // a parallel timing carries every other suite's load, so a suspect
@@ -205,25 +210,32 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
205
210
  const parallelCpu = phaseCpu.cpu
206
211
  const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
207
212
  const aloneAt = Date.now()
208
- for (const [i, t] of rerun.entries()) {
209
- const run = await runPhase([...plan.alone, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false, null, rec)
213
+ const runAlone = async (t: { file: string }, name: string): Promise<AloneRun | null> => {
214
+ const run = await runPhase([...plan.alone, t.file], join(dir, `${name}.json`), ceilingMs, tmp, false, null, rec)
210
215
  const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
211
- if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
216
+ return wallMs == null ? null : { wallMs, cpuMs: run.cpuMs }
217
+ }
218
+ for (const [i, t] of rerun.entries()) {
219
+ const first = await runAlone(t, `alone-${i}`)
220
+ if (first == null) continue
221
+ aloneRuns[t.file] = first
222
+ if (!retryAlone(t, first, budget, load)) continue
223
+ const second = await runAlone(t, `alone-${i}-again`)
224
+ if (second == null) continue
225
+ aloneRuns[t.file] = smallerRun(first, second)
226
+ notes.push(`note: ${t.file} ran alone twice, ${secs(first.cpuMs ?? first.wallMs)} then ${secs(second.cpuMs ?? second.wallMs)} of CPU, and the smaller stands: a lone run the scheduler spills onto slower cores burns more CPU for the same work`)
212
227
  }
213
228
  const aloneMs = Date.now() - aloneAt
214
229
  const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
215
- for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run and burned ${secs(c.cpuMs ?? 0)} of CPU there, plus ${secs(parallelCpu[c.file]?.priorMs ?? 0)} a lone run loads first, inside the ${budget}ms budget, so it did not run again alone`)
230
+ for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run but burned ${secs(c.ms)} of its own CPU there, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores), so it did not run again alone`)
216
231
  for (const c of cleared) notes.push(`note: ${c.file} took ${secs(c.wallMs)} alone but burned ${c.cpuMs != null ? secs(c.cpuMs) : '?'} of CPU, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores)`)
217
232
 
218
233
  // a ceiling kill makes bun report every worker it held as crashed, and
219
234
  // those are the suites that never finished, not an outside kill
220
235
  const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
221
- p?.stalled === true
222
- ? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
223
- : null
224
- const crashed = crashedWorkers(p1.out)
236
+ p?.stalled === true ? stallWords(name, ceilingMs, [...new Set([...files, ...interruptedSuites(p.out)])]) : null
225
237
  const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
226
- const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
238
+ const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null).map(normSuitePath))]
227
239
  for (const line of stalls) if (line != null) killed.unshift(line)
228
240
  if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
229
241
  if (p2?.signal != null) {
@@ -233,7 +245,6 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
233
245
  // the record behind the hub's test files to fix: every suite this run
234
246
  // timed, with the alone timing where one was taken, and red when a
235
247
  // test in it failed or its worker died
236
- const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
237
248
  const overSet = new Set(over.map((o) => o.file))
238
249
  ctx.complete = killed.length === 0
239
250
  ctx.out = phases.map((p) => p.out).join('\n')
@@ -241,12 +252,12 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
241
252
  const at = Date.now()
242
253
  const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
243
254
  const why = failLines(phases.map((p) => p.out).join('\n'))
244
- for (const c of crashedWorkers(p1.out)) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
255
+ for (const c of crashed) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
245
256
  // which phase timed each file, and the CPU spent on it: an alone
246
257
  // rerun, the parallel phase's own figure, or a phase that ran that one file
247
258
  const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
248
259
  const soloCpu = (p: Phase | null): number | null => (p != null && Object.keys(p.timings).length === 1 ? p.cpuMs : null)
249
- const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f]?.cpuMs ?? soloCpu(p1))
260
+ const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f] ?? soloCpu(p1))
250
261
  const check = process.env[CHECK_ID_ENV] ?? ''
251
262
  const serialFiles = p2 == null ? 0 : Object.keys(p2.timings).length
252
263
  const unrecorded = recordSuites(process.cwd(), Object.entries(timings).map(([file, ms]) => {
@@ -289,7 +300,7 @@ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
289
300
  if (fails.length > 0) { console.log(fails[0]); return 1 }
290
301
  const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
291
302
  const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
292
- console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
303
+ console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier, ${ranAs(plan.parallel, rawArgs)}${slowest}`)
293
304
  return 0
294
305
  } finally {
295
306
  rmSync(dir, { recursive: true, force: true })
@@ -2,17 +2,13 @@
2
2
  // worker by the suite runner. A suite over the budget in wall time is run again
3
3
  // alone to learn its CPU, one after the other, and on a loaded machine nearly
4
4
  // every gate heavy suite goes over in wall time: those re-runs were most of the
5
- // test check's wall time. A suite whose CPU in the parallel phase already
6
- // bounds its alone run under the budget needs no re-run.
5
+ // test check's wall time. A suite whose own CPU in the parallel phase is inside
6
+ // the budget needs no re-run.
7
7
  //
8
- // One line per suite: `<file>\t<cpu ms>\t<prior ms>`. `cpu` is the worker's own
9
- // CPU plus that of every child it reaped, from the end of the previous suite in
10
- // this worker to the end of this one, so the file's import and its hooks count.
11
- // `prior` is ALL the worker burned before this suite began: its start, and the
12
- // load, top level and tests of every earlier suite. A lone run can only need
13
- // what this worker computed by the end of the suite (a module an earlier suite
14
- // loaded, a memo an earlier test filled), so cpu plus prior bounds it from
15
- // above, and sharing the machine only raises CPU.
8
+ // One line per suite: `<file>\t<cpu ms>`, the worker's own CPU plus that of
9
+ // every child it reaped, from the end of the previous suite in this worker to
10
+ // the end of this one, so the file's import, its hooks and its tests count. What
11
+ // the worker burned before that (its start, earlier suites) is not this suite's.
16
12
  //
17
13
  // A child still alive, or dead and not yet reaped, when the suite ends has CPU
18
14
  // nobody counted yet, so that suite's line says `-` and it runs alone if it is
@@ -84,7 +80,7 @@ if (out != null && p != null) {
84
80
  // every suite gets a line, since the line is also how the runner knows
85
81
  // it ran; a suite whose figure cannot be trusted says `-`
86
82
  try {
87
- const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}\t${Math.round(ended)}` : '-\t-'
83
+ const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}` : '-'
88
84
  appendFileSync(out, `${Bun.main}\t${figure}\n`)
89
85
  } catch { /* no line: the suite runs alone if it is a suspect */ }
90
86
  ended = now
@@ -11,7 +11,8 @@
11
11
  import { basename, posix } from 'path'
12
12
  import { rawName, routesToShipkit, slotBody } from '../core/canonical-scripts.ts'
13
13
  import { parseJsonc } from '../ts/jsonc.ts'
14
- import { bunfigTest, normSuitePath, parseBunTestScript, type BunfigTest, type TestsConfig } from './judge-suites.ts'
14
+ import { repoOff, type OffKeys } from '../core/when.ts'
15
+ import { bunfigTest, inDotDir, normSuitePath, parseBunTestScript, type BunfigTest, type TestsConfig } from './judge-suites.ts'
15
16
 
16
17
  export { bunfigTest, type BunfigTest }
17
18
 
@@ -41,6 +42,7 @@ export type TestRun = {
41
42
  export function runReaches(file: string, run: TestRun): { runs: true } | { runs: false; ignoredBy: string } | { runs: false; outside: string } | null {
42
43
  if (run.args == null) return null
43
44
  const f = normSuitePath(file)
45
+ if (inDotDir(f)) return { runs: false, ignoredBy: 'a dot dir, which bun never discovers' }
44
46
  const cli: string[] = []
45
47
  const filters: string[] = []
46
48
  for (let i = 0; i < run.args.length; i++) {
@@ -183,19 +185,20 @@ export function planTests(o: {
183
185
  /** the whole plan for a repo, from what its config and files say. `read`
184
186
  * answers a repo-relative path, null when absent */
185
187
  export function testPlanOf(o: {
186
- config: { defaults?: Record<string, unknown>; units?: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] }
188
+ config: OffKeys & { units?: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] }
187
189
  files: string[]
188
190
  read: (rel: string) => string | null
189
191
  }): TestPlan & { units: TestUnitDecl[] } {
190
192
  const units = declaredTestUnits(o.config.units)
191
193
  let scripts: Record<string, string> = {}
192
194
  try { scripts = (JSON.parse(o.read('package.json') ?? '{}') as { scripts?: Record<string, string> }).scripts ?? {} } catch { scripts = {} }
193
- const slot = slotBody('test', o.config.defaults?.['test'] as { cmd?: unknown; logic?: unknown } | false | undefined, scripts)
195
+ const off = repoOff(o.config, 'test')
196
+ const slot = slotBody('test', off != null ? false : o.config.defaults?.['test'] as { cmd?: unknown; logic?: unknown } | undefined, scripts)
194
197
  const bunfig = (dir: string): BunfigTest => bunfigTest(o.read(dir === '' ? 'bunfig.toml' : posix.join(dir, 'bunfig.toml')))
195
198
  const rootRun: TestRun | null = slot.kind === 'override' ? { args: null, bunfig: bunfig(''), extraIgnores: [] }
196
199
  : slot.kind === 'script' ? { args: parseBunTestScript(slot.body), bunfig: bunfig(''), extraIgnores: unitIgnores(units) }
197
200
  : null
198
- const rootWhy = slot.kind === 'off' ? '`defaults.test: false` turns the root test check off, so nothing runs it'
201
+ const rootWhy = slot.kind === 'off' ? `the repo turns the root test check off (${off?.reason ?? 'off'}), so nothing runs it`
199
202
  : slot.kind === 'routed-empty' ? "the `test` script routes to shipkit and there is no 'test:raw' body, so the root test check runs nothing"
200
203
  : 'no `test` or `test:raw` script, so the root has no test check'
201
204
  const plan = planTests({
package/src/ts/config.ts CHANGED
@@ -10,9 +10,13 @@
10
10
  // 2. tsconfig "paths" keys ("src/*" → root 'src') when roots are not set
11
11
  // 3. fallback: ['src', 'scripts']
12
12
  // skip entries are repo-relative dir prefixes; the defaults below always apply.
13
+ // The repo config's `vendored` dirs (core/vendored.ts) are third party code:
14
+ // never crawled, never a package, never rewritten.
13
15
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
16
  import { join } from 'path'
15
17
  import { gitRoot } from '../core/git-root.ts'
18
+ import { isVendored } from '../core/vendored.ts'
19
+ import { readVendored } from '../core/vendored-read.ts'
16
20
 
17
21
  export type RvtPackage = {
18
22
  name: string // package.json "name" — the import root for files inside
@@ -24,6 +28,7 @@ export type RvtConfig = {
24
28
  roots: string[] // repo-relative dirs to crawl / allowed specifier roots
25
29
  skip: string[] // repo-relative dir prefixes to never touch
26
30
  packages: RvtPackage[] // discovered sub-packages, longest dir first
31
+ vendored?: string[] // repo-relative third party dirs (repo.config.ts `vendored`), skipped as exact prefixes
27
32
  }
28
33
 
29
34
  /** never source, at any depth */
@@ -83,9 +88,9 @@ function expandRootGlobs(root: string, roots: string[]): string[] {
83
88
 
84
89
  // sub-package discovery: any dir under the roots with a named package.json is
85
90
  // its own import root (files inside address each other via the package name)
86
- function discoverPackages(root: string, roots: string[], skip: string[]): RvtPackage[] {
91
+ function discoverPackages(root: string, roots: string[], skip: string[], vendored: string[]): RvtPackage[] {
87
92
  const found: RvtPackage[] = []
88
- const cfg = { root, roots, skip, packages: [] }
93
+ const cfg = { root, roots, skip, packages: [], vendored }
89
94
  const walk = (rel: string): void => {
90
95
  const abs = join(root, rel)
91
96
  let entries
@@ -128,9 +133,10 @@ export function loadConfig(cwd: string = process.cwd()): RvtConfig {
128
133
  }
129
134
  if (roots.length === 0) roots = rootsFromTsconfig(root)
130
135
  if (roots.length === 0) roots = ['src', 'scripts'].filter((r) => existsSync(join(root, r)))
131
- roots = expandRootGlobs(root, roots)
136
+ const vendored = readVendored(root)
137
+ roots = expandRootGlobs(root, roots).filter((r) => !isVendored(r, vendored))
132
138
  const mergedSkip = [...DEFAULT_SKIP, ...skip]
133
- const packages = discoverPackages(root, roots, mergedSkip)
139
+ const packages = discoverPackages(root, roots, mergedSkip, vendored)
134
140
  // a package named like a root (or another package) hijacks that specifier
135
141
  // prefix — resolution would silently diverge from tsc/bun; be loud
136
142
  const seen = new Set<string>(roots)
@@ -138,7 +144,7 @@ export function loadConfig(cwd: string = process.cwd()): RvtConfig {
138
144
  if (seen.has(p.name)) console.error(`rvt WARN: package name '${p.name}' (${p.dir}) collides with a root or another package — its specifiers may resolve wrong`)
139
145
  seen.add(p.name)
140
146
  }
141
- return { root, roots, skip: mergedSkip, packages }
147
+ return { root, roots, skip: mergedSkip, packages, vendored }
142
148
  }
143
149
 
144
150
  // innermost package containing a repo-relative path, or null
@@ -169,6 +175,7 @@ export const toPosix = (p: string): string => p.replace(/\\/g, '/')
169
175
  // package root is output, `wiki/build/<lib>/src` is source.
170
176
  export function isSkipped(relPath: string, cfg: RvtConfig, packageDirs: readonly string[] = []): boolean {
171
177
  const n = toPosix(relPath)
178
+ if (isVendored(n, cfg.vendored ?? [])) return true
172
179
  const segs = n.split('/')
173
180
  return cfg.skip.some((s) => {
174
181
  if (s.includes('/')) return n === s || n.startsWith(`${s}/`)
package/src/ts/imports.ts CHANGED
@@ -14,6 +14,7 @@ import { readdirSync, readFileSync, writeFileSync, existsSync, type Dirent } fro
14
14
  import { dirname, extname, isAbsolute, join, relative, resolve } from 'path'
15
15
  import { canonicalSpecifier, isSkipped, loadConfig, parseJsonc, resolvePackageSpecifier, toPosix, type RvtConfig } from 'rvlib-shipkit/src/ts/config.ts'
16
16
  import { templateLineMask } from 'rvlib-shipkit/src/ts/source-mask.ts'
17
+ import { isVendored } from 'rvlib-shipkit/src/core/vendored.ts'
17
18
 
18
19
  const ASSET_EXTENSIONS = new Set([
19
20
  '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
@@ -341,7 +342,8 @@ export function runImports(opts: { save?: boolean; files?: string[]; cfg?: RvtCo
341
342
  for (const e of result.errors) run.errors.push({ file: toPosix(relative(cfg.root, abs)), ...e })
342
343
  }
343
344
  if (opts.files != null && opts.files.length > 0) {
344
- for (const fp of opts.files) process1(fp)
345
+ // a vendored file named by the on-save hook is still third party code
346
+ for (const fp of opts.files) if (!isVendored(toPosix(relative(cfg.root, resolve(cfg.root, fp))), cfg.vendored ?? [])) process1(fp)
345
347
  } else {
346
348
  for (const r of cfg.roots) {
347
349
  const abs = join(cfg.root, r)
@@ -0,0 +1,11 @@
1
+ // the gate's typecheck keeps its own incremental cache, one file per project.
2
+ // a tsconfig's tsBuildInfoFile is shared by every tsc that runs that config (a
3
+ // watcher task, a raw script), and a verdict read off a cache another process
4
+ // writes is not a verdict on this tree. no fs here: it only names paths.
5
+ import { basename, dirname, join } from 'path'
6
+
7
+ export const gateBuildInfoArgs = (tsconfig: string): string[] => [
8
+ '--incremental',
9
+ '--tsBuildInfoFile',
10
+ join(dirname(tsconfig), 'node_modules/.cache/shipkit', `${basename(tsconfig, '.json')}.tsbuildinfo`),
11
+ ]
@@ -10,6 +10,7 @@
10
10
  // who fixes the one error tsc showed and re-runs into a second one has been
11
11
  // told the truth twice and believed it once.
12
12
  import { resolveTypecheckProjects } from 'rvlib-shipkit/src/ts/tsconfig-projects.ts'
13
+ import { gateBuildInfoArgs } from 'rvlib-shipkit/src/ts/typecheck-cache.ts'
13
14
 
14
15
  export function typecheckCli(argv: string[]): number {
15
16
  const [tsc, tsconfig] = argv
@@ -30,7 +31,7 @@ export function typecheckCli(argv: string[]): number {
30
31
 
31
32
  let worst = 0
32
33
  for (const p of resolved.projects) {
33
- const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { stdout: 'inherit', stderr: 'inherit' })
34
+ const r = Bun.spawnSync([tsc, '--noEmit', '-p', p, ...gateBuildInfoArgs(p)], { stdout: 'inherit', stderr: 'inherit' })
34
35
  if (r.exitCode !== 0) {
35
36
  console.log(`FAIL: ${p}`)
36
37
  worst = r.exitCode ?? 1
@@ -36,7 +36,7 @@ export function buildStepsPanel(root: string, config: RepoConfig): TuiPanelDef {
36
36
  const { tasks, disabled, unfilled } = await allTasks(config, root)
37
37
  rows = [
38
38
  ...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
39
- ...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: disabled by repo.config defaults'] })),
39
+ ...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: turned off in repo.config.ts'] })),
40
40
  ...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
41
41
  ]
42
42
  const byKey = new Map(rows.map((r, i) => [r.key, i]))
@@ -11,9 +11,11 @@
11
11
  // Depth = 0-1 BFS from the index (eager edge keeps depth, lazy adds 1);
12
12
  // unreachable files stay in the graph with no depth (lint-md.ts turns those,
13
13
  // plus dangling refs, into failures — the graph itself is a viewer).
14
+ // The enclosing repo's vendored dirs (core/vendored.ts) are never scanned.
14
15
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
15
16
  import { basename, dirname, join, relative, resolve } from 'path'
16
17
  import type { MdEdge, MdGraph, MdNode } from 'rvlib-shipkit/src/wiki/md-graph.ts'
18
+ import { vendoredAround } from 'rvlib-shipkit/src/core/vendored-read.ts'
17
19
 
18
20
  export type ScanMdConfig = {
19
21
  root: string // absolute dir to scan
@@ -22,7 +24,7 @@ export type ScanMdConfig = {
22
24
  boundary?: string // absolute dir enclosing root (a repo): refs leaving root but staying here resolve on disk
23
25
  }
24
26
 
25
- export type MdDanglingRef = { from: string; token: string } // an @/→/[](…) ref whose target is not in the scan
27
+ export type MdDanglingRef = { from: string; token: string; line: number } // an @/→/[](…) ref whose target is not in the scan, and its 1-based line in `from`
26
28
  export type MdScan = {
27
29
  graph: MdGraph
28
30
  index: string | null // resolved entrypoint(s), ' + '-joined when both defaults exist; null = none found
@@ -35,12 +37,14 @@ export function scanMdGraph(cfg: ScanMdConfig): MdGraph {
35
37
 
36
38
  export function scanMd(cfg: ScanMdConfig): MdScan {
37
39
  const skip = new Set(['node_modules', '.git', 'dist', 'tmp', ...(cfg.skipDirs ?? [])])
40
+ const repo = vendoredAround(cfg.root)
41
+ const vendored = new Set((repo?.dirs ?? []).map((d) => join(repo!.root, d)))
38
42
  const files: string[] = []
39
43
  const walk = (rel: string) => {
40
44
  for (const e of readdirSync(join(cfg.root, rel))) {
41
45
  const r = rel === '' ? e : `${rel}/${e}`
42
46
  if (statSync(join(cfg.root, r)).isDirectory()) {
43
- if (!skip.has(e) && !e.startsWith('.')) walk(r)
47
+ if (!skip.has(e) && !e.startsWith('.') && !vendored.has(join(cfg.root, r))) walk(r)
44
48
  } else if (e.endsWith('.md')) files.push(r)
45
49
  }
46
50
  }
@@ -84,19 +88,22 @@ export function scanMd(cfg: ScanMdConfig): MdScan {
84
88
  const raw = readFileSync(join(cfg.root, rel), 'utf8')
85
89
  const body = raw.startsWith('---\n') ? raw.slice(4).replace(/^[\s\S]*?^---\s*$/m, '') : raw
86
90
  const spans: string[] = []
87
- const prose = body.replace(/^```[\s\S]*?^```\s*$/gm, '').replace(/`([^`\n]+)`/g, (_, s: string) => { spans.push(s); return ' ' })
91
+ // fences blanked to their newlines, so an offset in prose still names its line
92
+ const prose = body.replace(/^```[\s\S]*?^```\s*$/gm, (f) => f.replace(/[^\n]/g, '')).replace(/`([^`\n]+)`/g, (_, s: string) => { spans.push(s); return ' ' })
93
+ const above = raw.slice(0, raw.length - body.length).split('\n').length - 1
94
+ const lineOf = (m: RegExpMatchArray): number => above + prose.slice(0, m.index! + m[0].length - 1).split('\n').length
88
95
  const out: MdEdge[] = []
89
96
  // strict: @/→/[](…) are deliberate wiki refs — a miss is a dangling ref.
90
97
  // Backtick spans mention paths casually (other repos, deleted examples) — resolve or ignore, never fail.
91
- const push = (token: string, eager: boolean, strict: boolean) => {
98
+ const push = (token: string, eager: boolean, strict: boolean, line = 0) => {
92
99
  const hit = resolveRef(rel, token)
93
- if (hit == null) { if (strict) dangling.push({ from: rel, token }); return }
100
+ if (hit == null) { if (strict) dangling.push({ from: rel, token, line }); return }
94
101
  for (const to of hit.to) if (to !== rel) out.push({ from: rel, to, eager })
95
102
  }
96
- for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true)
97
- for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true)
98
- for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true)
99
- for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true)
103
+ for (const m of prose.matchAll(/(?:^|[\s(])@((?:\.{1,2}\/)*[\w][\w./-]*\.md)/gm)) push(m[1]!, true, true, lineOf(m))
104
+ for (const m of prose.matchAll(/→\s*([\w][\w./-]*\.md)\b/g)) push(m[1]!, false, true, lineOf(m))
105
+ for (const m of prose.matchAll(/\]\(([^)\s:]+\.md)\)/g)) push(m[1]!, false, true, lineOf(m))
106
+ for (const m of prose.matchAll(/\]\(([^)\s:]*\/)\)/g)) push(m[1]!, false, true, lineOf(m))
100
107
  for (const s of spans) for (const m of s.matchAll(/[\w][\w./-]*\.md/g)) push(m[0], false, false)
101
108
  for (const s of spans) for (const m of s.matchAll(/(?:^|\s)([\w.][\w./-]*\/)(?=$|[\s<*])/g)) push(m[1]!, false, false)
102
109
  return out
@@ -1,131 +0,0 @@
1
- // SK009 commit AI tells (identity/how-we-commit.md): the commit-msg hook is
2
- // what refuses AI attribution, in every repo, so this check verifies the hook
3
- // is ARMED: shipkit's own hooksPath, or a foreign one whose commit-msg chains
4
- // the shipkit gate. It never scans history: what is pushed is accepted as it
5
- // is, and only a new commit can still be stopped. A declared-public repo also
6
- // needs the tracked attribution silencer, and its ref NAMES are checked (a
7
- // FABLE-checkpoint tag outs the agent like a trailer); a branch nobody pushed
8
- // is named "local only" and warns.
9
- import { createHash } from 'crypto'
10
- import { readFileSync } from 'fs'
11
- import { isAbsolute, join } from 'path'
12
- import { nestedIn } from '../core/nesting.ts'
13
- import { gitBin } from '../core/git-bin.ts'
14
- import { fileStamp } from '../core/file-stamp.ts'
15
- import { CONFIG_FILE } from '../core/check-pass.ts'
16
- import { LAYOUT } from '../core/layout.ts'
17
- import { check, type CheckEnv } from '../check.ts'
18
- import { declaredVisibility } from '../core/repo-facts.ts'
19
- import { hookBinInvocation } from '../hooks/run-hooks.ts'
20
-
21
- // the documented silencer: .claude/settings.json attribution.commit/.pr both
22
- // empty — the harness then never writes the trailer or the PR footer
23
- const attributionSilenced = (root: string): boolean => {
24
- try {
25
- const s = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf8')) as { attribution?: { commit?: string; pr?: string } }
26
- return s.attribution != null && s.attribution.commit === '' && s.attribution.pr === ''
27
- } catch {
28
- return false
29
- }
30
- }
31
-
32
- const readOrNull = (abs: string): string | null => {
33
- try {
34
- return readFileSync(abs, 'utf8')
35
- } catch {
36
- return null
37
- }
38
- }
39
-
40
- /** a commit-msg body that runs the shipkit gate on the message: a live line
41
- * (never a comment) whose failure is not swallowed by `|| true` */
42
- const chainsShipkit = (body: string): boolean =>
43
- body.split('\n').some((l) => !l.trim().startsWith('#') && /\bhooks\s+commit-msg\b/.test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
44
-
45
- /** the chain line to paste into a foreign hook: a clone with no install skips
46
- * the gate with a note, rather than failing every commit */
47
- export const guardedChain = (bin: string): string => {
48
- const cli = bin.replace(/^bun\s+/, '')
49
- return `if [ -f ${cli} ]; then ${bin} hooks commit-msg "$1" || exit 1; else echo "commit-msg: rvlib-shipkit not installed (run bun install), attribution gate skipped" >&2; fi`
50
- }
51
-
52
- // ref NAMES tell too. bare match only for names with no other meaning;
53
- // vendor and codename words (cursor, openai, gemini, opus, fable, …) name
54
- // legitimate branches and features, they only match in a checkpoint/session shape
55
- const REF_TELL = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
56
-
57
- const gitOut = (root: string, args: string[]): { code: number; out: string } => {
58
- const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
59
- return { code: p.exitCode, out: p.stdout.toString() }
60
- }
61
-
62
- /** what the answer depends on: the declared visibility, the hooksPath and the
63
- * commit-msg file it points at, and for a public repo every ref NAME and the
64
- * silencer. A commit moves none of them */
65
- export function historyKey(env: CheckEnv): string {
66
- const nested = nestedIn(env.root)
67
- if (nested != null) return `nested in ${nested}`
68
- const declared = declaredVisibility(env.config)
69
- const hooksPath = gitOut(env.root, ['config', 'core.hooksPath'])
70
- const dir = hooksPath.code === 0 ? hooksPath.out.trim() : ''
71
- const hook = `hooks=${dir}|msg=${dir === '' ? 'none' : fileStamp(isAbsolute(dir) ? join(dir, 'commit-msg') : join(env.root, dir, 'commit-msg'))}`
72
- if (declared !== 'public') return `declared ${declared}|${hook}`
73
- const refs = gitOut(env.root, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes']).out
74
- const digest = createHash('sha256').update(refs).digest('hex').slice(0, 16)
75
- return `public|${hook}|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
76
- }
77
-
78
- export const commitAiTells = check({
79
- id: 'SK009',
80
- blocking: true,
81
- title: 'commit ai tells',
82
- description: 'the commit-msg hook that refuses AI attribution is armed, and no ref name tells',
83
- owner: 'identity/how-we-commit.md',
84
- runs: { onPreCommit: false, onCi: true, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
85
- // git config and ref names, which no tree id describes, so they are the key
86
- cache: { by: 'files', inputs: [CONFIG_FILE], key: historyKey },
87
- fix: 'none',
88
- applies: 'always',
89
- run: async (c) => {
90
- const nested = nestedIn(c.root)
91
- if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
92
- const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
93
- if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
94
-
95
- const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
96
- const dir = hp.code === 0 ? hp.out.trim() : ''
97
- const chain = guardedChain(hookBinInvocation(c.root))
98
- if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '') {
99
- c.lines.push({ kind: 'skip', text: 'commit-msg hook not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
100
- } else if (dir === '') {
101
- c.fail('the commit-msg hook is not armed, so nothing refuses an AI attribution trailer: run `shipkit hooks install` (`shipkit ci` arms it too)')
102
- } else {
103
- const body = isAbsolute(dir) ? readOrNull(join(dir, 'commit-msg')) : c.read(`${dir}/commit-msg`)
104
- const own = dir === LAYOUT.hooks
105
- if (body == null)
106
- c.fail(`core.hooksPath=${dir} has no ${dir}/commit-msg, so nothing refuses an AI attribution trailer: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${chain}\``}`)
107
- else if (!chainsShipkit(body))
108
- c.fail(`core.hooksPath=${dir}: its commit-msg (${body.split('\n').find((l) => l.trim() !== '' && !l.startsWith('#'))?.trim().slice(0, 80) ?? 'empty'}) does not run the shipkit gate, so nothing refuses AI attribution: add \`${chain}\` above its last line`)
109
- else c.ok(`the commit-msg hook refuses AI attribution (${own ? dir : `${dir}, chained to the shipkit gate`})`)
110
- }
111
-
112
- if (declaredVisibility(c.config) !== 'public') return
113
- if (!attributionSilenced(c.root))
114
- c.fail(`.claude/settings.json does not silence AI attribution: track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
115
- const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
116
- if (refs.code !== 0) return void c.lines.push({ kind: 'skip', text: `ref names unverifiable: git for-each-ref exit ${refs.code}` })
117
- const all = refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '')
118
- const remote = new Set(all.filter((r) => r.startsWith('refs/remotes/')).map((r) => r.split('/').slice(3).join('/')))
119
- const tells = all.filter((r) => REF_TELL.test(r.replace(/^refs\/(?:heads|tags|remotes)\//, '')))
120
- const shown = (r: string): string => r.replace(/^refs\/(?:heads|tags|remotes)\//, '')
121
- const local = tells.filter((r) => r.startsWith('refs/heads/') && !remote.has(shown(r)))
122
- const out = tells.filter((r) => !local.includes(r) && !(r.startsWith('refs/heads/') && remote.has(shown(r))))
123
- const list = (rs: string[], suffix = ''): string => rs.slice(0, 5).map((r) => `${shown(r)}${suffix}`).join(' ') + (rs.length > 5 ? ' …' : '')
124
- if (out.length > 0)
125
- c.fail(`${out.length} ref name(s) carry an AI tell in a declared-public repo (${list(out)}): rename or delete them, a pushed one on the remote too, then \`git fetch --prune\` so a remote ref deleted there leaves this clone`)
126
- if (local.length > 0)
127
- c.warn(`${local.length} branch name(s) carry an AI tell (${list(local, ' (local only)')}): never push them under this name, rename or delete them`)
128
- },
129
- })
130
-
131
- export default commitAiTells
@@ -1,78 +0,0 @@
1
- // SK014 — the STE prose standard, in the gate (identity/writing-voice.md).
2
- //
3
- // Opt-in by declaration: a repo with no `prose.include` is SKIPPED, not
4
- // failed. STE is house doctrine for prose an agent writes, and a rule that
5
- // turned every existing repo red the day it shipped would be turned off
6
- // rather than obeyed.
7
- //
8
- // The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
9
- // mechanical, and a machine reporting them is never guessing. `soft` hits
10
- // (sentence length, passive voice, modals, semicolons, …) need a human read,
11
- // so they are a note, never an attention item, and only a declared
12
- // `baseline` makes them blocking, as a ratchet that can only go down.
13
- //
14
- // It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
15
- // default, so a rule with a fixer rewrites tracked files unattended, and
16
- // prose is not repo shape: a wrong package.json line is a config, a wrong
17
- // sentence is content. The rewrite lives behind an explicit `shipkit prose
18
- // --fix` that a human ran and can read the diff of.
19
- import { classSummary, proseLines, runProseCheck, type ProseReport } from '../prose/run-prose.ts'
20
- import { tierOf, type ProseClass } from '../prose/ste.ts'
21
- import { check } from '../check.ts'
22
-
23
- const OWNER = 'identity/writing-voice.md'
24
-
25
- export const proseSte = check({
26
- id: 'SK014',
27
- blocking: true,
28
- title: 'STE prose',
29
- description: 'house prose passes the mechanical STE rules',
30
- owner: OWNER,
31
- // keyed on what it reads: each include glob's listing and every file in
32
- // scope, so an edit outside the declared prose never reruns it
33
- runs: { onPreCommit: false, onCi: false, onFull: true, onRelease: true, onHealth: true, onSave: 'moved', onClock: false },
34
- cache: { by: 'reads' },
35
- fix: 'none',
36
- applies: 'always',
37
- run: (c) => {
38
- const cfg = c.config.prose
39
- if (cfg == null || (cfg.include ?? []).length === 0) {
40
- c.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
41
- return
42
- }
43
- const r = runProseCheck({ c, config: cfg })
44
- // a scope that resolves to zero files reads exactly like a clean repo,
45
- // and it is the opposite: the repo asked for the gate and the gate
46
- // inspects nothing. A green here is coverage nobody has.
47
- if (r.files === 0) {
48
- c.warn(
49
- `prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`,
50
- 'fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out',
51
- )
52
- return
53
- }
54
- const blocking = r.byTier.fix + r.byTier.hard
55
- for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) c.note(l)
56
- if (blocking > 0)
57
- c.fail(
58
- `${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
59
- r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
60
- )
61
- const soft = softSummary(r)
62
- if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
63
- c.fail(`${r.byTier.soft} soft hits (${soft}), above the declared baseline of ${cfg.baseline}: the ratchet only goes down`)
64
- else if (r.byTier.soft > 0)
65
- c.note(`${r.byTier.soft} soft hit(s) need a human read (${soft})${cfg.baseline != null ? `, within the baseline of ${cfg.baseline}` : ''}: \`shipkit prose\` lists them`)
66
- if (!c.failed && blocking === 0) c.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
67
- }
68
- })
69
-
70
- /** every soft class with its count, most first */
71
- const softSummary = (r: ProseReport): string =>
72
- Object.entries(r.byClass)
73
- .filter(([cls]) => tierOf(cls as ProseClass) === 'soft')
74
- .sort((a, b) => b[1] - a[1])
75
- .map(([cls, n]) => `${cls} ${n}`)
76
- .join(', ')
77
-
78
- export default proseSte