SLOPSHOPPER

quality-gate

Local JavaScript and TypeScript function complexity and measured V8 coverage, with per-function CRAP scores strictly below 6.

bandguardcommandstatuspromptprocess
★ 283v1.3.3MITupdated 2026-10-26Eigenwise/eigenwise-toolshed/plugins/quality-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quality-gate
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /quality-gate ⎿ quality-gate: Usage: /quality-gate on|off|markers on|markers off|status ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Quality Gate

/plugin marketplace add Eigenwise/eigenwise-toolshed
/plugin install quality-gate@eigenwise-toolshed

The first run installs the parser; nothing to do by hand. Node 22.5+ and npm are required. Quality Gate keeps TypeScript 7 in the plugin, with a shared installation lock and a 180-second deadline. quality-gate provision installs only the parser, or exits 0 when it is present. Offline or missing npm exits 2 with npm install --prefix <plugin root> as the remedy.

Quality Gate owns AST complexity, stable function identities and per-function CRAP scoring. No config is required. Without LCOV at coverage/lcov.info, crap checks complexity only and prints a coverage setup notice. Explicit LCOV and coverage commands still require authentic measured coverage.

--source <path> narrows configured or default sources for one run without changing config. Repeat it for multiple files or directories, relative to the measured project root, for example crap --project <root> --source src/changed.py --source src/other.py --cc-only. Config exclusions still apply; selectors cannot widen configured sources.

Invalid UTF-8, Python syntax errors and lone surrogates produce an informational UNVERIFIED row for that file. Other selected files are still measured.

The CRAP report also prints informational reuse signals after scored rows, including with --cc-only. DUPLICATE? name ≈ other (file:line) means a new body shares a name (ignoring case, underscores and hyphens) with a top-level JS/TS/Python definition in another source file at HEAD. CALLERS OPEN name: file:line lists up to two remaining call sites outside a removed body's old file. Removal uses the existing baseline/body pairing, including deleted files within the selected scope. Tests, fixtures, vendored and generated paths are skipped. These are regex/name checks: nested definitions are missed, and a same-named call in another module can count. They never change the verdict, exit code or receipts. JSON reports put the rows under signals; the summary includes the reuse-signal count.

Live measurement

The hooks module records edits and test-command results by working directory. After each turn with edits, it runs the plugin's CLI once per edited working directory to measure changed function complexity and reuse signals, then publishes the report with test pass/fail and stale status. Each CLI run has a 20-second timeout; tool calls never launch measurement. Subagent worktrees stay separate. Without coverage, it shows quality-gate: complexity only, coverage not set up once per session. Measurement failures leave the previous report untouched. Above the prompt, a single line stacks above other bands: $<cost> · CC <max> <ok|SPLIT n> · tests <pass|fail|none>[ stale]. SPLIT counts new or complexity-raised bodies at CC 6 or higher. The first unverified report adds · coverage not set up, then the status line keeps the reminder. The band yields to surveys and disappears with /quality-gate off or before the first report.

Turn measurement and inventory

quality-gate turn --json --project <root>
quality-gate turn --json --project <root> --since 2026-10-18T08:00:00Z
quality-gate inventory --project <root> --bytes 2048

turn measures staged, unstaged and untracked JS/TS/Python edits against HEAD. --since selects tracked and untracked files by modification time instead. It returns {bodies, signals, unverified, lcov}; each body has {file, name, cc, state, verdict}. States are new, modified-raised, legacy-unchanged; verdicts are pass, fail, unverified. Signals have {kind, file, name, detail} with kinds duplicate or open-callers. Coverage is never run or claimed: unverified is true and lcov is the detected default path or null.

inventory reuses the name-based top-level definition inventory at HEAD (JS/TS/Python, excluding tests and generated/vendor paths). Each line is name file; truncated output ends with … N more. The byte limit includes that line; budgets too small for the omission line produce no output.

Project CRAP gate

node plugins/quality-gate/bin/quality-gate.js crap --project <root> --base <revision>

