Skip to content

Commit e57de6f

Browse files
perf: large-repo indexing pass (type refs, health, dynamic hints, dead code) + update-path fixes (#450)
* perf(ingestion): index defined symbol names once in type-ref resolution Type-ref resolution scanned graph.successors() of every candidate file for every type reference to find a type's defining file, costing O(refs x candidates x symbols_per_file) on package-fan-out languages. On hugo (896 Go files) this was 8s of an 11s graph build. Build one node-to-defined-symbol-names map per resolve pass and answer lookups by set membership; hoist per-file candidate sorting out of the per-ref loop. Only file-to-file type_use edges are added while the strategies run, so the upfront index matches live scanning. The full graph snapshot on hugo is bit-identical under a fixed hash seed; graph build drops from 10.8s to 2.7s. * perf(health): precompute test-pair basenames once per analyze pass _has_paired_test_file scanned every analyzed path for every evaluated file, doing O(files x paths x candidates) string suffix checks. On hugo (2,014 files) that was 16.5s of the health phase under cProfile. A path matches endswith(/ + candidate) or equals the candidate exactly when its /-basename equals the candidate, so one basename set built per analyze pass answers every lookup by set membership. Health full analysis on hugo drops from 18.3s to 12.9s isolated (warm duplication cache). A regression test pins the new lookup to a verbatim copy of the old scan over a corpus of tricky paths. * fix(cli): record the workspace distill verdict before init fingerprints config repowise update backfills a workspace member's distill.commands.enabled verdict into .repowise/config.yaml at the start of every run. Init left the verdict unwritten, so the first update of every workspace member saw a config-fingerprint mismatch and silently replaced the incremental path with a full health re-score of every file (47s instead of the partial update on an ~900-file Go repo). Init now runs the same backfill before computing the fingerprint, making the update-time backfill a no-op. * perf(ingestion): share one walk snapshot across dynamic-hint extractors The 18 dynamic-hint extractors issued ~40 iter_glob queries per run and each walked the tree from disk (the C++ extractor alone walks once per extension). WalkSnapshot in fs_walk replays a single pruned walk for all of them, preserving iter_glob semantics and yield order, serving subtree-rooted queries from the snapshot, and falling back to a live walk outside the tree. extract_all on hugo drops from 2.9s to 0.8s with a bit-identical edge list. * fix(pipeline): converge the update-built graph with the init-built graph The incremental rebuild skipped two edge passes the init pipeline runs: dynamic hints never executed on update, and co_changes edges were re-added only for the changed files on a graph rebuilt from scratch, so unchanged files lost their co-change edges. Metrics persisted by an update disagreed with init for identical code, and the first post-init update could never hit the centrality cache, recomputing betweenness (~11s on hugo) on every fresh-init-then-update sequence. build_repo_graph now runs the dynamic-hint registry like init does, and index_changed_files exposes the full per-file partner map its repo-wide co-change walk already computes so rebuild_graph_and_git re-adds co_changes edges for every file. A convergence test asserts node and edge set equality plus centrality-cache signature equality between init-shaped and update-shaped builds. * refactor(git): derive blame ownership from the single porcelain pass Files below the function-blame commit floor took a second blame pass through gitpython repo.blame just for ownership, while denser files already derived ownership from the porcelain BlameIndex parse. One git blame --line-porcelain invocation now serves both signals for every file; the commit floor only gates whether the BlameIndex is retained for the function biomarkers, exactly as before. Wall time is neutral (blame cost is the subprocess, measured on a ~900-file repo), but ownership is bit-identical across that corpus, the GIL-holding gitpython object parse no longer competes with concurrent ingestion, and the duplicate code path is gone. * perf(analysis): memoize the dead-code never-flag regex match The never-flag alternation compiles ~540 globs into one pattern, so a single match costs tens of microseconds, and both node-scanning detector passes ask about the same ~15k node ids. A module-level lru_cache on the match (a pure function of the path; the pattern set is a module constant) cuts the full dead-code pass from 3.1s to 1.8s on hugo, 0.4s for repeat analyses, with identical findings.
1 parent a9af432 commit e57de6f

22 files changed

Lines changed: 1031 additions & 122 deletions

packages/cli/src/repowise/cli/commands/init_cmd/command.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,15 @@ async def _index_with_resume() -> Any:
795795
)
796796
register_editor_clients(console, repo_path)
797797

