Parsers¶
Prefer machine-readable artifacts over terminal text.
| parser | reads | command must include |
|---|---|---|
pytest |
JUnit XML | --junitxml {run_dir}/junit.xml |
coverage |
coverage XML (+ JUnit if present) | --cov-report=xml:{run_dir}/coverage.xml and --junitxml {run_dir}/junit.xml — without the latter, failing tests are not merged as findings and the parser says so in a note |
ruff |
JSON file | --output-format json --output-file {run_dir}/ruff.json |
ty |
terminal text | — (drift guards) |
mypy |
text, or NDJSON with format = "json" |
--output json (mypy ≥ 1.11) for NDJSON |
pyright |
JSON in log | --outputjson |
reformat |
black / ruff-format text | --check (no --diff) |
pip_audit |
JSON file | --progress-spinner off -f json -o {run_dir}/pip-audit.json; set a timeout (network tool). Skipped packages are a note, not a failure, and findings carry no location |
bandit |
JSON file | -f json -o {run_dir}/bandit.json |
pylint |
json2 (pylint ≥ 3.0) | --output-format=json2:{run_dir}/pylint.json — every message class except info becomes a finding, refactor and convention included; narrow it in pylint's own config, not in ckdn |
sarif |
SARIF file | whatever flag writes SARIF to {run_dir}/report.sarif (semgrep --sarif-output plus --error, gitleaks --report-format sarif --report-path, trivy --format sarif -o plus --exit-code 1) |
pre_commit |
pre-commit run terminal text |
pre-commit run (use --all-files for full-repo parity); per-hook findings on failure |
generic |
exit code only | — no findings by construction, so rc ≠ 0 reconciles to fail with the log tail attached, never to error |
Severity mapping. ty, mypy and pyright emit findings for errors
only; warnings are counted in summary.warning_count and never become
findings, because those tools exit 0 on warnings and a finding with rc == 0
reads as parse_mismatch. mypy note: lines fold into the preceding error's
detail, and are dropped when no error precedes them. bandit, pip_audit
and pylint (every class but info) emit each reported item as a finding.
Guards (loud failure, never silent green): self-consistency cross-checks
everywhere — pytest, pyright, pylint and bandit compare their report's
own declared counts against what was parsed, and the text parsers (ty,
mypy, reformat, pre_commit) check counts and clean markers. A missing or
unreadable report is error when rc ≠ 0 and parse_mismatch when
rc == 0 — the more surprising half. parser_ok=false on format drift.
Policy gates in ckdn config: fail_under (coverage) and score_fail_under
(pylint). Each turns a run red even when the tool exits 0, and each is opt-in
and silently skipped when unset — a coverage check with no fail_under
reports pass at any percentage, with a note saying the gate was skipped.
SARIF's fail_levels is not a gate: it selects which result levels become
findings and can never produce a gate failure. It defaults to ["error"], and a
result carrying no level counts as warning — so a tool that exits 0 while
reporting warnings yields zero findings and status: pass. Widening it to
fail_levels = ["error", "warning"] is only half the job: the tool must exit
nonzero on those warnings too, or the findings contradict rc == 0 and the run
reports parse_mismatch. Make the tool fail first (--error, --exit-code 1),
then widen fail_levels to match.
Filter severity tool-side where possible (bandit --severity-level) — a parser
must never hide findings the exit code knows about.
Artifact options. Every file-backed parser takes an artifact-path option
({run_dir} is substituted): junit (pytest, coverage), coverage_xml
(coverage), report (ruff, bandit, pip_audit, pylint, sarif). Paths
resolve strictly under the run directory; anything escaping it is rejected
before the file is opened.
Not supported on purpose: flake8 / isort / pydocstyle / pyupgrade (use ruff); vulture (overlaps CodeClone's structural dead-code analysis); safety (use pip-audit); mutmut-style mutation as a loop-time check.
Custom parsers¶
A parser reports facts; it never decides the final status.
A Finding carries id, kind and message (all required), plus optional
location — one preformatted string, path, path:line or path:line:column
— and detail, a bounded tuple of context lines. There is no severity
field: encode it in kind or detail. An absent location or detail is
omitted from the digest entirely.
from ckdn.parsers.base import Finding, ParseContext, ParseResult
class MyToolParser:
name = "mytool"
def parse(self, ctx: ParseContext) -> ParseResult:
report = ctx.artifact("report", "mytool.json")
if not report.exists():
return ParseResult(
parser_ok=False,
notes=[f"report not found: {report}"],
)
return ParseResult(findings=[...], summary={"count": 0})
Rules: prefer {run_dir} artifacts (ctx.artifact() refuses any path that
escapes the run directory); if parsing text, add a self-consistency guard;
findings = failure evidence only; bound everything; return parser_ok=False
instead of raising on bad output — a parser that raises anyway is caught and
recorded as parser_ok=false with a crashed note, never as a lost run.
Registration¶
Expose the parser under the ckdn.parsers entry-point group; ckdn
discovers it once per process (the lookup is cached) and resolves it
lazily. The value may be a Parser class (instantiated with no args) or an
instance. Built-in names take precedence and are never shadowed, two
plugins claiming the same name resolve first-discovered-wins, and a plugin
that fails to import is skipped rather than breaking ckdn.
Import the parser in ckdn/parsers/__init__.py and add an instance to the
tuple _REGISTRY is built from — the dict itself is a comprehension keyed
by parser.name.