From 86d70831c8e3397d6d1a954229cb58233d30ac57 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:35:16 +0200 Subject: [PATCH] perf(hunt): the source producer walks and reads the product tree once per change, not once per row facts_for walked the whole product tree and re-read every product file for each candidate row; on a Laravel application one change of 33 rows took four minutes, 96 percent of it there. The producer context already holds the product file list and the texts it read for entry points, so facts_for and callers_of now accept them. Same facts, tested by a call whose tree walk raises and whose file on disk is emptied after reading. Co-Authored-By: Claude Fable 5.1 --- src/openultrasast/hunt/producers.py | 5 +++-- src/openultrasast/source.py | 33 +++++++++++++++++++++++------ tests/test_source.py | 13 ++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) 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),)