Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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)
LayerRoleLLM?Network?Source
Layer 1Tree-sitter-based detectorsNoNosrc/detectors/
Layer 2Statistical ranker (Wilson / Jeffreys × log-sibling-count)NoNosrc/ranker.rs, src/calibration.rs
Layer 3Optional LLM adjudicatorYesYes (opt-in)src/adjudicator.rs
Layer 4SARIF 2.1.0 emitterNoNosrc/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 via reqwest) — used by scan --adjudicate.
  • ClaudeCliAdjudicator (CLI shellout to claude --print) — used by cntrdct cross-model-kappa (Q-13).
  • GeminiCliAdjudicator (CLI shellout to gemini -p) — used by cntrdct 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)

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

PointCheck
core::register_detectorRejects any Detector whose citations() returns empty at startup.
tests/citations_consistency.rsEvery citation key declared by any detector resolves to an entry in CITATIONS.md.
tests/citations_consistency.rsEach supported_languages() is either matched by a per-language citation or carries an explicit unconfirmed: annotation pointing at the survey notes.
.github/workflows/citations.ymlQ-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 in Evidence.language_citation_status and exposed at SARIF emission time as properties.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):

DetectorLanguageStatus
unreachable-after-terminatorRustGrandfathered
unreachable-after-terminatorPythonUnconfirmed
comment-codeRustGrandfathered
comment-codePythonUnconfirmed
arg-swapRustGrandfathered
arg-swapPythonConfirmed (Allamanis et al. NeurIPS 2021, PyBugLab)
clone-driftRustGrandfathered
clone-driftPythonConfirmed (Assi et al. TOSEM 2025)
pr-minerRustGrandfathered
pr-minerPythonUnconfirmed

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.md and from every Citation { doi: Some("...") } slot under src/.
  • Each DOI is checked against (a) a cached Retraction Watch snapshot at benchmarks/retraction-watch/cache.csv, pinned by cache.sha256, and (b) Crossref Works’ update-to field with type: "retraction".
  • A Mondays-06:00-UTC cron refreshes the cache via the Crossref Labs endpoint and opens a chore(citations): refresh Retraction Watch cache PR 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:

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

SubcommandNetwork?
cntrdct scan (default)No
cntrdct scan --adjudicateYes — Layer 3 LLM adjudicator (opt-in)
cntrdct calibrateNo
cntrdct calibrate --fit-plattNo
cntrdct evalNo
cntrdct cross-model-kappaIndirect — 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 scan path (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 --adjudicate only opens sockets to the Anthropic API. Use a network proxy or audit src/adjudicator.rs directly if you need this property.
  • That CLI subprocesses spawned by cross-model-kappa only 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:

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 of LabelledFinding rows and writes a HashMap<String, DetectorPrior> to disk.
  • The default labelled corpus (benchmarks/labelled-findings.jsonl) is hand-labelled across the seed and wild β corpora.
  • benchmarks/priors-default.json is the output of cntrdct calibrate against that corpus, embedded into the binary via include_str! so a fresh cargo install cntrdct ships 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:

  1. If --no-calibration is set, use UncalibratedRanker.
  2. Otherwise, if --priors <PATH> is set, load that file. A missing file errors out; a parsed file produces CalibratedRanker.
  3. Otherwise, if the default user-cache path exists, use CalibratedRanker.
  4. Otherwise, fall through to the embedded benchmarks/priors-default.json.
  5. 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 sizeMethodNotes
n >= 30WilsonPriorMethod::Wilson. Byte-stable with pre-Q-11 priors files.
n < 30, TP > 0Jeffreys (Beta credible interval)PriorMethod::Jeffreys. 2.5% quantile of Beta(TP+1, FP+1).
n < 30, TP = 0Jeffreys with BCD 2001 boundaryReturns 0.
n = 0Wilson conventionReturns 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_tp is already a Beta(1, 1) Bayesian update; pairing it with a Beta(1, 1) credible-interval lower bound at small n keeps both columns in one regime. Mixing the two would surface incompatible uncertainty semantics within the same DetectorPrior.
  • At n >= 30 Wilson 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:

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:

ClassMeaningUsed by
LogicBehavioural contradiction in control or data flow.clone-drift, config-interaction, pr-miner, unreachable-after-terminator
InterfaceCaller-callee mismatch in arguments or signatures.arg-swap
DataInconsistency in stored or transferred data.
DocumentationContradiction between comment and code.comment-code
PerformanceResource-use issue.
StandardsCoding-standard violation.
OtherDefects 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:

SeveritySARIF levelRationale
ErrorerrorHard contradictions; false positives are rare by construction.
WarningwarningDefault for shipped detectors.
NotenoteLower-confidence findings or downgrades.
InfononeUser-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::Info by construction. The variant enters the pipeline only through user-authored cntrdct.toml severity overrides. A user who explicitly downgrades a finding to Info is signalling “I want this less visible than Note.”
  • SARIF 2.1.0 §3.27.10 defines "none" as “the level is not applicable to the result,” and GitHub Code Scanning suppresses none-level results from the inline PR review surface. That is exactly what an Info-tagged finding should do.
  • The alternative remap (Info -> "note") would conflate Severity::Info and Severity::Note in 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

FilePins
src/core.rsAnomalyClass enum and serde projection.
src/sarif.rsThe Severity -> SARIF level mapping.
tests/sarif_lib.rsAssertions that the mapping holds end-to-end.
tests/multilang_config.rsAssertions that cntrdct::ALL_DETECTOR_IDS is fully present in tool.driver.rules.
.github/workflows/ci.ymlSarif.Multitool validate against the OASIS 2.1.0 schema on every CI run.

See also:

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.

PropertyValue
Detector IDarg-swap
LanguagesRust, Python
IEEE 1044-2009 classInterface
Default severityWarning
Specdocs/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.

  • 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”.

PropertyValue
Detector IDclone-drift
LanguagesRust, Python
IEEE 1044-2009 classLogic
Default severityWarning
Specdocs/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:

GatePurposeDefault
MIN_FN_TOKENS = 22drop trivially short functionsconstant
SIMILARITY_THRESHOLD = 0.5pairwise Jaccard for cluster membershipconstant
F5b scope-bounded clusteringclusters never cross crate / package boundariesprovenance-aware
F5c-i strict-majoritydominant partition must be > 50% of the clusterconstant
F5c-ii NEAR_DUPLICATE_THRESHOLD = 0.7drifted singleton must be near-duplicate of the dominant exemplarconstant
F5d-i multi-singleton suppression≥ 2 singleton partitions ⇒ designed family ⇒ silentconstant
F5d-ii length-imbalance × weak-dominant gatehigh length asymmetry under weak canonical evidence ⇒ silentconstants
F5d-iii small-cluster floorMIN_GROUP_SIZE cluster at the resolution limit ⇒ silentconstants

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 = 22 tokens (small utility functions whose drift signal is too noisy).
  • Functions inside impl / trait / mod blocks. Only top-level fn (Rust) and function_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.

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.

PropertyValue
Detector IDcomment-code
LanguagesRust, Python
IEEE 1044-2009 classDocumentation
Default severityNote
Specdocs/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:

PatternTrigger phraseConstraint
A — Result / Option claimreturns err, may fail, fallible, returns option, may return nonefunction’s return_type does not contain Result or Option
B — Panic claimpanic 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 claimdeprecated substringfunction 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’s instance_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) and tan-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).

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.

