Terms of Acceptance — the cccc and KISS gates¶
Measured 2026-09-11 against the full tracked tree (1,571 CCCC-supported files /
44,404 functions; 1,279 tracked Rust files / 20,011 KISS units), with
cccc 1.6.0 and kiss 0.4.10. The gates now run kiss 0.4.12 from the pinned
fork build (scripts/kiss_fork.py); orphan modules are no longer a kiss check
rule there (see .config/kiss.toml).
This page states what the two shape scanners accept and why, so that their reports read as clean-with-known-exceptions instead of as permanent noise, and so the number a burndown lane inherits is a real backlog rather than a raw finding count.
The rule about rules¶
The project rule is NO RATCHETS — expose tech debt. Everything on this page obeys it, and the test is mechanical:
- An exception here is a RULE about a class of code, with a stated reason and the measurement behind it. It is recomputed from the source under measurement on every run.
- There is no list of accepted files, no list of accepted functions, no
frozen count, no
--update-baseline, and no in-line suppression comment. Nothing on this page can be satisfied by editing a ledger. - A finding that is accepted is still counted and printed. An exemption nobody can see is a baseline by another name.
.kissconfigmust never exist. Barekiss checkwrites one, self-calibrated from what the repo currently passes, and silently disables four global rules. Thekiss-censushook fails if the file is present.
CCCC — the cyclomatic cap measures the wrong thing for Rust dispatch¶
Caps are scanner_contract.CCCC_MAX_CYCLOMATIC = 10 and
CCCC_MAX_COGNITIVE = 15, enforced on the diff by the complexity-staged
pre-commit hook and reported over the whole tree by cccc-census.
Cyclomatic complexity charges every match arm as an independent decision
point. For a flat, exhaustive match that is arithmetically correct and
semantically empty — there is no nesting, no interleaving, and no control-flow
surprise. Cognitive complexity, which does not charge a flat match per arm,
reports the same functions as trivial:
| function | cyclomatic | cognitive |
|---|---|---|
src/server/wire/mod.rs::dispatch_kind |
32 | 1 |
src/server/persistence/redb_backend.rs::handle_cmd |
54 | 8 |
Decomposing a genuinely exhaustive match is not a neutral refactor: it trades away rustc's exhaustiveness guarantee. While the match names every variant, adding an enum variant is a compile error at every dispatch site. Behind a lookup table or a boxed closure map, the same addition becomes a silent runtime fallthrough; behind a macro, the arms merely disappear from the scanner while the code is unchanged. Both are worse code with a better number.
The rule¶
Stated and implemented once, in scripts/rust_exhaustive_match.py. A Rust
function may exceed the cyclomatic cap — never the cognitive cap — only if
all four hold:
- its cognitive complexity is within the cognitive cap. The flat-dispatch claim is falsifiable, and cognitive complexity is the falsifier;
- its body contains at least one
match. Branching that is not dispatch — anif/elseladder, a chain of?— is ordinary complexity and gets no relief; - no arm of any
matchin the body is irrefutable: no_, no bare binding (other,ref x,mut x,x @ _), with or without a guard, and no|-alternation containing one. An irrefutable arm is exactly what makes a match non-exhaustive, so decomposing it forfeits no guarantee; - its residual cyclomatic complexity — the measured value minus the number of match arms in the body — is within the same cyclomatic cap every other function obeys. This introduces no new threshold. It says only that once the exhaustive dispatch is discounted, what is left must pass the ordinary gate.
Everything the rule cannot prove is not exempt: non-Rust source, a body that cannot be lexed, a missing start line, and an arm count that exceeds the measured cyclomatic complexity (which means the attribution is wrong, so the residual cannot be trusted) all fail closed.
Arm patterns are read from comment- and literal-masked source (rust_lexer), so
a _ => inside a string or a comment cannot invent a catch-all. The whole
function body is brace-matched — not a fixed line window. A first attempt at
this measurement used a 400-line window and misclassified a large dispatcher
whose catch-all sat beyond that window. tests/test_rust_exhaustive_match.py
pins the current streaming dispatcher shape and a synthetic case whose catch-all
appears after 450 lines, preserving coverage of that regression.
Before the terms report is classified, scripts/validate_cccc_census.py
requires the native CCCC schema: numeric fields stay in native ranges, names
are nonempty, and summary file/function/parse counts plus recursive child
metrics exactly match the retained report tree. File totals may also include
module-level code, so they only must be at least the recursive function
totals. A file with no functions remains valid when its summary function
metrics legitimately report zero.
The rule is strictly tighter than what it replaces¶
Nothing previously checked whether a high-cyclomatic match was exhaustive at all. A 68-arm dispatcher ending in a catch-all — which is not exhaustive, whose decomposition costs no safety whatsoever, and which is therefore ordinary debt — was exactly as unremarked as a genuinely exhaustive one. Applying this rule makes that population visible for the first time. That is the intent.
Measured result¶
44,404 functions measured, 437 over cyclomatic 10 or cognitive 15
cognitive over cap 201 genuinely complex — never accepted
ACCEPTED BY RULE 90 exhaustive dispatch, residual within cap
catch-all arm 71 the match is NOT exhaustive — newly visible debt
no `match` in the body 41 branching that is not dispatch
residual over cap 7 dispatch discounted, the rest still exceeds it
arm count > cyclomatic 3 attribution unproven — fails closed
not Rust (Python / JS) 24 rustc exhaustiveness does not apply
REAL BACKLOG 347
scripts/report_complexity_terms.py prints exactly this table. Its default
invocation remains an advisory report, so existing inspection commands keep
working. The cccc-census hook and the release scanner run it with
--require-zero: the complete accepted and backlog breakdown remains visible,
and the command exits 1 when REAL BACKLOG is nonzero. This is a live
whole-tree gate with no threshold, baseline, or suppression list, and it writes
nothing.
The 41 with no match at all are worth calling out. An earlier
classification that only grepped for a catch-all pattern counted them as
"exhaustive" and will have exempted them. They are if/else ladders and ?
chains, and they are debt.
KISS — three rules were calibrated for OO, not for Rust¶
Thresholds live in .config/kiss.toml, which carries the measurement next to every
number. Three were recalibrated on 2026-09-11; the file holds the full reasoning
inline, summarised here.
| rule | was | now | measured distribution | why |
|---|---|---|---|---|
returns_per_function |
5 | 8 | p90=2, p95=3, p99=5, max=29 over 16,883 functions | At 5, only 33 % of findings are corroborated by cccc cognitive complexity > 15; at 8, 53 %. 8 is the smallest cap at which the rule is right more often than wrong. Below it, it fires mostly on flat guard-clause validators — the exact shape its own advice tells the operator to write. |
methods_per_class |
10 | 13 | p90=8, p95=11, p99=31, max=222 over 2,040 impl units | KISS aggregates every impl block of a type within a file, trait impls included, and its advice ("extract related methods into a separate type with its own impl") is impossible for a trait impl — the trait defines the method set. This workspace's own ModalityContract has 22 implementations, 8 of them at exactly 12 methods and the largest at 13. A cap below 13 demands a refactor that does not exist. |
concrete_types_per_file |
8 | 20 | p90=7, p95=9, p99=18, max=140 over 1,279 files | Of 70 files over 8, 40 are already flagged oversized by lines_per_file / statements_per_file / functions_per_file; for those the type count adds nothing. The 30 it flags alone are uniformly the Rust contract-module pattern — eg-types/native_control.rs is 276 lines and 19 types, one Capacity* / SubmitWorkItem* request+result vocabulary. 20 clears the largest coherent contract module measured (19) and still names 9 grab-bags. |
Costs and judgement calls, stated rather than buried:
returns_per_function = 8fixes the curve by measurement, but "the rule should be right more often than it is wrong" is an acceptance criterion chosen by a human, not derived from the data. It is a judgement.methods_per_class = 13also relaxes inherent impls, where p95 is exactly 10 and the cap was therefore well calibrated. 88 of the 107 impls over 10 are inherent. KISS has one global number per language and cannot separate the two populations. The inherent god-objects (222, 58, 34, 32, 26, 22 methods) and the over-broad interfacesChunkStore(21) andPersistenceBackend(44) all remain flagged.- Two premises that turned out to be false and are recorded so nobody
re-derives them: KISS does not let the operator evade
methods_per_classby splitting a type into twoimplblocks in the same file (it aggregates them), andreturns_per_functiondoes not count?or the tail expression (checked on a probe) — so the "it punishes idiomatic Rust?" argument does not hold. The reasons above are the ones that survive measurement.
Rules deliberately left alone¶
nested_function_depth (46), max_indentation_depth (7), boolean_parameters
(26), positional_args (90), local_variables_per_function (91),
duplication (3), calls_per_function (203), statements_per_function (152),
and the file-size family — lines_per_file (97), statements_per_file (80),
functions_per_file (74), imported_names_per_file (10) — are real debt at
their current numbers. Each is already set at a measured percentile in
.config/kiss.toml and none of them mismeasures a Rust idiom. They belong to the
burndown lane, not to this page.
Measured result¶
Full tracked-tree census, the same 1,279 files under both configurations:
BEFORE (thresholds of 2026-08-27) 1,203 violations
AFTER (thresholds of 2026-09-11) 1,010 violations
returns_per_function 144 -> 51 (-93)
methods_per_class 107 -> 68 (-39)
concrete_types_per_file 70 -> 9 (-61)
every other rule unchanged
193 accepted by rule; 1,010 real backlog.
kiss-changed-rust is diff-scoped, not whole-file (BUG-CX-136 / F6)¶
kiss check <file> always reports EVERY violation the whole file carries,
not just what a commit's diff touched. Before this rule, kiss-changed-rust
therefore failed a commit that added a single comment to a large,
already-violating file for debt the commit never touched — the standard
workaround was git commit --no-verify, which silently disables every other
pre-commit hook too, not just this one.
The hook (scripts/check_kiss_staged.sh) now re-runs KISS a second time on
the HEAD blob of each changed file (a second ephemeral tree,
materialized once per run via git archive HEAD, alongside the existing
staged-index tree) and narrows the staged report through
scripts/kiss_diff_scope.py before deciding pass/fail:
- Function/item-scoped rules (
statements_per_function,returns_per_function,calls_per_function,local_variables_per_function,max_indentation_depth,branches_per_function,boolean_parameters,positional_args,annotations_per_function, and other*_per_functionrules): a finding counts only if the enclosing function/method is NEW (no same-named function existed at HEAD) or MODIFIED (the same-named function's exact source text — extracted by locatingfn <name>in a comment/string-masked view of the file and matching its balanced{...}body, the same lexical authority every other Rust scanner in this repository shares) differs from its HEAD version. A same-named function appearing more than once in a file is matched to its counterpart by ordinal position (file order), not by name alone. Touching a violating function's body means fixing it — a modification does not get to keep riding on "it was already broken." - File- or type-aggregate rules (
lines_per_file,statements_per_file,functions_per_file,interface_types_per_file,concrete_types_per_file,imported_names_per_file, andmethods_per_class— which sums one type's methods across everyimplblock in the file, so it has no single contiguous span to diff): a finding counts only if the rule is absent from the HEAD report for that file (newly crossed) or its reported count is strictly larger than HEAD's (worsened). An unchanged or improved count is pre-existing debt and does not fail the commit.
Matching is content-based, never line-number or bare-symbol-name based:
line numbers shift under reformatting and mechanical merges, and a bare
symbol name breaks under extraction (a function moved to a new module keeps
its name but is a different "item" for lineage purposes) — see
symbol-keyed-baselines-break-under-extraction and
architecture-gates-key-on-byte-offsets in the operator's working notes.
There is no baseline file, allowlist, or self-updating count anywhere in
this comparison: both the staged and the HEAD report are computed fresh, from
the two Git blobs, on every hook invocation — a ratchet will let today's
kiss.toml thresholds erode quietly; this rule instead re-derives "was this
introduced or made worse by THIS diff" from scratch every time.
tests/test_kiss_diff_scope.py fixtures the four defining scenarios directly
against the matcher (comment-only change to a file with a pre-existing
violation → pass; a new violating function → fail; modifying an
already-violating function → fail; a file-level threshold newly crossed →
fail); tests/test_kiss_staged.py also proves the hook's bash-level
wiring (materializing the HEAD tree, running the second KISS pass, invoking
the filter, propagating its exit status) end-to-end with a fake KISS binary.
The pinned-version check, the --config .config/kiss.toml requirement, the
one-path-per-invocation rule below, and the .kissconfig prohibition are all
unchanged by this — diff-scoping narrows WHICH of KISS's own findings can
fail the commit; it never changes what KISS itself measures or how it is
invoked per file.
Running the scanners¶
Never run bare kiss check — it writes the self-calibrating .kissconfig.
Always pass --config .config/kiss.toml and one path per invocation
(KISS 0.4.10 reports a false clean for a multi-path check). The two callers
that get this right are scripts/check_kiss_staged.sh (the pre-commit hook) and
the kiss-census hook; go through one of them.
pre-commit run --config .config/pre-commit.yaml complexity-staged --all-files # cccc, on the diff
pre-commit run --config .config/pre-commit.yaml kiss-changed-rust --all-files # KISS, on the diff
pre-commit run --config .config/pre-commit.yaml cccc-census --hook-stage manual --all-files # whole tree + the acceptance split
pre-commit run --config .config/pre-commit.yaml kiss-census --hook-stage manual --all-files
The kiss-census hook reports the de-duplicated union of one kiss check .
over the repository root and one run per package root (src, crates/<name>),
each a single-path invocation, in about 80 seconds. A per-file census treated
every file as its own codebase, so the cross-file duplication, orphan-module,
dependency-depth and cycle rules can never fire, and it paid KISS's Rust role
scan once per file (137 minutes in the hosted scanner job). The root run sees
cross-crate structure; the package runs resolve intra-crate crate:: paths
exactly. scripts/check_kiss_census.py records the measured comparison.