mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(model): let the project say what it ships, and rank that first
contributor-scan 2.10. Every one of libpng's twenty-five entry-point regions was a `main()` -- twenty-three under contrib/, plus example.c and pngtest.c -- and those regions carried the fifty interprocedural taint requests that dominated a 300s query phase. A library has no entry points, so entry-point-driven analysis had nothing to anchor to except its own example programs. The task said to derive this rather than hardcode `contrib/`, and a project already declares it. Makefile.am names installed targets in `lib_LTLIBRARIES` and `bin_PROGRAMS`, each target's `<target>_SOURCES` names its files, and `check_PROGRAMS`/`noinst_PROGRAMS` are automake's own word for "not installed" -- exactly the distinction wanted. Taking every `_SOURCES` would have called libpng's whole test suite shipped code, since contrib/libtests declares its sources the same way the library does. interprocedural regions 25 -> 1 taint query 300.1s -> 125.0s total query 311.9s -> 138.5s regions 121 -> 121, unchanged by design The surviving interprocedural region is contrib/tools/pngfix.c:main, which libpng genuinely installs. Two rules keep it honest. **Silence is not exclusion**: a project with no build file this can read gets None back and nothing changes, so vampi's numbers are untouched. **Not shipped is not unscanned**: those regions are still arbitrated function-locally, because a contributor editing a test must still get an answer -- what they lose is the expensive treatment and first claim on the budget. Entailed findings went 0 -> 3, and all three are false positives at contrib/gregbook/wpng.c:374, `strcpy(outname+len, ".png")`. It is safe: outname is char[256] and line 325 guards with `if ((len = strlen(inname)) > 250)`. They were not introduced so much as uncovered -- a different callDepth meant a different sink set, and the 26 fgets false positives were masking them. The structural cause is recorded as 2.12: c/injection and c/memory carry ZERO sanitizers, so no C flow can ever be discharged and every `argv -> strcpy` entails however carefully it is bounded. The discharge here is not a call but a guard that dominates the sink, which is precisely what dominance.sc already does for access control and is wired to that one family. Also adds Group 7, five tasks for stopping the same false positive twice. Two of today's three false-positive classes were one bug in three matchers -- `resolve` in `resolveUrl`, `user` in `users`, `gets` in `fgets` -- each fixed locally and each next one surviving because nobody carried the fix across. A model may group counter-examples by cause and draft an edit; only a split-respecting measurement may adopt one, because the predecessor's improve lever admitted shapes taught by the holdout pairs they then recovered and turned a -0.059 Youden into +0.059. Gates byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Opus 5
parent
67497c4d68
commit
6dc25d77e4
@@ -209,7 +209,7 @@
|
||||
- _Requirements: 5.1, 5.2_
|
||||
- _Depends: 2.10_
|
||||
|
||||
- [ ] 2.10 Do not spend the scan on example code
|
||||
- [x] 2.10 Do not spend the scan on example code
|
||||
- 22 of libpng's 30 highest-ranked regions are under `contrib/` -- the sample programs shipped with the
|
||||
library rather than the library. The budget and the 243s taint query go there first.
|
||||
- The rank already comes from the entry-point mapper; what is missing is that a path can be evidence too.
|
||||
@@ -221,6 +221,20 @@
|
||||
- _Requirements: 4.2, 8.1_
|
||||
- _Depends: 2.2_
|
||||
|
||||
- [ ] 2.12 A C flow that a bounds check governs is not a finding
|
||||
- `c/injection` and `c/memory` carry ZERO sanitizers, so no C flow can ever be discharged. Every
|
||||
`argv -> strcpy` entails however carefully it is bounded, which is why libpng reports
|
||||
`strcpy(outname+len, ".png")` at wpng.c:374 -- guarded four dozen lines earlier by
|
||||
`if ((len = strlen(inname)) > 250)` against a `char[256]`.
|
||||
- A sanitizer list will not fix this: the discharge is not a call, it is a GUARD that dominates the sink.
|
||||
That abstraction already exists and already works -- it is what `dominance.sc` does for access control --
|
||||
and it is wired to one family only. The work is to let a taint family name a guard-shaped discharge and
|
||||
route it through the arbiter that can decide one.
|
||||
- Observable: wpng.c:374 not entailed, with the pair corpus and both repositories measured either side. Do
|
||||
not do this by adding `strlen` to a sanitizer list; that would discharge the unguarded case too.
|
||||
- _Requirements: 8.1, 8.2_
|
||||
- _Depends: 2.9_
|
||||
|
||||
## Group 3 — Ship it
|
||||
|
||||
- [ ] 3.1 Contributor output
|
||||
@@ -301,3 +315,70 @@
|
||||
model rather than building groups 3–6 on an assumption.
|
||||
- Group 5 is deliberately last. The corpus is not the binding constraint today — reachability is — and 198 of
|
||||
238 OWASP pairs were left unvendored precisely because volume without readability makes every number worse.
|
||||
|
||||
## Group 7 — Stop repeating the same false positive
|
||||
|
||||
Three false-positive classes were found by hand today, and two of them were the *same defect in a different
|
||||
matcher*: `resolve` matched inside `resolveUrl`, `user` inside `users`, `gets` inside `fgets`. Each was fixed
|
||||
locally, and each next one survived because nobody carried the fix across. That is the thing to automate --
|
||||
not the judgement, the propagation.
|
||||
|
||||
The constraint that shapes every task here: **no LLM-authored patterns or policy.** A model may propose a
|
||||
hypothesis and draft a change; only a deterministic measurement may adopt one. And any loop that learns from
|
||||
the corpus must respect the train/holdout split, because the last one did not: five of eleven candidate
|
||||
shapes were taught by the holdout pairs they then "recovered", turning a -0.059 Youden into +0.059.
|
||||
|
||||
- [ ] 7.1 One matcher, asserted to be the only one
|
||||
- The three bugs above existed because three modules each grew their own token-matching rule. There is now
|
||||
one bounded matcher in `taint.sc` and one in `dominance.sc`, still separate copies of the same logic.
|
||||
- Make it one definition the queries share, and add a test that fails when a second appears -- a grep-level
|
||||
invariant is fine and is what would have caught the third instance.
|
||||
- Observable: a test that fails if any query matches a spec token with a bare `contains` or `startsWith`.
|
||||
- _Requirements: 8.1_
|
||||
|
||||
- [ ] 7.2 A counter-example ledger
|
||||
- When a finding is contradicted -- by a maintainer, by the judge with coverage, or by a declared contract
|
||||
like an OpenAPI `security` block -- record it: the site, the rung, the witness, the spec tokens that
|
||||
produced it, and the commit. That artifact is the durable part; today's three fixes left no trace a
|
||||
future regression could be checked against.
|
||||
- Each entry becomes a regression case: this site, at this commit, must not be reported at that rung.
|
||||
`fgets(buf, 256, f)` and VAmPI's `register_user` are the first two.
|
||||
- Observable: the ledger is committed, the cases run in CI offline, and re-introducing the substring match
|
||||
fails a test rather than a repository scan four weeks later.
|
||||
- _Requirements: 8.1, 10.3_
|
||||
- _Depends: 2.9_
|
||||
|
||||
- [ ] 7.3 Group the ledger by cause, and let a model propose the cause
|
||||
- Twenty-six identical findings were one defect; three separate bugs were one bug class. A deterministic
|
||||
pass should group counter-examples by what they share -- same spec token, same matcher clause, same
|
||||
family, same witness shape -- and report the groups by size.
|
||||
- **This is where the model earns its place, and only here.** Given a group, ask it for a hypothesis: what
|
||||
do these share, and what change would remove all of them? It drafts; it does not decide. The output is a
|
||||
proposed fact-table or matcher edit with a rationale, written to a review queue -- never to the ruleset,
|
||||
and never to the declared policy file.
|
||||
- Observable: run it against today's three fixes with the fixes reverted, and record how many it groups
|
||||
correctly and what it proposes. A model that cannot rediscover a known cause should not be trusted with
|
||||
an unknown one.
|
||||
- _Requirements: 8.1, 8.4_
|
||||
- _Depends: 7.2_
|
||||
|
||||
- [ ] 7.4 Adopt only what a split-respecting measurement confirms
|
||||
- A proposed edit is adopted only if it removes its counter-examples AND does not lose pair-corpus recall,
|
||||
measured on the **train** split with the holdout untouched. The predecessor's improve lever admitted a
|
||||
shape because it "recovers a currently missed holdout pair", which is how a -0.059 Youden was reported
|
||||
as +0.059.
|
||||
- The gates already exist and must stay byte-identical across an adoption, or the adoption is a
|
||||
regression wearing a fix's clothes.
|
||||
- Observable: an adoption record per accepted edit -- counter-examples removed, train recall before and
|
||||
after, holdout untouched and unread, gates identical.
|
||||
- _Requirements: 8.1, 8.2, 10.2, 10.3_
|
||||
- _Depends: 7.3_
|
||||
|
||||
- [ ] 7.5 Report what the loop cost as well as what it bought
|
||||
- A false-positive reducer that quietly trades recall is worse than none, and the trade is invisible unless
|
||||
it is measured on both sides. Today's 2.6 removed three leaks and lost no correct detection; that is the
|
||||
shape of an acceptable trade and it was only knowable because both numbers were taken.
|
||||
- Observable: every adoption records precision and recall on both repositories and the pair corpus, and a
|
||||
net-negative adoption is reverted automatically rather than argued about.
|
||||
- _Requirements: 8.2, 10.3_
|
||||
- _Depends: 7.4_
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"counts": {
|
||||
"load_bearing": 89,
|
||||
"load_bearing": 90,
|
||||
"standalone_capability": 7
|
||||
},
|
||||
"generated": "2026-09-09 (regenerated after contributor-scan 2.1 added the pinned-checkout recipes)",
|
||||
"generated": "2026-09-09 (regenerated after contributor-scan 2.10 added model/shipped.py)",
|
||||
"kept_with_a_lever_stripped": {
|
||||
"improve/evolve.py + improve/validator.py": "the design's Removed list named these whole, but each is a mix: the mechanism lever (removed) and the deterministic rule-status improve loop that `ousast improve` still runs from benchmark evidence, which has no LLM in it and is not what this feature supersedes. Deleting the files would have taken a retained capability with them."
|
||||
},
|
||||
"modules": 96,
|
||||
"modules": 97,
|
||||
"note": "Classification of every source module after the group-1 subtraction. load_bearing = reachable from a gate, the CLI, or named by a surviving requirement; standalone_capability = its own entry point or protocol, deliberately off the scan path; orphaned = no importer, no entry point, no surviving requirement -- removed, never carried.",
|
||||
"orphaned": [],
|
||||
"removed_in_this_pass": {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"caveat": "One repository, and the only one measured with a build declaration this module can read. The automake and CMake readers are first cuts; a project using bazel, meson or setuptools declares nothing to them yet and is correctly left alone rather than guessed at.",
|
||||
"delta": {
|
||||
"entailed_findings": "0 -> 3, and all three are false positives -- see below",
|
||||
"interprocedural_regions": "25 -> 1 (the survivor is contrib/tools/pngfix.c:main, which bin_PROGRAMS installs)",
|
||||
"regions": "121 -> 121, unchanged by design",
|
||||
"taint_query_seconds": "300.1 -> 125.0",
|
||||
"total_query_seconds": "311.9 -> 138.5"
|
||||
},
|
||||
"generated": "2026-09-09",
|
||||
"how": {
|
||||
"not_shipped_is_not_unscanned": "Non-shipped regions are still arbitrated, function-locally. A contributor editing a test must still get an answer about it; what they lose is the expensive interprocedural treatment and first claim on the budget.",
|
||||
"silence_is_not_exclusion": "A repository with no build file this can read gets None back and nothing changes. vampi declares nothing, so its numbers are untouched.",
|
||||
"the_project_declares_it": "Makefile.am names installed targets in lib_LTLIBRARIES and bin_PROGRAMS, and each target's <target>_SOURCES names its files. check_PROGRAMS and noinst_PROGRAMS are automake's own word for 'not installed', which is exactly the distinction wanted. Taking every _SOURCES would have called libpng's whole test suite shipped code -- contrib/libtests declares its sources the same way the library does."
|
||||
},
|
||||
"reading": "A project's own build files say what it ships, and libpng's say the library plus two installed tools. Every one of its twenty-five entry-point regions was a main() in an example program or a test tool, and those regions carried the interprocedural analysis that dominated the query phase. Ranking build-declared code first and withholding the interprocedural treatment from the rest cuts the taint query by 58% and changes no region count: not shipped is not unscanned.",
|
||||
"repo": "libpng at cf53e54ccf631d8de68a30ebb3b8eeed1d6f1a08",
|
||||
"spec": "contributor-scan 2.10 (Req 4.2, 8.1)",
|
||||
"the_three_new_findings_are_false_positives": {
|
||||
"site": "contrib/gregbook/wpng.c:374 -- strcpy(outname+len, \".png\")",
|
||||
"structural_cause": "Both C families carry ZERO sanitizers: c/injection and c/memory each have 0. No C flow can ever be discharged, and there is no notion of a bounds guard at all -- so every argv -> strcpy in any C codebase entails, however carefully it is bounded. Guard dominance is exactly the abstraction that decides this, and it is wired to access_control only. Recorded as task 2.12.",
|
||||
"why_it_is_safe": "outname is char[256], and line 325 guards with `if ((len = strlen(inname)) > 250)`. By line 374 len <= 250, so the copy writes at most 255 bytes. The bound dominates the sink.",
|
||||
"why_they_appeared_now": "They were not introduced by this change so much as uncovered by it: with callDepth 3 the sink set was different, and the 26 fgets false positives were masking them. Three identical rows for one site is task 2.9 again."
|
||||
}
|
||||
}
|
||||
@@ -917,13 +917,15 @@ def _run_model_layer(
|
||||
from .model.endpoint import resolve_chat_endpoint, resolve_models
|
||||
from .model.regions import regions_for
|
||||
from .model.scan import ScanBudget, scan_repository
|
||||
from .model.shipped import declared_sources
|
||||
|
||||
backend = resolve_cpg_backend()
|
||||
if not backend.available():
|
||||
runtime.state["degradations"].append({"stage": "model", "reason": "cpg_unavailable"})
|
||||
return [], _model_skipped("cpg_unavailable")
|
||||
|
||||
regions = regions_for(entries, targets)
|
||||
# The project's own build declaration, when it makes one, decides what the budget is spent on first.
|
||||
regions = regions_for(entries, targets, shipped=declared_sources(root))
|
||||
if not regions:
|
||||
return [], _model_skipped("no_regions")
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ Two restraints are deliberate:
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from .specs import config_specs, dominance_specs, taint_specs
|
||||
|
||||
@@ -59,9 +59,12 @@ class ScanRegion:
|
||||
families: tuple[str, ...]
|
||||
rank: float
|
||||
source: str # "entry_point" | "module_scope" | "file_fallback"
|
||||
# Does the project's own build declaration name this file? True when it declares nothing at all --
|
||||
# silence is not exclusion, and no repository is penalised for a build system we cannot read.
|
||||
shipped: bool = True
|
||||
|
||||
|
||||
def regions_for(entries: Sequence[object], targets: Sequence[object]) -> tuple[ScanRegion, ...]:
|
||||
def regions_for(entries: Sequence[object], targets: Sequence[object], *, shipped: frozenset[str] | None = None) -> tuple[ScanRegion, ...]:
|
||||
"""Regions for a repository, strongest first.
|
||||
|
||||
A file with an entry point yields regions for its handlers only — a fallback region beside them would
|
||||
@@ -142,7 +145,13 @@ def regions_for(entries: Sequence[object], targets: Sequence[object]) -> tuple[S
|
||||
regions.append(ScanRegion(path=path, function=None, language=language, families=families, rank=0.1, source="file_fallback"))
|
||||
|
||||
# A total order: rank descending, then path and function, so two runs of one repository agree.
|
||||
return tuple(sorted(regions, key=lambda r: (-r.rank, r.path, r.function or "")))
|
||||
if shipped is not None:
|
||||
regions = [replace(region, shipped=region.path in shipped) for region in regions]
|
||||
|
||||
# Shipped first, then rank. A project's own build declaration outranks any score computed here: libpng's
|
||||
# twenty-five entry points were all `main()` in example programs and test tools, and they took the
|
||||
# interprocedural analysis with them.
|
||||
return tuple(sorted(regions, key=lambda r: (not r.shipped, -r.rank, r.path, r.function or "")))
|
||||
|
||||
|
||||
def _language_of(target: object) -> str | None:
|
||||
|
||||
@@ -129,7 +129,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: (-r.rank, r.path, r.function or ""))
|
||||
ordered_regions = sorted(regions, key=lambda r: (not r.shipped, -r.rank, r.path, r.function or ""))
|
||||
|
||||
# Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet.
|
||||
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
|
||||
@@ -271,8 +271,13 @@ def _is_entry_point(region: ScanRegion) -> bool:
|
||||
|
||||
A module body is excluded even when the mapper marked it an entry point: a module has no parameters, so
|
||||
there is nothing for the parameter-source rule to mean there.
|
||||
|
||||
So is a file the project does not declare it ships. Every one of libpng's entry points was a `main()` in
|
||||
an example program or a test tool, and they carried the whole interprocedural budget with them; a
|
||||
library has no entry points, so there was nothing else for it to anchor to. Those regions are still
|
||||
arbitrated, function-locally -- not shipped is not unscanned.
|
||||
"""
|
||||
return region.source == "entry_point" and bool(region.function) and region.function != MODULE_SCOPE
|
||||
return region.source == "entry_point" and bool(region.function) and region.function != MODULE_SCOPE and region.shipped
|
||||
|
||||
|
||||
def _spec_for(family: str, language: str) -> ArbiterSpec | None:
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""What the project itself says it ships (contributor-scan Req 4.2, 8.1).
|
||||
|
||||
libpng's first repository scan spent its expensive analysis on the wrong two thirds of the tree. Every one
|
||||
of its twenty-five entry-point regions was a ``main()``, twenty-three of them under ``contrib/`` -- the
|
||||
example programs and test tools shipped beside the library -- and those regions produced the fifty
|
||||
interprocedural taint requests that dominated a 243-second query phase. A library has no entry points, so
|
||||
entry-point-driven analysis had nothing to anchor to except its examples.
|
||||
|
||||
The fix is not a list of directory names invented here. A project already declares what it ships, in its own
|
||||
build files, and that declaration is evidence in the repository rather than a convention someone assumed:
|
||||
|
||||
Makefile.am ``lib_LTLIBRARIES``/``bin_PROGRAMS`` name what is INSTALLED, and each target's
|
||||
``<target>_SOURCES`` names its files. ``check_PROGRAMS`` and ``noinst_PROGRAMS`` are
|
||||
declared by automake as not installed -- that is the project saying "these are our
|
||||
tests and internal tools", which is exactly the distinction wanted here.
|
||||
CMakeLists.txt ``add_library(png ...)``, ``target_sources(png PRIVATE ...)``
|
||||
|
||||
Two rules keep this honest:
|
||||
|
||||
* **Silence is not exclusion.** A project that declares nothing gets ``None`` back and nothing changes. No
|
||||
repository is penalised for not using a build system this module can read.
|
||||
* **Not shipped is not unscanned.** A file outside the declaration is still analysed; it is ranked below
|
||||
everything shipped and does not receive the interprocedural treatment. A contributor editing a test must
|
||||
still get an answer about it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# `libpng16_la_SOURCES = ...`, `foo_SOURCES += ...`. The value continues while lines end in a backslash.
|
||||
_AUTOMAKE_SOURCES = re.compile(r"^\s*(?P<target>\w[\w@.]*)_SOURCES\s*\+?=(?P<value>.*)$")
|
||||
# Which targets automake INSTALLS. `check_` and `noinst_` are declared not-installed and are skipped.
|
||||
_AUTOMAKE_INSTALLED = re.compile(r"^\s*(?:bin|sbin|lib|pkglib|libexec)_(?:PROGRAMS|LTLIBRARIES|LIBRARIES)\s*\+?=(?P<value>.*)$")
|
||||
_AT_SUBSTITUTION = re.compile(r"@[^@]*@")
|
||||
# `add_library(png SHARED a.c b.c)` and `target_sources(png PRIVATE a.c)`.
|
||||
_CMAKE_SOURCES = re.compile(r"\b(?:add_library|target_sources)\s*\((?P<value>[^)]*)\)", re.IGNORECASE | re.DOTALL)
|
||||
_CMAKE_KEYWORDS = frozenset({"static", "shared", "module", "interface", "object", "public", "private", "excludefromall"})
|
||||
|
||||
_SOURCE_SUFFIXES = frozenset({".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx", ".py", ".js", ".ts", ".java"})
|
||||
_MANIFESTS = ("Makefile.am", "CMakeLists.txt")
|
||||
|
||||
|
||||
def declared_sources(root: Path) -> frozenset[str] | None:
|
||||
"""Repository-relative paths the build files declare, or ``None`` when the project declares nothing.
|
||||
|
||||
Paths are resolved against the manifest's own directory, because a nested ``Makefile.am`` names its
|
||||
sources relative to itself.
|
||||
"""
|
||||
found: set[str] = set()
|
||||
seen_manifest = False
|
||||
for name in _MANIFESTS:
|
||||
for manifest in sorted(root.rglob(name)):
|
||||
if not manifest.is_file():
|
||||
continue
|
||||
seen_manifest = True
|
||||
try:
|
||||
text = manifest.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
base = manifest.parent
|
||||
for token in _tokens(name, text):
|
||||
path = (base / token).resolve()
|
||||
try:
|
||||
found.add(str(path.relative_to(root.resolve())))
|
||||
except ValueError: # a declaration pointing outside the tree
|
||||
continue
|
||||
if not seen_manifest:
|
||||
return None
|
||||
return frozenset(found)
|
||||
|
||||
|
||||
def _tokens(manifest: str, text: str) -> list[str]:
|
||||
if manifest == "Makefile.am":
|
||||
return _automake_tokens(text)
|
||||
return _cmake_tokens(text)
|
||||
|
||||
|
||||
def _automake_tokens(text: str) -> list[str]:
|
||||
"""Sources of INSTALLED targets only.
|
||||
|
||||
Taking every ``_SOURCES`` would defeat the purpose: libpng's `check_PROGRAMS` are pngstest, pngvalid and
|
||||
the rest of contrib/libtests, and they declare their sources the same way the library does.
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
installed: set[str] = set()
|
||||
for index in range(len(lines)):
|
||||
match = _AUTOMAKE_INSTALLED.match(lines[index])
|
||||
if match is not None:
|
||||
installed.update(_targets(_continued(lines, index)[0]))
|
||||
|
||||
out: list[str] = []
|
||||
index = 0
|
||||
while index < len(lines):
|
||||
match = _AUTOMAKE_SOURCES.match(lines[index])
|
||||
if match is None:
|
||||
index += 1
|
||||
continue
|
||||
value, index = _continued(lines, index)
|
||||
value = value.split("=", 1)[1] if "=" in value else value
|
||||
if _normalise(match.group("target")) in installed:
|
||||
out.extend(_files(value))
|
||||
index += 1
|
||||
return out
|
||||
|
||||
|
||||
def _continued(lines: list[str], index: int) -> tuple[str, int]:
|
||||
"""One logical line, following automake's backslash continuations, and where it ended."""
|
||||
value = lines[index]
|
||||
while value.rstrip().endswith("\\") and index + 1 < len(lines):
|
||||
index += 1
|
||||
value = value.rstrip().removesuffix("\\") + " " + lines[index]
|
||||
return value, index
|
||||
|
||||
|
||||
def _targets(line: str) -> set[str]:
|
||||
"""The target names on an installed-targets line, normalised the way a `_SOURCES` prefix appears."""
|
||||
value = line.split("=", 1)[1] if "=" in line else line
|
||||
out = set()
|
||||
for raw in value.replace("\\", " ").split():
|
||||
# `libpng16.la` declares `libpng16_la_SOURCES`; a plain program name is used as-is.
|
||||
out.add(_normalise(raw.replace(".", "_").removesuffix("_la") + "_la" if raw.endswith(".la") else raw))
|
||||
return out
|
||||
|
||||
|
||||
def _normalise(name: str) -> str:
|
||||
"""`libpng@PNGLIB_MAJOR@@PNGLIB_MINOR@_la` and `libpng16_la` must compare equal."""
|
||||
return _AT_SUBSTITUTION.sub("", name).replace(".", "_")
|
||||
|
||||
|
||||
def _cmake_tokens(text: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
for match in _CMAKE_SOURCES.finditer(text):
|
||||
for token in _files(match.group("value")):
|
||||
if token.lower() not in _CMAKE_KEYWORDS:
|
||||
out.append(token)
|
||||
return out
|
||||
|
||||
|
||||
def _files(value: str) -> list[str]:
|
||||
out = []
|
||||
for raw in value.replace("\\", " ").split():
|
||||
token = raw.strip().strip("\"'")
|
||||
# `$(wildcard ...)` and `@PNGLIB_MAJOR@` expand at build time and cannot be resolved here.
|
||||
if not token or "$" in token or "@" in token or "*" in token:
|
||||
continue
|
||||
if Path(token).suffix.lower() in _SOURCE_SUFFIXES:
|
||||
out.append(token)
|
||||
return out
|
||||
|
||||
|
||||
__all__ = ["declared_sources"]
|
||||
@@ -210,3 +210,31 @@ def test_a_module_body_is_not_offered_the_handler_families() -> None:
|
||||
assert "access_control" not in module.families
|
||||
assert "config_secrets" in module.families, "settings are exactly what module bodies carry"
|
||||
assert module.source == "module_scope"
|
||||
|
||||
|
||||
def test_a_project_that_declares_nothing_is_not_penalised() -> None:
|
||||
"""contributor-scan 2.10: silence is not exclusion.
|
||||
|
||||
`declared_sources` returns None for a repository with no build files this can read, and every region
|
||||
stays shipped. A tool that quietly deprioritised code because it could not parse a Makefile would be
|
||||
worse than one that ignores the question.
|
||||
"""
|
||||
from openultrasast.model.regions import regions_for
|
||||
|
||||
regions = regions_for([_entry()], [_target()], shipped=None)
|
||||
|
||||
assert all(region.shipped for region in regions)
|
||||
|
||||
|
||||
def test_what_the_project_does_not_ship_ranks_below_what_it_does() -> None:
|
||||
"""libpng's twenty-five entry points were all `main()` in example programs and test tools."""
|
||||
from openultrasast.model.regions import regions_for
|
||||
|
||||
entries = [_entry(path="lib.py", function="handler", access="public"), _entry(path="example.py", function="main", access="public")]
|
||||
targets = [_target(path="lib.py"), _target(path="example.py")]
|
||||
|
||||
regions = regions_for(entries, targets, shipped=frozenset({"lib.py"}))
|
||||
|
||||
ordered = [(r.path, r.shipped) for r in regions]
|
||||
assert ordered[0] == ("lib.py", True), "shipped code comes first whatever the rank says"
|
||||
assert ("example.py", False) in ordered, "not shipped is not unscanned"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""contributor-scan 2.10: what a project declares it ships, read from its own build files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
MAKEFILE_AM = """\
|
||||
check_PROGRAMS= pngtest pngvalid
|
||||
bin_PROGRAMS= pngfix
|
||||
lib_LTLIBRARIES=libpng@PNGLIB_MAJOR@@PNGLIB_MINOR@.la
|
||||
|
||||
libpng@PNGLIB_MAJOR@@PNGLIB_MINOR@_la_SOURCES = png.c pngerror.c\\
|
||||
\tpngget.c png.h
|
||||
|
||||
pngtest_SOURCES = contrib/libtests/pngtest.c
|
||||
pngvalid_SOURCES = contrib/libtests/pngvalid.c
|
||||
pngfix_SOURCES = contrib/tools/pngfix.c
|
||||
"""
|
||||
|
||||
|
||||
def test_only_installed_targets_count_as_shipped(tmp_path: Path) -> None:
|
||||
"""`check_PROGRAMS` declare their sources exactly as the library does, and are automake's own word for
|
||||
"not installed". Taking every `_SOURCES` would call libpng's whole test suite shipped code."""
|
||||
from openultrasast.model.shipped import declared_sources
|
||||
|
||||
(tmp_path / "Makefile.am").write_text(MAKEFILE_AM)
|
||||
for name in ("png.c", "pngerror.c", "pngget.c", "png.h"):
|
||||
(tmp_path / name).touch()
|
||||
|
||||
declared = declared_sources(tmp_path)
|
||||
|
||||
assert declared is not None
|
||||
assert {"png.c", "pngerror.c", "pngget.c", "png.h"} <= declared, "the library, across a continuation"
|
||||
assert "contrib/tools/pngfix.c" in declared, "bin_PROGRAMS is installed"
|
||||
assert "contrib/libtests/pngtest.c" not in declared, "check_PROGRAMS is not"
|
||||
assert "contrib/libtests/pngvalid.c" not in declared
|
||||
|
||||
|
||||
def test_a_repository_with_no_readable_build_files_declares_nothing(tmp_path: Path) -> None:
|
||||
"""None, not an empty set: the difference between "declares nothing" and "declares no sources"."""
|
||||
from openultrasast.model.shipped import declared_sources
|
||||
|
||||
(tmp_path / "app.py").write_text("x = 1\n")
|
||||
|
||||
assert declared_sources(tmp_path) is None
|
||||
|
||||
|
||||
def test_cmake_targets_are_read_and_their_keywords_are_not_files(tmp_path: Path) -> None:
|
||||
from openultrasast.model.shipped import declared_sources
|
||||
|
||||
(tmp_path / "CMakeLists.txt").write_text("add_library(png SHARED png.c png.h)\ntarget_sources(png PRIVATE extra.c)\n")
|
||||
|
||||
declared = declared_sources(tmp_path)
|
||||
|
||||
assert declared == frozenset({"png.c", "png.h", "extra.c"})
|
||||
|
||||
|
||||
def test_build_time_substitutions_are_skipped(tmp_path: Path) -> None:
|
||||
"""`$(wildcard ...)` and `@VERSION@` expand at build time; guessing at them would invent files."""
|
||||
from openultrasast.model.shipped import declared_sources
|
||||
|
||||
(tmp_path / "CMakeLists.txt").write_text("add_library(x STATIC ${GENERATED}.c real.c @[email protected] *.c)\n")
|
||||
|
||||
assert declared_sources(tmp_path) == frozenset({"real.c"})
|
||||
Reference in New Issue
Block a user