mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
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:
co-authored by
Claude Opus 5.5
parent
949977c7a6
commit
65dcb46b5e
@@ -51,6 +51,10 @@ class PartitionGraph:
|
|||||||
self.path_languages: dict[str, str] = {}
|
self.path_languages: dict[str, str] = {}
|
||||||
self.partitions: tuple[PartitionCoverage, ...] = ()
|
self.partitions: tuple[PartitionCoverage, ...] = ()
|
||||||
self.boundaries: tuple[str, ...] = ()
|
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.unparsed: tuple[str, ...] = ()
|
||||||
self.cpg_path = Path("partitioned-cpg")
|
self.cpg_path = Path("partitioned-cpg")
|
||||||
|
|
||||||
@@ -167,6 +171,8 @@ def build_partitions(
|
|||||||
graph = PartitionGraph(budget)
|
graph = PartitionGraph(budget)
|
||||||
paths: dict[str, list[Path]] = {}
|
paths: dict[str, list[Path]] = {}
|
||||||
boundaries: set[str] = set()
|
boundaries: set[str] = set()
|
||||||
|
owned_paths: dict[str, set[str]] = {}
|
||||||
|
owned_languages: dict[str, set[str]] = {}
|
||||||
excluded: set[str] = set()
|
excluded: set[str] = set()
|
||||||
facts = layout_facts()
|
facts = layout_facts()
|
||||||
root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
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):
|
if path.is_symlink() or is_vendored(relative + "/", facts):
|
||||||
excluded.add(relative + "/")
|
excluded.add(relative + "/")
|
||||||
boundaries.add("symlink_context_unresolved" if path.is_symlink() else "vendor_semantics_unresolved")
|
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)
|
dirs.remove(name)
|
||||||
for name in files:
|
for name in files:
|
||||||
if budget is not None and time.monotonic() >= budget.deadline_monotonic:
|
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():
|
if path.is_symlink() or not path.is_file():
|
||||||
excluded.add(relative)
|
excluded.add(relative)
|
||||||
boundaries.add("symlink_context_unresolved")
|
boundaries.add("symlink_context_unresolved")
|
||||||
|
owned_paths.setdefault("symlink_context_unresolved", set()).add(relative)
|
||||||
continue
|
continue
|
||||||
language = LANGUAGE_BY_EXTENSION.get(path.suffix.lower(), "unknown")
|
language = LANGUAGE_BY_EXTENSION.get(path.suffix.lower(), "unknown")
|
||||||
if language == "unknown":
|
if language == "unknown":
|
||||||
@@ -234,6 +243,7 @@ def build_partitions(
|
|||||||
if frontend is None:
|
if frontend is None:
|
||||||
records.append(PartitionCoverage(language, None, relative_paths, 0, "unsupported", ("frontend_unsupported",)))
|
records.append(PartitionCoverage(language, None, relative_paths, 0, "unsupported", ("frontend_unsupported",)))
|
||||||
boundaries.add("frontend_unsupported")
|
boundaries.add("frontend_unsupported")
|
||||||
|
owned_languages.setdefault("frontend_unsupported", set()).add(language)
|
||||||
continue
|
continue
|
||||||
scratch = Path(tempfile.mkdtemp(prefix="ousast-input-"))
|
scratch = Path(tempfile.mkdtemp(prefix="ousast-input-"))
|
||||||
graph.roots[language] = scratch
|
graph.roots[language] = scratch
|
||||||
@@ -283,6 +293,8 @@ def build_partitions(
|
|||||||
for record in records
|
for record in records
|
||||||
)
|
)
|
||||||
graph.boundaries = tuple(sorted(boundaries))
|
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
|
return graph
|
||||||
except BaseException:
|
except BaseException:
|
||||||
graph.cleanup()
|
graph.cleanup()
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ def _scan_repository_impl(
|
|||||||
execution_budget,
|
execution_budget,
|
||||||
{path: tuple(sorted(languages)) for path, languages in declarations.items()},
|
{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:
|
else:
|
||||||
cpg = _build(backend, root, dominant, execution_budget=execution_budget)
|
cpg = _build(backend, root, dominant, execution_budget=execution_budget)
|
||||||
build_seconds = round(time.monotonic() - build_started, 2)
|
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
|
if getattr(cpg, "unparsed", ()): # a backend need not carry the field
|
||||||
unparsed = tuple(cpg.unparsed)
|
unparsed = tuple(cpg.unparsed)
|
||||||
degradations.append(
|
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
|
# 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.
|
# 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]] = {}
|
scoped_graph_gaps: dict[str, list[str]] = {}
|
||||||
unscoped_graph_gaps: 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
|
# A gap that NAMES what it is about is owned by exactly that. The first evaluation on untouched code
|
||||||
# code (benchmarks/independent/results-v1.json) completed no question at all on eight of eleven projects,
|
# (benchmarks/independent/results-v1.json) completed no question at all on eight of eleven projects, and
|
||||||
# and on the ones traced the cause was this: `.eslintrc.js` and `webpack.config.js` absent from a
|
# the traced causes all had this shape: `.eslintrc.js` missing from a JavaScript graph, one symlinked
|
||||||
# JavaScript graph, a `contrib/apache/api.wsgi` shim absent from a Python graph, and every question of that
|
# `app.wsgi` that is not even source, one unparsed file, a Go directory in a Python project -- and every
|
||||||
# language marked unresolved. A missing file makes the graph incomplete ABOUT that file; an answer about
|
# question of the language, or of the repository, marked unresolved. A missing, skipped or unparsed file
|
||||||
# another file is still an answer, and the gap stays in the report as a boundary. A gap that names no files
|
# makes the graph incomplete ABOUT that file (a skipped directory, about what is under it); a language no
|
||||||
# keeps its partition-wide reach, and one that names no partition reaches everything, as before.
|
# frontend reads, about that language. An answer about anything else is still an answer, and every gap
|
||||||
file_owned_gaps: dict[str, set[str]] = {}
|
# 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:
|
for item in degradations:
|
||||||
if item.get("reason") not in GRAPH_INTEGRITY_GAPS:
|
if item.get("reason") not in GRAPH_INTEGRITY_GAPS:
|
||||||
continue
|
continue
|
||||||
partition = str(item.get("census_language") or "")
|
partition = str(item.get("census_language") or "")
|
||||||
absent_files = item.get("missing_file_names")
|
named_paths = item.get("owned_paths") or (item.get("missing_file_names") if partition else None)
|
||||||
if partition and isinstance(absent_files, list) and absent_files and all(isinstance(name, str) for name in absent_files):
|
if isinstance(named_paths, list) and named_paths and all(isinstance(name, str) for name in named_paths):
|
||||||
file_owned_gaps.setdefault(partition, set()).update(name.removeprefix("./") for name in absent_files)
|
path_owned_gaps.update(name.removeprefix("./") for name in named_paths)
|
||||||
elif partition:
|
elif partition:
|
||||||
scoped_graph_gaps.setdefault(partition, []).append(str(item["reason"]))
|
scoped_graph_gaps.setdefault(partition, []).append(str(item["reason"]))
|
||||||
else:
|
else:
|
||||||
unscoped_graph_gaps.append(str(item["reason"]))
|
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
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# unplaceable question is not evidence that the gap missed it.
|
||||||
language = question.identity.language
|
language = question.identity.language
|
||||||
reached = (
|
reached = bool(unscoped_graph_gaps) or bool(scoped_graph_gaps.get(language)) or owns(question.identity.path)
|
||||||
bool(unscoped_graph_gaps)
|
if reached or (scoped_graph_gaps and not language):
|
||||||
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")
|
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))))
|
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)
|
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
|
# Reading the graph failed or produced something incomplete. An answer drawn from it cannot be
|
||||||
# trusted, so a completed outcome is demoted.
|
# 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
|
# 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,
|
# 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
|
# so `files_unparsed` belongs here and demoting is the conservative reading. A missing file the
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
|
||||||
import pytest
|
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.contracts import ChangeContext, ChangedSpan, LineCorrespondence
|
||||||
from openultrasast.model.scan import ScanBudget, scan_repository
|
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"
|
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."""
|
"""Exercise the real partition boundaries rather than injecting a reason."""
|
||||||
(tmp_path / "source.php").write_text("readable\n")
|
(tmp_path / "source.php").write_text("readable\n")
|
||||||
if vendor:
|
if vendor:
|
||||||
@@ -247,7 +247,7 @@ def scan_with_layout(tmp_path, *, vendor=False, symlink=False, second_language=F
|
|||||||
return scan_repository(
|
return scan_repository(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
[region("source.php", "php")],
|
[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(),
|
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"
|
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)
|
result = scan_with_layout(tmp_path, symlink=True)
|
||||||
assert "symlink_context_unresolved" in result.scope.unresolved_boundaries
|
assert "symlink_context_unresolved" in result.scope.unresolved_boundaries
|
||||||
assert result.question_outcomes[0].status == "unresolved"
|
gap = [d for d in result.degradations if d.get("reason") == "symlink_context_unresolved"]
|
||||||
assert result.question_outcomes[0].reason == "graph_incomplete"
|
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):
|
def test_a_declared_exclusion_beside_an_integrity_failure_still_demotes(tmp_path):
|
||||||
result = scan_with_layout(tmp_path, vendor=True, symlink=True)
|
"""A vendor exclusion must not mask a real integrity failure: a census gap naming no file reaches its
|
||||||
assert {"vendor_semantics_unresolved", "symlink_context_unresolved"} <= set(result.scope.unresolved_boundaries)
|
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].status == "unresolved"
|
||||||
|
assert result.question_outcomes[0].reason == "graph_incomplete"
|
||||||
|
|
||||||
|
|
||||||
class FamilyBackend(Backend):
|
class FamilyBackend(Backend):
|
||||||
|
|||||||
@@ -192,11 +192,18 @@ def test_missing_graph_files_disable_safe_pruning_and_completion(tmp_path):
|
|||||||
cpg.unparsed = ("missing.py",)
|
cpg.unparsed = ("missing.py",)
|
||||||
return cpg
|
return cpg
|
||||||
|
|
||||||
result = scan_repository(source(tmp_path, "a.py"), [region("a.py")], backend=Partial({"a.py": vector(False)}), ranking_mode="evidence")
|
result = scan_repository(
|
||||||
assert len(result.scope.selected) == 1
|
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.scope.deferred == ()
|
||||||
assert result.question_outcomes[0].status == "unresolved"
|
# Completion is owned by the file: the unparsed one is unresolved, the one the graph read is answered.
|
||||||
assert result.family_coverage[0].completed == 0
|
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):
|
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")]
|
regions = [region("app.py", "python"), region("other.py", "python")]
|
||||||
result = scan_repository(root, regions, backend=_CensusGapBackend(None, evidence=evidence), population_complete=True)
|
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]
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user