--project selects the tree measured, including a linked worktree, independently of cwd. Without it the command uses cwd's Git root. Configuration comes from that tree's .claude/quality-gate/crap.json. The old .claude/quartermaster/crap.json is a fallback with a one-line migration notice. Every key is optional; flags override config:

{
  "sources": ["src"],
  "exclude": ["**/*.test.*"],
  "base": "main",
  "pythonPolicy": "python-ast-1",
  "lcov": "coverage/lcov.info",
  "coverageCommand": "your coverage command",
  "coverageTimeoutMs": 300000
}

Defaults: tracked JS/TS/Python files from git ls-files, excluding node_modules, dist, build, coverage, vendor and /test/fixtures/, coverage/lcov.info, no coverage command, local develop, main or master as the base. If none exists, or --base HEAD is selected, all selected bodies are scored. --base uses the merge base with HEAD, and only selected files that differ from it (or are untracked) are parsed, so unchanged files never cost a parse. One TypeScript parser process serves the whole run and has exited before the report returns. --ratchet and config ratchet remain accepted with a deprecation notice. max is accepted only as 6. Directory scans skip .git, node_modules and .claude; explicitly named files still work.

Each changed body is new, modified-raised (complexity increased) or legacy-unchanged (complexity held or fell). New and complexity-raised bodies must score strictly below 6. When coverage is UNVERIFIED, cc² + cc is the maximum possible CRAP: a bound strictly below 6 proves a pass without a coverage value. Legacy edits with equal or lower complexity are informational LEGACY rows, with their measured numbers; they never fail the gate. Exact owned source bytes determine change, so comments and formatting count. Nested owned bodies are excluded from their parent's fingerprint. Explicit user/project rules remain authoritative. Scores are compared before display rounding. Functions unchanged at the base don't enter the scoring scope; an empty scope says nothing to score and why. JS/TS baseline pairing anchors identical owned text first, then anonymous callbacks by their enclosing call expression and first string argument (for example, the test title), within the paired enclosing function. Positional fallback handles remaining bodies. Paired JSON rows report matchedBy: "text" | "call-site" | "position"; new bodies have matchedBy: null. Inserting tests above an edited callback preserves its baseline partner and the existing classification rules.

JS/TS .js, .mjs, .cjs, .ts, .mts, .cts, .jsx and .tsx use runtime function definitions from the AST. Type aliases and interfaces aren't functions. Nested bodies own their decisions independently. PHP and Vue script sections use lizard; install it with uv tool install lizard, pipx install lizard or pip install lizard. PHP uses exact bytes in its bound Lizard line span; Vue uses the bound script AST's owned bytes. Overlapping Lizard spans (nested or shared-line bodies) cannot establish independent ownership and are UNVERIFIED. A row that cannot be bound to a source definition is informational-unverified. --complexity <lizard.csv> accepts existing rows for all Lizard languages, including Rust, Go, Kotlin, Java, C/C++, Swift, Ruby, C# and Scala. These languages can also use a local lizard run. It never supplies a JS/TS or Python score. Lizard complexity-only runs enforce the fixed threshold; LCOV coverage stays UNVERIFIED because these adapters have no executable-line inventory. Unsupported source-only changes report each extension and changed-file count and exit 2; docs-only changes still exit 0 with "nothing to score". Python uses the stdlib AST adapter described below, with no radon or pip dependency at runtime.

--cc-only measures the complexity floor without touching coverage. Complexity 6 or more fails at any coverage; split the function, more tests cannot help. This mode rejects explicit --lcov and --coverage-command and ignores the configured coverage command. It cannot prove a full CRAP pass.

Python measurement policies

lib/python/policies.json is the supported policy enum. --python-policy overrides config pythonPolicy. The default is python-ast-1; no filename, project or consumer policy automatically selects another convention. The direct adapter stdin protocol is {text, file, policy?, oracles?} using exact original source and its project-relative POSIX filename. Node callers use pythonDescriptors(text, file, root, policy?, oracles?). Unknown policies and malformed selectors are refused.

