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
@@ -1,10 +1,8 @@
1
1
  // `shipkit serve` while a home runs: the hub itself runs in the home, and
2
- // this process only streams its lines. So the VS Code folder-open task keeps its
3
- // problem matcher and its clickable url, with no change to any repo's
4
- // tasks.json, and closing VS Code no longer stops the hub.
2
+ // this process only streams its lines, so stopping it never stops the hub.
5
3
  //
6
- // the log is what the home prints for that hub, so the frames the matcher
7
- // reads arrive as the hub prints them. On attach, the last complete frame is replayed first,
4
+ // the log is what the home prints for that hub, so the frames arrive as the
5
+ // hub prints them. On attach, the last complete frame is replayed first,
8
6
  // so the problems pane fills at once instead of at the next run.
9
7
  import { closeSync, existsSync, openSync, readSync, statSync } from 'fs'
10
8
  import { FRAME_BEGIN, FRAME_END_HEAD } from '../problems/problem-line.ts'
@@ -130,6 +130,7 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
130
130
 
131
131
  func applicationDidFinishLaunching(_ note: Notification) {
132
132
  if CommandLine.arguments.contains("--self-test-find") { return selfTestFind() }
133
+ if CommandLine.arguments.contains("--self-test-panels") { return selfTestPanels() }
133
134
  buildMenu()
134
135
  // esc closes the find bar of the key window, and reaches the page otherwise
135
136
  NSEvent.addLocalMonitorForEvents(matching: .keyDown) { e in
@@ -267,6 +268,61 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
267
268
  web.loadHTMLString("<p>one needle, two needle</p>", baseURL: nil)
268
269
  }
269
270
 
271
+ /** hears every js panel once it shows: the self test answers it */
272
+ var onPanel: ((NSAlert) -> Void)?
273
+
274
+ // `--self-test-panels`: a page in an offscreen window calls prompt, confirm
275
+ // and alert, answered OK after typing over the prompt's text, then again,
276
+ // answered with esc. What the page got printed, exit 0 only when every
277
+ // answer reached it. Return is checked as OK being the sheet's default
278
+ // button: a synthetic return reaches it only in a key window, and the test
279
+ // must not take the focus
280
+ func selfTestPanels() {
281
+ let web = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
282
+ web.navigationDelegate = self
283
+ web.uiDelegate = self
284
+ let win = NSWindow(contentRect: NSRect(x: -4000, y: -4000, width: 600, height: 400), styleMask: [.titled], backing: .buffered, defer: false)
285
+ win.contentView = Pane(web: web)
286
+ win.orderFrontRegardless()
287
+ windows.append(win)
288
+ var accept = true
289
+ var panels = 0
290
+ var returnIsOK = true
291
+ var escHeard = true
292
+ onPanel = { alert in
293
+ panels += 1
294
+ DispatchQueue.main.async {
295
+ if alert.window.defaultButtonCell !== alert.buttons[0].cell { returnIsOK = false }
296
+ if accept {
297
+ // typing replaces the default text only when it is selected in an active editor
298
+ if let field = alert.accessoryView as? NSTextField { (field.currentEditor() as? NSTextView)?.insertText("typed", replacementRange: NSRange(location: NSNotFound, length: 0)) }
299
+ return alert.buttons[0].performClick(nil)
300
+ }
301
+ if alert.buttons.count == 1 { return alert.buttons[0].performClick(nil) }
302
+ let esc = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: alert.window.windowNumber, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)
303
+ if let esc, alert.window.performKeyEquivalent(with: esc) { return }
304
+ escHeard = false
305
+ alert.buttons[1].performClick(nil)
306
+ }
307
+ }
308
+ let js = "JSON.stringify([prompt('name', 'x'), confirm('ok?'), (alert('hi'), 'alerted')])"
309
+ onLoad = { _ in
310
+ web.evaluateJavaScript(js) { a, _ in
311
+ accept = false
312
+ web.evaluateJavaScript(js) { b, _ in
313
+ let ok = (a as? String) ?? "none", cancel = (b as? String) ?? "none"
314
+ print("panels=\(panels)")
315
+ print("return-is-ok=\(returnIsOK)")
316
+ print("esc-heard=\(escHeard)")
317
+ print("ok=\(ok)")
318
+ print("cancel=\(cancel)")
319
+ exit(panels == 6 && returnIsOK && escHeard && ok == "[\"typed\",true,\"alerted\"]" && cancel == "[null,false,\"alerted\"]" ? 0 : 1)
320
+ }
321
+ }
322
+ }
323
+ web.loadHTMLString("<p>panels</p>", baseURL: nil)
324
+ }
325
+
270
326
  func windowWillClose(_ note: Notification) {
271
327
  windows.removeAll { $0 === note.object as? NSWindow }
272
328
  }
