One static binary that measures function complexity with tree-sitter and blocks coding agents (Claude Code, Codex, Pi) from finishing while the functions they changed exceed the limits. No external linters. Manual counting by a model is never an accepted measurement.
This document is the contract. Implementation, tests, and the harness package in
pickforge-platform/packages/complexity-gate follow it; deviations are reported
in the PR, not decided silently.
- Exact parity with ESLint, clippy, radon, gocyclo, or DCM. We are deterministic and at least as strict as those tools on the golden fixtures; small counting differences are expected and documented per language.
- Replacing repo-level CI gates (ESLint/clippy). Those stay; this is the agent-side gate.
- Cognitive complexity (v2 candidate).
| Metric | Definition | Default limit |
|---|---|---|
complexity |
cyclomatic: 1 + decision points in the function body, excluding nested functions |
15 |
depth |
max nesting of control-flow constructs (see below), nested functions reset to 0 | 4 |
lines |
lines from the function's first to last line inclusive, minus blank lines and comment-only lines (every non-whitespace byte inside comment nodes); nested functions included | 100 |
params |
declared parameters; a destructuring pattern counts as 1; receiver/self/this excluded |
6 |
A violation is value > limit. Test files (see config) are exempt from lines
only.
Each of the following adds 1:
if,else if/elif(a bareelseadds 0)- every loop:
for,for-in/of,while,do-while, Rustloopandwhile let, Python comprehensionfor - every non-default
case/arm inswitch/match/ Goselect;default,_, and bareelsearms add 0 - every
catch/exceptclause (afinallyadds 0) - every conditional expression: ternary
a ? b : c, Pythonx if c else y, comprehensioniffilter - every short-circuit boolean operator:
&&,||,??, Pythonand/or, and the assignment forms&&=,||=,??= - Rust
if let,let … else(counted as anif)
Not counted: optional chaining ?., Rust ?, null assertions, finally,
else, default arms, assert, default parameter values, try itself.
Constructs that open a level: if/else bodies, loops, switch/match, try
(the try body and every catch/except/finally body sit at the same level,
as in ESLint max-depth), Python with. An else if / elif chain stays at the level
of its first if. Conditional expressions and boolean operators do not add depth.
A nested function starts again at 0 and its body does not contribute to the
enclosing function's depth.
Functions are: function declarations, methods, constructors, getters/setters,
arrow functions, closures/lambdas, Python def/async def, Rust fn and
closures, Go func and function literals, Dart functions/methods/closures.
Names:
- named function/method →
name; methods →Type.namewhen the type is known - anonymous assigned to a binding → the binding name (
const handler = () => …→handler;foo: () => …→foo) - anonymous otherwise →
<anonymous> - Svelte template →
<template>(one synthetic function per component; see Svelte)
Each function is reported once with its own metrics. Nested functions are reported
separately; their decisions and depth are excluded from the parent, their lines
are included in the parent's lines.
| Language | Extensions | Grammar |
|---|---|---|
| JavaScript | .js .mjs .cjs .jsx |
tree-sitter-javascript |
| TypeScript | .ts .mts .cts |
tree-sitter-typescript (typescript) |
| TSX | .tsx |
tree-sitter-typescript (tsx) |
| Svelte | .svelte |
tree-sitter-svelte-ng (or equivalent) + TS grammar for <script> |
| Dart | .dart |
tree-sitter-dart |
| Rust | .rs |
tree-sitter-rust |
| Python | .py .pyi |
tree-sitter-python |
| Go | .go |
tree-sitter-go |
Anything else → UNVERIFIED. Grammar versions are pinned in Cargo.toml;
doctor --coverage (below) reports node kinds that look like control flow but are
not classified, so a grammar upgrade that introduces new syntax is visible.
<script>and<script context="module">/<script module>blocks are parsed with the TypeScript grammar (lang="ts") or JavaScript grammar. Functions inside are reported normally with their real line numbers in the.sveltefile.- The template is one synthetic function
<template>whose decision points are{#if},{:else if},{#each},{#await},{:catch}, and the boolean / ternary operators inside{…}expressions. Depth follows block nesting.<template>is exempt fromlinesandparams. - Style blocks are ignored.
- Rust:
matcharms count individually (a 20-armmatchon an enum is 20). This is stricter than clippy's cognitive metric by design; use a repo override if a crate is dominated by large dispatch matches. - Go: no ternary;
switchwith no tag counts eachcase;selectcounts eachcase. - Python:
matchcasearms count;case _does not. Comprehensionforandifeach count.withadds depth but no complexity. - Dart:
switchstatements and switch expressions count each case;??,??=count;?.does not; cascade..does not. - Svelte:
tree-sitter-svelte-ng1.0.2 is compatible. It exposes template expression contents assvelte_raw_text, so block structure comes from the grammar and boolean/ternary classification scans only those expression nodes. - JS/TS: matches ESLint
complexityrule semantics (including??and logical assignment);max-depthsemantics for depth;max-lines-per-functionwithskipBlankLines+skipCommentsfor lines.
Binary: complexity-gate.
complexity-gate check [--changed] [--format text|json] [--config <path>] [paths…]
complexity-gate hook claude
complexity-gate hook codex
complexity-gate init
complexity-gate doctor [--coverage]
complexity-gate --version
- With
paths: check those files/directories (directories recurse, honoring.gitignoreand configignore). - With
--changed: only functions touched by the working-tree diff againstHEAD(staged + unstaged) plus untracked files in full. Paths are resolved against the repository root (git rev-parse --show-toplevel), so the result is the same from any cwd inside the repository; reported paths are relative to the cwd. A function is "touched" when its line span intersects the post-image range of any added/modified hunk. Pure deletions touch nothing. Outside a Git repository, or with noHEAD,--changedfalls back to all given paths (or the cwd) and prints anote:line on stderr. Hook mode (hook claude|codex) does not fall back: outside a Git repository it prints anote: hook skippedline, emits no block, and a Stop resets the loop counter, so a session running from a non-repo cwd is never gated on the whole tree. The changed file set comes straight from Git:git diff HEADpost-image paths (which already include tracked files that a later.gitignorerule covers) plus untracked files fromgit ls-files --others --exclude-standard; configignoreapplies before any language lookup, so ignored paths never appear asUNVERIFIED. Explicit paths are normalized (./..) before intersecting. Non-UTF-8 diff output is decoded lossily; hunk headers are ASCII. Git is invoked with--no-ext-diff --no-textconv, external diff, textconv, fsmonitor, and hooks disabled, andGIT_DIR/GIT_WORK_TREE/GIT_EXTERNAL_DIFF/GIT_CONFIG_*removed from its environment. --changedand explicitpathstogether: intersection (changed functions within those paths).- Output
text(default), one line per violation, sorted by file then line:
FAIL src/auth.ts:42 authenticate complexity 18 > 15
FAIL src/auth.ts:42 authenticate depth 5 > 4
UNVERIFIED src/Foo.kt no grammar for .kt
- Output
json:
{
"version": "0.1.0",
"checked": 12,
"violations": [
{"file": "src/auth.ts", "line": 42, "function": "authenticate",
"metric": "complexity", "value": 18, "limit": 15}
],
"unverified": [{"file": "src/Foo.kt", "reason": "no grammar for .kt"}]
}- Exit codes:
0no violations;1at least one violation;2usage or runtime error (bad config, unreadable path).UNVERIFIEDalone never fails. UNVERIFIEDis emitted for a file that is explicitly named on the command line, or that has a known source-code extension with no grammar (.kt .java .c .cc .cpp .h .hpp .cs .swift .rb .php .scala .lua .zig .m .mm .ex .exs .hs .clj .sh .bash .pl .r), or that cannot be decoded as UTF-8. Non-source files found while walking a directory (.md,.json,.toml, images, …) are skipped silently. A file that cannot be read never aborts the scan.
Reads the Claude Code hook JSON from stdin and dispatches on hook_event_name:
PostToolUsewithtool_nameEdit,Write, orMultiEdit→check <tool_input.file_path>. On violations print JSON{"decision":"block","reason":"<text report>"}and exit 0 — this returns the report to the agent as feedback without undoing the edit. No violations → exit 0 with no output.Stop→check --changedincwd. On violations print{"decision":"block","reason":"<text report>\nRefactor the listed functions (see the complexity-gate skill), then finish."}and exit 0, which prevents the agent from stopping. Loop guard: consecutive blocks persession_idare counted in the state directory. The hook blocks at mosthook.max_blockstimes (default 3); every later Stop with violations is allowed and prints the report prefixed withUNRESOLVEDto stderr, exit 0. Only a clean run resets the counter (anUNRESOLVEDrelease does not). State file names derive from a sanitizedsession_id, never from a toolchain-dependent hash.- Any other event → exit 0, no output. Missing optional fields (
session_id,cwd) never cause a non-zero exit:cwddefaults to the process cwd and a missingsession_iduses an unkeyed counter. - Never exit non-zero from the hook for gate results; reserve non-zero for runtime errors, with a one-line stderr message.
Field names follow the current Claude Code hooks documentation; verify against the
docs during implementation and record the version checked in docs/hooks.md.
Same semantics, reading the Codex hooks JSON. Codex's event names and output
contract differ; implement the closest equivalents (post-edit feedback, stop
block) per the current Codex hooks documentation and record the mapping and
limitations in docs/hooks.md. Where Codex cannot block a stop, the hook must
still return the report as feedback.
Writes .complexity-gate.json in the current directory containing the effective
defaults, for repo-level overrides. Refuses to overwrite an existing file (exit 2).
Prints: binary version, config resolution chain with the effective values, state
directory, and each language → grammar version. --coverage additionally lists,
per grammar, node kinds whose name contains if, for, while, loop, match,
switch, case, catch, except, conditional, ternary, binary, or
logical that the language table neither counts nor explicitly ignores.
Resolution, later wins, shallow merge per top-level key:
- built-in defaults (
config.default.json, embedded) - user:
$XDG_CONFIG_HOME/complexity-gate/config.json(default~/.config/complexity-gate/config.json) - repo: nearest
.complexity-gate.jsonwalking up from the checked file's directory — always per file, also under--changed, so nested packages can carry their own limits --config <path>replaces step 3
{
"limits": { "complexity": 15, "depth": 4, "lines": 100, "params": 6 },
"tests": {
"patterns": ["**/*.test.*", "**/*.spec.*", "**/*_test.go", "**/test_*.py",
"**/*_test.py", "**/*_test.dart", "**/test/**", "**/tests/**",
"**/__tests__/**"],
"exempt": ["lines"]
},
"ignore": ["**/node_modules/**", "**/dist/**", "**/build/**", "**/target/**",
"**/.svelte-kit/**", "**/*.g.dart", "**/*.freezed.dart",
"**/*.min.js", "**/generated/**"],
"languages": {},
"hook": { "max_blocks": 3 }
}tests.patterns and ignore globs match paths relative to the Git repository
root (or to the common scan root outside Git), never to the process cwd.
tests.exempt accepts only lines; hook.max_blocks is clamped to at least 1.
A repo config is trusted like any repo file. Under --changed, when a
.complexity-gate.json is itself among the changed files the report starts with
note: .complexity-gate.json changed in this diff so a reviewer sees it.
languages.<name>.limits overrides limits for one language (javascript,
typescript, svelte, dart, rust, python, go). Unknown keys → exit 2
with the key named.
Loop-guard counters live in ~/.pickforge/complexity-gate/ (override with
COMPLEXITY_GATE_HOME), per the Pickforge local-storage policy. Nothing is ever
written into the checked repository except by init.
tests/fixtures/<language>/ holds source files plus expected.json
([{function, line, complexity, depth, lines, params}]). Each language has at
least: one trivial function, one function at exactly the limit, one over each
limit, nested functions, every decision-point kind listed above for that language,
and (Svelte) a template with nested blocks.
Reference numbers are derived once from the reference tool and recorded in the
fixture's expected.json under reference with the tool name and version:
ESLint or Oxlint complexity for JS/TS/TSX and Svelte scripts (the same rule
implementation; either is accepted, record which), radon for Python, gocyclo
for Go, lizard for Rust, hand-derived with a per-line comment for Dart and
Svelte templates. Every function in expected.json has a reference entry; when
the reference tool does not report a function (nested or anonymous), the entry
is marked hand_derived with its derivation. A
test asserts reference <= ours <= reference + delta for complexity on every
fixture, where delta is recorded per function in the reference entry with the reason
(default 0), plus exact equality with our own committed expectations. Every
language fixture includes a multi-branch else if chain, a try/catch, an
operator inside a string literal, and an anonymous callback inside a named
function.
Per the Pickforge gate baseline: cargo test --workspace --locked --all-targets;
cargo clippy --workspace --all-targets -- -D warnings with clippy.toml
cognitive-complexity-threshold = 15 and too_many_lines denied at 100;
cargo llvm-cov line floor at the ratchet (actual, rounded down); gitleaks and
osv-scanner jobs; Swatinem/rust-cache@v2 in every workflow including release;
cargo-dist release workflow producing linux-x86_64, linux-aarch64,
macos-aarch64, macos-x86_64, windows-x86_64 archives with checksums. The binary
gates itself: CI runs complexity-gate check crates and fails on violations.
Cargo.toml # workspace
crates/core/ # complexity-gate-core: parsing, metrics, config, diff spans
crates/cli/ # complexity-gate: clap CLI, hooks, doctor
config.default.json
docs/spec.md docs/hooks.md
tests/fixtures/<language>/
.github/workflows/{ci,release}.yml
clippy.toml osv-scanner.toml