diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index 4e3eb78..750dd0d 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -315,7 +315,7 @@ Preserve all completed task evidence below. - _Boundary: contributor-scan graph/scope evidence, Cache and Engine Boundary_ - _Depends: 10.5_ - _Requirements: 2.2, 2.3, 8.2, 8.4_ - - _Verified 2026-09-16:_ 1,365 tests passed, nine skipped; Ruff/format/mypy passed. Graph boundaries are split into integrity failures, which still demote a completed answer, and declared exclusions, which do not. Whether a particular answer needed the excluded semantics is carried by that question's own unresolved call destinations from 10.5, not assumed for every answer. Controls exercise the real partition logic rather than an injected reason: a vendored directory leaves the answer completed with the exclusion still reported, an unresolved symlink still demotes, and the two together still demote. The exclusion stays physical and out of targets and graphs. + - _Verified 2026-09-16:_ 1,365 tests passed, nine skipped; Ruff/format/mypy passed. Graph boundaries are split into integrity failures, which still demote a completed answer, and declared exclusions, which do not. Whether a particular answer needed the excluded semantics is carried by that question's own unresolved call destinations from 10.5, not assumed for every answer. Controls exercise the real partition logic rather than an injected reason: a vendored directory leaves the answer completed with the exclusion still reported, an unresolved symlink still demotes, and the two together still demote. The exclusion stays physical and out of targets and graphs. The NodeGoat rerun showed the same distinction was still missing inside comparison, where any degradation and any boundary blocked: comparison now filters declared exclusions and correspondence ambiguity on both sides, keeping integrity degradations and every other boundary blocking. - [ ] 10.7 Carry query-to-operation provenance into the reported evidence - The target is selected through a file-fallback question with `function=None` while rows report method `2`. M1a showed this did not block the comparison once the question completed, so it is provenance quality rather than the blocker. - Record verified query-to-operation provenance on the evidence; do not inject benchmark function names into regions or count coincident sites. diff --git a/src/openultrasast/push/policy.py b/src/openultrasast/push/policy.py index c00d1b4..78302c9 100644 --- a/src/openultrasast/push/policy.py +++ b/src/openultrasast/push/policy.py @@ -18,7 +18,7 @@ from openultrasast.model.contracts import ChangeContext, ExecutionBudget, Questi from openultrasast.model.ladder import Rung from openultrasast.model.pipeline import ModelFinding, _arbitrate_all from openultrasast.model.regions import AMBIGUOUS_CORRESPONDENCE, ScanRegion -from openultrasast.model.scan import ModelScanResult, ScanBudget, _prefetched, _spec_for, scan_repository +from openultrasast.model.scan import DECLARED_EXCLUSION_GAPS, ModelScanResult, ScanBudget, _prefetched, _spec_for, scan_repository from openultrasast.model.specs import ConfigSpec, DominanceSpec, TaintSpec from openultrasast.push.cache import ArtifactCache, SemanticKeys from openultrasast.push.contracts import ComparisonAnalysis, PushComparison, PushResult @@ -310,6 +310,26 @@ def _blocking_context(context: ChangeContext) -> tuple[str, ...]: return tuple(gap for gap in context.unresolved_boundaries if not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE)) +def _blocking_gaps(scan: ModelScanResult, *questions: QuestionIdentity) -> tuple[str, ...]: + """Scan gaps that can block a comparison claim. + + Correspondence ambiguity is decided per operation, and a declared exclusion is a scope + choice rather than a failure to read the graph, so neither belongs here. Both remain in + aggregate coverage. Bounded reachability is kept and filtered by the caller, because it + blocks only the claims that rest on a flow not having been traced. + """ + return tuple( + gap + for gap in _context_boundaries(scan, *questions) + if not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE) and gap.split(":")[0] not in DECLARED_EXCLUSION_GAPS + ) + + +def _blocking_degradations(scan: ModelScanResult) -> tuple[str, ...]: + """Degradations that undermine an answer. A declared exclusion is not one of them.""" + return tuple(str(d.get("reason")) for d in scan.degradations if str(d.get("reason")) not in DECLARED_EXCLUSION_GAPS) + + def _required_scope(question: QuestionIdentity, context: ChangeContext) -> set[QuestionIdentity]: """Base questions this comparison requires: the counterpart and its recorded dependencies. @@ -483,7 +503,7 @@ def _compare_evidence( required = _required_scope(op.question, context) selected = {q.identity for q in base.scope.selected} if base is not None and base.scope is not None else set() answered = {q.identity for q in base.question_outcomes if q.status == "completed"} if base is not None else set() - boundaries = _context_boundaries(base, *required) if base is not None else ("base_unavailable",) + boundaries = _blocking_gaps(base, *required) if base is not None else ("base_unavailable",) # Everything a base answer needs except its reachability, which limits only claims # that rest on a flow not having been traced. established = bool( @@ -492,7 +512,7 @@ def _compare_evidence( and base.scope.population_complete # A truncated base scan cannot establish absence, wherever it was truncated. and all(question.reason == "tier_zero" for question in base.scope.deferred) - and not base.degradations + and not _blocking_degradations(base) and required <= selected and required <= answered and {q.identity for q in base.question_outcomes} <= selected @@ -512,8 +532,8 @@ def _compare_evidence( not head.scope # A bounded reachability on the head can hide further flows; it cannot invalidate # the flow this finding was traced from, so it does not block the head's own claim. - or [gap for gap in _context_boundaries(head, op.question) if not gap.startswith(REACHABILITY_BOUNDED)] - or head.degradations + or [gap for gap in _blocking_gaps(head, op.question) if not gap.startswith(REACHABILITY_BOUNDED)] + or _blocking_degradations(head) or not _valid_answer(head, op.question, head_ops) or op.question not in {q.identity for q in head.scope.selected} ): diff --git a/tests/test_push_policy.py b/tests/test_push_policy.py index cf890ff..a14ad37 100644 --- a/tests/test_push_policy.py +++ b/tests/test_push_policy.py @@ -525,3 +525,27 @@ def test_a_non_reachability_base_gap_still_blocks_an_enumerated_absence(): def test_a_present_operation_still_refuses_novelty_under_bounded_reachability(): candidate = delta(scan(), bounded_base("exec(cmd)"), changed_line_context()) assert candidate.reason == "operation_present_in_base_without_traced_flow" + + +def degraded(result, *reasons): + return replace(result, degradations=tuple({"stage": "cpg", "reason": r} for r in reasons)) + + +def test_a_declared_exclusion_does_not_block_a_comparison_on_either_side(): + """M1b 10.6 in comparison: the exclusion is a scope choice, not a failed read.""" + head = degraded(scan(), "vendor_semantics_unresolved") + base = degraded(scan(rows=inventory_rows(), findings=False), "vendor_semantics_unresolved") + assert delta(head, base, changed_line_context()).novelty == "new" + + +def test_an_integrity_degradation_still_blocks_a_comparison(): + head = degraded(scan(), "vendor_semantics_unresolved", "cpg_empty") + base = scan(rows=inventory_rows(), findings=False) + assert delta(head, base, changed_line_context()).reason == "head_context_incomplete" + assert delta(scan(), degraded(base, "cpg_empty"), changed_line_context()).novelty == "unknown" + + +def test_a_declared_exclusion_boundary_does_not_block_either(): + base = scan(rows=inventory_rows(), findings=False) + base = replace(base, scope=replace(base.scope, unresolved_boundaries=("vendor_semantics_unresolved",))) + assert delta(scan(), base, changed_line_context()).novelty == "new"