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
|
@@ -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
|