Default: python-ast-1

Oracle qualification from source: test_* functions in test_*.py or _test.py, Test class methods and unittest.TestCase methods, each with an explicit reason string in the row; fixtures and setup (pytest fixtures, setUp/tearDown, conftest bodies) classified separately and never as oracles; nested classes, methods, closures and callbacks are independently owned rows that qualify only on their own merits, never by their parent; the assertion-statement surcharge is removed only on qualified oracle rows, and every decision inside an assert predicate or message is counted on every row.

The shipped lib/python/crap_adapter.py walks actual source with Python's stdlib ast. Node invokes the project's python3 or Windows py -3 from its root. Python 3.10 or newer is needed for match syntax. The adapter version is included in every Python JSON row and text row, with oracleClassification (oracle, fixture-setup or production), oracleReason, assertion count, decision count and qualified body name. Named lambda assignments include their assignment name; unnamed callbacks use <lambda> and a per-parent ordinal identity.

A body starts at 1. Add one for each if, conditional expression, for/async for, while and exception handler; one per short-circuit boolean operator; one per comprehension generator and filter; one per non-wildcard match case and per guard. Assertions add one except on qualified oracle bodies. Traversal still enters their predicates and messages, including f-string expressions. Nested bodies own their decisions and fingerprints, so editing only a closure doesn't raise its enclosing body's score. Decorators are preserved in source fingerprints; they do not add body decisions.

Test filename patterns qualify test functions and Test* methods; source-resolved unittest.TestCase bases also qualify methods outside those filenames. Import aliases for pytest.fixture and unittest.TestCase are recognized. Setup/teardown names in test modules or TestCase methods and every conftest body remain fixture/setup, retaining their assert surcharge. A Session class nested inside a test function gets production methods, not inherited oracle status.

Opt-in: python-radon-6.0.1-oracle-1

Ordinary production, setup, fixture and helper bodies follow the inspected Radon 6.0.1 visitor decisions: loops add one plus a nonempty else; try adds its handlers plus a nonempty else; match adds its case count minus any wildcard, with no separate guard surcharge. Predicate decisions in match guards still count. Ordinary asserts add one and stop traversal of their predicates/messages, exactly as native Radon does. Qualified oracle asserts drop only that statement surcharge and retain decisions in predicates, messages, comprehensions and f-strings. Nested classes isolate their body assertions and decisions from enclosing bodies, including decorators and bases. Methods and lambdas within those classes are still discovered and scored independently. Independent nested method/closure/lambda ownership remains the shared source adapter's contract. Radon is neither installed nor executed at runtime.

Without an explicit collection, source-name qualification requires a test_* definition in test_*.py or _test.py, or a test_ method of a source-resolved unittest.TestCase subclass. Test*/TestCase membership alone never qualifies a helper. Fixture/setup rules take precedence. These static filename/name/import rules establish no runtime collection, unshadowed symbols, human intent or assertion execution.

Config pythonOracles, Node option pythonOracles and protocol oracles accept an optional authoritative allowlist for this policy only:

{
  "pythonPolicy": "python-radon-6.0.1-oracle-1",
  "pythonOracles": [
    { "file": "tests/test_subject.py", "name": "_assert_unchanged", "reason": "snapshot invariant oracle" },
    { "file": "tests/test_subject.py", "name": "TestSubject.test_replacement", "reason": "replacement invariant oracle" }
  ]
}

An absent collection uses source-name rules. A supplied empty array qualifies no oracles. A supplied collection qualifies only its exact independently owned bodies; unlisted test_* names and nested children use native ordinary semantics. Each entry must contain exactly file/name/reason as nonempty strings, with an exact relative POSIX .py path and source-qualified body name, no patterns or traversal. Duplicate/conflicting entries, missing/ambiguous body matches and attempts to select fixtures/setup are refused. The full gate also refuses entries outside its selected source scope; it never scans unrelated sources. A direct single-file packet ignores entries for other files, and validates matches for its own file. Baseline analysis restricts the same allowlist to bodies present in that baseline, allowing genuinely new selected bodies.

