mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
Two vetoes M1a recorded were global where the evidence is local. Comparison required every selected base question to complete, so one unsupported family anywhere prevented an otherwise complete target comparison. Completeness is now demanded over the required scope: the counterpart question plus the dependencies the change context actually recorded for it. Every other gap stays in aggregate coverage and is still reported, never discharged. An incomplete counterpart, an incomplete recorded dependency, an unknown-ownership boundary and a truncated base scan all still yield unknown. Correspondence ambiguity was recorded file-wide and inherited by every question, so repeated unchanged lines in one file invalidated every question in the repository and blocked the whole transaction comparison. It is now decided per operation by the correspondence classifier: an operation needing an ambiguous anchor stays uncomparable, while a uniquely mapped or newly introduced operation in the same file is unaffected. The mapping is still withheld and still reported. On the NodeGoat case the ambiguity is on the target's own file, so file-level scoping would not have been enough; only per-operation resolution is. 1,347 tests passed, nine skipped. No capability enabled; rollout stays NO-GO. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
200 lines
9.1 KiB
Python
200 lines
9.1 KiB
Python
"""Change evidence informs exact existing scope, never another inclusion rule."""
|
|
|
|
from dataclasses import replace
|
|
|
|
import pytest
|
|
from test_model_scope import Backend, region, vector
|
|
|
|
from openultrasast.model.contracts import ChangeContext, ChangedSpan, LineCorrespondence
|
|
from openultrasast.model.scan import ScanBudget, scan_repository
|
|
|
|
|
|
def context(path="source.php"):
|
|
return ChangeContext(
|
|
"base",
|
|
"head",
|
|
(path.encode().hex(),),
|
|
(),
|
|
(ChangedSpan(path.encode().hex(), 2, 2, "base"),),
|
|
(),
|
|
(),
|
|
(),
|
|
(),
|
|
"filesystem-bytes-hex",
|
|
(LineCorrespondence(path.encode().hex(), path.encode().hex(), 3, 3, 2),),
|
|
)
|
|
|
|
|
|
def row(path, kind="callee", function="run"):
|
|
return {"kind": "context_method", "path": path, "function": function, "startLine": 1, "endLine": 10, "relationship": kind}
|
|
|
|
|
|
@pytest.mark.parametrize("language,ext", [("php", "php"), ("javascript", "js")])
|
|
@pytest.mark.parametrize("kind", ["entry", "callee", "field", "hook", "guard"])
|
|
def test_changed_dependency_reaches_unchanged_ranked_question(tmp_path, language, ext, kind):
|
|
source, sink = f"source.{ext}", f"sink.{ext}"
|
|
(tmp_path / source).write_text("source input\nremoved guard\nunchanged operation\n")
|
|
(tmp_path / sink).write_text("unchanged sink(input)\n")
|
|
backend = Backend({sink: vector() + [row(source, kind), {"kind": "context_summary"}]})
|
|
result = scan_repository(tmp_path, [region(sink, language)], backend=backend, change_context=context(source), population_complete=True)
|
|
assert len(result.scope.selected) == 1
|
|
relationship = result.change_context.relationships[0]
|
|
assert relationship.source.path == source
|
|
assert relationship.target == result.scope.selected[0].identity
|
|
assert relationship.kind == kind
|
|
assert any("base_span" in e for e in relationship.evidence)
|
|
assert any("affected_relationship=" in e for e in result.scope.selected[0].evidence)
|
|
assert len(backend.executed) == 1
|
|
|
|
|
|
def test_unavailable_relationships_cannot_be_completed_negative(tmp_path):
|
|
(tmp_path / "sink.php").write_text("readable\n")
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("sink.php", "php")],
|
|
backend=Backend({"sink.php": vector(False)}),
|
|
change_context=context(),
|
|
population_complete=True,
|
|
)
|
|
assert not any(q.reason == "tier_zero" for q in result.scope.deferred)
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
assert any("context_projection_unavailable" in b for b in result.scope.unresolved_boundaries)
|
|
|
|
|
|
def test_change_does_not_replace_ranker_selection(tmp_path):
|
|
for path in ("source.php", "sink.php"):
|
|
(tmp_path / path).write_text("readable\n")
|
|
regions = [region("source.php", "php", rank=0.1), region("sink.php", "php", rank=1)]
|
|
rows = {p: vector() + [row(p), {"kind": "context_summary"}] for p in ("source.php", "sink.php")}
|
|
kwargs = dict(backend=Backend(rows), budget=ScanBudget(max_regions=1), population_complete=True)
|
|
baseline = scan_repository(tmp_path, regions, **kwargs)
|
|
changed = scan_repository(tmp_path, regions, change_context=context(), **kwargs)
|
|
assert [q.identity for q in baseline.scope.selected] == [q.identity for q in changed.scope.selected]
|
|
assert [q.score for q in baseline.scope.selected] == [q.score for q in changed.scope.selected]
|
|
|
|
|
|
def test_global_boundary_survives_answer(tmp_path):
|
|
(tmp_path / "sink.php").write_text("readable\n")
|
|
ctx = replace(context(), unresolved_boundaries=("external summary missing",))
|
|
result = scan_repository(
|
|
tmp_path, [region("sink.php", "php")], backend=Backend({"sink.php": vector() + [{"kind": "context_summary"}]}), change_context=ctx
|
|
)
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
assert "external summary missing" in result.scope.unresolved_boundaries
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["../external.php", "/outside/missing.php", "missing.php"])
|
|
def test_missing_or_external_source_cannot_manufacture_relationship(tmp_path, path):
|
|
(tmp_path / "sink.php").write_text("readable\n")
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("sink.php", "php")],
|
|
backend=Backend({"sink.php": vector() + [row(path), {"kind": "context_summary"}]}),
|
|
change_context=context(),
|
|
)
|
|
assert result.change_context.relationships == ()
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["deleted_paths", "declaration_paths"])
|
|
def test_unexported_configuration_and_deleted_dependencies_are_explicit(tmp_path, field):
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
ctx = replace(context(), **{field: (b"source.php".hex(),)})
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("source.php", "php")],
|
|
backend=Backend({"source.php": vector() + [row("source.php"), {"kind": "context_summary"}]}),
|
|
change_context=ctx,
|
|
)
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
assert any("dependency_projection_unavailable" in b for b in result.scope.unresolved_boundaries)
|
|
|
|
|
|
def test_failed_build_retains_comparison_and_boundary(tmp_path):
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
ctx = replace(context(), unresolved_boundaries=("missing base",))
|
|
result = scan_repository(tmp_path, [region("source.php", "php")], backend=Backend(fail=True), change_context=ctx)
|
|
assert result.change_context == ctx
|
|
assert "missing base" in result.scope.unresolved_boundaries
|
|
|
|
|
|
def test_full_paths_do_not_merge_duplicate_basenames(tmp_path):
|
|
(tmp_path / "manifest.txt").write_text("input census\n")
|
|
for directory in ("a", "b"):
|
|
(tmp_path / directory).mkdir()
|
|
(tmp_path / directory / "source.php").write_text("readable\n")
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("b/source.php", "php")],
|
|
backend=Backend({"b/source.php": vector() + [row("b/source.php"), {"kind": "context_summary"}]}),
|
|
change_context=context("a/source.php"),
|
|
)
|
|
assert result.change_context.relationships == ()
|
|
|
|
|
|
def test_context_expansion_uses_transaction_deadline(tmp_path):
|
|
from openultrasast.model.contracts import ExecutionBudget, QuestionIdentity
|
|
from openultrasast.model.regions import affected_context
|
|
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
question = QuestionIdentity("unit", "php", "source.php", "run", "injection")
|
|
enriched, missing = affected_context(
|
|
context(), [question], {question: [row("source.php"), {"kind": "context_summary"}]}, tmp_path, ExecutionBudget(0, 1)
|
|
)
|
|
assert missing == {question}
|
|
assert enriched.relationships == ()
|
|
assert "change_context_deadline_exhausted" in enriched.unresolved_boundaries
|
|
|
|
|
|
def test_empty_method_projection_is_not_complete_negative(tmp_path):
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("source.php", "php")],
|
|
backend=Backend({"source.php": vector(False) + [{"kind": "context_summary"}]}),
|
|
change_context=context(),
|
|
)
|
|
assert result.scope.selected
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
assert any("context_scope_empty" in gap for gap in result.scope.unresolved_boundaries)
|
|
|
|
|
|
def test_missing_base_without_adapter_diagnostic_stays_incomplete(tmp_path):
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("source.php", "php")],
|
|
backend=Backend({"source.php": vector() + [row("source.php"), {"kind": "context_summary"}]}),
|
|
change_context=replace(context(), base_revision=None),
|
|
)
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
assert "comparison_base_unavailable" in result.scope.unresolved_boundaries
|
|
|
|
|
|
def test_correspondence_ambiguity_alone_does_not_make_every_question_incomplete(tmp_path):
|
|
"""M1b 10.3: repeated unchanged lines in one file no longer invalidate every question."""
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
ambiguous = replace(context(), unresolved_boundaries=(b"source.php".hex() + ":ambiguous_line_correspondence",))
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("source.php", "php")],
|
|
backend=Backend({"source.php": vector() + [row("source.php"), {"kind": "context_summary"}]}),
|
|
change_context=ambiguous,
|
|
)
|
|
assert result.question_outcomes[0].status == "completed"
|
|
# The mapping itself is still withheld and still reported in aggregate coverage.
|
|
assert any("ambiguous_line_correspondence" in gap for gap in result.change_context.unresolved_boundaries)
|
|
|
|
|
|
def test_any_other_inherited_transaction_gap_still_marks_questions_incomplete(tmp_path):
|
|
(tmp_path / "source.php").write_text("readable\n")
|
|
blocked = replace(context(), unresolved_boundaries=("snapshot:lfs_blob_unavailable",))
|
|
result = scan_repository(
|
|
tmp_path,
|
|
[region("source.php", "php")],
|
|
backend=Backend({"source.php": vector() + [row("source.php"), {"kind": "context_summary"}]}),
|
|
change_context=blocked,
|
|
)
|
|
assert result.question_outcomes[0].status == "unresolved"
|
|
assert result.question_outcomes[0].reason == "change_context_incomplete"
|