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
@@ -16,7 +16,7 @@
16
16
  // are told apart by reading the source first (`isLegacyCheckSource`).
17
17
  //
18
18
  // Skipped dirs: node_modules, dist, tmp, references (templates for other
19
- // repos), and every dot-dir.
19
+ // repos), every dot-dir, and the repo's vendored dirs (core/vendored.ts).
20
20
  import { RETIRED_CHECK_IDS } from './retired-checks.ts'
21
21
  import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
22
22
  import { basename, join, relative } from 'path'
@@ -24,6 +24,7 @@ import { check, type CheckDef, type CheckFrom } from '../check.ts'
24
24
 
25
25
  import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS, UNIT_SUFFIX, unitBase } from './check-file-names.ts'
26
26
  import { unitKind } from '../shipkit-files.ts'
27
+ import { readVendored } from './vendored-read.ts'
27
28
 
28
29
  export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
29
30
 
@@ -56,23 +57,27 @@ export type DiscoveredCheck = {
56
57
  from: CheckFrom
57
58
  }
58
59
 
59
- function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
60
+ function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[], prune?: ReadonlySet<string>): void {
60
61
  dirs?.push(dir)
61
62
  let entries
62
63
  try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
63
64
  for (const e of entries) {
64
65
  if (e.isDirectory()) {
65
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
66
+ const sub = join(dir, e.name)
67
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.') && prune?.has(sub) !== true) walk(sub, out, suffix, dirs, prune)
66
68
  } else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
67
69
  }
68
70
  }
69
71
 
72
+ /** the absolute vendored dirs of a repo, which no repo walk enters */
73
+ const vendoredPrune = (root: string): ReadonlySet<string> => new Set(readVendored(root).map((d) => join(root, d)))
74
+
70
75
  /** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
71
76
  * `dirs` collects every dir the walk read: a file added to one moves its
72
77
  * mtime, which is how a reader tells the list moved without walking again */
73
78
  export function repoUnitFiles(root: string, dirs?: string[]): string[] {
74
79
  const out: string[] = []
75
- walk(root, out, UNIT_SUFFIX, dirs)
80
+ walk(root, out, UNIT_SUFFIX, dirs, vendoredPrune(root))
76
81
  return out.sort()
77
82
  }
78
83
 
@@ -97,7 +102,7 @@ export async function importFresh(path: string): Promise<unknown> {
97
102
  * Sorted. */
98
103
  export function repoCheckFiles(root: string): string[] {
99
104
  const out: string[] = []
100
- walk(root, out)
105
+ walk(root, out, SUFFIX, undefined, vendoredPrune(root))
101
106
  return out.sort()
102
107
  }
103
108
 
@@ -158,50 +163,94 @@ function unloadable(path: string, message: string): CheckDef {
158
163
  }
159
164
  }
160
165
 