@@ -293,6 +349,46 @@ final class Delegate: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKU
293
349
  return nil
294
350
  }
295
351
 
352
+ // alert, confirm and prompt: without these WebKit answers each one at once
353
+ // (no panel, confirm false, prompt null), so a page's dialogs do nothing.
354
+ // WebKit throws when a handler is dropped or called twice: `panel` calls
355
+ // `done` exactly once on every path
356
+
357
+ /** one js panel: a sheet on the page's window, a modal where there is
358
+ * none. Return is OK, esc is Cancel; `done` hears whether OK was pressed */
359
+ func panel(_ web: WKWebView, _ message: String, cancel: Bool, field: NSTextField? = nil, done: @escaping (Bool) -> Void) {
360
+ let alert = NSAlert()
361
+ alert.messageText = message
362
+ alert.addButton(withTitle: "OK").keyEquivalent = "\r"
363
+ if cancel { alert.addButton(withTitle: "Cancel").keyEquivalent = "\u{1b}" }
364
+ if let field {
365
+ alert.accessoryView = field
366
+ alert.window.initialFirstResponder = field
367
+ }
368
+ guard let win = web.window else {
369
+ onPanel?(alert)
370
+ return done(alert.runModal() == .alertFirstButtonReturn)
371
+ }
372
+ alert.beginSheetModal(for: win) { r in done(r == .alertFirstButtonReturn) }
373
+ // first responder selects the whole default text, so typing replaces it
374
+ if let field { alert.window.makeFirstResponder(field) }
375
+ onPanel?(alert)
376
+ }
377
+
378
+ func webView(_ web: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
379
+ panel(web, message, cancel: false) { _ in completionHandler() }
380
+ }
381
+
382
+ func webView(_ web: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
383
+ panel(web, message, cancel: true) { ok in completionHandler(ok) }
384
+ }
385
+
386
+ func webView(_ web: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
387
+ let field = NSTextField(string: defaultText ?? "")
388
+ field.frame = NSRect(x: 0, y: 0, width: 300, height: 24)
389
+ panel(web, prompt, cancel: true, field: field) { ok in completionHandler(ok ? field.stringValue : nil) }
390
+ }
391
+
296
392
  func item(_ menu: NSMenu, _ title: String, _ action: Selector, _ key: String, _ mods: NSEvent.ModifierFlags = [.command], tag: Int = 0) {
297
393
  let i = menu.addItem(withTitle: title, action: action, keyEquivalent: key)
298
394
  i.keyEquivalentModifierMask = mods
@@ -11,6 +11,8 @@ import { homedir } from 'os'
11
11
  import { join } from 'path'
12
12
  import { gitBin } from '../core/git-bin.ts'
13
13
  import { boxesOf, type InsightsAnswer, type Manifest, type Scope } from './insights-shape.ts'
14
+ import { isVendored } from '../core/vendored.ts'
15
+ import { readVendored } from '../core/vendored-read.ts'
14
16
 
15
17
  export * from './insights-shape.ts'
16
18
 
@@ -40,7 +42,10 @@ async function manifestPaths(dir: string): Promise<string[]> {
40
42
  const p = Bun.spawn([gitBin(), 'ls-files', '-z', '--', 'package.json', '**/package.json'], { cwd: dir, stdout: 'pipe', stderr: 'ignore', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } })
41
43
  const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
42
44
  if (code !== 0) return existsSync(join(dir, 'package.json')) ? ['package.json'] : []
43
- return out.split('\0').filter(Boolean)
45
+ // a vendored library's manifest is its upstream's, not a package this repo uses
46
+ let vendored: string[] = []
47
+ try { vendored = readVendored(dir) } catch { /* a config the repo cannot read is its own gate's failure */ }
48
+ return out.split('\0').filter((f) => f !== '' && !isVendored(f, vendored))
44
49
  }
45
50
 
46
51
  /** one repo's manifests, read; a file that does not parse is an error line */
