fix(scan): symlink, unsupported-language and unparsed-file gaps are owned by what they name

This extends dbcb230 to the remaining graph-integrity gaps. In the independent
evaluation these gaps were repository-wide:
- alerta: one symlink, `alerta/app.wsgi`, which is not even source, made 261 of 261
  questions unresolved.
- wger and Signal K: a language no frontend reads did the same.
- YesWiki: unparsed files did the same.

Now:
- the partition build records the paths a symlink boundary skipped (a directory
  ends in `/`) and the languages no frontend reads;
- `files_unparsed` carries its full paths;
- the scan demotes only questions about an owned path or under an owned directory,
  and only questions in an owned language.
Every gap stays in the report as a boundary. A gap that names nothing keeps its
partition-wide or repository-wide reach. Tier-0 pruning stays off, as before.

The tests that pinned repository-wide reach now assert ownership. The one about a
vendor exclusion masking an integrity failure keeps its intent, using a census gap
that names no file.

Retune after protocol-v1; exploratory only.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-25 14:30:36 +02:00
co-authored by Claude Opus 5.5
parent 949977c7a6
commit 65dcb46b5e
4 changed files with 96 additions and 31 deletions
+12
View File
@@ -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()
+45 -19
View File
@@ -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
+15 -8
View File
@@ -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):
+24 -4
View File
@@ -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