Consuming the output¶
Chargate classifies; the caller decides. Every run writes two documents another tool
can read without importing any of Chargate: the net-new SARIF and the counts
JSON. The counts JSON carries a schema_version and is a stable public
interface — versioned, and broken only deliberately.
The first consumer is brimyr, a patch-coverage gate that folds Chargate's findings into the same pull-request verdict (MagmaMoose/brimyr#33). It reads these two documents rather than importing any of Chargate, which is the point on both sides: Chargate's core stays stdlib-only and brimyr keeps its own zero-runtime-dependency rule. A process boundary needs a documented shape, so here it is.
Gate on the counts, display the SARIF¶
The counts JSON is Chargate's own document: its keys mean what this page says they
mean, and the invariants below hold. The filtered SARIF is MegaLinter's shape — one
runs entry per linter, whatever each linter chose to put in it — and it is for showing
a human what was found: rendering a summary, uploading it, attaching it to a comment.
Counting results in the SARIF to decide pass/fail is re-implementing
sarif.counts.count_results against a document whose shape belongs to whoever emitted
it, and it gets the suppressed / SOPS-ignored / de-duplicated buckets wrong: those
results are gone from the filtered SARIF, and only the counts say how many there were
and which bucket took them.
Two surfaces are deliberately not interface: the stderr summary lines and the
job-summary markdown. Both are written for people and change whenever a better sentence
turns up. The action's step outputs are interface — they are declared in action.yml
like any other.
The command¶
chargate filter-sarif \
--sarif full.sarif \
--base "$BASE_SHA" --head "$HEAD_SHA" \
--out net-new.sarif \
--counts-json counts.json \
--no-gate
| Flag | Why it is part of the contract |
|---|---|
--sarif |
The report to classify. Any SARIF 2.1.0 producer, not only MegaLinter. |
--base / --head |
The two ends of the diff. Classification is against merge-base(base, head) unless --no-merge-base. |
--out |
Where the net-new-only SARIF goes: the input document with every non-net-new result pruned. |
--counts-json |
Where the counts document goes. |
--no-gate |
Report only — Chargate stops deciding, so the caller can. |
chargate ci writes the same pair through --filtered-out and --counts-json. They
are the same writers over the same FilterResult, so the contract does not change with
the subcommand; ci has no --no-gate, its report-only equivalent is --fail-on none
(fail_on: none on the action). The composite action always passes both — see
From the action.
--no-gate leaves exactly two exit codes, and the difference between them matters more
than any verdict:
| Exit | Meaning under --no-gate |
|---|---|
0 |
The classification ran. Both documents were written; the answer is in them. |
2 |
Chargate could not classify at all — unreadable SARIF, missing merge-base, shallow clone. |
A 2 is not "no findings", it is no answer. A consumer that treats the two alike
ships the exact failure this project exists to remove: a gate that scanned nothing and
reported green. Without --no-gate the exit code also carries 1 for a net-new finding
at or above --fail-on — that is Chargate deciding, which a consumer running its own
gate does not want.
The counts document¶
{
"schema_version": 1,
"net_new_count": 3,
"total_count": 128,
"pre_existing_count": 121,
"suppressed_count": 2,
"sops_ignored_count": 1,
"deduped_count": 1,
"per_level_total": { "error": 40, "warning": 70, "note": 18 },
"per_level_net_new": { "error": 1, "warning": 2 },
"per_severity_total": { "high": 12, "medium": 30 },
"per_severity_net_new": { "high": 1 }
}
| Key | Meaning |
|---|---|
schema_version |
The shape of this document. Read it before anything else. |
net_new_count |
Findings the diff introduced. The number a gate is about. |
total_count |
Every result in the input SARIF. |
pre_existing_count |
total − net_new − suppressed − sops_ignored − deduped. Never blocks. |
suppressed_count |
Author-accepted in-source suppressions. Never blocks. |
sops_ignored_count |
Secret-scanner hits on SOPS-encrypted values. Never blocks. |
deduped_count |
Net-new findings collapsed into an earlier identical one (same rule id + fingerprint). |
per_level_total / per_level_net_new |
Counts by SARIF level: error, warning, note, none. |
per_severity_total / per_severity_net_new |
Counts by severity band, derived from a numeric security-severity property: critical, high, medium, low, none. |
Two invariants hold for every schema-1 document, and a consumer should assert them rather than trust them. A counts file that contradicts itself is a bug on this side of the boundary, and asserting is how it surfaces as an error instead of as a number:
sum(per_level_net_new.values()) == net_new_count. Every result resolves to a level, so the per-level map partitions the net-new set.- The total result count across the filtered SARIF's
runsequalsnet_new_count. The two files describe one set.
Quality linters emit no security-severity, so the band maps stay empty
The per_severity_* maps are populated only from a numeric security-severity
property. Security scanners set it; quality linters do not — Ruff, ESLint, PMD and
golangci-lint emit a SARIF level and nothing else. On a
flavor: quality run per_severity_net_new is
therefore {} while per_level_net_new is populated, so a consumer thresholding on
bands over quality findings never blocks and never says why. Threshold on levels
there; brimyr's own threshold speaks SARIF levels for exactly this reason.
Chargate's own fail_on is not affected. gate.effective_band falls back to the
level when a result carries no security-severity (error→high, warning→medium,
note→low), so fail_on: high still blocks on an error from a quality run. It is
the counts document's band maps that stay empty, not the gate's view of severity.
schema_version, and what breaks it¶
| Change to the document | Breaking? |
|---|---|
| A key added | No. A reader that does not know it ignores it. |
| A key removed or renamed | Yes. |
| A key keeping its name and changing meaning | Yes, and the worst kind — nothing errors, the number is just wrong. |
| A new value inside an existing map (a level or band not seen before) | No. The maps are open. |
The rule on the far side follows from that table: hard-fail on a schema_version you
do not recognise. Do not fall back to a best-effort read of the keys you happen to
find, because guessing has exactly one shape of failure — a key you cannot find reads as
zero, zero net-new reads as a pass, and the gate goes green on a document it never
understood. Refusing is loud. Guessing is silent, and which of those two you get is what
this codebase is built around.
A bump is therefore a coordinated release on both sides of the boundary, not a cosmetic
edit. COUNTS_SCHEMA_VERSION lives beside the Counts dataclass in
src/chargate/sarif/counts.py, and cli.counts_to_dict emits it as the document's
first key, so a consumer reading a truncated or streamed file still gets it first.
From the action¶
| Output | Path | Written |
|---|---|---|
filtered_sarif_path |
chargate-reports/net-new.sarif |
Every run. |
counts_path |
chargate-reports/counts.json |
Every run. |
Both are workspace-relative, and both are written unconditionally — including on a baseline (push) run, where the net-new set is empty by construction. That is deliberate: a consumer that finds no file cannot tell "nothing was introduced" from "the step never got that far", and one of those two must not read as a pass.
- uses: magmamoose/chargate@v2
id: chargate
with:
flavor: quality
fail_on: none # Chargate reports; the next step decides
- name: Gate on the net-new findings
run: |
counts="${{ steps.chargate.outputs.counts_path }}"
jq -e '.schema_version == 1' "$counts" > /dev/null \
|| { echo "::error::unrecognised counts schema"; exit 2; }
jq -e '(.per_level_net_new.error // 0) == 0' "$counts" > /dev/null \
|| { echo "::error::net-new errors introduced by this PR"; exit 1; }
A counts file is not proof that anything was scanned
chargate ci writes both documents before it decides its own exit code, so a run
whose SARIF carried no runs at all — MegaLinter produced nothing — still leaves a
well-formed counts.json full of zeros behind, and then exits 2 for exactly
that reason. Read the step's outcome as well as the file. Zeros from a scan that
never happened are indistinguishable from a clean pull request, which is the failure
this project exists to remove. filter-sarif makes no such check at all: it
classifies whatever document it is handed, empty or not, because the caller chose
that file.
The gate_result / net_new_count / total_count outputs remain the quick path for a
workflow that only wants a number in a message. counts_path is for a tool that needs
the whole breakdown — which bucket, which level, which band — without parsing SARIF.