diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index 25e9519..7f6616a 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -300,13 +300,14 @@ Preserve all completed task evidence below. - _Depends: 10.1_ - _Requirements: 2.1, 2.2, 3.4, 8.1, 8.2_ - _Verified 2026-09-16:_ 1,354 tests passed, nine skipped; Ruff/format/mypy passed. The taint query exports one row per enumerated operation plus a completion marker, which is what makes an empty inventory meaningful. Comparison now separates three outcomes: operation present in the base without a traced flow, operation absent from an enumerated base, and a base scope never enumerated, which stays explicitly unresolved along with any malformed inventory. The arbiter skips every row carrying a kind, so an inventory row cannot become a flow with an empty source. Real-engine smoke on an authored two-revision JavaScript control confirmed emission, the zero-operation marker and unchanged answer integrity: `benchmarks/measurements/2026-09-16-operation-inventory-smoke.json`. Pre-inventory fixtures were given an enumerated base, since an unenumerated one can no longer establish absence. -- [ ] 10.5 Make the external and dynamic context boundary question-owned +- [x] 10.5 Make the external and dynamic context boundary question-owned - M1a recorded `dynamic_external_or_depth_context_unresolved` on the target's own question. The flag is computed over every in-scope method, so one unresolved call anywhere vetoes every question. - With 10.4 supplying inventory evidence, bind the boundary to what it actually limits: reachability, not enumeration. A question establishing absence from the inventory no longer needs complete reachability; a question asserting a traced flow still does. Unknown ownership remains a blocker; absent evidence of independence is not proof of independence. - Done when an absence established from the inventory survives an unrelated unresolved destination, while a reachability claim under a bounded scope stays unresolved. - _Boundary: contributor-scan query/context evidence, Ranker Scope Contract_ - _Depends: 10.4_ - _Requirements: 2.1, 2.2, 8.1, 8.2_ + - _Verified 2026-09-16:_ 1,362 tests passed, nine skipped; Ruff/format/mypy passed. The boundary is now bound to what it limits. At scan level it is recorded against its owning question without demoting that question's answer, while an unavailable projection, an empty scope and an unreadable extent still demote. In comparison it no longer blocks the head's own traced flow, and no longer blocks an absence read off a structural enumeration, but still blocks every claim resting on a flow not having been traced. The query declares its enumeration basis, because a scope followed through the call graph can be shrunk by the same unresolved destination; such an inventory cannot establish absence while reachability is bounded. Controls cover both directions and both bases; every other gap kind still blocks. - [ ] 10.6 Stop vendor exclusion acting as a global completion veto - `vendor_semantics_unresolved` degrades every otherwise complete outcome to `graph_incomplete`, including on a complete 44-file first-party census with successful query receipts. - Keep the exclusion physical and keep the coverage note. Let the inventory and reachability evidence from 10.4 and 10.5 carry whether a specific answer needed excluded semantics; a recorded dependency on an excluded path still blocks that question. diff --git a/src/openultrasast/cpg/queries/taint.sc b/src/openultrasast/cpg/queries/taint.sc index 0434d3b..e4d56ea 100644 --- a/src/openultrasast/cpg/queries/taint.sc +++ b/src/openultrasast/cpg/queries/taint.sc @@ -832,7 +832,15 @@ ) } - rows ++ inventory ++ List(ujson.Obj("kind" -> "operation_inventory_complete", "operations" -> inventory.size)) + // The enumeration's own basis, because a consumer cannot infer it. A file- or nesting-scoped + // question walks calls structurally, so an unresolved call destination cannot remove one from + // the scope and the inventory is complete regardless of reachability. Following the call graph + // makes the reachable set the scope, so the same unresolved destination can shrink what was + // enumerated. Only the first kind can support absence under bounded reachability. + val inventoryScope = if (function.nonEmpty && depth > 0) "reachability" else "structural" + rows ++ inventory ++ List( + ujson.Obj("kind" -> "operation_inventory_complete", "operations" -> inventory.size, "scope" -> inventoryScope) + ) } println("---OUSAST-CPG-BEGIN---") diff --git a/src/openultrasast/model/regions.py b/src/openultrasast/model/regions.py index d21af73..32db293 100644 --- a/src/openultrasast/model/regions.py +++ b/src/openultrasast/model/regions.py @@ -180,6 +180,11 @@ def _families(language: str, *, has_handler: bool) -> tuple[str, ...]: AMBIGUOUS_CORRESPONDENCE = "ambiguous_line_correspondence" +# Reachability from or through the scoped methods was bounded. It limits which further flows +# could be seen; it does not make this question's own answer or context ownership unknown, so +# it is recorded as a boundary without marking the question's context incomplete. The later +# comparison consults it for exactly the claims that rest on a flow not having been traced. +REACHABILITY_BOUNDED = "dynamic_external_or_depth_context_unresolved" def affected_context( @@ -223,9 +228,11 @@ def affected_context( local_gaps.append("context_projection_unavailable:contributor-scan") elif not any(r.get("kind") == "context_method" for r in rows): local_gaps.append("context_scope_empty:contributor-scan") + recorded_only: list[str] = [] for row in rows: if row.get("kind") == "context_boundary": - local_gaps.append(str(row.get("reason", "context_unresolved"))) + reason = str(row.get("reason", "context_unresolved")) + (recorded_only if reason.startswith(REACHABILITY_BOUNDED) else local_gaps).append(reason) if row.get("kind") != "context_method": continue path = Path(str(row.get("path", ""))) @@ -260,5 +267,5 @@ def affected_context( relationships.append(relation) if local_gaps or inherited_gap: affected_gaps.add(identity) - gaps.extend(reason + ":" + identity.question_id for reason in local_gaps) + gaps.extend(reason + ":" + identity.question_id for reason in (*local_gaps, *recorded_only)) return replace(context, relationships=tuple(relationships), unresolved_boundaries=tuple(dict.fromkeys(gaps))), affected_gaps diff --git a/src/openultrasast/push/policy.py b/src/openultrasast/push/policy.py index 0ea40a5..c00d1b4 100644 --- a/src/openultrasast/push/policy.py +++ b/src/openultrasast/push/policy.py @@ -138,6 +138,10 @@ def _operations(scan: ModelScanResult, budget: ExecutionBudget | None = None) -> INVENTORY_ROW = "operation_inventory" INVENTORY_COMPLETE = "operation_inventory_complete" +# Reachability from or through the scoped methods was bounded. It can hide further flows; it +# cannot invalidate a flow that was traced, and it cannot remove a call from a structurally +# scoped enumeration. +REACHABILITY_BOUNDED = "dynamic_external_or_depth_context_unresolved" @dataclass(frozen=True) @@ -150,7 +154,20 @@ class OperationSite(Contract): operation: str -def _inventory(scan: ModelScanResult, question: QuestionIdentity) -> tuple[OperationSite, ...] | None: +@dataclass(frozen=True) +class OperationInventory(Contract): + """What a question enumerated, and on what basis it selected the scope it enumerated. + + `structural` scopes are file- or nesting-based, so an unresolved call destination cannot + remove a call from them. A reachability-derived scope can be shrunk by exactly that, so its + inventory cannot establish absence while reachability is bounded. + """ + + sites: tuple[OperationSite, ...] + structural: bool + + +def _inventory(scan: ModelScanResult, question: QuestionIdentity) -> OperationInventory | None: """Operations enumerated for this question, or None when the scope was not enumerated. None is the honest answer for an answer that carries no completion marker, including every @@ -162,12 +179,15 @@ def _inventory(scan: ModelScanResult, question: QuestionIdentity) -> tuple[Opera if answer is None or answer.raw_rows_json is None: return None sites: list[OperationSite] = [] - complete = False + basis: str | None = None for row in json.loads(answer.raw_rows_json): if not isinstance(row, dict): continue if row.get("kind") == INVENTORY_COMPLETE: - complete = True + scope = row.get("scope") + if scope not in ("structural", "reachability"): + return None + basis = str(scope) continue if row.get("kind") != INVENTORY_ROW: continue @@ -177,7 +197,7 @@ def _inventory(scan: ModelScanResult, question: QuestionIdentity) -> tuple[Opera if not str(line).isdigit() or int(str(line)) < 1: return None sites.append(OperationSite(question, path, int(str(line)), operation)) - return tuple(sites) if complete else None + return OperationInventory(tuple(sites), basis == "structural") if basis is not None else None def _correspondence(op: EvidenceOperation, context: ChangeContext) -> tuple[tuple[str, int] | None, str]: @@ -463,19 +483,25 @@ 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() - complete = bool( + boundaries = _context_boundaries(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( base and base.scope 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 _context_boundaries(base, *required) and not base.degradations and required <= selected and required <= answered and {q.identity for q in base.question_outcomes} <= selected and all(_valid_answer(base, question, base_ops) for question in required) ) + complete = established and not boundaries + # An absence read off a structural enumeration survives bounded reachability; every + # other kind of gap still blocks. + enumerated = established and all(boundary.startswith(REACHABILITY_BOUNDED) for boundary in boundaries) if head_semantics != base_semantics: reason = "semantics_mismatch" elif context.base_revision is None: @@ -484,7 +510,9 @@ def _compare_evidence( reason = "change_context_unresolved" elif ( not head.scope - or _context_boundaries(head, op.question) + # 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 not _valid_answer(head, op.question, head_ops) or op.question not in {q.identity for q in head.scope.selected} @@ -492,33 +520,35 @@ def _compare_evidence( reason = "head_context_incomplete" elif execution_budget is not None and time.monotonic() >= execution_budget.deadline_monotonic: reason = "deadline_exhausted" - elif complete: + elif enumerated: same = tuple(b for b in old if b.source == op.source) reason = "operation_correspondence_unresolved" - if any( + if complete and any( b.discharged == op.discharged and (op.mechanism != "config" or json.loads(b.detail).get("literalArgs") == json.loads(op.detail).get("literalArgs")) for b in same ): novelty, reason = "unchanged", "same_supported_mechanism" - elif old and change and finding.rung == Rung.ENTAILED and not op.discharged: + elif complete and old and change and finding.rung == Rung.ENTAILED and not op.discharged: if same and all(b.discharged for b in same): novelty, reason = "worsened", "discharge_removed" elif not same and op.mechanism == "taint": reason = "source_correspondence_unresolved" elif ( - location is not None + complete + and location is not None and not old and change and op.mechanism == "taint" and finding.rung == Rung.ENTAILED and not op.discharged ): + # This absence is the absence of a traced flow, so it needs complete reachability. novelty, reason = "new", "source_connection_absent_from_comparable_base" elif correspondence == "changed_line" and change and op.mechanism == "taint": - # The edit introduced these lines. A complete counterpart answer that - # contains this operation elsewhere is movement, never novelty; only its - # total absence establishes that the change introduced the flow. + # The edit introduced these lines. A counterpart answer that contains this + # operation elsewhere is movement, never novelty; only its total absence from + # the enumeration establishes that the change introduced the flow. inventory = _inventory(base, counterpart) if base is not None else None if identical: reason = "operation_moved_within_change" @@ -526,12 +556,18 @@ def _compare_evidence( # The base scope was never enumerated, so absence cannot be established # from the absence of a traced flow. reason = "base_operation_inventory_unavailable" - elif any(site.operation == op.operation for site in inventory): + elif not inventory.structural and not complete: + # The enumeration followed the call graph, so the same bounded reachability + # that is unresolved here could have shrunk what was enumerated. + reason = "base_enumeration_scope_reachability_derived" + elif any(site.operation == op.operation for site in inventory.sites): # The operation is in the base. No traced flow reached it there, which is # a reachability statement, not evidence that the change introduced it. reason = "operation_present_in_base_without_traced_flow" elif finding.rung == Rung.ENTAILED and not op.discharged: novelty, reason = "new", "operation_absent_from_comparable_base" + elif not complete: + reason = "base_reachability_bounded" results.append(_candidate(finding, op, old, novelty, reason, change, context)) return tuple( replace( diff --git a/tests/test_model_change_context.py b/tests/test_model_change_context.py index 951b916..af49dbb 100644 --- a/tests/test_model_change_context.py +++ b/tests/test_model_change_context.py @@ -197,3 +197,37 @@ def test_any_other_inherited_transaction_gap_still_marks_questions_incomplete(tm ) assert result.question_outcomes[0].status == "unresolved" assert result.question_outcomes[0].reason == "change_context_incomplete" + + +def boundary(reason): + return {"kind": "context_boundary", "reason": reason} + + +def test_bounded_reachability_is_recorded_without_demoting_an_answered_question(tmp_path): + """M1b 10.5: the boundary limits which further flows could be seen, not this answer.""" + (tmp_path / "source.php").write_text("readable\n") + bounded = boundary("dynamic_external_or_depth_context_unresolved:contributor-scan") + rows = vector() + [row("source.php"), {"kind": "context_summary"}, bounded] + result = scan_repository( + tmp_path, + [region("source.php", "php")], + backend=Backend({"source.php": rows}), + change_context=context(), + ) + assert result.question_outcomes[0].status == "completed" + # Still reported, bound to the question that owns it, so comparison can consult it. + owned = [g for g in result.scope.unresolved_boundaries if g.startswith("dynamic_external_or_depth_context_unresolved")] + assert owned and owned[0].endswith(result.question_outcomes[0].identity.question_id) + + +def test_an_unresolved_context_projection_still_demotes_the_question(tmp_path): + (tmp_path / "source.php").write_text("readable\n") + rows = vector() + [row("source.php"), {"kind": "context_summary"}, boundary("context_location_unavailable")] + result = scan_repository( + tmp_path, + [region("source.php", "php")], + backend=Backend({"source.php": rows}), + change_context=context(), + ) + assert result.question_outcomes[0].status == "unresolved" + assert result.question_outcomes[0].reason == "change_context_incomplete" diff --git a/tests/test_push_policy.py b/tests/test_push_policy.py index d58657e..cf890ff 100644 --- a/tests/test_push_policy.py +++ b/tests/test_push_policy.py @@ -228,9 +228,18 @@ def test_unrelated_question_context_gap_does_not_poison_completed_comparison(): question_outcomes=(*head.question_outcomes, QuestionOutcome(other, "unresolved", "change_context_incomplete", "[]")), ) assert delta(head, scan(rows=[], findings=False)).novelty == "new" - own_gap = gap.rsplit(":", 1)[0] + ":" + head.scope.selected[0].identity.question_id + mine = head.scope.selected[0].identity.question_id + # M1b 10.5: the question's own bounded reachability can hide further flows, but it cannot + # invalidate the flow this finding was traced from, so it no longer blocks its own claim. + own_reachability = gap.rsplit(":", 1)[0] + ":" + mine assert ( - delta(replace(head, scope=replace(head.scope, unresolved_boundaries=(own_gap,))), scan(rows=[], findings=False)).novelty + delta(replace(head, scope=replace(head.scope, unresolved_boundaries=(own_reachability,))), scan(rows=[], findings=False)).novelty + == "new" + ) + # An unavailable context projection is unknown ownership, not bounded reachability, and still blocks. + own_projection = "context_projection_unavailable:contributor-scan:" + mine + assert ( + delta(replace(head, scope=replace(head.scope, unresolved_boundaries=(own_projection,))), scan(rows=[], findings=False)).novelty == "unknown" ) assert ( @@ -426,12 +435,13 @@ def test_any_other_transaction_context_gap_still_blocks(): assert (candidate.novelty, candidate.reason) == ("unknown", "change_context_unresolved") -def inventory_rows(*operations, complete=True, path="a.js", line=4): +def inventory_rows(*operations, complete=True, path="a.js", line=4, scope="structural"): rows = [ {"kind": "operation_inventory", "inventory": op, "inventoryFile": path, "inventoryLine": line, "inventoryMethod": "handler"} for op in operations ] - return rows + ([{"kind": "operation_inventory_complete", "operations": len(rows)}] if complete else []) + marker = {"kind": "operation_inventory_complete", "operations": len(rows), "scope": scope} + return rows + ([marker] if complete else []) def test_an_operation_present_in_the_base_inventory_is_not_new(): @@ -473,3 +483,45 @@ def test_inventory_rows_do_not_count_as_unparsed_answer_body(): rows = json.loads(head.question_outcomes[0].raw_rows_json) + inventory_rows("exec(cmd)") head = replace(head, question_outcomes=(replace(head.question_outcomes[0], raw_rows_json=json.dumps(rows)),)) assert delta(head, scan(rows=inventory_rows("query(sql, params)"), findings=False), changed_line_context()).novelty == "new" + + +def bounded_base(*operations, own_question=True, scope="structural"): + """A base whose only gap is its own bounded reachability.""" + base = scan(rows=inventory_rows(*operations, scope=scope), findings=False) + owner = base.scope.selected[0].identity if own_question else replace(base.scope.selected[0].identity, family="path") + gap = "dynamic_external_or_depth_context_unresolved:contributor-scan:" + owner.question_id + return replace(base, scope=replace(base.scope, unresolved_boundaries=(gap,))) + + +def test_absence_from_a_structural_enumeration_survives_bounded_reachability(): + """M1b 10.5: enumeration is structural, so an unresolved destination cannot remove a call.""" + candidate = delta(scan(), bounded_base(), changed_line_context()) + assert (candidate.novelty, candidate.reason) == ("new", "operation_absent_from_comparable_base") + + +def test_a_traced_flow_absence_still_requires_complete_reachability(): + """The anchored branch rests on no flow having been traced, so the bound still blocks.""" + candidate = delta(scan(), bounded_base(), context()) + assert candidate.novelty == "unknown" and candidate.reason == "base_reachability_bounded" + + +def test_a_reachability_derived_enumeration_cannot_establish_absence_while_bounded(): + candidate = delta(scan(), bounded_base(scope="reachability"), changed_line_context()) + assert candidate.novelty == "unknown" + assert candidate.reason == "base_enumeration_scope_reachability_derived" + + +def test_a_reachability_derived_enumeration_is_usable_when_reachability_is_complete(): + base = scan(rows=inventory_rows(scope="reachability"), findings=False) + assert delta(scan(), base, changed_line_context()).novelty == "new" + + +def test_a_non_reachability_base_gap_still_blocks_an_enumerated_absence(): + base = scan(rows=inventory_rows(), findings=False) + base = replace(base, scope=replace(base.scope, unresolved_boundaries=("graph_incomplete",))) + assert delta(scan(), base, changed_line_context()).novelty == "unknown" + + +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"