diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index b8a66cc..55f5a69 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -152,6 +152,21 @@ def scan_repository( # `add_filter("hook", )`. See `mapping.php_hook_callbacks`. hooks = _hook_callbacks(root, regions) + # The region cap is a COVERAGE fact, not a tuning knob nobody needs to hear about. Paid Memberships Pro + # yields 4,463 regions from 637 files, so a default budget of 500 examines 11% of the repository -- and + # a report that does not say so invites its silence to be read as a clean bill of health for the other + # 89%. `budget_exhausted` is a different thing: that is the JUDGE running out, this is the work never + # being collected at all. + if len(ordered_regions) > limits.max_regions: + degradations.append( + { + "stage": "model", + "reason": "regions_truncated", + "examined": limits.max_regions, + "total": len(ordered_regions), + } + ) + # Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet. work: list[tuple[str, ScanRegion, ArbiterSpec]] = [] for region in ordered_regions[: limits.max_regions]: diff --git a/src/openultrasast/reports.py b/src/openultrasast/reports.py index e5fadc1..be66bd7 100644 --- a/src/openultrasast/reports.py +++ b/src/openultrasast/reports.py @@ -436,6 +436,10 @@ _COVERAGE_DEGRADATIONS: dict[str, str] = { "files_unparsed": "{count} file(s) could not be parsed and are absent from the graph: {files}.", "query_failed": "A `{kind}` query failed for {requests} region(s), so those regions were not decided.", "budget_exhausted": "The model-call budget ran out with {regions_unasked} region(s) still unasked.", + "regions_truncated": ( + "Only the **{examined} highest-ranked of {total} regions** were examined. Nothing above says anything " + "about the rest of the repository." + ), "region_failed": "The region `{path}` could not be arbitrated.", } diff --git a/tests/test_model_scan.py b/tests/test_model_scan.py index 84b9299..ba09a74 100644 --- a/tests/test_model_scan.py +++ b/tests/test_model_scan.py @@ -390,3 +390,30 @@ def test_a_query_the_engine_could_not_answer_is_recorded_not_ignored() -> None: assert failures, "a query that could not be asked must be reported" assert failures[0]["kind"] == "taint" assert result.findings == () + + +def test_a_region_cap_that_hides_most_of_a_repository_is_reported() -> None: + """Paid Memberships Pro yields 4,463 regions from 637 files. A default budget of 500 examines 11% of it, + and a report that does not say so invites its silence to be read as a clean bill of health for the rest. + """ + from openultrasast.model.regions import ScanRegion + from openultrasast.model.scan import ScanBudget, scan_repository + + class _Backend: + def available(self) -> bool: + return True + + def build(self, root, *, language=""): # type: ignore[no-untyped-def] + from openultrasast.cpg.backend import CpgResult + + return CpgResult(cpg_path=Path("cpg.bin"), run=lambda query, params: []) + + regions = [ + ScanRegion(path=f"f{n}.py", function="handler", language="python", families=("injection",), rank=0.5, source="entry_point") + for n in range(30) + ] + result = scan_repository(Path("."), regions, backend=_Backend(), budget=ScanBudget(max_model_calls=0, max_regions=10)) + + truncated = [d for d in result.degradations if d.get("reason") == "regions_truncated"] + assert truncated, "the cap hid two thirds of the regions and said nothing" + assert truncated[0]["examined"] == 10 and truncated[0]["total"] == 30