PropertyValue
Detector IDconfig-interaction
LanguagesRust
IEEE 1044-2009 classLogic
Default severityWarning
Specdocs/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_pairs records the extras.

Examples that do not fire

  • #[cfg(feature = "a")] + #[cfg(feature = "b")] — no not(...).
  • #[cfg(unix)] + #[cfg(not(windows))] — different inner predicates.
  • #[cfg_attr(test, cfg(not(unix)))]cfg_attr is 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 contradictory cfg predicates 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_attr is 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.

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. lockunlock) and a single function that calls one without the other.

PropertyValue
Detector IDpr-miner
LanguagesRust, Python
IEEE 1044-2009 classLogic
Default severityWarning
Specdocs/spec/pr-miner-v0.md

How it works

  1. Each top-level function in the scan becomes one transaction whose items are the distinct call-site names inside its body.
  2. Apriori mines association rules {a} → {b} with support ≥ MIN_SUPPORT = 0.05 and confidence ≥ MIN_CONFIDENCE = 0.85.
  3. Any function whose body contains a but never b is a violator and receives one Finding.
  4. Finding.related lists every function in the database that satisfies the rule (capped at MAX_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 sees call_expression / call nodes whose head is a path / identifier / attribute.
  • Functions with fewer than MIN_TRANSACTION_ITEMS = 2 distinct 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 in g).

Language coverage

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).

  • 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-11 SMALL_SAMPLE_THRESHOLD = 30 switch).
  • 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.

PropertyValue
Detector IDunreachable-after-terminator
LanguagesRust, Python
IEEE 1044-2009 classLogic
Default severityWarning
Specdocs/spec/unreachable-after-terminator-v0.md

Terminator sets

LanguageTerminators
Rustreturn, break, continue, plus macro terminators panic!, unreachable!, todo!, unimplemented!, abort!, exit!
Pythonraise, 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 / trait items 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 { ... } without break.
  • match arms 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).

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:

  • exclude always wins. Any finding whose primary file matches an exclude glob is dropped.
  • include is “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 = false causes 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 whose detector_id is 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:

  1. Language enablement ([languages.<x>] enabled = false removes the file from the scan).
  2. Path filter ([paths] exclude then include).
  3. Per-detector enablement ([detectors.<id>] enabled = false).
  4. Per-language suppression ([languages.<x>] suppress).
  5. In-source attributes (In-source suppressions).
  6. 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

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

