rvlib-shipkit 1.9.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 (108) hide show
  1. package/CHANGELOG.md +34 -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/git-bootstrap.ts +1 -1
  48. package/src/home/attach.ts +3 -5
  49. package/src/home/insights.ts +6 -1
  50. package/src/home/pm-config.ts +2 -2
  51. package/src/home/run-daemon.ts +2 -2
  52. package/src/home/run-unit-action.ts +2 -2
  53. package/src/hooks/commit-message.ts +2 -14
  54. package/src/hooks/hook-gates.ts +38 -0
  55. package/src/hooks/leak-guard.ts +8 -3
  56. package/src/hooks/one-way-in-guard.ts +8 -2
  57. package/src/hooks/root-identity.ts +9 -6
  58. package/src/hooks/run-hooks.ts +72 -41
  59. package/src/hooks/staged.ts +49 -0
  60. package/src/hooks/tree-shrink.ts +41 -0
  61. package/src/init-repo.ts +0 -42
  62. package/src/ledger/check-table.ts +1 -1
  63. package/src/ledger/gate-checks.ts +45 -9
  64. package/src/ledger/ledger.ts +6 -1
  65. package/src/ledger/repo-view.ts +14 -4
  66. package/src/ledger/toolchain-identity.ts +4 -2
  67. package/src/mv/mv-md.ts +6 -1
  68. package/src/problems/extract.ts +27 -5
  69. package/src/problems/problem-line.ts +2 -3
  70. package/src/release/run-release.ts +13 -8
  71. package/src/repo-config.ts +44 -27
  72. package/src/scaffold/files/bunfig.toml +11 -0
  73. package/src/scaffold/files/vscode-settings.json +1 -2
  74. package/src/scaffold/jsonc-spans.ts +16 -0
  75. package/src/scaffold/project-scaffold.ts +43 -77
  76. package/src/serve/hub-app/check-view.tsx +4 -4
  77. package/src/serve/hub-app/check-words.ts +1 -0
  78. package/src/serve/hub-app/checks-svc.ts +1 -2
  79. package/src/serve/hub-app/home.html +0 -1
  80. package/src/serve/hub-app/hub-types.ts +0 -2
  81. package/src/serve/hub-app/index.html +0 -1
  82. package/src/serve/hub-app/project-list.css +1 -2
  83. package/src/serve/hub-app/tree-build.ts +3 -2
  84. package/src/serve/hub-app/tree-svc.ts +2 -2
  85. package/src/serve/hub-port.ts +3 -3
  86. package/src/serve/hub-runtime.ts +0 -3
  87. package/src/serve/serve-hub.ts +1 -16
  88. package/src/serve/tracked-watch.ts +1 -1
  89. package/src/start-repo.ts +15 -3
  90. package/src/suite/judge-suites.ts +29 -28
  91. package/src/suite/select.ts +2 -0
  92. package/src/suite/suite-cli.ts +6 -3
  93. package/src/suite/suite-cpu-preload.ts +7 -11
  94. package/src/suite/test-units.ts +7 -4
  95. package/src/ts/config.ts +12 -5
  96. package/src/ts/imports.ts +3 -1
  97. package/src/tui/build-steps-panel.ts +1 -1
  98. package/src/wiki/scan-md.ts +16 -9
  99. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  100. package/src/checks/SK014-prose-ste.check.ts +0 -78
  101. package/src/cli/cmd_Prose.ts +0 -74
  102. package/src/prose/prose-view.ts +0 -90
  103. package/src/prose/run-prose.ts +0 -187
  104. package/src/prose/ste.ts +0 -552
  105. package/src/scaffold/files/vscode-tasks.json +0 -24
  106. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  107. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  108. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -26,6 +26,7 @@ import { CHECK_TIMEOUT_MS, type RunnableCheck, type CheckBody } from '../core/ch
26
26
  import { FOREVER, ttlMsOf } from '../core/wire.ts'
27
27
  import { budgetOf } from '../core/budget.ts'
28
28
  import { repoOff, withRepoOff, whenSummary, type WhenDecl } from '../core/when.ts'
29
+ import { HOOK_BUDGET_MS, HOOK_GATES, HOOK_GATES_OWNER, hookGateOff, type HookName } from '../hooks/hook-gates.ts'
29
30
  import { cacheText, filesText, NOWHERE, sourced, type CacheDecl, type ResolvedRuns, type Runs } from '../core/runs.ts'
30
31
  import { isNamesInput, type CheckDecl } from './git-key.ts'
31
32
  import { populationOf } from '../problems/problem-line.ts'
@@ -131,6 +132,9 @@ export type CheckRow = {
131
132
  * `repo` = this repo declared it (a package.json script behind a floor
132
133
  * slot, a task, a rule, a check file, a check file). */
133
134
  from: CheckFrom
135
+ /** a COMMIT GATE: the git hook that runs it (hooks/hook-gates.ts). The
136
+ * runner never does, so every runner context reads false */
137
+ hook?: HookName
134
138
  }
