Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
112 commits
Select commit Hold shift + click to select a range
24c613b
fix(signal): require word boundaries for pattern-scan keywords
Aug 22, 2026
25b3343
fix(signal): pair non-fn remove+re-add to kill phantom removed symbols
Aug 22, 2026
f8bb536
fix(checks): classify semgrep tool errors as skipped, not code failures
Aug 22, 2026
7205a69
fix(heuristics): classify perf test-context per hit, not per hunk
Aug 22, 2026
ccc4b3b
merge: fix/pattern-word-boundaries
Aug 22, 2026
6e15d58
merge: fix/breaking-phantom-nonfn
Aug 22, 2026
c8a9322
merge: fix/perf-test-context-per-hit
Aug 22, 2026
50fc500
merge: fix/semgrep-exit2-tool-error
Aug 22, 2026
62fa2b4
fix(signal): represent unmeasured coverage as not-measured, not 100%
Aug 22, 2026
f17cb2d
fix(report): mark skipped heuristics explicitly instead of zero-fille…
Aug 22, 2026
f1f0af7
merge: skip-as-zero residual (fix/skip-as-zero-residual)
Aug 22, 2026
399788d
chore(deps): bump ammonia 4.1.3 -> 4.1.4 (RUSTSEC-2026-0213 XSS fix)
Aug 22, 2026
fc1548c
fix(verdict): stop reporting warnings as failed quality checks
Aug 22, 2026
122759e
fix(gate): fail loud when the pack carries no readable verdict
Aug 22, 2026
6dc3e89
merge: warnings are not failures (fix/warning-not-failure)
Aug 22, 2026
5b4b5aa
merge: readers stop guessing (fix/read-hardening)
Aug 22, 2026
f5f65f6
fix(perf): read test context from the patch target state only
Aug 22, 2026
0d34430
fix(signal): scope and full-declaration pairing for breaking changes
Aug 22, 2026
319efc7
docs(changelog): record perf context and breaking pairing fixes
Aug 22, 2026
3a77da2
test(output): adapt cut-1 exit-code tests to fail-loud gate reader
Aug 22, 2026
b4b30c3
merge: review followup for signal trackers (perf + breaking)
Aug 22, 2026
afa2e58
fix(perf): ignore trailing comments when resolving test context
Aug 22, 2026
7e31486
docs(breaking): record the measured basis for hunk-local module scope
Aug 22, 2026
968c23f
fix(gate): make MERGE_GATE.json state its schema and its failure origins
Aug 22, 2026
f5df942
fix(report): stamp report.json as schema 2.0 for its nullable coverag…
Aug 22, 2026
43237a3
docs(changelog): record the round-2 review followups
Aug 22, 2026
14ee56d
merge: round-2 review followup (schema truth, comment-aware trackers)
Aug 22, 2026
6819939
fix(breaking): pair duplicate public declarations one-to-one
Aug 22, 2026
92ed337
fix(perf): ignore braces inside string and char literals
Aug 22, 2026
ee1ccac
fix(gate): hold the schema contract to the validator's exact strings
Aug 22, 2026
c1572f9
docs(changelog): record the round-3 review followups
Aug 22, 2026
889dfd3
merge: round-3 review followup (one-to-one pairing, literal-aware tra…
Aug 22, 2026
f3ae43d
fix(perf): simplify token-start guard to satisfy clippy 1.94
Aug 22, 2026
72917c9
fix(output): treat a versioned pack without a decision object as corrupt
Aug 22, 2026
99328d1
merge: versioned packs must carry a decision object
Aug 22, 2026
9d06224
fix(gate): force conservative axes when a verdict is normalized to BLOCK
Aug 22, 2026
937d998
fix(signal): ignore literal and comment braces when tracking module s…
Aug 22, 2026
04f1119
fix(checks): preserve stdout diagnostics in the semgrep skip reason
Aug 22, 2026
50b21b1
docs: record the round-6 signal fixes in the changelog and gate contract
Aug 22, 2026
560fa7e
merge: round-6 review followup (normalized BLOCK axes, literal/commen…
Aug 22, 2026
a353b42
fix(regression): track block comments across lines when reading test …
Aug 22, 2026
12c61b7
fix(signal): keep cfg guards and literal delimiters out of declaratio…
Aug 22, 2026
dee5356
fix(signal): read pattern-scan word boundaries per character, not per…
Aug 22, 2026
b9a0f6a
fix(report): tell a disabled heuristics run apart from an unavailable…
Aug 22, 2026
ed562d2
merge: round-7 review followup (single-pass rust lexer, cfg-aware pai…
Aug 22, 2026
02f1ba4
fix(gate): reject mistyped decision signals in the CLI reader
Aug 22, 2026
6b75fd3
fix(mcp): read legacy root-shaped gate packs instead of calling them …
Aug 22, 2026
2603657
test(rust-source): pin raw strings that contain a comment marker
Aug 22, 2026
b2380dc
merge: round-8 review followup (typed gate readers on both surfaces, …
Aug 22, 2026
1fb70a4
fix(scanner): carry an open string literal across lines
Aug 22, 2026
2ab1255
fix(breaking): treat a stack of cfg attributes as one conjunction
Aug 22, 2026
fb63938
fix(gate): reject a non-object legacy decision root on both readers
Aug 22, 2026
c8a1d54
fix(gate): reconcile contradictory decision axes by conservativeness
Aug 22, 2026
f25caf3
fix(cli): count the pack's canonical checks for --fail-on-warnings
Aug 22, 2026
d94ebdc
merge: round-9 review followup (multiline literal state, cfg conjunct…
Aug 22, 2026
09a6c2f
fix(scanner): recognize raw C string literals
Aug 22, 2026
968fcc6
docs(gate): pin the decision-object precedence for schema-less packs
Aug 22, 2026
3d45c3c
docs(breaking): record why a module name is not carried to a later brace
Aug 22, 2026
54de7b4
docs(patterns): record the combining-mark limit of word-boundary matc…
Aug 22, 2026
f8f77bb
merge: round-10 review followup (raw C strings, decision precedence p…
Aug 22, 2026
fa16d4b
fix(gate): fold every verdict spelling through one shared vocabulary
Aug 22, 2026
cee2a2d
fix(breaking): read a cfg attribute to its balanced close
Aug 22, 2026
c6dc609
fix(breaking): stop cutting declaration text inside real signatures
Aug 22, 2026
b7d5bb3
fix(perf): close a test context opened over an item with no body
Aug 22, 2026
62267c5
merge: round-11 review followup (shared verdict vocabulary, balanced …
Aug 22, 2026
8775485
fix(patterns): bound every needle on the side that has an identifier …
Aug 22, 2026
4dc55cf
merge: round-12 review followup (needle-edge word bounding, eprint fa…
Aug 22, 2026
a6848e8
merge: sync PR #21 with main after #19 (docs + ammonia) and #20 (subs…
Aug 22, 2026
dd6f40d
fix(breaking): end a line comment at the line that wrote it
Aug 22, 2026
8a5f17f
merge: re-sync with main after changelog marker hotfix (7caa392)
Aug 22, 2026
f17f650
fix(breaking): count a cfg_attr that applies a cfg as a guard
Aug 22, 2026
0bc31c7
fix(report): carry the quality-failure origin into report.json
Aug 22, 2026
9338fb5
docs(breaking): record the item-body blind spot as an accepted 0.8 limit
Aug 22, 2026
94baf42
merge: round-13 review followup (cfg_attr guards, origin in report.js…
Aug 22, 2026
e4342aa
fix(perf): find the test body's opener past the signature's brackets
Aug 22, 2026
be2fb0d
fix(breaking): compare declarations without their comments
Aug 22, 2026
c392ed1
merge: round-14 review followup (comment-free comparison identity, si…
Aug 22, 2026
04d39bc
docs(changelog): the scanner does carry a literal across lines
Aug 22, 2026
a243e14
fix(breaking): count square brackets before a semicolon ends a declar…
Aug 22, 2026
6159852
fix(breaking): separate identity lines by the boundary that separated…
Aug 22, 2026
13ecf23
fix(breaking): keep the raw-identifier prefix in a module scope name
Aug 22, 2026
0ac7762
docs(breaking): record the cfg block-comment counter as a measured limit
Aug 22, 2026
79ad601
fix(perf): read a `<` as a generic opener only where one can be
Aug 22, 2026
860ff34
merge: round-15 review followup (array-type semicolons, literal newli…
Aug 22, 2026
61a60ee
fix(signal): keep a cfg guard across a block comment
Aug 23, 2026
cd65d73
fix(signal): read a const argument as type, not as a body opener
Aug 23, 2026
9c515ad
fix(cli): refuse a merge gate decision that states no signal
Aug 23, 2026
ebc9f98
merge: round-16 review followup (comment-aware cfg runs, const-argume…
Aug 23, 2026
106e4ac
fix(cli): honor --ci strictness on update runs and reused packs
Aug 23, 2026
2e16d9d
fix(mcp): align the decision reader with the CLI on unrankable signals
Aug 23, 2026
0dc537b
fix(signal): track the whole generic argument list when finding a bod…
Aug 23, 2026
2d842da
merge: round-17 review followup (generic-argument const blocks, MCP/C…
Aug 23, 2026
fad95ec
fix(signal): read an initializer brace as a value, not an item body
Aug 23, 2026
850388d
fix(signal): keep a line break in a declaration identity only inside …
Aug 23, 2026
793cd47
fix(perf): open inline test context only on a gate that provably hold…
Aug 23, 2026
b1ca07c
fix(artifacts): escape pipes in the breaking-changes markdown tables
Aug 23, 2026
d1da1e7
merge: round-18 final followup (initializer blocks, reflow-stable ide…
Aug 23, 2026
c5b8621
refactor(gate): keep the decision-signal reader helpers internal
Aug 23, 2026
58aecb2
fix(gate): rank a failed quality axis in the conservative reconciliation
Aug 23, 2026
27609e6
fix(artifacts): fence a table code span longer than the backticks ins…
Aug 23, 2026
5b9cdf3
merge: micro-round-19 followup (crate-private gate readers, quality a…
Aug 23, 2026
dbf8621
fix(breaking): reconstruct both sides of a hunk instead of truncating…
Aug 23, 2026
25e3b32
fix(gate): type quality_pass through readable_signal on both readers
Aug 23, 2026
3b4662c
fix(perf): accept a cfg(all(...)) test operand at any position
Aug 23, 2026
a746e73
fix(validator): require name and a known classification on 2.2 qualit…
Aug 23, 2026
06cfcf1
merge: round-20 followup (typed quality axis, context-fed pending dec…
Aug 23, 2026
856c8c7
fix(gate): rank the analysis and blocker axes, and close the axis table
Aug 23, 2026
a943fe4
fix(perf): match cfg markers against whole attributes, not physical l…
Aug 23, 2026
c3cba73
merge: round-21 followup (complete decision-axis reconciliation, mult…
Aug 23, 2026
8cdc464
fix(output): publish an absent decision axis from the reconciled outcome
Aug 23, 2026
67af56a
fix(perf): skip attribute brackets when looking for the item body
Aug 23, 2026
3261304
merge: round-22 followup (attribute-blind brace scan, legacy quality …
Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
692 changes: 677 additions & 15 deletions CHANGELOG.md

Large diffs are not rendered by default.

322 changes: 318 additions & 4 deletions docs/architecture.md

Large diffs are not rendered by default.

267 changes: 264 additions & 3 deletions docs/contracts/merge_gate.md

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion docs/gate-playbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ they must not parse stdout.
Use `prview gate --json` when CI needs a machine-readable summary, artifact
paths, or SARIF path discovery. Pass/fail still comes from the process exit code.

Exit `3` covers every way the run can end without a trustworthy verdict — the
review failing to execute, and the pack's `00_summary/MERGE_GATE.json` being
missing, unparsable, or stamped with a `schema_version` this build cannot read.
Plain `prview --ci` uses the same code for the same conditions: it never
re-derives a verdict when the gate artifact cannot be read.

## Breaking-change escalation

A genuine breaking API change in the diff — a removed public symbol, a changed
Expand All @@ -43,7 +49,11 @@ which command you run. Two contract lines, deliberately distinct:
hard failure (`BLOCK` or a broken quality gate); a `CONDITIONAL` verdict —
including a breaking-only `CONDITIONAL` — exits `0`, exactly as it does for any
other `CONDITIONAL` cause. This is the historical review contract and does not
change with breaking-change escalation.
change with breaking-change escalation. Warning-level checks are advisory and
do not break the quality gate, so a warnings-only run exits `0`; add
`--fail-on-warnings` to opt into exit `1` for them. Both `--ci` exits hold
whatever preset the run resolves to — `--ci --update` is still strict, and an
`--update` run with no new commits takes its exit from the pack it reused.
* **`prview gate`** — the contractual enforcement path. `CONDITIONAL` exits `1`,
and `prview gate --strict` exits `2` (see the exit-code contract above).

Expand Down
60 changes: 55 additions & 5 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,10 +200,60 @@ The decision surface is normalized so callers read one vocabulary:
clean `PASS`. A permissive flag on disk can never override a block/hold signal.

If the stored gate emits contradictory signals (for example `allow_merge: true`
alongside a block recommendation), the most conservative signal wins and a
`core_inconsistency` note is appended to `caveats`. Legacy gate tokens (`ALLOW`,
`HOLD`) written by older cores are still recognized on read and folded into the
`PASS` / `CONDITIONAL` surface rather than failing loud.
alongside a block recommendation, or a clean approval alongside
`quality_pass: false` — the contract permits `PASS` only when quality passes),
the most conservative signal wins and a `core_inconsistency` note is appended to
`caveats`. The note reports a
disagreement the pack actually states — the textual axes against the published
verdict, `allow_merge` against the flag published — so a self-consistent
`BLOCK` pack (`verdict: "BLOCK"`, `merge_recommendation: "block"`,
`allow_merge: false`) raises no caveat at all. The CLI `--json` surface
reconciles the same way through the same ranking
(`gate::rank_from_verdict` / `gate::rank_from_merge_rec`), so the two surfaces
cannot disagree about a contradictory pack. Legacy gate tokens (`ALLOW`,
`APPROVE`, `HOLD`) written by older cores are still recognized on read and folded
into the `PASS` / `CONDITIONAL` surface rather than failing loud. That fold is
`gate::canonical_verdict`, shared by this adapter, the CLI summary and
`prview gate`, and it ignores case: a stored `"pass"` reads as `PASS` on every
surface instead of approving on one and normalizing to `BLOCK` on another.

Anything the adapter could not read is named rather than dropped, and every such
case sets `normalized: true`:

- `unknown_verdict:` / `unknown_merge_recommendation:` — the field was present
but outside the known vocabulary, so it was ignored when deriving the decision.
A verdict that could not be ranked — outside the vocabulary, or simply absent
while another signal is stated — is substituted with `BLOCK`, and that
substitution governs the axes published beside it: `merge_recommendation`
reads `block` and `allow_merge` `false`, whatever the pack claimed. This is
the CLI's rule, applied here so the two readers cannot answer the same bytes
differently. `storage_corrupt` is reserved for a decision block stating NONE
of `verdict`, `merge_recommendation` and `allow_merge`; a signal that is
present but unrankable — including a lone `allow_merge` — is a decision the
pack gave, and it is normalized with a caveat rather than called corrupt.
- `unreadable_<field>:` — the field was present with the wrong JSON type
(`merge_recommendation: 7`, `allow_merge: "false"`, `quality_pass: "false"`,
`analysis_status: 7`, `blocking_issues: "Clippy"`). Emitted for every axis in
the ranking table of `docs/contracts/merge_gate.md`. A wrongly typed field is
not an absent one: it is ignored for ranking, but it is named, and the
decision is normalized conservatively around it. The pack is
`storage_corrupt` only when no signal was stated at all.
- `unknown_analysis_status:` — the field is a string outside
`complete` / `degraded` / `incomplete`. Like `unknown_merge_recommendation:`,
it cannot rank, so it is excluded from the reconciliation and named rather
than dropped in silence.
- `schema_forward_compat:` — the pack's `schema_version` is a newer MINOR of a
known MAJOR; it is read, and fields this build does not know are ignored. An
unknown MAJOR is `storage_corrupt`, and so is a `schema_version` that is
present but not a `MAJOR.MINOR` string. A pack with no `schema_version` at all
is pre-2.1 and is accepted silently, like the `ALLOW`/`HOLD` tokens — including
the pre-2.1 shape that carries its signals at the root instead of under
`decision`. A pack that STATES a `schema_version` and still has no `decision`
object is `storage_corrupt`, and so is a schema-less pack whose root is not an
object at all (an array, a scalar, `null`) — that root states no decision, it
is not a decision missing every field. Both readers apply those rules from one
place (`gate::select_decision_object`), so a pack the CLI reads is never one
the MCP adapter calls corrupt.

Completed response:

Expand Down Expand Up @@ -329,7 +379,7 @@ fields (e.g. `retry_after_ms`, `active_run_id`, `run_id`).
| `artifact_missing` | The requested artifact does not exist within the run, is not UTF-8 text, or would escape the run directory. |
| `tool_missing` | A required external tool is unavailable. |
| `storage_locked` | Another review is already running for this repo branch. Carries `active_run_id` and `retry_after_ms`. |
| `storage_corrupt` | `MERGE_GATE.json` is missing, invalid, has no recognizable decision, or an explicit `run_id` is ambiguous in storage. |
| `storage_corrupt` | `MERGE_GATE.json` is missing, invalid, carries a `schema_version` with an unknown MAJOR, states no decision signal at all, or an explicit `run_id` is ambiguous in storage. |
| `stale_run` | The run is still in progress or its process died before completing. Carries `retry_after_ms` while running. |

### Retrying
Expand Down
42 changes: 42 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,39 @@ introduced.
| `--no-color` | Disable ANSI colors |
| `--no-zip` | Skip ZIP creation |
| `--no-dashboard` | Skip HTML dashboard generation |
| `--soft-exit` | Always exit 0, whatever the checks found |
| `--fail-on-warnings` | With `--ci`: also exit 1 when any check reports warnings |

### `--ci` exit codes

`--ci` is the strict variant of the plain review run: it exits `1` on a `BLOCK`
verdict or a broken quality gate, and `0` otherwise. Warning-level signals — a
formatter delta, an unmaintained-crate advisory, lint warnings — are advisory:
they keep the verdict at `CONDITIONAL` and surface as review caveats, but they
do not fail the process. Add `--fail-on-warnings` to opt into exit `1` for them;
the flag requires `--ci` and does not affect `prview gate`, whose exit codes come
from the gate contract (see `docs/gate-playbook.md`).

`--fail-on-warnings` counts the artifact pack's check list, not the CLI's own.
The artifact run generates further checks — `public_api_diff`, `unsafe_audit`,
`ghost_refs` and the synthetic `heuristics_loctree` — which reach
`MERGE_GATE.json` and the dashboard but never the in-memory report the plain
tally is built from. The `--json` summary states both numbers:
`checks_summary.warned` is what the CLI ran, `checks_summary.warned_in_pack` is
the complete count the flag keys off, and it is always the larger of the two.

Strictness follows the `--ci` you typed, not the preset label the run reports.
`--update` outranks `--ci` when the execution preset is resolved, so
`prview --ci --fail-on-warnings --update` publishes `mode.execution_mode:
"update"` — and reading strictness off that label made both `--ci` exits
(`!quality_pass` and the warning hardening) silently inert for exactly the
combination CI jobs use.

An `--update` run that finds no new commits reuses the previous pack, and its
exit code is derived from that pack like any other run's: a reused `BLOCK` or a
reused warning under `--fail-on-warnings` exits non-zero rather than reporting a
green second invocation over an artifact nothing re-checked. `--soft-exit`
remains the one way to ask for `0` regardless.

## Examples

Expand Down Expand Up @@ -338,6 +371,15 @@ it carries the verdict, `output_dir`, a short `checks_summary`, `top_failures`,
on disk, especially the canonical `RUN.json` and `MERGE_GATE.json` pair, plus
`PR_REVIEW.md`.

The verdict fields (`verdict`, `allow_merge`, `quality_pass`,
`merge_recommendation`, `analysis_status`) are read from the run's
`00_summary/MERGE_GATE.json` and from nowhere else. If that artifact is missing,
unparsable, or stamped with a `schema_version` this build cannot read, prview
reports an execution error and exits `3` instead of re-deriving a verdict —
including on `--update` runs that re-read an earlier pack. A newer MINOR schema
within a known MAJOR is accepted and reported through the optional `caveats`
array, which also carries any verdict the reader had to normalize.

## Output

Artifacts are written to `$PRVIEW_HOME/runs/<repo>/<branch>/<run_id>/`
Expand Down
6 changes: 4 additions & 2 deletions src/artifacts/ai_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ pub(crate) fn generate_ai_index(
)?;
writeln!(
md,
"- Coverage signal: {}/{} changed code files ({}%)",
coverage.covered_count, coverage.total_source, coverage.pct
"- Coverage signal: {}/{} changed code files ({})",
coverage.covered_count,
coverage.total_source,
crate::artifacts::signal::format_coverage_pct(coverage.pct)
)?;
let gate_path = Path::new("00_summary/MERGE_GATE.json");
if dir.join(gate_path).exists()
Expand Down
9 changes: 7 additions & 2 deletions src/artifacts/dashboard/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2759,8 +2759,13 @@ const JS_SUFFIX: &str = r##"
}

if (quality.coverage) {
var pct = Math.round(quality.coverage.heuristic_ratio * 100);
comment += '**Coverage heuristic:** ' + pct + '% (' + quality.coverage.matched + '/' + quality.coverage.total + ')\n\n';
// heuristic_ratio is null when nothing was measured (0 changed
// source files) - do not round null into a 0%/100% claim.
var ratio = quality.coverage.heuristic_ratio;
var covLabel = (ratio === null || ratio === undefined)
? 'not measured'
: Math.round(ratio * 100) + '%';
comment += '**Coverage heuristic:** ' + covLabel + ' (' + quality.coverage.matched + '/' + quality.coverage.total + ')\n\n';
}

var hotspots = (diff.files || [])
Expand Down
21 changes: 11 additions & 10 deletions src/artifacts/dashboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,14 +557,15 @@ fn build_html(input: BuildHtmlInput<'_>) -> String {
)
};

let coverage_summary = if ctx.coverage.total_source > 0 {
i18n_template(
let coverage_summary = match ctx.coverage.pct {
Some(pct) => i18n_template(
"summary.coveragePct",
&format!("{}% coverage", ctx.coverage.pct),
&[("pct", ctx.coverage.pct.to_string())],
)
} else {
escape_html("N/A")
&format!("{}% coverage", pct),
&[("pct", pct.to_string())],
),
// Nothing was measured (no changed source files) — say so, do not
// borrow a number the heuristic never produced.
None => escape_html("N/A"),
};

let commit_count = diff.map(|d| d.commits.len()).unwrap_or(0);
Expand Down Expand Up @@ -927,12 +928,12 @@ fn build_html(input: BuildHtmlInput<'_>) -> String {
);
}
}
if ctx.coverage.total_source > 0 {
if ctx.coverage.pct < 80 {
if let Some(pct) = ctx.coverage.pct {
if pct < 80 {
let _ = write!(
nav,
"<a href=\"#section-coverage\"><span data-i18n=\"nav.coverage\">Coverage</span> <span class=\"nav-badge badge-warn\">{}</span></a>",
ctx.coverage.pct
pct
);
} else {
let _ = write!(
Expand Down
22 changes: 13 additions & 9 deletions src/artifacts/dashboard/sections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1027,18 +1027,21 @@ pub(super) fn build_action_center(
}

let cov = &ctx.coverage;
if cov.total_source > 0 || cov.non_code_count > 0 {
if cov.pct < 80 {
// `pct == None` means no changed source files were evaluated. A diff of
// pure non-code changes used to fall through here and emit a "Coverage:
// 100%" chip out of a 0/0 ratio — emit nothing instead.
if let Some(cov_pct) = cov.pct {
if cov_pct < 80 {
push_card(
&mut cards,
"#section-coverage",
if cov.pct < 50 {
if cov_pct < 50 {
"alert-error"
} else {
"alert-warning"
},
r#"<span data-i18n="section.coverage">Coverage</span>"#.to_string(),
escape_html(&format!("{}%", cov.pct)),
escape_html(&format!("{}%", cov_pct)),
i18n_template(
"message.changedCodeWithoutMatchingTests",
"Changed code without matching tests",
Expand All @@ -1055,7 +1058,7 @@ pub(super) fn build_action_center(
i18n_template(
"chip.coverageOk",
"Coverage: {pct}%",
&[("pct", cov.pct.to_string())],
&[("pct", cov_pct.to_string())],
)
),
);
Expand Down Expand Up @@ -1974,13 +1977,14 @@ pub(super) fn build_breaking_section(ctx: &DashboardContext) -> String {

pub(super) fn build_coverage_section(ctx: &DashboardContext) -> String {
let cov = &ctx.coverage;
if cov.total_source == 0 {
// No changed source files => nothing measured => no coverage section at all.
let Some(cov_pct) = cov.pct else {
return String::new();
}
};

// Coverage % is a metric, not a verdict: keep color only for the negative
// signal (below threshold = "what is wrong?"), neutralize the rest.
let pct_color = if cov.pct < 50 {
let pct_color = if cov_pct < 50 {
"var(--warn)"
} else {
"var(--fg)"
Expand Down Expand Up @@ -2049,7 +2053,7 @@ pub(super) fn build_coverage_section(ctx: &DashboardContext) -> String {
</div>
</div>"#,
color = pct_color,
pct = cov.pct,
pct = cov_pct,
coverage_detail = i18n_template(
"message.coverageDetail",
&format!(
Expand Down
6 changes: 3 additions & 3 deletions src/artifacts/dashboard/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fn mock_ctx() -> DashboardContext {
check_gates: vec![],
breaking: vec![],
coverage: super::super::CoverageDelta {
pct: 100,
pct: Some(100),
total_source: 1,
covered_count: 1,
uncovered: vec![],
Expand Down Expand Up @@ -372,7 +372,7 @@ fn test_header_merge_chip_shows_review_caveat() {
risk_level: BreakingRisk::High,
}];
ctx.coverage = super::super::CoverageDelta {
pct: 11,
pct: Some(11),
total_source: 43,
covered_count: 5,
uncovered: vec![],
Expand Down Expand Up @@ -509,7 +509,7 @@ fn test_merge_decision_card_review_caveats() {
risk_level: BreakingRisk::High,
}];
ctx.coverage = super::super::CoverageDelta {
pct: 11,
pct: Some(11),
total_source: 43,
covered_count: 5,
uncovered: vec![],
Expand Down
2 changes: 1 addition & 1 deletion src/artifacts/dashboard/trends_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ fn mock_ctx() -> DashboardContext {
check_gates: vec![],
breaking: vec![],
coverage: super::super::CoverageDelta {
pct: 100,
pct: Some(100),
total_source: 1,
covered_count: 1,
uncovered: vec![],
Expand Down
44 changes: 42 additions & 2 deletions src/artifacts/merge_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ pub(super) fn generate_merge_gate(input: MergeGateInput<'_>) -> Result<()> {
.count();

let gate = json!({
"schema_version": "2.1",
"schema_version": crate::gate::MERGE_GATE_SCHEMA_VERSION,
"generated_at": chrono::Local::now().to_rfc3339(),
"bridge_stage": config.bridge_stage,
"target": resolved_target.name,
Expand Down Expand Up @@ -285,6 +285,7 @@ pub(super) fn generate_merge_gate(input: MergeGateInput<'_>) -> Result<()> {
"quality_failure_details": quality_failures.details.iter().map(|detail| json!({
"name": detail.name,
"classification": detail.classification.as_str(),
"origin": detail.origin.as_str(),
})).collect::<Vec<_>>(),
"decision_reason": decision.reason,
"review_caveats": all_review_caveats,
Expand Down Expand Up @@ -500,7 +501,7 @@ mod tests {
CoverageDelta {
total_source: 0,
covered_count: 0,
pct: 0,
pct: None,
uncovered: Vec::new(),
covered: Vec::new(),
non_code_count: 0,
Expand Down Expand Up @@ -1132,6 +1133,45 @@ mod tests {
);
}

#[test]
fn preexisting_only_rustfmt_keeps_strict_gate_exit_zero() {
// Regression guard for the warning→failure cut: the gate exit contract is
// unchanged. The pre-existing-only rustfmt pack is a PASS, and `prview
// gate --strict` must still exit 0 on it — the same artifact the adapter
// in `gate.rs` reads, run through the same verdict → exit mapping.
use crate::gate::{GateVerdict, gate_exit_code};

let gate = run_gate_with_rustfmt_warning(false);
let verdict = GateVerdict::try_from(
gate["decision"]["verdict"]
.as_str()
.expect("verdict is a string"),
)
.expect("verdict is contract vocabulary");

assert_eq!(verdict, GateVerdict::Pass);
assert_eq!(gate_exit_code(verdict, true), 0);
assert_eq!(gate_exit_code(verdict, false), 0);
}

#[test]
fn introduced_warning_keeps_strict_gate_exit_two() {
// The other half of the contract: an in-diff warning stays CONDITIONAL,
// so `--strict` still exits 2. Warnings became honest, not toothless.
use crate::gate::{GateVerdict, gate_exit_code};

let gate = run_gate_with_rustfmt_warning(true);
let verdict = GateVerdict::try_from(
gate["decision"]["verdict"]
.as_str()
.expect("verdict is a string"),
)
.expect("verdict is contract vocabulary");

assert_eq!(verdict, GateVerdict::Conditional);
assert_eq!(gate_exit_code(verdict, true), 2);
}

#[test]
fn introduced_rustfmt_warning_in_diff_is_not_downgraded() {
// In-diff formatting warnings belong to the change: no downgrade.
Expand Down
Loading
Loading