ScenarioSurface
One specific call site / function known to be a false positiveIn-source comment / attribute. Document the reason inline.
Wholesale silencing of a detector for a vendored directorycntrdct.toml [paths] exclude
Silencing a detector for an entire languagecntrdct.toml [languages.<x>] suppress
Disabling a detector globallycntrdct.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
FlagDefaultEffect
--format <json|sarif>jsonOutput 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.tomlOverride the project config. Missing file is silently treated as an empty config.
--priors <PATH>per-user cache → embeddedOverride the Layer 2 priors JSON. The full fallback chain is --priors<cache_dir>/cntrdct/priors.json → embedded default → uncalibrated.
--no-calibrationoffForce the uncalibrated ranker (sibling-count ordering) regardless of priors availability.
--adjudicateoffRoute 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>5Number 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, and wilson_lower (Layer 2).
  • adjudication (verdict, raw confidence, optional Platt-calibrated confidence, calibration_tag) when --adjudicate ran.

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

CodeMeaning
0Scan completed successfully (regardless of finding count).
1Invalid 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:

ModeFlagSpec
Detector priors (default)noneranker-v1.md
LLM-confidence Platt fit--fit-plattllm-calibration-v0.md
Recall audit--audit-recallrecall-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 at tp = 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

CodeMeaning
0Calibration completed; output written.
1Invalid 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
FlagDefaultEffect
--manifest <PATH><corpus>/manifest.jsonlOverride the labelled manifest.
--baseline <NAMES>unsetQ-15: comma-separated baseline names (currently sourcerercc, pybuglab). Triggers side-by-side comparison.
--baselines-out <PATH>stdoutWrite the BaselineComparisonReport JSON to disk. Without this flag the report follows the EvalReport on stdout.
--baselines-skip-runoffRead 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.

BaselineDetectorImage
sourcererccclone-driftbaselines/sourcerercc/Dockerfile
pybuglabarg-swap (Python)baselines/pybuglab/Dockerfile

The release-time path is:

  1. Run Docker locally on the maintainer’s workstation against the audit + wild corpora.
  2. Commit the resulting JSONL under benchmarks/baselines/v<release>/<name>.jsonl.
  3. Re-run with --baselines-skip-run to 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

CodeMeaning
0Eval completed; report written.
1Invalid arguments, missing corpus / manifest, baseline misconfig.

See also

  • calibrate--audit-recall mode 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
FlagDefaultEffect
--output <PATH>stdoutWrite 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:

RangeInterpretation
κ < 0.0Worse than chance — judge disagreement systematic.
0.0 – 0.20Slight agreement.
0.21 – 0.40Fair agreement.
0.41 – 0.60Moderate agreement.
0.61 – 0.80Substantial agreement (the actionable floor).
0.81 – 1.00Almost 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 --print is invoked with --system-prompt + --tools ""
    • --strict-mcp-config + --no-session-persistence + --output-format json.
  • gemini -p is invoked with the GEMINI_SYSTEM_MD env 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

CodeMeaning
0Audit completed; report written.
1Invalid 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, and data mapping 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-cntrdct extension 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 conceptSARIF location
detector_idresult.ruleId and tool.driver.rules[].id
severityresult.level (Error / Warning / Note → error / warning / note; Info → none — see Severity)
IEEE 1044-2009 anomaly classresult.properties.anomalyClass
prior_methodresult.properties.priorMethod (wilson or jeffreys)
Citationsresult.relatedInformation[] (one entry per citation key)
Adjudicator verdictresult.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

SurfaceLocation
Source archive + checksumsGitHub Releases
Cratecrates.io/crates/cntrdct
Homebrew tapktrysmt/homebrew-cntrdct
Pre-built binaries (Linux x86_64/aarch64, macOS aarch64, Windows x86_64)GitHub Releases archives
Per-release notesGitHub Release body (auto-generated from Conventional Commits)
Cumulative changelogCHANGELOG.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 what gh release view and the Releases page show.
  • The CHANGELOG.md file at the repo root is the full cumulative history across every release, regenerated and committed back to master after the GitHub Release publishes. The commit lands as chore(changelog): update for vX.Y.Z and 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 cntrdct ignores pre-releases unless --version X.Y.Z-suffix is supplied explicitly.
  • The Homebrew tap formula is bumped only on non-pre-release tags, so brew install ktrysmt/cntrdct/cntrdct always points at the latest stable line.
  • The CI verify step strips the leading v and demands an exact match against Cargo.toml’s version, 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 by cntrdct calibrate against the labelled corpora and embedded into the binary via include_str!. Changing these does not bump major.

See also: docs/spec/ for per-feature contracts, and ROADMAP.md for the engineering schedule.