From dbcb230990b7b91af577e472fd890e7f153140a7 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:16:45 +0200 Subject: [PATCH] fix(scan): a census gap that names its missing files is owned by those files The first independent evaluation (results-v1.json) completed no question on eight of eleven projects. On the cases traced, the cause was a census gap: `.eslintrc.js` or `webpack.config.js` missing from a JavaScript graph, or `contrib/apache/api.wsgi` from a Python graph, and every question in that language was then marked unresolved. Now only questions about a named missing file are demoted. The gap is still reported as a boundary. A gap that names no files still reaches its whole partition. This is a retune after the evaluation. Per protocol-v1 it cannot be qualified on the same population. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- src/openultrasast/model/scan.py | 22 +++++++++++++--- tests/test_model_scope.py | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index 9bcc3fb..14cd446 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -590,11 +590,22 @@ 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]] = {} for item in degradations: if item.get("reason") not in GRAPH_INTEGRITY_GAPS: continue partition = str(item.get("census_language") or "") - if partition: + 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) + elif partition: scoped_graph_gaps.setdefault(partition, []).append(str(item["reason"])) else: unscoped_graph_gaps.append(str(item["reason"])) @@ -624,8 +635,13 @@ def _scan_repository_impl( # A gap with no partition is about the whole read and reaches everything. A scoped one reaches its # 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. - reached = bool(unscoped_graph_gaps) or bool(scoped_graph_gaps.get(question.identity.language)) - if reached or (scoped_graph_gaps and not question.identity.language): + 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): 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) diff --git a/tests/test_model_scope.py b/tests/test_model_scope.py index b43281e..039c548 100644 --- a/tests/test_model_scope.py +++ b/tests/test_model_scope.py @@ -388,3 +388,48 @@ def test_only_a_request_entry_treats_its_parameters_as_attacker_input() -> None: assert params["1"]["parameterSources"] == "false" # Still followed into its callees: only its own parameters stop being sources. assert params["1"]["callDepth"] == params["0"]["callDepth"] != "0" + + +class _CensusGapBackend(Backend): + """A graph whose census reads only some of its files, or reports no file names at all.""" + + def __init__(self, observed, **kwargs): + super().__init__(**kwargs) + self.observed = observed + + def build(self, root, **kwargs): + graph = super().build(root, **kwargs) + inner = graph.run_batch + + def batch(kind, requests): + answer = dict(inner(kind, requests)) + row = {"methods": "3", "files": "2"} + if self.observed is not None: + row["file_names"] = list(self.observed) + answer["__census__"] = [row] + return answer + + graph.run_batch = batch + return graph + + +def test_a_named_missing_file_demotes_only_the_questions_about_it(tmp_path): + """The first evaluation on untouched code completed no question on eight of eleven projects: a missing + `.eslintrc.js` or `api.wsgi` made every question of its language unresolved. A missing file makes the + graph incomplete about THAT file; an answer about another file is still an answer.""" + root = source(tmp_path, "app.py", "broken.py") + evidence = {"app.py": vector(), "broken.py": vector()} + regions = [region("app.py", "python"), region("broken.py", "python")] + result = scan_repository(root, regions, backend=_CensusGapBackend(["app.py"], evidence=evidence), population_complete=True) + status = {o.identity.path: (o.status, o.reason) for o in result.question_outcomes} + assert status["broken.py"] == ("unresolved", "graph_incomplete"), status + assert status["app.py"][0] == "completed", status + assert "partition_file_census_incomplete" in result.scope.unresolved_boundaries + + +def test_a_census_gap_that_names_no_file_still_reaches_its_partition(tmp_path): + root = source(tmp_path, "app.py", "other.py") + evidence = {"app.py": vector(), "other.py": vector()} + 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]