135
139
 
136
140
  /** the one axis every check answers: who wrote the declaration */
@@ -348,15 +352,16 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
348
352
  // time is not judged and its answer never stands in for a quick one
349
353
  out.push({ row, runnable: runnableOf(row, taskBody(t), { unbudgeted: full }) })
350
354
  }
351
- // a slot `defaults: { <id>: false }` turned off never resolves to a task,
352
- // so without this it had no row at all: no `runs in` value, no reason, and
353
- // a count of off checks that read zero while a gate step sat dark. It has
354
- // no command, which is why it declares `never` rather than joining the
355
- // burn-down of checks nobody has keyed yet.
355
+ // a slot the repo turned off never resolves to a task, so without this it
356
+ // had no row at all: no `runs in` value, no reason, and a count of off
357
+ // checks that read zero while a gate step sat dark. It has no command,
358
+ // which is why it declares `never` rather than joining the burn-down of
359
+ // checks nobody has keyed yet.
356
360
  for (const id of floor.disabled) {
357
- const runs = sourced(NOWHERE, 'none', 'repo.config.ts', null, `defaults.${id}: false`)
361
+ const off = repoOff(c, id) ?? { reason: 'turned off', source: 'off' as const }
362
+ const runs = sourced(NOWHERE, 'none', 'repo.config.ts', null, off.reason)
358
363
  const row: CheckRow = {
359
- decl: { id, never: `turned off by defaults.${id}: false, so there is no command to key` },
364
+ decl: { id, never: `the slot is off, so there is no command to key` },
360
365
  title: id,
361
366
  description: slotDescription(id, declaredTestUnits(c.units).length > 0),
362
367
  gate: false,
@@ -369,8 +374,8 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
369
374
  optIn: false,
370
375
  fixable: false,
371
376
  runs,
372
- cache: `never: turned off by defaults.${id}: false`,
373
- when: whenSummary(runs, { reason: `defaults.${id}: false`, source: 'defaults' }, false),
377
+ cache: `never: the slot is off`,
378
+ when: whenSummary(runs, off, false),
374
379
  deps: null,
375
380
  // the repo wrote the line that turned it off
376
381
  from: 'repo',
@@ -518,6 +523,37 @@ export async function composeChecks(root: string, c: RepoConfig, o: ComposeOpts
518
523
  const body: CheckBody = { cmd: ['bun', d.path], ...(task?.env == null ? {} : { env: task.env }), ...(task?.cwd == null ? {} : { cwd: resolve(root, task.cwd) }) }
519
524
  out.push({ row, runnable: runnableOf(row, body, { pointer: `bun ${rel}` }) })
520
525
  }
526
+ // THE COMMIT GATES: the git hooks run them (hooks/run-hooks.ts), never the
527
+ // runner, so a row here only makes them enumerable and `off` able to name
528
+ // them. They run in no runner context, and a run by name says where they run
529
+ for (const g of HOOK_GATES) {
530
+ const na = c.nested === true ? 'n/a here: a nested folder, the parent work tree owns the commit gates'
531
+ : g.id === 'blocked-paths' && (c.hooks?.blockedPaths ?? []).length === 0 ? 'n/a here: no hooks.blockedPaths declared' : null
532
+ const off = hookGateOff(c, g.id)
533
+ const hookWords = g.hook === 'both' ? 'the pre-commit and commit-msg hooks' : `the ${g.hook} hook`
534
+ const runs = withRepoOff(sourced(NOWHERE, 'none', 'derived', `a commit gate: ${hookWords} run it on every commit`, na), off)
535
+ const row: CheckRow = {
536
+ decl: { id: g.id, never: `a commit gate runs on the index at commit, never cached` },
537
+ title: g.title,
538
+ description: g.description,
539
+ gate: false,
540
+ build: false,
541
+ blocking: true,
542
+ owner: HOOK_GATES_OWNER,
543
+ group: null,
544
+ timeoutMs: null,
545
+ budgetMs: HOOK_BUDGET_MS,
546
+ optIn: false,
547
+ fixable: false,
548
+ runs,
549
+ cache: `never: ${hookWords}`,
550
+ when: whenSummary(runs, off, false, { hook: true }),
551
+ deps: null,
552
+ from: 'shipkit',
553
+ hook: g.hook,
554
+ }
555
+ out.push({ row, runnable: runnableOf(row, { run: (ctx) => ctx.note(`${g.id} runs in ${hookWords}, never in a gate: \`shipkit hooks list\` shows it`) }) })
556
+ }
521
557
  return out
522
558
  }
523
559
 
@@ -273,9 +273,14 @@ export class Ledger {
273
273
  /** buffer what a check just measured. Nothing reaches the store until
274
274
  * commit() has re-hashed the inputs (rule 2). A READS check with no read
275
275
  * set (it reached the world) is never stored (rule 7). */
276
- record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null): void {
276
+ record(id: string, outcome: CheckOutcome, reads?: ReadSet | null, notKept?: string | null, waiverFiles: readonly string[] = []): void {
277
277
  const k = this.key(id)
278
278
  if (k.key == null) return
279
+ // a READS answer carries every waiving file in its read set. A
280
+ // CONTENT key moves only with its inputs, so a directive outside
281
+ // them could be added or removed under a stored answer
282
+ const outside = k.population === 'content' ? waiverFiles.find((f) => !k.inputOids.some(([p]) => p === '.' || f === p || f.startsWith(`${p}/`))) : undefined
283
+ if (outside != null) { this.ranWhy.set(id, { group: 'not kept', why: `a site in ${outside}, which an in-file directive can waive, sits outside its declared inputs` }); return }
279
284
  if (k.population === 'reads') {
280
285
  if (reads == null) { this.ranWhy.set(id, { group: 'not kept', why: notKept ?? 'it read outside what the cache can see' }); return }
281
286
  this.buffered.set(id, { ...outcome, reads, components: k.components })
@@ -7,8 +7,14 @@
7
7
  // Built once per run and shared: `c.list` in every check and the ledger's
8
8
  // replay check read the same snapshot, so a check never lists one tree and is
9
9
  // validated against another inside one run.
10
+ //
11
+ // A listing leaves out the repo's vendored dirs (core/vendored.ts), unless
12
+ // its glob names a path inside one: a check never mistakes third party code
13
+ // for the repo's own, and the replay check lists through the same filter.
10
14
  import { relative } from 'path'
11
15
  import { gitBin } from '../core/git-bin.ts'
16
+ import { listingWithoutVendored } from '../core/vendored.ts'
17
+ import { readVendored } from '../core/vendored-read.ts'
12
18
  import { GitScanError, scanPaths, WHOLE_TREE, type ScanResult } from './git-scan.ts'
13
19
 
14
20
  export class RepoView {
@@ -21,26 +27,30 @@ export class RepoView {
21
27
  readonly fallback: string | null,
22
28
  /** the scan it was read off, handed to the next open as its prior */
23
29
  readonly scan: ScanResult | null = null,
30
+ /** the repo's vendored dirs, as its config text declared them at open */
31
+ readonly vendored: readonly string[] = [],
24
32
  ) {}
25
33
 
26
34
  /** `prior`: an earlier view of the same root in this run. The tree is
27
35
  * still staged and hashed fresh, so the list is today's, and an unchanged
28
36
  * tree reuses the prior's answer */
29
37
  static open(root: string, prior?: RepoView | null): RepoView {
38
+ const vendored = readVendored(root)
30
39
  try {
31
40
  const scan = scanPaths(root, [WHOLE_TREE], prior?.scan ?? undefined, { treeOnly: true })
32
41
  const tree = scan.oidByPath.get(WHOLE_TREE)
33
42
  if (tree == null) throw new GitScanError('git could not name the work tree')
34
- return new RepoView(filesOf(root, tree), null, scan)
43
+ return new RepoView(filesOf(root, tree), null, scan, vendored)
35
44
  } catch (e) {
36
- return new RepoView(walk(root), e instanceof Error ? e.message : String(e))
45
+ return new RepoView(walk(root), e instanceof Error ? e.message : String(e), null, vendored)
37
46
  }
38
47
  }
39
48
 
40
- /** the files matching `glob`, sorted */
49
+ /** the files matching `glob`, sorted, the vendored ones left out unless
50
+ * the glob names a path inside one */
41
51
  list(glob: string): string[] {
42
52
  const g = new Bun.Glob(glob)
43
- return this.files.filter((f) => g.match(f))
53
+ return listingWithoutVendored(this.files.filter((f) => g.match(f)), glob, this.vendored)
44
54
  }
45
55
  }
46
56
 
@@ -328,8 +328,10 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
328
328
  // stands.
329
329
  const lib = new LibSource(cliEntry)
330
330
  const r = createHash('sha256')
331
- // the runtime. Free, and a bun upgrade genuinely changes behaviour
332
- r.update(`bun=${Bun.version}\n`)
331
+ // the runtime. Free, and a bun upgrade genuinely changes behaviour. The
332
+ // revision too: every canary of one version reports the same version,
333
+ // and a canary upgrade can change what `bun lint` reports
334
+ r.update(`bun=${Bun.version}+${Bun.revision}\n`)
333
335
  for (const l of runnerLines(lib, cliEntry)) r.update(`${l}\n`)
334
336
  return { id: r.digest('hex').slice(0, 16), install: installOf(root, lib), lib }
335
337
  }
package/src/mv/mv-md.ts CHANGED
@@ -8,6 +8,8 @@
8
8
  import { execSync } from 'child_process'
9
9
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'fs'
10
10
  import { basename, dirname, join, relative } from 'path'
11
+ import { isVendored } from '../core/vendored.ts'
12
+ import { vendoredAround } from '../core/vendored-read.ts'
11
13
 
12
14
  export type MvIo = { print: (l: string) => void; printErr: (l: string) => void }
13
15
  export type MvOpts = { write?: boolean; commit?: boolean }
@@ -130,8 +132,11 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
130
132
  const bound = (t: string): RegExp =>
131
133
  new RegExp('(?<![\\w-])' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + (/[/.]$/.test(t) || t.endsWith('.ts') ? '' : '(?![\\w-])'), 'g')
132
134
 
135
+ // third party code keeps its own references, byte for byte
136
+ const repo = vendoredAround(root)
137
+ const vendored = (repo?.dirs ?? []).map((d) => posix(relative(root, join(repo!.root, d))))
133
138
  const tracked = sh('git ls-files', root).split('\n').filter(Boolean)
134
- .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f))
139
+ .filter((f) => !/\.(png|jpe?g|gif|webp|ico|woff2?|ttf|zip|gz|lockb|lock)$/i.test(f) && !isVendored(f, vendored))
135
140
 
136
141
  // every path this move relocates, old → new. The sibling dir carries its
137
142
  // whole subtree, and the probe is renamed after the page, so its own entry
@@ -1,6 +1,8 @@
1
1
  // file:line findings out of what a check printed, one function per format the
2
- // floor meets: tsc, oxlint (both its piped and its graphical output), eslint's
3
- // stylish output, a failing bun test, and a shipkit wire line naming a file.
2
+ // floor meets: tsc, bun lint (the unix format the built-in lint slot asks
3
+ // for), oxlint (both its piped and its graphical output, kept while a repo
4
+ // still lints with it: SK001 warns on one, and it dies with the last), eslint's stylish
5
+ // output, a failing bun test, and a shipkit wire line naming a file.
4
6
  // Every one feeds the one Problem shape (problem-line.ts).
5
7
  //
6
8
  // a finding the source cannot anchor in a file is dropped rather than pinned
@@ -62,6 +64,20 @@ export function fromTsc(text: string, ctx: ExtractCtx): Problem[] {
62
64
  return out
63
65
  }
64
66
 
67
+ const BUN_LINT_UNIX = /^(.+?):(\d+):(\d+): (.*) \[(Error|Warning)\/([^\]]+)\]$/
68
+
69
+ /** bun lint `--format unix`: `src/a.ts:1:7: msg [Warning/no-unused-vars]` */
70
+ export function fromBunLint(text: string, ctx: ExtractCtx): Problem[] {
71
+ const out: Problem[] = []
72
+ for (const raw of stripAnsi(text).split('\n')) {
73
+ const m = BUN_LINT_UNIX.exec(raw)
74
+ if (m == null) continue
75
+ const file = repoFile(ctx, m[1]!, false)
76
+ if (file != null) out.push({ file, line: Number(m[2]), col: Number(m[3]), severity: m[5] === 'Error' ? 'error' : 'warning', check: ctx.check, message: `${m[6]} ${m[4]!.trim()}` })
77
+ }
78
+ return out
79
+ }
80
+
65
81
  const OXLINT_PIPED = /^(.+?):(\d+):(\d+): (error|warning) (\S+?): (.*)$/