798+
# Inherit the workspace's distill rewrite-hook verdict NOW, before the
799+
# config fingerprint below is computed. `repowise update` runs the same
800+
# backfill at its start; if init leaves the verdict unwritten, the first
801+
# update writes it, sees a fingerprint mismatch, and silently replaces
802+
# the incremental path with a full health re-score of every file.
803+
from repowise.cli.commands.workspace_cmd import inherit_workspace_distill_verdict
804+
805+
inherit_workspace_distill_verdict(repo_path)
806+
798807
# ---- State (always) ----
799808
# Even in index-only mode we persist `last_sync_commit` so that a
800809
# subsequent `repowise update` (e.g. fired by the post-commit hook) has

packages/core/src/repowise/core/analysis/dead_code/analyzer.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,19 @@ def _never_flag_regex(patterns: tuple[str, ...]) -> re.Pattern[str]:
339339
return re.compile("|".join(fnmatch.translate(os.path.normcase(p)) for p in patterns))
340340

341341

342+
@lru_cache(maxsize=131072)
343+
def _never_flag_regex_match(path: str) -> bool:
344+
"""Memoized ``_never_flag_regex`` match for the default pattern set.
345+
346+
The alternation has ~540 branches, so one match costs tens of
347+
microseconds, and the detector passes ask about the same node ids
348+
repeatedly (every graph node is checked by both the unreachable-files
349+
and unused-exports passes). Pure function of *path*: the pattern set is
350+
a module constant, so process-wide memoization is sound.
351+
"""
352+
return _never_flag_regex(_NEVER_FLAG_PATTERNS).match(os.path.normcase(path)) is not None
353+
354+
342355
class DeadCodeAnalyzer:
343356
"""Detects unreachable files, unused exports, unused internals, and
344357
zombie packages using the dependency graph and git metadata.
@@ -1186,7 +1199,7 @@ def _should_never_flag(self, path: str, whitelist: set[str]) -> bool:
11861199
"""Return True if path should never be flagged as dead."""
11871200
if path in whitelist:
11881201
return True
1189-
if _never_flag_regex(_NEVER_FLAG_PATTERNS).match(os.path.normcase(path)):
1202+
if _never_flag_regex_match(path):
11901203
return True
11911204
# Workspace-driven never-flag — set by language warmups that read
11921205
# the build manifest (Gradle non-``main`` source sets, Cargo

packages/core/src/repowise/core/analysis/health/engine.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -219,11 +219,24 @@ def _build_repo_commit_counts(git_meta_map: dict[str, dict]) -> dict[str, int]:
219219
return out
220220

221221

222-
def _has_paired_test_file(rel_path: str, all_paths: set[str]) -> bool:
222+
def _path_basenames(all_paths: set[str]) -> set[str]:
223+
"""Final path components of *all_paths*, split on ``/`` only.
224+
225+
A path matches ``other.endswith("/" + c) or other == c`` for a
226+
slash-free candidate filename ``c`` exactly when its ``/``-basename
227+
equals ``c``, so one precomputed basename set answers every
228+
``_has_paired_test_file`` lookup. Splitting on ``/`` only (not ``\\``)
229+
preserves that equivalence for any non-POSIX path that slips in.
230+
"""
231+
return {p.rsplit("/", 1)[-1] for p in all_paths}
232+
233+
234+
def _has_paired_test_file(rel_path: str, path_basenames: set[str]) -> bool:
223235
"""Heuristic: does any other file look like a test for *rel_path*?
224236
225237
Cheap and conservative — looks for common test-file naming
226-
conventions paired with the same basename.
238+
conventions paired with the same basename. *path_basenames* is the
239+
precomputed ``_path_basenames`` set for the analyzed file list.
227240
"""
228241
p = Path(rel_path)
229242
stem = p.stem
@@ -241,9 +254,7 @@ def _has_paired_test_file(rel_path: str, all_paths: set[str]) -> bool:
241254
f"{stem}.spec.cts",
242255
f"{stem}_test.go",
243256
}
244-
return any(
245-
any(other.endswith("/" + c) or other == c for c in candidates) for other in all_paths
246-
)
257+
return not candidates.isdisjoint(path_basenames)
247258

248259

249260
class HealthAnalyzer:
@@ -301,7 +312,7 @@ def analyze(
301312
# PageRank is optional — graph_builder.symbol_pagerank exists but
302313
# is symbol-level; we use file-level in-degree as the dependents
303314
# signal (cheap, deterministic, conservative).
304-
all_paths = {pf.file_info.path for pf in self.parsed_files}
315+
path_basenames = _path_basenames({pf.file_info.path for pf in self.parsed_files})
305316
repo_commit_counts = _build_repo_commit_counts(self.git_meta_map)
306317
graph_view: HasEdge | None = _ImportEdgeView(self.graph) if self.graph is not None else None
307318

@@ -364,7 +375,7 @@ def analyze(
364375
file_metric, file_findings = self._evaluate_file(
365376
pf,
366377
fcx,
367-
all_paths,
378+
path_basenames,
368379
disabled=file_disabled,
369380
dup_report=dup_report,
370381
graph_view=graph_view,
@@ -422,7 +433,7 @@ async def analyze_async(
422433
per_file_disabled: dict[str, set[str]] = cfg.get("per_file_disabled", {}) or {}
423434
changed_set: set[str] | None = set(changed_files) if changed_files is not None else None
424435

425-
all_paths = {pf.file_info.path for pf in self.parsed_files}
436+
path_basenames = _path_basenames({pf.file_info.path for pf in self.parsed_files})
426437
repo_commit_counts = _build_repo_commit_counts(self.git_meta_map)
427438
graph_view: HasEdge | None = _ImportEdgeView(self.graph) if self.graph is not None else None
428439

@@ -504,7 +515,7 @@ async def _one(pf: Any) -> tuple[Any, FileComplexity]:
504515
file_metric, file_findings = self._evaluate_file(
505516
pf,
506517
fcx,
507-
all_paths,
518+
path_basenames,
508519
disabled=file_disabled,
509520
dup_report=dup_report,
510521
graph_view=graph_view,
@@ -581,7 +592,7 @@ def _evaluate_file(
581592
self,
582593
pf: Any,
583594
fcx: FileComplexity,
584-
all_paths: set[str],
595+
path_basenames: set[str],
585596
*,
586597
disabled: list[str],
587598
dup_report: DuplicationReport,
@@ -627,7 +638,7 @@ def _evaluate_file(
627638
file_path=file_path,
628639
language=pf.file_info.language,
629640
nloc=nloc,
630-
has_test_file=_has_paired_test_file(file_path, all_paths)
641+
has_test_file=_has_paired_test_file(file_path, path_basenames)
631642
or _is_test_file(file_path)
632643
or _coverage_is_test_file(file_path),
633644
module=module,

packages/core/src/repowise/core/fs_walk.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242
log = structlog.get_logger(__name__)
4343

44-
__all__ = ["PRUNED_DIRS", "PRUNED_DIRS_DERIVED", "iter_glob", "walk_repo"]
44+
__all__ = ["PRUNED_DIRS", "PRUNED_DIRS_DERIVED", "WalkSnapshot", "iter_glob", "walk_repo"]
4545

4646
# Directory basenames that can NEVER hold first-party source or manifests:
4747
# VCS metadata, package/venv caches, and tool caches. Pruned at every level
@@ -196,6 +196,83 @@ def _matches(rel_posix: str, basename: str, pattern: str) -> bool:
196196
return fnmatch.fnmatch(rel_posix, pattern) or fnmatch.fnmatch(rel_posix, "*/" + pattern)
197197

198198

199+
class WalkSnapshot:
200+
"""One pruned walk, replayed for many :func:`iter_glob`-style queries.
201+
202+
Callers that issue several ``iter_glob`` queries against the same tree
203+
(the dynamic-hint extractors run ~40 of them) pay one filesystem walk
204+
here and answer every query from memory. Replay preserves
205+
:func:`iter_glob` semantics and yield order exactly: entries are stored
206+
in walk order, files before directories per directory, and ``/``-tail
207+
patterns match against paths relative to the *query* root.
208+
209+
Queries rooted below the snapshot root are served from the snapshot's
210+
subtree (os.walk pre-order keeps a subtree's entries in the same
211+
relative order a direct walk of that subtree would produce). A query
212+
root outside the snapshot tree falls back to a live walk.
213+
"""
214+
215+
__slots__ = ("_entries", "_prune_dirs", "_prune_nested_git", "root")
216+
217+
def __init__(
218+
self,
219+
root: Path | str,
220+
*,
221+
prune_dirs: frozenset[str] = PRUNED_DIRS,
222+
prune_nested_git: bool = True,
223+
) -> None:
224+
self.root = Path(root)
225+
self._prune_dirs = prune_dirs
226+
self._prune_nested_git = prune_nested_git
227+
# (dirpath, root-relative posix dir ('' for the root), dirnames, filenames)
228+
self._entries: list[tuple[Path, str, list[str], list[str]]] = []
229+
for dirpath, dirnames, filenames in walk_repo(
230+
self.root, prune_dirs=prune_dirs, prune_nested_git=prune_nested_git
231+
):
232+
rel = dirpath.relative_to(self.root).as_posix()
233+
self._entries.append(
234+
(dirpath, "" if rel == "." else rel, list(dirnames), list(filenames))
235+
)
236+
237+
def iter_glob(self, root: Path | str, patterns: str | Iterable[str]) -> Iterator[Path]:
238+
"""Replay of ``iter_glob(root, patterns)`` against the snapshot."""
239+
query_root = Path(root)
240+
pats = (patterns,) if isinstance(patterns, str) else tuple(patterns)
241+
242+
sub_rel: str | None
243+
if query_root == self.root:
244+
sub_rel = None
245+
else:
246+
try:
247+
sub_rel = query_root.relative_to(self.root).as_posix()
248+
except ValueError:
249+
# Outside the snapshot tree: serve live with the same pruning.
250+
yield from iter_glob(
251+
query_root,
252+
pats,
253+
prune_dirs=self._prune_dirs,
254+
prune_nested_git=self._prune_nested_git,
255+
)
256+
return
257+
258+
for dirpath, rel_dir, dirnames, filenames in self._entries:
259+
if sub_rel is None:
260+
q_rel = rel_dir
261+
elif rel_dir == sub_rel:
262+
q_rel = ""
263+
elif rel_dir.startswith(sub_rel + "/"):
264+
q_rel = rel_dir[len(sub_rel) + 1 :]
265+
else:
266+
continue
267+
prefix = "" if q_rel == "" else q_rel + "/"
268+
for name in filenames:
269+
if any(_matches(prefix + name, name, p) for p in pats):
270+
yield dirpath / name
271+
for name in dirnames:
272+
if any(_matches(prefix + name, name, p) for p in pats):
273+
yield dirpath / name
274+
275+
199276
def iter_glob(
200277
root: Path | str,
201278
patterns: str | Iterable[str],

packages/core/src/repowise/core/ingestion/dynamic_hints/base.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,24 @@ class DynamicEdge:
2020
class DynamicHintExtractor(ABC):
2121
name: str
2222

23+
# Shared walk snapshot, attached by HintRegistry.extract_all so the ~40
24+
# _rglob queries the extractor fleet issues replay ONE filesystem walk
25+
# instead of each walking the tree again. None -> live walk (direct
26+
# extractor use in tests keeps working unchanged).
27+
_walk_snapshot = None
28+
2329
@abstractmethod
2430
def extract(self, repo_root: Path) -> list[DynamicEdge]: ...
2531

26-
@staticmethod
27-
def _rglob(root: Path, pattern: str) -> Iterator[Path]:
32+
def _rglob(self, root: Path, pattern: str) -> Iterator[Path]:
2833
"""Pruned replacement for :py:meth:`pathlib.Path.rglob`.
2934
3035
Skips ``node_modules``, ``.venv``, ``.next``, etc. so large
3136
polyrepos don't tank the dynamic-hints phase. See
32-
:mod:`dynamic_hints._walk` for the full prune list.
37+
:mod:`dynamic_hints._walk` for the full prune list. Served from
38+
the registry's shared :class:`~repowise.core.fs_walk.WalkSnapshot`
39+
when one is attached.
3340
"""
41+
if self._walk_snapshot is not None:
42+
return self._walk_snapshot.iter_glob(root, pattern)
3443
return iter_glob(root, pattern)

packages/core/src/repowise/core/ingestion/dynamic_hints/registry.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,38 @@ def extract_all(self, repo_root: Path) -> list[DynamicEdge]:
8989
if not self._extractors:
9090
return edges
9191

92-
with ThreadPoolExecutor(max_workers=self._max_workers) as pool:
93-
futures = {pool.submit(self._run_one, ex, repo_root): ex for ex in self._extractors}
94-
for future in as_completed(futures):
95-
ex = futures[future]
96-
try:
97-
got = future.result()
98-
except Exception as e:
99-
log.warning("dynamic_hints_failed", extractor=ex.name, error=str(e))
100-
continue
101-
edges.extend(got)
102-
log.debug("dynamic_hints", extractor=ex.name, count=len(got))
92+
# One pruned walk shared by every extractor: the fleet issues ~40
93+
# _rglob queries, and each used to re-walk the tree. Snapshot
94+
# construction failure falls back to per-extractor live walks.
95+
snapshot = None
96+
try:
97+
from repowise.core.fs_walk import WalkSnapshot
98+
99+
from ._walk import PRUNED_DIRS
100+
101+
snapshot = WalkSnapshot(repo_root, prune_dirs=PRUNED_DIRS)
102+
except Exception as e: # pragma: no cover - snapshot is best-effort
103+
log.warning("dynamic_hints_snapshot_failed", error=str(e))
104+
for ex in self._extractors:
105+
ex._walk_snapshot = snapshot
106+
107+
try:
108+
with ThreadPoolExecutor(max_workers=self._max_workers) as pool:
109+
futures = {
110+
pool.submit(self._run_one, ex, repo_root): ex for ex in self._extractors
111+
}
112+
for future in as_completed(futures):
113+
ex = futures[future]
114+
try:
115+
got = future.result()
116+
except Exception as e:
117+
log.warning("dynamic_hints_failed", extractor=ex.name, error=str(e))
118+
continue
119+
edges.extend(got)
120+
log.debug("dynamic_hints", extractor=ex.name, count=len(got))
121+
finally:
122+
for ex in self._extractors:
123+
ex._walk_snapshot = None
103124
# Thread-completion order varies run-to-run; sort so downstream graph
104125
# construction (and therefore the exported KG) stays deterministic.
105126
edges.sort(key=lambda e: (e.source, e.target, e.edge_type, e.hint_source, e.weight))

packages/core/src/repowise/core/ingestion/dynamic_hints/xaml.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ class XamlDynamicHints(DynamicHintExtractor):
9595

9696
def extract(self, repo_root: Path) -> list[DynamicEdge]:
9797
# Cheap pre-flight: any XAML in the tree at all?
98-
xaml_files = list(_iter_xaml_files(repo_root))
98+
xaml_files = list(_iter_xaml_files(repo_root, rglob=self._rglob))
9999
if not xaml_files:
100100
return []
101101

@@ -172,10 +172,13 @@ def extract(self, repo_root: Path) -> list[DynamicEdge]:
172172
# Helpers (module-level so they're easy to unit-test in isolation)
173173
# ---------------------------------------------------------------------------
174174

175-
def _iter_xaml_files(repo_root: Path):
176-
from ._walk import iter_glob as _iter_glob
175+
def _iter_xaml_files(repo_root: Path, rglob=None):
176+
if rglob is None:
177+
from ._walk import iter_glob as _iter_glob
178+
179+
rglob = _iter_glob
177180
for ext in _XAML_EXTS:
178-
for path in _iter_glob(repo_root, f"*{ext}"):
181+
for path in rglob(repo_root, f"*{ext}"):
179182
try:
180183
rel = path.resolve().relative_to(repo_root.resolve())
181184
except ValueError:

packages/core/src/repowise/core/ingestion/git_indexer/file_history.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@
3030
_PR_NUMBER_RE,
3131
HOTSPOT_HALFLIFE_DAYS,
3232
)
33-
from .enrich import detect_original_path, get_blame_ownership, is_significant_commit
34-
from .function_blame import build_blame_index, ownership_from_blame
33+
from .enrich import detect_original_path, is_significant_commit
34+
from .function_blame import (
35+
_MIN_COMMITS_FOR_BLAME,
36+
build_blame_index,
37+
ownership_from_blame,
38+
)
3539
from .records import (
3640
_LOG_FORMAT,
3741
_RECORD_SEP,
@@ -384,6 +388,14 @@ def index_file(
384388
# primary-owner signal and the in-memory ``BlameIndex`` consumed by
385389
# ``function_hotspot`` / ``code_age_volatility``. Skipped for large
386390
# files — git blame is O(lines) and can block the executor thread.
391+
#
392+
# Files below the function-blame commit floor used to take a second
393+
# blame pass through gitpython's ``repo.blame`` just for ownership.
394+
# That object-building parse is pure Python, holds the GIL across the
395+
# 20-way thread fan-out, and on a large repo MOST files sit below the
396+
# floor, so it dominated the FULL git phase. The porcelain parse now
397+
# serves ownership for every file; the floor only gates whether the
398+
# BlameIndex is retained for the function biomarkers, as before.
387399
if include_blame:
388400
try:
389401
file_size = (repo_path / file_path).stat().st_size
@@ -392,25 +404,15 @@ def index_file(
392404
repo,
393405
file_path,
394406
repo_path=repo_path,
395-
commit_count_total=meta["commit_count_total"],
396407
)
397408
if blame_idx.lines:
398-
meta["blame_index"] = blame_idx
409+
if meta["commit_count_total"] >= _MIN_COMMITS_FOR_BLAME:
410+
meta["blame_index"] = blame_idx
399411
blame_name, blame_email, blame_pct = ownership_from_blame(blame_idx)
400412
if blame_name:
401413
meta["primary_owner_name"] = blame_name
402414
meta["primary_owner_email"] = blame_email
403415
meta["primary_owner_commit_pct"] = blame_pct
404-
else:
405-
# Below the in-blame commit-count floor — fall back
406-
# to the legacy gitpython ownership computation so we
407-
# don't lose owner data on small files that the
408-
# function biomarkers don't need anyway.
409-
blame_name, blame_email, blame_pct = get_blame_ownership(repo, file_path)
410-
if blame_name:
411-
meta["primary_owner_name"] = blame_name
412-
meta["primary_owner_email"] = blame_email
413-
meta["primary_owner_commit_pct"] = blame_pct
414416
except Exception:
415417
pass # blame is best-effort
416418

0 commit comments

Comments
 (0)