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
seam/query/names.py ADDED
@@ -0,0 +1,470 @@
1
+ """Name-resolution helpers for the qualified<->bare edge bridging (Tier A, Slices 1-3).
2
+
3
+ Cross-layer utility module — imports only stdlib + seam/config. Imported by both
4
+ engine.py (query layer) and analysis modules (impact.py, flows.py). Pattern mirrors
5
+ seam/query/clusters.py in terms of import discipline: no engine, no tools, no other
6
+ query sub-modules. Changes here have blast radius into both layers — keep it focused.
7
+
8
+ ROOT CAUSE this module fixes:
9
+ Seam stores method symbol names as the QUALIFIED string "Class.method" but stores
10
+ call-edge target_name as the BARE identifier "method". This asymmetry means
11
+ callers/callees of a qualified symbol never join in context(). This module provides:
12
+
13
+ - bare_name(qualified) -> str
14
+ The rightmost identifier after the last dot. If there is no dot the input is
15
+ returned unchanged. Never raises on empty or malformed input.
16
+
17
+ - is_container_symbol(conn, name) -> bool [Slice 3]
18
+ Returns True if the named symbol is a class/interface/struct (container).
19
+ Returns False for functions, methods, unknown names, or empty string.
20
+ Never raises.
21
+
22
+ - get_member_names(conn, name) -> list[str] [Slice 3]
23
+ Return the bare member names of a class container (symbols WHERE name LIKE 'Class.%').
24
+ Bounded by SEAM_NAME_EXPANSION_CAP. Returns [] for unknown/non-container names
25
+ and for classes with zero indexed members (graceful, not an error). Never raises.
26
+
27
+ - edge_match_names(conn, name) -> list[str]
28
+ The set of strings to use for edge table lookups (target_name IN / source_name IN).
29
+ Returns [name] when name has no dot (bare — exact match only).
30
+ Returns [name, bare_suffix] when name contains a dot, so a call edge stored as
31
+ the bare form is also matched. Order is stable: qualified first, bare second.
32
+ [Slice 3] When name is a bare class/container, also appends member bare names
33
+ (e.g. 'parse', 'validate') so callers of any member are union-matched.
34
+
35
+ - resolve_query_to_defs(conn, name) -> list[sqlite3.Row] [Slice 2]
36
+ Resolve a query name to ALL matching symbol definition rows.
37
+ Resolution order:
38
+ 1. Exact name match → return all rows with that exact name.
39
+ 2. If no exact match AND name has no dot (bare identifier):
40
+ Scan for symbols whose name ends with ".{name}" (qualified defs).
41
+ Filter in Python to ensure exact suffix match (not just LIKE match).
42
+ 3. If no exact match AND name has a dot → return [] (unknown qualified name).
43
+ Never raises. Returns an empty list when nothing is found.
44
+
45
+ - expand_impact_seeds(conn, name) -> list[str] [Slice 4]
46
+ Expand a query name into the set of walk() seed strings for impact/trace analysis.
47
+ This bridges the qualified-symbol / bare-edge asymmetry at the walk() call boundary.
48
+
49
+ Expansion rules (applied in order; always returns a deduped list):
50
+ 1. Qualified name (has dot): [name, bare_suffix]
51
+ e.g. "Parser.parse" → ["Parser.parse", "parse"]
52
+ Ensures walk() matches edges that store bare "parse" as target_name.
53
+ 2. Container name (class/struct/interface, no dot): [name] + bare member names
54
+ e.g. "Parser" → ["Parser", "parse", "validate"]
55
+ Ensures walk() matches callers of any method of the class.
56
+ 3. Bare non-container name: [name]
57
+ e.g. "orchestrate" → ["orchestrate"]
58
+ Exact match only — no expansion needed.
59
+
60
+ The caller passes ALL returned seeds to walk() at once; walk() already handles
61
+ multi-seed BFS (treats all seeds as the same starting level).
62
+ Never raises. Returns [name] as a safe fallback on any error.
63
+
64
+ WHY not store bare name in the DB:
65
+ Scope guard — Tier A is read-path-only. No schema change, no migration, no re-index.
66
+ The bridging is pure read-time reconciliation using what is already stored.
67
+
68
+ WHY edge_match_names takes a conn param:
69
+ Slice 3 uses DB queries to find all symbols whose name starts with "Class." so
70
+ edge_match_names can include member bare names for container symbols.
71
+ """
72
+
73
+ import logging
74
+ import sqlite3
75
+
76
+ from seam.config import SEAM_BARE_RESOLVE_CAP, SEAM_NAME_EXPANSION_CAP
77
+
78
+ logger = logging.getLogger(__name__)
79
+
80
+ # Symbol kinds that represent containers (have members in the graph).
81
+ # Closed vocabulary matching the schema comment: 'function' | 'class' | 'method' |
82
+ # 'interface' | 'type'. Rust/C/C++ use 'struct' (via graph_common kind mapping).
83
+ _CONTAINER_KINDS: frozenset[str] = frozenset({"class", "interface", "struct"})
84
+
85
+
86
+ def bare_name(qualified: str) -> str:
87
+ """Return the rightmost identifier after the last dot.
88
+
89
+ Examples:
90
+ "Class.method" -> "method"
91
+ "pkg.Class.method" -> "method"
92
+ "authenticate" -> "authenticate" (no dot, returned unchanged)
93
+ "" -> "" (empty, returned unchanged)
94
+ "Class." -> "" (trailing dot, bare part is empty)
95
+ ".method" -> "method" (leading dot)
96
+
97
+ Never raises. Handles all edge cases by relying on str.rsplit behaviour.
98
+ """
99
+ if "." not in qualified:
100
+ return qualified
101
+ # rsplit with maxsplit=1 gives ["prefix", "bare"] or ["", "bare"] for ".method"
102
+ _, _, after = qualified.rpartition(".")
103
+ return after
104
+
105
+
106
+ def is_container_symbol(conn: sqlite3.Connection, name: str) -> bool:
107
+ """Return True when the named symbol is a class/interface/struct (container kind).
108
+
109
+ Containers are symbols whose members appear as "Class.member" qualified names in
110
+ the index. This determines whether edge_match_names should fan out to member names.
111
+
112
+ Resolution: queries the symbols table for ANY row with this exact name and checks
113
+ if its kind is in _CONTAINER_KINDS. Only the first (lowest-id) row is checked —
114
+ homonyms with conflicting kinds are pathological and rare in practice.
115
+
116
+ Returns False for:
117
+ - Unknown names (not in DB)
118
+ - function / method / type kinds
119
+ - empty string input
120
+ Never raises.
121
+ """
122
+ if not name:
123
+ return False
124
+ try:
125
+ row = conn.execute(
126
+ "SELECT kind FROM symbols WHERE name = ? ORDER BY id LIMIT 1",
127
+ (name,),
128
+ ).fetchone()
129
+ if row is None:
130
+ return False
131
+ return row["kind"] in _CONTAINER_KINDS
132
+ except Exception: # noqa: BLE001
133
+ # Degrade gracefully — read path must never crash.
134
+ logger.debug("is_container_symbol: DB error for name=%r", name, exc_info=True)
135
+ return False
136
+
137
+
138
+ def get_member_names(conn: sqlite3.Connection, class_name: str) -> list[str]:
139
+ """Return the bare names of all members of a class/container symbol.
140
+
141
+ Queries for symbols whose name starts with "class_name." (the LIKE prefix) then
142
+ filters in Python to ensure the prefix is exact (no false positives from LIKE).
143
+ Returns bare names (the part after the last dot) bounded by SEAM_NAME_EXPANSION_CAP.
144
+
145
+ Examples:
146
+ class_name="Parser", members=["Parser.parse", "Parser.validate"]
147
+ → returns ["parse", "validate"]
148
+
149
+ Returns [] for:
150
+ - Unknown/non-container names
151
+ - Classes with zero indexed members (graceful — not an error)
152
+ - Empty string input
153
+ Never raises.
154
+ """
155
+ if not class_name:
156
+ return []
157
+ try:
158
+ # The LIKE pre-filter runs in SQL so the Python loop only sees candidates
159
+ # that can plausibly be members. The '.' in 'Class.%' is a LIKE literal
160
+ # (not a wildcard), so 'ClassExtra.method' can never satisfy 'Class.%' —
161
+ # the SQL cap is therefore safe: it does not under-count real members.
162
+ # The Python startswith check below is still required because LIKE is
163
+ # case-insensitive by default on ASCII, whereas Python identity is exact.
164
+ candidate_rows = conn.execute(
165
+ "SELECT name FROM symbols WHERE name LIKE ? ORDER BY id LIMIT ?",
166
+ (f"{class_name}.%", SEAM_NAME_EXPANSION_CAP),
167
+ ).fetchall()
168
+ except Exception: # noqa: BLE001
169
+ logger.debug("get_member_names: DB error for class=%r", class_name, exc_info=True)
170
+ return []
171
+
172
+ prefix = f"{class_name}."
173
+ members: list[str] = []
174
+ for row in candidate_rows:
175
+ sym_name: str = row["name"]
176
+ # Guard against LIKE case-folding and any future schema quirk: the bare-name
177
+ # after the last dot must come from exactly "class_name.member", not a coincidental
178
+ # prefix match. Without this, a class named "Foo" would absorb "FooBar.method" rows
179
+ # that sneak through on case-insensitive SQLite builds.
180
+ if sym_name.startswith(prefix):
181
+ member_bare = bare_name(sym_name)
182
+ if member_bare and member_bare not in members:
183
+ members.append(member_bare)
184
+ if len(members) >= SEAM_NAME_EXPANSION_CAP:
185
+ # The SQL LIMIT cap fires before the Python prefix filter removes non-members,
186
+ # so the final member list can still reach the cap even after filtering.
187
+ # Break early to honour the cap exactly rather than overshooting.
188
+ break
189
+
190
+ if members:
191
+ logger.debug(
192
+ "get_member_names: class=%r -> %d member(s): %s",
193
+ class_name,
194
+ len(members),
195
+ members,
196
+ )
197
+ return members
198
+
199
+
200
+ def edge_match_names(conn: sqlite3.Connection, name: str) -> list[str]:
201
+ """Return the list of names to use for edges.target_name / edges.source_name lookups.
202
+
203
+ The returned list is ordered and deduplicated:
204
+ - When name has no dot AND is not a container: [name] (exact match only)
205
+ - When name has no dot AND IS a container: [name, member1, member2, ...]
206
+ Container = class/interface/struct. Member bare names are included so that
207
+ call edges to any member method are matched for the class context.
208
+ Bounded by SEAM_NAME_EXPANSION_CAP (see seam/config.py).
209
+ - When name has a dot: [name, bare_suffix] (qualified first, then bare)
210
+ A qualified method name (Class.method) is NOT treated as a container —
211
+ only the containing class would be, not the method itself.
212
+
213
+ WHY two names for qualified:
214
+ Seam's extractor stores edge target_name as the bare identifier (e.g. "method")
215
+ but symbol name as the qualified string ("Class.method"). Matching ONLY on the
216
+ qualified name would miss all call edges; matching only on the bare would add
217
+ false positives for other classes' methods with the same name. Including BOTH
218
+ maximises recall while keeping the query simple (IN clause).
219
+
220
+ WHY member fan-out for containers (Slice 3):
221
+ When name is a class, there are no call edges that target the class name itself
222
+ (callers invoke methods, not the class). Expanding to member bare names unions
223
+ all callers of "Class.method" edges into the class context result.
224
+
225
+ WHY qualified first / container name first:
226
+ The first entry is always the "canonical" name the caller asked about, making
227
+ the list deterministic and allowing debug logging to identify which match came
228
+ from the exact vs. the bridged form.
229
+ """
230
+ # Defensive: always return a list[str] regardless of input
231
+ if not name:
232
+ return [name]
233
+
234
+ bare = bare_name(name)
235
+
236
+ # Name contains a dot → it's a qualified method reference, not a container.
237
+ # Return [qualified, bare] per Slice 1 logic. No member fan-out for methods.
238
+ if bare != name:
239
+ return [name, bare]
240
+
241
+ # Bare name (no dot): check if it's a container to decide on member fan-out.
242
+ member_names = get_member_names(conn, name) if is_container_symbol(conn, name) else []
243
+
244
+ if not member_names:
245
+ # Non-container bare name OR container with zero members → exact match only.
246
+ return [name]
247
+
248
+ # Container with members: [class_name] + member bare names (deduped, bounded by cap).
249
+ # class_name itself first (canonical), then members in discovery order.
250
+ result = [name]
251
+ seen: set[str] = {name}
252
+ for m in member_names:
253
+ if m not in seen and len(result) <= SEAM_NAME_EXPANSION_CAP:
254
+ result.append(m)
255
+ seen.add(m)
256
+ return result
257
+
258
+
259
+ def resolve_query_to_defs(conn: sqlite3.Connection, name: str) -> list[sqlite3.Row]:
260
+ """Resolve a query name to ALL matching symbol definition rows (Slice 2).
261
+
262
+ Resolution order:
263
+ 1. Exact name match — return all symbols where name == query.
264
+ This preserves byte-stability for callers that already have exact names.
265
+ 2. Bare-name suffix scan (only when name has NO dot and exact returned nothing):
266
+ Find all qualified symbols whose name ends with ".{name}" — e.g. querying
267
+ "speakText" finds "TTS.speakText", "AudioPlayer.speakText". Filtered in Python
268
+ to guarantee exact suffix match (LIKE alone would match "speakTextExtra").
269
+ 3. Qualified-but-absent: if name has a dot and exact returned nothing → return [].
270
+ We never suffix-scan for partially-qualified queries (too ambiguous).
271
+
272
+ Returns a list of sqlite3.Row objects ready for use in context() (fields: name, file,
273
+ start_line, end_line, kind, docstring, signature, decorators, is_exported, visibility,
274
+ qualified_name). Never raises. Returns [] when nothing is found.
275
+
276
+ WHY separate from edge_match_names:
277
+ edge_match_names returns strings for edge JOIN lookups (fast, no DB query).
278
+ resolve_query_to_defs returns full symbol rows for definition aggregation — it
279
+ IS a DB query and drives which defs context() merges. They serve different callers.
280
+
281
+ WHY SQL LIKE + Python filter for the suffix scan:
282
+ SQLite LIKE with '%.name' can match "speakTextEx" if there were a symbol "X.speakTextEx"
283
+ where the LIKE matches "speakTextEx" → false positive. The Python filter checks that
284
+ the suffix after the last dot is EXACTLY `name`, making the scan exact.
285
+ """
286
+ if not name:
287
+ return []
288
+
289
+ try:
290
+ # Step 1: exact name match — always try this first.
291
+ # Fetches all Phase 4 enrichment columns so context() can build a full ContextResult.
292
+ # LIMIT is applied here as a safety cap — common identifiers can have many defs.
293
+ exact_rows = conn.execute(
294
+ """
295
+ SELECT s.name, f.path AS file, s.start_line, s.end_line, s.kind, s.docstring,
296
+ s.signature, s.decorators, s.is_exported, s.visibility, s.qualified_name
297
+ FROM symbols s
298
+ JOIN files f ON s.file_id = f.id
299
+ WHERE s.name = ?
300
+ ORDER BY s.id
301
+ LIMIT ?
302
+ """,
303
+ (name, SEAM_BARE_RESOLVE_CAP),
304
+ ).fetchall()
305
+ except Exception: # noqa: BLE001
306
+ logger.debug("resolve_query_to_defs: DB error (exact match) for name=%r", name, exc_info=True)
307
+ return []
308
+
309
+ if exact_rows:
310
+ # Exact match found — return as-is, preserving byte-stability.
311
+ return exact_rows
312
+
313
+ # Step 2: bare-name suffix scan — only if name has no dot (bare identifier).
314
+ # A query like "Class.method" that isn't in the index is treated as unknown.
315
+ if "." in name:
316
+ # Qualified name not found → unknown. No suffix scan for partially-qualified queries.
317
+ return []
318
+
319
+ # Candidate scan: find symbols whose name contains a dot and ends with ".{name}".
320
+ # LIKE '%.name' is a pre-filter (with a leading % it CANNOT use the B-tree index —
321
+ # it is a full-table scan); Python confirms exact suffix to avoid LIKE false positives.
322
+ # LIMIT here caps the scan before the Python filter to prevent O(N) unbounded reads
323
+ # on large codebases (common bare names like "run", "get", "parse" can match 1000+ rows).
324
+ suffix_pattern = f"%.{name}"
325
+ try:
326
+ candidate_rows = conn.execute(
327
+ """
328
+ SELECT s.name, f.path AS file, s.start_line, s.end_line, s.kind, s.docstring,
329
+ s.signature, s.decorators, s.is_exported, s.visibility, s.qualified_name
330
+ FROM symbols s
331
+ JOIN files f ON s.file_id = f.id
332
+ WHERE s.name LIKE ?
333
+ ORDER BY s.id
334
+ LIMIT ?
335
+ """,
336
+ (suffix_pattern, SEAM_BARE_RESOLVE_CAP),
337
+ ).fetchall()
338
+ except Exception: # noqa: BLE001
339
+ logger.debug("resolve_query_to_defs: DB error (suffix scan) for name=%r", name, exc_info=True)
340
+ return []
341
+
342
+ # Python-side exact suffix filter: the bare part after the LAST dot must be exactly `name`.
343
+ # This eliminates LIKE false-positives (e.g. "Foo.speakTextWrapper" for query "speakText").
344
+ matched = [row for row in candidate_rows if bare_name(row["name"]) == name]
345
+
346
+ if matched:
347
+ logger.debug(
348
+ "resolve_query_to_defs: bare '%s' resolved to %d qualified def(s): %s",
349
+ name,
350
+ len(matched),
351
+ [r["name"] for r in matched],
352
+ )
353
+
354
+ return matched
355
+
356
+
357
+ def expand_impact_seeds(conn: sqlite3.Connection, name: str) -> list[str]:
358
+ """Expand a query name into the set of walk() seed strings for impact/trace analysis.
359
+
360
+ Bridges the qualified-symbol / bare-edge asymmetry: Seam stores symbol names as
361
+ qualified strings ("Class.method") but stores call-edge target_name as the bare
362
+ identifier ("method"). This makes walk() see no upstream for a qualified symbol.
363
+ Seed expansion resolves this at the walk() call boundary — no schema change needed.
364
+
365
+ Expansion rules (deduped, stable order):
366
+ 1. Qualified name (has dot): [name, bare_suffix]
367
+ e.g. "Parser.parse" -> ["Parser.parse", "parse"]
368
+ walk() with direction=upstream then finds edges targeting bare "parse".
369
+
370
+ 2. Container (class/struct/interface, no dot): [name] + member bare names
371
+ e.g. "Parser" -> ["Parser", "parse", "validate"]
372
+ Unions callers of all methods into a single walk() pass.
373
+ Bounded by SEAM_NAME_EXPANSION_CAP (same cap as get_member_names).
374
+
375
+ 3. Non-container bare name: [name] (exact match only, no expansion)
376
+ e.g. "orchestrate" -> ["orchestrate"]
377
+
378
+ WHY this lives in names.py (leaf module):
379
+ names.py is already the single source of truth for all qualified<->bare bridging.
380
+ Placing expansion here keeps impact.py and flows.py thin — they just call this
381
+ function and pass the results to walk(). Leaf layering: imports only stdlib + config.
382
+
383
+ Never raises. Returns [name] on any error (safe fallback — degrades to pre-slice-4).
384
+ """
385
+ if not name:
386
+ # Empty string — return as-is; walk() will handle gracefully.
387
+ return [name]
388
+
389
+ bare = bare_name(name)
390
+
391
+ # Case 1: qualified name (contains a dot) → [qualified, bare].
392
+ # A qualified method "Class.method" is NOT a container — skip member fan-out.
393
+ if bare != name:
394
+ result = [name]
395
+ # Guard against trailing-dot edge case ("Class." → bare is ""), which bare_name()
396
+ # handles by returning "". Including an empty string as a seed would match all
397
+ # edges via target_name="", producing a massive false-positive blast radius.
398
+ if bare:
399
+ result.append(bare)
400
+ logger.debug(
401
+ "expand_impact_seeds: qualified '%s' -> %s",
402
+ name,
403
+ result,
404
+ )
405
+ return result
406
+
407
+ # Case 2: bare name — check if it is a container (class/struct/interface).
408
+ member_names = get_member_names(conn, name) if is_container_symbol(conn, name) else []
409
+
410
+ if member_names:
411
+ # Container: [class_name] + BOTH bare and qualified member forms (deduped).
412
+ #
413
+ # WHY both forms (Tier-B asymmetry fix): pre-Tier-B, member-call edges stored a
414
+ # BARE target_name ("method"), so the bare member seed matched at the walk's first
415
+ # hop. Tier B's receiver-type inference made many member-call edge targets QUALIFIED
416
+ # ("Class.method") — so the bare seed alone now MISSES those edges at d=1, and a
417
+ # genuine direct caller (e.g. an injector calling Class.start) is pushed to d=2 or
418
+ # dropped. This is the exact qualified<->bare asymmetry edge_match_names already
419
+ # bridges for context(); the impact/trace seed path needs the same bridge. Emitting
420
+ # the qualified "Class.method" form alongside the bare "method" restores d=1 matching
421
+ # for Tier-B-qualified call edges WITHOUT losing the pre-Tier-B bare matches.
422
+ #
423
+ # member_names is already bounded by SEAM_NAME_EXPANSION_CAP inside get_member_names,
424
+ # so the seed list is at most 1 + 2*cap — the cap governs MEMBER count, not raw seeds.
425
+ result = [name]
426
+ seen: set[str] = {name}
427
+ for m in member_names:
428
+ for cand in (m, f"{name}.{m}"):
429
+ if cand and cand not in seen:
430
+ result.append(cand)
431
+ seen.add(cand)
432
+ logger.debug(
433
+ "expand_impact_seeds: container '%s' -> %d seed(s): %s",
434
+ name,
435
+ len(result),
436
+ result,
437
+ )
438
+ return result
439
+
440
+ # Case 3: bare name that is neither a container nor (necessarily) a stored symbol.
441
+ # It may be a METHOD stored ONLY as "Class.method" with no bare symbol — the exact
442
+ # case Tier B introduced (receiver-typed qualified edge targets). impact/trace must
443
+ # resolve it the same way context() does, else `impact speakText` shows found=false
444
+ # with empty upstream even though callers exist. resolve_query_to_defs() returns the
445
+ # EXACT row when a bare symbol genuinely exists (standalone function → bare name only,
446
+ # byte-identical to before), or the qualified suffix matches when it is a method.
447
+ defs = resolve_query_to_defs(conn, name)
448
+ qualified = [
449
+ row["name"]
450
+ for row in defs
451
+ if row["name"] != name and bare_name(row["name"]) == name
452
+ ]
453
+ if qualified:
454
+ result = [name]
455
+ seen = {name}
456
+ for q in qualified:
457
+ if q not in seen and len(result) <= SEAM_NAME_EXPANSION_CAP:
458
+ result.append(q)
459
+ seen.add(q)
460
+ logger.debug(
461
+ "expand_impact_seeds: bare method '%s' -> %d qualified seed(s): %s",
462
+ name,
463
+ len(result),
464
+ result,
465
+ )
466
+ return result
467
+
468
+ # Genuinely standalone / unknown bare name — exact match only (pre-Tier-B behavior).
469
+ logger.debug("expand_impact_seeds: bare non-container '%s' -> ['%s']", name, name)
470
+ return [name]