Every Python row includes measurementPolicy, concrete oracleReason and oracleSelector (the exact explicit entry, or null for source-name/ordinary rows). The top-level gate report and Git receipt carry pythonPolicy and a supplied pythonOracles collection. The adapter identity remains python-ast-1 separately from the selected measurement policy. Explicit selection establishes qualification only, never execution or coverage. Consumer adoption/settings/permissions and fresh independent exact-source acceptance remain separate authorities.

Python compiles the exact parsed source with optimization disabled to inventory executable bytecode lines, then attributes that inventory to each body's owned AST lines, excluding nested bodies. Every inventoried line needs an explicit DA record. Sibling bodies on the same executable source line are unverified because line coverage cannot separate them. Ancestor/descendant bodies with contained owned-line inventories share DA hits, with an explicit note that line execution is an upper bound for callback execution. Put those bodies on separate lines for per-function proof. Missing records, excluded statements and ambiguous multiline normalization are unverified, never guessed zero or full coverage. An explicit zero-hit DA record is measured zero. Docstrings and optimized-away statements that produce no bytecode lines do not enter the denominator; an empty inventory remains unverified. Python keeps the same hash proof, strict threshold and physical base comparison.

Use locally installed coverage.py (for example pipx install coverage) to generate LCOV with coverage run script.py then coverage lcov -o <path>. A coverageCommand can write its data file and LCOV into QUALITY_GATE_COVERAGE_DIR. The live regression skips with an explicit UNVERIFIED reason when coverage.py is absent; the adapter itself needs only Python.

Upgrade to measured coverage

--coverage-command runs in the measured tree with no time limit by default. Set coverageTimeoutMs in either config path, or override it with --coverage-timeout-ms <ms>. Values are integers from 0 to 2147483647 milliseconds; 0 disables the limit. The 300000 value above is an optional 5-minute example. On timeout the gate names the elapsed limit, setting and flag, kills the shell and its process tree (Windows and POSIX), and leaves coverage UNVERIFIED. Sources are hashed before and after; a source edit invalidates the run. QUALITY_GATE_COVERAGE_DIR and the compatible QUARTERMASTER_COVERAGE_DIR name a fresh empty report directory. Write lcov.info there. $VAR, ${VAR} and %VAR% in the command are expanded from its environment before it is spawned, so one command works under cmd.exe and sh alike; an unset name stays as written. Explicit CLI --lcov wins over isolated output and config. Otherwise isolated output wins over config lcov, which is a fallback (default coverage/lcov.info). Without a command, the CLI path, configured path or default is read in that order. Existing fallback or explicit output must be rewritten during the command. Missing or invalid selected output stays unverified; a stale configured file never replaces fresh isolated output.

For pre-existing LCOV, supply a sibling <lcov>.sources.json containing SHA-256 hashes captured alongside that coverage, keyed by project-relative source path:

{ "src/example.ts": "<sha256 of the scored source bytes at coverage capture>" }

A missing or differing hash makes coverage unverified. Generating hashes later against arbitrary old coverage does not establish provenance. Source hashes prove byte identity, not executable-line completeness or coverage exclusions. Python requires its exact-source bytecode inventory and complete DA records. JS/TS inventories each body's executable lines from the TypeScript AST: the start line of every statement the body owns, with variable declarations anchored to each initializer and parenthesized expression bodies anchored to the inner expression. Uninitialized declarators add no line. It excludes nested bodies, decorators, blocks, empty statements, type aliases, interfaces and declare statements. Coverage is hit lines over that inventory. Every inventoried line needs a DA record (c8 and Istanbul both emit one per statement line); a missing record, sibling bodies sharing an executable line and an empty body stay UNVERIFIED. When parent identities prove ancestry and the descendant's owned lines are contained in the ancestor's (including equal inventories), each body uses its own executable lines, including shared DA hits. Every affected row includes a coverageNote: DA counts measure line execution, an upper bound for callback execution, rather than independent callback hits. This applies to JS/TS callbacks and Python lambdas; sibling callbacks on one line still require separate lines. Lizard adapters (PHP, Vue and the rest) have no executable-line inventory and return UNVERIFIED even when all supplied hits are positive or zero. The measure entry with genuine same-session V8 capture stays available for exact byte-range JS/TS coverage.

