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/analysis/steer.py ADDED
@@ -0,0 +1,282 @@
1
+ """Truncation-steer generator for seam_impact output (E4).
2
+
3
+ LEAF MODULE — pure function over plain dicts. Imports only stdlib (typing).
4
+ No database access, no config import, no IO, never raises.
5
+ Mirrors the leaf discipline of seam/analysis/byte_budget.py and
6
+ seam/analysis/relevance.py.
7
+
8
+ WHY this module exists (the usability gap it closes):
9
+ When seam_impact drops entries — via the per-tier count cap or the E1-FULL
10
+ byte ceiling — the response carries 'truncated' and 'byte_capped' counts that
11
+ are honest but inert. An agent knows entries were dropped but must guess the
12
+ remedy (raise the limit? bump max_bytes? narrow the query?). The information
13
+ needed to act is known at trim time but discarded.
14
+
15
+ generate_steer() receives the already-computed trim metadata and returns a
16
+ flat list of ready-to-act prose hints — e.g.:
17
+ "Raise limit to 17 to see 12 more WILL_BREAK upstream dependents."
18
+ "Pass max_bytes=0 for the full untrimmed blast radius."
19
+ Each hint is a complete, actionable sentence. The list is ABSENT (not empty-
20
+ and-present) when nothing was trimmed — so presence is the "there is more"
21
+ signal and absence is unambiguous.
22
+
23
+ When the byte ceiling drops ALL entries (the anti-false-safe case), the steer
24
+ always includes an explicit warning that the blast radius was trimmed to nothing
25
+ and this is NOT "no dependents" — it must not let an agent conclude the symbol
26
+ is safe to delete when its dependents were merely dropped.
27
+
28
+ Steer content rules (from E4 spec):
29
+ (a) Count-cap drops → suggest the smallest `limit` that would reveal them:
30
+ risk_summary[dir][tier] is the minimum (current kept count + omitted count).
31
+ Emits one hint per direction+tier that has count-cap omissions.
32
+ (b) Byte-ceiling drops → suggest raising or zeroing `max_bytes`.
33
+ Emits one hint covering the total byte-ceiling drop.
34
+ (c) All-trimmed (entries lists all empty, risk_summary non-empty) → explicit
35
+ anti-false-safe line. Emitted BEFORE the byte-ceiling remedy hint.
36
+ (d) Both caps fired → both hints without double-counting. Count-cap portion =
37
+ truncated[dir][tier] - byte_capped_portion_per_tier. Since byte_capped.omitted
38
+ is a total (not per-direction/tier), we output the two remedies separately:
39
+ count-cap hints first, then the byte-ceiling hint.
40
+
41
+ Null-contract:
42
+ `byte_capped=None` means the byte ceiling did not fire (same null-contract as
43
+ the handler's _apply_byte_ceiling usage). An empty {} byte_capped is handled
44
+ defensively (treated as not-fired).
45
+
46
+ Never raises. On any exception returns [].
47
+ """
48
+
49
+ import logging
50
+ from typing import Any
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ # Default tier/direction priority. These are DEFAULTS only — the handler injects the
55
+ # canonical impact.py TIER_* constants via the tier_order/direction_order params so the
56
+ # names have a single source of truth (the leaf stays standalone-testable without
57
+ # importing impact.py, preserving leaf discipline). Order matters: highest priority
58
+ # first (WILL_BREAK before MAY_NEED_TESTING) for DISPLAY; the byte-portion deduction
59
+ # walks this reversed (least-valuable first) to mirror fit_to_byte_budget's trim order.
60
+ _DEFAULT_TIER_ORDER = ("WILL_BREAK", "LIKELY_AFFECTED", "MAY_NEED_TESTING")
61
+ _DEFAULT_DIRECTION_ORDER = ("upstream", "downstream")
62
+
63
+
64
+ def generate_steer(
65
+ *,
66
+ truncated: dict[str, dict[str, int]],
67
+ byte_capped: dict[str, int] | None,
68
+ risk_summary: dict[str, dict[str, int]],
69
+ limit: int,
70
+ max_bytes: int,
71
+ tier_order: tuple[str, ...] = _DEFAULT_TIER_ORDER,
72
+ direction_order: tuple[str, ...] = _DEFAULT_DIRECTION_ORDER,
73
+ ) -> list[str]:
74
+ """Generate actionable truncation-steer hints from seam_impact trim metadata.
75
+
76
+ Called by handle_seam_impact AFTER per-tier capping and byte-ceiling trimming
77
+ have both run. Returns a flat list of ready-to-act prose strings. Returns []
78
+ (empty, never None) when nothing was trimmed or on any internal error.
79
+
80
+ Args:
81
+ truncated: The merged per-direction/tier omitted count map.
82
+ {direction: {tier: count}}. Includes BOTH count-cap and
83
+ byte-ceiling drops (additive, as in the handler). May be {}.
84
+ byte_capped: The byte_capped metadata dict {"limit": int, "omitted": int}
85
+ when the byte ceiling fired and dropped ≥1 entry, or None
86
+ when the ceiling did not fire (unlimited or nothing trimmed).
87
+ risk_summary: The honest pre-cap total per direction+tier.
88
+ {direction: {tier: count}}. Used to compute the suggested
89
+ limit value (minimum that reveals all omitted entries).
90
+ limit: The per-tier count cap that was applied (SEAM_IMPACT_MAX_RESULTS
91
+ or the per-call override). Used in hint phrasing.
92
+ max_bytes: The byte budget that was applied (SEAM_IMPACT_MAX_BYTES or the
93
+ per-call override). Used in hint phrasing. 0 = unlimited.
94
+ tier_order: Risk tiers in DISPLAY (highest-priority-first) order. Injected by
95
+ the handler from impact.py's TIER_* constants (single source of truth).
96
+ direction_order: Directions in display order. Injected by the handler.
97
+
98
+ Returns:
99
+ list[str]: Prose hint lines. Empty list when nothing to say.
100
+ Never raises — any exception returns [].
101
+ """
102
+ try:
103
+ return _generate_steer_impl(
104
+ truncated=truncated,
105
+ byte_capped=byte_capped,
106
+ risk_summary=risk_summary,
107
+ limit=limit,
108
+ max_bytes=max_bytes,
109
+ tier_order=tier_order,
110
+ direction_order=direction_order,
111
+ )
112
+ except Exception:
113
+ # Safety net: never break the read path. Empty steer is always safe.
114
+ # Logged (matching _apply_byte_ceiling's failure precedent) so a malformed-input
115
+ # bug that silently drops the steer — including the all-trimmed anti-false-safe
116
+ # warning generated inside this path — is observable rather than invisible.
117
+ logger.warning("seam_impact steer generation failed; omitting next_actions", exc_info=True)
118
+ return []
119
+
120
+
121
+ def _total_risk(risk_summary: Any) -> int:
122
+ """Sum all counts in risk_summary. Returns 0 on malformed input."""
123
+ if not isinstance(risk_summary, dict):
124
+ return 0
125
+ total = 0
126
+ for dir_map in risk_summary.values():
127
+ if not isinstance(dir_map, dict):
128
+ continue
129
+ for count in dir_map.values():
130
+ if isinstance(count, int):
131
+ total += count
132
+ return total
133
+
134
+
135
+ def _total_truncated(truncated: Any) -> int:
136
+ """Sum all counts in truncated. Returns 0 on malformed input."""
137
+ if not isinstance(truncated, dict):
138
+ return 0
139
+ total = 0
140
+ for dir_map in truncated.values():
141
+ if not isinstance(dir_map, dict):
142
+ continue
143
+ for count in dir_map.values():
144
+ if isinstance(count, int):
145
+ total += count
146
+ return total
147
+
148
+
149
+ def _is_all_trimmed(
150
+ truncated: dict[str, dict[str, int]],
151
+ byte_capped: dict[str, int] | None,
152
+ risk_summary: dict[str, dict[str, int]],
153
+ ) -> bool:
154
+ """Return True when the byte ceiling dropped ALL entries (entries shown == 0).
155
+
156
+ All-trimmed means: risk_summary is non-empty (there ARE real dependents)
157
+ AND total truncated == total risk_summary (every entry was dropped).
158
+ We only flag this when byte_capped is present (the ceiling fired), because
159
+ all-entries-count-capped is a normal use case (the count cap is opt-in at
160
+ limit=0), while all-entries-byte-capped is the dangerous false-safe case.
161
+ """
162
+ if not byte_capped or not isinstance(byte_capped, dict):
163
+ return False
164
+ byte_omitted = byte_capped.get("omitted", 0)
165
+ if not isinstance(byte_omitted, int) or byte_omitted <= 0:
166
+ return False
167
+ total_risk = _total_risk(risk_summary)
168
+ total_trunc = _total_truncated(truncated)
169
+ # All-trimmed: every entry that exists in risk_summary was dropped.
170
+ return total_risk > 0 and total_trunc >= total_risk
171
+
172
+
173
+ def _generate_steer_impl(
174
+ *,
175
+ truncated: dict[str, dict[str, int]],
176
+ byte_capped: dict[str, int] | None,
177
+ risk_summary: dict[str, dict[str, int]],
178
+ limit: int,
179
+ max_bytes: int,
180
+ tier_order: tuple[str, ...],
181
+ direction_order: tuple[str, ...],
182
+ ) -> list[str]:
183
+ """Inner implementation — only called when input types are validated by caller.
184
+
185
+ Separated from the public function so the try/except wraps the whole body.
186
+ """
187
+ # Validate core inputs are dicts to avoid TypeErrors below.
188
+ if not isinstance(truncated, dict):
189
+ return []
190
+ if not isinstance(risk_summary, dict):
191
+ return []
192
+
193
+ # Early exit: nothing was trimmed at all.
194
+ byte_capped_clean = byte_capped if isinstance(byte_capped, dict) else None
195
+ byte_omitted = 0
196
+ if byte_capped_clean:
197
+ raw_omitted = byte_capped_clean.get("omitted", 0)
198
+ byte_omitted = raw_omitted if isinstance(raw_omitted, int) else 0
199
+
200
+ total_trunc = _total_truncated(truncated)
201
+ if total_trunc == 0 and byte_omitted == 0:
202
+ return []
203
+
204
+ hints: list[str] = []
205
+
206
+ # ── (c) All-trimmed-to-nothing warning (must come first — most critical) ──
207
+ if _is_all_trimmed(truncated, byte_capped_clean, risk_summary):
208
+ hints.append(
209
+ "WARNING: The blast radius was trimmed to nothing by the byte ceiling. "
210
+ "This is NOT 'no dependents' — dependents exist but were omitted. "
211
+ "Pass max_bytes=0 for the full untrimmed blast radius before concluding "
212
+ "this symbol is safe to delete."
213
+ )
214
+ # After the all-trimmed warning, still emit the max_bytes remedy below
215
+ # (it is the actionable fix). Skip count-cap hints since the byte ceiling
216
+ # dominated and the count cap is irrelevant here.
217
+ if byte_capped_clean:
218
+ byte_limit = byte_capped_clean.get("limit", max_bytes)
219
+ if not isinstance(byte_limit, int):
220
+ byte_limit = max_bytes
221
+ hints.append(
222
+ f"Pass max_bytes=0 (currently {byte_limit}) for the full untrimmed blast radius."
223
+ )
224
+ return hints
225
+
226
+ # ── (a) Count-cap hints — one per direction+tier with count-cap omissions ──
227
+ # truncated[dir][tier] is the MERGED omitted count (count-cap drops + byte-ceiling
228
+ # drops, additive). The two have DIFFERENT remedies — count-cap drops are revealed by
229
+ # raising `limit`, byte-ceiling drops only by raising `max_bytes` — so attributing a
230
+ # byte-dropped entry to the count cap yields a hint ("Raise limit to N") that, when
231
+ # followed, does NOT reveal the entry (the agent re-queries, sees the same truncation,
232
+ # and may wrongly conclude the dependent is phantom).
233
+ #
234
+ # The count-cap portion is computable EXACTLY from risk_summary and limit, with no
235
+ # byte-drop data: the per-tier count cap keeps min(entries, limit), so it drops
236
+ # max(risk - limit, 0) from each tier (the cap is the SAME limit for every tier). This
237
+ # is exact — a tier that was NOT count-capped (risk <= limit) but byte-dropped yields
238
+ # portion == 0 and gets NO 'raise limit' hint, only the byte remedy below. (This is why
239
+ # we do not split the global byte_capped.omitted scalar across tiers — that scalar
240
+ # cannot say which tiers it came from, whereas max(risk - limit, 0) is exact.)
241
+ if limit > 0: # limit == 0 (unlimited) ⇒ no count-cap drops anywhere
242
+ for direction in direction_order:
243
+ dir_risk = risk_summary.get(direction, {})
244
+ if not isinstance(dir_risk, dict):
245
+ continue
246
+ dir_trunc = truncated.get(direction, {})
247
+ if not isinstance(dir_trunc, dict):
248
+ dir_trunc = {}
249
+ for tier in tier_order:
250
+ merged = dir_trunc.get(tier, 0)
251
+ if not isinstance(merged, int) or merged <= 0:
252
+ continue # nothing omitted from this tier at all
253
+ risk = dir_risk.get(tier, 0)
254
+ if not isinstance(risk, int):
255
+ risk = 0
256
+ # Exact count-cap drop; clamp to merged (can't exceed the total omitted).
257
+ portion = min(max(risk - limit, 0), merged)
258
+ if portion <= 0:
259
+ continue # this tier's omissions were ALL byte-ceiling drops
260
+ # Suggest the minimum limit that reveals every entry in this tier
261
+ # (risk = limit + portion when portion > 0, so this equals `risk`). Clamp
262
+ # to never suggest a value <= the current limit (which would tell the agent
263
+ # to LOWER the cap and HIDE entries).
264
+ suggested_limit = max(risk, limit + portion)
265
+ hints.append(
266
+ f"Raise limit to {suggested_limit} to see {portion} more "
267
+ f"{tier} {direction} dependents "
268
+ f"(currently capped at {limit})."
269
+ )
270
+
271
+ # ── (b) Byte-ceiling hint ─────────────────────────────────────────────────
272
+ if byte_omitted > 0 and byte_capped_clean:
273
+ byte_limit = byte_capped_clean.get("limit", max_bytes)
274
+ if not isinstance(byte_limit, int):
275
+ byte_limit = max_bytes
276
+ hints.append(
277
+ f"Pass max_bytes=0 (currently {byte_limit}) for the full "
278
+ f"untrimmed blast radius ({byte_omitted} "
279
+ f"{'entry' if byte_omitted == 1 else 'entries'} trimmed by byte ceiling)."
280
+ )
281
+
282
+ return hints
@@ -0,0 +1,253 @@
1
+ """Edge-synthesis engine — pure function, no DB access, deterministic, never raises.
2
+
3
+ LAYER: leaf — imports only stdlib + synthesis_channels. No DB access.
4
+
5
+ This module implements whole-graph edge synthesis: given the already-extracted symbols,
6
+ edges, and per-file source text, it synthesizes additional edges that a parser cannot see
7
+ (dynamic dispatch, interface dispatch, observer callbacks, etc.) and returns them as a list
8
+ of edge-like dicts ready for persistence.
9
+
10
+ Channels implemented:
11
+ A2 — interface-override (this module): for each concrete class C that has an
12
+ extends/implements edge to a base/interface B, link every method of B to every
13
+ same-name method of C as a synthesized 'call' edge. Deliberate OVER-APPROXIMATION.
14
+ Direct subtypes only (one hop). Bounded by fanout_cap.
15
+
16
+ A1a — closure-collection (synthesis_channels.py): collection field iterated + element
17
+ invoked, paired with sites that append a closure to the same field (cross-file).
18
+ Emit dispatcher → appended-callback as synthesized call edge.
19
+ CRITICAL NEGATIVE: iteration without element invocation → no edge emitted.
20
+
21
+ A1b — event-emitter (synthesis_channels.py): registrar verbs (on[A-Z][A-Za-z]*, subscribe,
22
+ addListener, addEventListener, register, watch, listen, addCallback) paired with
23
+ dispatcher verbs (emit, trigger, notify, dispatch, fire, publish, flush), keyed
24
+ by event-string literal. Only NAMED handlers produce edges.
25
+
26
+ Design rules (shared with clustering.py):
27
+ - Public function synthesize_edges() is PURE: no side effects, deterministic output.
28
+ - Never raises: all internal errors degrade to "no edge emitted" (same contract as
29
+ parsers: conservatism over explosions). The caller (synthesis_index.py) handles errors.
30
+ - Cap-bounded: fanout_cap limits edges per base-method/field/event to prevent explosions.
31
+ - Pairing by string names only — no node IDs, no graph DB. Mirrors how the rest of
32
+ Seam stores edges: source/target are plain symbol name strings.
33
+ """
34
+
35
+ import logging
36
+ from typing import Any
37
+
38
+ from seam.analysis.synthesis_channels import (
39
+ run_closure_collection_channel,
40
+ run_event_emitter_channel,
41
+ )
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ # Channel identifier for the interface-override synthesis.
46
+ # Stored verbatim in edges.synthesized_by for provenance tracing.
47
+ _CHANNEL_INTERFACE_OVERRIDE = "interface-override"
48
+
49
+ # Inheritance edge kinds that trigger A2 override fan-out.
50
+ _INHERITANCE_KINDS = frozenset({"extends", "implements"})
51
+
52
+
53
+ def synthesize_edges(
54
+ symbols: list[dict[str, Any]],
55
+ edges: list[dict[str, Any]],
56
+ *,
57
+ file_sources: dict[str, str],
58
+ fanout_cap: int,
59
+ ) -> list[dict[str, Any]]:
60
+ """Synthesize dynamic-dispatch edges from the whole-graph symbol and edge data.
61
+
62
+ This is the main entry point for the synthesis engine. It is PURE: it reads
63
+ from the supplied in-memory data structures and returns a list of new edge dicts,
64
+ never touching the database or raising any exception.
65
+
66
+ Args:
67
+ symbols: List of symbol dicts from the DB (must have 'name' and 'kind' keys).
68
+ edges: List of edge dicts from the DB (must have 'source', 'target', 'kind').
69
+ file_sources: Dict mapping file path → source text (reserved for A1 channels;
70
+ the A2 channel does not use source text but accepts it for a stable
71
+ API so slice 2 can add channels without changing this signature).
72
+ fanout_cap: Maximum synthesized edges per base-method across all implementations.
73
+ 0 = unlimited (no cap applied).
74
+
75
+ Returns:
76
+ List of edge-like dicts, each with keys:
77
+ source, target, kind, confidence, synthesized_by
78
+ (and sentinel values for file/line since synthesized edges are not file-scoped).
79
+
80
+ Never raises: any internal error degrades to returning whatever edges were built
81
+ before the error occurred, and logs a warning.
82
+ """
83
+ result: list[dict[str, Any]] = []
84
+
85
+ # A2: interface-override channel (symbol/edge-graph based, no source text needed).
86
+ try:
87
+ result.extend(_run_a2_interface_override(symbols, edges, fanout_cap))
88
+ except Exception as exc: # noqa: BLE001
89
+ logger.warning(
90
+ "synthesis: A2 interface-override channel failed (%s: %s) — "
91
+ "no edges from this channel",
92
+ type(exc).__name__,
93
+ exc,
94
+ )
95
+
96
+ # A1a: closure-collection channel (source-text based).
97
+ try:
98
+ result.extend(run_closure_collection_channel(symbols, file_sources, fanout_cap))
99
+ except Exception as exc: # noqa: BLE001
100
+ logger.warning(
101
+ "synthesis: closure-collection channel failed (%s: %s) — "
102
+ "no edges from this channel",
103
+ type(exc).__name__,
104
+ exc,
105
+ )
106
+
107
+ # A1b: event-emitter channel (source-text based).
108
+ try:
109
+ result.extend(run_event_emitter_channel(symbols, file_sources, fanout_cap))
110
+ except Exception as exc: # noqa: BLE001
111
+ logger.warning(
112
+ "synthesis: event-emitter channel failed (%s: %s) — "
113
+ "no edges from this channel",
114
+ type(exc).__name__,
115
+ exc,
116
+ )
117
+
118
+ return result
119
+
120
+
121
+ def _run_a2_interface_override(
122
+ symbols: list[dict[str, Any]],
123
+ edges: list[dict[str, Any]],
124
+ fanout_cap: int,
125
+ ) -> list[dict[str, Any]]:
126
+ """A2 channel: interface/base→implementation method fan-out.
127
+
128
+ Algorithm (one hop, no transitive walk, deliberate over-approximation):
129
+
130
+ 1. Build a set of all symbol names that are methods (kind='method').
131
+ Symbol names for methods follow the 'ClassName.methodName' qualified pattern.
132
+
133
+ 2. For each symbol with an 'extends' or 'implements' edge pointing to a base B,
134
+ the source of that edge is the concrete class C (C extends/implements B).
135
+
136
+ 3. For each method B.m of base B that exists in the symbol table,
137
+ check if C.m also exists. If so, emit:
138
+ source=B.m, target=C.m, kind='call', confidence='INFERRED',
139
+ synthesized_by='interface-override'
140
+
141
+ 4. Apply fanout_cap: if fanout_cap > 0, cap at most fanout_cap implementations
142
+ per base method (i.e. at most fanout_cap edges per source='B.m').
143
+
144
+ WHY over-approximation: correctly resolving which implementation runs at a given
145
+ call site would require type inference, MRO, or runtime analysis. Seam's synthesis
146
+ contract is: emit ALL plausible dispatch targets (conservative on false negatives),
147
+ with a cap to prevent graph explosions. An agent querying seam_impact sees ALL
148
+ implementations — which is the right default for blast-radius analysis.
149
+
150
+ WHY one-hop only: if C extends B extends A, this channel emits B.m→C.m and A.m→B.m
151
+ separately (via their respective direct inheritance edges) — both are emitted in the
152
+ same pass because both are direct inheritance edges in the edges table. No transitive
153
+ walk is required to achieve this; the direct-edge scan naturally covers all levels.
154
+ """
155
+ # ── Step 1: Build symbol lookup structures ────────────────────────────────
156
+ # method_names: set of all qualified method names 'ClassName.methodName'.
157
+ # class_methods: maps class/interface name → set of bare method names.
158
+ method_names: set[str] = set()
159
+ class_methods: dict[str, set[str]] = {}
160
+
161
+ for sym in symbols:
162
+ try:
163
+ name = sym.get("name", "")
164
+ kind = sym.get("kind", "")
165
+ if not name or not kind:
166
+ continue
167
+ if kind == "method" and "." in name:
168
+ method_names.add(name)
169
+ cls, bare = name.split(".", 1)
170
+ class_methods.setdefault(cls, set()).add(bare)
171
+ except Exception: # noqa: BLE001
172
+ # Malformed symbol dict — skip silently (never-raise contract).
173
+ continue
174
+
175
+ # ── Step 2: Build inheritance map: subclass → set of direct bases ─────────
176
+ # sub_to_bases: maps 'ConcreteClass' → {'IBase1', 'IBase2', ...}
177
+ sub_to_bases: dict[str, set[str]] = {}
178
+
179
+ for edge in edges:
180
+ try:
181
+ kind = edge.get("kind", "")
182
+ if kind not in _INHERITANCE_KINDS:
183
+ continue
184
+ src = edge.get("source", "")
185
+ tgt = edge.get("target", "")
186
+ if not src or not tgt:
187
+ continue
188
+ sub_to_bases.setdefault(src, set()).add(tgt)
189
+ except Exception: # noqa: BLE001
190
+ continue
191
+
192
+ # ── Step 3: Synthesize override call edges ────────────────────────────────
193
+ # For each (concrete_class, base) pair from inheritance edges,
194
+ # for each method of base, check if concrete_class also has that method.
195
+ # Track per-base-method emission count to enforce fanout_cap.
196
+
197
+ # base_method_count: base_method_name → number of synth edges emitted so far
198
+ base_method_count: dict[str, int] = {}
199
+ result: list[dict[str, Any]] = []
200
+
201
+ # Sort for determinism (same graph → same edges, same order).
202
+ for sub_cls in sorted(sub_to_bases):
203
+ bases = sorted(sub_to_bases[sub_cls])
204
+ for base in bases:
205
+ base_bare_methods = class_methods.get(base, set())
206
+ if not base_bare_methods:
207
+ continue # Base has no indexed methods — skip.
208
+
209
+ sub_bare_methods = class_methods.get(sub_cls, set())
210
+ if not sub_bare_methods:
211
+ continue # Concrete class has no methods — skip.
212
+
213
+ # Emit an edge for each method name shared between base and subclass.
214
+ for bare_method in sorted(base_bare_methods):
215
+ if bare_method not in sub_bare_methods:
216
+ continue # No matching implementation in subclass — skip.
217
+
218
+ base_method_name = f"{base}.{bare_method}"
219
+ impl_method_name = f"{sub_cls}.{bare_method}"
220
+
221
+ # Apply fanout_cap per base method.
222
+ if fanout_cap > 0:
223
+ current_count = base_method_count.get(base_method_name, 0)
224
+ if current_count >= fanout_cap:
225
+ logger.debug(
226
+ "synthesis: A2 fanout_cap=%d reached for %s — skipping %s",
227
+ fanout_cap,
228
+ base_method_name,
229
+ impl_method_name,
230
+ )
231
+ continue
232
+ base_method_count[base_method_name] = current_count + 1
233
+
234
+ result.append({
235
+ "source": base_method_name,
236
+ "target": impl_method_name,
237
+ "kind": "call",
238
+ "confidence": "INFERRED",
239
+ "synthesized_by": _CHANNEL_INTERFACE_OVERRIDE,
240
+ # Synthesized edges are not file-scoped. The bridge (index_synthesis)
241
+ # stores them under the synthetic ':synthesis:' file row and supplies
242
+ # file_id + line at persist time — the leaf emits neither sentinel.
243
+ "line": 0,
244
+ })
245
+
246
+ logger.debug(
247
+ "synthesis: A2 interface-override channel emitted %d edges "
248
+ "(%d subclass→base pairs, %d base methods checked)",
249
+ len(result),
250
+ sum(len(v) for v in sub_to_bases.values()),
251
+ len(base_method_count),
252
+ )
253
+ return result