@@ -30,9 +30,9 @@ if (import.meta.main) {
30
30
  const out: PmConfig = { index: house.index, processes: [], errors: [], inputs, stamp: stampOf(inputs) }
31
31
  if (house.index != null) {
32
32
  // a unit file imports the lib by name: this copy, like the daemon's
33
- const { useOneLib } = await import('../core/one-lib.ts')
33
+ const { installOneLib } = await import('../core/one-lib.ts')
34
34
  const { scanGlobalUnits } = await import('./global-units.ts')
35
- useOneLib()
35
+ installOneLib()
36
36
  const u = await scanGlobalUnits(repos.map((r) => ({ name: r.name, path: r.path })), { files: (r) => files.get(r) ?? [] })
37
37
  out.processes = u.processes
38
38
  out.errors = u.errors
@@ -285,6 +285,9 @@ export class Resource {
285
285
  * while it answers, else it starts and opens once it does */
286
286
  async open(how: OpenHow): Promise<void> {
287
287
  if (this.decl.url == null) throw new Error(`${this.decl.id} declares no url to open`)
288
+ // the status is the last tick's: a process that died since would get
289
+ // a window on a dead port and never start
290
+ await this.tick()
288
291
  if (this.status === 'up') {
289
292
  this.show(how, this.decl.url)
290
293
  return
@@ -7,7 +7,7 @@
7
7
  // at once (lib-watch.ts): the new image follows the jobs still running. The
8
8
  // machine resources are pm's.
9
9
  import { fileURLToPath } from 'url'
10
- import { useOneLib } from '../core/one-lib.ts'
10
+ import { installOneLib } from '../core/one-lib.ts'
11
11
  import { reclaimPreviousImage } from '../core/instance.ts'
12
12
  import { reloadOnOwnCode } from './lib-watch.ts'
13
13
  import { loadHub } from '../serve/hub-runtime.ts'
@@ -47,7 +47,7 @@ export async function runDaemon(root: string, io: HomeIo, o: { port?: number } =
47
47
  }
48
48
  // before the first repo config loads: every one of them, and every check
49
49
  // file, then imports this copy of the lib
50
- useOneLib()
50
+ installOneLib()
51
51
  // a process this one starts runs its gate and its hub in process
52
52
  process.env[WORKER_ENV] = '1'
53
53
  // a restart in place keeps the pid: the check children its previous
@@ -12,8 +12,8 @@ if (import.meta.main) {
12
12
  console.error('usage: bun run-unit-action.ts <unit file> <action id>')
13
13
  process.exit(2)
14
14
  }
15
- const { useOneLib } = await import('../core/one-lib.ts')
16
- useOneLib()
15
+ const { installOneLib } = await import('../core/one-lib.ts')
16
+ installOneLib()
17
17
  const u = ((await import(file)) as { default?: GlobalProcessUnit }).default
18
18
  const run = u?.actions?.[id]?.run
19
19
  if (run == null) {
@@ -1,9 +1,7 @@
1
1
  // the commit-message predicate of the commit-msg hook, which blocks at write
2
- // time: the one guard against AI attribution (SK009 checks it is armed).
3
- //
4
- // mechanical text rules only, so this stays publishable.
2
+ // time. mechanical text rules only, so this stays publishable.
5
3
 
6
- export type OffenseKind = 'dash' | 'attribution' | 'language'
4
+ export type OffenseKind = 'dash' | 'language'
7
5
  export type Offense = { line: number; text: string; kind: OffenseKind; why: string }
8
6
 
9
7
  /** git surfaces are English, always (identity/writing-voice.md). cheap French
@@ -34,13 +32,6 @@ export function frenchHits(line: string, languageAllow: string[] = []): boolean
34
32
  return words.size >= 2
35
33
  }
36
34
 
37
- /** trailers and footers that name an assistant, assistant session trailers,
38
- * assistant share-domain urls (a session link is a louder tell than any
39
- * trailer), plus the robot emoji. the session/url family lists every
40
- * assistant, not just the one that bit first. */
41
- export const AI_ATTRIBUTION =
42
- /(?:co-authored-by|assisted-by|signed-off-by):[^\n]*\b(?:claude|anthropic|copilot|chatgpt|openai|cursor|aider|devin|gemini)\b|generated with[^\n]*\b(?:claude|copilot|chatgpt|gemini)\b|\b(?:claude|chatgpt|copilot|cursor|aider|devin|gemini|codex)-session:|\b(?:claude\.(?:ai|com)|chatgpt\.com|chat\.openai\.com)\b|🤖/i
43
-
44
35
  /** everything below git's scissors line is the verbose-mode diff, which git
45
36
  * strips before committing, diff content is not ours to police. */
46
37
  export function stripNonMessage(message: string): string {
@@ -62,8 +53,6 @@ export function commitMessageOffenses(message: string, opts?: { languageAllow?:
62
53
  const body = raw.replace(/^\s*[-*] /, '')
63
54
  if (body.includes(' - '))
64
55
  offenses.push({ line: i + 1, text: raw, kind: 'dash', why: 'inline " - " separator: same fix' })
65
- if (AI_ATTRIBUTION.test(raw))
66
- offenses.push({ line: i + 1, text: raw, kind: 'attribution', why: 'AI attribution: no assistant trailer, session trailer, claude.ai/chatgpt.com url, "Generated with" footer, or robot emoji' })
67
56
  if (frenchHits(raw, opts?.languageAllow))
68
57
  offenses.push({ line: i + 1, text: raw, kind: 'language', why: 'French: git surfaces are English, always (identity/writing-voice.md); quote product copy in "…" or `…` to exempt it' })
69
58
  })
@@ -71,4 +60,3 @@ export function commitMessageOffenses(message: string, opts?: { languageAllow?:
71
60
  }
72
61
 
73
62
  export const hasDashPunctuation = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'dash')
74
- export const hasAiAttribution = (message: string): boolean => commitMessageOffenses(message).some((o) => o.kind === 'attribution')
@@ -0,0 +1,38 @@
1
+ // THE COMMIT GATES the git hooks run, as an inventory. They run from
2
+ // hooks/run-hooks.ts, never through the runner, so the hook stays a leaf that
3
+ // loads no check discovery. This list is what every other surface reads to
4
+ // show them beside the checks (`shipkit checks`, `shipkit when`, the hub) and
5
+ // what `off` names to turn one off. Type imports only: the hook loads it.
6
+ import type { RepoConfig } from '../repo-config.ts'
7
+ import { repoOff, type RepoOff } from '../core/when.ts'
8
+
9
+ /** which git hook runs the gate */
10
+ export type HookName = 'pre-commit' | 'commit-msg' | 'both'
11
+
12
+ export type HookGate = { id: string; hook: HookName; title: string; description: string }
13
+
14
+ export const HOOK_GATES = [
15
+ { id: 'tree-shrink', hook: 'pre-commit', title: 'tree shrink', description: 'the commit keeps at least half of the files HEAD tracks' },
16
+ { id: 'leak-guard', hook: 'both', title: 'leak guard', description: 'no staged added line, staged path or commit message matches a banned keyword' },
17
+ { id: 'blocked-paths', hook: 'pre-commit', title: 'blocked paths', description: 'nothing under a hooks.blockedPaths prefix is staged' },
18
+ { id: 'root-identity', hook: 'pre-commit', title: 'root identity', description: 'the repo name in package.json and repo.config.ts holds, and no scratch file is staged' },
19
+ { id: 'commit-message', hook: 'commit-msg', title: 'commit message', description: 'the commit message carries no dash punctuation and no French' },
20
+ { id: 'commit-language', hook: 'commit-msg', title: 'commit language', description: 'the commit message carries no French, the half of commit-message that off lifts alone' },
21
+ ] as const satisfies readonly HookGate[]
22
+
23
+ export type HookGateId = (typeof HOOK_GATES)[number]['id']
24
+
25
+ /** the page that owns every commit gate */
26
+ export const HOOK_GATES_OWNER = 'build/shipkit/commit-gates.md'
27
+
28
+ /** the whole pre-commit hook, boot included, past which it warns */
29
+ export const HOOK_BUDGET_MS = 1_000
30
+
31
+ /** the repo turned this gate off, and why. commit-language is the French half
32
+ * of commit-message, so turning commit-message off turns it off too */
33
+ export function hookGateOff(c: RepoConfig, id: HookGateId): RepoOff | null {
34
+ const own = repoOff(c, id)
35
+ if (own != null || id !== 'commit-language') return own
36
+ const parent = repoOff(c, 'commit-message')
37
+ return parent == null ? null : { ...parent, reason: `commit-message is off: ${parent.reason}` }
38
+ }
@@ -285,6 +285,8 @@ export type LeakGuardOpts = {
285
285
  allow?: LeakAllowEntry[]
286
286
  /** override the machine-global allowlist path; null reads none */
287
287
  allowFile?: string | null
288
+ /** the staged paths, when the caller already listed them (hooks/staged.ts) */
289
+ stagedNames?: string[]
288
290
  }
289
291
 
290
292
  /** git c-quotes a path that carries a space or a non-ascii byte. */
@@ -368,13 +370,16 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
368
370
  }
369
371
 
370
372
  const targets: ScanTarget[] = []
371
- if (o.staged !== false) {
373
+ if (o.staged !== false && o.stagedNames?.length !== 0) {
372
374
  const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
373
375
  if (diff.exitCode === 0)
374
376
  for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
375
377
  // a filename can leak as loudly as a line of code
376
- const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
377
- if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
378
+ const names = o.stagedNames ?? (() => {
379
+ const p = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
380
+ return p.exitCode === 0 ? p.stdout.toString().split('\n') : []
381
+ })()
382
+ for (const n of names) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
378
383
  }
379
384
  for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
380
385
 
@@ -4,14 +4,15 @@
4
4
  // type instead: `permissions.deny` already blocks these, but a bare deny tells
5
5
  // the model only that it was denied, and a refusal with no replacement just
6
6
  // sends it looking for another way in. It also refuses a gate piped into
7
- // another command, whose exit code the pipe hides.
7
+ // another command, whose exit code the pipe hides, and a `git stash` in the
8
+ // forms the deny row cannot see.
8
9
  //
9
10
  // It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
10
11
  // small json read, no clipanion, no repo config. Anything it cannot decide it
11
12
  // waves through, because a guard that fails closed on its own bugs would wedge
12
13
  // a repo with no way to tell why, and `permissions.deny` is still the floor
13
14
  // underneath it. Measured at ~10ms per call.
14
- import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
15
+ import { deniedHeads, GIT_STASH_HEAD, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal, stashCallIn, stashRefusal } from '../core/one-way-in-policy.ts'
15
16
 
16
17
  const input = await Bun.stdin.text()
17
18
  let command = ''
@@ -38,6 +39,11 @@ try {
38
39
  } catch {
39
40
  process.exit(0)
40
41
  }
42
+ const stash = command !== '' && denied.has(GIT_STASH_HEAD) ? stashCallIn(command) : null
43
+ if (stash != null) {
44
+ console.error(stashRefusal(stash))
45
+ process.exit(2)
46
+ }
41
47
  const hit = command === '' || denied.size === 0 ? null : rawRunnerHit(command, denied)
42
48
  if (hit == null) process.exit(0)
43
49
  // exit 2 is the block: stderr becomes the reason the model reads
@@ -8,6 +8,7 @@
8
8
  import { existsSync } from 'fs'
9
9
  import { join } from 'path'
10
10
  import { gitBin } from '../core/git-bin.ts'
11
+ import { readStaged, type Staged } from './staged.ts'
11
12
 
12
13
  export type IdentityOutcome =
13
14
  | { status: 'skipped'; reason: string }
@@ -47,19 +48,21 @@ function identityDrift(root: string, path: string, read: (s: string) => string |
47
48
  return `${path}: name '${before}' → '${after}'`
48
49
  }
49
50
 
50
- export function runRootIdentity(root: string): IdentityOutcome {
51
- if (git(root, ['rev-parse', '--verify', 'HEAD']).code !== 0)
51
+ export function runRootIdentity(root: string, staged: Staged = readStaged(root)): IdentityOutcome {
52
+ if (!staged.head)
52
53
  return { status: 'skipped', reason: 'no HEAD yet — nothing to compare the first commit against' }
53
54
  if (process.env['SHIPKIT_ALLOW_IDENTITY_CHANGE'] === '1')
54
55
  return { status: 'skipped', reason: 'SHIPKIT_ALLOW_IDENTITY_CHANGE=1' }
55
56
 
57
+ // a file the commit does not stage is the same blob in the index and in
58
+ // HEAD, so only a staged one is worth reading
59
+ const touched = new Set(staged.names)
56
60
  const changes = [
57
- identityDrift(root, 'package.json', packageName),
58
- ...(existsSync(join(root, 'repo.config.ts')) ? [identityDrift(root, 'repo.config.ts', configName)] : []),
61
+ touched.has('package.json') ? identityDrift(root, 'package.json', packageName) : null,
62
+ touched.has('repo.config.ts') && existsSync(join(root, 'repo.config.ts')) ? identityDrift(root, 'repo.config.ts', configName) : null,
59
63
  ].filter((c): c is string => c != null)
60
64
 
61
- const added = git(root, ['diff', '--cached', '--name-only', '--diff-filter=A'])
62
- const scratch = added.out.split('\n').map((l) => l.trim()).filter((l) => l !== '' && SCRATCH.test(l))
65
+ const scratch = staged.entries.filter((e) => e.status === 'A' && SCRATCH.test(e.path)).map((e) => e.path)
63
66
 
64
67
  return changes.length === 0 && scratch.length === 0 ? { status: 'clean' } : { status: 'changed', changes, scratch }
65
68
  }
@@ -4,20 +4,24 @@
4
4
  // while its next commit is already doomed, and nothing can enumerate what
5
5
  // the hooks enforce. `shipkit hooks list` answers that without reading shell.
6
6
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
7
- import { dirname, join } from 'path'
7
+ import { dirname, join, relative } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
9
  import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
10
10
  import { commitMessageOffenses } from './commit-message.ts'
11
11
  import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
12
12
  import { runRootIdentity } from './root-identity.ts'
13
+ import { readStaged } from './staged.ts'
14
+ import { ALLOW_MASS_DELETE, runTreeShrink } from './tree-shrink.ts'
13
15
  import { gitRoot } from '../core/git-root.ts'
14
16
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
15
17
  import type { RepoConfig } from '../repo-config.ts'
16
18
  import { gitBin } from '../core/git-bin.ts'
17
- import { discoverChecks } from '../core/discover-checks.ts'
19
+ import { discoverPreCommit, SLOW_IMPORT_MS } from '../core/pre-commit-discovery.ts'
18
20
  import { CheckCtx } from '../core/check-ctx.ts'
21
+ import { CheckPass } from '../core/check-pass.ts'
19
22
  import { runCheckBody, type CheckDef } from '../check.ts'
20
23
  import { repoOff } from '../core/when.ts'
24
+ import { HOOK_BUDGET_MS, hookGateOff, type HookGateId } from './hook-gates.ts'
21
25
  import { formatLine } from '../core/wire.ts'
22
26
 
23
27
  export type HooksConfig = {
@@ -56,11 +60,11 @@ export type HooksConfig = {
56
60
  /** words the commit-message language gate never counts as French
57
61
  * (proper nouns that carry accents without being a language choice) */
58
62
  languageAllow?: string[]
59
- /** disable a built-in gate, visible in `hooks list`, never silent.
60
- * 'commit-language' lifts ONLY the French check, keeping the dash and
61
- * AI-attribution guards armed. 'commit-message' lifts the dash and French
62
- * checks; the AI-attribution guard is never lifted. */
63
- disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
63
+ /** RETIRED, read as `off` while SK029 ports it there: a commit gate is
64
+ * turned off like any check, `off: { <gate id>: '<why>' }`
65
+ * (build/shipkit/runs.md). Dies once `shipkit fleet` shows no repo
66
+ * carrying it */
67
+ disable?: HookGateId[]
64
68
  }
65
69
 
66
70
  const HOOK_DIR = LAYOUT.hooks
@@ -220,9 +224,26 @@ const draftsOpen = (c: RepoConfig): boolean => c.visibility === 'private'
220
224
  export async function runPreCommit(root: string, config: RepoConfig): Promise<GateOutcome[]> {
221
225
  const started = performance.now()
222
226
  const h = config.hooks ?? {}
227
+ const off = (id: HookGateId): boolean => hookGateOff(config, id) != null
223
228
  const out: GateOutcome[] = []
224
- if (!(h.disable ?? []).includes('leak-guard')) {
225
- const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow })
229
+ const staged = readStaged(root)
230
+ if (!off('tree-shrink')) {
231
+ const r = runTreeShrink(root, staged)
232
+ if (r.status === 'skipped') out.push({ id: 'tree-shrink', ok: true, lines: [`skip: tree-shrink (${r.reason})`] })
233
+ else if (r.status === 'clean') out.push({ id: 'tree-shrink', ok: true, lines: ['ok: tree-shrink, the commit keeps the tree'] })
234
+ else if (r.status === 'shrunk') out.push({
235
+ id: 'tree-shrink',
236
+ ok: false,
237
+ lines: [
238
+ `FAIL: this commit keeps ${r.stagedFiles} of HEAD's ${r.headFiles} files (${r.deleted} deleted), under half the tree`,
239
+ ` a commit from a private GIT_INDEX_FILE that was never filled with \`git read-tree HEAD\` deletes every file it did not stage`,
240
+ ` fix: commit from the real index by pathspec (git commit -m … -- <paths>), or fill the private index first`,
241
+ ` a deliberate mass delete: ${ALLOW_MASS_DELETE}=1 git commit ...`,
242
+ ],
243
+ })
244
+ }
245
+ if (!off('leak-guard')) {
246
+ const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow, stagedNames: staged.names })
226
247
  if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
227
248
  else if (r.status === 'clean') out.push({
228
249
  id: 'leak-guard',
@@ -246,10 +267,8 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
246
267
  ],
247
268
  })
248
269
  }
249
- if ((h.blockedPaths ?? []).length > 0) {
250
- const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
251
- const staged = names.stdout.toString().split('\n').filter(Boolean)
252
- const blocked = blockedStaged(staged, config)
270
+ if ((h.blockedPaths ?? []).length > 0 && !off('blocked-paths')) {
271
+ const blocked = blockedStaged(staged.names, config)
253
272
  out.push(blocked.length === 0
254
273
  ? { id: 'blocked-paths', ok: true, lines: [`ok: blocked-paths clean (${h.blockedPaths!.join(', ')})`] }
255
274
  : {
@@ -262,8 +281,8 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
262
281
  ],
263
282
  })
264
283
  }
265
- if (!(h.disable ?? []).includes('root-identity')) {
266
- const r = runRootIdentity(root)
284
+ if (!off('root-identity')) {
285
+ const r = runRootIdentity(root, staged)
267
286
  if (r.status === 'skipped') out.push({ id: 'root-identity', ok: true, lines: [`skip: root-identity (${r.reason})`] })
268
287
  else if (r.status === 'clean') out.push({ id: 'root-identity', ok: true, lines: ['ok: root-identity unchanged'] })
269
288
  else out.push({
@@ -297,15 +316,19 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
297
316
  return out
298
317
  }
299
318
 
300
- /** the whole hook, boot included, past which it warns */
301
- export const HOOK_BUDGET_MS = 1_000
319
+ export { HOOK_BUDGET_MS }
302
320
 
303
321
  /** the checks that declare `onPreCommit` and apply here, with their budget */
304
322
  export type PreCommitCheck = { id: string; title: string; owner: string; budgetMs: number; run: NonNullable<CheckDef['run']>; repair: unknown }
305
323
 
306
324
  export async function preCommitChecks(root: string, config: RepoConfig): Promise<PreCommitCheck[]> {
325
+ return (await preCommitDiscovery(root, config)).checks
326
+ }
327
+
328
+ async function preCommitDiscovery(root: string, config: RepoConfig): Promise<{ checks: PreCommitCheck[]; slow: { path: string; ms: number }[] }> {
329
+ const found = await discoverPreCommit(root)
307
330
  const out: PreCommitCheck[] = []
308
- for (const u of await discoverChecks(root)) {
331
+ for (const u of found.checks) {
309
332
  const p = u.def.runs.onPreCommit
310
333
  if (p === false || u.def.run == null) continue
311
334
  if (repoOff(config, u.id) != null) continue
@@ -314,16 +337,25 @@ export async function preCommitChecks(root: string, config: RepoConfig): Promise
314
337
  }
315
338
  out.push({ id: u.id, title: u.title, owner: u.owner, budgetMs: p.budgetMs, run: u.def.run, repair: u.def.repair })
316
339
  }
317
- return out
340
+ return { checks: out, slow: found.slow }
318
341
  }
319
342
 
320
343
  /** every `onPreCommit` check, run RAW on the working tree: no ledger, no
321
344
  * cache, no repair. A failure refuses the commit, a pass over its budget
322
- * warns by name and never blocks on time */
345
+ * warns by name and never blocks on time. One pass for all of them, and one
346
+ * that records no config reads, since nothing stores these answers */
323
347
  async function runPreCommitChecks(root: string, config: RepoConfig): Promise<GateOutcome[]> {
324
348
  const out: GateOutcome[] = []
325
- for (const k of await preCommitChecks(root, config)) {
326
- const c = CheckCtx.open({ root, config })
349
+ const found = await preCommitDiscovery(root, config)
350
+ for (const s of found.slow)
351
+ out.push({
352
+ id: 'slow-import',
353
+ ok: true,
354
+ lines: [`WARN ${relative(root, s.path)} took ${Math.round(s.ms)}ms to import, over ${SLOW_IMPORT_MS}ms: every surface that lists the checks pays it, so move its heavy imports inside \`run\` (build/shipkit/writing-a-check.md)`],
355
+ })
356
+ const pass = new CheckPass(root, config, undefined, false)
357
+ for (const k of found.checks) {
358
+ const c = new CheckCtx(pass)
327
359
  const t = performance.now()
328
360
  await runCheckBody({ id: k.id, title: k.title, owner: k.owner, run: k.run, fix: k.repair }, c)
329
361
  const ms = performance.now() - t
@@ -339,12 +371,12 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
339
371
  const h = config.hooks ?? {}
340
372
  const out: GateOutcome[] = []
341
373
  const message = readFileSync(messageFile, 'utf8')
342
- if (!(h.disable ?? []).includes('commit-message')) {
343
- const languageOff = (h.disable ?? []).includes('commit-language')
374
+ if (hookGateOff(config, 'commit-message') == null) {
375
+ const languageOff = hookGateOff(config, 'commit-language') != null
344
376
  const offenses = commitMessageOffenses(message, { languageAllow: h.languageAllow })
345
377
  .filter((o) => o.kind !== 'language' || !languageOff)
346
378
  out.push(offenses.length === 0
347
- ? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation, no AI attribution${languageOff ? '' : ', English'})`] }
379
+ ? { id: 'commit-message', ok: true, lines: [`ok: commit message clean (no dash punctuation${languageOff ? '' : ', English'})`] }
348
380
  : {
349
381
  id: 'commit-message',
350
382
  ok: false,
@@ -353,17 +385,10 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
353
385
  ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`),
354
386
  ],
355
387
  })
356
- } else {
357
- // AI attribution is the one message rule no config lifts: this hook is
358
- // the guard against it in every repo (identity/how-we-commit.md)
359
- const offenses = commitMessageOffenses(message).filter((o) => o.kind === 'attribution')
360
- out.push(offenses.length === 0
361
- ? { id: 'ai-attribution', ok: true, lines: ['ok: no AI attribution in the commit message'] }
362
- : { id: 'ai-attribution', ok: false, lines: [`FAIL: commit message carries AI attribution`, ...offenses.map((o) => ` line ${o.line}: ${o.text.slice(0, 100)}\n ${o.why}`)] })
363
388
  }
364
389
  // the leak guard also reads the MESSAGE, a private name pasted into a
365
390
  // commit body is as public as one pasted into a file
366
- if (!(h.disable ?? []).includes('leak-guard')) {
391
+ if (hookGateOff(config, 'leak-guard') == null) {
367
392
  const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, allow: h.leakAllow, text: [{ label: 'commit message', content: message }] })
368
393
  if (r.status === 'hit')
369
394
  out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(hitLine), ...waivedLines(r.waived)] })
@@ -381,27 +406,33 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
381
406
  return out
382
407
  }
383
408
 
384
- /** what blocks a commit here, the enumeration hand-rolled hooks never had. */
409
+ /** what blocks a commit here, the enumeration hand-rolled hooks never had.
410
+ * A gate the repo turned off stays listed, with the reason `off` gives it */
385
411
  export function listGates(config: RepoConfig, root = process.cwd(), checks: { id: string; budgetMs: number }[] = []): string[] {
386
412
  const h = config.hooks ?? {}
387
- const off = new Set(h.disable ?? [])
413
+ const offWord = (id: HookGateId): string => {
414
+ const o = hookGateOff(config, id)
415
+ return o == null ? '' : ` (OFF: ${o.reason})`
416
+ }
388
417
  const kw = h.keywordFile ?? KEYWORD_FILE
389
418
  // a gate listed as active but unable to fire is worse than no listing:
390
419
  // this surface exists so nobody has to guess
391
- const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
420
+ const leakState = offWord('leak-guard') !== '' ? offWord('leak-guard') : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
392
421
  // a waiver nobody can enumerate is a hole nobody can audit: the count of
393
422
  // declared exemptions belongs on the same surface as the gate itself
394
423
  const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
395
424
  const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
425
+ const messageOff = offWord('commit-message')
396
426
  return [
397
427
  `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
398
- ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
399
- `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
428
+ ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths${offWord('blocked-paths')} — nothing under ${h.blockedPaths!.join(', ')} enters git${draftsOpen(config) && blockedStaged([COMFY_DRAFTS], config).length === 0 && h.blockedPaths!.some((p) => COMFY_DRAFTS.startsWith(p)) ? `, except ${COMFY_DRAFTS} (declared private)` : ''}`] : []),
429
+ `pre-commit root-identity${offWord('root-identity')} — the repo's own name in package.json + repo.config.ts, and scratch files`,
430
+ `pre-commit tree-shrink${offWord('tree-shrink')} — the commit keeps at least half of HEAD's files (${ALLOW_MASS_DELETE}=1 lifts it for one commit)`,
400
431
  ...checks.map((c) => `pre-commit ${c.id} — the check, onPreCommit, budget ${c.budgetMs}ms`),
401
432
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
402
- off.has('commit-message')
403
- ? `commit-msg commit-message (DISABLED by config) — dash punctuation + French; ai-attribution stays on, never disabled`
404
- : `commit-msg commit-message — dash punctuation + AI attribution (never disabled) + French${off.has('commit-language') ? ' (commit-language DISABLED by config)' : ''}`,
433
+ messageOff !== ''
434
+ ? `commit-msg commit-message${messageOff} — dash punctuation + French`
435
+ : `commit-msg commit-message — dash punctuation + French${offWord('commit-language') === '' ? '' : ` (commit-language${offWord('commit-language')})`}`,
405
436
  `commit-msg leak-guard${leakState} — the message text`,
406
437
  ...(h.commitMsg ?? []).map((c) => `commit-msg ${c.id} — ${c.cmd.join(' ')}`),
407
438
  ]