Vitest projects need their own coverage provider's LCOV output, for example through an installed matching @vitest/coverage-v8. Raw NODE_V8_COVERAGE from vite-node can contain only evalmachine.<anonymous> entries, which cannot identify scored sources. Missing coverage stays unverified, with the reason and complexity shown.

Exit codes: 0 for passed, nothing to score or informational-only rows; 1 for a score failure; 2 for a missing prerequisite or an enforced row whose verdict still depends on unverified coverage. At cc 1 the maximum CRAP is 2, so UNVERIFIED coverage passes with a bound note and its coverage reason retained. At cc 2 the bound equals 6, so cc 2 to 5 still need coverage. JSON retains the reason in changedMetrics[].unverified and adds boundVerdict: "pass"; bound-passing rows are excluded from the blocking unverified list. --json prints rows, hashes, class counts, verdict and receipt path. --help lists the flags and next actions.

Receipts for hook consumers

Every completed Git crap or measure run writes .claude/quality-gate/receipts/<head-sha>.json, replacing that HEAD's previous run atomically. Set --receipts-dir <path> on either command to keep receipts outside the measured tree. It overrides QUALITY_GATE_RECEIPTS_DIR; relative paths resolve from cwd. The default directory is unchanged. Prerequisite and unverified crap runs also write a receipt when HEAD exists. No receipt is written outside a repository with a committed HEAD.

Schema: gateVersion (crap-1), pythonPolicy, optional pythonOracles, project (measured root), base, head, files (relative path to SHA-256 map), rowCounts (new, modified-raised, legacy-unchanged, unverified), verdict, timestamp, ccOnly and reasons. Verdicts are passed, failed, unverified or nothing to score. Informational-unverified rows still produce an unverified receipt, even with exit 0. A prerequisite receipt has an empty file map and its reason.

The live measurement hook publishes informational turn reports, not acceptance receipts. A receipt consumer must compare the receipt's root, HEAD and file hashes with the current tree; it must not accept ccOnly, nothing to score, failed or unverified as a full CRAP pass. The Q3 consumers report unverified receipts once per session, avoiding repeated blocking loops.

Existing V8 measurement entry

node plugins/quality-gate/bin/quality-gate.js measure --project <root> --base <revision> --coverage <V8-directory> <files...>

This command consumes an existing same-session V8 capture and prints JSON. It runs no tests and installs no hooks. Capture with QUALITY_GATE_V8_CAPTURE=<empty directory> and the canonical realpath file URL of lib/core.mjs in NODE_OPTIONS=--import=<file URL>. Children must inherit both environment values; a command-line --import alone does not preload children. The preload uses Node's inspector to save precise coverage and Debugger.getScriptSource bytes from the same session. Keep that capture unchanged. In the inspected Claude Code 2.1.295, a direct worktree-isolated Bash startup assignment NODE_OPTIONS=--import=<url> followed by a Node-family command is refused by the host guard, because startup code cannot be shown not to be Git. Quality Gate does not issue that refusal, an immutable URL or hash did not change it, and no shipped capture-launch command supports that exact boundary. Other contexts, capture methods and versions were not assessed. After that actual refusal, stop: the measurement stays UNVERIFIED, and do not retry, reword, split the command or route around it. Raw NODE_V8_COVERAGE reports lack executed-byte binding and return UNVERIFIED; hashing today's files alongside old reports does not repair them.

Changed-hunk branch checks

