seam-code 0.3.0__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.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/analysis/impact.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Impact analysis — blast-radius assessment for a target symbol.
|
|
2
|
+
|
|
3
|
+
Given a symbol name and direction, runs the traversal walk and buckets
|
|
4
|
+
reachable symbols into risk tiers by distance.
|
|
5
|
+
|
|
6
|
+
Public interface:
|
|
7
|
+
``impact(conn, target, direction, max_depth, repo_root=None) -> ImpactResult``
|
|
8
|
+
|
|
9
|
+
Risk tiers (from PRD / CLAUDE.md):
|
|
10
|
+
WILL_BREAK — distance 1 (direct dependents — definitely affected)
|
|
11
|
+
LIKELY_AFFECTED — distance 2 (indirect dependents — probably affected)
|
|
12
|
+
MAY_NEED_TESTING — distance 3+ (transitive dependents — test to be sure)
|
|
13
|
+
|
|
14
|
+
ImpactResult structure:
|
|
15
|
+
A dict with the following top-level keys:
|
|
16
|
+
found — bool: True if the target exists as a symbol or edge endpoint;
|
|
17
|
+
False if not found in the index (unknown symbol / typo).
|
|
18
|
+
target — str: the queried symbol name.
|
|
19
|
+
<direction-key(s)> — TierGroup dicts.
|
|
20
|
+
|
|
21
|
+
For direction in {"upstream", "downstream"}:
|
|
22
|
+
{"found": bool, "target": str, <direction>: TierGroup}
|
|
23
|
+
For direction="both":
|
|
24
|
+
{"found": bool, "target": str, "upstream": TierGroup, "downstream": TierGroup}
|
|
25
|
+
|
|
26
|
+
TierGroup is a dict mapping tier name -> list of TieredEntry.
|
|
27
|
+
|
|
28
|
+
TieredEntry structure:
|
|
29
|
+
name — symbol name
|
|
30
|
+
distance — hop count from the target
|
|
31
|
+
confidence — path confidence (EXTRACTED | INFERRED | AMBIGUOUS)
|
|
32
|
+
resolved_by — Phase 5: how confidence was decided (from walk); None for pre-wired rows
|
|
33
|
+
tier — risk tier string (WILL_BREAK | LIKELY_AFFECTED | MAY_NEED_TESTING)
|
|
34
|
+
file — absolute file path if the name is an indexed symbol; None otherwise
|
|
35
|
+
|
|
36
|
+
Unknown symbol handling:
|
|
37
|
+
If the target has no edges and no matching symbol, return an ImpactResult
|
|
38
|
+
with found=False and empty TierGroups (NOT an error, NOT None).
|
|
39
|
+
This lets callers distinguish "symbol not found" from "found but no dependents".
|
|
40
|
+
|
|
41
|
+
Import hierarchy:
|
|
42
|
+
analysis.impact imports from analysis.traversal only.
|
|
43
|
+
No imports from server, cli, or query.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
import logging
|
|
47
|
+
import sqlite3
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from seam.analysis.testpaths import is_test_file
|
|
52
|
+
from seam.analysis.traversal import _SQL_VAR_BATCH, Reached, walk
|
|
53
|
+
from seam.query.names import expand_impact_seeds
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# ── Tier name constants ────────────────────────────────────────────────────────
|
|
58
|
+
# Must match exactly the tier names in CLAUDE.md d=1/2/3.
|
|
59
|
+
|
|
60
|
+
TIER_WILL_BREAK = "WILL_BREAK" # distance == 1
|
|
61
|
+
TIER_LIKELY_AFFECTED = "LIKELY_AFFECTED" # distance == 2
|
|
62
|
+
TIER_MAY_NEED_TESTING = "MAY_NEED_TESTING" # distance >= 3
|
|
63
|
+
|
|
64
|
+
# Depth bounds for clamping user input.
|
|
65
|
+
_MIN_DEPTH = 1
|
|
66
|
+
_MAX_DEPTH = 10
|
|
67
|
+
_DEFAULT_DEPTH = 3
|
|
68
|
+
|
|
69
|
+
# Valid direction values.
|
|
70
|
+
_VALID_DIRECTIONS = {"upstream", "downstream", "both"}
|
|
71
|
+
|
|
72
|
+
# ── Public types ───────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
# TieredEntry is a plain dict rather than a TypedDict so that the `file: str | None`
|
|
75
|
+
# field can coexist with the other `str`/`int` fields without mypy complaints about
|
|
76
|
+
# mixed-type TypedDicts. The shape is documented below and in CONTRACT.md.
|
|
77
|
+
#
|
|
78
|
+
# TieredEntry shape:
|
|
79
|
+
# name (str) — symbol name
|
|
80
|
+
# distance (int) — hops from the target
|
|
81
|
+
# confidence (str) — EXTRACTED | INFERRED | AMBIGUOUS
|
|
82
|
+
# tier (str) — WILL_BREAK | LIKELY_AFFECTED | MAY_NEED_TESTING
|
|
83
|
+
# file (str | None) — absolute path if name is an indexed symbol; else None
|
|
84
|
+
# kind (str) — E4: edge kind of the final hop of the winning path.
|
|
85
|
+
# Full vocabulary: call | import | extends | implements |
|
|
86
|
+
# instantiates | holds | reads | writes | uses.
|
|
87
|
+
# Empty string for degenerate / pre-E4 cases (never absent).
|
|
88
|
+
# synthesized_by (str | None) — E4: synthesis channel name when the final hop is
|
|
89
|
+
# heuristic (e.g. 'interface-override'), or None when
|
|
90
|
+
# the final hop is statically extracted. Same null-contract
|
|
91
|
+
# as resolved_by/best_candidate: null ≡ static.
|
|
92
|
+
|
|
93
|
+
# TierGroup maps tier-name -> list of entries for that tier.
|
|
94
|
+
# e.g. {"WILL_BREAK": [...], "LIKELY_AFFECTED": [...], "MAY_NEED_TESTING": [...]}
|
|
95
|
+
TierGroup = dict[str, list[dict[str, Any]]]
|
|
96
|
+
|
|
97
|
+
# ImpactResult: a plain dict with keys: found, target, and direction-key(s).
|
|
98
|
+
# Using Any for the value type because the dict holds bool/str/TierGroup values.
|
|
99
|
+
ImpactResult = dict[str, Any]
|
|
100
|
+
|
|
101
|
+
# ── Internal helpers ───────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _distance_to_tier(distance: int) -> str:
|
|
105
|
+
"""Map a hop distance to a risk tier name.
|
|
106
|
+
|
|
107
|
+
d=1 -> WILL_BREAK
|
|
108
|
+
d=2 -> LIKELY_AFFECTED
|
|
109
|
+
d=3+ -> MAY_NEED_TESTING
|
|
110
|
+
"""
|
|
111
|
+
if distance == 1:
|
|
112
|
+
return TIER_WILL_BREAK
|
|
113
|
+
if distance == 2:
|
|
114
|
+
return TIER_LIKELY_AFFECTED
|
|
115
|
+
return TIER_MAY_NEED_TESTING
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _lookup_files_for_names(
|
|
119
|
+
conn: sqlite3.Connection,
|
|
120
|
+
names: list[str],
|
|
121
|
+
) -> dict[str, str]:
|
|
122
|
+
"""Return a mapping of symbol name -> absolute file path for indexed symbols.
|
|
123
|
+
|
|
124
|
+
Names that are not indexed symbols (external import targets, unresolved names, etc.)
|
|
125
|
+
are absent from the returned dict (callers should treat absence as file=None).
|
|
126
|
+
|
|
127
|
+
When a name maps to multiple symbols (ambiguous, same name in multiple files),
|
|
128
|
+
the first match is chosen deterministically by ORDER BY files.path.
|
|
129
|
+
|
|
130
|
+
Uses _SQL_VAR_BATCH chunking to avoid SQLite's SQLITE_MAX_VARIABLE_NUMBER limit.
|
|
131
|
+
"""
|
|
132
|
+
if not names:
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
file_map: dict[str, str] = {}
|
|
136
|
+
|
|
137
|
+
for batch_start in range(0, len(names), _SQL_VAR_BATCH):
|
|
138
|
+
batch = names[batch_start : batch_start + _SQL_VAR_BATCH]
|
|
139
|
+
placeholders = ",".join("?" * len(batch))
|
|
140
|
+
|
|
141
|
+
# Join symbols to files to get the absolute path. ORDER BY path for determinism
|
|
142
|
+
# when multiple symbols share the same name across files.
|
|
143
|
+
sql = f"""
|
|
144
|
+
SELECT s.name, f.path
|
|
145
|
+
FROM symbols s
|
|
146
|
+
JOIN files f ON f.id = s.file_id
|
|
147
|
+
WHERE s.name IN ({placeholders})
|
|
148
|
+
ORDER BY f.path
|
|
149
|
+
"""
|
|
150
|
+
rows = conn.execute(sql, batch).fetchall()
|
|
151
|
+
|
|
152
|
+
for row in rows:
|
|
153
|
+
# Only record the first (deterministic) result for each name.
|
|
154
|
+
if row["name"] not in file_map:
|
|
155
|
+
file_map[row["name"]] = row["path"]
|
|
156
|
+
|
|
157
|
+
return file_map
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _test_only_file_stems(conn: sqlite3.Connection) -> set[str]:
|
|
161
|
+
"""Return file STEMS that belong exclusively to test files in the index.
|
|
162
|
+
|
|
163
|
+
WHY this exists: import edges are sourced at the importing file's STEM (e.g.
|
|
164
|
+
`test_fts` for tests/unit/test_fts.py), not at an indexed symbol. So a test file
|
|
165
|
+
that does `from seam.query.fts import rescore` shows up as an upstream dependent
|
|
166
|
+
named `test_fts` with file=None — which is_test_file(None) tags False, leaking
|
|
167
|
+
test dependents into the "production-only" blast radius (include_tests=False).
|
|
168
|
+
|
|
169
|
+
Production code that calls `fts.rescore()` imports the MODULE (`fts`), so the only
|
|
170
|
+
import edges that target a bare symbol like `rescore` come from test files — exactly
|
|
171
|
+
the entries we must hide. Mapping such a stem back to its test file lets is_test
|
|
172
|
+
tagging catch them.
|
|
173
|
+
|
|
174
|
+
Conservative by construction: a stem is returned ONLY when EVERY file with that
|
|
175
|
+
stem is a test file. If a production file shares the stem, the stem is omitted so a
|
|
176
|
+
real production dependent is never hidden (we'd rather show a stray test than hide
|
|
177
|
+
production code). Single query; result is small (one entry per file).
|
|
178
|
+
"""
|
|
179
|
+
test_stems: set[str] = set()
|
|
180
|
+
prod_stems: set[str] = set()
|
|
181
|
+
try:
|
|
182
|
+
for row in conn.execute("SELECT path FROM files").fetchall():
|
|
183
|
+
stem = Path(row["path"]).stem
|
|
184
|
+
(test_stems if is_test_file(row["path"]) else prod_stems).add(stem)
|
|
185
|
+
except sqlite3.Error:
|
|
186
|
+
# Degrade gracefully: no stem refinement, behave as before (never raises).
|
|
187
|
+
return set()
|
|
188
|
+
return test_stems - prod_stems
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _symbol_exists(conn: sqlite3.Connection, target: str) -> bool:
|
|
192
|
+
"""Return True if target appears as a symbol name or an edge source/target.
|
|
193
|
+
|
|
194
|
+
This covers:
|
|
195
|
+
- A fully indexed symbol (in symbols table).
|
|
196
|
+
- A symbol that only appears as a source/target in edges (external caller, etc.)
|
|
197
|
+
but was never extracted as a definition.
|
|
198
|
+
"""
|
|
199
|
+
# Check symbols table first (cheapest common case).
|
|
200
|
+
row = conn.execute("SELECT 1 FROM symbols WHERE name = ? LIMIT 1", (target,)).fetchone()
|
|
201
|
+
if row is not None:
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
# Check edges table: target might be an external symbol not indexed as a definition.
|
|
205
|
+
row = conn.execute(
|
|
206
|
+
"SELECT 1 FROM edges WHERE source_name = ? OR target_name = ? LIMIT 1",
|
|
207
|
+
(target, target),
|
|
208
|
+
).fetchone()
|
|
209
|
+
return row is not None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _build_tier_group(
|
|
213
|
+
reached: list[Reached],
|
|
214
|
+
file_map: dict[str, str],
|
|
215
|
+
test_stems: set[str] | None = None,
|
|
216
|
+
) -> TierGroup:
|
|
217
|
+
"""Convert a list of Reached symbols into a TierGroup dict.
|
|
218
|
+
|
|
219
|
+
Initializes all three tier keys so callers can always index without KeyError,
|
|
220
|
+
even if some tiers are empty. Each entry includes:
|
|
221
|
+
- file (str | None) — absolute path for indexed symbols; None otherwise
|
|
222
|
+
- is_test (bool) — True when the entry's declaring file is a test file
|
|
223
|
+
(per is_test_file()), OR — for a file=None import-edge
|
|
224
|
+
source — when its name is a test-only file stem (see
|
|
225
|
+
_test_only_file_stems). False for production files and
|
|
226
|
+
for unresolved names that are not test-file stems.
|
|
227
|
+
- resolved_by (str | None) — Phase 5 provenance string from walk(); None for
|
|
228
|
+
fast-path (name-count only) or pre-Phase-5 rows.
|
|
229
|
+
|
|
230
|
+
The `file` field contract is unchanged — only is_test tagging is refined for
|
|
231
|
+
file=None import-source entries so the production-only blast radius is genuinely
|
|
232
|
+
test-free. test_stems=None means "no refinement" (the pre-existing behavior).
|
|
233
|
+
"""
|
|
234
|
+
stems = test_stems or set()
|
|
235
|
+
group: TierGroup = {
|
|
236
|
+
TIER_WILL_BREAK: [],
|
|
237
|
+
TIER_LIKELY_AFFECTED: [],
|
|
238
|
+
TIER_MAY_NEED_TESTING: [],
|
|
239
|
+
}
|
|
240
|
+
for r in reached:
|
|
241
|
+
tier = _distance_to_tier(r["distance"])
|
|
242
|
+
file_path = file_map.get(r["name"]) # None if name is not an indexed symbol
|
|
243
|
+
group[tier].append(
|
|
244
|
+
{
|
|
245
|
+
"name": r["name"],
|
|
246
|
+
"distance": r["distance"],
|
|
247
|
+
"confidence": r["confidence"],
|
|
248
|
+
# Phase 5: resolved_by from walk() (None for fast-path / no import context)
|
|
249
|
+
"resolved_by": r.get("resolved_by"),
|
|
250
|
+
"tier": tier,
|
|
251
|
+
"file": file_path,
|
|
252
|
+
# is_test_file(None) returns False — safe default for unresolved names.
|
|
253
|
+
# Refinement: a file=None import-edge source whose name is a test-only
|
|
254
|
+
# file stem (e.g. `test_fts`) is a test dependent → tag it is_test so the
|
|
255
|
+
# production-only filter hides it. Does NOT touch the `file` field.
|
|
256
|
+
"is_test": is_test_file(file_path) or (file_path is None and r["name"] in stems),
|
|
257
|
+
# Phase 5: best_candidate is the highest-proximity declaring file for
|
|
258
|
+
# AMBIGUOUS entries (PRD story 6). None for non-AMBIGUOUS or unavailable.
|
|
259
|
+
"best_candidate": r.get("best_candidate"),
|
|
260
|
+
# E4: edge kind of the final hop of the winning path (e.g. 'call', 'holds',
|
|
261
|
+
# 'reads', 'uses', etc.). Copied directly from walk()'s Reached result.
|
|
262
|
+
# Same provenance source as resolved_by — all four fields describe one edge.
|
|
263
|
+
"kind": r.get("kind", ""),
|
|
264
|
+
# E4: synthesis channel name when the final hop is a synthesized (heuristic)
|
|
265
|
+
# edge; None for statically-extracted edges. Same null-contract as resolved_by.
|
|
266
|
+
"synthesized_by": r.get("synthesized_by"),
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
return group
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Public interface ───────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def clamp_depth(depth: int) -> int:
|
|
276
|
+
"""Clamp max_depth to the valid range [1, 10]. Exported for use by handlers."""
|
|
277
|
+
return max(_MIN_DEPTH, min(_MAX_DEPTH, depth))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _filter_tests_from_tier_group(tier_group: TierGroup) -> TierGroup:
|
|
281
|
+
"""Return a new TierGroup with all is_test=True entries removed.
|
|
282
|
+
|
|
283
|
+
All three tier keys are preserved (even if their lists become empty after
|
|
284
|
+
filtering) so callers can always index without KeyError.
|
|
285
|
+
"""
|
|
286
|
+
return {
|
|
287
|
+
tier: [entry for entry in entries if not entry.get("is_test", False)]
|
|
288
|
+
for tier, entries in tier_group.items()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _count_test_entries(tier_group: TierGroup) -> int:
|
|
293
|
+
"""Count is_test=True entries across all tiers (how many include_tests=False hides)."""
|
|
294
|
+
return sum(
|
|
295
|
+
1 for entries in tier_group.values() for entry in entries if entry.get("is_test", False)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def impact(
|
|
300
|
+
conn: sqlite3.Connection,
|
|
301
|
+
target: str,
|
|
302
|
+
direction: str = "upstream",
|
|
303
|
+
max_depth: int = _DEFAULT_DEPTH,
|
|
304
|
+
include_tests: bool = True,
|
|
305
|
+
repo_root: Path | None = None,
|
|
306
|
+
) -> ImpactResult:
|
|
307
|
+
"""Compute the blast radius of a symbol.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
conn: Open SQLite connection (read-only; no writes).
|
|
311
|
+
target: Symbol name to analyze. Unknown symbols return found=False.
|
|
312
|
+
direction: "upstream" (who depends on target), "downstream" (what target
|
|
313
|
+
depends on), or "both" (full neighborhood). Default: "upstream".
|
|
314
|
+
max_depth: Maximum hops from target. Clamped to [1, 10]. Default: 3.
|
|
315
|
+
include_tests: When True (default), all dependents are returned — test files
|
|
316
|
+
included, tagged with is_test=True. When False, entries whose
|
|
317
|
+
file lives in a test tree are filtered out from all tiers.
|
|
318
|
+
Entries with file=None (unresolved names) are always included
|
|
319
|
+
because their provenance is unknown (is_test=False by rule).
|
|
320
|
+
repo_root: Repository root for Phase 5 import-promotion resolution.
|
|
321
|
+
When provided and SEAM_IMPORT_RESOLUTION="on", hop confidence
|
|
322
|
+
is resolved via resolve_edge() so imported homonyms promote
|
|
323
|
+
to EXTRACTED 'import'. None → name-count resolution only.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
ImpactResult dict with the following keys:
|
|
327
|
+
found (bool) — True if target is a known symbol or edge endpoint.
|
|
328
|
+
target (str) — the queried name (echoed back for agent convenience).
|
|
329
|
+
<direction-key(s)>:
|
|
330
|
+
"upstream" -> {"upstream": TierGroup}
|
|
331
|
+
"downstream" -> {"downstream": TierGroup}
|
|
332
|
+
"both" -> {"upstream": TierGroup, "downstream": TierGroup}
|
|
333
|
+
|
|
334
|
+
All TierGroup dicts always contain all three tier keys
|
|
335
|
+
(WILL_BREAK, LIKELY_AFFECTED, MAY_NEED_TESTING), even if empty.
|
|
336
|
+
|
|
337
|
+
Each TieredEntry includes:
|
|
338
|
+
name (str) — symbol name
|
|
339
|
+
distance (int) — hops from target
|
|
340
|
+
confidence (str) — EXTRACTED | INFERRED | AMBIGUOUS
|
|
341
|
+
tier (str) — risk tier name
|
|
342
|
+
file (str | None) — absolute path; None if not an indexed symbol
|
|
343
|
+
is_test (bool) — True if file is a test file; False otherwise
|
|
344
|
+
|
|
345
|
+
When include_tests=False, the result also carries:
|
|
346
|
+
hidden_tests (int) — number of test-file dependents that were
|
|
347
|
+
filtered out. Lets callers tell "no dependents"
|
|
348
|
+
(hidden_tests==0) from "only test dependents,
|
|
349
|
+
all hidden" (hidden_tests>0) — the latter is
|
|
350
|
+
NOT safe to treat as dead code.
|
|
351
|
+
|
|
352
|
+
found=False: target not in the index. Empty TierGroups are included.
|
|
353
|
+
found=True, empty tiers: target is indexed but has no dependents in the given direction.
|
|
354
|
+
|
|
355
|
+
Raises:
|
|
356
|
+
ValueError: if direction is not one of the three valid values.
|
|
357
|
+
"""
|
|
358
|
+
if direction not in _VALID_DIRECTIONS:
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"Invalid direction {direction!r}. Must be one of: {sorted(_VALID_DIRECTIONS)}"
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
safe_depth = clamp_depth(max_depth)
|
|
364
|
+
|
|
365
|
+
# Expand the target name to a set of walk() seeds — bridges the qualified-symbol /
|
|
366
|
+
# bare-edge asymmetry. e.g. "Parser.parse" -> ["Parser.parse", "parse"] so that
|
|
367
|
+
# walk() matches edges whose target_name is the bare "parse" (as stored by the extractor).
|
|
368
|
+
# For a class seed like "Parser" -> ["Parser", "parse", "validate"] to aggregate
|
|
369
|
+
# all member callers. A bare METHOD ("speakText") expands to its qualified def(s)
|
|
370
|
+
# ("TTS.speakText") so a method stored only as Class.method still resolves.
|
|
371
|
+
# The seeds list is deduped and bounded by config cap.
|
|
372
|
+
seeds = expand_impact_seeds(conn, target)
|
|
373
|
+
logger.debug("impact: target=%r expanded to seeds=%s", target, seeds)
|
|
374
|
+
|
|
375
|
+
# Determine existence over the EXPANDED seeds — lets callers distinguish "not found"
|
|
376
|
+
# from "found but isolated". Checking the seeds (not just the raw target) is what makes
|
|
377
|
+
# `impact speakText` report found=True when the symbol is stored as `TTS.speakText`:
|
|
378
|
+
# the bare target itself is not a symbol/edge endpoint, but its qualified seed is.
|
|
379
|
+
found = any(_symbol_exists(conn, s) for s in seeds)
|
|
380
|
+
|
|
381
|
+
# Collect all reached symbols so we can batch the file lookup.
|
|
382
|
+
upstream_reached: list[Reached] = []
|
|
383
|
+
downstream_reached: list[Reached] = []
|
|
384
|
+
|
|
385
|
+
if direction in ("upstream", "both"):
|
|
386
|
+
upstream_reached = walk(conn, seeds, "upstream", safe_depth, repo_root=repo_root)
|
|
387
|
+
|
|
388
|
+
if direction in ("downstream", "both"):
|
|
389
|
+
downstream_reached = walk(conn, seeds, "downstream", safe_depth, repo_root=repo_root)
|
|
390
|
+
|
|
391
|
+
# Batch lookup of files for all reached symbol names (single query per batch).
|
|
392
|
+
all_names = [r["name"] for r in upstream_reached] + [r["name"] for r in downstream_reached]
|
|
393
|
+
file_map = _lookup_files_for_names(conn, all_names)
|
|
394
|
+
|
|
395
|
+
# Build result structure: tag each entry with is_test, then optionally filter.
|
|
396
|
+
# When include_tests=False we also count what we removed and expose it as
|
|
397
|
+
# `hidden_tests`, so callers can distinguish "no dependents at all" from
|
|
398
|
+
# "all dependents were tests and got filtered" — the latter is NOT safe to
|
|
399
|
+
# treat as dead code (tests would break). Without this signal include_tests=False
|
|
400
|
+
# could give a dangerous false-safe.
|
|
401
|
+
result: ImpactResult = {"found": found, "target": target}
|
|
402
|
+
hidden_tests = 0
|
|
403
|
+
|
|
404
|
+
# Compute test-only file stems ONLY when filtering (include_tests=False) so the
|
|
405
|
+
# is_test tag catches file=None import-edge sources from test files. Skipped when
|
|
406
|
+
# include_tests=True so seam_changes (which calls impact() with the analysis-layer
|
|
407
|
+
# default) pays zero extra cost and keeps byte-stable risk verdicts.
|
|
408
|
+
test_stems = _test_only_file_stems(conn) if not include_tests else None
|
|
409
|
+
|
|
410
|
+
if direction in ("upstream", "both"):
|
|
411
|
+
tier_group = _build_tier_group(upstream_reached, file_map, test_stems)
|
|
412
|
+
if not include_tests:
|
|
413
|
+
hidden_tests += _count_test_entries(tier_group)
|
|
414
|
+
tier_group = _filter_tests_from_tier_group(tier_group)
|
|
415
|
+
result["upstream"] = tier_group
|
|
416
|
+
|
|
417
|
+
if direction in ("downstream", "both"):
|
|
418
|
+
tier_group = _build_tier_group(downstream_reached, file_map, test_stems)
|
|
419
|
+
if not include_tests:
|
|
420
|
+
hidden_tests += _count_test_entries(tier_group)
|
|
421
|
+
tier_group = _filter_tests_from_tier_group(tier_group)
|
|
422
|
+
result["downstream"] = tier_group
|
|
423
|
+
|
|
424
|
+
# Only include hidden_tests in the result when test filtering was actually applied.
|
|
425
|
+
# Its absence signals "tests included in the output" to callers — no ambiguity.
|
|
426
|
+
# Its presence tells callers how many dependents were hidden, which is the
|
|
427
|
+
# anti-false-safe signal: hidden_tests>0 means "there ARE test dependents, they
|
|
428
|
+
# were just filtered", so an agent cannot conclude the symbol is unused or safe
|
|
429
|
+
# to delete because it sees empty tiers. hidden_tests==0 means filtering produced
|
|
430
|
+
# the same result as not filtering (no test dependents existed to remove).
|
|
431
|
+
if not include_tests:
|
|
432
|
+
result["hidden_tests"] = hidden_tests
|
|
433
|
+
|
|
434
|
+
logger.debug(
|
|
435
|
+
"impact(target=%r, direction=%r, max_depth=%d, include_tests=%s, found=%s) -> %s",
|
|
436
|
+
target,
|
|
437
|
+
direction,
|
|
438
|
+
safe_depth,
|
|
439
|
+
include_tests,
|
|
440
|
+
found,
|
|
441
|
+
{k: sum(len(v) for v in tg.values()) for k, tg in result.items() if isinstance(tg, dict)},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
return result
|