diffcontext 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
diffcontext/history.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
history.py — git co-change history as a retrieval signal.
|
|
3
|
+
|
|
4
|
+
The failure taxonomy (benchmarks/EVAL_V2_REPORT.md) shows a hard ceiling
|
|
5
|
+
for every static signal: cross-subsystem conceptual links (a settings
|
|
6
|
+
flag and the security check that reads it) have no call edge, no lexical
|
|
7
|
+
overlap, and no co-location — graph, BM25, and hybrid all scored 0/20 on
|
|
8
|
+
that bucket. The only signal that CAN see those pairs is the repository's
|
|
9
|
+
own history: files that changed together before tend to change together
|
|
10
|
+
again (Zimmermann et al.'s classic co-change result, here as a retrieval
|
|
11
|
+
signal rather than a recommender).
|
|
12
|
+
|
|
13
|
+
Design constraints honored:
|
|
14
|
+
* Zero runtime dependencies — plain `git log` via subprocess.
|
|
15
|
+
* Graceful degradation — no git repo / no git binary / timeout produce
|
|
16
|
+
an EMPTY index (scores_for_files returns {}), never an exception.
|
|
17
|
+
* File-level granularity — symbol-level history is noisy and expensive
|
|
18
|
+
to mine (rename/move tracking); file-level association is the
|
|
19
|
+
literature-standard compromise. The blend spreads a file's score to
|
|
20
|
+
the symbols inside it.
|
|
21
|
+
* Leakage control for evaluation — `exclude_commits` lets a benchmark
|
|
22
|
+
mine history WITHOUT the commits it is evaluating on. Scoring a
|
|
23
|
+
commit's co-change partners with an index that already contains that
|
|
24
|
+
very commit would be train-on-test leakage; the eval harness passes
|
|
25
|
+
every mined eval commit here.
|
|
26
|
+
|
|
27
|
+
Association score: for a changed file c and candidate file f,
|
|
28
|
+
assoc(f | c) = cochange_count(f, c) / change_count(c)
|
|
29
|
+
i.e. the empirical probability that a commit touching c also touched f,
|
|
30
|
+
maxed over all changed files. Pairs seen fewer than `min_cochanges`
|
|
31
|
+
times are ignored (a single shared commit is usually coincidence).
|
|
32
|
+
Sweeping commits (> max_files_per_commit files) are skipped entirely —
|
|
33
|
+
mass renames and formatting passes assert nothing about relatedness.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import subprocess
|
|
39
|
+
from collections import Counter
|
|
40
|
+
from typing import Dict, Iterable, Optional, Set
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
DEFAULT_MAX_COMMITS = 3000 # how much history to mine
|
|
45
|
+
MAX_FILES_PER_COMMIT = 25 # skip sweeping commits (mechanical churn)
|
|
46
|
+
DEFAULT_MIN_COCHANGES = 2 # pairs must co-occur at least this often
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CoChangeIndex:
|
|
50
|
+
"""
|
|
51
|
+
File-level co-change statistics mined from `git log`.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
cci = CoChangeIndex("/path/to/repo")
|
|
55
|
+
scores = cci.scores_for_files(["./src/auth.py"])
|
|
56
|
+
# {"./src/tokens.py": 0.42, ...} association in [0, 1]
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
repo_path: str,
|
|
62
|
+
max_commits: int = DEFAULT_MAX_COMMITS,
|
|
63
|
+
max_files_per_commit: int = MAX_FILES_PER_COMMIT,
|
|
64
|
+
min_cochanges: int = DEFAULT_MIN_COCHANGES,
|
|
65
|
+
exclude_commits: Optional[Set[str]] = None,
|
|
66
|
+
):
|
|
67
|
+
self.repo_path = os.path.abspath(repo_path)
|
|
68
|
+
self.min_cochanges = min_cochanges
|
|
69
|
+
self.pair_counts: Dict[str, Counter] = {} # "./a.py" -> {"./b.py": n}
|
|
70
|
+
self.file_counts: Counter = Counter() # "./a.py" -> n commits touching it
|
|
71
|
+
self.mined_commits = 0
|
|
72
|
+
|
|
73
|
+
# Excluded hashes matched on their first 10 chars so both full and
|
|
74
|
+
# abbreviated hashes (as eval harnesses store them) work.
|
|
75
|
+
excluded = {h[:10] for h in (exclude_commits or set())}
|
|
76
|
+
|
|
77
|
+
commits = self._read_history(max_commits)
|
|
78
|
+
for commit_hash, files in commits:
|
|
79
|
+
if commit_hash[:10] in excluded:
|
|
80
|
+
continue
|
|
81
|
+
files = [f for f in files if f.endswith(".py")]
|
|
82
|
+
if len(files) < 2 or len(files) > max_files_per_commit:
|
|
83
|
+
# <2: nothing co-changed; >cap: sweeping mechanical commit
|
|
84
|
+
if files:
|
|
85
|
+
self.mined_commits += 1
|
|
86
|
+
for f in files:
|
|
87
|
+
self.file_counts["./" + f] += 1
|
|
88
|
+
continue
|
|
89
|
+
self.mined_commits += 1
|
|
90
|
+
rels = ["./" + f for f in files]
|
|
91
|
+
for f in rels:
|
|
92
|
+
self.file_counts[f] += 1
|
|
93
|
+
for i, a in enumerate(rels):
|
|
94
|
+
counter_a = self.pair_counts.setdefault(a, Counter())
|
|
95
|
+
for b in rels[i + 1:]:
|
|
96
|
+
counter_a[b] += 1
|
|
97
|
+
self.pair_counts.setdefault(b, Counter())[a] += 1
|
|
98
|
+
|
|
99
|
+
def _read_history(self, max_commits: int):
|
|
100
|
+
"""[(full_hash, [file, ...]), ...] — empty list on any failure."""
|
|
101
|
+
try:
|
|
102
|
+
res = subprocess.run(
|
|
103
|
+
["git", "log", f"--max-count={max_commits}", "--no-merges",
|
|
104
|
+
"--name-only", "--format=%x00%H"],
|
|
105
|
+
cwd=self.repo_path, capture_output=True, text=True, timeout=120,
|
|
106
|
+
)
|
|
107
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
108
|
+
logger.warning("co-change mining skipped: git log failed (%s)", e)
|
|
109
|
+
return []
|
|
110
|
+
if res.returncode != 0:
|
|
111
|
+
logger.warning(
|
|
112
|
+
"co-change mining skipped: git log exited %d", res.returncode
|
|
113
|
+
)
|
|
114
|
+
return []
|
|
115
|
+
|
|
116
|
+
commits = []
|
|
117
|
+
for block in res.stdout.split("\x00"):
|
|
118
|
+
if not block.strip():
|
|
119
|
+
continue
|
|
120
|
+
lines = block.strip().split("\n")
|
|
121
|
+
commit_hash = lines[0].strip()
|
|
122
|
+
files = [ln.strip() for ln in lines[1:] if ln.strip()]
|
|
123
|
+
commits.append((commit_hash, files))
|
|
124
|
+
return commits
|
|
125
|
+
|
|
126
|
+
def scores_for_files(self, changed_files: Iterable[str]) -> Dict[str, float]:
|
|
127
|
+
"""
|
|
128
|
+
Association score in [0, 1] for every file that historically
|
|
129
|
+
co-changed with any of `changed_files` (max over the changed
|
|
130
|
+
files). Accepts "./rel/path.py" or "rel/path.py". The changed
|
|
131
|
+
files themselves are excluded from the result.
|
|
132
|
+
"""
|
|
133
|
+
changed = {
|
|
134
|
+
f if f.startswith("./") else "./" + f for f in changed_files
|
|
135
|
+
}
|
|
136
|
+
out: Dict[str, float] = {}
|
|
137
|
+
for c in changed:
|
|
138
|
+
total = self.file_counts.get(c, 0)
|
|
139
|
+
if total <= 0:
|
|
140
|
+
continue
|
|
141
|
+
for f, n in self.pair_counts.get(c, Counter()).items():
|
|
142
|
+
if f in changed or n < self.min_cochanges:
|
|
143
|
+
continue
|
|
144
|
+
assoc = n / total
|
|
145
|
+
if assoc > out.get(f, 0.0):
|
|
146
|
+
out[f] = assoc
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
def scores_for_symbols(self, changed_symbols: Iterable[str]) -> Dict[str, float]:
|
|
150
|
+
"""Convenience: same as scores_for_files, keyed off the file part
|
|
151
|
+
of changed symbol IDs ("./a.py:fn" -> "./a.py")."""
|
|
152
|
+
return self.scores_for_files(
|
|
153
|
+
{s.split(":")[0] for s in changed_symbols}
|
|
154
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""impact subpackage — blast radius, scoring, traversal."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
blast_radius.py — Find all symbols transitively affected by a change.
|
|
3
|
+
|
|
4
|
+
Given a changed symbol, walks the REVERSE graph (callers) to find
|
|
5
|
+
everything that depends on it.
|
|
6
|
+
|
|
7
|
+
Performance fix: accept an optional pre-built reverse graph so the caller
|
|
8
|
+
can build it once and reuse it across multiple symbols instead of
|
|
9
|
+
rebuilding it O(N) times.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Dict, List, Optional, Set
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_reverse_graph(graph: Dict[str, List[str]]) -> Dict[str, Set[str]]:
|
|
16
|
+
"""Build the reverse (caller) graph once; reuse for many queries."""
|
|
17
|
+
reverse: Dict[str, Set[str]] = {}
|
|
18
|
+
for caller, callees in graph.items():
|
|
19
|
+
for callee in callees:
|
|
20
|
+
reverse.setdefault(callee, set()).add(caller)
|
|
21
|
+
return reverse
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_blast_radius(
|
|
25
|
+
graph: Dict[str, List[str]],
|
|
26
|
+
changed_symbol: str,
|
|
27
|
+
reverse: Optional[Dict[str, Set[str]]] = None,
|
|
28
|
+
) -> List[str]:
|
|
29
|
+
"""
|
|
30
|
+
All functions that (transitively) call changed_symbol.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
graph: Forward call graph (caller -> [callees]).
|
|
34
|
+
changed_symbol: The symbol whose blast radius we want.
|
|
35
|
+
reverse: Pre-built reverse graph. If None it is built here
|
|
36
|
+
(correct but wasteful when called in a loop).
|
|
37
|
+
|
|
38
|
+
Uses iterative DFS with cycle detection.
|
|
39
|
+
"""
|
|
40
|
+
if reverse is None:
|
|
41
|
+
reverse = build_reverse_graph(graph)
|
|
42
|
+
|
|
43
|
+
affected: List[str] = []
|
|
44
|
+
visited: Set[str] = set()
|
|
45
|
+
|
|
46
|
+
# Iterative DFS on reverse graph
|
|
47
|
+
stack = [changed_symbol]
|
|
48
|
+
visited.add(changed_symbol)
|
|
49
|
+
|
|
50
|
+
while stack:
|
|
51
|
+
current = stack.pop()
|
|
52
|
+
for caller in reverse.get(current, ()):
|
|
53
|
+
if caller not in visited:
|
|
54
|
+
visited.add(caller)
|
|
55
|
+
affected.append(caller)
|
|
56
|
+
stack.append(caller)
|
|
57
|
+
|
|
58
|
+
return affected
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scoring.py — Impact scoring for prioritizing symbols in context.
|
|
3
|
+
|
|
4
|
+
Algorithm: Bidirectional decay propagation from changed symbols.
|
|
5
|
+
|
|
6
|
+
Forward (callees): score decays by CALLEE_DECAY per hop
|
|
7
|
+
Backward (callers): score decays by CALLER_DECAY per hop
|
|
8
|
+
|
|
9
|
+
Structural bonuses:
|
|
10
|
+
- Sibling bonus: shares a caller with a changed symbol (co-change signal),
|
|
11
|
+
weighted by 1/caller_outdegree so hub callers don’t flood the pool,
|
|
12
|
+
and log-scaled before capping to dampen compounding across many hubs.
|
|
13
|
+
- Structural bonus: log2(1 + indegree)*3 + log2(1 + outdegree),
|
|
14
|
+
hard-capped at STRUCT_MAX so mega-hub nodes (outdegree=400) don’t
|
|
15
|
+
accumulate +800 and crowd out genuinely co-changed code.
|
|
16
|
+
|
|
17
|
+
Changes vs v1:
|
|
18
|
+
1. Structural bonus capped (STRUCT_MAX=15). Uncapped bonus turned hubs
|
|
19
|
+
into permanent top-scorers regardless of actual co-change signal.
|
|
20
|
+
2. BFS propagation cutoff raised 5.0 → 8.0. Large repos (transformers)
|
|
21
|
+
have long paths; 5.0 prematurely cut valid propagation chains.
|
|
22
|
+
3. Sibling bonus log-scaled before accumulation to dampen compounding.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
from collections import deque
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Dict, List, Optional, Set
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Tunable constants — the module-level values are the tuned defaults; pass a
|
|
32
|
+
# ScoringConfig to compute_impact_scores() to override per call instead of
|
|
33
|
+
# editing these.
|
|
34
|
+
CHANGED_SCORE = 100.0
|
|
35
|
+
CALLEE_DECAY = 0.65 # lowered: callees are less likely to co-change
|
|
36
|
+
CALLER_DECAY = 0.85 # raised: callers co-change more than callees
|
|
37
|
+
CALLEE_BASE = 90.0 # direct callee of changed symbol
|
|
38
|
+
CALLER_BASE = 85.0 # direct caller
|
|
39
|
+
SIBLING_BASE = 60.0 # base for sibling contribution (divided by caller_outdegree)
|
|
40
|
+
SIBLING_MAX = 80.0 # cap: a symbol can’t accumulate more than this from siblings
|
|
41
|
+
BLAST_BASE = 30.0 # in blast_radii but not reached by BFS
|
|
42
|
+
MAX_HOPS = 8 # propagation depth limit
|
|
43
|
+
BFS_CUTOFF = 8.0 # stop propagating a path when score drops below this
|
|
44
|
+
STRUCT_MAX = 15.0 # hard cap on structural bonus (log-scaled)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ScoringConfig:
|
|
49
|
+
"""
|
|
50
|
+
Tunable weights for impact scoring. A harness running different loop
|
|
51
|
+
kinds (broad refactor check vs. narrow bug fix) can pass its own config
|
|
52
|
+
instead of editing module constants; benchmark ablations can sweep
|
|
53
|
+
configs without touching source.
|
|
54
|
+
|
|
55
|
+
Defaults are read from the module-level constants at construction time,
|
|
56
|
+
so existing tuning (and tests that monkeypatch the constants) keep
|
|
57
|
+
working unchanged.
|
|
58
|
+
"""
|
|
59
|
+
changed_score: float = None # type: ignore[assignment]
|
|
60
|
+
callee_decay: float = None # type: ignore[assignment]
|
|
61
|
+
caller_decay: float = None # type: ignore[assignment]
|
|
62
|
+
callee_base: float = None # type: ignore[assignment]
|
|
63
|
+
caller_base: float = None # type: ignore[assignment]
|
|
64
|
+
sibling_base: float = None # type: ignore[assignment]
|
|
65
|
+
sibling_max: float = None # type: ignore[assignment]
|
|
66
|
+
blast_base: float = None # type: ignore[assignment]
|
|
67
|
+
max_hops: int = None # type: ignore[assignment]
|
|
68
|
+
bfs_cutoff: float = None # type: ignore[assignment]
|
|
69
|
+
struct_max: float = None # type: ignore[assignment]
|
|
70
|
+
|
|
71
|
+
def __post_init__(self):
|
|
72
|
+
defaults = {
|
|
73
|
+
"changed_score": CHANGED_SCORE, "callee_decay": CALLEE_DECAY,
|
|
74
|
+
"caller_decay": CALLER_DECAY, "callee_base": CALLEE_BASE,
|
|
75
|
+
"caller_base": CALLER_BASE, "sibling_base": SIBLING_BASE,
|
|
76
|
+
"sibling_max": SIBLING_MAX, "blast_base": BLAST_BASE,
|
|
77
|
+
"max_hops": MAX_HOPS, "bfs_cutoff": BFS_CUTOFF,
|
|
78
|
+
"struct_max": STRUCT_MAX,
|
|
79
|
+
}
|
|
80
|
+
for name, default in defaults.items():
|
|
81
|
+
if getattr(self, name) is None:
|
|
82
|
+
object.__setattr__(self, name, default)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def describe_scoring_basis(config: "Optional[ScoringConfig]" = None) -> str:
|
|
86
|
+
"""
|
|
87
|
+
One-line, human/LLM-readable summary of the live scoring parameters.
|
|
88
|
+
|
|
89
|
+
Consumed by the context compiler's meta-header so the description can
|
|
90
|
+
never drift from the actual algorithm again (it used to be hardcoded
|
|
91
|
+
prose that went stale when constants changed). Pass the same
|
|
92
|
+
ScoringConfig used for scoring to describe a non-default run.
|
|
93
|
+
"""
|
|
94
|
+
cfg = config if config is not None else ScoringConfig()
|
|
95
|
+
return (
|
|
96
|
+
f"changed={cfg.changed_score:.0f} "
|
|
97
|
+
f"| direct_callee={cfg.callee_base:.0f} | direct_caller={cfg.caller_base:.0f} "
|
|
98
|
+
f"| 2hop_callee={cfg.callee_base * cfg.callee_decay:.1f} "
|
|
99
|
+
f"| 2hop_caller={cfg.caller_base * cfg.caller_decay:.1f} "
|
|
100
|
+
f"| struct bonus=log2-scaled, capped at {cfg.struct_max:.0f}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compute_impact_scores(
|
|
105
|
+
graph: Dict[str, List[str]],
|
|
106
|
+
changed_symbols: List[str],
|
|
107
|
+
blast_radii: Dict[str, List[str]],
|
|
108
|
+
expanded_deps: List[str] = None,
|
|
109
|
+
reverse: Optional[Dict[str, Set[str]]] = None,
|
|
110
|
+
config: Optional[ScoringConfig] = None,
|
|
111
|
+
) -> Dict[str, float]:
|
|
112
|
+
"""
|
|
113
|
+
Score every symbol's relevance to understanding the change.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
graph: Forward call graph.
|
|
117
|
+
changed_symbols: Symbols that were modified.
|
|
118
|
+
blast_radii: Pre-computed blast radii per changed symbol.
|
|
119
|
+
expanded_deps: Symbols reachable by forward dependency expansion.
|
|
120
|
+
reverse: Pre-built reverse graph (built internally if None).
|
|
121
|
+
config: Scoring weights; module-constant defaults if None.
|
|
122
|
+
|
|
123
|
+
Returns dict of symbol_id -> score (higher = more important).
|
|
124
|
+
"""
|
|
125
|
+
cfg = config if config is not None else ScoringConfig()
|
|
126
|
+
# Build reverse graph once (or reuse caller's)
|
|
127
|
+
if reverse is None:
|
|
128
|
+
reverse = {}
|
|
129
|
+
for caller, callees in graph.items():
|
|
130
|
+
for callee in callees:
|
|
131
|
+
reverse.setdefault(callee, set()).add(caller)
|
|
132
|
+
|
|
133
|
+
changed_set = set(changed_symbols)
|
|
134
|
+
scores: Dict[str, float] = {}
|
|
135
|
+
|
|
136
|
+
# ── 1. Changed symbols = 100 ──────────────────────────────────────────
|
|
137
|
+
for sym in changed_symbols:
|
|
138
|
+
scores[sym] = cfg.changed_score
|
|
139
|
+
|
|
140
|
+
# ── 2. Forward BFS (callees) with decay ──────────────────────────────
|
|
141
|
+
queue: deque = deque()
|
|
142
|
+
for sym in changed_symbols:
|
|
143
|
+
for callee in graph.get(sym, []):
|
|
144
|
+
if callee not in changed_set:
|
|
145
|
+
queue.append((callee, cfg.callee_base, 1))
|
|
146
|
+
|
|
147
|
+
visited_fwd: Set[str] = set(changed_symbols)
|
|
148
|
+
while queue:
|
|
149
|
+
node, score, hop = queue.popleft()
|
|
150
|
+
if node in visited_fwd or hop > cfg.max_hops:
|
|
151
|
+
continue
|
|
152
|
+
visited_fwd.add(node)
|
|
153
|
+
scores[node] = max(scores.get(node, 0.0), score)
|
|
154
|
+
next_score = score * cfg.callee_decay
|
|
155
|
+
if next_score >= cfg.bfs_cutoff:
|
|
156
|
+
for callee in graph.get(node, []):
|
|
157
|
+
if callee not in visited_fwd:
|
|
158
|
+
queue.append((callee, next_score, hop + 1))
|
|
159
|
+
|
|
160
|
+
# ── 3. Backward BFS (callers) with decay ─────────────────────────────
|
|
161
|
+
queue2: deque = deque()
|
|
162
|
+
for sym in changed_symbols:
|
|
163
|
+
for caller in reverse.get(sym, set()):
|
|
164
|
+
if caller not in changed_set:
|
|
165
|
+
queue2.append((caller, cfg.caller_base, 1))
|
|
166
|
+
|
|
167
|
+
visited_bwd: Set[str] = set(changed_symbols)
|
|
168
|
+
while queue2:
|
|
169
|
+
node, score, hop = queue2.popleft()
|
|
170
|
+
if node in visited_bwd or hop > cfg.max_hops:
|
|
171
|
+
continue
|
|
172
|
+
visited_bwd.add(node)
|
|
173
|
+
scores[node] = max(scores.get(node, 0.0), score)
|
|
174
|
+
next_score = score * cfg.caller_decay
|
|
175
|
+
if next_score >= cfg.bfs_cutoff:
|
|
176
|
+
for caller in reverse.get(node, set()):
|
|
177
|
+
if caller not in visited_bwd:
|
|
178
|
+
queue2.append((caller, next_score, hop + 1))
|
|
179
|
+
|
|
180
|
+
# ── 4. Specificity-weighted sibling bonus ────────────────────────────
|
|
181
|
+
sibling_accumulator: Dict[str, float] = {}
|
|
182
|
+
|
|
183
|
+
for sym in changed_symbols:
|
|
184
|
+
for caller in reverse.get(sym, set()):
|
|
185
|
+
caller_callees = graph.get(caller, [])
|
|
186
|
+
caller_outdegree = len(caller_callees)
|
|
187
|
+
if caller_outdegree <= 1:
|
|
188
|
+
continue
|
|
189
|
+
raw_contribution = cfg.sibling_base / caller_outdegree
|
|
190
|
+
contribution = math.log2(1.0 + raw_contribution)
|
|
191
|
+
for sibling in caller_callees:
|
|
192
|
+
if sibling not in changed_set and sibling != sym:
|
|
193
|
+
sibling_accumulator[sibling] = (
|
|
194
|
+
sibling_accumulator.get(sibling, 0.0) + contribution
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for sibling, bonus in sibling_accumulator.items():
|
|
198
|
+
capped_bonus = min(bonus, cfg.sibling_max)
|
|
199
|
+
scores[sibling] = scores.get(sibling, 0.0) + capped_bonus
|
|
200
|
+
|
|
201
|
+
# ── 5. Expanded deps: give them a meaningful score ────────────────────
|
|
202
|
+
if expanded_deps:
|
|
203
|
+
for sym in expanded_deps:
|
|
204
|
+
if sym not in scores:
|
|
205
|
+
scores[sym] = cfg.blast_base
|
|
206
|
+
|
|
207
|
+
# ── 6. Remaining blast radius symbols not yet scored ─────────────────
|
|
208
|
+
for sym, radius in blast_radii.items():
|
|
209
|
+
for affected in radius:
|
|
210
|
+
if affected not in scores:
|
|
211
|
+
scores[affected] = cfg.blast_base
|
|
212
|
+
|
|
213
|
+
# ── 7. Structural bonus: log-scaled, hard-capped ──────────────────────────
|
|
214
|
+
# Previous formula (indegree*2 + outdegree) was unbounded: a hub with
|
|
215
|
+
# indegree=50 got +100 structural bonus, drowning all co-change signal.
|
|
216
|
+
# log2(1+degree) grows slowly and the cfg.struct_max cap prevents run-away.
|
|
217
|
+
for sym in scores:
|
|
218
|
+
indegree = len(reverse.get(sym, set()))
|
|
219
|
+
outdegree = len(graph.get(sym, []))
|
|
220
|
+
struct_bonus = math.log2(1 + indegree) * 3 + math.log2(1 + outdegree)
|
|
221
|
+
scores[sym] += min(struct_bonus, cfg.struct_max)
|
|
222
|
+
|
|
223
|
+
return scores
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
traversal.py — Dependency expansion (forward graph walk).
|
|
3
|
+
|
|
4
|
+
Given selected symbols, walk forward edges to include their callees.
|
|
5
|
+
Supports both unbounded DFS and bounded BFS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Dict, List, Optional, Set
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def expand_dependencies(
|
|
12
|
+
graph: Dict[str, List[str]],
|
|
13
|
+
selected_symbols: List[str],
|
|
14
|
+
max_depth: Optional[int] = None,
|
|
15
|
+
) -> List[str]:
|
|
16
|
+
"""
|
|
17
|
+
Walk forward edges from selected_symbols.
|
|
18
|
+
|
|
19
|
+
max_depth=None -> full transitive closure (iterative DFS)
|
|
20
|
+
max_depth=N -> only nodes reachable within N hops (BFS)
|
|
21
|
+
"""
|
|
22
|
+
visited: Set[str] = set()
|
|
23
|
+
result: List[str] = []
|
|
24
|
+
|
|
25
|
+
if max_depth is None:
|
|
26
|
+
# Iterative DFS — safe on large repos (no recursion limit)
|
|
27
|
+
stack = list(selected_symbols)
|
|
28
|
+
while stack:
|
|
29
|
+
func = stack.pop()
|
|
30
|
+
if func in visited:
|
|
31
|
+
continue
|
|
32
|
+
visited.add(func)
|
|
33
|
+
result.append(func)
|
|
34
|
+
for dep in reversed(graph.get(func, [])):
|
|
35
|
+
if dep not in visited:
|
|
36
|
+
stack.append(dep)
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
# Bounded BFS
|
|
40
|
+
frontier = list(selected_symbols)
|
|
41
|
+
for func in frontier:
|
|
42
|
+
if func not in visited:
|
|
43
|
+
visited.add(func)
|
|
44
|
+
result.append(func)
|
|
45
|
+
|
|
46
|
+
depth = 0
|
|
47
|
+
while frontier and depth < max_depth:
|
|
48
|
+
next_frontier = []
|
|
49
|
+
for func in frontier:
|
|
50
|
+
for dep in graph.get(func, []):
|
|
51
|
+
if dep not in visited:
|
|
52
|
+
visited.add(dep)
|
|
53
|
+
result.append(dep)
|
|
54
|
+
next_frontier.append(dep)
|
|
55
|
+
frontier = next_frontier
|
|
56
|
+
depth += 1
|
|
57
|
+
|
|
58
|
+
return result
|