measure fails every new or complexity-raised body at complexity 6 or more, including when coverage is UNVERIFIED. The failure row says failure: complexity floor and retains the coverage reason. Below that floor, unverified coverage remains measurement debt. A cc 1 body passes by the same cc² + cc bound, with boundVerdict: "pass" in changedMetrics; cc 2 to 5 remain unresolved. measure exits 1 for failures or unresolved measurements, otherwise 0. The bound does not excuse untaken or unverified branch outcomes. Equal/lowered legacy bodies stay informational.

measure keeps function coverage unchanged and also reads precise block counts for conditions in --base changed hunks. New and complexity-raised bodies fail when an if/else, ternary, logical short-circuit, switch arm or loop outcome never executed. Legacy bodies with equal/lowered complexity report the same evidence informationally. Untouched condition lines do not enter this check. Explicit external-new files check every condition.

Each JSON row includes branches: [{line, text, untaken}]. Only untaken or UNVERIFIED conditions appear. The JSON summary and stderr summary count branch failures, informational conditions and UNVERIFIED conditions. Add a

Source 4 files
hooks/register.ts 66 lines
1import type { EngineInterface, Register } from 'claude-code';
2import type { QualityGateStore } from '../types';
3import { registerMeasurement } from './measure.ts';
4import { registerBand } from './band.tsx';
5
6async function readSettings($: EngineInterface): Promise<QualityGateStore> {
7  return {
8    enabled: (await $.store.get('enabled')) !== false,
9    markers: (await $.store.get('markers')) !== false,
10  };
11}
12
13async function inventory($: EngineInterface, cwd: string): Promise<string> {
14  try {
15    const result = await $.process.run(['node', `${$.plugin.root}/bin/quality-gate.js`, 'inventory', '--project', cwd, '--bytes', '2048'], { timeoutMs: 10000 });
16    return result.exitCode === 0 ? result.stdout.trim() : '';
17  } catch {
18    return '';
19  }
20}
21
22async function status($: EngineInterface): Promise<string> {
23  const settings = await readSettings($);
24  return `quality-gate ${settings.enabled ? 'on' : 'off'}; markers ${settings.markers ? 'on' : 'off'}`;
25}
26
27export const register: Register = (on) => {
28  registerMeasurement(on);
29  registerBand(on);
30  let reuseInventory = '';
31  let inventoryStarted = false;
32
33  on('session.start', async ($, event, next) => {
34    const result = await next(event);
35    await $.command.register({ name: 'quality-gate', description: 'Switch quality guidance and shortcut markers on or off.', argumentHint: 'on|off|markers on|markers off|status' });
36    if (!inventoryStarted) {
37      inventoryStarted = true;
38      void $.process.run(['node', `${$.plugin.root}/bin/quality-gate.js`, 'provision'], { timeoutMs: 180000 }).catch(() => undefined);
39      reuseInventory = await inventory($, event.cwd);
40    }
41    return result;
42  });
43
44  on('prompt.compose', async ($, event, next) => {
45    const result = await next(event);
46    const settings = await readSettings($);
47    if (!settings.enabled) return result;
48    let text = await $.fs.read(`${$.plugin.root}/skills/quality/PERSONA.md`);
49    if (!settings.markers) text = text.replace(/^A shortcut[^\n]*$/m, 'List each shortcut and its limit at the end of your reply, never as a source comment.');
50    if (reuseInventory) text += `\nReuse inventory:\n${reuseInventory}`;
51    return { sections: [...result.sections, { id: 'quality-gate:persona', text, scope: 'session' }] };
52  });
53
54  on('command.run', { command: 'quality-gate' }, async ($, event) => {
55    const argument = event.args.trim();
56    const changes: Record<string, readonly [keyof QualityGateStore, boolean]> = {
57      on: ['enabled', true], off: ['enabled', false],
58      'markers on': ['markers', true], 'markers off': ['markers', false],
59    };
60    const change = changes[argument];
61    if (change) await $.store.set(change[0], change[1]);
62    else if (argument !== 'status') return { text: 'Usage: /quality-gate on|off|markers on|markers off|status' };
63    return { text: await status($) };
64  });
65};
66
hooks/measure.ts 98 lines
1import type { EngineInterface, On } from 'claude-code'
2import type { TestRun, TurnReport } from '../types'
3
4export const TEST_COMMAND = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\bnode\s+--test\b|\bpytest\b|\bvitest\b|\bjest\b|\bgo test\b|\bcargo test\b/
5const TURN = { plugin: 'quality-gate', key: 'turn' } as const
6const NOTICE = { plugin: 'quality-gate', key: 'coverageNoticeShown' } as const
7
8type ProjectTurn = { files: Set<string>; since: string; agentId?: string; tests: TestRun }
9
10function editedPath(input: unknown): string | undefined {
11  if (typeof input !== 'object' || input === null) return
12  return [Reflect.get(input, 'file_path'), Reflect.get(input, 'notebook_path')].find((value): value is string => typeof value === 'string')
13}
14
15function unfinishedShell(output: unknown): boolean {
16  if (typeof output !== 'object' || output === null) return false
17  return Reflect.get(output, 'backgroundTaskId') !== undefined || Reflect.get(output, 'interrupted') === true
18}
19
20async function coverageNotice($: EngineInterface, unverified: boolean) {
21  if (!unverified) return
22  if ((await $.state.get(NOTICE)).value) return
23  await $.ui.status('complexity only, coverage not set up')
24  await $.state.set(NOTICE, true)
25}
26
27async function measureProject($: EngineInterface, cwd: string, project: ProjectTurn) {
28  const result = await $.process.run(['node', `${$.plugin.root}/bin/quality-gate.js`, 'turn', '--json', '--project', cwd, '--since', project.since], { cwd, timeoutMs: 20000 })
29  if (result.exitCode !== 0) return
30  const report: TurnReport = JSON.parse(result.stdout)
31  if (!Array.isArray(report.bodies) || !Array.isArray(report.signals) || typeof report.unverified !== 'boolean') return
32  const { value = {} } = await $.state.get(TURN)
33  const at = new Date(await $.clock.now()).toISOString()
34  await $.state.set(TURN, { ...value, [cwd]: { at, bodies: report.bodies, signals: report.signals, unverified: report.unverified, tests: { ...project.tests } } })
35  await coverageNotice($, report.unverified)
36}
37
38export function registerMeasurement(on: On) {
39  const projects = new Map<string, ProjectTurn>()
40  const starts = new Map<string | undefined, string>()
41
42  function projectFor(cwd: string, at: string, agentId?: string): ProjectTurn {
43    let project = projects.get(cwd)
44    if (!project) {
45      project = { files: new Set(), since: at, agentId, tests: { status: 'none', at: '', stale: false } }
46      projects.set(cwd, project)
47    }
48    return project
49  }
50
51  on('turn.start', async ($, event, next) => {
52    const result = await next(event)
53    try {
54      starts.set(event.agentId, new Date(await $.clock.now()).toISOString())
55    } catch {}
56    return result
57  })
58
59  on('classic.PostToolUse', { tool_name: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, event, next) => {
60    const result = await next(event)
61    try {
62      const file = editedPath(event.tool_input)
63      if (!file) return result
64      const at = new Date(await $.clock.now()).toISOString()
65      const project = projectFor(event.cwd, at, event.agent_id)
66      if (project.files.size === 0) project.since = starts.get(event.agent_id) ?? at
67      project.files.add(file)
68      project.tests.stale = true
69    } catch {}
70    return result
71  })
72
73  on('tool.call', { tool: ['Bash', 'PowerShell'], command: TEST_COMMAND }, async ($, event, next) => {
74    const result = await next(event)
75    try {
76      if (unfinishedShell(result.result)) return result
77      const cwd = await $.session.cwd()
78      const at = new Date(await $.clock.now()).toISOString()
79      const project = projectFor(cwd, at, event.agentId)
80      project.tests = { status: result.isError || result.deny ? 'fail' : 'pass', at, stale: false }
81    } catch {}
82    return result
83  })
84
85  on('turn.complete', async ($, event, next) => {
86    const result = await next(event)
87    for (const [cwd, project] of projects) {
88      if (project.agentId !== event.agentId || project.files.size === 0) continue
89      project.files.clear()
90      try {
91        await measureProject($, cwd, project)
92      } catch {}
93    }
94    starts.delete(event.agentId)
95    return result
96  })
97}
98
hooks/band.tsx 56 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { MeasuredBody, TurnMeasurement } from '../types'
4
5const turns = atom({ plugin: 'quality-gate', key: 'turn' } as const, {})
6const noticeAt = atom({ plugin: 'quality-gate', key: 'bandCoverageNoticeAt' } as const, '')
7
8function needsSplit(body: MeasuredBody): boolean {
9  return body.state !== 'legacy-unchanged' && body.cc >= 6
10}
11
12function complexityText(bodies: MeasuredBody[]): string {
13  const maximum = Math.max(0, ...bodies.map(body => body.cc))
14  const splits = bodies.filter(needsSplit).length
15  return `CC ${maximum} ${splits ? `SPLIT ${splits}` : 'ok'}`
16}
17
18async function coverageSuffix($: EngineInterface, turn: TurnMeasurement): Promise<string> {
19  if (!turn.unverified) return ''
20  const shownAt = await read($, noticeAt)
21  return shownAt === turn.at ? ' · coverage not set up' : ''
22}
23
24async function bandText($: EngineInterface, turn: TurnMeasurement): Promise<string> {
25  const usage = await $.session.usage()
26  const cost = usage.cost?.usd ?? 0
27  const stale = turn.tests.stale ? ' stale' : ''
28  return `$${cost.toFixed(2)} · ${complexityText(turn.bodies)} · tests ${turn.tests.status}${stale}${await coverageSuffix($, turn)}`
29}
30
31export function registerBand(on: On) {
32  on('state.set', { plugin: 'quality-gate', key: 'turn' }, async ($, event, next) => {
33    const result = await next(event)
34    const turn = event.value[await $.session.cwd()]
35    if (!turn?.unverified || (await $.store.get('enabled')) === false) return result
36    if (!(await read($, noticeAt))) {
37      await $.state.set({ plugin: 'quality-gate', key: 'bandCoverageNoticeAt' }, turn.at)
38    }
39    return result
40  })
41  on('ui.render', { component: 'AbovePrompt' }, async ($, event, next) => {
42    const below = await next(event)
43    if (event.props.hasSurvey || (await $.store.get('enabled')) === false) return below
44    const turn = (await read($, turns))[await $.session.cwd()]
45    if (!turn) return below
46    const text = await bandText($, turn)
47    const { Box, Text } = $.ui.resolve(event)
48    return (
49      <Box flexDirection="column" width={event.props.bodyColumns}>
50        <Text dimColor wrap="truncate-end">{text}</Text>
51        {below}
52      </Box>
53    )
54  })
55}
56
types/index.d.ts 34 lines
1export type MeasuredBody = {
2  file: string
3  name: string
4  cc: number
5  state: 'new' | 'modified-raised' | 'legacy-unchanged'
6  verdict: string
7}
8
9export type ReuseSignal = {
10  kind: 'duplicate' | 'open-callers'
11  file: string
12  name: string
13  detail: string
14}
15
16export type TestRun = { status: 'pass' | 'fail' | 'none'; at: string; stale: boolean }
17export type TurnReport = { bodies: MeasuredBody[]; signals: ReuseSignal[]; unverified: boolean }
18export type TurnMeasurement = TurnReport & { at: string; tests: TestRun }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'quality-gate': {
23      turn: Record<string, TurnMeasurement>
24      coverageNoticeShown: boolean
25      bandCoverageNoticeAt: string
26    }
27  }
28}
29
30export interface QualityGateStore {
31  enabled: boolean
32  markers: boolean
33}
34