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.
- package/CHANGELOG.md +66 -0
- package/package.json +1 -1
- package/src/assets/gen/comfyts-gen.ts +31 -14
- package/src/assets/gen/generate-roll.ts +20 -7
- package/src/assets/kit-config.ts +9 -0
- package/src/assets/kits/identity-kit.ts +7 -1
- package/src/assets/run-assets.ts +3 -3
- package/src/assets/server/studio-server.ts +3 -3
- package/src/check.ts +5 -2
- package/src/checks/SK001-package-json-shape.check.ts +47 -12
- package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
- package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
- package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
- package/src/checks/SK029-explicit-check-files.check.ts +25 -8
- package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
- package/src/cli/cmd_Ci.ts +6 -5
- package/src/cli/cmd_Commands.ts +21 -5
- package/src/cli/cmd_Hooks.ts +6 -2
- package/src/cli/cmd_Init.ts +1 -1
- package/src/cli/cmd_Serve.ts +1 -2
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli.ts +2 -4
- package/src/collections/providers.ts +1 -1
- package/src/core/budget.ts +6 -8
- package/src/core/bun-builtins.ts +36 -0
- package/src/core/bun-tool-cli.ts +23 -0
- package/src/core/canonical-scripts.ts +6 -4
- package/src/core/check-ctx.ts +104 -10
- package/src/core/check-file-names.ts +2 -3
- package/src/core/check-pass.ts +17 -1
- package/src/core/check-runner.ts +32 -10
- package/src/core/config-rewrite.ts +27 -6
- package/src/core/discover-checks.ts +64 -15
- package/src/core/fix-scope.ts +5 -0
- package/src/core/floor-resolve.ts +18 -8
- package/src/core/git-root.ts +22 -12
- package/src/core/job-run.ts +2 -2
- package/src/core/layout.ts +4 -1
- package/src/core/legacy-shapes.ts +40 -0
- package/src/core/one-lib.ts +5 -3
- package/src/core/one-way-in-policy.ts +106 -13
- package/src/core/pre-commit-discovery.ts +106 -0
- package/src/core/retired-checks.ts +2 -0
- package/src/core/run-ci.ts +23 -16
- package/src/core/run-fix.ts +9 -0
- package/src/core/run-fixers.ts +91 -35
- package/src/core/runs-matrix.ts +7 -0
- package/src/core/slots.ts +36 -6
- package/src/core/vendored-read.ts +39 -0
- package/src/core/vendored.ts +82 -0
- package/src/core/waivers.ts +125 -0
- package/src/core/when-block.ts +25 -185
- package/src/core/when.ts +30 -13
- package/src/core/wire.ts +17 -1
- package/src/git-bootstrap.ts +1 -1
- package/src/home/attach.ts +3 -5
- package/src/home/gui-app/ShipkitGui.swift +96 -0
- package/src/home/insights.ts +6 -1
- package/src/home/pm-config.ts +2 -2
- package/src/home/resources.ts +3 -0
- package/src/home/run-daemon.ts +2 -2
- package/src/home/run-unit-action.ts +2 -2
- package/src/hooks/commit-message.ts +2 -14
- package/src/hooks/hook-gates.ts +38 -0
- package/src/hooks/leak-guard.ts +8 -3
- package/src/hooks/one-way-in-guard.ts +8 -2
- package/src/hooks/root-identity.ts +9 -6
- package/src/hooks/run-hooks.ts +72 -41
- package/src/hooks/staged.ts +49 -0
- package/src/hooks/tree-shrink.ts +41 -0
- package/src/init-repo.ts +0 -42
- package/src/ledger/check-table.ts +1 -1
- package/src/ledger/gate-checks.ts +45 -9
- package/src/ledger/ledger.ts +6 -1
- package/src/ledger/repo-view.ts +14 -4
- package/src/ledger/toolchain-identity.ts +4 -2
- package/src/mv/mv-md.ts +6 -1
- package/src/plan/planner.ts +21 -4
- package/src/plan/repo-source.ts +7 -3
- package/src/problems/extract.ts +27 -5
- package/src/problems/problem-line.ts +2 -3
- package/src/release/run-release.ts +30 -16
- package/src/release/unit-gate.ts +12 -5
- package/src/repo-config.ts +51 -27
- package/src/scaffold/files/bunfig.toml +11 -0
- package/src/scaffold/files/vscode-settings.json +1 -2
- package/src/scaffold/jsonc-spans.ts +16 -0
- package/src/scaffold/project-scaffold.ts +43 -77
- package/src/serve/hub-app/check-view.tsx +4 -4
- package/src/serve/hub-app/check-words.ts +1 -0
- package/src/serve/hub-app/checks-svc.ts +1 -2
- package/src/serve/hub-app/home.html +0 -1
- package/src/serve/hub-app/hub-types.ts +0 -2
- package/src/serve/hub-app/index.html +0 -1
- package/src/serve/hub-app/project-list.css +1 -2
- package/src/serve/hub-app/tree-build.ts +3 -2
- package/src/serve/hub-app/tree-svc.ts +2 -2
- package/src/serve/hub-port.ts +3 -3
- package/src/serve/hub-runtime.ts +3 -5
- package/src/serve/project-gutter.ts +5 -2
- package/src/serve/run-lanes.ts +7 -3
- package/src/serve/serve-hub.ts +1 -16
- package/src/serve/tracked-watch.ts +1 -1
- package/src/smoke/run-smoke.ts +13 -6
- package/src/start-repo.ts +15 -3
- package/src/suite/judge-suites.ts +81 -32
- package/src/suite/select.ts +2 -0
- package/src/suite/suite-cli.ts +27 -16
- package/src/suite/suite-cpu-preload.ts +7 -11
- package/src/suite/test-units.ts +7 -4
- package/src/ts/config.ts +12 -5
- package/src/ts/imports.ts +3 -1
- package/src/ts/typecheck-cache.ts +11 -0
- package/src/ts/typecheck-cli.ts +2 -1
- package/src/tui/build-steps-panel.ts +1 -1
- package/src/wiki/scan-md.ts +16 -9
- package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
- package/src/checks/SK014-prose-ste.check.ts +0 -78
- package/src/cli/cmd_Prose.ts +0 -74
- package/src/prose/prose-view.ts +0 -90
- package/src/prose/run-prose.ts +0 -187
- package/src/prose/ste.ts +0 -552
- package/src/scaffold/files/vscode-tasks.json +0 -24
- package/src/serve/hub-app/prose-hits-st.ts +0 -125
- package/src/serve/hub-app/prose-hits-view.tsx +0 -176
- package/src/serve/hub-app/prose-hits.css +0 -21
package/src/home/attach.ts
CHANGED
|
@@ -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
|
|
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
|
|
7
|
-
//
|
|
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
|
package/src/home/insights.ts
CHANGED
|
@@ -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
|
-
|
|
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 */
|
package/src/home/pm-config.ts
CHANGED
|
@@ -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 {
|
|
33
|
+
const { installOneLib } = await import('../core/one-lib.ts')
|
|
34
34
|
const { scanGlobalUnits } = await import('./global-units.ts')
|
|
35
|
-
|
|
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
|
package/src/home/resources.ts
CHANGED
|
@@ -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
|
package/src/home/run-daemon.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 {
|
|
16
|
-
|
|
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
|
|
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' | '
|
|
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
|
+
}
|
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -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 =
|
|
377
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
disable?:
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
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 (!(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
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 (
|
|
343
|
-
const languageOff = (
|
|
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
|
|
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 (
|
|
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
|
|
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 =
|
|
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${
|
|
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
|
-
|
|
403
|
-
? `commit-msg commit-message
|
|
404
|
-
: `commit-msg commit-message — dash punctuation +
|
|
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
|
]
|