166
+ /** a built-in that does not load, as a repo that only CONSUMES the toolkit
167
+ * sees it: one warning, never a red gate, since only the toolkit's own repo
168
+ * can fix the file, and its gate stays red on it */
169
+ function brokenBuiltin(path: string, message: string): CheckDef {
170
+ return {
171
+ ...unloadable(path, message),
172
+ blocking: false,
173
+ run: (c) => c.warn(`${basename(path)} does not load, so this gate skips it: ${message.replace(`${path}: `, '')}`, 'the toolkit repo owns the file, and its own gate fails on it'),
174
+ }
175
+ }
176
+
177
+ const BUILTIN_OWNER = 'build/shipkit/built-in-checks.md'
178
+
161
179
  /** every check this repo can run: its own files, plus the toolkit's. Throws
162
180
  * on a registration nobody can act on (a duplicate id, a repo check squatting
163
181
  * the reserved prefix, a built-in with no owner page), so a broken
164
- * declaration dies at load rather than half way through a gate. */
165
- export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
182
+ * declaration dies at load rather than half way through a gate. A broken
183
+ * built-in is that only in the toolkit's own repo: elsewhere it warns. */
184
+ export type DiscoverOpts = {
185
+ /** a file not to import at all: the pre-commit gate skips the files its
186
+ * declarations table already knows run no onPreCommit body */
187
+ skip?: (path: string) => boolean
188
+ /** every import, timed: `def` is null for a unit file of another kind,
189
+ * and `loaded` is false for a file that did not load */
190
+ imported?: (path: string, ms: number, def: CheckDef | null, loaded: boolean) => void
191
+ /** the toolkit's checks dir, a parameter for tests only */
192
+ builtinDir?: string
193
+ }
194
+
195
+ export async function discoverChecks(root: string, o: DiscoverOpts = {}): Promise<DiscoveredCheck[]> {
196
+ const builtinDir = o.builtinDir ?? BUILTIN_CHECK_DIR
166
197
  const builtins: string[] = []
167
- walk(BUILTIN_CHECK_DIR, builtins)
198
+ walk(builtinDir, builtins)
168
199
  const found: string[] = []
169
- walk(root, found)
200
+ walk(root, found, SUFFIX, undefined, vendoredPrune(root))
170
201
  const units = repoUnitFiles(root)
171
202
  // the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
172
203
  // discovered twice is a duplicate id rather than a second check. Real paths,
173
204
  // because a consumer reaches the same files through a linked node_modules
174
205
  const real = (f: string): string => { try { return realpathSync(f) } catch { return f } }
175
206
  const shipped = new Set(builtins.map(real))
207
+ // its own source, never an install of it
208
+ const inRoot = relative(real(root), real(builtinDir))
209
+ const hostsToolkit = !inRoot.startsWith('..') && !inRoot.split(/[\\/]/).includes('node_modules')
176
210
  const all = found.filter((f) => !shipped.has(real(f))).sort()
177
211
  builtins.sort()
178
212
  const names = fallbackNames(root, all)
179
213
  // a legacy script is left to its own reader, and never imported here
180
- const own = all.filter((f) => !isLegacyCheckFile(f))
214
+ const own = all.filter((f) => o.skip?.(f) === true || !isLegacyCheckFile(f))
181
215
  const base = (f: string): string => basename(f).slice(0, -SUFFIX.length)
182
216
 
183
217
  const out: DiscoveredCheck[] = []
184
218
  const ids = new Set<string>()
185
219
  for (const f of [...builtins, ...own, ...units]) {
186
220
  const isBuiltin = builtins.includes(f)
221
+ if (o.skip?.(f) === true) continue
187
222
  let def: CheckDef | null
223
+ let loadError: string | null = null
224
+ const t = performance.now()
188
225
  try {
189
226
  def = await load(f)
227
+ o.imported?.(f, performance.now() - t, def, true)
190
228
  } catch (e) {
229
+ o.imported?.(f, performance.now() - t, null, false)
191
230
  // a file that does not load is ONE red row, never a dead inventory:
192
231
  // every other check still runs, and this one says why in every run
193
- def = unloadable(f, (e as Error).message)
232
+ loadError = (e as Error).message
233
+ def = unloadable(f, loadError)
194
234
  }
195
235
  if (def == null) continue
196
236
  const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
197
237
  const fallback = isBuiltin
198
238
  ? (BUILTIN_ID_RE.exec(base(f))?.[1] ?? base(f))
199
239
  : f.endsWith(UNIT_SUFFIX) ? unitBase(f) : names.get(f)!
200
- const id = def.id ?? fallback
240
+ let id = def.id ?? fallback
201
241
  if (isBuiltin) {
202
- if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
203
- if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
204
- if (RETIRED_CHECK_IDS[id] != null) throw new Error(`${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`)
242
+ const refused = loadError != null ? null
243
+ : id !== fallback ? `${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`
244
+ : (def.owner ?? '') === '' ? `${f}: a built-in check names the page that OWNS the invariant`
245
+ : RETIRED_CHECK_IDS[id] != null ? `${f}: id '${id}' is retired (${RETIRED_CHECK_IDS[id]}), and an id is never reused`
246
+ : null
247
+ // the toolkit's own repo dies on a refused built-in, a consumer
248
+ // only warns: it cannot fix the file, and must not lose its gate
249
+ if (refused != null && hostsToolkit) throw new Error(refused)
250
+ if (refused != null || loadError != null) {
251
+ def = hostsToolkit ? { ...def, owner: BUILTIN_OWNER } : { ...brokenBuiltin(f, refused ?? loadError!), owner: BUILTIN_OWNER }
252
+ id = fallback
253
+ }
205
254
  } else if (id.startsWith('SK')) {
206
255
  throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
207
256
  }
@@ -0,0 +1,5 @@
1
+ // does a gate run its repair pass (core/run-fixers.ts). A leaf, so the hub's
2
+ // run lanes read the same answer the gate acts on without loading the gate.
3
+
4
+ /** `fix` named wins. Unnamed: a whole gate repairs, a narrowed run does not */
5
+ export const fixPassRuns = (o: { fix?: boolean; onlyIds?: readonly string[] }): boolean => o.fix ?? (o.onlyIds ?? []).length === 0
@@ -20,10 +20,14 @@ import type { CheckCtx } from './check-ctx.ts'
20
20
  import { checkUnitReader, type UnitReader } from '../release/unit-resolve.ts'
21
21
  import { parseJsonc } from '../ts/jsonc.ts'
22
22
  import { resolveTypecheckProjects } from '../ts/tsconfig-resolve.ts'
23
+ import { gateBuildInfoArgs } from '../ts/typecheck-cache.ts'
23
24
  import { parseBunTestScript, suiteFlags } from '../suite/judge-suites.ts'
24
25
  import { declaredTestUnits, siblingUnits, testCheckId, unitIgnores, unitTestBody, unitTsconfig, type TestUnitDecl } from '../suite/test-units.ts'
25
26
  import { describeFirst, type TaskDef } from '../check.ts'
26
27
  import type { RepoConfig } from '../repo-config.ts'
28
+ import { repoOff } from './when.ts'
29
+ import { bunfigDeclares, bunLintCmd } from './bun-builtins.ts'
30
+ import { normalizeVendored } from './vendored.ts'
27
31
 
28
32
  /** how the floor reads the repo, by repo-relative path, plus the one lookup
29
33
  * outside it: a binary on PATH */
@@ -64,10 +68,10 @@ export const unitTestDescription = (u: { id: string; rel: string }): string =>
64
68
  export const unitTypecheckDescription = (u: { id: string; rel: string }): string =>
65
69
  describeFirst(`tsc finds no type error under ${u.rel}`, `tsc finds no type error in unit ${u.id}`)
66
70
 
67
- export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'unit'
71
+ export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)' | 'built-in (bun)' | 'unit'
68
72
  export type FloorResolution = {
69
73
  tasks: TaskDef[]
70
- /** slots disabled by `defaults: { x: false }` — always printed */
74
+ /** slots the repo turned off (core/when.ts repoOff), always printed */
71
75
  disabled: string[]
72
76
  /** slots nothing could fill — always printed, never silent */
73
77
  unfilled: { id: string; reason: string }[]
@@ -160,9 +164,14 @@ function tsconfigChain(rel: string, io: FloorReader): string[] {
160
164
  }
161
165
 
162
166
  // zero-config floor (O6): with no script, imports:check/cycles resolve to
163
- // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc. Returns
164
- // the task, or the reason the slot stays empty.
165
- function builtinFloorTask(id: string, root: string, io: FloorReader): { task: TaskDef; source: FloorSource } | string {
167
+ // shipkit's own rvlib-ts-tools dep, typecheck to the repo's tsc, lint to
168
+ // `bun lint` once bunfig.toml declares [lint]. Returns the task, or the
169
+ // reason the slot stays empty.
170
+ function builtinFloorTask(id: string, root: string, io: FloorReader, vendored: readonly string[]): { task: TaskDef; source: FloorSource } | string {
171
+ if (id === 'lint') {
172
+ if (!bunfigDeclares(io.text('bunfig.toml'), 'lint')) return `no 'lint' script and no [lint] section in bunfig.toml`
173
+ return { task: { id, cmd: bunLintCmd(vendored) }, source: 'built-in (bun)' }
174
+ }
166
175
  if (id === 'imports:check' || id === 'cycles') {
167
176
  let cli: string
168
177
  // shipkit's OWN module, resolved from this file: no dependency to be
@@ -194,7 +203,7 @@ function builtinFloorTask(id: string, root: string, io: FloorReader): { task: Ta
194
203
  const projects = resolveTypecheckProjects(tsconfig, io, root)
195
204
  plain = projects.projects.length === 1 && projects.projects[0] === tsconfig && projects.missing.length === 0
196
205
  } catch { plain = false }
197
- if (plain) return { task: { id, cmd: [tsc, '--noEmit'] }, source: 'built-in (tsc)' }
206
+ if (plain) return { task: { id, cmd: [tsc, '--noEmit', ...gateBuildInfoArgs(tsconfig)] }, source: 'built-in (tsc)' }
198
207
  const cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/typecheck-cli.ts'))
199
208
  return { task: { id, cmd: ['bun', cli, tsc, tsconfig] }, source: 'built-in (tsc)' }
200
209
  }
@@ -233,7 +242,8 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
233
242
  const notes: string[] = []
234
243
  const subs = declaredTestUnits(c.units)
235
244
  for (const id of FLOOR_IDS) {
236
- const override = c.defaults?.[id]
245
+ // a slot the repo turned off resolves to no task, whichever spelling said so
246
+ const override = repoOff(c, id) != null ? false : c.defaults?.[id]
237
247
  const description = slotDescription(id, subs.length > 0)
238
248
  const slot = slotBody(id, override, scripts)
239
249
  if (slot.kind === 'off') { disabled.push(id); continue }
@@ -249,7 +259,7 @@ export function resolveFloor(c: RepoConfig, root: string, io: FloorReader): Floo
249
259
  return override == null || override === false ? t : { ...t, ...override, id }
250
260
  }
251
261
  if (slot.kind === 'script') { tasks.push(decorate({ id, cmd: scriptCmd(c, id, slot.name, slot.body, notes, subs) })); sources[id] = 'package.json script'; continue }
252
- const b = builtinFloorTask(id, root, io)
262
+ const b = builtinFloorTask(id, root, io, (c.vendored ?? []).map(normalizeVendored))
253
263
  // a routed script with nothing behind it measures NOTHING, and the
254
264
  // reason must say that rather than `no '<id>' script`, which is false
255
265
  if (typeof b === 'string') unfilled.push({ id, reason: slot.kind === 'routed-empty' ? `script '${id}' routes to shipkit and there is no '${rawName(id)}' body to run` : b })
@@ -58,14 +58,23 @@ export function probeGitRoot(at: string): Probe {
58
58
  return p
59
59
  }
60
60
 
61
- /** the variables that let git find a repo with no `.git` in reach */
61
+ /** the variables that let git find a repo with no `.git` in reach. They name
62
+ * ONE repo for the whole process (git sets GIT_DIR for every hook in a linked
63
+ * worktree), so they only answer for a directory with no `.git` of its own:
64
+ * any other directory is asked with them stripped, or the hook's repo would
65
+ * claim the house and every repo the process looks at */
62
66
  const LOCATING_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR']
67
+ const locatingEnvSet = (): boolean => LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')
68
+ const discoveryEnv = (): Record<string, string | undefined> => {
69
+ const env = { ...process.env }
70
+ for (const k of LOCATING_ENV) delete env[k]
71
+ return env
72
+ }
63
73
 
64
- /** git's discovery walks up looking for `.git`. With none at or above and no
65
- * variable pointing elsewhere, its answer is already known, and a gate or a
66
- * test suite that probes fresh temp dirs would spawn a process per dir for it */
74
+ /** git's discovery walks up looking for `.git`. With none at or above, its
75
+ * answer is already known, and a gate or a test suite that probes fresh temp
76
+ * dirs would spawn a process per dir for it */
67
77
  function nothingInReach(dir: string): boolean {
68
- if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return false
69
78
  let cur: string
70
79
  try {
71
80
  cur = realpathSync(dir)
@@ -81,12 +90,11 @@ function nothingInReach(dir: string): boolean {
81
90
  }
82
91
 
83
92
  /** the common case answered from the disk: `.git` is a plain directory right
84
- * at `dir`, which git describes as exactly that. Anything unusual (a locating
85
- * variable, a gitfile or a symlinked `.git`, a shared common dir, a config
86
- * that relocates the work tree, marks it bare or includes another file, a
87
- * `.git` owned by someone else) is left to git */
93
+ * at `dir`, which git describes as exactly that. Anything unusual (a gitfile
94
+ * or a symlinked `.git`, a shared common dir, a config that relocates the
95
+ * work tree, marks it bare or includes another file, a `.git` owned by
96
+ * someone else) is left to git */
88
97
  function plainRoot(dir: string): GitRoot | null {
89
- if (LOCATING_ENV.some((k) => (process.env[k] ?? '') !== '')) return null
90
98
  try {
91
99
  const top = realpathSync(dir)
92
100
  const gitDir = join(top, '.git')
@@ -102,8 +110,9 @@ function plainRoot(dir: string): GitRoot | null {
102
110
  }
103
111
 
104
112
  function ask(dir: string): Probe {
105
- if (nothingInReach(dir)) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
106
- const plain = plainRoot(dir)
113
+ const none = nothingInReach(dir)
114
+ if (none && !locatingEnvSet()) return { root: null, error: 'fatal: not a git repository (or any of the parent directories): .git' }
115
+ const plain = none ? null : plainRoot(dir)
107
116
  if (plain != null) return { root: plain, error: '' }
108
117
  let p: ReturnType<typeof Bun.spawnSync>
109
118
  try {
@@ -111,6 +120,7 @@ function ask(dir: string): Probe {
111
120
  cwd: dir,
112
121
  stdout: 'pipe',
113
122
  stderr: 'pipe',
123
+ env: none ? { ...process.env } : discoveryEnv(),
114
124
  })
115
125
  } catch (e) {
116
126
  return { root: null, error: e instanceof Error ? e.message : String(e) }
@@ -4,14 +4,14 @@
4
4
  // the machine's one background turn here, so the turn outlives a daemon
5
5
  // reload the same as the run does.
6
6
  import { basename } from 'path'
7
- import { useOneLib } from './one-lib.ts'
7
+ import { installOneLib } from './one-lib.ts'
8
8
  import { appendJobCheck, type JobKind, type JobOpts } from './jobs.ts'
9
9
  import { autoGatePool, takeSlot } from './slots.ts'
10
10
  import type { CheckEvent } from './check-runner.ts'
11
11
 
12
12
  export async function runJob(root: string, id: string, kind: JobKind, job: JobOpts, print: (l: string) => void): Promise<number> {
13
13
  // before the repo config loads: it imports this copy of the lib, never its own install
14
- if (job.oneLib === true) useOneLib()
14
+ if (job.oneLib === true) installOneLib()
15
15
  const turn = job.turn === true ? await takeSlot(autoGatePool(), { repo: basename(root), check: 'auto gate' }, { pollMs: 250 }) : null
16
16
  try {
17
17
  const onCheck = (e: CheckEvent): void => appendJobCheck(root, id, e)
@@ -19,6 +19,9 @@ export const LAYOUT = {
19
19
  identity: sk('identity'),
20
20
  assets: sk('assets'),
21
21
  comfy: sk('comfy'),
22
+ // a bug's repro test once its fix lands: kept for the record, never run
23
+ // (bun test skips dot dirs), moved into the suite only when it earns its wait
24
+ repros: sk('repros'),
22
25
  // local: drafts, private notes, venue listings until they go live
23
26
  private: sk('private'),
24
27
  reflections: sk('reflections'),
@@ -41,7 +44,7 @@ export const LAYOUT = {
41
44
  } as const
42
45
 
43
46
  /** the children the SK004 block re-includes: everything else under `.shipkit/` stays local */
44
- export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy] as const
47
+ export const TRACKED_DIRS = [LAYOUT.asks, LAYOUT.hooks, LAYOUT.identity, LAYOUT.assets, LAYOUT.comfy, LAYOUT.repros] as const
45
48
 
46
49
  /** the machine-global private folder, under $HOME: one keyword list and one
47
50
  * allowlist per machine, never copied into a repo */
@@ -221,3 +221,43 @@ export function dropConfigKey(text: string, key: string): string | null {
221
221
  while (from > 0 && lines[from - 1]!.trim().startsWith('//') && !/^\/\/ (>>>|<<<) shipkit/.test(lines[from - 1]!.trim())) from--
222
222
  return [...lines.slice(0, from), ...lines.slice(end + 1)].join('\n')
223
223
  }
224
+
225
+ const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
226
+
227
+ /** the config text without one entry of a top level object key, as in
228
+ * `disable` inside `hooks` or `lint: false` inside `defaults`, plus the words
229
+ * of the comment written right above the entry, which are the closest thing
230
+ * to a reason the entry ever had. `value` is the entry's value as a regex
231
+ * source, so only the shape the caller expects is touched. The parent goes
232
+ * too once nothing is left in it. Null when the parent or the entry is not
233
+ * there exactly once, or sits in a shape this reader does not follow */
234
+ export function dropConfigEntry(text: string, parent: string, entry: string, value: string): { text: string; comment: string | null } | null {
235
+ const lines = text.split('\n')
236
+ const at = lines.map((l, i) => (new RegExp(`^\\s*${esc(parent)}\\s*:`).test(l) ? i : -1)).filter((i) => i !== -1)
237
+ if (at.length !== 1) return null
238
+ const start = at[0]!
239
+ const end = statementEnd(lines, start)
240
+ const key = `(['"]?)${esc(entry)}\\1\\s*:\\s*(?:${value})`
241
+ if (start === end) {
242
+ // one line: `hooks: { blockedPaths: [...], disable: ['leak-guard'] },`
243
+ const line = lines[start]!
244
+ const hits = line.match(new RegExp(`(?<![\\w$])${key}`, 'g')) ?? []
245
+ if (hits.length !== 1) return null
246
+ let next = line.replace(new RegExp(`,\\s*${key}`), '')
247
+ if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}\\s*,\\s*`), '')
248
+ if (next === line) next = line.replace(new RegExp(`(?<![\\w$])${key}`), '')
249
+ const out = [...lines.slice(0, start), next, ...lines.slice(start + 1)].join('\n')
250
+ return { text: /:\s*\{\s*\}\s*,?\s*(\/\/.*)?$/.test(next) ? (dropConfigKey(out, parent) ?? out) : out, comment: null }
251
+ }
252
+ const inner = lines.map((l, i) => (i > start && i <= end && new RegExp(`^\\s*${key}\\s*,?\\s*$`).test(l) ? i : -1)).filter((i) => i !== -1)
253
+ if (inner.length !== 1) return null
254
+ const i = inner[0]!
255
+ let from = i
256
+ while (from - 1 > start && lines[from - 1]!.trim().startsWith('//')) from--
257
+ const comment = lines.slice(from, i).map((l) => l.trim().replace(/^\/\/\s?/, '')).join(' ').trim()
258
+ const kept = [...lines.slice(0, from), ...lines.slice(i + 1)]
259
+ const body = kept.slice(start + 1, end - (i - from + 1))
260
+ const empty = body.every((l) => l.trim() === '' || l.trim().startsWith('//'))
261
+ const out = kept.join('\n')
262
+ return { text: empty ? (dropConfigKey(out, parent) ?? out) : out, comment: comment === '' ? null : comment }
263
+ }
@@ -102,7 +102,7 @@ const LOADERS: Record<string, 'ts' | 'tsx' | 'js' | 'jsx'> = { '.ts': 'ts', '.ts
102
102
  let installed = false
103
103
 
104
104
  /** route every import of the package to this process's copy. Idempotent */
105
- export function useOneLib(root: string = LIB_ROOT): void {
105
+ export function installOneLib(root: string = LIB_ROOT): void {
106
106
  if (installed) return
107
107
  installed = true
108
108
  const exports = readExports(root)
@@ -119,7 +119,9 @@ export function useOneLib(root: string = LIB_ROOT): void {
119
119
  build.onLoad({ filter: /\/(node_modules\/rvlib-shipkit|install\/cache\/rvlib-shipkit@[^/]*)\// }, (a) => {
120
120
  const loader = LOADERS[extname(a.path)] ?? 'ts'
121
121
  const sub = foreignLibPath(a.path, root)
122
- if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader }
122
+ // the host copy itself lives under node_modules when it is an
123
+ // install: its path can carry importFresh's `?v=` stamp
124
+ if (sub == null) return { contents: readFileSync(a.path.replace(/\?.*$/, ''), 'utf8'), loader }
123
125
  return reexport(join(root, sub))
124
126
  })
125
127
  for (const name of SHARED) {
@@ -131,7 +133,7 @@ export function useOneLib(root: string = LIB_ROOT): void {
131
133
  const skip = [...new Set([host, real(host)])].map((h) => `(?!${esc(h)}/)`).join('')
132
134
  build.onLoad({ filter: new RegExp(`^${skip}.*/node_modules/${esc(name)}/`) }, (a) => {
133
135
  const sub = foreignSharedPath(a.path, name, host)
134
- if (sub == null) return { contents: readFileSync(a.path, 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
136
+ if (sub == null) return { contents: readFileSync(a.path.replace(/\?.*$/, ''), 'utf8'), loader: LOADERS[extname(a.path)] ?? 'js' }
135
137
  return reexport(sharedTarget(a.path, sub, name, host))
136
138
  })
137
139
  }
@@ -14,7 +14,7 @@
14
14
  // The deny is the floor and the hook is the messenger. When the hook cannot
15
15
  // run, enforcement degrades to terse rather than to nothing.
16
16
  //
17
- // Subprocesses are NOT intercepted (verified): shipkit spawning oxlint or
17
+ // Subprocesses are NOT intercepted (verified): shipkit spawning tsc or
18
18
  // `bun run test:raw` from inside a permitted `shipkit ci` is invisible to
19
19
  // these rules, which is exactly what makes the gate itself still work.
20
20
 
@@ -24,7 +24,7 @@ export type RawRunner = {
24
24
  /** the floor slot it answers, so ONE exemption governs the script name and
25
25
  * the shell command together: a repo that keeps `test` raw is not then
26
26
  * refused `bun test` */
27
- slot: 'test' | 'lint' | 'typecheck'
27
+ slot: 'test' | 'typecheck'
28
28
  /** the shipkit command that answers the same question, cache and all */
29
29
  use: string
30
30
  /** the narrower way in, when there is one: iterating on a single repro
@@ -47,25 +47,118 @@ export const RAW_RUNNERS: readonly RawRunner[] = [
47
47
  { head: 'bunx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
48
48
  { head: 'npx tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
49
49
  { head: 'tsc', slot: 'typecheck', use: 'shipkit ci --only typecheck' },
50
- { head: 'bunx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
51
- { head: 'npx oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
52
- { head: 'oxlint', slot: 'lint', use: 'shipkit ci --only lint' },
53
- { head: 'npx eslint', slot: 'lint', use: 'shipkit ci --only lint' },
54
- { head: 'eslint', slot: 'lint', use: 'shipkit ci --only lint' },
55
50
  ]
56
51
 
57
- // the FORMATTER is deliberately absent, and that is not an oversight. It
58
- // answers no cached question and produces no green anyone could mistake for a
59
- // verification, shipkit's own fix pass runs it BY SCRIPT (`bun run fmt`), and
60
- // denying the bare binary while that stays open would be theatre. `shipkit ci`
61
- // formats before it gates, which is the only reason the reflex matters at all.
52
+ // the LINTER and the FORMATTER are deliberately absent. `bun lint` and
53
+ // `bun fmt` are the house way in: bun's built-ins cover a whole repo in well
54
+ // under a second, so a cache has nothing to save, and the formatter is a
55
+ // manual tool the gate never runs.
62
56
 
63
57
  /** the `permissions.deny` entries, in the syntax Claude Code matches:
64
58
  * `Bash(<head>*)` is a prefix match, and a compound command is checked one
65
59
  * segment at a time, so `cd x && bun test` is caught too */
66
60
  export const denyRules = (exemptSlots: Iterable<string> = []): string[] => {
67
61
  const off = new Set(exemptSlots)
68
- return RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`)
62
+ return [...RAW_RUNNERS.filter((r) => !off.has(r.slot)).map((r) => `Bash(${r.head}*)`), GIT_STASH_RULE]
63
+ }
64
+
65
+ // `git stash` in a tree that other agents share takes every agent's
66
+ // uncommitted work, not just the caller's (identity/how-we-commit.md: never
67
+ // stash as a side effect). The deny row is the floor. Claude Code matches it
68
+ // per subcommand of a compound and inside `$(...)`, and never inside
69
+ // `bash -c '...'`, after git's own global options (`git -C dir stash`), or
70
+ // through `eval`: the guard closes those (stashCallIn).
71
+ export const GIT_STASH_HEAD = 'git stash'
72
+ export const GIT_STASH_RULE = `Bash(${GIT_STASH_HEAD}*)`
73
+
74
+ /** the stash subcommands that only read, which the guard lets through */
75
+ const STASH_READS = new Set(['list', 'show'])
76
+
77
+ /** git's global options that take their value as the NEXT word */
78
+ const GIT_VALUE_OPTS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path'])
79
+
80
+ const unquote = (w: string): string => w.replace(/^(['"])(.*)\1$/, '$2')
81
+
82
+ /** a segment's words, a quoted run kept as one word, subshell and group
83
+ * brackets dropped */
84
+ const quotedWords = (seg: string): string[] =>
85
+ (seg.match(/(?:'[^']*'|"(?:[^"\\]|\\.)*"|[^\s'"])+/g) ?? []).map((w) => w.replace(/^[({]+|[)}]+$/g, '')).filter((w) => w !== '')
86
+
87
+ /** the bodies of `$(...)` and backtick substitutions outside single quotes,
88
+ * where a shell runs them */
89
+ function substitutions(seg: string): string[] {
90
+ const out: string[] = []
91
+ let single = false
92
+ for (let i = 0; i < seg.length; i++) {
93
+ const ch = seg[i]!
94
+ if (ch === "'") { single = !single; continue }
95
+ if (single) continue
96
+ if (ch === '`') {
97
+ const end = seg.indexOf('`', i + 1)
98
+ if (end < 0) break
99
+ out.push(seg.slice(i + 1, end))
100
+ i = end
101
+ } else if (ch === '$' && seg[i + 1] === '(') {
102
+ let depth = 0
103
+ let j = i + 1
104
+ for (; j < seg.length; j++) {
105
+ if (seg[j] === '(') depth++
106
+ else if (seg[j] === ')' && --depth === 0) break
107
+ }
108
+ out.push(seg.slice(i + 2, j))
109
+ i = j
110
+ }
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** the script a segment hands to another shell: `bash -c '<script>'`,
116
+ * `sh -c`, `zsh -c`, or `eval <words>` */
117
+ function innerScript(words: string[], seg: string): string | null {
118
+ const program = words[0]?.split('/').pop() ?? ''
119
+ if (program === 'eval') {
120
+ const all = quotedWords(seg)
121
+ return all.slice(all.indexOf(words[0]!) + 1).map(unquote).join(' ')
122
+ }
123
+ if (!/^(?:ba|z|da|k)?sh$/.test(program)) return null
124
+ const m = /\s-[a-z]*c[a-z]*\s+(?:'([^']*)'|"((?:[^"\\]|\\.)*)"|(\S+))/.exec(seg)
125
+ return m == null ? null : (m[1] ?? m[2] ?? m[3] ?? null)
126
+ }
127
+
128
+ /** the segment of a command that runs a stash which is not a read, or null.
129
+ * Like rawRunnerHit it reads only what a shell would RUN: a heredoc body and
130
+ * a quoted argument are data, so a commit message that mentions the command
131
+ * is never refused. */
132
+ export function stashCallIn(command: string, depth = 0): string | null {
133
+ if (depth > 3) return null
134
+ for (const seg of shellSegments(command)) {
135
+ for (const inner of substitutions(seg)) {
136
+ const hit = stashCallIn(inner, depth + 1)
137
+ if (hit != null) return hit
138
+ }
139
+ const words = programWords(seg)
140
+ const script = innerScript(words, seg)
141
+ if (script != null) {
142
+ const hit = stashCallIn(script, depth + 1)
143
+ if (hit != null) return hit
144
+ continue
145
+ }
146
+ if ((words[0]?.split('/').pop() ?? '') !== 'git') continue
147
+ // re-split with quotes kept whole: `git -C "a dir" stash`
148
+ const all = quotedWords(seg)
149
+ const g = all.findIndex((w) => w.split('/').pop() === 'git')
150
+ const args = all.slice(g + 1)
151
+ let i = 0
152
+ while (i < args.length && args[i]!.startsWith('-')) i += GIT_VALUE_OPTS.has(args[i]!) ? 2 : 1
153
+ if (unquote(args[i] ?? '') !== 'stash') continue
154
+ if (!STASH_READS.has(unquote(args[i + 1] ?? ''))) return seg.trim()
155
+ }
156
+ return null
157
+ }
158
+
159
+ /** what a stash refusal says: why, what to do instead, how to lift it */
160
+ export function stashRefusal(segment: string): string {
161
+ return `\`${segment.slice(0, 80)}\` stashes: in a tree that other agents share, a stash takes every agent's uncommitted work, not just yours. Set your own work aside with a commit by pathspec (git commit -m … -- <paths>), and read an existing stash by revision: \`git log -g --oneline refs/stash\`, \`git show stash@{0}:<file>\`. Lifting this: delete the "${GIT_STASH_RULE}" line from .claude/settings.json permissions.deny (identity/how-we-commit.md).`
69
162
  }
70
163
 
71
164
  /** what a refusal says. Never just the ban: a refusal with no replacement