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 <[email protected]>
This commit is contained in:
Marius Ciepluch
2026-10-14 09:35:16 +02:00
co-authored by Claude Fable 5.1
parent 4063fd1562
commit 86d70831c8
3 changed files with 42 additions and 9 deletions
+3 -2
View File
@@ -250,12 +250,13 @@ def _roles(function: Any, facts: Any) -> dict[str, str]:
return roles 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) ir, text = ctx.ir(path), ctx.read(path)
facts, flow = load_obligation_facts(), load_facts() facts, flow = load_obligation_facts(), load_facts()
scoped = facts.for_language(ir.language) scoped = facts.for_language(ir.language)
if not scoped.operations and not scoped.dischargers: 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}) dominance = OrderDominance({path: text})
result = check_obligations( result = check_obligations(
irs={path: (ir, text)}, irs={path: (ir, text)},
+26 -7
View File
@@ -420,25 +420,44 @@ def _read_source(root: Path, path: str) -> str:
return resolved.read_text(encoding="utf-8", errors="replace") return resolved.read_text(encoding="utf-8", errors="replace")
def callers_of(root: Path, name: str, *, files: Iterable[str]) -> tuple[tuple[str, int], ...]: 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.""" """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) 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: def facts_for(
"""Compute repository facts at a line; supplied entry points avoid repeated mapping.""" 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) root = Path(root)
text = _read_source(root, path) text = _read_source(root, path)
language = canonical_language(detect_language(root / path)) language = canonical_language(detect_language(root / path))
spans = ranges(path, text, language) spans = ranges(path, text, language)
function = enclosing(spans, line) 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: if entry_points is None:
from .mapping import analyze_entry_points from .mapping import analyze_entry_points
entry_points = analyze_entry_points(root, [build_file_target(root, root / file) for file in files]) 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: def _digest(names: Sequence[str]) -> str:
+13
View File
@@ -135,3 +135,16 @@ def test_parse_rejection_is_not_an_empty_result(monkeypatch):
) )
with pytest.raises(ValueError, match="source parse failed"): with pytest.raises(ValueError, match="source parse failed"):
source.ranges("app.py", "def broken(", "python") 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),)