rvlib-shipkit 1.9.0 → 2.0.1

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 (109) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +1 -1
  3. package/src/check.ts +5 -2
  4. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  5. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  6. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  7. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  8. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  9. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  10. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  11. package/src/cli/cmd_Ci.ts +3 -3
  12. package/src/cli/cmd_Commands.ts +21 -5
  13. package/src/cli/cmd_Hooks.ts +6 -2
  14. package/src/cli/cmd_Init.ts +1 -1
  15. package/src/cli/cmd_Serve.ts +1 -2
  16. package/src/cli/cmd_Start.ts +1 -1
  17. package/src/cli.ts +2 -4
  18. package/src/core/budget.ts +6 -8
  19. package/src/core/bun-builtins.ts +36 -0
  20. package/src/core/bun-tool-cli.ts +23 -0
  21. package/src/core/canonical-scripts.ts +6 -4
  22. package/src/core/check-ctx.ts +104 -10
  23. package/src/core/check-file-names.ts +2 -3
  24. package/src/core/check-pass.ts +17 -1
  25. package/src/core/check-runner.ts +19 -6
  26. package/src/core/config-rewrite.ts +27 -6
  27. package/src/core/discover-checks.ts +30 -10
  28. package/src/core/floor-resolve.ts +16 -7
  29. package/src/core/job-run.ts +2 -2
  30. package/src/core/layout.ts +4 -1
  31. package/src/core/legacy-shapes.ts +40 -0
  32. package/src/core/one-lib.ts +1 -1
  33. package/src/core/one-way-in-policy.ts +106 -13
  34. package/src/core/pre-commit-discovery.ts +106 -0
  35. package/src/core/retired-checks.ts +2 -0
  36. package/src/core/run-ci.ts +16 -10
  37. package/src/core/run-fix.ts +9 -0
  38. package/src/core/run-fixers.ts +91 -35
  39. package/src/core/runs-matrix.ts +7 -0
  40. package/src/core/slots.ts +1 -1
  41. package/src/core/vendored-read.ts +39 -0
  42. package/src/core/vendored.ts +82 -0
  43. package/src/core/waivers.ts +125 -0
  44. package/src/core/when-block.ts +25 -185
  45. package/src/core/when.ts +30 -13
  46. package/src/core/wire.ts +17 -1
  47. package/src/fleet/update-fleet.ts +11 -2
  48. package/src/git-bootstrap.ts +1 -1
  49. package/src/home/attach.ts +3 -5
  50. package/src/home/insights.ts +6 -1
  51. package/src/home/pm-config.ts +2 -2
  52. package/src/home/run-daemon.ts +2 -2
  53. package/src/home/run-unit-action.ts +2 -2
  54. package/src/hooks/commit-message.ts +2 -14
  55. package/src/hooks/hook-gates.ts +38 -0
  56. package/src/hooks/leak-guard.ts +8 -3
  57. package/src/hooks/one-way-in-guard.ts +8 -2
  58. package/src/hooks/root-identity.ts +9 -6
  59. package/src/hooks/run-hooks.ts +72 -41
  60. package/src/hooks/staged.ts +49 -0
  61. package/src/hooks/tree-shrink.ts +41 -0
  62. package/src/init-repo.ts +0 -42
  63. package/src/ledger/check-table.ts +1 -1
  64. package/src/ledger/gate-checks.ts +45 -9
  65. package/src/ledger/ledger.ts +6 -1
  66. package/src/ledger/repo-view.ts +14 -4
  67. package/src/ledger/toolchain-identity.ts +4 -2
  68. package/src/mv/mv-md.ts +6 -1
  69. package/src/problems/extract.ts +27 -5
  70. package/src/problems/problem-line.ts +2 -3
  71. package/src/release/run-release.ts +13 -8
  72. package/src/repo-config.ts +44 -27
  73. package/src/scaffold/files/bunfig.toml +11 -0
  74. package/src/scaffold/files/vscode-settings.json +1 -2
  75. package/src/scaffold/jsonc-spans.ts +16 -0
  76. package/src/scaffold/project-scaffold.ts +43 -77
  77. package/src/serve/hub-app/check-view.tsx +4 -4
  78. package/src/serve/hub-app/check-words.ts +1 -0
  79. package/src/serve/hub-app/checks-svc.ts +1 -2
  80. package/src/serve/hub-app/home.html +0 -1
  81. package/src/serve/hub-app/hub-types.ts +0 -2
  82. package/src/serve/hub-app/index.html +0 -1
  83. package/src/serve/hub-app/project-list.css +1 -2
  84. package/src/serve/hub-app/tree-build.ts +3 -2
  85. package/src/serve/hub-app/tree-svc.ts +2 -2
  86. package/src/serve/hub-port.ts +3 -3
  87. package/src/serve/hub-runtime.ts +0 -3
  88. package/src/serve/serve-hub.ts +1 -16
  89. package/src/serve/tracked-watch.ts +1 -1
  90. package/src/start-repo.ts +15 -3
  91. package/src/suite/judge-suites.ts +29 -28
  92. package/src/suite/select.ts +2 -0
  93. package/src/suite/suite-cli.ts +6 -3
  94. package/src/suite/suite-cpu-preload.ts +7 -11
  95. package/src/suite/test-units.ts +7 -4
  96. package/src/ts/config.ts +12 -5
  97. package/src/ts/imports.ts +3 -1
  98. package/src/tui/build-steps-panel.ts +1 -1
  99. package/src/wiki/scan-md.ts +16 -9
  100. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  101. package/src/checks/SK014-prose-ste.check.ts +0 -78
  102. package/src/cli/cmd_Prose.ts +0 -74
  103. package/src/prose/prose-view.ts +0 -90
  104. package/src/prose/run-prose.ts +0 -187
  105. package/src/prose/ste.ts +0 -552
  106. package/src/scaffold/files/vscode-tasks.json +0 -24
  107. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  108. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  109. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -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)
