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.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -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