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,433 @@
1
+ """Edge-synthesis channels for A1 dynamic-dispatch patterns.
2
+
3
+ LAYER: leaf — imports only stdlib + seam.config. No DB access.
4
+
5
+ This module implements the source-text-based synthesis channels:
6
+ A1a — closure-collection dispatch: a collection field that holds closures is
7
+ iterated AND the element invoked; paired globally (cross-file) by
8
+ collection/field name with sites that APPEND a closure.
9
+ A1b — EventEmitter/observer dispatch: registrar verbs (on[A-Z][A-Za-z]*, subscribe,
10
+ addListener, addEventListener, register, watch, listen, addCallback)
11
+ paired with dispatcher verbs (emit, trigger, notify, dispatch, fire,
12
+ publish, flush) keyed by event-string literal.
13
+
14
+ Design rules (shared with synthesis.py):
15
+ - All public functions are PURE: no side effects, deterministic output.
16
+ - Never raise: internal errors degrade to "no edge emitted" (same contract as
17
+ parsers). Caller (synthesis.py) has its own outer try/except guard.
18
+ - Cap-bounded: fanout_cap limits edges per collection/event to prevent graph
19
+ explosions on widely-shared field names.
20
+ - Pairing uses string names — no node IDs. Mirrors the rest of Seam's edge model.
21
+ - Only NAMED handlers/callbacks produce edges (inline/anonymous lambdas are skipped
22
+ as they are not addressable by the index). This is the conservatism contract.
23
+
24
+ WHY source-text-based: the extractor already captures the AST-derived symbol/edge
25
+ graph. The patterns here (iteration + element invocation, event string matching) are
26
+ NOT reliably captured by the tree-sitter extractor (they require data-flow awareness).
27
+ Regex scanning of source text is sufficient for the patterns in scope and avoids
28
+ re-running the parser in the synthesis pass.
29
+ """
30
+
31
+ import logging
32
+ import re
33
+ from typing import Any
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # ── Channel identifiers (stored in edges.synthesized_by) ─────────────────────
38
+ CHANNEL_CLOSURE_COLLECTION = "closure-collection"
39
+ CHANNEL_EVENT_EMITTER = "event-emitter"
40
+
41
+
42
+ # ─────────────────────────────────────────────────────────────────────────────
43
+ # A1a — Closure-Collection Dispatch
44
+ # ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ # Patterns that match "collection iterated AND element INVOKED":
47
+ # forEach { $0() } — Swift $0 invocation
48
+ # forEach { it() } — Kotlin it invocation
49
+ # forEach(h => h()) — JS/TS arrow invoke
50
+ # forEach(fn) — passing a bare invoker? NOT matched (no evidence of invocation)
51
+ # for cb in cbs: cb() — Python for-loop with call on next line / same expr
52
+ # for h in handlers: h() — same
53
+ # Critically EXCLUDED:
54
+ # forEach { print($0) } — $0 not invoked (arg to another fn, not called itself)
55
+ # forEach { it.process() } — it.method() is a method call on the element, not element()
56
+ # map { $0.value } — field access, no invocation
57
+
58
+ # Swift/Kotlin: <fieldName>.forEach { $0() } or { it() }
59
+ # The trailing `()` is the load-bearing gate: it requires the element to be INVOKED,
60
+ # not merely referenced. `forEach { print($0) }` passes $0 as an argument (no `()`
61
+ # directly after it) and so emits nothing — only genuine element invocation is dispatch.
62
+ _RE_CC_INVOKE_SWIFT_KT = re.compile(
63
+ r"(?<!\w)([\w]+)\s*\.\s*forEach\s*\{\s*(?:\$0|it)\s*\(\s*\)",
64
+ re.MULTILINE,
65
+ )
66
+
67
+ # Regex for JS/TS: <field>.forEach(h => h()) or .forEach((h) => h())
68
+ _RE_CC_INVOKE_JS_ARROW = re.compile(
69
+ r"(?<!\w)([\w]+)\s*\.\s*forEach\s*\(\s*(?:\w+|\(\w+\))\s*=>\s*(?:\w+|\(\w+\))\s*\(\s*\)",
70
+ re.MULTILINE,
71
+ )
72
+
73
+ # Regex for Python: for <var> in <field>:\n <var>() — variable invoked directly
74
+ # We match "for VARNAME in FIELDNAME:" followed by VARNAME()
75
+ _RE_CC_INVOKE_PY = re.compile(
76
+ r"for\s+(\w+)\s+in\s+(?:self\.)?(\w+)\s*:\s*\n(?:[ \t]+)\1\s*\(",
77
+ re.MULTILINE,
78
+ )
79
+
80
+ # Regex for field.append/push/add/insert/addCallback with a NAMED (non-lambda) target:
81
+ # field.append(myCallback) / field.push(handler) / field.add(fn) ...
82
+ # We require the argument to be a BARE IDENTIFIER (no parens = not an inline call,
83
+ # no lambda keyword, no curly braces).
84
+ # Matches: <obj>.<field>.<verb>(identifier) OR <field>.<verb>(identifier)
85
+ _RE_CC_APPEND = re.compile(
86
+ r"(?:[\w]+\s*\.\s*)?([\w]+)\s*\.\s*(?:append|push|add|insert|addCallback|enqueue)\s*\(\s*([A-Za-z_]\w*)\s*\)",
87
+ re.MULTILINE,
88
+ )
89
+
90
+ # Companion to _RE_CC_APPEND for the bare `field.append(fn)` form (no `obj.` prefix).
91
+ # The negative lookbehind `(?<!\.)` ensures we don't double-match the tail of an
92
+ # `obj.field.append(...)` expression already captured by _RE_CC_APPEND above.
93
+ _RE_CC_APPEND_BARE = re.compile(
94
+ r"(?<!\.)(\w+)\s*\.\s*(?:append|push|add|insert)\s*\(\s*([A-Za-z_]\w*)\s*\)",
95
+ re.MULTILINE,
96
+ )
97
+
98
+
99
+ def _find_iteration_fields(sources: dict[str, str]) -> set[str]:
100
+ """Return field names that are iterated AND element is invoked (across all source files).
101
+
102
+ These are the DISPATCHER-side collection names.
103
+ """
104
+ fields: set[str] = set()
105
+ for source_text in sources.values():
106
+ try:
107
+ for m in _RE_CC_INVOKE_SWIFT_KT.finditer(source_text):
108
+ fields.add(m.group(1))
109
+ for m in _RE_CC_INVOKE_JS_ARROW.finditer(source_text):
110
+ fields.add(m.group(1))
111
+ # Python: match captures (loop_var, field_name) — field is group 2
112
+ for m in _RE_CC_INVOKE_PY.finditer(source_text):
113
+ fields.add(m.group(2))
114
+ except Exception: # noqa: BLE001
115
+ continue
116
+ return fields
117
+
118
+
119
+ def _find_append_sites(
120
+ sources: dict[str, str],
121
+ dispatch_fields: set[str],
122
+ known_symbol_names: set[str],
123
+ ) -> dict[str, list[str]]:
124
+ """Return mapping field_name → list[callback_name] from append sites.
125
+
126
+ Only NAMED functions/methods that exist in the symbol index are included
127
+ (conservatism: unnamed callbacks / lambdas are not indexable targets).
128
+ """
129
+ field_to_callbacks: dict[str, list[str]] = {}
130
+ for source_text in sources.values():
131
+ try:
132
+ for m in _RE_CC_APPEND.finditer(source_text):
133
+ field = m.group(1)
134
+ callback = m.group(2)
135
+ if field not in dispatch_fields:
136
+ continue
137
+ # Only emit edges to symbols actually in the index.
138
+ if callback not in known_symbol_names:
139
+ continue
140
+ field_to_callbacks.setdefault(field, []).append(callback)
141
+ for m in _RE_CC_APPEND_BARE.finditer(source_text):
142
+ field = m.group(1)
143
+ callback = m.group(2)
144
+ if field not in dispatch_fields:
145
+ continue
146
+ if callback not in known_symbol_names:
147
+ continue
148
+ field_to_callbacks.setdefault(field, []).append(callback)
149
+ except Exception: # noqa: BLE001
150
+ continue
151
+ return field_to_callbacks
152
+
153
+
154
+ def run_closure_collection_channel(
155
+ symbols: list[dict[str, Any]],
156
+ file_sources: dict[str, str],
157
+ fanout_cap: int,
158
+ ) -> list[dict[str, Any]]:
159
+ """A1a: closure-collection dispatch channel.
160
+
161
+ Algorithm:
162
+ 1. Scan all source files for "collection iterated AND element invoked" patterns
163
+ → these are the dispatcher-side field names.
164
+ 2. Scan all source files for "field.append(namedCallback)" patterns on those fields
165
+ → these are the registration sites.
166
+ 3. For each (field, callback) pair found, emit a synthesized call edge.
167
+ 4. Apply fanout_cap per field.
168
+
169
+ The edge source is the dispatch field name (the collection); the target is the
170
+ named callback that was appended to it.
171
+
172
+ Never raises — all errors degrade to "no edge emitted for this file/pattern".
173
+ """
174
+ if not file_sources:
175
+ return []
176
+
177
+ result: list[dict[str, Any]] = []
178
+ try:
179
+ # Build the set of names the index knows (qualified + bare parts).
180
+ known_names = _build_known_names(symbols)
181
+
182
+ # Step 1: Find all dispatcher-side collection fields.
183
+ dispatch_fields = _find_iteration_fields(file_sources)
184
+ if not dispatch_fields:
185
+ return []
186
+
187
+ # Step 2: Find append sites for those fields.
188
+ field_callbacks = _find_append_sites(file_sources, dispatch_fields, known_names)
189
+ if not field_callbacks:
190
+ return []
191
+
192
+ # Step 3: Emit edges, deduplicated, cap-bounded.
193
+ for field_name in sorted(field_callbacks):
194
+ callbacks = sorted(set(field_callbacks[field_name])) # dedup + determinism
195
+ # Cap by TRUNCATING (vs the event-emitter channel, which drops the whole
196
+ # event): an over-subscribed collection still has a few genuine dispatch
197
+ # targets worth keeping, whereas a 100-handler event key is more likely a
198
+ # false-positive string collision. Different precision/recall trade per channel.
199
+ if fanout_cap > 0:
200
+ callbacks = callbacks[:fanout_cap]
201
+
202
+ for cb_name in callbacks:
203
+ # Find the fully-qualified target name (prefer qualified over bare).
204
+ target = _resolve_name(cb_name, symbols)
205
+ result.append({
206
+ "source": field_name,
207
+ "target": target,
208
+ "kind": "call",
209
+ "confidence": "INFERRED",
210
+ "synthesized_by": CHANNEL_CLOSURE_COLLECTION,
211
+ # Not file-scoped — the bridge supplies file_id + line at persist time.
212
+ "line": 0,
213
+ })
214
+ except Exception as exc: # noqa: BLE001
215
+ logger.warning(
216
+ "synthesis: closure-collection channel failed (%s: %s) — partial results",
217
+ type(exc).__name__,
218
+ exc,
219
+ )
220
+ return result
221
+
222
+
223
+ # ─────────────────────────────────────────────────────────────────────────────
224
+ # A1b — EventEmitter / Observer Dispatch
225
+ # ─────────────────────────────────────────────────────────────────────────────
226
+
227
+ # Registrar verbs: match on[A-Z][A-Za-z]* or the listed explicit words.
228
+ # We capture: .on('eventKey', namedHandler) or .subscribe('key', fn) etc.
229
+ # Groups: (event_key, handler_name)
230
+ _RE_EE_REGISTRAR = re.compile(
231
+ r"\.\s*(?:on(?=[A-Z][A-Za-z]*\s*\()|on\b|subscribe\b|addListener\b|addEventListener\b"
232
+ r"|register\b|watch\b|listen\b|addCallback\b)"
233
+ r"\s*\(\s*['\"]([A-Za-z_][\w.-]*)['\"]" # event string key
234
+ r"\s*,\s*"
235
+ r"([A-Za-z_]\w*)" # named handler identifier (bare — not a lambda/anonymous)
236
+ r"\s*\)",
237
+ re.MULTILINE,
238
+ )
239
+
240
+ # Special: onEventName(handler) — no event string, name from the verb itself
241
+ # e.g. emitter.onDone(completionHandler) → key='Done', handler='completionHandler'
242
+ _RE_EE_ON_VERB = re.compile(
243
+ r"\.\s*(on([A-Z][A-Za-z]*))\s*\(\s*([A-Za-z_]\w*)\s*\)",
244
+ re.MULTILINE,
245
+ )
246
+
247
+ # Dispatcher verbs: .emit('key') / .trigger('key') etc.
248
+ # Group: (event_key)
249
+ _RE_EE_DISPATCHER = re.compile(
250
+ r"\.\s*(?:emit|trigger|notify|dispatch|fire|publish|flush)\s*\(\s*['\"]([A-Za-z_][\w.-]*)['\"]",
251
+ re.MULTILINE,
252
+ )
253
+
254
+
255
+ def _collect_registrations(sources: dict[str, str]) -> dict[str, list[str]]:
256
+ """Scan sources for registrar calls → mapping event_key → list[handler_name]."""
257
+ event_to_handlers: dict[str, list[str]] = {}
258
+ for source_text in sources.values():
259
+ try:
260
+ # Standard: .on('key', handler) / .subscribe('key', fn) etc.
261
+ for m in _RE_EE_REGISTRAR.finditer(source_text):
262
+ event_key = m.group(1)
263
+ handler = m.group(2)
264
+ if _is_anonymous(handler):
265
+ continue
266
+ event_to_handlers.setdefault(event_key, []).append(handler)
267
+
268
+ # on[A-Z]Verb style: .onDone(handler) — key derived from verb name
269
+ for m in _RE_EE_ON_VERB.finditer(source_text):
270
+ # group(1)=full verb 'onDone', group(2)=bare key 'Done', group(3)=handler
271
+ event_key = m.group(2) # e.g. 'Done'
272
+ handler = m.group(3)
273
+ if _is_anonymous(handler):
274
+ continue
275
+ event_to_handlers.setdefault(event_key, []).append(handler)
276
+ except Exception: # noqa: BLE001
277
+ continue
278
+ return event_to_handlers
279
+
280
+
281
+ def _collect_dispatch_keys(sources: dict[str, str]) -> set[str]:
282
+ """Scan sources for dispatcher calls → set of dispatched event keys."""
283
+ keys: set[str] = set()
284
+ for source_text in sources.values():
285
+ try:
286
+ for m in _RE_EE_DISPATCHER.finditer(source_text):
287
+ keys.add(m.group(1))
288
+ except Exception: # noqa: BLE001
289
+ continue
290
+ return keys
291
+
292
+
293
+ def _is_anonymous(name: str) -> bool:
294
+ """Return True if name looks like an anonymous/inline expression, not a bare identifier.
295
+
296
+ A bare identifier is all word chars [A-Za-z_][A-Za-z0-9_]*. Anything with parens, braces,
297
+ arrows, lambda keywords, etc. is anonymous.
298
+ """
299
+ return not re.fullmatch(r"[A-Za-z_]\w*", name)
300
+
301
+
302
+ def run_event_emitter_channel(
303
+ symbols: list[dict[str, Any]],
304
+ file_sources: dict[str, str],
305
+ fanout_cap: int,
306
+ ) -> list[dict[str, Any]]:
307
+ """A1b: EventEmitter/observer dispatch channel.
308
+
309
+ Algorithm:
310
+ 1. Scan all source files for registrar calls → event_key → [handler_name].
311
+ 2. Scan all source files for dispatcher calls → set of dispatched event keys.
312
+ 3. For each event_key dispatched that has registered handlers:
313
+ a. Filter: only handlers whose name appears in the symbol index.
314
+ b. Apply fanout_cap: skip event if handler count > fanout_cap.
315
+ c. Emit synthesized call edges (source=event_key, target=handler_name).
316
+
317
+ The edge source is the event key (a string constant, not a symbol name). This is
318
+ intentional: the dispatcher is not necessarily a single method but an event concept.
319
+ Using the event key as source keeps the edge within the string-name model and lets
320
+ callers of seam_impact target the event key directly.
321
+
322
+ Never raises — all errors degrade to "no edge emitted for this file/pattern".
323
+ """
324
+ if not file_sources:
325
+ return []
326
+
327
+ result: list[dict[str, Any]] = []
328
+ try:
329
+ # Build the set of names the index knows (qualified + bare parts).
330
+ known_names = _build_known_names(symbols)
331
+
332
+ # Step 1: Collect registrations.
333
+ event_to_handlers = _collect_registrations(file_sources)
334
+ if not event_to_handlers:
335
+ return []
336
+
337
+ # Step 2: Collect dispatched keys.
338
+ dispatch_keys = _collect_dispatch_keys(file_sources)
339
+ if not dispatch_keys:
340
+ return []
341
+
342
+ # Step 3: Emit edges for matched (event_key, handler) pairs.
343
+ for event_key in sorted(dispatch_keys):
344
+ raw_handlers = event_to_handlers.get(event_key, [])
345
+ if not raw_handlers:
346
+ continue
347
+
348
+ # Filter to only named/indexed handlers; dedup; sort for determinism.
349
+ handlers = sorted(set(
350
+ h for h in raw_handlers
351
+ if not _is_anonymous(h) and (h in known_names or not known_names)
352
+ ))
353
+
354
+ if not handlers:
355
+ continue
356
+
357
+ # Apply fanout_cap: if too many handlers, skip this event entirely
358
+ # (a generic event with 100 handlers is likely a false positive pattern).
359
+ if fanout_cap > 0 and len(handlers) > fanout_cap:
360
+ logger.debug(
361
+ "synthesis: event-emitter fanout_cap=%d exceeded for event '%s' "
362
+ "(%d handlers) — skipping",
363
+ fanout_cap,
364
+ event_key,
365
+ len(handlers),
366
+ )
367
+ continue
368
+
369
+ for handler in handlers:
370
+ target = _resolve_name(handler, symbols)
371
+ result.append({
372
+ "source": event_key,
373
+ "target": target,
374
+ "kind": "call",
375
+ "confidence": "INFERRED",
376
+ "synthesized_by": CHANNEL_EVENT_EMITTER,
377
+ # Not file-scoped — the bridge supplies file_id + line at persist time.
378
+ "line": 0,
379
+ })
380
+ except Exception as exc: # noqa: BLE001
381
+ logger.warning(
382
+ "synthesis: event-emitter channel failed (%s: %s) — partial results",
383
+ type(exc).__name__,
384
+ exc,
385
+ )
386
+ return result
387
+
388
+
389
+ # ─────────────────────────────────────────────────────────────────────────────
390
+ # Shared helpers
391
+ # ─────────────────────────────────────────────────────────────────────────────
392
+
393
+
394
+ def _build_known_names(symbols: list[dict[str, Any]]) -> set[str]:
395
+ """Return all indexed symbol names plus their bare (post-last-dot) forms.
396
+
397
+ Both source-text channels filter synthesized targets to names the index
398
+ actually knows (conservatism: never emit an edge to an unknown identifier).
399
+ Adding the bare suffix lets a simple identifier in source ('myHandler') match
400
+ a qualified symbol ('Class.myHandler').
401
+ """
402
+ known: set[str] = set()
403
+ for sym in symbols:
404
+ name = sym.get("name", "") if isinstance(sym, dict) else ""
405
+ if not name:
406
+ continue
407
+ known.add(name)
408
+ if "." in name:
409
+ known.add(name.split(".")[-1])
410
+ return known
411
+
412
+
413
+ def _resolve_name(bare_name: str, symbols: list[dict[str, Any]]) -> str:
414
+ """Prefer the qualified name (Class.method) over the bare name if a unique match exists.
415
+
416
+ If exactly one symbol has the bare name as its suffix (or equals it), return that
417
+ qualified name. Otherwise return the bare name as-is (string-name-keyed contract).
418
+
419
+ WHY: synthesis channels often find bare function/callback names from source text.
420
+ Returning the qualified name where unambiguous gives downstream seam_impact/context
421
+ a better match against the symbol index.
422
+ """
423
+ matches = []
424
+ for sym in symbols:
425
+ name = sym.get("name", "") if isinstance(sym, dict) else ""
426
+ if not name:
427
+ continue
428
+ if name == bare_name or name.endswith(f".{bare_name}"):
429
+ matches.append(name)
430
+ if len(matches) == 1:
431
+ return matches[0]
432
+ # Ambiguous or not found — keep the bare name.
433
+ return bare_name
@@ -0,0 +1,103 @@
1
+ """Test-file path heuristic — pure function, no I/O, no external deps.
2
+
3
+ Used by impact.py to tag TieredEntry items with is_test (bool).
4
+
5
+ Import hierarchy: this module imports nothing from seam (no circular deps).
6
+ It may safely be imported by any analysis-layer module.
7
+
8
+ Rule (documented):
9
+ Returns True when the path satisfies ANY of:
10
+ 1. A directory SEGMENT (exact match, case-sensitive) is 'tests' or 'test'.
11
+ Segment = one component from Path.parts — NOT a substring search.
12
+ 'testdata/', 'contest/', 'attest/' etc. do NOT match.
13
+ 2. The basename (case-insensitive) matches:
14
+ test_*.py — Python test_* prefix
15
+ *_test.py — Python *_test suffix
16
+ conftest.py — pytest config / fixtures
17
+ *.spec.js — JS/TS spec files
18
+ *.spec.jsx
19
+ *.spec.ts
20
+ *.spec.tsx
21
+ *.test.js
22
+ *.test.jsx
23
+ *.test.ts
24
+ *.test.tsx
25
+
26
+ Returns False for None or empty string (safe default).
27
+
28
+ False-positive protection:
29
+ 'latest.py', 'attestation.py', 'contest.py' must NOT match.
30
+ Only Path.parts is used for directory checks — never substring search.
31
+ Basename patterns are anchored (prefix/suffix) via str.startswith /
32
+ str.endswith + explicit suffix checks.
33
+ """
34
+
35
+ from pathlib import Path
36
+
37
+ # Directory segments that indicate a test tree.
38
+ # Case-sensitive: 'tests' and 'test' are conventional in Python projects.
39
+ _TEST_DIR_SEGMENTS: frozenset[str] = frozenset({"tests", "test"})
40
+
41
+ # JS/TS spec/test double-extensions.
42
+ # e.g. 'widget.spec.ts' — we check (name_lower ends with one of these).
43
+ _DOUBLE_EXTENSIONS: tuple[str, ...] = (
44
+ ".spec.js",
45
+ ".spec.jsx",
46
+ ".spec.ts",
47
+ ".spec.tsx",
48
+ ".test.js",
49
+ ".test.jsx",
50
+ ".test.ts",
51
+ ".test.tsx",
52
+ )
53
+
54
+
55
+ def is_test_file(path: str | None) -> bool:
56
+ """Return True if path belongs to a test file; False otherwise.
57
+
58
+ Args:
59
+ path: Absolute or relative file path string, or None.
60
+
61
+ Returns:
62
+ True — file is a test file (matches any rule above).
63
+ False — file is a production file, unresolved, or path is None/empty.
64
+
65
+ Never raises; None or empty string returns False.
66
+ """
67
+ # Guard: None or empty is treated as unknown → not a test file.
68
+ if not path:
69
+ return False
70
+
71
+ p = Path(path)
72
+
73
+ # Rule 1: check every directory segment (not the final basename) for exact match.
74
+ # Path.parts gives ('/', 'project', 'tests', 'foo.py') for '/project/tests/foo.py'.
75
+ # We skip the last part (the filename itself) — only directory segments matter here.
76
+ directory_parts = p.parts[:-1] # exclude the basename
77
+ for part in directory_parts:
78
+ if part in _TEST_DIR_SEGMENTS:
79
+ return True
80
+
81
+ # Rule 2: basename pattern matching (case-insensitive).
82
+ name_lower = p.name.lower()
83
+
84
+ # conftest.py — exact match on lowercased name.
85
+ if name_lower == "conftest.py":
86
+ return True
87
+
88
+ # test_*.py — must start with 'test_' AND end with '.py'.
89
+ # The 'test_' prefix is anchored at the start, preventing 'latest.py' matches.
90
+ if name_lower.startswith("test_") and name_lower.endswith(".py"):
91
+ return True
92
+
93
+ # *_test.py — must end with '_test.py'.
94
+ # Anchored suffix prevents 'attestation.py' matches (does not end with '_test.py').
95
+ if name_lower.endswith("_test.py"):
96
+ return True
97
+
98
+ # *.spec.{js,jsx,ts,tsx} and *.test.{js,jsx,ts,tsx} — double-extension check.
99
+ for ext in _DOUBLE_EXTENSIONS:
100
+ if name_lower.endswith(ext):
101
+ return True
102
+
103
+ return False