diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 5a8e453..5094e95 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -13,6 +13,15 @@
"url": "https://github.com/pickforge/pickforge-platform.git",
"path": "packages/review-tutor/claude-plugin"
}
+ },
+ {
+ "name": "complexity-gate",
+ "description": "Block completion when changed functions exceed complexity limits.",
+ "source": {
+ "source": "git-subdir",
+ "url": "https://github.com/pickforge/pickforge-platform.git",
+ "path": "packages/complexity-gate/claude-plugin"
+ }
}
]
}
diff --git a/README.md b/README.md
index 2a21844..272d5f3 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ Shared platform packages for Pickforge desktop apps.
- `@pickforge/tauri-release`: signed Tauri release and updater-feed helpers.
- `@pickforge/brand`: CSS tokens, fonts, reset, and framework-neutral primitives.
- `@pickforge/auth`: UI-free Supabase Auth wrapper and entitlement reader.
+- `@pickforge/complexity-gate`: cross-harness function complexity checks and stop gates.
Desktop apps keep updating from signed Tauri artifacts and signed `latest.json`
feeds. Stable releases stay tag-driven; nightly builds use a separate opt-in
diff --git a/bun.lock b/bun.lock
index ed9767b..ac11eae 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,55 +18,78 @@
},
"packages/auth": {
"name": "@pickforge/auth",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@supabase/supabase-js": "^2.110.0",
},
},
"packages/billing": {
"name": "@pickforge/billing",
- "version": "0.12.0",
+ "version": "0.13.0",
},
"packages/brand": {
"name": "@pickforge/brand",
- "version": "0.12.0",
+ "version": "0.13.0",
"dependencies": {
"@fontsource/geist-mono": "^5.2.8",
"@fontsource/geist-sans": "^5.2.5",
},
},
+ "packages/complexity-gate": {
+ "name": "@pickforge/complexity-gate",
+ "version": "0.1.0",
+ "bin": {
+ "complexity-gate": "bin/complexity-gate",
+ "complexity-gate-install": "dist/install.js",
+ },
+ "devDependencies": {
+ "@earendil-works/pi-coding-agent": "^0.84.2",
+ },
+ "peerDependencies": {
+ "@earendil-works/pi-coding-agent": ">=0.83.0",
+ },
+ "optionalPeers": [
+ "@earendil-works/pi-coding-agent",
+ ],
+ },
"packages/edge-shared": {
"name": "@pickforge/edge-shared",
- "version": "0.12.0",
+ "version": "0.13.0",
},
"packages/flags": {
"name": "@pickforge/flags",
- "version": "0.12.0",
+ "version": "0.13.0",
},
"packages/review-tutor": {
"name": "@pickforge/review-tutor",
- "version": "0.0.0",
+ "version": "0.13.0",
+ "bin": {
+ "review-tutor": "dist/bin.js",
+ },
"devDependencies": {
"@earendil-works/pi-coding-agent": "^0.84.2",
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.83.0",
},
+ "optionalPeers": [
+ "@earendil-works/pi-coding-agent",
+ ],
},
"packages/sync": {
"name": "@pickforge/sync",
- "version": "0.12.0",
+ "version": "0.13.0",
},
"packages/tauri-release": {
"name": "@pickforge/tauri-release",
- "version": "0.12.0",
+ "version": "0.13.0",
"bin": {
"pickforge-tauri-release": "dist/cli.js",
},
},
"packages/tauri-updater": {
"name": "@pickforge/tauri-updater",
- "version": "0.12.0",
+ "version": "0.13.0",
},
},
"overrides": {
@@ -281,6 +304,8 @@
"@pickforge/brand": ["@pickforge/brand@workspace:packages/brand"],
+ "@pickforge/complexity-gate": ["@pickforge/complexity-gate@workspace:packages/complexity-gate"],
+
"@pickforge/edge-shared": ["@pickforge/edge-shared@workspace:packages/edge-shared"],
"@pickforge/flags": ["@pickforge/flags@workspace:packages/flags"],
diff --git a/package.json b/package.json
index e445f1a..18874b9 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"packages/*"
],
"scripts": {
- "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/tauri-updater build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build && bun run --cwd packages/review-tutor build",
+ "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/tauri-updater build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build && bun run --cwd packages/review-tutor build && bun run --cwd packages/complexity-gate build",
"test": "vitest run",
"test:supabase": "supabase test db supabase/tests/database --local && bun run supabase/tests/welcome-credits-concurrency.ts && bun test packages/billing/test/checkout-lifecycle.contract.test.ts && bun test packages/sync/test/lww.contract.test.ts && bun test packages/edge-shared/test/router-attempt.contract.test.ts",
"test:coverage": "vitest run --coverage",
diff --git a/packages/complexity-gate/README.md b/packages/complexity-gate/README.md
new file mode 100644
index 0000000..e63c60c
--- /dev/null
+++ b/packages/complexity-gate/README.md
@@ -0,0 +1,50 @@
+# @pickforge/complexity-gate
+
+Function-complexity feedback and stop gates for Pi, Claude Code, and Codex. The npm package downloads the matching Rust binary, verifies its SHA-256 checksum, and keeps install non-fatal when a release or network is unavailable.
+
+## Install
+
+Requires Node 22 or newer. Set `COMPLEXITY_GATE_BIN` to an existing binary to skip the release download. Set `COMPLEXITY_GATE_VERSION` to a release tag (default `v0.1.0`).
+
+```bash
+npm install -g @pickforge/complexity-gate
+complexity-gate-install --all
+```
+
+Choose one or more harnesses with `--harness claude,codex,pi`. With no flags, the installer prompts for a comma-separated list. `--print` prints configuration without writing, and `--home
` changes the settings root.
+
+### Pi
+
+```bash
+pi install npm:@pickforge/complexity-gate
+```
+
+The extension checks files after `edit` and `write` tool results. Violations are appended as tool feedback. At agent-turn completion it checks changed functions and queues up to three refactor follow-ups per session.
+
+### Claude Code
+
+```bash
+claude plugin marketplace add pickforge/pickforge-platform
+claude plugin install complexity-gate@pickforge
+```
+
+Alternatively, `complexity-gate-install --harness claude` merges the equivalent hooks into `~/.claude/settings.json` without replacing existing hooks.
+
+### Codex
+
+```bash
+complexity-gate-install --harness codex
+cp -r node_modules/@pickforge/complexity-gate/codex-skill/complexity-gate ~/.codex/skills/
+```
+
+The installer merges `codex-hooks.json` into `~/.codex/hooks.json`.
+
+## Configure
+
+Run `complexity-gate init` to create `.complexity-gate.json`. The defaults are complexity 15, depth 4, 100 nonblank/non-comment lines, and 6 parameters. See the installed skill for the refactoring workflow.
+
+The wrapper resolves the executable in this order: `COMPLEXITY_GATE_BIN`, the verified binary under `vendor/`, then `complexity-gate` on `PATH`. stdin, stdout, stderr, argv, and exit status are inherited unchanged.
+
+## Hook documentation
+
+Hook formats and event names were checked against the Claude Code plugin/hooks and Codex hooks documentation on 2026-08-26. Both currently expose `PostToolUse` and `Stop`; both fragments invoke the Rust binary's harness-specific hook adapter.
diff --git a/packages/complexity-gate/bin/complexity-gate b/packages/complexity-gate/bin/complexity-gate
new file mode 100755
index 0000000..0d2abc1
--- /dev/null
+++ b/packages/complexity-gate/bin/complexity-gate
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+import { access, realpath } from "node:fs/promises";
+import { constants } from "node:fs";
+import { spawn } from "node:child_process";
+import { delimiter, dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const script = await realpath(fileURLToPath(import.meta.url));
+const root = dirname(dirname(script));
+const binaryName = process.platform === "win32" ? "complexity-gate.exe" : "complexity-gate";
+const vendored = join(root, "vendor", binaryName);
+
+async function usable(path) {
+ try { await access(path, constants.X_OK); return true; } catch { return false; }
+}
+
+async function pathBinary() {
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
+ const candidate = join(directory, binaryName);
+ if (!(await usable(candidate))) continue;
+ try { if (await realpath(candidate) === script) continue; } catch { continue; }
+ return candidate;
+ }
+}
+
+let selected;
+for (const candidate of [process.env.COMPLEXITY_GATE_BIN, vendored]) {
+ if (candidate && await usable(candidate)) { selected = candidate; break; }
+}
+selected ??= await pathBinary();
+if (!selected) {
+ console.error("complexity-gate: binary not found; set COMPLEXITY_GATE_BIN or run complexity-gate-install");
+ process.exit(127);
+}
+const child = spawn(selected, process.argv.slice(2), { stdio: "inherit" });
+child.on("error", () => {
+ console.error("complexity-gate: binary not found; set COMPLEXITY_GATE_BIN or run complexity-gate-install");
+ process.exit(127);
+});
+child.on("exit", (code, signal) => signal ? process.kill(process.pid, signal) : process.exit(code ?? 1));
diff --git a/packages/complexity-gate/claude-plugin/.claude-plugin/plugin.json b/packages/complexity-gate/claude-plugin/.claude-plugin/plugin.json
new file mode 100644
index 0000000..a9f1c2a
--- /dev/null
+++ b/packages/complexity-gate/claude-plugin/.claude-plugin/plugin.json
@@ -0,0 +1,9 @@
+{
+ "name": "complexity-gate",
+ "version": "0.1.0",
+ "description": "Block agent completion when changed functions exceed complexity limits.",
+ "author": { "name": "Pickforge" },
+ "homepage": "https://github.com/pickforge/pickforge-platform/tree/main/packages/complexity-gate",
+ "repository": "https://github.com/pickforge/pickforge-platform",
+ "license": "MIT"
+}
diff --git a/packages/complexity-gate/claude-plugin/hooks/hooks.json b/packages/complexity-gate/claude-plugin/hooks/hooks.json
new file mode 100644
index 0000000..e924317
--- /dev/null
+++ b/packages/complexity-gate/claude-plugin/hooks/hooks.json
@@ -0,0 +1,12 @@
+{
+ "description": "Check edited and changed functions with complexity-gate.",
+ "hooks": {
+ "PostToolUse": [{
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [{ "type": "command", "command": "complexity-gate hook claude" }]
+ }],
+ "Stop": [{
+ "hooks": [{ "type": "command", "command": "complexity-gate hook claude" }]
+ }]
+ }
+}
diff --git a/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md b/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md
new file mode 100644
index 0000000..c356adf
--- /dev/null
+++ b/packages/complexity-gate/claude-plugin/skills/complexity-gate/SKILL.md
@@ -0,0 +1,57 @@
+---
+name: complexity-gate
+description: Measure and reduce function complexity with the complexity-gate binary. Use when a complexity-gate hook reports FAIL lines, when the user asks to refactor, simplify, or clean up code, mentions complexity, maintainability, deeply nested logic, or god functions, or after writing any nontrivial branching code.
+---
+
+# Complexity gate
+
+Never estimate complexity yourself. The only accepted numbers come from:
+
+```bash
+complexity-gate check # one file
+complexity-gate check --changed # every function you touched this session
+```
+
+Output: `FAIL path:line name metric value > limit`. Metrics: `complexity`
+(cyclomatic), `depth` (nesting), `lines`, `params`. `UNVERIFIED path` means no
+grammar for that language: say so in your report, do not count by hand.
+
+The Stop hook re-runs `--changed` when you try to finish and blocks while any
+FAIL remains. Fix the listed functions; do not suppress, rename, or move them to
+escape the diff.
+
+## Refactor tactics, in order of preference
+
+1. **Guard clauses.** Invert conditions, return early, kill nesting.
+2. **Extract function.** Each piece gets a name that says what, not how.
+3. **Lookup table / map** instead of if-else or switch chains.
+4. **Named predicates.** `if (isEligibleForRefund(order))` beats a 4-clause boolean.
+5. **Polymorphism / strategy** for switch-on-type, only when the switch appears in 2+ places.
+6. **Flatten loops.** Extract the loop body; use `continue` instead of nested `if`.
+
+## Hard rules
+
+- Preserve behavior. Run tests before and after. No tests: say so, refactor conservatively.
+- Don't game the metric. A dense one-liner hiding six branches is worse than the
+ honest if-chain it replaced. Complexity moves into well-named units, it does not
+ disappear into cleverness.
+- Don't break public APIs or exported signatures without asking.
+- One responsibility per function. If the name needs "and", split.
+- Never raise a limit in `.complexity-gate.json` to get green. Legacy code you
+ did not touch is not your problem; the gate only checks changed functions.
+
+## Workflow
+
+1. Run `complexity-gate check --changed`; rank FAILs by value descending.
+2. Refactor worst first, one function at a time.
+3. Re-run the check. End with:
+
+```
+## Complexity report
+| Function | Metric | Before | After |
+|----------|--------|--------|-------|
+| parseOrder | complexity | 18 | 6 |
+
+Extracted: validateHeader, resolveDiscount
+Behavior verified:
+```
diff --git a/packages/complexity-gate/codex-hooks.json b/packages/complexity-gate/codex-hooks.json
new file mode 100644
index 0000000..ba64718
--- /dev/null
+++ b/packages/complexity-gate/codex-hooks.json
@@ -0,0 +1,12 @@
+{
+ "description": "Check edited and changed functions with complexity-gate.",
+ "hooks": {
+ "PostToolUse": [{
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [{ "type": "command", "command": "complexity-gate hook codex" }]
+ }],
+ "Stop": [{
+ "hooks": [{ "type": "command", "command": "complexity-gate hook codex" }]
+ }]
+ }
+}
diff --git a/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md b/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md
new file mode 100644
index 0000000..c356adf
--- /dev/null
+++ b/packages/complexity-gate/codex-skill/complexity-gate/SKILL.md
@@ -0,0 +1,57 @@
+---
+name: complexity-gate
+description: Measure and reduce function complexity with the complexity-gate binary. Use when a complexity-gate hook reports FAIL lines, when the user asks to refactor, simplify, or clean up code, mentions complexity, maintainability, deeply nested logic, or god functions, or after writing any nontrivial branching code.
+---
+
+# Complexity gate
+
+Never estimate complexity yourself. The only accepted numbers come from:
+
+```bash
+complexity-gate check # one file
+complexity-gate check --changed # every function you touched this session
+```
+
+Output: `FAIL path:line name metric value > limit`. Metrics: `complexity`
+(cyclomatic), `depth` (nesting), `lines`, `params`. `UNVERIFIED path` means no
+grammar for that language: say so in your report, do not count by hand.
+
+The Stop hook re-runs `--changed` when you try to finish and blocks while any
+FAIL remains. Fix the listed functions; do not suppress, rename, or move them to
+escape the diff.
+
+## Refactor tactics, in order of preference
+
+1. **Guard clauses.** Invert conditions, return early, kill nesting.
+2. **Extract function.** Each piece gets a name that says what, not how.
+3. **Lookup table / map** instead of if-else or switch chains.
+4. **Named predicates.** `if (isEligibleForRefund(order))` beats a 4-clause boolean.
+5. **Polymorphism / strategy** for switch-on-type, only when the switch appears in 2+ places.
+6. **Flatten loops.** Extract the loop body; use `continue` instead of nested `if`.
+
+## Hard rules
+
+- Preserve behavior. Run tests before and after. No tests: say so, refactor conservatively.
+- Don't game the metric. A dense one-liner hiding six branches is worse than the
+ honest if-chain it replaced. Complexity moves into well-named units, it does not
+ disappear into cleverness.
+- Don't break public APIs or exported signatures without asking.
+- One responsibility per function. If the name needs "and", split.
+- Never raise a limit in `.complexity-gate.json` to get green. Legacy code you
+ did not touch is not your problem; the gate only checks changed functions.
+
+## Workflow
+
+1. Run `complexity-gate check --changed`; rank FAILs by value descending.
+2. Refactor worst first, one function at a time.
+3. Re-run the check. End with:
+
+```
+## Complexity report
+| Function | Metric | Before | After |
+|----------|--------|--------|-------|
+| parseOrder | complexity | 18 | 6 |
+
+Extracted: validateHeader, resolveDiscount
+Behavior verified:
+```
diff --git a/packages/complexity-gate/extensions/complexity-gate.ts b/packages/complexity-gate/extensions/complexity-gate.ts
new file mode 100644
index 0000000..8726654
--- /dev/null
+++ b/packages/complexity-gate/extensions/complexity-gate.ts
@@ -0,0 +1,56 @@
+import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
+
+type Violation = { file: string; line: number; function: string; metric: string; value: number; limit: number };
+type GateResult = { violations?: Violation[] };
+
+function report(result: GateResult): string {
+ return (result.violations ?? []).map((item) =>
+ `FAIL ${item.file}:${item.line} ${item.function} ${item.metric} ${item.value} > ${item.limit}`,
+ ).join("\n");
+}
+
+async function check(pi: ExtensionAPI, cwd: string, args: string[]): Promise {
+ try {
+ const result = await pi.exec("complexity-gate", ["check", ...args, "--format", "json"], { cwd });
+ if (result.code !== 0 && result.code !== 1) return "";
+ return report(JSON.parse(result.stdout) as GateResult);
+ } catch {
+ return "";
+ }
+}
+
+function notify(ctx: ExtensionContext, message: string): void {
+ try { ctx.ui.notify(message, "warning"); } catch {}
+}
+
+function fileFromInput(input: unknown): string | undefined {
+ if (!input || typeof input !== "object") return undefined;
+ const value = (input as { path?: unknown; file_path?: unknown }).path ?? (input as { file_path?: unknown }).file_path;
+ return typeof value === "string" ? value : undefined;
+}
+
+export default function complexityGateExtension(pi: ExtensionAPI): void {
+ let blocks = 0;
+ pi.on("session_start", () => { blocks = 0; });
+ pi.on("tool_result", async (event, ctx) => {
+ try {
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
+ const file = fileFromInput(event.input);
+ if (!file) return;
+ const feedback = await check(pi, ctx.cwd, [file]);
+ if (!feedback) return;
+ notify(ctx, "complexity-gate found violations in the edited file");
+ return { content: [...event.content, { type: "text" as const, text: `\ncomplexity-gate feedback:\n${feedback}` }] };
+ } catch { return; }
+ });
+ pi.on("agent_end", async (_event, ctx) => {
+ try {
+ const feedback = await check(pi, ctx.cwd, ["--changed"]);
+ if (!feedback) { blocks = 0; return; }
+ notify(ctx, "complexity-gate found violations in changed functions");
+ if (blocks >= 3) return;
+ blocks += 1;
+ pi.sendUserMessage(`${feedback}\nRefactor the listed functions (see the complexity-gate skill), then finish.`, { deliverAs: "followUp" });
+ } catch { return; }
+ });
+}
diff --git a/packages/complexity-gate/package.json b/packages/complexity-gate/package.json
new file mode 100644
index 0000000..458e1a5
--- /dev/null
+++ b/packages/complexity-gate/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@pickforge/complexity-gate",
+ "version": "0.1.0",
+ "description": "Cross-harness function complexity gate for coding agents.",
+ "type": "module",
+ "license": "MIT",
+ "keywords": ["pi-package", "claude-code-plugin", "codex", "code-review", "complexity"],
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/pickforge/pickforge-platform.git",
+ "directory": "packages/complexity-gate"
+ },
+ "homepage": "https://github.com/pickforge/pickforge-platform/tree/main/packages/complexity-gate",
+ "engines": { "node": ">=22" },
+ "publishConfig": { "access": "public" },
+ "pi": {
+ "extensions": ["extensions/complexity-gate.ts"],
+ "skills": ["skills/complexity-gate"]
+ },
+ "bin": {
+ "complexity-gate": "bin/complexity-gate",
+ "complexity-gate-install": "dist/install.js"
+ },
+ "files": ["bin", "dist", "scripts", "extensions", "skills", "claude-plugin", "codex-skill", "codex-hooks.json", "README.md"],
+ "scripts": {
+ "build": "tsup src/install.ts src/postinstall.ts --format esm --clean --splitting false --out-dir dist",
+ "postinstall": "node scripts/postinstall.cjs",
+ "test": "cd ../.. && vitest run packages/complexity-gate/test",
+ "typecheck": "tsc -p ../../tsconfig.json --noEmit"
+ },
+ "peerDependencies": { "@earendil-works/pi-coding-agent": ">=0.83.0" },
+ "peerDependenciesMeta": { "@earendil-works/pi-coding-agent": { "optional": true } },
+ "devDependencies": { "@earendil-works/pi-coding-agent": "^0.84.2" }
+}
diff --git a/packages/complexity-gate/scripts/postinstall.cjs b/packages/complexity-gate/scripts/postinstall.cjs
new file mode 100644
index 0000000..df3c2f1
--- /dev/null
+++ b/packages/complexity-gate/scripts/postinstall.cjs
@@ -0,0 +1,11 @@
+const { existsSync } = require("node:fs");
+const { join } = require("node:path");
+
+const built = join(__dirname, "..", "dist", "postinstall.js");
+if (!existsSync(built)) {
+ console.warn("complexity-gate: package is not built; skipping binary download");
+} else {
+ import(built).then((module) => module.postinstall()).catch((error) => {
+ console.warn(`complexity-gate: binary download skipped: ${error instanceof Error ? error.message : String(error)}`);
+ });
+}
diff --git a/packages/complexity-gate/skills/complexity-gate/SKILL.md b/packages/complexity-gate/skills/complexity-gate/SKILL.md
new file mode 100644
index 0000000..c356adf
--- /dev/null
+++ b/packages/complexity-gate/skills/complexity-gate/SKILL.md
@@ -0,0 +1,57 @@
+---
+name: complexity-gate
+description: Measure and reduce function complexity with the complexity-gate binary. Use when a complexity-gate hook reports FAIL lines, when the user asks to refactor, simplify, or clean up code, mentions complexity, maintainability, deeply nested logic, or god functions, or after writing any nontrivial branching code.
+---
+
+# Complexity gate
+
+Never estimate complexity yourself. The only accepted numbers come from:
+
+```bash
+complexity-gate check # one file
+complexity-gate check --changed # every function you touched this session
+```
+
+Output: `FAIL path:line name metric value > limit`. Metrics: `complexity`
+(cyclomatic), `depth` (nesting), `lines`, `params`. `UNVERIFIED path` means no
+grammar for that language: say so in your report, do not count by hand.
+
+The Stop hook re-runs `--changed` when you try to finish and blocks while any
+FAIL remains. Fix the listed functions; do not suppress, rename, or move them to
+escape the diff.
+
+## Refactor tactics, in order of preference
+
+1. **Guard clauses.** Invert conditions, return early, kill nesting.
+2. **Extract function.** Each piece gets a name that says what, not how.
+3. **Lookup table / map** instead of if-else or switch chains.
+4. **Named predicates.** `if (isEligibleForRefund(order))` beats a 4-clause boolean.
+5. **Polymorphism / strategy** for switch-on-type, only when the switch appears in 2+ places.
+6. **Flatten loops.** Extract the loop body; use `continue` instead of nested `if`.
+
+## Hard rules
+
+- Preserve behavior. Run tests before and after. No tests: say so, refactor conservatively.
+- Don't game the metric. A dense one-liner hiding six branches is worse than the
+ honest if-chain it replaced. Complexity moves into well-named units, it does not
+ disappear into cleverness.
+- Don't break public APIs or exported signatures without asking.
+- One responsibility per function. If the name needs "and", split.
+- Never raise a limit in `.complexity-gate.json` to get green. Legacy code you
+ did not touch is not your problem; the gate only checks changed functions.
+
+## Workflow
+
+1. Run `complexity-gate check --changed`; rank FAILs by value descending.
+2. Refactor worst first, one function at a time.
+3. Re-run the check. End with:
+
+```
+## Complexity report
+| Function | Metric | Before | After |
+|----------|--------|--------|-------|
+| parseOrder | complexity | 18 | 6 |
+
+Extracted: validateHeader, resolveDiscount
+Behavior verified:
+```
diff --git a/packages/complexity-gate/src/install.ts b/packages/complexity-gate/src/install.ts
new file mode 100644
index 0000000..f9c1d7d
--- /dev/null
+++ b/packages/complexity-gate/src/install.ts
@@ -0,0 +1,143 @@
+#!/usr/bin/env node
+import { constants } from "node:fs";
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { dirname, join } from "node:path";
+import { spawn } from "node:child_process";
+import { createInterface } from "node:readline/promises";
+import { fileURLToPath } from "node:url";
+
+export type Harness = "claude" | "codex" | "pi";
+type Json = Record;
+
+export const hookFragments: Record, Json> = {
+ claude: {
+ hooks: {
+ PostToolUse: [{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: "complexity-gate hook claude" }] }],
+ Stop: [{ hooks: [{ type: "command", command: "complexity-gate hook claude" }] }],
+ },
+ },
+ codex: {
+ hooks: {
+ PostToolUse: [{ matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: "complexity-gate hook codex" }] }],
+ Stop: [{ hooks: [{ type: "command", command: "complexity-gate hook codex" }] }],
+ },
+ },
+};
+
+function stable(value: unknown): string { return JSON.stringify(value); }
+
+function isPlainObject(value: unknown): value is Json {
+ if (typeof value !== "object" || value === null) return false;
+ const prototype = Object.getPrototypeOf(value) as unknown;
+ return prototype === Object.prototype || prototype === null;
+}
+
+function validateDocument(value: unknown, source: string): asserts value is Json {
+ if (!isPlainObject(value)) throw new Error(`${source}: expected a JSON object`);
+ if (value.hooks === undefined) return;
+ if (!isPlainObject(value.hooks)) throw new Error(`${source}: expected "hooks" to be an object`);
+ for (const [event, entries] of Object.entries(value.hooks)) {
+ if (!Array.isArray(entries)) throw new Error(`${source}: expected "hooks.${event}" to be an array`);
+ }
+}
+
+export function mergeHookFragment(existing: Json, fragment: Json): Json {
+ validateDocument(existing, "existing document");
+ validateDocument(fragment, "hook fragment");
+ const currentHooks = (existing.hooks ?? {}) as Json;
+ const incomingHooks = (fragment.hooks ?? {}) as Json;
+ const hooks: Json = { ...currentHooks };
+ for (const [event, entries] of Object.entries(incomingHooks)) {
+ const current = Array.isArray(currentHooks[event]) ? currentHooks[event] as unknown[] : [];
+ const additions = (entries as unknown[]).filter((entry) => !current.some((item) => stable(item) === stable(entry)));
+ hooks[event] = [...current, ...additions];
+ }
+ return { ...existing, hooks };
+}
+
+async function readJson(path: string): Promise {
+ try {
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
+ validateDocument(parsed, path);
+ return parsed;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
+ if (error instanceof SyntaxError) throw new Error(`${path}: invalid JSON: ${error.message}`);
+ throw error;
+ }
+}
+
+async function mergeFile(path: string, fragment: Json): Promise {
+ const existing = await readJson(path);
+ const merged = mergeHookFragment(existing, fragment);
+ if (stable(existing) === stable(merged)) return;
+ await mkdir(dirname(path), { recursive: true });
+ await writeFile(path, `${JSON.stringify(merged, null, 2)}\n`);
+}
+
+async function executable(name: string): Promise {
+ const paths = (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":");
+ for (const path of paths) {
+ try { await access(join(path, name), constants.X_OK); return true; } catch {}
+ }
+ return false;
+}
+
+export function spawnPi(command: string, platform = process.platform, spawnProcess = spawn): ReturnType {
+ const args = ["install", "npm:@pickforge/complexity-gate"];
+ return platform === "win32"
+ ? spawnProcess("cmd.exe", ["/d", "/s", "/c", command, ...args], { stdio: "inherit" })
+ : spawnProcess(command, args, { stdio: "inherit" });
+}
+
+async function runPi(): Promise {
+ const command = process.platform === "win32" ? "pi.cmd" : "pi";
+ if (!(await executable(command))) {
+ console.log("pi install npm:@pickforge/complexity-gate");
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const child = spawnPi(command);
+ child.on("error", reject);
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`pi install exited ${code}`)));
+ });
+}
+
+export function parseArgs(argv: string[]): { harnesses: Harness[]; print: boolean; home: string } {
+ let home = homedir();
+ let print = false;
+ const harnesses: Harness[] = [];
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === "--print") print = true;
+ else if (arg === "--all") harnesses.push("claude", "codex", "pi");
+ else if (arg === "--home" && argv[index + 1]) home = argv[index += 1]!;
+ else if (arg === "--harness" && argv[index + 1]) harnesses.push(...argv[index += 1]!.split(",") as Harness[]);
+ else throw new Error(`unknown or incomplete option: ${arg}`);
+ }
+ const unique = [...new Set(harnesses)];
+ if (unique.some((value) => !["claude", "codex", "pi"].includes(value))) throw new Error("--harness expects claude,codex,pi");
+ return { harnesses: unique, print, home };
+}
+
+async function promptHarnesses(): Promise {
+ const input = createInterface({ input: process.stdin, output: process.stdout });
+ const answer = await input.question("Install for harnesses (claude,codex,pi; comma-separated): ");
+ input.close();
+ return parseArgs(["--harness", answer]).harnesses;
+}
+
+export async function install(argv = process.argv.slice(2)): Promise {
+ const options = parseArgs(argv);
+ const harnesses = options.harnesses.length ? options.harnesses : await promptHarnesses();
+ if (options.print) {
+ for (const harness of harnesses) console.log(harness === "pi" ? "pi install npm:@pickforge/complexity-gate" : JSON.stringify(hookFragments[harness], null, 2));
+ return;
+ }
+ if (harnesses.includes("claude")) await mergeFile(join(options.home, ".claude", "settings.json"), hookFragments.claude);
+ if (harnesses.includes("codex")) await mergeFile(join(options.home, ".codex", "hooks.json"), hookFragments.codex);
+ if (harnesses.includes("pi")) await runPi();
+}
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) install().catch((error) => { console.error(`complexity-gate-install: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 2; });
diff --git a/packages/complexity-gate/src/postinstall.ts b/packages/complexity-gate/src/postinstall.ts
new file mode 100644
index 0000000..be0044c
--- /dev/null
+++ b/packages/complexity-gate/src/postinstall.ts
@@ -0,0 +1,99 @@
+import { createHash } from "node:crypto";
+import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { basename, dirname, join } from "node:path";
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const targets: Record = {
+ "linux-x64": "x86_64-unknown-linux-gnu",
+ "linux-arm64": "aarch64-unknown-linux-gnu",
+ "darwin-arm64": "aarch64-apple-darwin",
+ "darwin-x64": "x86_64-apple-darwin",
+ "win32-x64": "x86_64-pc-windows-msvc",
+};
+
+export type DownloadOptions = {
+ platform?: NodeJS.Platform;
+ arch?: string;
+ tag?: string;
+ vendorDir: string;
+ fetchImpl?: typeof fetch;
+};
+
+function run(file: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn(file, args, { stdio: "ignore" });
+ child.on("error", reject);
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`${file} exited ${code}`)));
+ });
+}
+
+async function body(response: Response, url: string): Promise {
+ if (!response.ok) throw new Error(`download failed (${response.status}): ${url}`);
+ return new Uint8Array(await response.arrayBuffer());
+}
+
+function expectedChecksum(text: string, archive: string): string {
+ const line = text.split("\n").find((value) => value.trim().endsWith(archive));
+ const checksum = line?.trim().split(/\s+/)[0];
+ if (!checksum || !/^[a-f\d]{64}$/i.test(checksum)) throw new Error(`checksum missing for ${archive}`);
+ return checksum.toLowerCase();
+}
+
+async function extract(archivePath: string, outputDir: string, platform: NodeJS.Platform): Promise {
+ if (platform === "win32") {
+ await run("powershell.exe", ["-NoProfile", "-Command", `Expand-Archive -LiteralPath '${archivePath.replaceAll("'", "''")}' -DestinationPath '${outputDir.replaceAll("'", "''")}' -Force`]);
+ return;
+ }
+ await run("tar", ["-xJf", archivePath, "-C", outputDir]);
+}
+
+export async function downloadBinary(options: DownloadOptions): Promise {
+ const platform = options.platform ?? process.platform;
+ const target = targets[`${platform}-${options.arch ?? process.arch}`];
+ if (!target) throw new Error(`unsupported platform: ${platform}/${options.arch ?? process.arch}`);
+ const tag = options.tag ?? process.env.COMPLEXITY_GATE_VERSION ?? "v0.1.0";
+ const extension = platform === "win32" ? "zip" : "tar.xz";
+ const archive = `complexity-gate-${target}.${extension}`;
+ const base = `https://github.com/pickforge/complexity-gate/releases/download/${tag}`;
+ const fetchImpl = options.fetchImpl ?? fetch;
+ const [archiveBytes, sumsBytes] = await Promise.all([
+ fetchImpl(`${base}/${archive}`).then((response) => body(response, archive)),
+ fetchImpl(`${base}/sha256.sum`).then((response) => body(response, "sha256.sum")),
+ ]);
+ const expected = expectedChecksum(new TextDecoder().decode(sumsBytes), archive);
+ const actual = createHash("sha256").update(archiveBytes).digest("hex");
+ if (actual !== expected) throw new Error(`checksum mismatch for ${archive}`);
+ const temp = await mkdtemp(join(tmpdir(), "complexity-gate-"));
+ try {
+ const archivePath = join(temp, archive);
+ await writeFile(archivePath, archiveBytes);
+ await extract(archivePath, temp, platform);
+ const binaryName = platform === "win32" ? "complexity-gate.exe" : "complexity-gate";
+ // cargo-dist archives unpack into a directory named after the archive stem.
+ const source = join(temp, `complexity-gate-${target}`, binaryName);
+ await mkdir(options.vendorDir, { recursive: true });
+ const destination = join(options.vendorDir, binaryName);
+ await rename(source, destination);
+ if (platform !== "win32") await chmod(destination, 0o755);
+ return destination;
+ } finally {
+ await rm(temp, { recursive: true, force: true });
+ }
+}
+
+export async function postinstall(): Promise {
+ if (process.env.COMPLEXITY_GATE_BIN) {
+ console.warn("complexity-gate: COMPLEXITY_GATE_BIN is set; skipping binary download");
+ return;
+ }
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+ try {
+ await downloadBinary({ vendorDir: join(packageRoot, "vendor") });
+ } catch (error) {
+ console.warn(`complexity-gate: binary download skipped: ${error instanceof Error ? error.message : String(error)}`);
+ }
+}
+
+if (basename(process.argv[1] ?? "") === "postinstall.js") await postinstall();
diff --git a/packages/complexity-gate/test/bin.test.ts b/packages/complexity-gate/test/bin.test.ts
new file mode 100644
index 0000000..490b993
--- /dev/null
+++ b/packages/complexity-gate/test/bin.test.ts
@@ -0,0 +1,29 @@
+import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { spawn } from "node:child_process";
+import { afterEach, describe, expect, it } from "vitest";
+
+const roots: string[] = [];
+afterEach(async () => Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true }))));
+
+function execute(file: string, args: string[], env: NodeJS.ProcessEnv): Promise<{ code: number | null; stdout: string }> {
+ return new Promise((resolveResult) => {
+ const child = spawn(file, args, { env });
+ let stdout = "";
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
+ child.on("exit", (code) => resolveResult({ code, stdout }));
+ });
+}
+
+describe("binary forwarding", () => {
+ it("prefers COMPLEXITY_GATE_BIN and forwards argv and exit code", async () => {
+ const root = await mkdtemp(join(tmpdir(), "complexity-bin-"));
+ roots.push(root);
+ const binary = join(root, "gate");
+ await writeFile(binary, "#!/bin/sh\nprintf '%s' \"$*\"\nexit 7\n");
+ await chmod(binary, 0o755);
+ const result = await execute(process.execPath, [resolve("packages/complexity-gate/bin/complexity-gate"), "check", "a.ts"], { ...process.env, COMPLEXITY_GATE_BIN: binary });
+ expect(result).toEqual({ code: 7, stdout: "check a.ts" });
+ });
+});
diff --git a/packages/complexity-gate/test/extension.test.ts b/packages/complexity-gate/test/extension.test.ts
new file mode 100644
index 0000000..d1e5790
--- /dev/null
+++ b/packages/complexity-gate/test/extension.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it, vi } from "vitest";
+import complexityGateExtension from "../extensions/complexity-gate.ts";
+
+const violation = JSON.stringify({ violations: [{ file: "src/a.ts", line: 2, function: "work", metric: "depth", value: 5, limit: 4 }] });
+
+function host(stdout = violation, code = 1) {
+ const handlers = new Map Promise>();
+ const sendUserMessage = vi.fn();
+ const exec = vi.fn(async () => ({ code, stdout, stderr: "" }));
+ const pi = { exec, sendUserMessage, on: (name: string, handler: never) => handlers.set(name, handler) };
+ complexityGateExtension(pi as never);
+ const ctx = { cwd: "/repo", ui: { notify: vi.fn() } };
+ return { handlers, sendUserMessage, exec, ctx };
+}
+
+describe("Pi extension", () => {
+ it("injects feedback after an edited file violates", async () => {
+ const { handlers, ctx, exec } = host();
+ const result = await handlers.get("tool_result")!({ toolName: "edit", input: { path: "src/a.ts" }, content: [] }, ctx) as any;
+ expect(exec).toHaveBeenCalledWith("complexity-gate", ["check", "src/a.ts", "--format", "json"], { cwd: "/repo" });
+ expect(result.content[0].text).toContain("FAIL src/a.ts:2 work");
+ expect(ctx.ui.notify).toHaveBeenCalled();
+ });
+
+ it("stays silent when clean", async () => {
+ const { handlers, ctx, sendUserMessage } = host(JSON.stringify({ violations: [] }), 0);
+ expect(await handlers.get("tool_result")!({ toolName: "write", input: { path: "src/a.ts" }, content: [] }, ctx)).toBeUndefined();
+ await handlers.get("agent_end")!({}, ctx);
+ expect(sendUserMessage).not.toHaveBeenCalled();
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
+ });
+
+ it("limits changed-file follow-ups to three per session", async () => {
+ const { handlers, ctx, sendUserMessage } = host();
+ for (let count = 0; count < 5; count += 1) await handlers.get("agent_end")!({}, ctx);
+ expect(sendUserMessage).toHaveBeenCalledTimes(3);
+ expect(sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("Refactor the listed functions"), { deliverAs: "followUp" });
+ });
+});
diff --git a/packages/complexity-gate/test/install.test.ts b/packages/complexity-gate/test/install.test.ts
new file mode 100644
index 0000000..a426d98
--- /dev/null
+++ b/packages/complexity-gate/test/install.test.ts
@@ -0,0 +1,74 @@
+import { spawnSync } from "node:child_process";
+import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { hookFragments, install, mergeHookFragment, spawnPi } from "../src/install.ts";
+
+const roots: string[] = [];
+afterEach(async () => Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true }))));
+
+describe("hook installation", () => {
+ it("preserves hooks and is idempotent", () => {
+ const existing = { hooks: { Stop: [{ hooks: [{ type: "command", command: "other" }] }] }, other: true };
+ const once = mergeHookFragment(existing, hookFragments.claude);
+ expect(mergeHookFragment(once, hookFragments.claude)).toEqual(once);
+ expect(once.other).toBe(true);
+ expect((once.hooks as Record).Stop).toHaveLength(2);
+ });
+
+ it("writes the same file only once", async () => {
+ const home = await mkdtemp(join(tmpdir(), "complexity-install-"));
+ roots.push(home);
+ await mkdir(join(home, ".claude"));
+ await writeFile(join(home, ".claude", "settings.json"), "{\"theme\":\"dark\"}\n");
+ await install(["--harness", "claude", "--home", home]);
+ const once = await readFile(join(home, ".claude", "settings.json"), "utf8");
+ await install(["--harness", "claude", "--home", home]);
+ expect(await readFile(join(home, ".claude", "settings.json"), "utf8")).toBe(once);
+ expect(JSON.parse(once).theme).toBe("dark");
+ });
+
+ it.each(["[]", "42", '{"hooks":"invalid"}', '{"hooks":{"Stop":"x"}}'])("rejects invalid settings without changing %s", async (contents) => {
+ const home = await mkdtemp(join(tmpdir(), "complexity-invalid-"));
+ roots.push(home);
+ const path = join(home, ".claude", "settings.json");
+ await mkdir(join(home, ".claude"));
+ await writeFile(path, contents);
+ const result = spawnSync(process.execPath, [resolve("packages/complexity-gate/src/install.ts"), "--harness", "claude", "--home", home], { encoding: "utf8" });
+ expect(result.status).not.toBe(0);
+ expect(result.stderr).toContain(path);
+ expect(await readFile(path, "utf8")).toBe(contents);
+ });
+
+ it("preserves unrelated keys and hooks", async () => {
+ const home = await mkdtemp(join(tmpdir(), "complexity-preserve-"));
+ roots.push(home);
+ const path = join(home, ".claude", "settings.json");
+ const existing = { theme: "dark", hooks: { Custom: [{ command: "keep" }], Stop: [{ hooks: [{ command: "other" }] }] } };
+ await mkdir(join(home, ".claude"));
+ await writeFile(path, JSON.stringify(existing));
+ await install(["--harness", "claude", "--home", home]);
+ const installed = JSON.parse(await readFile(path, "utf8"));
+ expect(installed.theme).toBe(existing.theme);
+ expect(installed.hooks.Custom).toEqual(existing.hooks.Custom);
+ expect(installed.hooks.Stop[0]).toEqual(existing.hooks.Stop[0]);
+ });
+
+ it("runs pi.cmd through cmd.exe on Windows", async () => {
+ const spawn = vi.fn();
+ spawnPi("pi.cmd", "win32", spawn as never);
+ expect(spawn).toHaveBeenCalledWith("cmd.exe", ["/d", "/s", "/c", "pi.cmd", "install", "npm:@pickforge/complexity-gate"], { stdio: "inherit" });
+ });
+
+ it("prints fragments without writing", async () => {
+ const home = await mkdtemp(join(tmpdir(), "complexity-print-"));
+ roots.push(home);
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ await install(["--print", "--all", "--home", home]);
+ expect(log).toHaveBeenCalledTimes(3);
+ expect(log.mock.calls.flat().join("\n")).toContain("complexity-gate hook codex");
+ await expect(readFile(join(home, ".claude", "settings.json"))).rejects.toMatchObject({ code: "ENOENT" });
+ log.mockRestore();
+ });
+});
diff --git a/packages/complexity-gate/test/postinstall.test.ts b/packages/complexity-gate/test/postinstall.test.ts
new file mode 100644
index 0000000..61a9c81
--- /dev/null
+++ b/packages/complexity-gate/test/postinstall.test.ts
@@ -0,0 +1,62 @@
+import { createHash } from "node:crypto";
+import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { downloadBinary, postinstall } from "../src/postinstall.ts";
+
+const roots: string[] = [];
+afterEach(async () => Promise.all(roots.splice(0).map((path) => rm(path, { recursive: true, force: true }))));
+
+describe("binary downloader", () => {
+ it("vendors the binary from the cargo-dist archive directory", async () => {
+ const root = await mkdtemp(join(tmpdir(), "complexity-archive-"));
+ roots.push(root);
+ const stem = "complexity-gate-x86_64-unknown-linux-gnu";
+ await mkdir(join(root, stem));
+ await writeFile(join(root, stem, "complexity-gate"), "#!/bin/sh\necho fake\n");
+ await chmod(join(root, stem, "complexity-gate"), 0o755);
+ await promisify(execFile)("tar", ["-cJf", join(root, `${stem}.tar.xz`), "-C", root, stem]);
+ const archive = await readFile(join(root, `${stem}.tar.xz`));
+ const checksum = createHash("sha256").update(archive).digest("hex");
+ const fetchImpl = vi.fn(async (url: string) => new Response(url.endsWith("sha256.sum")
+ ? `${checksum} *${stem}.tar.xz\n`
+ : new Uint8Array(archive)));
+ const vendorDir = join(root, "vendor");
+ const destination = await downloadBinary({ vendorDir, platform: "linux", arch: "x64", fetchImpl: fetchImpl as unknown as typeof fetch });
+ expect(destination).toBe(join(vendorDir, "complexity-gate"));
+ expect((await stat(destination)).mode & 0o111).not.toBe(0);
+ });
+
+
+ it("uses the configurable cargo-dist asset name with fake fetch", async () => {
+ const vendorDir = await mkdtemp(join(tmpdir(), "complexity-download-"));
+ roots.push(vendorDir);
+ const archive = new TextEncoder().encode("not an archive");
+ const checksum = createHash("sha256").update(archive).digest("hex");
+ const fetchImpl = vi.fn(async (url: string) => new Response(url.endsWith("sha256.sum")
+ ? `${checksum} complexity-gate-x86_64-unknown-linux-gnu.tar.xz\n`
+ : archive));
+ await expect(downloadBinary({ vendorDir, platform: "linux", arch: "x64", tag: "v9.8.7", fetchImpl: fetchImpl as unknown as typeof fetch })).rejects.toThrow("tar exited");
+ expect(fetchImpl.mock.calls[0]![0]).toContain("/v9.8.7/complexity-gate-x86_64-unknown-linux-gnu.tar.xz");
+ });
+
+ it("rejects a checksum mismatch before extraction", async () => {
+ const fetchImpl = vi.fn(async (url: string) => new Response(url.endsWith("sha256.sum")
+ ? `${"0".repeat(64)} complexity-gate-x86_64-unknown-linux-gnu.tar.xz\n`
+ : "archive"));
+ await expect(downloadBinary({ vendorDir: "/unused", platform: "linux", arch: "x64", fetchImpl: fetchImpl as unknown as typeof fetch })).rejects.toThrow("checksum mismatch");
+ });
+
+ it("skips download when the binary override is set", async () => {
+ const previous = process.env.COMPLEXITY_GATE_BIN;
+ process.env.COMPLEXITY_GATE_BIN = "/custom/gate";
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ await postinstall();
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("skipping binary download"));
+ warn.mockRestore();
+ if (previous === undefined) delete process.env.COMPLEXITY_GATE_BIN; else process.env.COMPLEXITY_GATE_BIN = previous;
+ });
+});