Introduction
cntrdct is an evidence-based linter for logical contradictions and technical inconsistencies in Rust and Python code. Every finding cites the peer-reviewed paper that justifies the detection — no detector ships without one.
This guide covers the design constraints, the six Layer 1 detectors,
how to configure and suppress findings, the supported workflows
(scan, calibrate, eval, cross-model-kappa), and the
integrations (GitHub Action, LSP, SARIF, Claude Code skill).
For installation and a one-line quickstart, jump to Getting started.
Status
Alpha. The detector set, ranker priors, and SARIF mapping are stable under SemVer; the LSP server and VS Code extension are still pre-1.0.
Where to find things
- Source code, issues, and releases: github.com/ktrysmt/cntrdct.
- Bibliography behind each detector: CITATIONS.md.
- Engineering roadmap: ROADMAP.md.
- Per-feature specifications:
docs/spec/.
Getting started
Install
# crates.io
cargo install cntrdct
# macOS / Linux via Homebrew
brew tap ktrysmt/cntrdct
brew install cntrdct
# pre-built archive (no compile); requires cargo-binstall
cargo binstall cntrdct
# install script (Linux x86_64/aarch64, macOS aarch64, Windows x86_64)
curl -fsSL https://raw.githubusercontent.com/ktrysmt/cntrdct/master/scripts/install.sh | bash
The cargo install path additionally ships a cargo-cntrdct shim so
cargo cntrdct scan works alongside cntrdct scan.
First scan
cntrdct scan ./src # JSON to stdout (default)
cntrdct scan ./src --format sarif # SARIF 2.1.0 for code-scanning tools
The default scan is fully offline: walker → tree-sitter parsers → Layer 1 detectors → Layer 2 ranker → Layer 4 SARIF emitter. No network access, no telemetry. See Network access policy for the design property and the CI gate that enforces it.
Optional: LLM adjudication
scan --adjudicate routes the top-N findings through the Layer 3
Anthropic Messages API for a second-opinion verdict. Gated behind the
ANTHROPIC_API_KEY environment variable; off by default.
ANTHROPIC_API_KEY=... cntrdct scan ./src --adjudicate
See scan for the full flag reference.
Examples
Three runnable end-to-end examples live under
examples/:
a plain scan, a calibration run against a labelled corpus, and an
adjudication run against a mock API.
Four-layer architecture
cntrdct is organised into four layers with a deliberate separation between deterministic and stochastic surfaces. Each finding flows through the layers in the same fixed order:
source files
-> Layer 1 (tree-sitter detectors)
-> Layer 2 (statistical ranker)
-> Layer 3 (optional LLM adjudicator; only when --adjudicate)
-> Layer 4 (SARIF 2.1.0 emitter)
| Layer | Role | LLM? | Network? | Source |
|---|---|---|---|---|
| Layer 1 | Tree-sitter-based detectors | No | No | src/detectors/ |
| Layer 2 | Statistical ranker (Wilson / Jeffreys × log-sibling-count) | No | No | src/ranker.rs, src/calibration.rs |
| Layer 3 | Optional LLM adjudicator | Yes | Yes (opt-in) | src/adjudicator.rs |
| Layer 4 | SARIF 2.1.0 emitter | No | No | src/sarif.rs |
Layer 1 — deterministic detectors
Each detector under src/detectors/ is a pure function from a
parsed tree-sitter syntax tree to a Vec<Finding>. Six detectors
ship in v0: arg-swap, clone-drift, comment-code,
config-interaction, pr-miner, unreachable-after-terminator.
The detector set is registered through cntrdct::ALL_DETECTOR_IDS,
which is the single source of truth that tests/wiring_consistency.rs
asserts against both scan_full_with_config and the SARIF
tool.driver.rules array. Adding or removing a detector at one site
without the other fails CI.
All detectors reach tree-sitter through the
crate::parsers::parser_for(Language::*).ts_language() seam (Q-10),
so adding a new language is a single-module change in
src/parsers.rs rather than a cross-detector edit.
Layer 2 — statistical ranker
The ranker scores findings by a Wilson or Jeffreys lower bound on
detector precision multiplied by a log-scaled sibling-finding count.
Priors are fit by cntrdct calibrate against a labelled corpus
(benchmarks/labelled-findings.jsonl) and embedded into the binary
via include_str!. The runtime fallback chain is: explicit
--priors -> per-user cache -> embedded default -> uncalibrated
(see Statistical priors (P4)).
Layer 3 — LLM adjudicator (opt-in)
Layer 3 is the only layer permitted to invoke an LLM. Three
providers ship behind a common PromptDispatch trait:
AnthropicAdjudicator(HTTP viareqwest) — used byscan --adjudicate.ClaudeCliAdjudicator(CLI shellout toclaude --print) — used bycntrdct cross-model-kappa(Q-13).GeminiCliAdjudicator(CLI shellout togemini -p) — used bycntrdct cross-model-kappa.
The two CLI providers do not open sockets from cntrdct itself; the
underlying CLIs handle their own auth and HTTP. Verdict confidence
is post-hoc Platt-calibrated by the Q-12 helper
apply_llm_calibration when a fitted registry is present; v0 ships
the registry empty.
Layer 4 — SARIF emitter
src/sarif.rs converts Vec<Finding> to SARIF 2.1.0 JSON validated
against the OASIS schema on every CI run. Severity maps to
IEEE 1044-2009 anomaly classes at emission time
(see Severity and anomaly classes (P5)).
Why this split
The Layer 3 boundary is load-bearing: it is the only layer permitted
to open a socket, and only when --adjudicate is passed. The
default scan pipeline (Layers 1 -> 2 -> 4) is fully offline,
deterministic, and embeddable. The CI network-isolation job runs
cntrdct scan inside a Linux network namespace
(sudo unshare --net) on every push and PR; any unintended socket
open fails the job with ENETUNREACH / EAI_*. See
Network access policy (P3) for the full enforcement
story.
The deterministic-by-default split is what lets cntrdct be a
reproducible artefact: a given source tree plus a given binary
always produces the same SARIF output, modulo the explicit
--adjudicate opt-in.
See also:
- Citation policy (P1)
- Network access policy (P3)
- Statistical priors (P4)
- Severity and anomaly classes (P5)
docs/spec/for per-layer contracts.
Citation policy (P1)
Every detector ships with at least one peer-reviewed citation. The constraint — known internally as P1 — is the most stringent guarantee cntrdct makes, and it is enforced structurally rather than by convention.
The rule
- Every Layer 1 detector must declare at least one citation. The citation must point at a peer-reviewed publication or established benchmark that justifies the detection.
- Multi-language detectors should ship at least one citation grounded in empirical work on each supported language. This is a SHOULD, not a MUST — see Multi-language below for the boundary cases.
- Citations have DOIs where possible. The Q-6 retraction monitor uses the DOI to cross-check against Retraction Watch and Crossref Works.
Enforcement points
| Point | Check |
|---|---|
core::register_detector | Rejects any Detector whose citations() returns empty at startup. |
tests/citations_consistency.rs | Every citation key declared by any detector resolves to an entry in CITATIONS.md. |
tests/citations_consistency.rs | Each supported_languages() is either matched by a per-language citation or carries an explicit unconfirmed: annotation pointing at the survey notes. |
.github/workflows/citations.yml | Q-6 retraction monitor (see below). |
A detector with empty citations() cannot register, so a P1
violation fails before scan even begins. The consistency test runs
on every push and PR.
Multi-language extension
P1 was authored when cntrdct was Rust-only. The M-6 spec
(docs/spec/citations-policy.md)
codifies the multi-language case. When a detector declares support
for a new language, the implementer runs a literature survey and
records its outcome as one of:
Confirmed— a peer-reviewed citation grounded in empirical work on the target language exists, either because the cited paper’s experimental subjects include that language, because a separate peer-reviewed paper applies the algorithm to that language with quantitative evaluation, or because the citation introduces a benchmark in that language.Unconfirmed— the survey returned no qualifying citation. The detector still ships; the gap is captured inEvidence.language_citation_statusand exposed at SARIF emission time asproperties.languageCitationStatus. SARIF consumers can filter or weight indirectly-grounded findings.
When the survey is Unconfirmed, the implementer must still record
the search under docs/surveys/<detector>-<lang>-<YYYY-MM>.md — the
deliverable is the survey itself, not the citation. The existing
Rust citations on the v0 detectors are grandfathered, since they
predate the multi-language rule.
The current per-language statuses (excerpted from
docs/spec/citations-policy.md):
| Detector | Language | Status |
|---|---|---|
unreachable-after-terminator | Rust | Grandfathered |
unreachable-after-terminator | Python | Unconfirmed |
comment-code | Rust | Grandfathered |
comment-code | Python | Unconfirmed |
arg-swap | Rust | Grandfathered |
arg-swap | Python | Confirmed (Allamanis et al. NeurIPS 2021, PyBugLab) |
clone-drift | Rust | Grandfathered |
clone-drift | Python | Confirmed (Assi et al. TOSEM 2025) |
pr-miner | Rust | Grandfathered |
pr-miner | Python | Unconfirmed |
CITATIONS.md layout
CITATIONS.md
is grouped by layer:
- Layer 1 — per-detector citations.
- Layer 2 — ranker statistical methods (Wilson interval, Brown-Cai-DasGupta 2001, Thulin 2014).
- Layer 3 — LLM adjudicator references (Platt 1999, Spiess et al. 2025, the cross-model κ references).
Each entry carries the venue, year, DOI when available, and a
Languages: line declaring which languages the citation is
grounded in (M-6 extension).
Q-6 retraction monitor
P1’s “peer-reviewed prior art” assumption breaks if a cited paper is
retracted after shipping. The Q-6 monitor
(scripts/check_retractions.py) closes this loop on every CI run:
- DOIs are extracted from
CITATIONS.mdand from everyCitation { doi: Some("...") }slot undersrc/. - Each DOI is checked against (a) a cached Retraction Watch snapshot
at
benchmarks/retraction-watch/cache.csv, pinned bycache.sha256, and (b) Crossref Works’update-tofield withtype: "retraction". - A Mondays-06:00-UTC cron refreshes the cache via the Crossref Labs
endpoint and opens a
chore(citations): refresh Retraction Watch cachePR when the snapshot changes. - A synthetic-DOI fixture at
tests/fixtures/retraction-watch/pins the failure path so a future loosening of the matcher breaks CI rather than silently re-opening citations to retracted work.
See also:
docs/spec/citations-policy.md— the full multi-language citation rule.CITATIONS.md— bibliography.docs/surveys/— recorded literature surveys forUnconfirmedlanguages.
Network access policy (P3)
The default cntrdct scan pipeline never opens a socket. This is
the P3 design constraint, and it is enforced structurally in CI on
every push and pull request.
What does not touch the network
| Subcommand | Network? |
|---|---|
cntrdct scan (default) | No |
cntrdct scan --adjudicate | Yes — Layer 3 LLM adjudicator (opt-in) |
cntrdct calibrate | No |
cntrdct calibrate --fit-platt | No |
cntrdct eval | No |
cntrdct cross-model-kappa | Indirect — shells out to CLIs that themselves talk to the network |
Layers 1 (detectors), 2 (ranker), and 4 (SARIF emitter) are
deterministic and offline. The Q-12 apply_llm_calibration helper
is Layer 2 / Layer 4 post-processing, also offline.
The two opt-in network paths
scan --adjudicate invokes the Layer 3 adjudicator. It is gated
behind ANTHROPIC_API_KEY and hits the Anthropic Messages API via
reqwest. The reqwest dependency is reachable only from
src/adjudicator.rs::ReqwestClient and the
build_default_adjudicator constructor in src/lib.rs — adding a
non-adjudicator reach into reqwest is a P3 violation and breaks
the netns gate below.
cntrdct cross-model-kappa (Q-13) is the cross-model audit. It
shells out to claude --print and gemini -p, both of which handle
their own auth and HTTP. No API keys are read by cntrdct itself, and
no socket is opened from the cntrdct process. The subprocess that
talks to the network is the user’s installed CLI.
CI enforcement: the netns gate
.github/workflows/ci.yml runs a network-isolation job on every
push and PR. The job:
- Creates a fresh Linux network namespace via
sudo unshare --net. - Runs the full
cntrdct scanpath (walker -> parsers -> Layer 1 detectors -> Layer 2 ranker -> Layer 4 SARIF emitter) inside that namespace. - Asserts the emitted SARIF document is non-empty and well-formed.
The namespace has no outbound routes, so any unintended socket open
fails with ENETUNREACH or EAI_* and the job goes red. There is
no opt-out. Adding a non-adjudicator network path on scan,
calibrate, or eval breaks both the P3 constraint and the netns
gate.
The cross-model-kappa subcommand is excluded from the netns gate
by design — its whole purpose is to spawn subprocesses that talk to
the network. The same exclusion applies to scan --adjudicate.
Implementation note (AppArmor)
The first netns implementation used the unprivileged unshare -r --net form, but Ubuntu 24.04’s AppArmor unprivileged_userns
profile blocks /proc/self/uid_map writes from non-root processes
on GitHub-hosted runners. The current job uses sudo unshare --net
instead — passwordless sudo is available on GHA runners, and
--no-calibration keeps the scan from needing $HOME access since
priors are embedded into the binary via include_str!. If GHA’s
runner image ever loosens the AppArmor profile, the unprivileged
form is preferable for the smaller blast radius.
What this guarantees, what it does not
The netns gate proves the default scan path opens no sockets. It
does not prove:
- That
scan --adjudicateonly opens sockets to the Anthropic API. Use a network proxy or auditsrc/adjudicator.rsdirectly if you need this property. - That CLI subprocesses spawned by
cross-model-kappaonly talk to the LLM vendor’s API. The audit explicitly delegates trust to the vendor CLI (claude/gemini). - That tree-sitter parsers, the SARIF crate, or the standard library never attempt DNS or socket calls under exotic conditions. These would fail under netns even if they tried, so the gate catches them retroactively.
See also:
- Four-layer architecture — where the Layer 3 boundary sits.
docs/spec/cross-model-kappa-v0.md— design rationale for the CLI-shellout shape.docs/spec/adjudicator-v0.md— Layer 3 contract.
Statistical priors (P4)
The Layer 2 ranker scores findings by a Wilson or Jeffreys lower bound on detector precision multiplied by a log-scaled sibling-finding count. The priors come from labelled corpora — never from prompts, never from hardcoded constants. This is the P4 design constraint.
Where priors come from
The pipeline lives in src/calibration.rs plus src/ranker.rs:
cntrdct calibrate <CORPUS>reads a JSONL ofLabelledFindingrows and writes aHashMap<String, DetectorPrior>to disk.- The default labelled corpus
(
benchmarks/labelled-findings.jsonl) is hand-labelled across the seed and wild β corpora. benchmarks/priors-default.jsonis the output ofcntrdct calibrateagainst that corpus, embedded into the binary viainclude_str!so a freshcargo install cntrdctships with calibration ready.
The uncalibrated ranker continues to ship None for the
calibration columns when no corpus is available, rather than
guessed values — P4 explicitly rules out hand-authored numbers
even as defaults.
Runtime fallback chain
cntrdct scan picks the ranker by:
- If
--no-calibrationis set, useUncalibratedRanker. - Otherwise, if
--priors <PATH>is set, load that file. A missing file errors out; a parsed file producesCalibratedRanker. - Otherwise, if the default user-cache path exists, use
CalibratedRanker. - Otherwise, fall through to the embedded
benchmarks/priors-default.json. - As a final fallback,
UncalibratedRanker(silent, no warning).
Wilson and Jeffreys, switched at n = 30
Below cell size n = TP + FP = 30 the calibrator switches from the
Wilson 95% lower bound to a Beta(1, 1) Bayes-Laplace 2.5% quantile.
The boundary at TP = 0 applies the Brown-Cai-DasGupta 2001 §4
modification (return 0) so Jeffreys agrees with Wilson at the most
common observation-free cell.
| Cell size | Method | Notes |
|---|---|---|
n >= 30 | Wilson | PriorMethod::Wilson. Byte-stable with pre-Q-11 priors files. |
n < 30, TP > 0 | Jeffreys (Beta credible interval) | PriorMethod::Jeffreys. 2.5% quantile of Beta(TP+1, FP+1). |
n < 30, TP = 0 | Jeffreys with BCD 2001 boundary | Returns 0. |
n = 0 | Wilson convention | Returns 0. |
The chosen method is recorded on each finding as prior_method
(RankedFinding.prior_method, SARIF
result.properties.priorMethod) so the choice is auditable
downstream.
Why Jeffreys at small N
The roadmap framing “Jeffreys is closer to nominal than Wilson at
n < 30” is a useful shorthand but does not hold robustly under
one-sided lower coverage averaged over p. With the BCD 2001
boundary correction, both methods sit similarly close to nominal at
small n. The actual reason for switching is methodological
coherence:
posterior_tpis already a Beta(1, 1) Bayesian update; pairing it with a Beta(1, 1) credible-interval lower bound at smallnkeeps both columns in one regime. Mixing the two would surface incompatible uncertainty semantics within the sameDetectorPrior.- At
n >= 30Wilson and the Bayes-Laplace bound agree to several decimals, so the choice is irrelevant; Wilson is retained for byte-stability with pre-Q-11 priors files.
See docs/spec/ranker-v1.md
“Q-11 design notes” for the full argument.
Rank score formula
For each finding f, the calibrated ranker computes:
rank_score = wilson_or_jeffreys_lower(detector_id)
* (1.0 + log2(1.0 + related.len()))
The lower bound replaces the raw TP rate, so a rare-but-precise detector cannot dominate before enough evidence accumulates (Z-Ranking, Kremenek & Engler SAS 2003). The log-sibling factor is monotone and sub-linear in group size, rewarding corroboration without letting one huge clone group flood the top of the ranking.
Q-12 LLM confidence calibration
The Q-12 helper (apply_llm_calibration in
src/llm_calibration.rs) applies post-hoc Platt scaling to the
Layer 3 adjudicator’s verdict confidence, per (detector_id, anomaly_class) cell. Platt parameters are fit by
cntrdct calibrate --fit-platt <CORPUS> against a labelled
adjudication corpus and embedded as
benchmarks/llm-calibration/platt-default.json.
This module sits in the deterministic Layer 2 / Layer 4 surface,
not Layer 3 — apply_llm_calibration only post-processes whatever
the adjudicator returned, with no LLM call. v0 ships the registry
empty ({}), so the helper is a no-op until a real labelled
adjudication corpus is fit. The motivating evidence is Spiess,
Koohestani & Sergeyuk (2025), which shows that verbalised
confidence is not better calibrated than raw output at corpus
scale, and the companion Spiess et al. ICSE 2025 paper, which
demonstrates that post-hoc Platt scaling lifts expected calibration
error on code-LLM outputs.
See also:
docs/spec/ranker-v1.md— ranker contract and Q-11 design notes.docs/spec/llm-calibration-v0.md— Q-12 Platt calibration spec.- Workflows: calibrate — CLI usage for both priors and Platt modes.
Severity and anomaly classes (P5)
Every finding carries two orthogonal labels:
- An IEEE 1044-2009 anomaly classification (
AnomalyClass), describing the kind of defect. - A SARIF-compatible severity level (
Severity), describing the attention the finding warrants.
Both are pinned at SARIF emission time and asserted by
tests/sarif_lib.rs. The mapping is the P5 design constraint.
IEEE 1044-2009 anomaly classes
Finding.anomaly_class is one of seven values defined in
src/core.rs:
| Class | Meaning | Used by |
|---|---|---|
Logic | Behavioural contradiction in control or data flow. | clone-drift, config-interaction, pr-miner, unreachable-after-terminator |
Interface | Caller-callee mismatch in arguments or signatures. | arg-swap |
Data | Inconsistency in stored or transferred data. | — |
Documentation | Contradiction between comment and code. | comment-code |
Performance | Resource-use issue. | — |
Standards | Coding-standard violation. | — |
Other | Defects not covered above. | — |
The class is fixed per detector — clone-drift is always Logic,
arg-swap is always Interface, and so on. Future detectors may
populate the unused rows.
SARIF level mapping
Each finding’s Severity maps to a SARIF 2.1.0 level value at
emission time:
Severity | SARIF level | Rationale |
|---|---|---|
Error | error | Hard contradictions; false positives are rare by construction. |
Warning | warning | Default for shipped detectors. |
Note | note | Lower-confidence findings or downgrades. |
Info | none | User-authored downgrade; signals “less visible than Note” to GitHub Code Scanning. |
The mapping is one-way at the SARIF surface, but the original
Finding.raw_severity is preserved in result.properties.raw so
downstream consumers that branch on the four-valued vocabulary can
recover it.
Info -> none decision log
Severity::Info maps to SARIF "none", not "note". The rationale,
recorded in docs/spec/sarif-v0.md
F5:
- No shipped detector emits
Severity::Infoby construction. The variant enters the pipeline only through user-authoredcntrdct.tomlseverity overrides. A user who explicitly downgrades a finding toInfois signalling “I want this less visible thanNote.” - SARIF 2.1.0 §3.27.10 defines
"none"as “the level is not applicable to the result,” and GitHub Code Scanning suppressesnone-level results from the inline PR review surface. That is exactly what anInfo-tagged finding should do. - The alternative remap (
Info -> "note") would conflateSeverity::InfoandSeverity::Notein SARIF output, defeating the user’s intent.
The trade-off is explicit: GitHub Code Scanning hides Info
findings from the inline PR review surface. This is intentional and
documented so a future SARIF consumer change does not silently
reopen the question.
Severity remapping via cntrdct.toml
Severity can be overridden per-detector or per-path in
cntrdct.toml:
[severity]
"clone-drift" = "note" # global downgrade
[[severity.path]]
glob = "tests/**"
detector = "clone-drift"
level = "info" # tests-only downgrade
The override is applied before SARIF emission, so the SARIF level
reflects the remapped value. The original detector-side severity is
still available in result.properties.raw.
See Configuration: cntrdct.toml reference for the full schema.
What is pinned where
| File | Pins |
|---|---|
src/core.rs | AnomalyClass enum and serde projection. |
src/sarif.rs | The Severity -> SARIF level mapping. |
tests/sarif_lib.rs | Assertions that the mapping holds end-to-end. |
tests/multilang_config.rs | Assertions that cntrdct::ALL_DETECTOR_IDS is fully present in tool.driver.rules. |
.github/workflows/ci.yml | Sarif.Multitool validate against the OASIS 2.1.0 schema on every CI run. |
See also:
docs/spec/sarif-v0.md— SARIF emitter contract and F5 decision log.- Integrations: SARIF output — consumer-side notes on the emitted document.
arg-swap
Flags a two-argument call site whose argument identifier names match the function’s parameter names in reversed order. The contradiction is between the call’s positional layout and the callee’s parameter contract.
| Property | Value |
|---|---|
| Detector ID | arg-swap |
| Languages | Rust, Python |
| IEEE 1044-2009 class | Interface |
| Default severity | Warning |
| Spec | docs/spec/arg-swap-v0.md |
What it flags
fn copy(dst: &mut [u8], src: &[u8]) { /* ... */ }
fn main() {
let mut dst = vec![0u8; 16];
let src = b"hello";
copy(src, &mut dst); // <- flagged: argument order reversed
}
The detector resolves the call to the same-file definition of copy,
notices that the call’s first identifier src matches the second
parameter name and vice versa, and emits a finding pointing at the
call site with the definition in related. The evidence.raw payload
carries callee_name, parameter_names, and argument_names.
What it does not flag
- Calls whose arguments are not bare identifiers (literals,
expressions, field accesses).
copy(some.dst, src)is out of scope because the AST node is not a simple identifier. - Single-argument or N ≥ 3 calls. Only binary functions participate.
- Method calls (
obj.copy(a, b)) and qualified-path calls (mod::copy(a, b)). - Cross-file resolution. The call and the definition must live in the same source file.
- Calls where the call-site identifier names are the same as the parameter names but in the right order, or where the names share no overlap at all.
The narrow scope is deliberate: arg-swap trades recall for precision so the signal is actionable without an LLM in the loop.
Language coverage
- Rust: confirmed citation. Pradel & Sen “DeepBugs” (FSE 2018) and Rice et al. “Detecting Argument Selection Defects” (ICSE 2017) ground the AST-level shape used here.
- Python: confirmed citation. Allamanis, Jackson-Flux, Brockschmidt
(NeurIPS 2021, PyBugLab / PyPIBugs) covers the same swap pattern on
PyPI corpora. PyBugLab also ships as the Q-15 SOTA baseline
comparator for arg-swap (
baselines/pybuglab/).
Configuration
Suppress in-source:
- Rust:
#[cntrdct::allow(arg-swap)]on the call site’s enclosing function or item. - Python:
# cntrdct: allow(arg-swap)as a trailing or preceding comment (Q-9).
Suppress project-wide via cntrdct.toml:
[detectors.arg-swap]
enabled = false
[languages.python]
suppress = ["arg-swap"]
See In-source suppressions and cntrdct.toml reference.
Related
- Citation policy (P1) — why every language addition needs its own grounding.
- Statistical priors (P4) — how the Layer 2 ranker re-orders arg-swap findings by Wilson / Jeffreys lower bound against the calibrated corpus.
- pr-miner — complementary “implicit pairing” rule miner; both detectors target the same family of API-contract violations from different angles.
clone-drift
Flags a near-duplicate function whose AST has diverged from the strict majority of its siblings in the same scope. The contradiction is between “looks like a copy” and “doesn’t actually do what its copies do”.
| Property | Value |
|---|---|
| Detector ID | clone-drift |
| Languages | Rust, Python |
| IEEE 1044-2009 class | Logic |
| Default severity | Warning |
| Spec | docs/spec/clone-drift-v0.md |
What it flags
#![allow(unused)]
fn main() {
fn poll_a(cx: &mut Context<'_>) -> Poll<()> {
if let Some(x) = inner_a(cx) { return Poll::Ready(x); }
Poll::Pending
}
fn poll_b(cx: &mut Context<'_>) -> Poll<()> {
if let Some(x) = inner_b(cx) { return Poll::Ready(x); }
Poll::Pending
}
fn poll_c(cx: &mut Context<'_>) -> Poll<()> {
if let Some(x) = inner_c(cx) { return Poll::Ready(x); }
Poll::Pending
}
fn poll_d(cx: &mut Context<'_>) -> Poll<()> {
// <- flagged: drifted from the other three
if inner_d(cx).is_some() { break; }
Poll::Pending
}
}
poll_a / poll_b / poll_c normalise to identical AST node
sequences and form a dominant partition of size 3. poll_d belongs to
the same Jaccard ≥ 0.5 cluster but has a singleton normalised form
that differs by a small surgical edit (return Poll::Ready(x) →
break). The finding points at poll_d; related lists the three
canonical-form siblings.
How the gates compose
The detector applies four ordered gates so the textbook “one of N copies missed an update” pattern fires while designed N-variant families and library-shape clusters do not:
| Gate | Purpose | Default |
|---|---|---|
MIN_FN_TOKENS = 22 | drop trivially short functions | constant |
SIMILARITY_THRESHOLD = 0.5 | pairwise Jaccard for cluster membership | constant |
| F5b scope-bounded clustering | clusters never cross crate / package boundaries | provenance-aware |
| F5c-i strict-majority | dominant partition must be > 50% of the cluster | constant |
F5c-ii NEAR_DUPLICATE_THRESHOLD = 0.7 | drifted singleton must be near-duplicate of the dominant exemplar | constant |
| F5d-i multi-singleton suppression | ≥ 2 singleton partitions ⇒ designed family ⇒ silent | constant |
| F5d-ii length-imbalance × weak-dominant gate | high length asymmetry under weak canonical evidence ⇒ silent | constants |
| F5d-iii small-cluster floor | MIN_GROUP_SIZE cluster at the resolution limit ⇒ silent | constants |
Gates F5b / F5c / F5d landed across P-6 and P-7 in response to the wild β corpus residuals. Wild β clone-drift FP count is 0 in both Rust and Python after F5d.
Language coverage
- Rust: grandfathered citation (Juergens et al. ICSE 2009 on inconsistent clone changes); the broader Bettenburg MSR 2009 and Krinke ICSM 2007 citations supply the F5c-ii drift gate’s grounding.
- Python: confirmed citation. Assi, Hassan, Zou (TOSEM 2025, DOI
10.1145/3721125) replicate NiCad / SourcererCC on nine Python deep-
learning frameworks. SourcererCC also ships as the Q-15 SOTA
baseline comparator for clone-drift (
baselines/sourcerercc/).
What it does not flag
- Clusters smaller than
MIN_GROUP_SIZE = 3. - Functions whose normalised AST has fewer than
MIN_FN_TOKENS = 22tokens (small utility functions whose drift signal is too noisy). - Functions inside
impl/trait/modblocks. Only top-levelfn(Rust) andfunction_definition(Python) participate in v0. - Cross-scope siblings. The F5b scope rule keeps clustering within a single crate / package / parent-directory namespace.
Configuration
[detectors.clone-drift]
enabled = false
# Severity overrides apply at SARIF emission time (P5).
severity = "Note"
Per-language disable:
[languages.python]
suppress = ["clone-drift"]
See In-source suppressions for the per-function attribute and comment forms.
Related
- arg-swap — different shape of same-file contradiction (parameter contract vs structural-clone consistency).
- Statistical priors (P4) — embedded clone-drift Wilson lower bound is 0.676 (8 TP / 0 FP) after P-7.
- Severity and anomaly classes (P5) — Logic anomaly class mapping.
comment-code
Flags a doc comment whose rendered prose claims a behaviour the implementation does not exhibit. The contradiction is between the documented contract and the actual code.
| Property | Value |
|---|---|
| Detector ID | comment-code |
| Languages | Rust, Python |
| IEEE 1044-2009 class | Documentation |
| Default severity | Note |
| Spec | docs/spec/comment-code-v0.md |
Three patterns
The Tan et al. iComment (SOSP 2007) and aComment (PLDI 2011) papers laid out three structural patterns that a pattern-based detector can match without NLP. cntrdct ships all three:
| Pattern | Trigger phrase | Constraint |
|---|---|---|
| A — Result / Option claim | returns err, may fail, fallible, returns option, may return none | function’s return_type does not contain Result or Option |
| B — Panic claim | panic substring (matches panic and panics) | function body contains none of panic!, unwrap, expect(, unreachable!, assert!, assert_eq!, assert_ne!, todo!, unimplemented!, debug_assert |
| C — Deprecated claim | deprecated substring | function does not carry a #[deprecated] attribute |
Pattern A example
#![allow(unused)]
fn main() {
/// Returns Err on bad input.
fn parse(input: &str) -> i32 { // <- flagged: not Result/Option
input.parse().unwrap_or(0)
}
}
Pattern B example
#![allow(unused)]
fn main() {
/// Panics if `x` is zero.
fn safe_divide(x: i32, y: i32) -> i32 { // <- flagged: no panic site
if x == 0 { return 0; }
y / x
}
}
Pattern C example
#![allow(unused)]
fn main() {
/// # Deprecated
/// Use `bar` instead.
fn foo() {} // <- flagged: no #[deprecated] attr
}
Python extensions
Python ships the same three patterns, with two suppression rules added in response to wild-corpus residuals:
- F5b —
:raises:factory suppression. A docstring that mentions a raise but whose function returns a call expression (factory shape, e.g. attrs’sinstance_of(type) -> _InstanceOfValidator(type)) is reporting the returned object’s behaviour, not the factory’s. The raise-pattern is suppressed. - F5c —
.. deprecated::directive subject. reST directives whose body starts with*or backtick deprecate a parameter, not the function. Function-level directives still fire.
Citation grounding
- Rust: confirmed via
tan-sosp-2007(iComment Pattern A / B / C taxonomy) andtan-pldi-2011(aComment). Both are emitted on every finding. - Python: unconfirmed per
docs/surveys/comment-code-python-2026-05.md. P1 is satisfied by the Rust citations under the citations-policy grandfather clause for cross-language extensions.
Audit signal
The Q-14 recall-audit harness closed comment-code at 34 / 0 /
1.00 across twenty-three permissive-licensed upstreams, saturating
all three Tan SOSP 2007 patterns. This is the strongest single recall
signal cntrdct currently publishes; the other five detectors all sit
at 0.00 recall_upper_bound by design (they exercise narrower v0 scope
choices that the audit corpus deliberately overshoots — see
docs/spec/recall-audit-v0.md).
Configuration
[detectors.comment-code]
enabled = true
severity = "Warning" # raise above the default Note
In-source:
- Rust:
#[cntrdct::allow(comment-code)]on the function. - Python:
# cntrdct: allow(comment-code)immediately before the function definition (Q-9 suppression scanner).
Related
- Citation policy (P1)
- Severity and anomaly classes (P5) — the Documentation anomaly class is unique to this detector at v0.
config-interaction
Flags a top-level Rust item that carries two #[cfg(...)] attributes
whose predicates are structurally negations of each other — so the
item is unsatisfiable under every feature configuration.
| Property | Value |
|---|---|
| Detector ID | config-interaction |
| Languages | Rust |
| IEEE 1044-2009 class | Logic |
| Default severity | Warning |
| Spec | docs/spec/config-interaction-v0.md |
What it flags
#![allow(unused)]
fn main() {
#[cfg(feature = "x")]
#[cfg(not(feature = "x"))]
fn never_compiled() { // <- flagged: dead under every cfg
// ...
}
}
The detector canonicalises each predicate’s text, checks for the
not(X) / X pair structurally, and emits a finding pointing at the
item with both attribute locations in related. The
evidence.raw.inner_predicate field carries the canonical predicate
string so reviewers can audit the match without re-reading the source.
Detection is deliberately literal: only structural negation pairs
fire. Anything that requires SAT-style reasoning over all(...) /
any(...) combinations is out of scope for v0.
Examples that fire
#[cfg(unix)]+#[cfg(not(unix))]on the same struct.#[cfg(all(unix, x))]+#[cfg(not(all(unix, x)))]— the inner predicates are byte-equal after canonicalisation.- Three attributes
P,not(P),not(P)— one finding;evidence.raw.additional_pairsrecords the extras.
Examples that do not fire
#[cfg(feature = "a")]+#[cfg(feature = "b")]— nonot(...).#[cfg(unix)]+#[cfg(not(windows))]— different inner predicates.#[cfg_attr(test, cfg(not(unix)))]—cfg_attris out of scope at v0.- A single
#[cfg(unix)]on its own — no pair.
Language coverage
Rust-only at v0. Python’s if sys.version_info / typing-style
version guards have no AST-level analogue to #[cfg], so the
detector does not extend cross-language.
Citations:
tartler-eurosys-2011— Tartler, Lohmann, Sincero, Schröder-Preikschat. EuroSys 2011. The canonical reference for the feature-consistency anomaly family this detector targets.nadi-icse-2014— Nadi, Berger, Kästner, Czarnecki. ICSE 2014. Empirical evidence that contradictorycfgpredicates recur in long-lived codebases.
Both citation keys appear on every finding.
Configuration
[detectors.config-interaction]
enabled = false
Per-item suppression:
#![allow(unused)]
fn main() {
#[cntrdct::allow(config-interaction)]
#[cfg(feature = "x")]
#[cfg(not(feature = "x"))]
fn intentionally_unbuildable() {}
}
Limitations
- No SAT solver — the v0 check is structural.
cfg_attris not unwrapped.- Predicates that differ only in whitespace inside comments are not unified (the canonicaliser collapses runs of whitespace but does not strip embedded comments).
- Build-system files (
Cargo.toml,build.rs) are out of scope; this detector only walks Rust source.
Related
- Four-layer architecture
- Severity and anomaly classes (P5) — Logic anomaly class.
- unreachable-after-terminator —
the F4b cfg-gated terminator suppression interacts with the same
attribute family from the opposite side (suppressing false
positives on the canonical
#[cfg(X)] return ...; #[cfg(not(X))] return ...idiom).
pr-miner
Mines implicit programming rules via Apriori frequent-itemset
analysis over per-function call-site transactions, then flags
function bodies that violate the mined rule. The contradiction is
between a statistically-supported pairing (e.g. lock → unlock)
and a single function that calls one without the other.
| Property | Value |
|---|---|
| Detector ID | pr-miner |
| Languages | Rust, Python |
| IEEE 1044-2009 class | Logic |
| Default severity | Warning |
| Spec | docs/spec/pr-miner-v0.md |
How it works
- Each top-level function in the scan becomes one transaction whose items are the distinct call-site names inside its body.
- Apriori mines association rules
{a} → {b}withsupport ≥ MIN_SUPPORT = 0.05andconfidence ≥ MIN_CONFIDENCE = 0.85. - Any function whose body contains
abut neverbis a violator and receives one Finding. Finding.relatedlists every function in the database that satisfies the rule (capped atMAX_RELATED = 32).
MAX_ITEMSET_SIZE = 2 keeps the algorithm to pairs in v0. Lifting
to FP-growth and N > 2 itemsets is tracked under Future Q-series
candidates in the roadmap.
What it flags
#![allow(unused)]
fn main() {
fn handle_request(conn: &Mutex<Conn>) -> Result<()> {
let guard = lock(conn);
process(&guard);
// <- flagged: 9 of 10 functions that call lock(...) also call unlock(...)
Ok(())
}
}
The message format is:
function calls lock but never unlock; 9 of 10 similar functions (90%) call both
The N / M / percent figures come straight from the rule’s
support and confidence numbers; the evidence.raw payload carries
the full rule_lhs, rule_rhs, support, confidence,
transaction_count, and related_capped fields so a reviewer can
audit the inference without re-running the miner.
What it does not flag
- Closures, dynamic dispatch (
obj[i]()), and other non-identifier call heads. The extractor only seescall_expression/callnodes whose head is apath/identifier/attribute. - Functions with fewer than
MIN_TRANSACTION_ITEMS = 2distinct call items. Single-call bodies have no signal. - Any rule when the transaction database is smaller than
MIN_DATABASE_SIZE = 20. Apriori on tiny corpora is too noisy to act on. - Inter-procedural rules (lock acquired in
f, released ing).
Language coverage
- Rust:
li-zhou-fse-2005is grandfathered as a confirmed citation perdocs/spec/citations-policy.md. - Python: unconfirmed citation per
docs/surveys/pr-miner-python-2026-05.md. P1 is satisfied by the Rust citation as a cross-language extension.
Empirical FP profile
The v0.1 calibration labels 16 TP / 22 FP across the seed and wild
corpora (posterior_tp ≈ 0.43). The dominant failure mode (21 / 22
FPs) is stdlib-constructor co-occurrence: Err(...) and Ok(...)
both reduce to the items Err / Ok under the last-segment rule,
so Apriori mines them as a paired API even though no contract is
being violated. The spec’s “v1 mitigations under consideration”
section discusses three tightening options (stop-list, fully-
qualified paths, cardinality post-filter); none are shipped yet, and
the Layer 2 ranker is what currently keeps these false positives
near the bottom of the output.
The Q-14 recall audit closed pr-miner at 2/0/1.00 against the
Semgrep open-never-closed rule on the audit corpus — the
narrow-but-clean recall signal complements the wild-corpus FP
picture.
Configuration
[detectors.pr-miner]
enabled = false
Per-language disable:
[languages.python]
suppress = ["pr-miner"]
In-source: #[cntrdct::allow(pr-miner)] (Rust) or
# cntrdct: allow(pr-miner) (Python).
Related
- arg-swap — different family of same-file API- contract violations.
- Statistical priors (P4) — pr-miner is the
only shipped detector at v0.4.3 carrying
prior_method = "wilson"(n = 38, above the Q-11SMALL_SAMPLE_THRESHOLD = 30switch). - SOTA baselines — PyBugLab pairs naturally with the arg-swap-style violations pr-miner surfaces on Python.
unreachable-after-terminator
Flags a statement that follows an unconditional terminator within the same block. The contradiction is between the program’s static control-flow graph and the textual layout of the source.
| Property | Value |
|---|---|
| Detector ID | unreachable-after-terminator |
| Languages | Rust, Python |
| IEEE 1044-2009 class | Logic |
| Default severity | Warning |
| Spec | docs/spec/unreachable-after-terminator-v0.md |
Terminator sets
| Language | Terminators |
|---|---|
| Rust | return, break, continue, plus macro terminators panic!, unreachable!, todo!, unimplemented!, abort!, exit! |
| Python | raise, sys.exit(...), os._exit(...), assert False, trailing return |
What it flags
#![allow(unused)]
fn main() {
fn lookup(name: &str) -> Option<u32> {
return cache_lookup(name);
log::info!("lookup {}", name); // <- flagged
}
}
For each block, the detector classifies statements as terminators or
non-terminators, walks the block in order, and emits one Finding
pointing at the first statement after a terminator. Subsequent
statements in the same block are silent to avoid duplicate noise on
a single contradiction site (evidence.raw.following_count records
how many statements were rendered unreachable).
What it does not flag (by design)
The wild β corpus exposed two systematic false-positive patterns that the spec closes via F4b and F4c:
-
F4b — cfg-gated terminators. The canonical cross-platform return idiom
#![allow(unused)] fn main() { #[cfg(feature = "x")] return foo(); #[cfg(not(feature = "x"))] return bar(); }has exactly one terminator active per cfg evaluation; treating them as sequential statements produced 10 / 10 false positives on the wild Rust β corpus. F4b suppresses the terminator side; a terminator followed by a cfg-gated follower still fires (T34).
-
F4c — hoisted item suppression.
fn/const/static/use/struct/enum/type/mod/impl/traititems are hoisted by the Rust compiler; their textual position carries no runtime ordering. They are filtered out of the block’s statement list before terminator analysis runs. An executable statement after the items still fires (T37).
Additional non-goals:
- Inter-procedural reachability (a helper that always panics).
- Branch-merging analysis (
if cond { return 1 } else { return 2 } bar();requires dataflow, out of v0 scope). loop { ... }withoutbreak.matcharms whose every branch diverges.
Language coverage
- Rust: grandfathered citations. Hovemeyer & Pugh OOPSLA 2004 defines the “UR — Unreachable code” bug pattern; Engler et al. SOSP 2001 establishes the broader “Bugs as Deviant Behavior” framing.
- Python: unconfirmed citation per
docs/surveys/unreachable-after-terminator-python-2026-05.md; P1 is satisfied by the two grandfathered Rust citations.
Interaction with #[allow(unreachable_code)]
The detector treats #[allow(unreachable_code)] on any ancestor
function or block (and the #![allow(unreachable_code)] inner-
attribute form) as a suppression. The match is by textual substring
on the attribute source — robust attribute parsing is out of v0
scope and unnecessary for the high-precision common case.
Configuration
[detectors.unreachable-after-terminator]
enabled = true
severity = "Warning"
In-source:
- Rust:
#[cntrdct::allow(unreachable-after-terminator)]on the containing function, or the bare#[allow(unreachable_code)]form which the detector already respects. - Python:
# cntrdct: allow(unreachable-after-terminator)(Q-9).
Related
- config-interaction — the cfg attribute family interacts with both detectors from opposite sides.
- Severity and anomaly classes (P5) — Logic anomaly class.
cntrdct.toml reference
A cntrdct.toml placed at the scan root tunes per-detector behaviour,
filters by path, and controls per-language enablement. The file is
optional — a missing cntrdct.toml is silently treated as the empty
config (every detector on, every language on, no suppression). The
file location can be overridden with cntrdct scan --config <PATH>.
The full schema lives in src/config.rs and is enforced via
serde(deny_unknown_fields), so a typo in a section name is a hard
parse error rather than a silent skip.
Top-level shape
[detectors.<id>] # per-detector overrides (enabled, severity)
[paths] # include / exclude globs
[languages.<canonical>] # per-language overrides (enabled, suppress)
[detectors.<id>]
[detectors.clone-drift]
enabled = false # drop every clone-drift finding
[detectors.arg-swap]
severity = "error" # remap raw severity
[detectors.unreachable-after-terminator]
enabled = true
severity = "note"
enabled accepts true or false (default true). severity
accepts the lowercase strings "info", "note", "warning",
"error"; the value replaces the detector’s raw_severity on every
finding before SARIF emission applies the IEEE 1044-2009 mapping
(Severity and anomaly classes (P5)).
<id> values are exactly the strings in
cntrdct::ALL_DETECTOR_IDS — i.e. arg-swap, clone-drift,
comment-code, config-interaction, pr-miner,
unreachable-after-terminator. Unknown IDs are accepted but
ineffective; consider running cntrdct scan and checking findings
to confirm the section is wired.
[paths]
[paths]
exclude = ["vendor/**", "**/generated/*.rs"]
include = ["src/**", "lib/**"]
Both fields are lists of globset-compatible patterns evaluated
against the finding’s primary file (relative to the scan root).
The semantics are:
excludealways wins. Any finding whose primary file matches an exclude glob is dropped.includeis “allowlist mode” when non-empty: a finding survives only if its primary file matches at least one include glob.- Both empty (or absent) is the default: every file is in scope.
Path filtering happens at the apply stage, not at file discovery, so
exclusion does not save scan time — it filters the output. To skip a
directory at discovery time, use a per-language disable ([languages.<x>] enabled = false) or invoke cntrdct against a narrower path.
[languages.<canonical>]
[languages.rust]
enabled = true
[languages.python]
enabled = true
suppress = ["pr-miner", "comment-code"]
Two effects:
enabled = falsecauses the file walker to skip files of that language at discovery time. This is the right knob to turn off an entire language family.suppress = ["<id>", ...]drops findings whose primary file is in that language and whosedetector_idis in the list. Equivalent in spirit to[detectors.<id>] enabled = false, but scoped to one language so a detector can stay on for Rust while being silenced on Python (or vice versa).
Canonical language names are rust and python at v0.4.3. Unknown
keys ([languages.ruby] etc.) are accepted but ineffective.
Precedence
When multiple knobs apply to the same finding, the apply order is:
- Language enablement (
[languages.<x>] enabled = falseremoves the file from the scan). - Path filter (
[paths]excludetheninclude). - Per-detector enablement (
[detectors.<id>] enabled = false). - Per-language suppression (
[languages.<x>] suppress). - In-source attributes (In-source suppressions).
- Per-detector severity remap (
[detectors.<id>] severity = "...").
Any single drop in steps 1–5 short-circuits the finding before SARIF emission. Severity remap (step 6) only fires on findings that survive the drops.
See also
- In-source suppressions —
#[cntrdct::allow(...)]attribute and# cntrdct: allow(...)comment forms. - Severity and anomaly classes (P5) — what
remapping
severityactually changes downstream.
In-source suppressions
Findings can be suppressed at the call site without editing
cntrdct.toml. Two equivalent surfaces ship: a Rust attribute and a
Python line comment. Both are recognised by the Q-9 / Q-10 tree-
sitter parser seam and apply at the apply stage after Layer 1
finishes — the detector still runs, but the matching findings are
filtered before they reach SARIF emission.
Rust — #[cntrdct::allow(...)]
#![allow(unused)]
fn main() {
#[cntrdct::allow(clone-drift)]
fn looks_like_a_drifted_clone_but_is_intentional() { /* ... */ }
}
The attribute precedes the item it suppresses. The argument list takes one or more detector IDs; multiple IDs in a single attribute are equivalent to stacking multiple attributes.
#![allow(unused)]
fn main() {
#[cntrdct::allow(clone-drift, unreachable-after-terminator)]
fn double_suppressed() { /* ... */ }
}
Empty argument list is the catch-all: every detector is silenced on that item.
#![allow(unused)]
fn main() {
#[cntrdct::allow()]
fn silence_everything_on_this_function() { /* ... */ }
}
Recognised attribute scopes:
function_item— the most common case.struct_item,enum_item,impl_item,mod_item,static_item,const_item,trait_item,type_item,union_item— anywhere a Rust outer attribute attaches.- Block-level inner attributes (
#![cntrdct::allow(...)]) suppress the enclosing block.
The suppression range covers the entire item span, so a finding anywhere inside the function — primary or related — is dropped.
Python — # cntrdct: allow(...)
The Q-9 tree-sitter-python suppression scanner recognises two positions for the comment:
Trailing form
do_something(b, a) # cntrdct: allow(arg-swap)
Suppresses findings whose primary.start_line equals the comment
line. Use this for narrow, in-line suppression.
Standalone (whole-line) form
# cntrdct: allow(arg-swap)
def looks_like_a_swap_but_intentional(b, a):
do_something(b, a)
Suppresses findings whose primary falls anywhere inside the next
non-comment named sibling — function, class, or top-level statement
— mirroring the Rust attribute-precedes-item shape at line
granularity.
Catch-all
# cntrdct: allow()
def silence_everything():
...
Both forms accept the empty argument list as the catch-all.
What gets matched
The argument tokens between the parentheses are split on commas and
trimmed; each must be a detector ID from cntrdct::ALL_DETECTOR_IDS.
Whitespace inside the parentheses is irrelevant. Unknown IDs are
accepted at parse time but have no effect — they simply do not match
any finding.
Test coverage lives in tests/multilang_config.rs
(python_attribute_allow_* cases) and tests/suppression.rs for the
Rust attribute paths. All three surfaces (Rust attribute, Python
standalone, Python trailing) round-trip through the same apply
helper, so the precedence rules in cntrdct.toml
reference apply uniformly.
When to use which surface
| Scenario | Surface |
|---|---|
| One specific call site / function known to be a false positive | In-source comment / attribute. Document the reason inline. |
| Wholesale silencing of a detector for a vendored directory | cntrdct.toml [paths] exclude |
| Silencing a detector for an entire language | cntrdct.toml [languages.<x>] suppress |
| Disabling a detector globally | cntrdct.toml [detectors.<id>] enabled = false |
In-source suppressions are preferred for narrow, justified
exceptions because they keep the rationale next to the code. Bulk
silencing belongs in cntrdct.toml where it can be reviewed in one
place.
See also
- cntrdct.toml reference — config-file precedence and per-path / per-language knobs.
- Citation policy (P1) — every suppression is a claim that the citing paper’s prior art does not apply to that code; keep the comment alongside the suppression honest.
scan
cntrdct scan <PATH> is the primary entry point. It walks a directory
or file, parses each supported-language source through tree-sitter,
runs all six Layer 1 detectors, applies the Layer 2 ranker, optionally
routes the top-N findings through the Layer 3 LLM adjudicator, and
emits the result as JSON or SARIF 2.1.0.
Synopsis
cntrdct scan ./src # JSON to stdout
cntrdct scan ./src --format sarif > out.sarif # SARIF to file
cargo cntrdct scan ./src # cargo subcommand shim
| Flag | Default | Effect |
|---|---|---|
--format <json|sarif> | json | Output format. JSON pretty-prints Vec<RankedFinding> (carries rank_score, posterior_tp, wilson_lower). SARIF emits OASIS 2.1.0 ordered by descending rank_score. |
--config <PATH> | scan-root cntrdct.toml | Override the project config. Missing file is silently treated as an empty config. |
--priors <PATH> | per-user cache → embedded | Override the Layer 2 priors JSON. The full fallback chain is --priors → <cache_dir>/cntrdct/priors.json → embedded default → uncalibrated. |
--no-calibration | off | Force the uncalibrated ranker (sibling-count ordering) regardless of priors availability. |
--adjudicate | off | Route the top-N findings through Layer 3 LLM. Requires ANTHROPIC_API_KEY. Absence of the key prints a stderr note and continues without adjudication. |
--adjudicate-top <N> | 5 | Number of top-ranked findings to send to the adjudicator. |
Output shapes
JSON is the default. Each RankedFinding carries:
detector_id,primary,related,message,severity,anomaly_class,evidence(verbatim from Layer 1).rank_score(Wilson / Jeffreys precision × log-sibling-count),prior_method("wilson"or"jeffreys"),posterior_tp, andwilson_lower(Layer 2).adjudication(verdict, raw confidence, optional Platt-calibrated confidence, calibration_tag) when--adjudicateran.
SARIF emission is validated against the OASIS 2.1.0 schema on every
CI run; tool.driver.rules carries one entry per detector ID per the
cntrdct::ALL_DETECTOR_IDS single source of truth (Q-4).
Exit codes
| Code | Meaning |
|---|---|
| 0 | Scan completed successfully (regardless of finding count). |
| 1 | Invalid arguments, path not found, or any ScanError. |
scan never panics on bad input; parse failures inside a single file
are logged to stderr and the file is skipped silently.
Network boundary
scan itself is fully offline. Only the optional --adjudicate flag
opens a socket — and even then, only from
src/adjudicator.rs::ReqwestClient. The CI network-isolation job
runs cntrdct scan inside a Linux network namespace
(sudo unshare --net) on every push and PR; any unintended socket
open fails the job with ENETUNREACH / EAI_*. See
Network access policy (P3) for the full
enforcement story.
Worked example
$ cntrdct scan ./src --format json | jq '.[0]'
{
"detector_id": "comment-code",
"primary": { "file": "src/parser.rs", "start_line": 42, ... },
"message": "doc comment claims 'panics' but implementation does not match",
"severity": "Note",
"anomaly_class": "Documentation",
"rank_score": 0.657,
"prior_method": "jeffreys",
"posterior_tp": 0.71,
"evidence": { "raw": { "pattern": "B", "trigger": "panics" }, ... }
}
See also
- calibrate — rebuild the Layer 2 priors against your own labelled corpus.
- eval — precision / recall / F1 reports.
- SARIF output — how to wire the SARIF emission into GitHub Code Scanning.
- Spec:
docs/spec/cli-v0.md.
calibrate
cntrdct calibrate fits statistical artefacts against a labelled
corpus. It has three modes, all behind the same subcommand:
| Mode | Flag | Spec |
|---|---|---|
| Detector priors (default) | none | ranker-v1.md |
| LLM-confidence Platt fit | --fit-platt | llm-calibration-v0.md |
| Recall audit | --audit-recall | recall-audit-v0.md |
Default mode — detector priors (P4)
cntrdct calibrate benchmarks/labelled-findings.jsonl \
--output benchmarks/priors-default.json
Reads a JSONL of labelled findings (each row carries
detector_id, is_true_positive, optionally anomaly_class and
evidence) and writes one prior per detector. The Q-11 small-N
switch picks the lower-bound method automatically:
tp + fp ≥ 30: Wilson 95% lower bound (prior_method = "wilson").tp + fp < 30: Beta(1, 1) Bayes-Laplace 2.5% lower bound (prior_method = "jeffreys"), with the BCD 2001 §4 boundary modification attp = 0.
Default --output is <cache_dir>/cntrdct/priors.json (the per-user
cache). The shipped binary embeds benchmarks/priors-default.json
via include_str!, so a fresh cargo install cntrdct carries
calibrated priors out of the box. The fallback chain at scan time is
--priors → per-user cache → embedded default → uncalibrated.
--fit-platt mode — LLM-confidence calibration (Q-12)
cntrdct calibrate --fit-platt llm-confidence.jsonl \
--output benchmarks/llm-calibration/platt-default.json
Fits post-hoc Platt scaling parameters (a, b) per
(detector_id, anomaly_class) cell from a JSONL of
LabelledLlmConfidence rows (raw_confidence, is_correct,
detector_id, anomaly_class). The fitted registry is consumed by
the in-binary helper apply_llm_calibration, which populates
AdjudicationResult.calibrated_confidence and the SARIF
result.properties.adjudication.calibrated_confidence field.
v0 ships an empty registry; the helper is a no-op fallback until a
real labelled adjudication corpus is fit. On the constructed
pathology fixture under tests/calibration_ece.rs (over-confidence
at 0.95 / 0.85 / 0.75 raw with empirical accuracy ≈ 0.5), raw ECE
0.256 drops to ≈ 0.001 after Platt.
--audit-recall mode — Q-14 recall audit
cntrdct calibrate --audit-recall benchmarks/audit-corpus
The positional argument is a directory in this mode (not a JSONL
file). The directory must contain a manifest.jsonl listing
expected findings sourced from external bug catalogues (NVD / OSV /
Semgrep / CodeQL / Clippy / rustc lint testset / paper-appendix /
upstream bug-fix commits). Output defaults to stdout; pass
--output <PATH> to write to disk.
The audit is recall-bias-counter-selected per Heckman & Williams
(IST 2011), sitting alongside the self-selected
benchmarks/wild-corpus/ whose provenance measures the false-
positive rate. The v0.4.3 audit closed at overall recall_upper_bound
0.66 raw 0.6557, with comment-code saturating all three Tan SOSP
2007 patterns at 34 / 0 / 1.00 across twenty-three permissive-
licensed upstreams. Per CLAUDE.md’s release procedure, re-running
this audit at every release tag is good hygiene when detector logic
has changed.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Calibration completed; output written. |
| 1 | Invalid arguments, missing corpus / manifest, or fit failure. |
See also
- scan — consumes the priors at runtime via the fallback chain.
- Statistical priors (P4) — concepts-level explainer of what the calibrator produces and why.
- eval — for measuring detector quality against a labelled corpus without rebuilding priors.
eval
cntrdct eval <CORPUS_DIR> runs the full scan pipeline against a
labelled corpus and reports precision, recall, and F1 — both per
detector and overall. It is the routine measurement workflow for
“did detector quality move when I changed the code?”.
Synopsis
cntrdct eval benchmarks/wild-corpus-python # JSON to stdout
cntrdct eval benchmarks/audit-corpus \
--baseline sourcerercc,pybuglab \
--baselines-out baseline-comparison.json
| Flag | Default | Effect |
|---|---|---|
--manifest <PATH> | <corpus>/manifest.jsonl | Override the labelled manifest. |
--baseline <NAMES> | unset | Q-15: comma-separated baseline names (currently sourcerercc, pybuglab). Triggers side-by-side comparison. |
--baselines-out <PATH> | stdout | Write the BaselineComparisonReport JSON to disk. Without this flag the report follows the EvalReport on stdout. |
--baselines-skip-run | off | Read pre-cached baseline JSONL under <corpus>/../baselines/v<release>/<name>.jsonl instead of invoking Docker. |
Manifest shape
Each row of manifest.jsonl declares one source file and the
findings expected from it:
{"path": "files/quiet_a.rs", "expected": [
{"detector_id": "clone-drift", "start_line": 42},
{"detector_id": "comment-code", "start_line": 87}
]}
ManifestEntry carries optional source, license, and sha256
fields (M-4) so wild-corpus rows can pin the upstream provenance.
Baseline comparison (Q-15)
The --baseline flag runs cntrdct alongside a pinned external
comparator and reports side-by-side precision / recall / F1 on the
same corpus. Baselines ship as digest-pinned Docker images invoked
with docker run --network=none --rm --read-only so the comparison
is reproducible from a clean environment.
| Baseline | Detector | Image |
|---|---|---|
sourcerercc | clone-drift | baselines/sourcerercc/Dockerfile |
pybuglab | arg-swap (Python) | baselines/pybuglab/Dockerfile |
The release-time path is:
- Run Docker locally on the maintainer’s workstation against the audit + wild corpora.
- Commit the resulting JSONL under
benchmarks/baselines/v<release>/<name>.jsonl. - Re-run with
--baselines-skip-runto regenerate the report from the committed artefact in CI.
BaselineComparisonReport.priors_default_sha256 records the SHA-256
of the priors file the comparison ran against so the numbers can be
re-derived from a known-good binary state.
Phase D — live Docker runs against real corpora and populated README numbers — is pending on the maintainer workstation as of v0.4.3.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Eval completed; report written. |
| 1 | Invalid arguments, missing corpus / manifest, baseline misconfig. |
See also
- calibrate —
--audit-recallmode also operates on a corpus directory and is a complementary measurement axis (recall against externally-sourced ground truth). - scan — eval reuses the scan pipeline internally; any change that affects scan output reflects in eval numbers.
- Spec:
docs/spec/eval-v0.md,docs/spec/sota-baselines-v0.md.
cross-model-kappa
cntrdct cross-model-kappa <CORPUS> (Q-13) routes the same finding
set through Claude Code’s claude --print and the Gemini CLI’s
gemini -p, then reports pairwise Cohen’s κ per
(detector_id, anomaly_class) cell. It is the on-demand audit that
measures inter-judge reliability of the Layer 3 adjudicator.
Synopsis
cntrdct cross-model-kappa findings.jsonl
cntrdct cross-model-kappa findings.json --output audit.json
| Flag | Default | Effect |
|---|---|---|
--output <PATH> | stdout | Write the audit JSON to disk. With this flag a one-line summary is also printed to stderr. |
The positional argument accepts either a JSONL of RankedFinding
rows or a JSON array — exactly the shape cntrdct scan --format json emits, so the round-trip scan | cross-model-kappa composes
cleanly.
Interpreting κ
κ is reported per (detector_id, anomaly_class) cell using the
Landis & Koch (1977) substantial-agreement floor:
| Range | Interpretation |
|---|---|
| κ < 0.0 | Worse than chance — judge disagreement systematic. |
| 0.0 – 0.20 | Slight agreement. |
| 0.21 – 0.40 | Fair agreement. |
| 0.41 – 0.60 | Moderate agreement. |
| 0.61 – 0.80 | Substantial agreement (the actionable floor). |
| 0.81 – 1.00 | Almost perfect agreement. |
Cells below 0.6 are flagged as low-reliability adjudication regions and indicate the judges disagree often enough that downstream consumers should treat the verdict as advisory rather than load- bearing.
Auth model
cntrdct does not read API keys for this subcommand. Both CLIs handle
their own auth via subscription / OAuth (claude /login, gemini auth). At least two live providers are required to compute κ; a
missing or unauthenticated CLI surfaces as a skipped provider in
the audit JSON.
The CLI providers are spawned with current_dir = <tempdir> so
CLAUDE.md / GEMINI.md auto-discovery does not interfere, and the
agentic persona is stripped:
claude --printis invoked with--system-prompt+--tools ""--strict-mcp-config+--no-session-persistence+--output-format json.
gemini -pis invoked with theGEMINI_SYSTEM_MDenv override pointing at a temp file and--output-format json.
Network boundary (P3)
cross-model-kappa is the one subcommand excluded from the
network-isolation CI gate. It does not open sockets from
cntrdct — it shells out to subprocesses that themselves talk to the
network. Same shape as scan --adjudicate. See
Network access policy (P3).
Why on-demand, not nightly
Continuous monitoring was dropped for measurement-stationarity
reasons. Commercial LLMs version-bump silently; sampler
stochasticity at temperature 0 still produces variance; and the
time-series κ would have captured noise more than any cntrdct-side
property. The audit ships as an on-demand snapshot only. The full
design rationale lives in
docs/spec/cross-model-kappa-v0.md
“Design rationale”.
Exit codes
| Code | Meaning |
|---|---|
| 0 | Audit completed; report written. |
| 1 | Invalid arguments, fewer than two live providers, corpus parse error. |
See also
- scan — produces input findings for the audit when
combined with
--format json. - calibrate
--fit-platt— the Q-12 LLM- confidence calibration is downstream of this κ audit (the audit measures reliability; the calibration corrects systematic miscalibration).
GitHub Action
The reusable GitHub Action (T2-9) consumes the pre-built cntrdct
binary and surfaces findings as PR comments matching the GitHub
Annotations convention.
A working sample workflow lives at
examples/github-action-usage.yml.
The mixed Rust + Python SARIF path is verified end-to-end by
tests/multilang_config.rs so the action emits a single combined
SARIF document for repositories that span both languages.
paths filter
The paths: input accepts <path>:<lang_csv> per-line entries, e.g.
paths: |
src/:rust
scripts/:python
tests/:rust,python
prepare_config.py, merge_json.py, and merge_sarif.py (shipped
under examples/) implement the filter and the multi-language merge.
Language Server (LSP)
cntrdct-lsp exposes findings to IDEs via the Language Server
Protocol. The binary ships behind the optional lsp Cargo feature:
cargo install cntrdct --features lsp
Phase status (T3-12)
- Phase 1 — server scaffolding, lifecycle methods, log routing. Landed 2026-05-08.
- Phase 1.b —
textDocument/{didOpen,didChange,didSave,didClose}→textDocument/publishDiagnostics. Severity, code, source, message, range,relatedInformation, anddatamapping per the spec. Landed 2026-05-08. - Phase 1.c — per-URI 250 ms debouncing on
didChange. Landed 2026-05-09. - Phase 1.c+ — per-URI generation counter so a fresher event overtaking a still-running scan suppresses the stale publish. Landed 2026-05-09.
- Phase 2 —
vscode-cntrdctextension scaffolding (TypeScript / pnpm), bundling the LSP binary auto-downloaded from GitHub Releases. Pending. - Phase 3 — VS Code Marketplace listing + announcement. Pending.
Spec:
docs/spec/lsp-v0.md.
SARIF output
cntrdct scan --format sarif emits SARIF 2.1.0 compatible with
GitHub Code Scanning and any other OASIS-conformant consumer. Every
CI run validates the emission against the official OASIS schema via
Sarif.Multitool validate (.github/workflows/ci.yml).
Mapping table
| cntrdct concept | SARIF location |
|---|---|
detector_id | result.ruleId and tool.driver.rules[].id |
severity | result.level (Error / Warning / Note → error / warning / note; Info → none — see Severity) |
| IEEE 1044-2009 anomaly class | result.properties.anomalyClass |
prior_method | result.properties.priorMethod (wilson or jeffreys) |
| Citations | result.relatedInformation[] (one entry per citation key) |
| Adjudicator verdict | result.properties.adjudication.* |
The rules taxonomy under tool.driver.rules[] is pinned by
tests/wiring_consistency.rs against
cntrdct::ALL_DETECTOR_IDS — adding a detector without registering it
in both the construction path and the SARIF emitter fails CI.
Spec:
docs/spec/sarif-v0.md.
Claude Code skill
With the cntrdct binary on PATH, Claude Code users can invoke
/cntrdct to run a scan and have the top findings summarised in
chat. The skill definition lives under
.claude/skills/cntrdct/
in the repo.
The skill is purely a thin wrapper around the same cntrdct scan
pipeline a CLI user would invoke. It does not enable adjudication by
default; pass --adjudicate explicitly if an ANTHROPIC_API_KEY is
present in the shell environment Claude Code inherits.
FAQ
Does cntrdct send my code anywhere by default?
No. The default scan runs entirely offline. Only two opt-in flows
talk to the network: scan --adjudicate (Anthropic Messages API,
gated behind ANTHROPIC_API_KEY) and cross-model-kappa (shells out
to claude --print and gemini -p, which handle their own auth).
The network-isolation CI job enforces the property structurally on
every push and pull request. See
Network access policy.
Why does every detector cite a paper?
cntrdct’s value proposition is evidence-grounded findings: a reviewer
can follow each finding back to the peer-reviewed work that justifies
the detection rule. The constraint is enforced at registration time
and in CI (tests/citations_consistency.rs). See
Citation policy (P1).
Can I run cntrdct on Python-only repositories?
Yes. Five of the six Layer 1 detectors support Python
(unreachable-after-terminator, clone-drift, arg-swap,
comment-code, pr-miner); config-interaction is Rust-only because
Python lacks a semantic analogue to #[cfg(...)]. See
Configuration for
[languages.python] enablement.
Why are some Python citations marked Unconfirmed?
The citation-policy spec (docs/spec/citations-policy.md) requires at
least one citation grounded in empirical work on the target language.
Three detectors (comment-code, pr-miner,
unreachable-after-terminator) cite Rust empirical work under a
grandfather clause until a confirmed Python citation is identified.
See the per-detector pages for status.
How do I report a false positive?
File an issue using the
bug report template.
Include the source snippet, the SARIF result block (or JSON finding),
and the cntrdct version. If the false positive reflects a corpus gap,
contributing a labelled negative under benchmarks/ is the most
direct fix path.
Is there a paid / hosted version?
No. cntrdct is MIT-licensed and runs locally.
Releases and versioning
cntrdct follows Semantic Versioning on the
cntrdct crate. Detector identifiers, the SARIF schema mapping, the
ranker prior-shape contract, and the cntrdct.toml schema are all
covered by SemVer. Internal modules under src/detectors/ are not
public API and can change between minor versions.
Where releases live
| Surface | Location |
|---|---|
| Source archive + checksums | GitHub Releases |
| Crate | crates.io/crates/cntrdct |
| Homebrew tap | ktrysmt/homebrew-cntrdct |
| Pre-built binaries (Linux x86_64/aarch64, macOS aarch64, Windows x86_64) | GitHub Releases archives |
| Per-release notes | GitHub Release body (auto-generated from Conventional Commits) |
| Cumulative changelog | CHANGELOG.md at repo root |
Installing a specific version
# crates.io stable
cargo install cntrdct --version 0.4.2 --locked
# Pre-release versions (cargo install ignores them by default —
# the explicit --version qualifier is required)
cargo install cntrdct --version 0.2.0-rc.1 --locked
# Homebrew tap (always tracks the latest non-pre-release tag)
brew install ktrysmt/cntrdct/cntrdct
# Pre-built archive (no compile)
cargo binstall cntrdct
Release notes vs. CHANGELOG.md
Two surfaces are generated by git-cliff from cliff.toml on every
v* tag push:
- The GitHub Release body is
--latest --strip header— only the commits since the previous tag, grouped by Conventional Commits prefix (Features, Bug Fixes, Performance, Refactor, Promotions, Documentation, Tests, CI, Chores). This is whatgh release viewand the Releases page show. - The
CHANGELOG.mdfile at the repo root is the full cumulative history across every release, regenerated and committed back tomasterafter the GitHub Release publishes. The commit lands aschore(changelog): update for vX.Y.Zand the parser skips that prefix so the bot commit stays out of subsequent release notes.
If both surfaces drift (e.g. a tag was force-recreated), regenerate locally with:
git cliff --config cliff.toml --output CHANGELOG.md
Pre-release suffixes
Pre-release tags follow X.Y.Z-(alpha|beta|rc).N. Three operational
notes:
cargo install cntrdctignores pre-releases unless--version X.Y.Z-suffixis supplied explicitly.- The Homebrew tap formula is bumped only on non-pre-release tags, so
brew install ktrysmt/cntrdct/cntrdctalways points at the latest stable line. - The CI verify step strips the leading
vand demands an exact match againstCargo.toml’sversion, so the pre-release suffix in the tag and the manifest must agree byte for byte.
What is not versioned
Two surfaces ship under explicit “not SemVer” labels:
- The research workspace (
research/cntrdct-research) has its own version cycle and is not released through this procedure. It is not a public consumer surface. - Embedded priors (
benchmarks/priors-default.json) and the LLM Platt registry (benchmarks/llm-calibration/platt-default.json) are data, not code; both are regenerated bycntrdct calibrateagainst the labelled corpora and embedded into the binary viainclude_str!. Changing these does not bump major.
See also: docs/spec/
for per-feature contracts, and
ROADMAP.md
for the engineering schedule.