Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e309841
fix(git): diff artifacts from merge-base
Jul 7, 2026
290bdd2
fix(report): populate range merge_base from diff base
Jul 7, 2026
7d32744
fix(checks): run ruff/mypy/js checks against fetched target in remote…
Jul 7, 2026
40c8d5a
fix(cache): key rust checks by cargo_root manifest set
Jul 7, 2026
13665e5
fix(checks): distinguish rustfmt missing from formatting diff
Jul 7, 2026
9309fbc
fix(cache): escape repo path in glob patterns
Jul 7, 2026
aca3e1c
fix(checks): surface cargo audit informational warnings
Jul 7, 2026
48d4ff2
fix(cache): widen cache key hash to 16 bytes
Jul 7, 2026
8534fe0
fix(storage): skip corrupt index lines instead of truncating
Jul 7, 2026
4f81935
fix(mcp): surface in-flight runs in verdict without run_id
Jul 7, 2026
b947aed
fix(mcp): fail loud on unreadable MERGE_GATE in quick path
Jul 7, 2026
f22bcb7
fix(mcp): treat pid 0 as dead in liveness checks
Jul 7, 2026
32c0342
fix(storage): fsync before rename in index save
Jul 7, 2026
ca94912
fix(storage): add age signal to stale-lock detection
Jul 7, 2026
5daa417
fix(mcp): serialize run activation to close R2b TOCTOU
Jul 7, 2026
d85cf35
fix(artifacts): keep report.json verdict in sync with merge gate
Jul 7, 2026
8919afd
fix(signal): require module match in coverage stem strategy
Jul 7, 2026
1846d6d
fix(signal): use identifier-boundary match for orphaned resources
Jul 7, 2026
5849a91
fix(output): clarify summary when failures degraded to advisory
Jul 7, 2026
db8fa20
fix(output): handle analysis_status=incomplete explicitly
Jul 7, 2026
698f57c
refactor(cache): use glob::Pattern::escape for repo-root glob escaping
Jul 7, 2026
85d4eb7
fix(checks): fold workspace-root lockfile into member cargo cache keys
Jul 7, 2026
375ea35
fix(heuristics): compute snapshot regression from the merge base
Jul 7, 2026
3574ed7
fix(verdict): trust snapshot-backed linters in the pre-existing downg…
Jul 7, 2026
5597cac
fix(mcp): prefer the live in-flight run for HEAD over a stale complet…
Jul 7, 2026
ca29088
perf(checks): share one target snapshot across all checks in a run
Jul 7, 2026
db78f3e
test(checks): harden run_js_command local-bin test against ETXTBSY race
Jul 7, 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
9 changes: 7 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,16 @@ pub struct Repository {
impl Repository {
pub fn resolve_target(&self, config: &Config) -> Result<ResolvedRef>;
pub fn resolve_bases(&self, config: &Config) -> Result<Vec<ResolvedRef>>;
pub fn resolve_diff_bases(&self, target: &ResolvedRef, bases: &[ResolvedRef], quiet: bool) -> Vec<ResolvedRef>;
pub fn generate_diffs(&self, target: &ResolvedRef, bases: &[ResolvedRef], quiet: bool) -> Result<Vec<Diff>>;
pub fn commit_patch(&self, commit_id: &str) -> Result<String>;
}
```

Artifact diffs use `resolve_diff_bases()` before `generate_diffs()`, so the
review pack matches GitHub's three-dot "Files changed" model: branch names stay
displayable as bases, while each diff is anchored at the target/base merge-base.

Advantages of git2 over the `git` CLI:
- much faster for batch operations
- no subprocess spawning
Expand Down Expand Up @@ -451,8 +456,8 @@ impl Cache {
}

// Key generation
pub fn rust_hash(repo_root: &PathBuf) -> String {
// git_head_short + cargo_files_hash + src_files_hash
pub fn rust_hash(root: &Path) -> String {
// Cargo.toml/Cargo.lock hash + Rust source hash, 16-byte digest segments
}
```

Expand Down
25 changes: 15 additions & 10 deletions src/artifacts/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,14 @@ pub(crate) fn build_dashboard_context(input: DashboardContextInput<'_>) -> Dashb
};
let blocking = config.policy.is_blocking(severity, status_class);
if blocking {
blocking_issues.push(format!(
"Loctree heuristics (dead_exports={}, cycles={})",
dead, cycles
));
record_blocking_issue(
&mut blocking_issues,
&mut worst_merge,
format!(
"Loctree heuristics (dead_exports={}, cycles={})",
dead, cycles
),
);
}
let name = if let Some(h) = heuristics {
format!(
Expand All @@ -190,12 +194,13 @@ pub(crate) fn build_dashboard_context(input: DashboardContextInput<'_>) -> Dashb
// Inline findings blocking — THREAD 7: gate on introduced/unclassified
// findings, not the raw error count, so the dashboard agrees with the merge
// gate and a pre-existing-only scan does not block.
let inline_severity = config.policy.severity_for("inline_findings");
let inline_class = effective_inline_gate_class(inline, &clean_comparison);
let inline_blocking = config.policy.is_blocking(inline_severity, inline_class);
if inline_blocking {
blocking_issues.push(format!("INLINE_FINDINGS ({})", inline.status));
}
apply_inline_gate_outcome(
config,
inline,
&clean_comparison,
&mut blocking_issues,
&mut worst_merge,
);

let policy_allow_merge = blocking_issues.is_empty();

Expand Down
34 changes: 34 additions & 0 deletions src/artifacts/findings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,40 @@ pub(super) fn effective_inline_gate_class(
}
}

pub(super) struct InlineGateOutcome {
pub severity: crate::policy::PolicySeverity,
pub blocking: bool,
}

pub(super) fn record_blocking_issue(
blocking_issues: &mut Vec<String>,
worst_merge: &mut crate::policy::engine::MergeRecommendation,
issue: impl Into<String>,
) {
blocking_issues.push(issue.into());
*worst_merge = crate::policy::engine::MergeRecommendation::Block;
}

pub(super) fn apply_inline_gate_outcome(
config: &Config,
inline: &InlineFindingsSummary,
clean: &CleanComparison,
blocking_issues: &mut Vec<String>,
worst_merge: &mut crate::policy::engine::MergeRecommendation,
) -> InlineGateOutcome {
let severity = config.policy.severity_for("inline_findings");
let class = effective_inline_gate_class(inline, clean);
let blocking = config.policy.is_blocking(severity, class);
if blocking {
record_blocking_issue(
blocking_issues,
worst_merge,
format!("INLINE_FINDINGS ({})", inline.status),
);
}
InlineGateOutcome { severity, blocking }
}

pub(super) fn gate_class_for_check(status: crate::checks::CheckStatus) -> GateClass {
match status {
crate::checks::CheckStatus::Passed => GateClass::Pass,
Expand Down
88 changes: 79 additions & 9 deletions src/artifacts/merge_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,21 +104,22 @@ pub(super) fn generate_merge_gate(input: MergeGateInput<'_>) -> Result<()> {
if !has_heuristics {
let (heuristics_check, heuristics_issue) = build_heuristics_gate_check(config, heuristics);
if let Some(issue) = heuristics_issue {
blocking_issues.push(issue);
worst_merge = MergeRecommendation::Block;
record_blocking_issue(&mut blocking_issues, &mut worst_merge, issue);
}
gate_checks.push(heuristics_check);
}

let inline_severity = config.policy.severity_for("inline_findings");
// THREAD 7: gate on introduced/unclassified findings, not the raw error
// count — a scan with only pre-existing errors must not block the merge.
let inline_class = effective_inline_gate_class(inline, &clean_comparison);
let inline_blocking = config.policy.is_blocking(inline_severity, inline_class);
if inline_blocking {
blocking_issues.push(format!("INLINE_FINDINGS ({})", inline.status));
worst_merge = MergeRecommendation::Block;
}
let inline_gate = apply_inline_gate_outcome(
config,
inline,
&clean_comparison,
&mut blocking_issues,
&mut worst_merge,
);
let inline_severity = inline_gate.severity;
let inline_blocking = inline_gate.blocking;

let policy_allow_merge = blocking_issues.is_empty();

Expand Down Expand Up @@ -704,6 +705,75 @@ mod tests {
.expect("gate check")
}

#[test]
fn artifact_consistency_inline_blocking_verdict_matches_report_and_gate() {
use crate::policy::engine::MergeRecommendation;

let tmp = tempfile::tempdir().expect("tempdir");
let mut config = test_config();
config.policy.checks.insert(
"inline_findings".to_string(),
crate::policy::PolicySeverity::Block,
);
let inline = InlineFindingsSummary {
status: "failed".to_string(),
findings_count: 1,
dashboard_findings: vec![DashboardFinding {
level: "error",
check_name: "Semgrep scan".to_string(),
check_id: "semgrep_scan".to_string(),
message: "introduced finding".to_string(),
in_diff: Some(true),
}],
};
let coverage = empty_coverage();
let (resolved_target, resolved_bases) = resolved_refs();

generate_merge_gate(MergeGateInput {
dir: tmp.path(),
config: &config,
checks: &[],
heuristics: None,
inline: &inline,
breaking: &[],
coverage: &coverage,
diffs: &[],
skipped_checks: &[],
resolved_target: &resolved_target,
resolved_bases: &resolved_bases,
clean_comparison: CleanComparison::for_test(true, true),
})
.expect("merge gate");

let raw =
std::fs::read_to_string(tmp.path().join("MERGE_GATE.json")).expect("read gate json");
let gate: serde_json::Value = serde_json::from_str(&raw).expect("parse gate json");
let dashboard = build_dashboard_context(DashboardContextInput {
config: &config,
checks: &[],
heuristics: None,
inline: &inline,
breaking: Vec::new(),
coverage,
diff_dir: tmp.path(),
skipped_checks: Vec::new(),
out_dir: tmp.path(),
diffs: &[],
ownership_map: Vec::new(),
clean_comparison: CleanComparison::for_test(true, true),
});

assert_eq!(
gate["decision"]["merge_recommendation"].as_str(),
Some("block")
);
assert_eq!(dashboard.merge_recommendation, MergeRecommendation::Block);
assert_eq!(
dashboard.verdict,
gate["decision"]["verdict"].as_str().expect("gate verdict")
);
}

#[test]
fn effective_outcome_is_the_single_shared_verdict_source() {
use crate::policy::engine::{MergeRecommendation, PolicyEngine};
Expand Down
7 changes: 6 additions & 1 deletion src/artifacts/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,11 @@ fn build_report(input: &ReportInput<'_>) -> Report {
..
} = input;

let diff_merge_base = diffs
.first()
.map(|diff| diff.base_commit_id.clone())
.or_else(|| resolved_bases.first().map(|base| base.commit_id.clone()));

// -- meta --
let meta = Meta {
generated_at: run_started_at.to_string(),
Expand All @@ -605,7 +610,7 @@ fn build_report(input: &ReportInput<'_>) -> Report {
range: RangeMeta {
base: resolved_bases.first().map(|b| b.name.clone()),
head: resolved_target.name.clone(),
merge_base: resolved_bases.first().map(|b| b.commit_id.clone()),
merge_base: diff_merge_base,
},
};

Expand Down
70 changes: 66 additions & 4 deletions src/artifacts/signal/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ fn find_matching_test<'a>(
.and_then(|p| p.to_str())
.unwrap_or("");

// Strategy 1: Exact stem match (strip test prefix/suffix) → High
// Strategy 1: Exact stem match (strip test prefix/suffix)
for test in test_files {
let test_stem = Path::new(&test.path)
.file_stem()
Expand All @@ -511,19 +511,29 @@ fn find_matching_test<'a>(
.unwrap_or(test_stem);

if test_base == src_stem {
return Some((test, CoverageMatchTier::High));
let tier = if same_coverage_module(&source.path, &test.path) {
CoverageMatchTier::High
} else {
CoverageMatchTier::Low
};
return Some((test, tier));
}
}

// Strategy 2: Path-mirrored (tests/foo/bar.rs <-> src/foo/bar.rs) → High
// Strategy 2: Path-mirrored (tests/foo/bar.rs <-> src/foo/bar.rs)
for test in test_files {
if test.path.contains("tests/") || test.path.contains("__tests__/") {
let test_filename = Path::new(&test.path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if test_filename == src_stem {
return Some((test, CoverageMatchTier::High));
let tier = if same_coverage_module(&source.path, &test.path) {
CoverageMatchTier::High
} else {
CoverageMatchTier::Low
};
return Some((test, tier));
}
}
}
Expand Down Expand Up @@ -601,6 +611,37 @@ fn find_matching_test<'a>(
None
}

fn same_coverage_module(source_path: &str, test_path: &str) -> bool {
coverage_module_key(source_path) == coverage_module_key(test_path)
}

fn coverage_module_key(path: &str) -> Vec<String> {
let mut components = Path::new(path)
.components()
.filter_map(|component| component.as_os_str().to_str())
.filter(|component| !component.is_empty())
.map(|component| component.to_string())
.collect::<Vec<_>>();

if components
.first()
.is_some_and(|component| matches!(component.as_str(), "src" | "tests" | "__tests__"))
{
components.remove(0);
}

components.pop();

while components
.last()
.is_some_and(|component| matches!(component.as_str(), "tests" | "__tests__"))
{
components.pop();
}

components
}

/// Strategy 5 (import recovery): find a test file that imports the source module.
///
/// Only called for source files that had no filename-heuristic match.
Expand Down Expand Up @@ -823,6 +864,27 @@ mod tests {
assert_eq!(signal.confidence, "high");
}

#[test]
fn artifact_consistency_stem_match_across_different_modules_is_low_confidence() {
let diff = mock_diff(vec![
mock_file_change("src/a/util.rs", FileStatus::Modified, 10, 5),
mock_file_change("tests/b/util_test.rs", FileStatus::Modified, 5, 2),
]);

let signal = compute_coverage_signal(&[diff], None, None);

assert_eq!(signal.covered_count, 1);
assert_eq!(
signal.covered_files[0],
(
"src/a/util.rs".to_string(),
"tests/b/util_test.rs".to_string(),
CoverageMatchTier::Low,
)
);
assert_eq!(signal.confidence, "medium");
}

#[test]
fn coverage_delta_excludes_non_code_files_from_source_count() {
let diff = mock_diff(vec![
Expand Down
Loading
Loading