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,615 @@
1
+ """Impact-shaping machinery and handle_seam_impact handler.
2
+
3
+ Extracted from seam/server/tools.py (Slice 2, P2 #103) as a pure mechanical split.
4
+ No logic change — byte-identical output before and after the extraction.
5
+
6
+ Contains:
7
+ - _serialize_tier_entry (used by tests directly)
8
+ - _prioritize_tier_entries (used by tests directly)
9
+ - _compute_self_context
10
+ - _shape_tier_group
11
+ - _BYTE_CEILING_TRUNCATED_RESERVE, _count_direction_entries, _apply_byte_ceiling
12
+ - handle_seam_impact
13
+ - _STEER_RESERVE_MARGIN, _attach_steer
14
+
15
+ Import dependency: impact_handler → handler_common (one direction only, no cycle).
16
+ """
17
+
18
+ import logging
19
+ import sqlite3
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import seam.config as config
24
+ from seam.analysis import impact as impact_module
25
+ from seam.analysis.byte_budget import fit_to_byte_budget, serialized_size
26
+ from seam.analysis.relevance import order_by_relevance, owning_container, partition_self_refs
27
+ from seam.analysis.steer import generate_steer
28
+ from seam.query.names import get_member_names, is_container_symbol
29
+ from seam.server.handler_common import (
30
+ _apply_verbosity,
31
+ _invalid_input,
32
+ _maybe_attach_staleness,
33
+ _relativize,
34
+ _resolve_uid,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # ── Tier entry serialization ──────────────────────────────────────────────────
40
+
41
+
42
+ def _serialize_tier_entry(
43
+ entry: dict[str, Any],
44
+ root: Path,
45
+ verbose: bool,
46
+ omit_null_candidate: bool = False,
47
+ ) -> dict[str, Any]:
48
+ """Serialize a single TieredEntry dict from the analysis layer.
49
+
50
+ Relativizes file paths, includes Phase 5 provenance fields, and applies
51
+ verbosity stripping. Extracted to keep the main handler readable.
52
+
53
+ E1: when omit_null_candidate is True, the `best_candidate` key is DROPPED
54
+ when its value is null. best_candidate is only meaningful for AMBIGUOUS
55
+ entries; for EXTRACTED/INFERRED it is always null and carries no signal, so
56
+ omitting it is lossless (null ≡ absent) and reclaims ~25 B/entry. In lean
57
+ mode (_apply_verbosity already stripped it) this is a no-op.
58
+
59
+ E4: when SEAM_EDGE_PROVENANCE=on, emits:
60
+ - 'kind': the edge kind of the final hop (always present, NOT in _HEAVY_FIELDS
61
+ because it is a core field kept in lean mode — like 'confidence').
62
+ - 'synthesized_by': synthesis channel name when heuristic, null for static.
63
+ In _HEAVY_FIELDS → stripped in lean mode (verbose=False), just like resolved_by.
64
+ IMPORTANT: null is RETAINED in verbose mode (unlike best_candidate which is
65
+ E1-omitted). For synthesized_by, null = "static edge", which is the common,
66
+ informative case and must not be dropped.
67
+ When SEAM_EDGE_PROVENANCE=off, neither 'kind' nor 'synthesized_by' is emitted →
68
+ byte-identical pre-E4 output.
69
+ """
70
+ base: dict[str, Any] = {
71
+ "name": entry["name"],
72
+ "distance": entry["distance"],
73
+ "confidence": entry["confidence"],
74
+ # Phase 5: resolved_by carries import-promotion provenance.
75
+ # null when name-count fast-path was used (repo_root absent or "off").
76
+ "resolved_by": entry.get("resolved_by"),
77
+ "tier": entry["tier"],
78
+ "file": _relativize(entry["file"], root) if entry["file"] is not None else None,
79
+ "is_test": entry["is_test"],
80
+ # Phase 5: best_candidate surfaces the most-proximate declaring
81
+ # file for AMBIGUOUS entries (PRD story 6). Null for non-AMBIGUOUS or
82
+ # when proximity data was unavailable. Relativized like other file paths.
83
+ "best_candidate": (
84
+ _relativize(entry["best_candidate"], root)
85
+ if entry.get("best_candidate") is not None
86
+ else None
87
+ ),
88
+ }
89
+
90
+ # E4: emit edge provenance fields when the knob is on.
91
+ # 'kind' is always kept (not in _HEAVY_FIELDS); 'synthesized_by' is in
92
+ # _HEAVY_FIELDS and gets stripped by _apply_verbosity when verbose=False.
93
+ if config.SEAM_EDGE_PROVENANCE == "on":
94
+ base["kind"] = entry.get("kind", "") # defensive: empty string for pre-E4 entries
95
+ base["synthesized_by"] = entry.get("synthesized_by") # null = static, retained
96
+
97
+ record = _apply_verbosity(base, verbose)
98
+ if omit_null_candidate and record.get("best_candidate") is None:
99
+ record.pop("best_candidate", None)
100
+ return record
101
+
102
+
103
+ def _prioritize_tier_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
104
+ """Order production (is_test=False) entries before test entries within a tier.
105
+
106
+ Stable sort: preserves the analysis layer's BFS/distance order WITHIN the
107
+ production and test groups (Python's sort is stable). Applied BEFORE the
108
+ per-tier cap so that when the cap drops entries, production callers — what an
109
+ agent assessing blast radius actually cares about — survive ahead of test
110
+ dependents. WHY this matters: in a test-heavy repo a hub symbol's tier can be
111
+ dominated by test callers (e.g. rescore had 52 test vs 9 production callers in
112
+ LIKELY_AFFECTED), pushing the production callers past the cap of 25 and out of
113
+ the default output entirely. Token budget is unchanged (still <= limit/tier).
114
+ """
115
+ return sorted(entries, key=lambda e: e.get("is_test", False))
116
+
117
+
118
+ def _compute_self_context(
119
+ conn: sqlite3.Connection,
120
+ target: str,
121
+ ) -> tuple[str | None, set[str]]:
122
+ """Resolve the target's container and own member-name set for self-ref ranking.
123
+
124
+ Returns (container, self_names) where:
125
+ - container is the class/struct the target belongs to (the target itself when
126
+ it IS a container, or its owning container when it's a method like "Foo.bar").
127
+ None when the target is a free function / bare name with no container — such a
128
+ target has no self-references and ordering falls back to production-before-test.
129
+ - self_names is {container} ∪ {bare member names}. The owning_container() check in
130
+ classify_self_ref handles qualified member entries ("Foo.bar"); self_names
131
+ carries the container name and the BARE member entries ("bar") that
132
+ owning_container() cannot resolve.
133
+
134
+ WHY resolve the container even for a method target: querying impact on a single
135
+ method "Foo.bar" should still surface EXTERNAL callers ahead of "Foo"'s other
136
+ methods — those siblings live in the same file the developer is already editing,
137
+ so they are low-signal self-references just like in the class-level case.
138
+
139
+ Never raises (delegates to names.py helpers, which never raise).
140
+ """
141
+ if is_container_symbol(conn, target):
142
+ container: str | None = target
143
+ else:
144
+ # Method ("Foo.bar") -> "Foo"; bare function ("run") -> None (no container).
145
+ container = owning_container(target)
146
+
147
+ if container is None:
148
+ return None, set()
149
+
150
+ members = get_member_names(conn, container) # bare names, capped by config
151
+ self_names = {container, *members}
152
+ return container, self_names
153
+
154
+
155
+ def _shape_tier_group(
156
+ tier_group: dict[str, list[dict[str, Any]]],
157
+ root: Path,
158
+ *,
159
+ verbose: bool,
160
+ effective_limit: int | None,
161
+ relevance_on: bool,
162
+ self_ref_mode: str,
163
+ container: str | None,
164
+ self_names: set[str],
165
+ omit_null_candidate: bool = False,
166
+ ) -> tuple[dict[str, list[dict[str, Any]]], dict[str, int], int]:
167
+ """Order, cap, and serialize one direction's tier group (E2/E3 output shaping).
168
+
169
+ Returns (capped_tiers, dir_truncated, dir_hidden_self_refs):
170
+ - capped_tiers — {tier: [serialized entries]} after ordering + cap.
171
+ - dir_truncated — {tier: count omitted by the per-tier cap}.
172
+ - dir_hidden_self_refs — count of self-refs dropped in "hide" mode (else 0).
173
+
174
+ Ordering runs BEFORE the cap so the cap sheds the lowest-relevance entries first.
175
+ The analysis layer's ascending-distance order is preserved within each relevance
176
+ group by the stable sort, so entries[:N] keeps the closest, highest-signal dependents.
177
+ """
178
+ capped_tiers: dict[str, list[dict[str, Any]]] = {}
179
+ dir_truncated: dict[str, int] = {}
180
+ dir_hidden_self_refs = 0
181
+
182
+ for tier, entries in tier_group.items():
183
+ if not relevance_on:
184
+ # Relevance off: byte-identical revert to production-before-test.
185
+ entries = _prioritize_tier_entries(entries)
186
+ elif self_ref_mode == "hide":
187
+ # Drop the target's own members entirely; count them; order the remaining
188
+ # externals production-before-test. risk_summary (counted by the caller
189
+ # before this) still includes self-refs, so the blast radius stays honest —
190
+ # the dropped members surface as hidden_self_refs.
191
+ external, self_refs = partition_self_refs(entries, container, self_names)
192
+ dir_hidden_self_refs += len(self_refs)
193
+ entries = order_by_relevance(external, container, self_names)
194
+ else:
195
+ # "rank" (default) or "show": keep everything, externals/production first
196
+ # and self-references last (so the cap sheds them first).
197
+ entries = order_by_relevance(entries, container, self_names)
198
+
199
+ if effective_limit is not None and len(entries) > effective_limit:
200
+ kept = entries[:effective_limit]
201
+ dir_truncated[tier] = len(entries) - effective_limit
202
+ else:
203
+ kept = entries
204
+ dir_truncated[tier] = 0
205
+
206
+ # Serialize each kept entry: relativize paths + apply verbose stripping +
207
+ # E1 null-best_candidate omission.
208
+ capped_tiers[tier] = [
209
+ _serialize_tier_entry(entry, root, verbose, omit_null_candidate) for entry in kept
210
+ ]
211
+
212
+ return capped_tiers, dir_truncated, dir_hidden_self_refs
213
+
214
+
215
+ # Worst-case size (chars) of the trailing `truncated` structure the byte pass can add
216
+ # on top of what the count cap already wrote: 6 (direction × tier) slots in the CLI emit
217
+ # serialization. Reserved (with the exact byte_capped size) so the FINAL response —
218
+ # entries PLUS the trailing byte_capped/truncated metadata — still fits the budget,
219
+ # making the ceiling a hard guarantee rather than entries-only.
220
+ _BYTE_CEILING_TRUNCATED_RESERVE = 200
221
+
222
+
223
+ def _count_direction_entries(response: dict[str, Any]) -> int:
224
+ """Total entries across all direction-tier lists — the upper bound on `omitted`."""
225
+ total = 0
226
+ for direction in ("upstream", "downstream"):
227
+ dir_group = response.get(direction)
228
+ if isinstance(dir_group, dict):
229
+ for tier_val in dir_group.values():
230
+ if isinstance(tier_val, list):
231
+ total += len(tier_val)
232
+ return total
233
+
234
+
235
+ def _apply_byte_ceiling(
236
+ response: dict[str, Any], budget: int, *, extra_reserve: int = 0
237
+ ) -> dict[str, Any]:
238
+ """Apply the E1-FULL byte ceiling to a fully-assembled seam_impact response.
239
+
240
+ Runs AFTER the per-tier count cap and E2/E3 relevance ordering. When budget > 0 and
241
+ the response does not already fit, trims entries (via fit_to_byte_budget) from the
242
+ least-valuable end, merges the byte-dropped counts into response["truncated"]
243
+ additively (so risk_summary - shown == truncated holds end-to-end), and sets
244
+ response["byte_capped"] = {"limit", "omitted"}.
245
+
246
+ Hard-ceiling guarantee: the trim runs against `budget - reserve`, where `reserve`
247
+ is the exact byte_capped size plus a worst-case allowance for the `truncated` growth
248
+ this function appends afterwards — so the FINAL serialized response (entries + that
249
+ trailing metadata) stays within `budget`. The only exception is a `budget` smaller
250
+ than the irreducible envelope, where no entries fit at all.
251
+
252
+ When budget <= 0: returns the response unchanged (byte-identical revert).
253
+ When the response already fits: returns it unchanged, byte_capped NOT added (so a
254
+ generous budget is a true no-op).
255
+
256
+ Never raises — the whole body is guarded; on any failure the untrimmed response is
257
+ returned. If the trim degrades to a no-op despite the response NOT fitting (the leaf's
258
+ never-raises safety net fired), that silent path is logged so it is observable.
259
+
260
+ Args:
261
+ response: Fully-assembled seam_impact response dict (non-mutating).
262
+ budget: SEAM_IMPACT_MAX_BYTES (from param). 0 or negative = unlimited.
263
+ extra_reserve: Additional bytes to hold back from the trim budget (E4). The
264
+ handler passes the serialized size of the `next_actions` steer here so
265
+ the FINAL response — entries + byte_capped/truncated + next_actions —
266
+ still fits `budget`. byte_capped["limit"] keeps reporting the true
267
+ `budget` (not the reduced trim budget), so the reported ceiling is honest.
268
+
269
+ Returns:
270
+ The (possibly trimmed) response dict with merged truncated + byte_capped.
271
+ """
272
+ if budget <= 0:
273
+ return response
274
+
275
+ try:
276
+ # Already within budget (including the steer the handler will append) → no trim.
277
+ if serialized_size(response) + extra_reserve <= budget:
278
+ return response
279
+
280
+ # Reserve room for the trailing metadata the merge appends, so the final
281
+ # response still fits. byte_capped is sized exactly (omitted <= entry count);
282
+ # truncated growth uses a worst-case 6-slot allowance. extra_reserve (E4) holds
283
+ # back room for the next_actions steer the handler appends after this returns.
284
+ total_entries = _count_direction_entries(response)
285
+ reserve = serialized_size({"byte_capped": {"limit": budget, "omitted": total_entries}})
286
+ reserve += _BYTE_CEILING_TRUNCATED_RESERVE + max(extra_reserve, 0)
287
+ effective = max(budget - reserve, 1)
288
+
289
+ trimmed, byte_dropped, total_omitted = fit_to_byte_budget(response, budget=effective)
290
+
291
+ if total_omitted == 0:
292
+ # The response did NOT fit (checked above) yet nothing was trimmed — the
293
+ # leaf's never-raises safety net fired. Surface it and return untrimmed
294
+ # rather than attach a misleading byte_capped that claims a trim happened.
295
+ logger.warning(
296
+ "seam_impact byte ceiling could not trim output (budget=%d); returning untrimmed",
297
+ budget,
298
+ )
299
+ return response
300
+
301
+ # Merge byte_dropped into truncated ADDITIVELY so the invariant holds:
302
+ # risk_summary[dir][tier] - shown[dir][tier] == truncated[dir][tier]
303
+ existing_truncated: dict[str, dict[str, int]] = dict(trimmed.get("truncated", {}))
304
+ for direction, tier_map in byte_dropped.items():
305
+ dir_trunc = dict(existing_truncated.get(direction, {}))
306
+ for tier, count in tier_map.items():
307
+ dir_trunc[tier] = dir_trunc.get(tier, 0) + count
308
+ existing_truncated[direction] = dir_trunc
309
+
310
+ # trimmed is a new dict from fit_to_byte_budget (never mutates input).
311
+ result = dict(trimmed)
312
+ result["truncated"] = existing_truncated
313
+ result["byte_capped"] = {"limit": budget, "omitted": total_omitted}
314
+ return result
315
+ except Exception:
316
+ # The handler claims "never raises" in its own right (not only via the leaf).
317
+ logger.warning("seam_impact byte ceiling failed; returning untrimmed output", exc_info=True)
318
+ return response
319
+
320
+
321
+ # Impact limit defaults (mirrored here for the handler's default parameter).
322
+ _IMPACT_DEPTH_DEFAULT = 3
323
+ _IMPACT_DIRECTION_DEFAULT = "upstream"
324
+
325
+
326
+ def handle_seam_impact(
327
+ conn: sqlite3.Connection,
328
+ target: str,
329
+ root: Path,
330
+ direction: str = _IMPACT_DIRECTION_DEFAULT,
331
+ max_depth: int = _IMPACT_DEPTH_DEFAULT,
332
+ include_tests: bool = False,
333
+ verbose: bool = True,
334
+ limit: int = config.SEAM_IMPACT_MAX_RESULTS,
335
+ max_bytes: int = config.SEAM_IMPACT_MAX_BYTES,
336
+ *,
337
+ uid: str | None = None,
338
+ ) -> dict[str, Any]:
339
+ """Handler for the seam_impact MCP tool.
340
+
341
+ Computes blast radius for a target symbol: which symbols are affected if the
342
+ target changes, grouped into risk tiers by distance.
343
+
344
+ Args:
345
+ conn: Open SQLite connection.
346
+ target: Symbol name to analyze (must not be blank/whitespace).
347
+ root: Project root for path relativization. Each TieredEntry includes a
348
+ `file` field (absolute path from the analysis layer) which is
349
+ relativized to root before returning.
350
+ direction: "upstream" | "downstream" | "both". Default: "upstream".
351
+ max_depth: Max hops. Clamped to [1, 10]. Default: 3.
352
+ include_tests: When False (default), test-file dependents are filtered out from
353
+ all tiers — "what breaks?" answers with the PRODUCTION blast radius,
354
+ and the count of hidden test dependents surfaces as `hidden_tests`.
355
+ When True, test-file dependents are included and tagged is_test=True.
356
+ (Test dependents are derivable separately via seam_affected.)
357
+ verbose: When True (default), output includes all Phase 4/5 enrichment fields.
358
+ When False, heavy fields (resolved_by, best_candidate, etc.) are
359
+ stripped from each entry — lean mode.
360
+ limit: Per-tier entry cap. Default: SEAM_IMPACT_MAX_RESULTS (25).
361
+ Entries arrive distance-ordered from the analysis layer (tiers group
362
+ by distance), so the kept slice is always the closest/highest-risk.
363
+ limit <= 0 means unlimited (all entries returned).
364
+ max_bytes: Optional byte ceiling for the serialized output (characters of compact
365
+ JSON). Default: SEAM_IMPACT_MAX_BYTES (0 = unlimited). When > 0, the
366
+ ceiling runs AFTER the per-tier count cap and E2/E3 ordering, trimming
367
+ entries from the least-valuable end (downstream before upstream,
368
+ MAY_NEED_TESTING before WILL_BREAK, tail before front) until the
369
+ serialized output fits. The dropped counts are merged into `truncated`
370
+ additively and a `byte_capped` key is added when the ceiling fired
371
+ (byte_capped is ABSENT when max_bytes=0 or nothing was trimmed). 0 or
372
+ negative means unlimited — byte-identical to the pre-feature output.
373
+
374
+ Returns:
375
+ A JSON-able dict with the impact result, or an error dict on bad input.
376
+ Top-level keys always include `found`, `target`, and `risk_summary`.
377
+ risk_summary is {direction: {tier: count}} computed from the FULL pre-cap
378
+ result — it is always trustworthy even when entry lists are capped.
379
+ NOTE: "full" means before the `limit` cap, but AFTER the include_tests filter —
380
+ when include_tests=False, risk_summary counts the production-only blast radius
381
+ (test dependents are already excluded), matching the entries actually returned.
382
+ When any tier was capped, `truncated` is included: {direction: {tier: omitted}}.
383
+ When the byte ceiling fires, `byte_capped` is added: {"limit": int, "omitted": int}.
384
+
385
+ Shape for direction="upstream":
386
+ {"found": bool, "target": str, "risk_summary": {...},
387
+ "upstream": {"WILL_BREAK": [...], "LIKELY_AFFECTED": [...], "MAY_NEED_TESTING": [...]}}
388
+ Shape for direction="both":
389
+ {"found": bool, "target": str, "risk_summary": {...},
390
+ "upstream": {...tiers...}, "downstream": {...tiers...}}
391
+
392
+ Each entry in a tier list includes:
393
+ file (str | None) — relative path from project root; None for unindexed.
394
+ is_test (bool) — True if the entry's file is a test file.
395
+
396
+ Error shapes:
397
+ {"error": "INVALID_INPUT", "message": "..."} — blank target or invalid direction.
398
+ """
399
+ # uid (P6c): a stable handle pins the exact symbol. The impact graph is
400
+ # name-keyed (edges store names), so we resolve the uid to its symbol NAME and
401
+ # analyze that — the handle just removes the homonym disambiguation round-trip.
402
+ # An unknown uid returns the standard found=False result (not an error).
403
+ if uid is not None:
404
+ resolved = _resolve_uid(conn, uid)
405
+ if resolved is None:
406
+ return {"found": False, "target": uid, "risk_summary": {}}
407
+ target = resolved[0]
408
+
409
+ # Validate: target must not be empty or whitespace-only.
410
+ if not target or not target.strip():
411
+ return _invalid_input("target must not be empty or whitespace-only")
412
+
413
+ # Validate direction before passing to impact (impact raises ValueError on bad direction,
414
+ # but we want the standard INVALID_INPUT shape here in the handler).
415
+ valid_directions = {"upstream", "downstream", "both"}
416
+ if direction not in valid_directions:
417
+ return _invalid_input(
418
+ f"direction must be one of: {sorted(valid_directions)}; got {direction!r}"
419
+ )
420
+
421
+ # Clamp max_depth via impact module's own clamp helper (single source of truth).
422
+ safe_depth = impact_module.clamp_depth(max_depth)
423
+
424
+ raw = impact_module.impact(
425
+ conn,
426
+ target=target.strip(),
427
+ direction=direction,
428
+ max_depth=safe_depth,
429
+ include_tests=include_tests,
430
+ # Thread repo_root for Phase 5 import-promotion (root is already the project root).
431
+ repo_root=root,
432
+ )
433
+
434
+ # Build the response: pass found/target through, relativize file paths in entries.
435
+ response: dict[str, Any] = {
436
+ "found": raw["found"],
437
+ "target": raw["target"],
438
+ }
439
+
440
+ # Determine whether capping is active (limit <= 0 means unlimited).
441
+ effective_limit = limit if limit > 0 else None
442
+
443
+ # E2/E3 output shaping (handler-layer only — seam_changes/seam_affected bypass this).
444
+ # relevance_on ranks EXTERNAL dependents ahead of the target's own members so the
445
+ # per-tier cap drops self-references first. self_ref_mode "hide" additionally drops
446
+ # self-refs entirely and surfaces hidden_self_refs (mirrors hidden_tests).
447
+ relevance_on = config.SEAM_IMPACT_RELEVANCE_SORT == "on"
448
+ self_ref_mode = config.SEAM_IMPACT_SELF_REF
449
+ # E1: drop null best_candidate per entry (lossless; null ≡ absent) to keep the
450
+ # default output lean so more high-signal dependents survive the per-tier cap.
451
+ omit_null_candidate = config.SEAM_IMPACT_OMIT_NULL_CANDIDATE == "on"
452
+ # Resolve the self-ref context only when it can actually change ordering — i.e.
453
+ # relevance is on and the mode treats self-refs specially ("rank"/"hide"). "show"
454
+ # and relevance-off skip the lookup (container=None → no entry is a self-ref).
455
+ if relevance_on and self_ref_mode in ("rank", "hide"):
456
+ container, self_names = _compute_self_context(conn, target.strip())
457
+ else:
458
+ container, self_names = None, set()
459
+ hidden_self_refs = 0
460
+
461
+ # Build risk_summary and capped tiers for each direction key present in raw.
462
+ # WHY compute summary first: risk_summary must reflect the FULL pre-cap result
463
+ # (story 15) — we count before slicing so truncation cannot hide the true total.
464
+ # In "hide" mode the summary still counts self-refs (the honest total); the dropped
465
+ # self-refs surface separately as hidden_self_refs.
466
+ risk_summary: dict[str, dict[str, int]] = {}
467
+ truncated: dict[str, dict[str, int]] = {}
468
+
469
+ for dir_key in ("upstream", "downstream"):
470
+ if dir_key not in raw:
471
+ continue
472
+ tier_group = raw[dir_key]
473
+
474
+ # ── 1. Count BEFORE capping (risk_summary denominator) ────────────────
475
+ # Counts the FULL pre-cap tier group including self-refs (the honest total).
476
+ dir_summary = {tier: len(entries) for tier, entries in tier_group.items()}
477
+ risk_summary[dir_key] = dir_summary
478
+
479
+ # ── 2. Order (E2/E3) + per-tier cap + serialize ───────────────────────
480
+ capped_tiers, dir_truncated, dir_hidden = _shape_tier_group(
481
+ tier_group,
482
+ root,
483
+ verbose=verbose,
484
+ effective_limit=effective_limit,
485
+ relevance_on=relevance_on,
486
+ self_ref_mode=self_ref_mode,
487
+ container=container,
488
+ self_names=self_names,
489
+ omit_null_candidate=omit_null_candidate,
490
+ )
491
+ hidden_self_refs += dir_hidden
492
+ response[dir_key] = capped_tiers
493
+
494
+ # Only include truncated for directions where something was actually dropped.
495
+ if any(count > 0 for count in dir_truncated.values()):
496
+ truncated[dir_key] = dir_truncated
497
+
498
+ # risk_summary is always present — it is the honest summary of the full blast radius.
499
+ response["risk_summary"] = risk_summary
500
+
501
+ # truncated is only present when at least one tier was capped in any direction.
502
+ # Absence signals "nothing was dropped" (omitted vs all-zero to reduce token cost).
503
+ if truncated:
504
+ response["truncated"] = truncated
505
+
506
+ # Surface hidden_tests when present (include_tests=False filtered test dependents).
507
+ # Lets MCP callers distinguish "no dependents" from "all dependents were tests and
508
+ # were hidden" — without it, the production-only default could read as a false-safe.
509
+ if "hidden_tests" in raw:
510
+ response["hidden_tests"] = raw["hidden_tests"]
511
+
512
+ # Surface hidden_self_refs whenever hide mode is active (even when 0), so agents
513
+ # can rely on its presence to reconcile risk_summary against the shown entries.
514
+ if relevance_on and self_ref_mode == "hide":
515
+ response["hidden_self_refs"] = hidden_self_refs
516
+
517
+ # E1-FULL: byte ceiling — runs LAST (before steer), after count cap + E2/E3 ordering.
518
+ # When max_bytes > 0, trims entries from the least-valuable end until the
519
+ # serialized output fits the budget. byte_capped is set only when the ceiling
520
+ # actually fired (i.e. at least one entry was dropped). When max_bytes <= 0
521
+ # this is a no-op (byte-identical revert). seam_changes/seam_affected bypass
522
+ # this entirely because they call the analysis layer directly.
523
+ final_response = _apply_byte_ceiling(response, max_bytes)
524
+
525
+ # E4: truncation steer — runs AFTER byte ceiling so it reads the merged truncated
526
+ # totals (count-cap drops + byte-ceiling drops) and the byte_capped metadata.
527
+ # Generates ready-to-act prose hints when ≥1 entry was trimmed. ABSENT when
528
+ # nothing was trimmed (so presence is an unambiguous "there is more" signal).
529
+ # Gated by SEAM_IMPACT_STEER; "off" = byte-identical pre-E4 (no next_actions key).
530
+ # `response` (pre-ceiling) is passed so the steer-aware re-trim starts clean rather
531
+ # than re-trimming an already-trimmed response (which would double-count truncated).
532
+ if config.SEAM_IMPACT_STEER == "on":
533
+ final_response = _attach_steer(
534
+ final_response, response, limit=limit, max_bytes=max_bytes
535
+ )
536
+
537
+ # P2: attach staleness banner LAST — purely additive, byte-identical when fresh.
538
+ return _maybe_attach_staleness(final_response, conn, root)
539
+
540
+
541
+ # Small margin (chars) added to the steer-byte reserve when re-trimming so the
542
+ # regenerated steer's digit growth (byte-drop counts grow as more entries are trimmed)
543
+ # cannot nudge the response back over the budget.
544
+ _STEER_RESERVE_MARGIN = 64
545
+
546
+
547
+ def _attach_steer(
548
+ final_response: dict[str, Any],
549
+ pre_ceiling_response: dict[str, Any],
550
+ *,
551
+ limit: int,
552
+ max_bytes: int,
553
+ ) -> dict[str, Any]:
554
+ """Generate the E4 next_actions steer and attach it WITHIN the byte ceiling (E4 fix).
555
+
556
+ The steer is generated from the post-ceiling trim metadata. Naively appending it would
557
+ push the response past max_bytes — defeating the E1-FULL hard ceiling exactly when the
558
+ ceiling fired (the steer fires iff something was trimmed). So when max_bytes is active
559
+ and attaching the steer would breach the budget, we re-run the ceiling from the
560
+ PRE-CEILING response (clean — not the already-trimmed one, which would double-count
561
+ `truncated`), reserving room for the steer, then regenerate it for the smaller set.
562
+
563
+ WHY a single re-trim converges: the steer's count-cap hints depend only on the
564
+ count-cap portion of `truncated` (applied before the ceiling), which is INVARIANT
565
+ under further byte trimming. So the regenerated steer differs from the first only in
566
+ the byte-hint's trailing count — a few digits — absorbed by _STEER_RESERVE_MARGIN.
567
+ No iteration loop needed.
568
+
569
+ All-trimmed (budget-below-envelope) is the documented exception: entries are already
570
+ empty, re-trimming changes nothing, and the anti-false-safe WARNING is the point — it
571
+ is attached even if it exceeds a sub-envelope budget (the same carve-out the
572
+ irreducible envelope already has).
573
+
574
+ tier_order / direction_order are injected from impact.py's canonical TIER_* constants
575
+ so the steer has a single source of truth for the tier names (no hardcoded copy).
576
+ """
577
+ tier_order = (
578
+ impact_module.TIER_WILL_BREAK,
579
+ impact_module.TIER_LIKELY_AFFECTED,
580
+ impact_module.TIER_MAY_NEED_TESTING,
581
+ )
582
+
583
+ def _make_steer(resp: dict[str, Any]) -> list[str]:
584
+ return generate_steer(
585
+ truncated=resp.get("truncated", {}),
586
+ byte_capped=resp.get("byte_capped"),
587
+ risk_summary=resp.get("risk_summary", {}),
588
+ limit=limit,
589
+ max_bytes=max_bytes,
590
+ tier_order=tier_order,
591
+ direction_order=("upstream", "downstream"),
592
+ )
593
+
594
+ steer = _make_steer(final_response)
595
+ if not steer:
596
+ return final_response
597
+
598
+ # Keep the steer inside the byte budget (E4 STOP fix). Only re-trim when the budget
599
+ # is active AND attaching the steer would actually breach it.
600
+ if max_bytes > 0:
601
+ steer_bytes = serialized_size({"next_actions": steer})
602
+ if serialized_size(final_response) + steer_bytes > max_bytes:
603
+ # Re-trim from the PRE-ceiling response (clean single pass) reserving room
604
+ # for the steer, then regenerate the steer for the now-smaller entry set.
605
+ final_response = _apply_byte_ceiling(
606
+ pre_ceiling_response,
607
+ max_bytes,
608
+ extra_reserve=steer_bytes + _STEER_RESERVE_MARGIN,
609
+ )
610
+ steer = _make_steer(final_response)
611
+ if not steer:
612
+ return final_response
613
+
614
+ final_response["next_actions"] = steer
615
+ return final_response