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