66
82
  const OXLINT_HEAD = /^\s*(x|!|×|⚠)\s+(.+)$/
67
83
  const OXLINT_AT = /^\s*(?:,-|╭─)\[(.+):(\d+):(\d+)\]\s*$/
@@ -148,14 +164,20 @@ export function fromBunTest(text: string, ctx: ExtractCtx): Problem[] {
148
164
 
149
165
  const WIRE_REF = /((?:[\w@.~-]+\/)*[\w@.~-]+\.[A-Za-z0-9]+):(\d+)(?::(\d+))?/g
150
166
 
151
- /** a FAIL or WARN wire line naming `path:line` or `path:line:col`. The path
152
- * must be a file this repo has: a url and a `host:port` look the same */
167
+ /** a FAIL or WARN wire line: at the site the check reported when it gave one,
168
+ * else at every `path:line` or `path:line:col` the text names. The path must
169
+ * be a file this repo has: a url and a `host:port` look the same */
153
170
  export function fromWire(lines: Line[], ctx: ExtractCtx): Problem[] {
154
171
  const out: Problem[] = []
155
172
  for (const l of lines) {
156
173
  if (l.kind !== 'fail' && l.kind !== 'warn') continue
157
174
  const severity: Severity = l.kind === 'fail' ? 'error' : 'warning'
158
175
  const message = l.text.replace(/^\[[^\]]+\]\s*/, '').trim()
176
+ if (l.at != null) {
177
+ const file = repoFile(ctx, l.at.file, true)
178
+ if (file != null) out.push({ file, line: l.at.line ?? 1, col: l.at.col ?? 1, severity, check: ctx.check, message })
179
+ continue
180
+ }
159
181
  for (const m of l.text.matchAll(WIRE_REF)) {
160
182
  const before = l.text.slice(0, m.index)
161
183
  if (/[a-z]+:\/\/\S*$/i.test(before)) continue
@@ -173,7 +195,7 @@ export function extractProblems(src: { lines: Line[]; output?: string }, ctx: Ex
173
195
  const text = src.output ?? ''
174
196
  const all = [
175
197
  ...fromWire(src.lines, ctx),
176
- ...(text === '' ? [] : [...fromTsc(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
198
+ ...(text === '' ? [] : [...fromTsc(text, ctx), ...fromBunLint(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
177
199
  ]
178
200
  const seen = new Set<string>()
179
201
  return all.filter((p) => {
@@ -1,7 +1,6 @@
1
1
  // the ONE shape a problem takes on the hub terminal, and the vscode problem
2
- // matcher that reads it back. The printer and every tasks.json writer import
3
- // this file, so the line the hub prints and the regex the editor matches can
4
- // never drift apart.
2
+ // matcher that reads it back, in one file so the two can never drift apart.
3
+ // No house task carries the matcher any more (SK017 removes hub tasks).
5
4
  //
6
5
  // a run prints one FRAME: the begin line, one problem line per finding, the
7
6
  // end line. The matcher is a background one, so vscode clears the pane at the
@@ -117,7 +117,7 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
117
117
  return await runCi(root, {
118
118
  fix: o.fix ?? false,
119
119
  // a check's repair never runs on the release path (runs.onRelease):
120
- // only the mechanical slots (the formatter) may, when bump asks
120
+ // only the mechanical slots (the import fixer) may, when bump asks
121
121
  fixRules: false,
122
122
  // the rows declaring onRelease, whatever the tier
123
123
  release: true,
@@ -243,13 +243,7 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
243
243
  const remoteName0 = 'remote' in remote ? remote.remote : null
244
244
  const tagExists = (): boolean => tagExistsLocally(root, tag)
245
245
  const remoteState: RemoteTagState = remoteName0 == null ? { state: 'unreadable', error: 'no remote' } : remoteTagState(root, remoteName0, tag)
246
- if (remoteName0 != null && remoteState.state === 'unreadable')
247
- blockers.push(`cannot read tags on '${remoteName0}' (${remoteState.error}) — the push would be a guess`)
248
- // a tag NAME the remote already carries at another commit is not a
249
- // resumable release: this version would describe a tree that shipped as
250
- // something else, and git refuses the push anyway
251
- if (remoteState.state === 'different')
252
- blockers.push(`tag ${tag} already on '${remoteName0 ?? ''}' at ${remoteState.remoteOid.slice(0, 8)}, ${remoteState.localOid == null ? 'and not here at all' : `not ${remoteState.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`)
246
+ blockers.push(...tagBlockers(remoteName0, remoteState, tag))
253
247
  const ghReleaseExists = (): boolean =>
254
248
  wantGh && Bun.which('gh') != null && Bun.spawnSync(['gh', 'release', 'view', tag], { cwd: root }).exitCode === 0
255
249
  const fullyShipped = tagExists() && remoteState.state === 'same' && (!wantGh || ghReleaseExists()) && (!wantNpm || alreadyPublished)
@@ -365,3 +359,14 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
365
359
  rmSync(work, { recursive: true, force: true })
366
360
  }
367
361
  }
362
+
363
+ /** what the remote's view of the tag forbids: a remote nobody can read makes
364
+ * the push a guess, and a tag NAME it already carries at another commit is not
365
+ * a resumable release (this version would describe a tree that shipped as
366
+ * something else, and git refuses the push anyway) */
367
+ export function tagBlockers(remote: string | null, state: RemoteTagState, tag: string): string[] {
368
+ if (remote != null && state.state === 'unreadable') return [`cannot read tags on '${remote}' (${state.error}) — the push would be a guess`]
369
+ if (state.state === 'different')
370
+ return [`tag ${tag} already on '${remote ?? ''}' at ${state.remoteOid.slice(0, 8)}, ${state.localOid == null ? 'and not here at all' : `not ${state.localOid.slice(0, 8)} as here`} — that version number is burnt, bump a new one`]
371
+ return []
372
+ }
@@ -5,21 +5,21 @@
5
5
  // (lint/typecheck/test/imports:check/cycles): a package.json script wins;
6
6
  // with no script, imports:check/cycles fill from shipkit's own
7
7
  // rvlib-ts-tools dep and typecheck from the repo's tsc (O6 zero-config);
8
- // a slot that can't fill emits a visible reason, disabling via
9
- // `defaults: { x: false }` prints a skipped-by-config line — never silence.
8
+ // a slot that can't fill emits a visible reason, and one turned off with
9
+ // `off: { x: '<why>' }` prints a skipped-by-config line, never silence.
10
10
  // The `contracts` task is always present when any contract applies.
11
- import { existsSync } from 'fs'
11
+ import { existsSync, readFileSync } from 'fs'
12
12
  import { isAbsolute, join, normalize } from 'path'
13
13
  import { ROUTED_SCRIPTS } from './core/canonical-scripts.ts'
14
14
  import { FLOOR_IDS } from './core/floor.ts'
15
15
  export { resolveFloor, type FloorResolution, type FloorSource } from './core/floor.ts'
16
16
  import type { ContractManifest } from './contracts/contract-types.ts'
17
- import type { ProseConfig } from './prose/run-prose.ts'
18
- import { proseWordsProblems } from './prose/ste.ts'
19
17
  import { validateSmokeConfig, type SmokeConfig } from './smoke/run-smoke.ts'
20
18
  import { RULE_ID_RE, validateRules } from './rules/rule.ts'
21
19
  import { builtinCheckIds } from './core/discover-checks.ts'
20
+ import { RETIRED_CHECK_IDS } from './core/retired-checks.ts'
22
21
  import { parseWhen } from './core/when.ts'
22
+ import { normalizeVendored, vendoredOfText, vendoredProblem } from './core/vendored.ts'
23
23
  import { budgetValueProblem } from './core/budget.ts'
24
24
  import type { Rule } from './check.ts'
25
25
  import { leakAllowProblems } from './hooks/leak-guard.ts'
@@ -80,12 +80,13 @@ export type RepoConfig = {
80
80
  social?: string[]
81
81
  /** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
82
82
  docsBase?: string
83
- /** a floor slot: `false` disables it (always visible), a def with a
84
- * `cmd` or `logic` REPLACES it, and a def with neither DECORATES whatever
85
- * fills it. The decoration is how a repo declares what its own `test`
86
- * script reads (`{ test: { inputs: ['tests', 'src'] } }`) without
87
- * restating the command: the read set is repo knowledge, the command is
88
- * already in package.json, and restating it is a second source of truth. */
83
+ /** a floor slot: a def with a `cmd` or `logic` REPLACES it, and a def
84
+ * with neither DECORATES whatever fills it. The decoration is how a repo
85
+ * declares what its own `test` script reads (`{ test: { inputs:
86
+ * ['tests', 'src'] } }`) without restating the command: the read set is
87
+ * repo knowledge, the command is already in package.json, and restating
88
+ * it is a second source of truth. A slot set to `false` is RETIRED, read
89
+ * as `off` while SK029 ports it there: a slot turns off like any check */
89
90
  defaults?: Record<string, Partial<TaskDef> | false>
90
91
  /** DEPRECATED, read through 0.40 and rewritten by `shipkit check --fix
91
92
  * SK016`: a build step is a `<name>.check.ts` file with `build: true` */
@@ -167,10 +168,10 @@ export type RepoConfig = {
167
168
  * folder only: it never commits, untracks, tags or pushes on the parent's
168
169
  * history. Rule SK015 fails when this and the disk disagree (core/nesting.ts) */
169
170
  nested?: boolean
170
- /** THE CHECKS THIS REPO TURNS OFF, check id → why. The one thing a repo
171
- * says about a check it does not own: where a check runs is its own
172
- * declaration (build/shipkit/runs.md), and an off check runs in no
173
- * context. `defineRepo` throws on a blank reason and on a rule id no
171
+ /** THE CHECKS THIS REPO TURNS OFF, check id → why, for a whole check it
172
+ * does not own (one false finding is waived at its site instead): where
173
+ * a check runs is its own declaration (build/shipkit/runs.md), and an
174
+ * off check runs in no context. `defineRepo` throws on a blank reason and on a rule id no
174
175
  * check carries. The hub writes this map through the managed block in
175
176
  * this file (core/when-block.ts). */
176
177
  off?: Record<string, string>
@@ -251,6 +252,18 @@ export type RepoConfig = {
251
252
  * repo root is refused outright: a single line that excuses everything
252
253
  * would turn a blocking rule into a false green. */
253
254
  notReleasable?: string[]
255
+ /** repo-relative dirs of THIRD PARTY code (a git subtree, a copied
256
+ * library) that must stay byte identical to upstream. Every tool that
257
+ * treats the tree as the repo's own source leaves them out: the import
258
+ * fixer and its package discovery, imports:check, cycles, the fix pass
259
+ * (a fixer that rewrites a file under one gets it put back, with a
260
+ * WARN naming it), bun lint, the checks' file
261
+ * listings, check discovery, the md scan and `shipkit mv`. Cache keys
262
+ * and watchers still read them, because they are still inputs, and the
263
+ * leak guard still scans what a commit stages there. Write it
264
+ * as a literal array of strings: the tools that never load this file
265
+ * read it as text. One entry is one dir, never a glob */
266
+ vendored?: string[]
254
267
  /** git-hook gates: built-in leak guard + commit-message predicate, plus the
255
268
  * repo's own commands. `shipkit hooks list` enumerates them, so what blocks
256
269
  * a commit is answerable without reading shell. hooks/run-hooks.ts */
@@ -263,12 +276,6 @@ export type RepoConfig = {
263
276
  /** what a `*.check.ts` with no `export const blocking` means here */
264
277
  blocking?: boolean
265
278
  }
266
- /** house prose in STE (identity/writing-voice.md), gated by rule SK014.
267
- * `include` is what makes the rule run at all: a repo that declares no
268
- * scope is skipped rather than failed, because STE binds the prose an
269
- * agent WRITES and no repo can be opted in from the outside.
270
- * prose/run-prose.ts */
271
- prose?: ProseConfig
272
279
  /** `shipkit smoke`: the deployed url, the routes a read only browser pass
273
280
  * visits after a deploy, and how it logs in. The password is a secret
274
281
  * NAME: the value never sits in config, argv or env. smoke/run-smoke.ts */
@@ -300,7 +307,7 @@ export type RepoConfig = {
300
307
  logDir?: string
301
308
  }
302
309
 
303
- const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'prose', 'smoke', 'tests'])
310
+ const KNOWN_KEYS = new Set(['name', 'venues', 'social', 'docsBase', 'defaults', 'tasks', 'asks', 'creds', 'credsCommand', 'credsReadCommand', 'notifyCommand', 'contracts', 'gitignore', 'ignoredInputs', 'untrackGenerated', 'nested', 'off', 'when', 'budgets', 'ruleExempt', 'visibility', 'archived', 'reviewer', 'oneWayIn', 'rules', 'release', 'units', 'notReleasable', 'vendored', 'hooks', 'checkDefaults', 'tui', 'serve', 'identity', 'logDir', 'smoke', 'tests'])
304
311
 
305
312
  export function defineRepo(c: RepoConfig): RepoConfig {
306
313
  if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
@@ -324,7 +331,7 @@ export function defineRepo(c: RepoConfig): RepoConfig {
324
331
  // gate is exactly the never-silence failure mode (A23)
325
332
  for (const k of Object.keys(c))
326
333
  if (!KNOWN_KEYS.has(k))
327
- throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : ''}`)
334
+ throw new Error(`defineRepo: unknown key '${k}'${k === 'checks' ? ` — checks:[] was removed; drop a <name>.check.ts file instead ('shipkit check' auto-discovers it)` : k === 'home' ? ` — home.resources was removed; declare each one in a <name>.shipkit.ts file as \`export default globalProcess({...})\`` : k === 'prose' ? ` — prose was removed with rule SK014: delete the key` : ''}`)
328
335
  if (c.release?.publisher != null && !(PUBLISHERS as readonly string[]).includes(c.release.publisher))
329
336
  throw new Error(`defineRepo: release.publisher '${String(c.release.publisher)}' is not a publisher (${PUBLISHERS.join(', ')})`)
330
337
  // a typo'd unit key would silently drop a target and release the wrong shape
@@ -348,6 +355,11 @@ export function defineRepo(c: RepoConfig): RepoConfig {
348
355
  if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
349
356
  throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
350
357
  }
358
+ if (c.vendored != null && !Array.isArray(c.vendored)) throw new Error(`defineRepo: vendored is a list of repo-relative dirs`)
359
+ for (const d of c.vendored ?? []) {
360
+ const bad = vendoredProblem(d)
361
+ if (bad != null) throw new Error(`defineRepo: vendored '${String(d)}' ${bad}`)
362
+ }
351
363
  for (const [k, slot] of Object.entries(c.defaults ?? {})) {
352
364
  if (!(FLOOR_IDS as readonly string[]).includes(k))
353
365
  throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
@@ -385,6 +397,7 @@ export function defineRepo(c: RepoConfig): RepoConfig {
385
397
  for (const [id, reason] of Object.entries(c.off ?? {})) {
386
398
  if (id.trim() === '') throw new Error(`defineRepo: off has an entry with no check id`)
387
399
  if (typeof reason !== 'string' || reason.trim() === '') throw new Error(`defineRepo: off.${id} needs a reason: a check nobody can explain is a silenced gate`)
400
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`defineRepo: off.${id} names a retired rule (${RETIRED_CHECK_IDS[id]}): delete the entry`)
388
401
  if (RULE_ID_RE.test(id) && !builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
389
402
  throw new Error(`defineRepo: off.${id} names no rule, the ids are in \`shipkit checks\``)
390
403
  }
@@ -403,15 +416,12 @@ export function defineRepo(c: RepoConfig): RepoConfig {
403
416
  }
404
417
  // an exemption silences a gate, so it names a rule that exists and says why
405
418
  for (const [id, reason] of Object.entries(c.ruleExempt ?? {})) {
419
+ if (RETIRED_CHECK_IDS[id] != null) throw new Error(`defineRepo: ruleExempt.${id} names a retired rule (${RETIRED_CHECK_IDS[id]}): delete the entry`)
406
420
  if (!builtinCheckIds().includes(id) && !(c.rules ?? []).some((r) => r.id === id))
407
421
  throw new Error(`defineRepo: ruleExempt.${id} names no rule, the ids are in \`shipkit checks\``)
408
422
  if (typeof reason !== 'string' || reason.trim() === '')
409
423
  throw new Error(`defineRepo: ruleExempt.${id} needs a reason, an exemption nobody can explain is a silenced gate`)
410
424
  }
411
- // a word list entry silences or adds gate hits, so a malformed one dies
412
- // here rather than matching nothing
413
- const words = proseWordsProblems(c.prose?.words)
414
- if (words.length > 0) throw new Error(`defineRepo: ${words[0]}`)
415
425
  // a smoke typo would land as a run that visits nothing and exits 0
416
426
  if (c.smoke != null) validateSmokeConfig(c.smoke)
417
427
  // a leak-guard waiver is the one config row that can make a gate quieter,
@@ -439,5 +449,12 @@ export async function loadRepoConfig(root: string, o: { fresh?: boolean } = {}):
439
449
  if (!existsSync(path)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
440
450
  const mod = (await import(o.fresh === true ? `${path}?v=${Date.now()}-${Math.random().toString(36).slice(2)}` : path)) as { default?: RepoConfig }
441
451
  if (mod.default == null) throw new Error(`${path}: must default-export defineRepo({...})`)
452
+ // the tools that never load the config read `vendored` off the text, so
453
+ // a value the text cannot show would omit one set of dirs here and
454
+ // another there
455
+ const loaded = (mod.default.vendored ?? []).map(normalizeVendored)
456
+ const read = vendoredOfText(readFileSync(path, 'utf8'))
457
+ if (loaded.join('\0') !== read.join('\0'))
458
+ throw new Error(`${path}: \`vendored\` must be a literal array of strings, the tools that never load the config read it as text (they read [${read.join(', ')}], the config says [${loaded.join(', ')}])`)
442
459
  return mod.default
443
460
  }
@@ -0,0 +1,11 @@
1
+ # `bun lint` is the house linter, and `sk ci` runs it once [lint] is declared.
2
+ # [fmt] is the house style for a manual `bun fmt`: nothing formats in the gate.
3
+ [fmt]
4
+ semi = false
5
+ singleQuote = true
6
+ printWidth = 140
7
+ tabWidth = 4
8
+ # code only: prose keeps its own shape, and package.json belongs to its fixers
9
+ ignorePatterns = ["*.md", "*.json", "*.jsonc", "*.yaml", "*.yml", "*.toml"]
10
+
11
+ [lint]
@@ -33,8 +33,7 @@
33
33
  "js/ts.tsserver.automaticTypeAcquisition.enabled": false,
34
34
  "js/ts.tsserver.checkNpmIsInstalled": false,
35
35
 
36
- // oxc lints in-editor; eslint off; formatter owns formatting, not tsserver
37
- "oxc.enable": true,
36
+ // eslint off, and no formatter on save: formatting is a manual `bun fmt`
38
37
  "eslint.enable": false,
39
38
  "js/ts.format.enabled": false
40
39
  }
@@ -111,3 +111,19 @@ export function removalSpan(text: string, arr: Extract<Node, { kind: 'array' }>,
111
111
  }
112
112
  return [item.start, item.end]
113
113
  }
114
+
115
+ /** the spans that take several items out of one array at once, disjoint so
116
+ * they apply in any order. An item with a kept one after it goes with its own
117
+ * comma, and the removed run after the last kept item goes as one span from
118
+ * that item's end, so no comma is left dangling */
119
+ export function removalSpans(text: string, arr: Extract<Node, { kind: 'array' }>, idxs: Iterable<number>): [number, number][] {
120
+ const gone = new Set(idxs)
121
+ if (gone.size === 0) return []
122
+ const kept = arr.items.flatMap((_, i) => (gone.has(i) ? [] : [i]))
123
+ const lastKept = kept.at(-1) ?? -1
124
+ const spans: [number, number][] = []
125
+ for (const i of [...gone].sort((a, b) => a - b)) if (i < lastKept) spans.push(removalSpan(text, arr, i))
126
+ const tail = [...gone].filter((i) => i > lastKept).sort((a, b) => a - b)
127
+ if (tail.length > 0) spans.push([lastKept < 0 ? arr.items[tail[0]!]!.start : arr.items[lastKept]!.end, arr.items[tail.at(-1)!]!.end])
128
+ return spans
129
+ }