feat(facts): the [[layout]] fact -- vendored trees are out, test paths are not product

Two decisions the maintainer made, as rows rather than constants. A vendored tree (vendor/, node_modules/,
third_party/) is somebody else's unit: out of the targets in preprocess and out of the graph through the
frontend's --exclude, which the scan hands to the backend because the CPG seam imports only the standard
library. A test path (tests/, test-*.php, *Test.php) stays in scope -- a test that reaches a sink still
names the sink -- but its regions are not shipped and order after the product's. Measured before the row:
207 of WP Statistics's 357 PHP files were vendored, and 14 of the 67 regions ahead of MW WP Form's CVE
were test files. The position benchmark applies both, so it measures the order the scan spends.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-11 14:27:50 +02:00
co-authored by Claude Fable 5.1
parent 9142cb1fb4
commit b821ac6e4e
9 changed files with 593 additions and 347 deletions
+8 -4
View File
@@ -278,7 +278,7 @@ class JoernBackend:
return has_cpg()
def build(self, root: Path, *, language: str = "") -> CpgResult | None:
def build(self, root: Path, *, language: str = "", exclude: Sequence[str] = ()) -> CpgResult | None:
"""Build a CPG for ``root``. ``None`` on any failure — the caller degrades, never guesses.
``language`` lets a failed ``joern-parse`` retry through the frontend directly, which is what Joern
@@ -312,7 +312,7 @@ class JoernBackend:
# A frontend whose failures are visible and retryable is worth more than one whose are not.
if language.lower() in _PREFER_FRONTEND:
self.last_failure = ""
shards, unparsed = self._build_sharded(root, scratch, language)
shards, unparsed = self._build_sharded(root, scratch, language, exclude=exclude)
if not shards and self.last_failure:
# A build that FAILED BY NAME is not one to retry through the other launcher: the graph the
# census could not load would be rebuilt the same and never asked again.
@@ -450,7 +450,9 @@ class JoernBackend:
)
return ""
def _build_sharded(self, root: Path, scratch: Path, language: str) -> tuple[tuple[Path, ...], tuple[str, ...]]:
def _build_sharded(
self, root: Path, scratch: Path, language: str, exclude: Sequence[str] = ()
) -> tuple[tuple[Path, ...], tuple[str, ...]]:
"""Build the tree, and give the files it refuses a CPG of their OWN rather than losing them.
A frontend that drops a file drops it from the only graph there is, and every question about that
@@ -465,7 +467,9 @@ class JoernBackend:
Returns the CPGs to query and the files that defeated even a shard of their own.
"""
# Excluded from the start, because one of them costs the entire graph rather than itself.
defective = self._files_with_frontend_defect(root, language)
# `exclude` is the caller's: the vendored trees the `[[layout]]` facts put out of scope. This seam
# imports only the standard library, so it is handed the names rather than reading the table.
defective = (*exclude, *self._files_with_frontend_defect(root, language))
if defective:
logger.warning(
"excluding %d file(s) under %s that php2cpg miscompiles (a `global` inside a closure): %s",
+68
View File
@@ -0,0 +1,68 @@
"""Where the `[[layout]]` facts meet a repository: which paths are vendored, which are tests.
One place, imported by the preprocessor (vendored trees leave the targets), the scan (test regions are
not product, and vendored directories leave the graph) and the position benchmark (so it measures the
order the scan spends). Facts are rows; this is only the matching.
"""
from __future__ import annotations
import fnmatch
from collections.abc import Iterable, Sequence
from pathlib import Path
from ..semantic.facts import LayoutFact, load_facts
from .regions import ScanRegion
def layout_facts(language: str | None = None) -> tuple[LayoutFact, ...]:
"""The layout rows -- for one language, or for every language when the question is about a tree."""
facts = load_facts()
return tuple(facts.for_language(language).layouts if language else facts.layouts)
def _matches(relative: str, patterns: Iterable[str]) -> bool:
"""gitignore-style: `dir/` names a directory at any depth, anything else a path or basename glob."""
parts = relative.split("/")
for pattern in patterns:
if pattern.endswith("/"):
if pattern.rstrip("/") in parts[:-1] or pattern.rstrip("/") in parts:
return True
elif fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(parts[-1], pattern):
return True
return False
def is_vendored(relative: str, facts: Sequence[LayoutFact] | None = None) -> bool:
rows = facts if facts is not None else layout_facts()
return _matches(relative, (p for fact in rows for p in fact.vendored))
def is_test_path(relative: str, facts: Sequence[LayoutFact] | None = None) -> bool:
rows = facts if facts is not None else layout_facts()
return _matches(relative, (p for fact in rows for p in fact.tests))
def vendored_directories(root: Path, facts: Sequence[LayoutFact] | None = None) -> tuple[str, ...]:
"""The vendored directories actually present under ``root``, repository-relative, shallowest first --
what a frontend's ``--exclude`` takes."""
rows = facts if facts is not None else layout_facts()
names = {p.rstrip("/") for fact in rows for p in fact.vendored if p.endswith("/")}
found: list[str] = []
for path in sorted(root.rglob("*")):
if not path.is_dir() or path.name not in names:
continue
relative = path.relative_to(root).as_posix()
if any(relative.startswith(f"{seen}/") for seen in found):
continue # already covered by a shallower vendored directory
found.append(relative)
return tuple(found)
def with_layout(regions: Sequence[ScanRegion], facts: Sequence[LayoutFact] | None = None) -> tuple[ScanRegion, ...]:
"""Test-path regions marked not shipped -- ordered after the product's, never removed. A region a
build manifest already declared shipped stays so: the manifest is the project's own word."""
from dataclasses import replace
rows = facts if facts is not None else layout_facts()
return tuple(replace(r, shipped=False) if r.shipped and is_test_path(r.path, rows) else r for r in regions)
+11 -4
View File
@@ -31,6 +31,7 @@ from .config_value import request_params as config_params
from .dominance import request_params as dominance_params
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
from .ladder import Rung
from .layout import with_layout
from .pipeline import ModelFinding, scan_region
from .regions import MODULE_SCOPE, ScanRegion
from .specs import ConfigSpec, DominanceSpec, TaintSpec, config_specs, dominance_specs, taint_specs
@@ -168,7 +169,7 @@ def scan_repository(
# Sort here rather than trusting the caller. The budget decides what goes unexamined, so the order it is
# spent in belongs to whoever holds the budget.
ordered_regions = sorted(regions, key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
ordered_regions = sorted(with_layout(regions), key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
# The hook link table, built once for the whole scan. It is a property of the repository rather than of
# any region, and it is read from the source text because the graph does not carry it: php2cpg drops a
@@ -441,11 +442,17 @@ def _dispose(cpg: object) -> None:
def _build(backend: Any, root: Path, language: str) -> Any:
"""Build through the backend, passing the language when the backend can use it."""
"""Build through the backend, passing the language and the vendored trees when the backend can use them."""
from .layout import vendored_directories
excluded = vendored_directories(root) if root.is_dir() else ()
try:
return backend.build(root, language=language)
return backend.build(root, language=language, exclude=excluded)
except TypeError: # a backend from before the retry existed, including every test double
return backend.build(root)
try:
return backend.build(root, language=language)
except TypeError:
return backend.build(root)
def _prefetched(rows: list[object]): # type: ignore[no-untyped-def]
+10 -1
View File
@@ -97,7 +97,7 @@ def enumerate_source_files(root: Path) -> list[Path]:
patterns = _load_ignore_patterns(root)
paths: list[Path] = []
for path in root.rglob("*"):
if not path.is_file() or _has_ignored_dir(root, path) or _is_ignored(root, path, patterns):
if not path.is_file() or _has_ignored_dir(root, path) or _is_ignored(root, path, patterns) or _is_vendored(root, path):
continue
if detect_language(path) != "unknown":
paths.append(path)
@@ -177,6 +177,15 @@ def _has_ignored_dir(root: Path, path: Path) -> bool:
return any(part in IGNORED_DIRS for part in relative_parts[:-1])
def _is_vendored(root: Path, path: Path) -> bool:
"""A tree the `[[layout]]` facts call somebody else's code. Out of the targets entirely: a bundled
library is a separate unit, analysed as one or not at all (contributor-scan 5.13; the decision is the
maintainer's and lives in the fact table, not here)."""
from .model.layout import is_vendored
return is_vendored(path.relative_to(root).as_posix())
def _is_ignored(root: Path, path: Path, patterns: list[str]) -> bool:
relative = path.relative_to(root).as_posix()
return any(fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(path.name, pattern) for pattern in patterns)
@@ -200,3 +200,13 @@ parameterized = true
id = "wordpress_hooks"
register = ["add_action", "add_filter", "add_shortcode"]
apply = ["apply_filters", "apply_filters_ref_array", "do_action", "do_action_ref_array"]
# What a PHP repository's paths say about scope. `vendored` trees are out of the scan and out of the graph
# (a bundled library is a separate unit); `tests` paths stay in scope but are not product, so their
# regions are ordered after the product's. Measured before this row: 207 of WP Statistics's 357 PHP files
# were vendored and two of them were 60% of its graph; 14 of the 67 regions ahead of MW WP Form's CVE
# were test-mail.php and test-mail-parser.php.
[[layout]]
id = "php_composer_and_wordpress"
vendored = ["vendor/", "node_modules/", "third_party/", "third-party/", "lib/vendor/"]
tests = ["tests/", "test/", "spec/", "test-*.php", "*Test.php", "*.test.php", "*_test.php", "phpunit/"]
+43
View File
@@ -74,6 +74,27 @@ class DispatchFact:
language: str
@dataclass(frozen=True)
class LayoutFact:
"""What a repository's PATHS say about scope, per ecosystem: which trees are somebody else's code and
which files are tests.
Both are policy, which is why they are rows and not constants. Vendored code is a separate unit --
"divide and rule": a library is analysed as a library, or not at all, never as part of the project
that bundles it -- so a `vendored` tree is out of the targets AND out of the graph. A `tests` path is
in scope but not product: its regions are ordered after the product's, never removed, because a test
that reaches a sink still names the sink.
Patterns are gitignore-style, matched against the repository-relative path and against each directory
on it: `vendor/` names any directory called vendor at any depth, `test-*.php` any file so named.
"""
id: str
vendored: tuple[str, ...]
tests: tuple[str, ...]
language: str
@dataclass(frozen=True)
class SemanticFacts:
version: str
@@ -81,6 +102,7 @@ class SemanticFacts:
sinks: tuple[SinkFact, ...]
sanitizers: tuple[SanitizerFact, ...]
dispatches: tuple[DispatchFact, ...] = ()
layouts: tuple[LayoutFact, ...] = ()
def for_language(self, language: str) -> SemanticFacts:
key = _LANGUAGE_ALIASES.get(language, language)
@@ -90,6 +112,7 @@ class SemanticFacts:
sinks=tuple(item for item in self.sinks if item.language == key),
sanitizers=tuple(item for item in self.sanitizers if item.language == key),
dispatches=tuple(item for item in self.dispatches if item.language == key),
layouts=tuple(item for item in self.layouts if item.language == key),
)
@@ -101,6 +124,7 @@ def load_facts(directory: Path | None = None) -> SemanticFacts:
sinks: list[SinkFact] = []
sanitizers: list[SanitizerFact] = []
dispatches: list[DispatchFact] = []
layouts: list[LayoutFact] = []
versions: list[str] = []
found = False
for path in sorted(root.glob("*.toml")):
@@ -115,6 +139,7 @@ def load_facts(directory: Path | None = None) -> SemanticFacts:
sinks.extend(_sinks(payload.get("sink"), language, path))
sanitizers.extend(_sanitizers(payload.get("sanitizer"), language, path))
dispatches.extend(_dispatches(payload.get("dispatch"), language, path))
layouts.extend(_layouts(payload.get("layout"), language, path))
if not found:
raise FactLoadError(f"no semantic fact files in {root}")
return SemanticFacts(
@@ -123,6 +148,7 @@ def load_facts(directory: Path | None = None) -> SemanticFacts:
sinks=tuple(sinks),
sanitizers=tuple(sanitizers),
dispatches=tuple(dispatches),
layouts=tuple(layouts),
)
@@ -143,6 +169,23 @@ def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
return facts
def _layouts(value: object, language: str, path: Path) -> list[LayoutFact]:
facts: list[LayoutFact] = []
for item in _items(value):
identifier = str(item.get("id", "")).strip()
if not identifier:
raise FactLoadError(f"layout without an id in {path}")
facts.append(
LayoutFact(
id=identifier,
vendored=_strings(item.get("vendored", []), "vendored", path),
tests=_strings(item.get("tests", []), "tests", path),
language=language,
)
)
return facts
def _items(value: object) -> list[dict[str, object]]:
if value is None:
return []