diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index 7f6616a..4e3eb78 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -308,13 +308,14 @@ Preserve all completed task evidence below. - _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 +- [x] 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. - Done when an unrelated vendor exclusion no longer invalidates a first-party answer, while a question with evidence of dependency on excluded code remains unresolved and the exclusion stays out of targets and graphs. - _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. - [ ] 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/benchmarks/measurements/2026-09-16-operation-inventory-smoke.json b/benchmarks/measurements/2026-09-16-operation-inventory-smoke.json index d5d7d79..895613c 100644 --- a/benchmarks/measurements/2026-09-16-operation-inventory-smoke.json +++ b/benchmarks/measurements/2026-09-16-operation-inventory-smoke.json @@ -82,5 +82,42 @@ "degradations_by_side": { "head": [], "base": [] + }, + "after_task_10_5": { + "purpose": "Same control after binding the reachability boundary to what it limits.", + "provenance": { + "engine": "joern-4.0.625", + "queries": "c170a0d445b9bf2af2dc5ffedfbf276e89887cff963fc99cd737506a840341e2", + "policy": "422ed83b1ea418a692c722554f728b843bb34e611686a8c60de393d76c8bce46", + "core": "002f0901a9f6b27d56afa6b9e6e9db83ee5d1fed5a49a7280525f53aaa9f585f", + "semantics": "21e6641c98406181446cce13b23cd52f7b3f5196d361368e0e19dbeaae0dd581" + }, + "handler_injection_status": { + "head": [ + "completed", + "query_answered_and_arbitrated" + ], + "base": [ + "completed", + "query_answered_and_arbitrated" + ] + }, + "candidates": [ + { + "site": "app/api.js:5:handler", + "novelty": "new", + "reason": "source_connection_absent_from_comparable_base", + "witness": "req.body.value -> eval(safe) in app/api.js (line 5, 3 steps)", + "admitted": false, + "admission_reasons": [ + "capability_unavailable" + ] + } + ], + "result": { + "finding_status": "none", + "coverage_status": "incomplete" + }, + "note": "Both target questions now complete and the production comparison reaches a new verdict with an exact witness. The single remaining admission reason is capability_unavailable, which is the deliberate eligibility gate: no capability is qualified, so no alert is emitted. This is an authored one-file control, not a real vulnerability, and it qualifies nothing." } } diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index 91b505a..ef08820 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -509,25 +509,11 @@ def _scan_repository_impl( if unasked: degradations.append({"stage": "model", "reason": "budget_exhausted", "regions_unasked": len(unasked)}) - graph_gaps = tuple( - str(d["reason"]) - for d in degradations - if d.get("reason") - in { - "files_unparsed", - "cpg_empty", - "partition_file_census_incomplete", - "partition_file_census_unavailable", - "cpg_sharded", - "cross_partition_semantics_unresolved", - "vendor_semantics_unresolved", - "symlink_context_unresolved", - "frontend_unsupported", - "source_unreadable", - "ambiguous_frontend_path", - "typescript_property_support_unvalidated", - } - ) + graph_gaps = tuple(str(d["reason"]) for d in degradations if d.get("reason") in GRAPH_INTEGRITY_GAPS) + # A declared exclusion is a deliberate scope choice, not a failure to read the graph. It stays + # in coverage, and whether a particular answer needed the excluded semantics is carried by that + # question's own unresolved call destinations, not assumed for every answer in the repository. + declared_gaps = tuple(str(d["reason"]) for d in degradations if d.get("reason") in DECLARED_EXCLUSION_GAPS) for question in scope.selected: rid = question.identity.question_id if rid not in outcomes: @@ -542,7 +528,7 @@ def _scan_repository_impl( outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="change_context_incomplete") if graph_gaps and outcomes[rid].status == "completed": outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete") - scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps)))) + scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps, *declared_gaps)))) ordered_outcomes = tuple(outcomes[q.identity.question_id] for q in scope.selected) scanned = len(judged) @@ -573,6 +559,29 @@ def _scan_repository_impl( ) +# Reading the graph failed or produced something incomplete. An answer drawn from it cannot be +# trusted, so a completed outcome is demoted. +GRAPH_INTEGRITY_GAPS = frozenset( + { + "files_unparsed", + "cpg_empty", + "partition_file_census_incomplete", + "partition_file_census_unavailable", + "cpg_sharded", + "cross_partition_semantics_unresolved", + "symlink_context_unresolved", + "frontend_unsupported", + "source_unreadable", + "ambiguous_frontend_path", + "typescript_property_support_unvalidated", + } +) +# Declared, intentional exclusions. The exclusion stays physical and stays reported; it is not +# evidence that any particular first-party answer is wrong. Treating it as one demoted every +# answer on every real repository, because every real repository excludes its dependencies. +DECLARED_EXCLUSION_GAPS = frozenset({"vendor_semantics_unresolved"}) + + def _identity(unit: str, region: ScanRegion, family: str) -> QuestionIdentity: return QuestionIdentity(unit, region.language, region.path, region.function, family) diff --git a/tests/test_model_change_context.py b/tests/test_model_change_context.py index af49dbb..4109395 100644 --- a/tests/test_model_change_context.py +++ b/tests/test_model_change_context.py @@ -231,3 +231,40 @@ def test_an_unresolved_context_projection_still_demotes_the_question(tmp_path): ) assert result.question_outcomes[0].status == "unresolved" assert result.question_outcomes[0].reason == "change_context_incomplete" + + +def scan_with_layout(tmp_path, *, vendor=False, symlink=False): + """Exercise the real partition boundaries rather than injecting a reason.""" + (tmp_path / "source.php").write_text("readable\n") + if vendor: + (tmp_path / "vendor").mkdir() + (tmp_path / "vendor" / "lib.php").write_text("third party\n") + if symlink: + (tmp_path / "linked.php").symlink_to(tmp_path / "source.php") + rows = vector() + [row("source.php"), {"kind": "context_summary"}] + return scan_repository( + tmp_path, + [region("source.php", "php")], + backend=Backend({"source.php": rows}), + change_context=context(), + ) + + +def test_a_declared_vendor_exclusion_does_not_invalidate_a_first_party_answer(tmp_path): + """M1b 10.6: every real repository excludes its dependencies; that is a scope choice.""" + result = scan_with_layout(tmp_path, vendor=True) + assert "vendor_semantics_unresolved" in result.scope.unresolved_boundaries + assert result.question_outcomes[0].status == "completed" + + +def test_an_unresolved_symlink_still_demotes_the_answer(tmp_path): + result = scan_with_layout(tmp_path, symlink=True) + assert "symlink_context_unresolved" in result.scope.unresolved_boundaries + assert result.question_outcomes[0].status == "unresolved" + assert result.question_outcomes[0].reason == "graph_incomplete" + + +def test_a_declared_exclusion_beside_an_integrity_failure_still_demotes(tmp_path): + result = scan_with_layout(tmp_path, vendor=True, symlink=True) + assert {"vendor_semantics_unresolved", "symlink_context_unresolved"} <= set(result.scope.unresolved_boundaries) + assert result.question_outcomes[0].status == "unresolved"