@@ -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
@@ -1,74 +0,0 @@
1
- // shipkit prose — the STE checker as a command, so a writer can run it on one
2
- // file while drafting instead of waiting for the gate. Rule SK014 runs the
3
- // same module over the repo's declared scope.
4
- import { Command, Option } from 'clipanion'
5
- import { loadRepoConfig } from '../repo-config.ts'
6
- import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
7
- import { CheckCtx } from '../core/check-ctx.ts'
8
-
9
- export class ProseCommand extends Command {
10
- static override paths = [['prose']]
11
-
12
- static override usage = Command.Usage({
13
- category: 'Health',
14
- description: 'STE prose check (identity/writing-voice.md): mechanical fixes, hard violations, soft warnings',
15
- examples: [
16
- ['every file this repo declares as prose', 'sk prose'],
17
- ['one file while drafting', 'sk prose wiki/build/shipkit.md'],
18
- ['apply every mechanical rewrite', 'sk prose --fix'],
19
- ['the procedural 20-word limit', 'sk prose --strict'],
20
- ['machine-readable', 'sk prose --json'],
21
- ],
22
- })
23
-
24
- // `targets`, not `paths`: clipanion's Command owns the static `paths`
25
- targets = Option.Rest()
26
- fix = Option.Boolean('--fix', false, { description: 'apply the mechanical rewrites (contractions, latin, one-to-one substitutions, empty words)' })
27
- json = Option.Boolean('--json', false, { description: 'machine-readable { files, byClass, hits }' })
28
- strict = Option.Boolean('--strict', false, { description: 'the 20-word procedural sentence limit instead of the 25-word descriptive one' })
29
- cap = Option.String('--cap', '25', { description: 'how many hit lines to print before the per-class totals' })
30
-
31
- async execute(): Promise<number> {
32
- const root = process.cwd()
33
- const config = await loadRepoConfig(root)
34
- const cfg = { ...(config?.prose ?? {}) }
35
- if (this.strict) cfg.strict = true
36
- const explicit = this.targets.length > 0
37
- if ((cfg.include ?? []).length === 0 && !explicit) {
38
- this.context.stdout.write(
39
- `skip: this repo declares no prose scope — add \`prose: { include: ['**/*.md'], exempt: [...] }\` to repo.config.ts (identity/writing-voice.md)\n`,
40
- )
41
- return 0
42
- }
43
- const r = runProseCheck({ c: CheckCtx.open({ root, config }), config: cfg, paths: explicit ? this.targets : undefined, fix: this.fix })
44
- if (this.json) {
45
- // every hit carries its file: a flat list without one cannot be
46
- // acted on, and the grouping is already in byFile
47
- const hits = r.byFile.flatMap((f) => f.hits.map((h) => ({ file: f.file, ...h })))
48
- this.context.stdout.write(`${JSON.stringify({ files: r.files, byTier: r.byTier, byClass: r.byClass, fixed: r.fixed, hits }, null, 2)}\n`)
49
- return r.byTier.fix + r.byTier.hard > 0 ? 1 : 0
50
- }
51
- const w = (l: string) => this.context.stdout.write(`${l}\n`)
52
- // a declared scope that resolves to nothing reads as `0 file(s)
53
- // STE-clean`, the same green a clean repo gets, over a check that
54
- // inspected no file at all (rule SK014 warns on the same fact)
55
- if (!explicit && r.files === 0) {
56
- w(`WARN prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`)
57
- w(' fix: fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out')
58
- return 0
59
- }
60
- if (r.fixed.length > 0) w(`ok: rewrote ${r.fixed.length} file(s)`)
61
- for (const l of proseLines(r, { cap: Number(this.cap) || 25 })) w(l)
62
- const blocking = r.byTier.fix + r.byTier.hard
63
- const baseline = cfg.baseline
64
- const overBaseline = baseline != null && r.byTier.soft > baseline
65
- if (blocking === 0 && !overBaseline) {
66
- w(`ok: ${r.files} file(s) STE-clean${r.byTier.soft > 0 ? `, ${r.byTier.soft} soft warning(s)${baseline != null ? ` (baseline ${baseline})` : ''}` : ''}`)
67
- return 0
68
- }
69
- if (blocking > 0)
70
- w(`FAIL: ${blocking} STE violation(s) in ${r.byFile.length} file(s) — ${classSummary(r)}${r.fixable > 0 ? `\n fix: shipkit prose --fix clears ${r.fixable} of them` : ''}`)
71
- if (overBaseline) w(`FAIL: ${r.byTier.soft} soft hits, above this repo's baseline of ${baseline} — lower the count, or move the baseline down in repo.config.ts`)
72
- return 1
73
- }
74
- }
@@ -1,90 +0,0 @@
1
- // The SK014 page's data: every hit of the prose check, grouped by the word
2
- // (or the rule, for the classes that judge a shape), noisiest first, each hit
3
- // with its file, its line and the sentence around it. It runs the SAME
4
- // module the rule runs, over the same config, so the page and the gate can
5
- // never count differently.
6
- import type { CheckCtx } from '../core/check-ctx.ts'
7
- import type { RepoConfig } from '../repo-config.ts'
8
- import { runProseCheck } from './run-prose.ts'
9
- import type { ProseClass, ProseHit, ProseTier, ProseWords } from './ste.ts'
10
-
11
- export type ProseViewHit = {
12
- file: string
13
- line: number
14
- /** the source line around the hit, clipped to a readable width */
15
- excerpt: string
16
- /** where the hit sits inside `excerpt`, [from, to) */
17
- mark: [number, number]
18
- why: string
19
- fix?: string
20
- }
21
-
22
- export type ProseGroup = {
23
- /** `<class>:<word>` for a word hit, `<class>` for a shape */
24
- key: string
25
- cls: ProseClass
26
- tier: ProseTier
27
- /** the dictionary word or phrase an allow entry names; null on a shape */
28
- word: string | null
29
- count: number
30
- hits: ProseViewHit[]
31
- }
32
-
33
- export type ProseView = {
34
- /** false when the repo declares no prose scope: the rule skips it */
35
- scoped: boolean
36
- files: number
37
- total: number
38
- byTier: Record<ProseTier, number>
39
- groups: ProseGroup[]
40
- /** the repo's own lists as the config loads them */
41
- words: ProseWords
42
- }
43
-
44
- /** how much of a long line the page shows on each side of the hit */
45
- const SIDE = 70
46
-
47
- /** the source line a hit sits on, clipped around it, with the hit's span
48
- * inside the clip. A hit that runs past its line (a long sentence, a
49
- * paragraph) marks to the end of the line */
50
- export function excerptOf(text: string, h: Pick<ProseHit, 'start' | 'end'>): { excerpt: string; mark: [number, number] } {
51
- const from = text.lastIndexOf('\n', h.start - 1) + 1
52
- const nl = text.indexOf('\n', h.start)
53
- const to = nl < 0 ? text.length : nl
54
- const line = text.slice(from, to)
55
- let a = h.start - from
56
- let b = Math.min(h.end, to) - from
57
- let lo = Math.max(0, a - SIDE)
58
- const hi = Math.min(line.length, Math.max(b, a) + SIDE)
59
- // a leading indent is noise in a one line quote
60
- while (lo < a && /\s/.test(line[lo]!)) lo++
61
- const head = lo > 0 ? '…' : ''
62
- const tail = hi < line.length ? '…' : ''
63
- const excerpt = head + line.slice(lo, hi).trimEnd() + tail
64
- a = a - lo + head.length
65
- b = Math.min(b - lo + head.length, excerpt.length - tail.length)
66
- return { excerpt, mark: [a, Math.max(a, b)] }
67
- }
68
-
69
- export function proseView(c: Pick<CheckCtx, 'root' | 'read' | 'list' | 'write'>, config: RepoConfig): ProseView {
70
- const cfg = config.prose
71
- const empty: ProseView = { scoped: false, files: 0, total: 0, byTier: { fix: 0, hard: 0, soft: 0 }, groups: [], words: cfg?.words ?? {} }
72
- if (cfg == null || (cfg.include ?? []).length === 0) return empty
73
- const r = runProseCheck({ c, config: cfg, keepText: true })
74
- const groups = new Map<string, ProseGroup>()
75
- for (const f of r.byFile)
76
- for (const h of f.hits) {
77
- const key = h.word == null ? h.cls : `${h.cls}:${h.word}`
78
- let g = groups.get(key)
79
- if (g == null) {
80
- g = { key, cls: h.cls, tier: h.tier, word: h.word ?? null, count: 0, hits: [] }
81
- groups.set(key, g)
82
- }
83
- g.count++
84
- const ex = excerptOf(f.text ?? '', h)
85
- g.hits.push({ file: f.file, line: h.line, ...ex, why: h.why, ...(h.fix == null ? {} : { fix: h.fix }) })
86
- }
87
- // the noisiest first: one allow entry on the top word clears the most
88
- const sorted = [...groups.values()].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
89
- return { scoped: true, files: r.files, total: r.hits.length, byTier: r.byTier, groups: sorted, words: cfg.words ?? {} }
90
- }