diff --git a/src/openultrasast/hunt/producers.py b/src/openultrasast/hunt/producers.py index 537a3a1..f4878f9 100644 --- a/src/openultrasast/hunt/producers.py +++ b/src/openultrasast/hunt/producers.py @@ -250,12 +250,13 @@ def _roles(function: Any, facts: Any) -> dict[str, str]: return roles -def _obligations(path: str, ctx: Context) -> list[dict[str, object]]: +def _obligations(path: str, ctx: Context) -> list[dict[str, object]] | None: ir, text = ctx.ir(path), ctx.read(path) facts, flow = load_obligation_facts(), load_facts() scoped = facts.for_language(ir.language) if not scoped.operations and not scoped.dischargers: - raise _Absent("obligations_language_unsupported") + # A missing fact table is not a failed parse or a checked empty result. + return None dominance = OrderDominance({path: text}) result = check_obligations( irs={path: (ir, text)}, diff --git a/src/openultrasast/source.py b/src/openultrasast/source.py index fc2af18..6149080 100644 --- a/src/openultrasast/source.py +++ b/src/openultrasast/source.py @@ -420,25 +420,44 @@ def _read_source(root: Path, path: str) -> str: return resolved.read_text(encoding="utf-8", errors="replace") -def callers_of(root: Path, name: str, *, files: Iterable[str]) -> tuple[tuple[str, int], ...]: - """Return sorted call locations from the supplied repository-relative product files.""" +def callers_of(root: Path, name: str, *, files: Iterable[str], texts: Mapping[str, str] | None = None) -> tuple[tuple[str, int], ...]: + """Return sorted call locations from the supplied repository-relative product files. + + ``texts`` holds file contents already read by the caller; files absent from it are read here.""" root = Path(root) - return tuple((path, call.line) for path in sorted(set(files)) for call in call_sites(_read_source(root, path), (name,))) + + def text_of(path: str) -> str: + return texts[path] if texts is not None and path in texts else _read_source(root, path) + + return tuple((path, call.line) for path in sorted(set(files)) for call in call_sites(text_of(path), (name,))) -def facts_for(root: Path, path: str, line: int, *, entry_points: Sequence[EntryPointRecord] | None = None) -> SourceFacts: - """Compute repository facts at a line; supplied entry points avoid repeated mapping.""" +def facts_for( + root: Path, + path: str, + line: int, + *, + entry_points: Sequence[EntryPointRecord] | None = None, + files: Sequence[str] | None = None, + texts: Mapping[str, str] | None = None, +) -> SourceFacts: + """Compute repository facts at a line; supplied entry points, product files and texts avoid repeated work. + + A caller computing facts for many lines of one checkout (the hunter's feature table) walks the product tree + and reads it once; without ``files`` and ``texts`` every call walked and read the whole tree again.""" root = Path(root) text = _read_source(root, path) language = canonical_language(detect_language(root / path)) spans = ranges(path, text, language) function = enclosing(spans, line) - files = product_files(root) + files = list(files) if files is not None else product_files(root) if entry_points is None: from .mapping import analyze_entry_points entry_points = analyze_entry_points(root, [build_file_target(root, root / file) for file in files]) - return SourceFacts(language, function, bounds(spans, function), callers_of(root, function, files=files), tuple(entry_points)) + return SourceFacts( + language, function, bounds(spans, function), callers_of(root, function, files=files, texts=texts), tuple(entry_points) + ) def _digest(names: Sequence[str]) -> str: diff --git a/tests/test_source.py b/tests/test_source.py index c9abafb..9545a16 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -135,3 +135,16 @@ def test_parse_rejection_is_not_an_empty_result(monkeypatch): ) with pytest.raises(ValueError, match="source parse failed"): source.ranges("app.py", "def broken(", "python") + + +def test_facts_for_reuses_supplied_product_files_and_texts(tmp_path, monkeypatch): + """With files and texts supplied, facts_for neither walks the tree nor reads the files it was given.""" + from openultrasast import source + + (tmp_path / "app.py").write_text("def handler(value):\n return value\n") + (tmp_path / "other.py").write_text("from app import handler\n\n\ndef run():\n return handler(1)\n") + monkeypatch.setattr(source, "product_files", lambda root: (_ for _ in ()).throw(AssertionError("walked the tree"))) + texts = {"other.py": (tmp_path / "other.py").read_text()} + (tmp_path / "other.py").write_text("") # the supplied text, not the disk, is what callers_of reads + facts = source.facts_for(tmp_path, "app.py", 2, entry_points=[], files=["app.py", "other.py"], texts=texts) + assert facts.function == "handler" and facts.callers == (("other.py", 5),)