Against CodeQL, Semgrep, and the structure tools¶
The conclusion first¶
Nothing on this page is a replacement for anything else on this page. They answer different questions, and the useful thing is to know which tool answers which.
| Question | Tool |
|---|---|
| Does this code have a defect or a vulnerability? Does tainted input reach a sink? | CodeQL, Semgrep |
| Is this file correct and consistent in style? Do the types agree? | ruff, mypy, clang-tidy, tsc |
| How healthy is this codebase overall, as a score I can watch over time? | aggregate structure tools (sentrux, radon, lizard, SonarQube) |
| Is this routine risky because nothing tests it? | coverage tools (pytest --cov, diff-cover), and composites over them such as CRAP |
| Has this problem already been solved somewhere in the tree, in some other shape? | embedding-based clone finders (slopo) |
Which routine is too complex to change safely, where is it, and did this commit make it worse than it was in HEAD? |
this gate |
The last row is the one nothing else in the list does. Not because the others are weak, but because a per-entity before-and-after comparison inside a single commit is a different mechanism from a scan, and none of them is built as one.
CodeQL and Semgrep¶
These are the two this project is most often mistaken for, and the distinction is worth stating carefully rather than dismissively.
They are correctness tools on a security axis. CodeQL builds a database from a real compilation or extraction and runs queries with genuine interprocedural data flow and taint tracking. Semgrep matches syntactic and semantic patterns quickly, with cross-file taint analysis in the Pro engine. Both find real defects: injection, unsafe deserialisation, missing authorisation, hardcoded credentials, use-after-free. This gate finds none of those and does not try to.
Do not read this as "this replaces CodeQL". It does not. If you have a CodeQL workflow, keep it.
There are three real differences, and only the third is a claim about capability:
-
Different axis. A routine can be at
CyclomaticStrict45 and contain no vulnerability at all. A one-line routine can contain a critical one. Neither tool sees the other's finding, because they are not looking at the same property. -
Different unit of work. CodeQL and Semgrep are usually run per push or per pull request over the whole database or the whole tree, and their output is a set of alerts to triage. This gate runs inside the pre-commit hook, over the staged change only, and its output is a yes or a no about that commit.
-
CodeQL can compute complexity metrics; it is not a commit-time ratchet. This is the honest version of the claim. CodeQL's standard libraries expose cyclomatic complexity and related metrics, and you can absolutely write a query that reports functions above a threshold. What is not available out of the box is the mechanism this gate is built around: build a second database from
HEAD, match entities across the two by a stable key, and refuse a commit because one entity's value moved in the wrong direction. That is buildable on CodeQL. It is not what CodeQL's default setup does, and doing it per commit rather than per pull request is a different performance problem.
Where they overlap usefully: all three emit SARIF 2.1.0, so scitools-hook check --sarif
findings.sarif lands in the same GitHub code-scanning view as your CodeQL results.
Aggregate structure tools¶
Tools in this family — sentrux, radon, lizard, SonarQube's maintainability rating — score
a codebase and track the score. They are genuinely useful, and this project's own steering
documents credit srccheck as the origin of the threshold-per-scope idea.
Two things were measured directly for this page, against the same six-file demo repository used in the quickstart, with the same dependency cycle and the same over-complex routine in it. The comparison below is what those runs actually printed.
A score is a different unit of answer from a finding¶
sentrux's scan returns the aggregate:
That is the number you put on a dashboard and watch. It is not something you can act on
directly, and it is not meant to be: quality_signal going from 5400 to 5333 does not tell
you which commit did it.
Its health call does give named diagnostics, and on this repository it found the cycle:
The gate, on the same tree, reported the same cycle with the closing edges and their reference counts:
$ scitools-hook check --staged
error structure.file_cycle
2 files form a dependency cycle that did not exist before the change: pricing/catalog.py,
pricing/rates.py; closed by pricing/catalog.py -> pricing/rates.py (3 refs),
pricing/rates.py -> pricing/catalog.py (3 refs)
hint: break the cycle: invert one dependency -- move the shared type into a module both
files can import, or pass it in instead of importing back
The extra content is which edges close the cycle, how many references each carries, and that it did not exist before this change. The first two tell you where to cut. The third is what makes it a gate rather than an inventory: a cycle that was already there does not block the commit that happens to touch one of its files.
Where a real parse beats name matching¶
This is where the two approaches diverge at scale, and it is worth being precise about the mechanism rather than the score.
Most fast structure tools resolve imports by matching module names, because parsing every language properly is expensive. That works on a small tree — sentrux resolved this six-file demo correctly. It degrades on a large one, where module basenames collide and dynamic or relative imports do not match by name.
The figures that motivated this project came from a private 770-file Python repository. They were reported to the author of this documentation and have not been re-measured here, so they are stated as received:
On that repository the incumbent tool reported
cycle_count = 0, because it resolves imports by module-name suffix and left roughly 2 029 of 4 165 specs unresolved. The gate, using Understand's real parse, found three dependency cycles: 9 files acrossshells/config↔shells/pods, 18 files acrossshells/dashboard↔shells/reporting, and one betweenscriptsandtests/unit— each with its closing edges and reference counts named.The same tool reported
complex_fn_count = 15andgod_file_count = 16, and its maintainer described having to bisect by untracking files to find which ones they were.
Treat the specific numbers as second-hand. The mechanism behind them is not second-hand and is checkable in this repository: Understand builds a real database per language, and the extension map that feeds it is re-measured against the installed build by a contract test in both directions.
A calibration difference worth knowing about¶
On the demo repository, the two tools disagreed about what counts as complex. sentrux's
complex_functions list was empty; its cog_complex_functions reported render at a
cognitive complexity of 29. The gate reported the same routine at CyclomaticStrict 12
against a limit of 10, and blocked on it.
Neither is wrong. They are different metrics with different calibrations, and if you run both you should expect them to disagree at the margin. What matters is that only one of them is comparing against the previous commit.
The heuristic this gate deliberately does not use¶
sentrux's redundancy diagnostic on the demo listed six dead_functions, including
pricing.settle.settle and pricing.catalog.price_in — both of which are the module's
public interface, called from outside the tree. On a library, "nothing in this repository
calls it" and "dead" are not the same statement.
This gate does not have a dead-code rule, and the reason is recorded in
analysis/structure/calls.py as a measurement on that 770-file project:
The routine with the highest
CyclomaticStrictof all — a dataclass's__post_init__, 45 — has a call-graph fan-in of zero, because the call that runs it is generated by@dataclassand appears in no source file. A "nothing calls this" rule would have named it dead code, which is the opposite of the truth.
That is not a criticism of the heuristic in general. It is why the gate's primary rules are per-entity metrics rather than graph statistics, and why the call-graph rules it does have report every finding as an explicit lower bound with the resolution rate attached.
Coverage-weighted complexity: the CRAP metric¶
CRAP — Change Risk Anti-Patterns — is the composite most often suggested for this gate, so the answer is recorded here with the measurement behind it. It scores one routine as
with a conventional ceiling of 30, where comp is cyclomatic complexity and cov is the
fraction of that routine covered by tests.
It is not shipped, and the reason is not taste. The half of it that is new to this gate is the half the gate has no input for, and with that half held at zero the metric is a re-labelling of a ceiling that is already in force.
Measured: with no coverage term, CRAP is CyclomaticStrict under another name¶
The gate reads two things: git, and an Understand database. Neither carries test coverage;
nothing in the pipeline ingests a coverage.xml, an lcov file or a .coverage. With
cov(m) = 0 — the only value this gate could supply for every routine — CRAP collapses to
comp^2 + comp, which is strictly increasing in comp and therefore ranks a population in
exactly the order CyclomaticStrict already ranks it.
That is arithmetic, not a measurement, so it was run as one: every routine of two repositories, out of the database the gate's own cache held, on Understand 8.0.1262.
facdrone, aa06c5a, 2026-09-17 |
this repository, 0.3.0, 2026-09-13 | |
|---|---|---|
| Routines walked | 10 463 | 7 050 |
CyclomaticStrict p50 / p95 / p99 / max |
1 / 7 / 9 / 21 | 1 / 4 / 6 / 14 |
Order by CRAP identical to order by CyclomaticStrict |
yes | yes |
| Discordant pairs (400-routine sample, 79 800 pairs) | 0 | 0 |
Tied pairs, CyclomaticStrict / CRAP |
21 190 / 21 190 | 46 811 / 46 811 |
Reported by the shipped routine.CyclomaticStrict = 10 |
21 (0.2%) | 3 (0.0%) |
Reported by CRAP > 30 at cov = 0 |
794 (7.6%) | 100 (1.4%) |
Not one pair of routines is ordered differently by the two numbers, and the tie structure is identical, because squaring a non-negative integer and adding it back preserves both. The ten worst routines are the same ten in the same order on both repositories.
The only thing the second row adds is a different cut point on the same axis: CRAP > 30
at zero coverage is CyclomaticStrict ≥ 6. It reports a strict superset of what the shipped
ceiling reports — 773 extra routines on facdrone, 97 here — and every extra one sits in
CyclomaticStrict 6 to 10. Shipping it would not be a new rule. It would be the existing
ceiling moved from max = 10 to max = 5, on two repositories where nobody measured
that number, stated in a unit (a score of 42) that no hint can answer.
What the coverage term buys, and which way it points¶
Suppose the coverage were available. This is what it would be worth, per routine — the coverage at which CRAP falls back under 30, with facdrone's population at each complexity:
comp |
coverage needed for CRAP <= 30 |
facdrone routines at that comp |
|---|---|---|
| 6 | 12.6% | 253 |
| 8 | 29.9% | 157 |
| 10 | 41.5% | 72 |
| 11 | 46.1% | 10 |
| 15 | 59.5% | 2 |
| 20 | 70.8% | 1 |
| 25 | 80.0% | 0 |
| 30 | unreachable at any coverage | 0 |
Read the column downwards and the direction of the metric becomes the problem. Coverage does not tighten CRAP, it relaxes it, and it relaxes it fastest exactly where this gate is least willing to yield:
| Coverage assumed uniform | facdrone routines the gate blocks that CRAP <= 30 forgives |
worst one forgiven |
|---|---|---|
| 50% | 12 of 21 | CyclomaticStrict 12 |
| 60% | 18 of 21 | 15 |
| 70% | 19 of 21 | 17 |
| 80% | 21 of 21 | 21 |
At 80% coverage CRAP reports nothing at all on facdrone, including the 21-branch routine the
gate refuses today; on this repository, whose own suite holds src/ at 98.19% branch
coverage, CRAP is silent at every complexity below 30 while the gate still refuses three
routines. A tested routine is not a cheaper routine to change. That is the whole
disagreement: CRAP prices the risk that a defect escapes, and a test suite genuinely lowers
that risk; this gate prices the cost of the next change, and a test suite does not lower
that. The working-set argument is about what an agent or a
reviewer can hold in their head, and 21 branches is 21 branches whether or not they are
covered.
Where the coverage number does not exist at all¶
The third measurement is the one that decided it. Coverage is collected for the code under
test, conventionally --cov=src, so the gate's population and the coverage tool's population
are not the same population:
| facdrone | this repository | |
|---|---|---|
Routines under src/ |
3 890 (37%) | 1 577 (22%) |
| Routines outside it — tests, scripts, tooling | 6 573 (63%) | 5 473 (78%) |
Of the routines the gate blocks, how many are outside src/ |
15 of 21 | 3 of 3 |
For the majority of what the gate judges, cov(m) is not a number that exists. Taken as 0 it
makes CRAP harshest precisely where the input is missing rather than where the code is worst;
taken from a run that includes the test files themselves it approaches 1, and the metric
forgives a 15-branch test helper for the crime of having executed. Both answers are
arbitrary, and the routines this actually decides are not edge cases — they are 15 of the 21
findings facdrone gets today, and all three of this repository's.
What to do instead¶
Keep the two questions with the tools that can answer them.
- "Is this change untested?" belongs to the coverage tool, which owns the data:
pytest --cov --cov-fail-under,diff-coverfor the change rather than the tree, or your CI's coverage gate. This repository runs the first of those at 85% branch coverage over all ofsrc/scitools_hook, and that check is not improved by being folded into a complexity score. - "Can this routine still be changed safely?" is what the shipped ceilings answer, per
entity, against
HEAD, with a hint naming the refactoring:CyclomaticStrict10,CyclomaticModified8,MaxNesting3,CountPath100.
Running both gives you each finding in a unit you can act on. Multiplying them together gives you a number that cannot tell you which of the two to fix — and as the tables above show, lets a high value of one buy off the other.
What would change this¶
Not the score; the conjunction behind it. A rule of the shape "this commit touched a routine
that is both complex and has no covering test" keeps the two facts separate and is
answerable. It is buildable here — RoutineShape already carries each routine's first and
last line for the duplicate-block rule, so
a line-range join against a coverage report is the small part — and it is not built, for
three reasons that are the same reasons the lean-code rules carry
trust floors:
- A pre-commit hook does not run your test suite. The coverage report arrives as an artifact of unknown age, and a stale one errs towards forgiveness — a rule that goes quiet when its input rots is worse than no rule.
- There is no single coverage format, and this gate analyses whatever Understand analyses. Each language is a separate parser and a separate staleness story.
- The population mismatch above would have to be declared, not averaged away: the rule
would have to say "not judged" for every routine the coverage run never looked at, the way
LinesPerStatementdeclines to judge a routine under its floor.
If you want it, say so in an issue with the coverage format you produce. It would ship as a warning with a freshness check, behind its own floor, and never as a number that trades complexity against coverage.
Semantic clone detection: slopo and the embedding approach¶
slopo names a failure this gate's own lean-code family was written for and only half solves, so it is worth being precise about where the line falls. Its README states the problem better than a paraphrase would:
agents miss a solution that already exists somewhere and implement it again. This is not copy-paste; this is a similar implementation for the same problem, which is hard to detect for humans, AI, and other tools.
It embeds code snippets, clusters them by cosine similarity, and re-ranks so that matches
far apart in the tree outrank neighbours — up to a 15% boost across directories and 10% by
line distance within a file. Ten languages, a slopo.conf.yaml, and a Markdown report as the
output. It is a CLI you run, not a hook. Nothing about slopo's own behaviour was measured
for this page — this machine has no network — so the description above is its README's, the
way the sentrux figures further up are marked as received. What is measured here is the other
side of the line: what this gate's duplication rules do and do not find.
What the gate's two duplication rules actually compare¶
Both are lexical, and deliberately so. Neither resolves a reference, which is why neither takes a trust floor:
structure.duplicate_blockhashes each code line — a 64-bit truncated SHA-256 — and reports a window of at leastduplicates_min_lines(12 by default) that stands in more than one place. Exact repetition, whitespace and comments already out.structure.similar_routinetakes each routine's token shape fromEnt.lexer(False), with every identifier collapsed toIDand every literal to its class, and groups routines whose shapes match atsimilar_threshold(0.90) or better over at leastsimilar_min_statements(6) statements.
So the second rule is blind to names, blind to literals, and reads the sequence of token
kinds. That places it at parameterised clone detection: the twelve normalize methods that
are one routine with a provider name in them. It is not a semantic comparison, and the
distance between those two things is measurable.
Measured: where the token shape stops seeing it¶
One fixture, three files, one decision written three ways — the same six-way banding of a
score — run through scitools-hook check --all on Understand 8.0.1262. The threshold was
then lowered run by run until each pair was reported, which is what puts a number on the gap:
| The same function, rewritten as | Token-shape ratio | Reported at the shipped 0.90? |
|---|---|---|
| the same branches with every identifier and literal renamed | 1.00 | yes — one family of 2 |
six guard clauses turned into one elif ladder with a single exit |
0.64 | no |
a tuple of (limit, label) pairs and a loop over it |
0.34 | no |
The first row is the rule working exactly as intended: nothing textual survives the rename, and the shape still matches perfectly. The other two are the same six comparisons producing the same six answers, and the rule does not pair them — which is the honest boundary. This gate finds a routine that was copied and renamed. It does not find a routine that was re-implemented, and a model asked the same question twice in two sessions re-implements far more often than it copies.
Note also that the second row is the easy miss: same language, same branch order, same comparison operators, and the ratio is already 0.64.
Why the answer is not "lower the threshold"¶
Because the band the restructured twins land in is where unrelated code lives too, and that was measured on a real repository rather than on the fixture. facdrone's own calibration records what the two knobs cost:
similar_threshold / similar_min_statements |
Families reported | Full audit |
|---|---|---|
| 0.90 / 6 (shipped) | 128 | 84 s |
| 0.90 / 4 | 292 | — |
| 0.85 / 4 | 394 | 352 s |
Moving the threshold from 0.90 to 0.85 — a fraction of the way down to 0.64 — triples the report and quadruples the runtime, and the families it adds are not restructured twins: they are short routines that share a shape by coincidence. A ratio is a statement about token sequences, and no setting of it turns it into a statement about meaning. That is the argument for a different mechanism, and it is the argument slopo is making.
Where each one belongs¶
They do not compete, and the reason is the one this page keeps returning to: they answer in different units.
| Question | Answered by |
|---|---|
| Did this commit add a routine that already exists here under other names? | this gate, at commit time, as a refusal or a warning naming the family |
| Has this problem already been solved somewhere in the tree, in some other shape? | an embedding tool such as slopo, as a ranked report you read |
Run slopo the way its README suggests — periodically, and especially after a stretch of
agent-written code, which is when re-implementation arrives in bulk. Keep it out of the
pre-commit path, and that is not a criticism of the tool; it is the same boundary as
CRAP above. A cosine of 0.86 cannot be the
reason a commit was refused: the finding cannot name the evidence a reader checks, the hint
under it cannot be written, and the number is not stable across model versions, which is
fatal for a ratchet that compares today's value against HEAD's.
A report that a human or an agent triages is exactly the right shape for a fuzzy signal.
One idea in it is worth taking without embeddings: slopo ranks a match higher when its ends
sit far apart in the tree. structure.similar_routine weighs every family the same wherever
its members live, and duplication across a package boundary is worse than duplication inside
one file. That is a pure token-shape change, measurable on the 128 families facdrone already
reports, and it is recorded here as a direction rather than a promise.
The thing none of them do¶
Per-entity, before and after, within one commit.
routine legacy.report.render CyclomaticStrict rose from 12 to 13;
an affected entity may not get worse than it was
That sentence requires four things at once: a stable entity identity across two separate
analysis databases, a before state built from HEAD, a per-entity comparison rather than a
per-file or per-project one, and the whole thing fast enough to run before git commit
returns. It is the only claim on this page that is not shared with some other tool in the
list.
Running them together¶
They compose without conflict, and the ordering only matters for speed:
# .pre-commit-config.yaml -- cheapest first
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.5 # whatever you already pin
hooks: [{id: ruff-check}, {id: ruff-format}]
- repo: https://github.com/norandom/scitools-hook
rev: v0.3.0 # a tag of this repository
hooks: [{id: scitools-hook}]
CodeQL and Semgrep stay where they are, on the pull request, where their runtime is affordable and their output is triaged rather than gating.