diff --git a/.kiro/specs/contributor-scan/tasks.md b/.kiro/specs/contributor-scan/tasks.md index cf62689..f6e1fce 100644 --- a/.kiro/specs/contributor-scan/tasks.md +++ b/.kiro/specs/contributor-scan/tasks.md @@ -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_ diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index 6d3e8d2..a1bd487 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -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": { diff --git a/benchmarks/measurements/2026-09-09-repo-libpng-shipped-scope.json b/benchmarks/measurements/2026-09-09-repo-libpng-shipped-scope.json new file mode 100644 index 0000000..642e2dc --- /dev/null +++ b/benchmarks/measurements/2026-09-09-repo-libpng-shipped-scope.json @@ -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 _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." + } +} diff --git a/src/openultrasast/cli.py b/src/openultrasast/cli.py index 5f481d9..6603c27 100644 --- a/src/openultrasast/cli.py +++ b/src/openultrasast/cli.py @@ -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") diff --git a/src/openultrasast/model/regions.py b/src/openultrasast/model/regions.py index 4ec2fff..88c8db5 100644 --- a/src/openultrasast/model/regions.py +++ b/src/openultrasast/model/regions.py @@ -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: diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index 5c8ac25..fff716f 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -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: diff --git a/src/openultrasast/model/shipped.py b/src/openultrasast/model/shipped.py new file mode 100644 index 0000000..77a8ac0 --- /dev/null +++ b/src/openultrasast/model/shipped.py @@ -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 + ``_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\w[\w@.]*)_SOURCES\s*\+?=(?P.*)$") +# 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.*)$") +_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[^)]*)\)", 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"] diff --git a/tests/test_model_regions.py b/tests/test_model_regions.py index fae554b..856aa9b 100644 --- a/tests/test_model_regions.py +++ b/tests/test_model_regions.py @@ -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" diff --git a/tests/test_model_shipped.py b/tests/test_model_shipped.py new file mode 100644 index 0000000..e35c44e --- /dev/null +++ b/tests/test_model_shipped.py @@ -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 @SUBST@.c *.c)\n") + + assert declared_sources(tmp_path) == frozenset({"real.c"})