"""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" 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" 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"