diff --git a/src/openultrasast/model/partitions.py b/src/openultrasast/model/partitions.py index f6099b7..b3043cd 100644 --- a/src/openultrasast/model/partitions.py +++ b/src/openultrasast/model/partitions.py @@ -51,6 +51,10 @@ class PartitionGraph: self.path_languages: dict[str, str] = {} self.partitions: tuple[PartitionCoverage, ...] = () self.boundaries: tuple[str, ...] = () + # What each boundary is ABOUT, where that is known: the paths a symlink boundary skipped (a directory + # ends in `/`) and the languages no frontend reads. A boundary absent from both reaches everything. + self.boundary_paths: dict[str, tuple[str, ...]] = {} + self.boundary_languages: dict[str, tuple[str, ...]] = {} self.unparsed: tuple[str, ...] = () self.cpg_path = Path("partitioned-cpg") @@ -167,6 +171,8 @@ def build_partitions( graph = PartitionGraph(budget) paths: dict[str, list[Path]] = {} boundaries: set[str] = set() + owned_paths: dict[str, set[str]] = {} + owned_languages: dict[str, set[str]] = {} excluded: set[str] = set() facts = layout_facts() root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) @@ -192,6 +198,8 @@ def build_partitions( if path.is_symlink() or is_vendored(relative + "/", facts): excluded.add(relative + "/") boundaries.add("symlink_context_unresolved" if path.is_symlink() else "vendor_semantics_unresolved") + if path.is_symlink(): + owned_paths.setdefault("symlink_context_unresolved", set()).add(relative + "/") dirs.remove(name) for name in files: if budget is not None and time.monotonic() >= budget.deadline_monotonic: @@ -206,6 +214,7 @@ def build_partitions( if path.is_symlink() or not path.is_file(): excluded.add(relative) boundaries.add("symlink_context_unresolved") + owned_paths.setdefault("symlink_context_unresolved", set()).add(relative) continue language = LANGUAGE_BY_EXTENSION.get(path.suffix.lower(), "unknown") if language == "unknown": @@ -234,6 +243,7 @@ def build_partitions( if frontend is None: records.append(PartitionCoverage(language, None, relative_paths, 0, "unsupported", ("frontend_unsupported",))) boundaries.add("frontend_unsupported") + owned_languages.setdefault("frontend_unsupported", set()).add(language) continue scratch = Path(tempfile.mkdtemp(prefix="ousast-input-")) graph.roots[language] = scratch @@ -283,6 +293,8 @@ def build_partitions( for record in records ) graph.boundaries = tuple(sorted(boundaries)) + graph.boundary_paths = {reason: tuple(sorted(paths)) for reason, paths in owned_paths.items()} + graph.boundary_languages = {reason: tuple(sorted(languages)) for reason, languages in owned_languages.items()} return graph except BaseException: graph.cleanup() diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index 14cd446..668f0e9 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -247,7 +247,7 @@ def _scan_repository_impl( execution_budget, {path: tuple(sorted(languages)) for path, languages in declarations.items()}, ) - degradations.extend({"stage": "model", "reason": reason} for reason in cpg.boundaries) + degradations.extend(_boundary_degradations(cpg)) else: cpg = _build(backend, root, dominant, execution_budget=execution_budget) build_seconds = round(time.monotonic() - build_started, 2) @@ -287,7 +287,14 @@ def _scan_repository_impl( if getattr(cpg, "unparsed", ()): # a backend need not carry the field unparsed = tuple(cpg.unparsed) degradations.append( - {"stage": "model", "reason": "files_unparsed", "count": len(unparsed), "files": [Path(name).name for name in unparsed[:20]]} + { + "stage": "model", + "reason": "files_unparsed", + "count": len(unparsed), + "files": [Path(name).name for name in unparsed[:20]], + # Whole, so the gap can be owned by exactly these files. + "owned_paths": [str(name).removeprefix("./") for name in unparsed], + } ) # Count calls here rather than reading a client's own meter: the budget is this driver's contract and @@ -590,25 +597,31 @@ def _scan_repository_impl( # rule is now written in the design: a gap is owned by the narrowest scope that caused it. scoped_graph_gaps: dict[str, list[str]] = {} unscoped_graph_gaps: list[str] = [] - # A census gap that NAMES its missing files is owned by those files. The first evaluation on untouched - # code (benchmarks/independent/results-v1.json) completed no question at all on eight of eleven projects, - # and on the ones traced the cause was this: `.eslintrc.js` and `webpack.config.js` absent from a - # JavaScript graph, a `contrib/apache/api.wsgi` shim absent from a Python graph, and every question of that - # language marked unresolved. A missing file makes the graph incomplete ABOUT that file; an answer about - # another file is still an answer, and the gap stays in the report as a boundary. A gap that names no files - # keeps its partition-wide reach, and one that names no partition reaches everything, as before. - file_owned_gaps: dict[str, set[str]] = {} + # A gap that NAMES what it is about is owned by exactly that. The first evaluation on untouched code + # (benchmarks/independent/results-v1.json) completed no question at all on eight of eleven projects, and + # the traced causes all had this shape: `.eslintrc.js` missing from a JavaScript graph, one symlinked + # `app.wsgi` that is not even source, one unparsed file, a Go directory in a Python project -- and every + # question of the language, or of the repository, marked unresolved. A missing, skipped or unparsed file + # makes the graph incomplete ABOUT that file (a skipped directory, about what is under it); a language no + # frontend reads, about that language. An answer about anything else is still an answer, and every gap + # stays in the report as a boundary. A gap that names nothing reaches its partition, or everything. + path_owned_gaps: set[str] = set() for item in degradations: if item.get("reason") not in GRAPH_INTEGRITY_GAPS: continue partition = str(item.get("census_language") or "") - absent_files = item.get("missing_file_names") - if partition and isinstance(absent_files, list) and absent_files and all(isinstance(name, str) for name in absent_files): - file_owned_gaps.setdefault(partition, set()).update(name.removeprefix("./") for name in absent_files) + named_paths = item.get("owned_paths") or (item.get("missing_file_names") if partition else None) + if isinstance(named_paths, list) and named_paths and all(isinstance(name, str) for name in named_paths): + path_owned_gaps.update(name.removeprefix("./") for name in named_paths) elif partition: scoped_graph_gaps.setdefault(partition, []).append(str(item["reason"])) else: unscoped_graph_gaps.append(str(item["reason"])) + + def owns(path: str) -> bool: + path = path.removeprefix("./") + return path in path_owned_gaps or any(gap.endswith("/") and path.startswith(gap) for gap in path_owned_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. @@ -636,12 +649,8 @@ def _scan_repository_impl( # own partition, and a question whose language the scan cannot place is treated as reached: an # unplaceable question is not evidence that the gap missed it. language = question.identity.language - reached = ( - bool(unscoped_graph_gaps) - or bool(scoped_graph_gaps.get(language)) - or question.identity.path.removeprefix("./") in file_owned_gaps.get(language, set()) - ) - if reached or ((scoped_graph_gaps or file_owned_gaps) and not language): + reached = bool(unscoped_graph_gaps) or bool(scoped_graph_gaps.get(language)) or owns(question.identity.path) + if reached or (scoped_graph_gaps and not language): outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete") 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) @@ -677,6 +686,23 @@ 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. # +def _boundary_degradations(cpg: Any) -> list[dict[str, Any]]: + """One degradation per boundary, carrying what it is about where the partition build recorded it: the + skipped paths of a symlink boundary, one entry per language no frontend reads. See the ownership rule in + `scan_repository`.""" + paths = getattr(cpg, "boundary_paths", {}) or {} + languages = getattr(cpg, "boundary_languages", {}) or {} + out: list[dict[str, Any]] = [] + for reason in cpg.boundaries: + if paths.get(reason): + out.append({"stage": "model", "reason": reason, "owned_paths": list(paths[reason])}) + elif languages.get(reason): + out.extend({"stage": "model", "reason": reason, "census_language": language} for language in languages[reason]) + else: + out.append({"stage": "model", "reason": reason}) + return out + + # The test for membership is whether the gap could make a reported answer WRONG, not whether it cost # coverage. An unparsed file may hold the sanitizer that would have cleared a flow this scan reports, # so `files_unparsed` belongs here and demoting is the conservative reading. A missing file the diff --git a/tests/test_model_change_context.py b/tests/test_model_change_context.py index 6e083b1..02ce365 100644 --- a/tests/test_model_change_context.py +++ b/tests/test_model_change_context.py @@ -3,7 +3,7 @@ from dataclasses import replace import pytest -from test_model_scope import Backend, region, vector +from test_model_scope import Backend, _CensusGapBackend, region, vector from openultrasast.model.contracts import ChangeContext, ChangedSpan, LineCorrespondence from openultrasast.model.scan import ScanBudget, scan_repository @@ -233,7 +233,7 @@ def test_an_unresolved_context_projection_still_demotes_the_question(tmp_path): assert result.question_outcomes[0].reason == "change_context_incomplete" -def scan_with_layout(tmp_path, *, vendor=False, symlink=False, second_language=False): +def scan_with_layout(tmp_path, *, vendor=False, symlink=False, second_language=False, unnamed_census=False): """Exercise the real partition boundaries rather than injecting a reason.""" (tmp_path / "source.php").write_text("readable\n") if vendor: @@ -247,7 +247,7 @@ def scan_with_layout(tmp_path, *, vendor=False, symlink=False, second_language=F return scan_repository( tmp_path, [region("source.php", "php")], - backend=Backend({"source.php": rows}), + backend=_CensusGapBackend(None, evidence={"source.php": rows}) if unnamed_census else Backend({"source.php": rows}), change_context=context(), ) @@ -259,17 +259,24 @@ def test_a_declared_vendor_exclusion_does_not_invalidate_a_first_party_answer(tm assert result.question_outcomes[0].status == "completed" -def test_an_unresolved_symlink_still_demotes_the_answer(tmp_path): +def test_a_skipped_symlink_is_owned_by_its_own_path(tmp_path): + """alerta's one symlink, `alerta/app.wsgi` -- not even source -- made all 261 questions unresolved. The link + is skipped, so the graph is incomplete about THAT path; an answer about a file it read is still an answer. + The boundary stays reported, and it names the 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" + gap = [d for d in result.degradations if d.get("reason") == "symlink_context_unresolved"] + assert gap and gap[0].get("owned_paths") == ["linked.php"], result.degradations + assert result.question_outcomes[0].status == "completed" 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) + """A vendor exclusion must not mask a real integrity failure: a census gap naming no file reaches its + whole partition, excluded dependencies or not.""" + result = scan_with_layout(tmp_path, vendor=True, unnamed_census=True) + assert {"vendor_semantics_unresolved", "partition_file_census_unavailable"} <= set(result.scope.unresolved_boundaries) assert result.question_outcomes[0].status == "unresolved" + assert result.question_outcomes[0].reason == "graph_incomplete" class FamilyBackend(Backend): diff --git a/tests/test_model_scope.py b/tests/test_model_scope.py index 039c548..9acd9cc 100644 --- a/tests/test_model_scope.py +++ b/tests/test_model_scope.py @@ -192,11 +192,18 @@ def test_missing_graph_files_disable_safe_pruning_and_completion(tmp_path): cpg.unparsed = ("missing.py",) return cpg - result = scan_repository(source(tmp_path, "a.py"), [region("a.py")], backend=Partial({"a.py": vector(False)}), ranking_mode="evidence") - assert len(result.scope.selected) == 1 + result = scan_repository( + source(tmp_path, "a.py", "missing.py"), + [region("a.py"), region("missing.py")], + backend=Partial({"a.py": vector(False), "missing.py": vector(False)}), + ranking_mode="evidence", + ) + # Pruning stays off: the unparsed file may hold the very sink a vector did not see. + assert len(result.scope.selected) == 2 assert result.scope.deferred == () - assert result.question_outcomes[0].status == "unresolved" - assert result.family_coverage[0].completed == 0 + # Completion is owned by the file: the unparsed one is unresolved, the one the graph read is answered. + status = {o.identity.path: o.status for o in result.question_outcomes} + assert status == {"a.py": "completed", "missing.py": "unresolved"}, status def test_cli_sample_uses_deferred_order_not_scanned_count(tmp_path): @@ -433,3 +440,16 @@ def test_a_census_gap_that_names_no_file_still_reaches_its_partition(tmp_path): regions = [region("app.py", "python"), region("other.py", "python")] result = scan_repository(root, regions, backend=_CensusGapBackend(None, evidence=evidence), population_complete=True) assert all(o.status == "unresolved" for o in result.question_outcomes), [(o.status, o.reason) for o in result.question_outcomes] + + +def test_a_language_no_frontend_reads_demotes_only_its_own_questions(tmp_path): + """wger's Python questions all went unresolved because the repository also holds files of a language no + frontend reads. That gap is about THAT language; it is reported, and it names the language.""" + root = source(tmp_path, "app.py") + (tmp_path / "tools").mkdir() + (tmp_path / "tools" / "main.go").write_text("package main\n") + result = scan_repository(root, [region("app.py")], backend=Backend({"app.py": vector()}), population_complete=True) + gap = [d for d in result.degradations if d.get("reason") == "frontend_unsupported"] + assert gap and gap[0].get("census_language") == "go", result.degradations + assert "frontend_unsupported" in result.scope.unresolved_boundaries + assert result.question_outcomes[0].status == "completed", result.question_outcomes