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
seam/query/structure.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""Whole-repository structure view — Tier D11.
|
|
2
|
+
|
|
3
|
+
Single public entry point:
|
|
4
|
+
build_structure(conn, root, *, path=None, max_depth=..., max_nodes=...) -> StructureResult
|
|
5
|
+
|
|
6
|
+
WHY THIS MODULE EXISTS (vs. seam_clusters)
|
|
7
|
+
-------------------------------------------
|
|
8
|
+
Seam has two complementary views of a codebase:
|
|
9
|
+
|
|
10
|
+
seam_clusters — SEMANTIC community view: groups symbols that are functionally
|
|
11
|
+
related regardless of where they live on disk. A cluster might
|
|
12
|
+
span files in src/, lib/, and tests/ if they call each other.
|
|
13
|
+
Useful for "what is logically coupled to X?".
|
|
14
|
+
|
|
15
|
+
seam_structure — PHYSICAL container map: shows the filesystem hierarchy as-is
|
|
16
|
+
(dir → file → class → members). Area labels (from clusters) are
|
|
17
|
+
annotated on each node, so the structural view is cluster-aware,
|
|
18
|
+
but the primary organisation is always the filesystem. Useful for
|
|
19
|
+
"where does X live, and what else is in that file/directory?".
|
|
20
|
+
|
|
21
|
+
An agent answering "how is this repo structured?" needs the physical map first, then
|
|
22
|
+
drills into clusters for semantic grouping. The two views complement rather than
|
|
23
|
+
replace each other.
|
|
24
|
+
|
|
25
|
+
Builds a directory -> file -> container tree by joining symbols to files in a
|
|
26
|
+
single read query. The result carries:
|
|
27
|
+
- dir nodes: directories in the file-path hierarchy
|
|
28
|
+
- file nodes: indexed files under each directory
|
|
29
|
+
- container nodes: class/interface/type symbols within each file
|
|
30
|
+
- function nodes: top-level functions (kind='function' with no '.' in name)
|
|
31
|
+
- members: method/member count rolled into the owning container node
|
|
32
|
+
(NOT emitted as separate nodes)
|
|
33
|
+
|
|
34
|
+
This is a PURE READ module — leaf, no seam deps beyond config.
|
|
35
|
+
It imports only stdlib + sqlite3. No server or CLI imports.
|
|
36
|
+
|
|
37
|
+
Slice 1 invariants:
|
|
38
|
+
- truncated is always 0 (no per-directory or per-file caps applied).
|
|
39
|
+
- The full tree is built for every indexed file.
|
|
40
|
+
|
|
41
|
+
Slice 2 — functional-area annotation:
|
|
42
|
+
- file node area = label of the cluster with the PLURALITY of that file's
|
|
43
|
+
symbols (via symbols.cluster_id -> clusters.label).
|
|
44
|
+
- Tie-breaking: lowest cluster_id wins deterministically.
|
|
45
|
+
- dir node area = plurality of its DIRECT child files' areas.
|
|
46
|
+
- When no cluster data exists (cluster_id all NULL, or clusters table absent),
|
|
47
|
+
area stays None for all nodes — graceful degradation, never an error.
|
|
48
|
+
|
|
49
|
+
Slice 3 — scoping and bounds:
|
|
50
|
+
- path: optional Path to scope the tree to a subtree. Only files whose absolute
|
|
51
|
+
path is under `path` are included. Unknown / out-of-tree paths degrade to an
|
|
52
|
+
empty tree, never an error.
|
|
53
|
+
- max_depth: maximum nesting depth (root = depth 0). IMPORTANT: depth counts
|
|
54
|
+
ALL tree levels — dir, file, AND container nodes each consume a level. A
|
|
55
|
+
file's containers are at depth ≥ 2 even when the file is a direct child of
|
|
56
|
+
root. This is intentional: the depth cap governs tree size, not directory
|
|
57
|
+
nesting alone. Use SEAM_STRUCTURE_MAX_DEPTH (default 8) to allow enough
|
|
58
|
+
levels for most repos (typical: 3–5 levels of dirs + 1 file + 1 container).
|
|
59
|
+
- max_nodes: maximum total non-root nodes. Nodes are added BFS-order (closest
|
|
60
|
+
to root first); excess nodes are omitted and their count added to truncated.
|
|
61
|
+
Set max_nodes <= 0 for UNLIMITED (matches the seam_impact limit=0 convention;
|
|
62
|
+
avoids the footgun where a negative value silently empties the tree).
|
|
63
|
+
|
|
64
|
+
Container detection (kind vocabulary normalizes to class/interface/type):
|
|
65
|
+
- kind in {'class', 'interface', 'type'} -> container node.
|
|
66
|
+
- kind == 'method' -> rolled into parent container's `members` count.
|
|
67
|
+
- A qualified name like 'Owner.member' -> method, regardless of stored kind.
|
|
68
|
+
- kind == 'function' and name has no '.' -> top-level function node under file.
|
|
69
|
+
|
|
70
|
+
Path contract:
|
|
71
|
+
- dir/file paths: relative to `root` (no absolute paths leak).
|
|
72
|
+
- container paths: None (logical; no single declaring line shipped here).
|
|
73
|
+
|
|
74
|
+
Never raises on a partial/empty/garbage index — returns an empty but valid tree.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
import logging
|
|
78
|
+
import os
|
|
79
|
+
import sqlite3
|
|
80
|
+
from pathlib import Path
|
|
81
|
+
from typing import TypedDict
|
|
82
|
+
|
|
83
|
+
import seam.config as config
|
|
84
|
+
|
|
85
|
+
logger = logging.getLogger(__name__)
|
|
86
|
+
|
|
87
|
+
# ── TypedDicts ────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class StructureNode(TypedDict):
|
|
91
|
+
"""A single node in the structure tree.
|
|
92
|
+
|
|
93
|
+
Fields:
|
|
94
|
+
kind: 'dir' | 'file' | 'container' | 'function'
|
|
95
|
+
name: display name (dir basename, file name, symbol name)
|
|
96
|
+
path: repo-root-relative path string; None for container nodes
|
|
97
|
+
symbol_count: total symbol rows in this subtree (file: row count in DB;
|
|
98
|
+
dir: sum of children; container: its own members + 1 for self)
|
|
99
|
+
area: functional-area label (Slice 1: always None)
|
|
100
|
+
children: child nodes (dirs/files under a dir; containers/funcs under a file)
|
|
101
|
+
members: count of method/member rows rolled into this container (0 for non-containers)
|
|
102
|
+
"""
|
|
103
|
+
kind: str
|
|
104
|
+
name: str
|
|
105
|
+
path: str | None
|
|
106
|
+
symbol_count: int
|
|
107
|
+
area: str | None
|
|
108
|
+
children: list # list[StructureNode] — recursive, TypedDict can't self-ref cleanly
|
|
109
|
+
members: int
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class StructureResult(TypedDict):
|
|
113
|
+
"""Top-level result from build_structure().
|
|
114
|
+
|
|
115
|
+
Fields:
|
|
116
|
+
tree: Root StructureNode (always a 'dir' node representing `root`).
|
|
117
|
+
truncated: Count of nodes omitted by caps (Slice 1: always 0).
|
|
118
|
+
"""
|
|
119
|
+
tree: StructureNode
|
|
120
|
+
truncated: int
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Container kind detection ──────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
# Kinds stored in the DB that map to container nodes.
|
|
126
|
+
_CONTAINER_KINDS: frozenset[str] = frozenset({"class", "interface", "type"})
|
|
127
|
+
|
|
128
|
+
# Kinds stored in the DB that always map to methods/members (rolled up).
|
|
129
|
+
_METHOD_KINDS: frozenset[str] = frozenset({"method"})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _is_container(kind: str) -> bool:
|
|
133
|
+
"""Return True when the symbol kind is a container (class/interface/type)."""
|
|
134
|
+
return kind in _CONTAINER_KINDS
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_method_or_member(name: str, kind: str) -> bool:
|
|
138
|
+
"""Return True when the symbol should be rolled into a container's `members` count.
|
|
139
|
+
|
|
140
|
+
Rules (in order):
|
|
141
|
+
1. Stored kind is 'method' -> always a member.
|
|
142
|
+
2. Name contains '.' (e.g. 'Owner.member') -> method, regardless of stored kind.
|
|
143
|
+
WHY rule 2: extractors for some languages store method kind as 'function' with
|
|
144
|
+
a qualified name like 'MyClass.do_something'. Seam's qualified_name contract is
|
|
145
|
+
that methods carry 'Owner.method' qualified names. Using '.' as the heuristic
|
|
146
|
+
here mirrors the convention used in graph_common and graph.py.
|
|
147
|
+
"""
|
|
148
|
+
if kind in _METHOD_KINDS:
|
|
149
|
+
return True
|
|
150
|
+
# Bare name with a '.' separator -> qualified method name
|
|
151
|
+
if "." in name:
|
|
152
|
+
return True
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── Query ─────────────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _fetch_all_symbols(conn: sqlite3.Connection) -> list[tuple[str, str, str, int]]:
|
|
160
|
+
"""Fetch (file_path, name, kind, start_line) for all indexed symbols.
|
|
161
|
+
|
|
162
|
+
Returns an empty list on any DB error (graceful degradation — never raises).
|
|
163
|
+
The JOIN on files gives us the absolute file_path stored in the DB; callers
|
|
164
|
+
relativize to root before building tree nodes.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
rows = conn.execute(
|
|
168
|
+
"""
|
|
169
|
+
SELECT f.path AS file_path, s.name, s.kind, s.start_line
|
|
170
|
+
FROM symbols s
|
|
171
|
+
JOIN files f ON f.id = s.file_id
|
|
172
|
+
ORDER BY f.path, s.start_line
|
|
173
|
+
"""
|
|
174
|
+
).fetchall()
|
|
175
|
+
return [(r["file_path"], r["name"], r["kind"], r["start_line"]) for r in rows]
|
|
176
|
+
except Exception:
|
|
177
|
+
logger.warning("build_structure: failed to fetch symbols; returning empty tree", exc_info=True)
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ── Cluster / area helpers ────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _clusters_available(conn: sqlite3.Connection) -> bool:
|
|
185
|
+
"""Return True when the clusters table + symbols.cluster_id column both exist.
|
|
186
|
+
|
|
187
|
+
WHY: pre-v4 indexes lack the clusters table; querying it would raise an error.
|
|
188
|
+
We detect the absence and degrade to area=None on all nodes.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
row = conn.execute(
|
|
192
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='clusters' LIMIT 1"
|
|
193
|
+
).fetchone()
|
|
194
|
+
if row is None:
|
|
195
|
+
return False
|
|
196
|
+
# Check that symbols.cluster_id exists
|
|
197
|
+
col_names = {r["name"] for r in conn.execute("PRAGMA table_info(symbols)").fetchall()}
|
|
198
|
+
return "cluster_id" in col_names
|
|
199
|
+
except Exception:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _fetch_cluster_labels(conn: sqlite3.Connection) -> dict[int, str]:
|
|
204
|
+
"""Return {cluster_id: label} for all clusters.
|
|
205
|
+
|
|
206
|
+
Returns an empty dict when no clusters exist or on any error.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
rows = conn.execute("SELECT id, label FROM clusters").fetchall()
|
|
210
|
+
return {r["id"]: r["label"] for r in rows}
|
|
211
|
+
except Exception:
|
|
212
|
+
# Log: a swallowed failure here makes every node's area silently None,
|
|
213
|
+
# indistinguishable from a healthy unclustered index — undebuggable otherwise.
|
|
214
|
+
logger.warning(
|
|
215
|
+
"build_structure: failed to fetch cluster labels; area will be None", exc_info=True
|
|
216
|
+
)
|
|
217
|
+
return {}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _fetch_file_cluster_counts(
|
|
221
|
+
conn: sqlite3.Connection,
|
|
222
|
+
) -> dict[str, dict[int, int]]:
|
|
223
|
+
"""Return per-file cluster symbol counts.
|
|
224
|
+
|
|
225
|
+
Shape: {abs_file_path: {cluster_id: symbol_count}}
|
|
226
|
+
|
|
227
|
+
Only rows with a non-NULL cluster_id are included. Files whose symbols
|
|
228
|
+
are entirely unclustered will be absent from the dict.
|
|
229
|
+
|
|
230
|
+
Returns an empty dict on any DB error (graceful degradation).
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
rows = conn.execute(
|
|
234
|
+
"""
|
|
235
|
+
SELECT f.path AS file_path, s.cluster_id, COUNT(*) AS cnt
|
|
236
|
+
FROM symbols s
|
|
237
|
+
JOIN files f ON f.id = s.file_id
|
|
238
|
+
WHERE s.cluster_id IS NOT NULL
|
|
239
|
+
GROUP BY f.path, s.cluster_id
|
|
240
|
+
"""
|
|
241
|
+
).fetchall()
|
|
242
|
+
result: dict[str, dict[int, int]] = {}
|
|
243
|
+
for r in rows:
|
|
244
|
+
file_path: str = r["file_path"]
|
|
245
|
+
cid: int = r["cluster_id"]
|
|
246
|
+
cnt: int = r["cnt"]
|
|
247
|
+
result.setdefault(file_path, {})[cid] = cnt
|
|
248
|
+
return result
|
|
249
|
+
except Exception:
|
|
250
|
+
logger.warning(
|
|
251
|
+
"build_structure: failed to fetch cluster counts; area will be None", exc_info=True
|
|
252
|
+
)
|
|
253
|
+
return {}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _plurality_area(
|
|
257
|
+
cluster_counts: dict[int, int],
|
|
258
|
+
labels: dict[int, str],
|
|
259
|
+
) -> str | None:
|
|
260
|
+
"""Return the label of the cluster with the most symbols; None if no data.
|
|
261
|
+
|
|
262
|
+
Tie-breaking: lowest cluster_id wins (deterministic).
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
cluster_counts: {cluster_id: symbol_count_in_this_file}
|
|
266
|
+
labels: {cluster_id: label} for all known clusters
|
|
267
|
+
"""
|
|
268
|
+
if not cluster_counts:
|
|
269
|
+
return None
|
|
270
|
+
# Sort by (-count, cluster_id) so the highest count / lowest id comes first.
|
|
271
|
+
best_cid = min(cluster_counts, key=lambda cid: (-cluster_counts[cid], cid))
|
|
272
|
+
return labels.get(best_cid)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _dir_plurality_area(child_areas: list[str | None]) -> str | None:
|
|
276
|
+
"""Return plurality area from a list of child file area strings.
|
|
277
|
+
|
|
278
|
+
Only non-None areas are counted. Returns None when all areas are None.
|
|
279
|
+
Tie-breaking: alphabetically first label (deterministic, stable).
|
|
280
|
+
|
|
281
|
+
WHY alphabetic tie-break (not id): dir-level area is derived from
|
|
282
|
+
file-level string labels; cluster ids are not directly accessible here.
|
|
283
|
+
Alphabetic is reproducible and avoids a second DB lookup.
|
|
284
|
+
"""
|
|
285
|
+
counts: dict[str, int] = {}
|
|
286
|
+
for a in child_areas:
|
|
287
|
+
if a is not None:
|
|
288
|
+
counts[a] = counts.get(a, 0) + 1
|
|
289
|
+
if not counts:
|
|
290
|
+
return None
|
|
291
|
+
# Sort by (-count, label) — most common, then alphabetically first on tie.
|
|
292
|
+
return min(counts, key=lambda lbl: (-counts[lbl], lbl))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ── Tree builder ──────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _make_dir_node(name: str, path: str | None) -> StructureNode:
|
|
299
|
+
"""Create an empty 'dir' StructureNode."""
|
|
300
|
+
return StructureNode(
|
|
301
|
+
kind="dir",
|
|
302
|
+
name=name,
|
|
303
|
+
path=path,
|
|
304
|
+
symbol_count=0,
|
|
305
|
+
area=None,
|
|
306
|
+
children=[],
|
|
307
|
+
members=0,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _make_file_node(name: str, rel_path: str) -> StructureNode:
|
|
312
|
+
"""Create an empty 'file' StructureNode."""
|
|
313
|
+
return StructureNode(
|
|
314
|
+
kind="file",
|
|
315
|
+
name=name,
|
|
316
|
+
path=rel_path,
|
|
317
|
+
symbol_count=0,
|
|
318
|
+
area=None,
|
|
319
|
+
children=[],
|
|
320
|
+
members=0,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _make_container_node(name: str) -> StructureNode:
|
|
325
|
+
"""Create a 'container' StructureNode (class/interface/type)."""
|
|
326
|
+
return StructureNode(
|
|
327
|
+
kind="container",
|
|
328
|
+
name=name,
|
|
329
|
+
path=None, # containers have no path — logical node
|
|
330
|
+
symbol_count=1, # counts itself; method roll-ups add to members
|
|
331
|
+
area=None,
|
|
332
|
+
children=[],
|
|
333
|
+
members=0,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _make_function_node(name: str) -> StructureNode:
|
|
338
|
+
"""Create a 'function' StructureNode for a top-level function."""
|
|
339
|
+
return StructureNode(
|
|
340
|
+
kind="function",
|
|
341
|
+
name=name,
|
|
342
|
+
path=None,
|
|
343
|
+
symbol_count=1,
|
|
344
|
+
area=None,
|
|
345
|
+
children=[],
|
|
346
|
+
members=0,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _get_or_create_dir(
|
|
351
|
+
parent_children: list,
|
|
352
|
+
dir_name: str,
|
|
353
|
+
dir_rel_path: str,
|
|
354
|
+
dir_map: dict[str, StructureNode],
|
|
355
|
+
) -> StructureNode:
|
|
356
|
+
"""Return the existing dir child or create and append a new one.
|
|
357
|
+
|
|
358
|
+
WHY dict cache: we may visit many files under the same subdir; O(1) lookup
|
|
359
|
+
avoids scanning the children list each time.
|
|
360
|
+
"""
|
|
361
|
+
if dir_rel_path in dir_map:
|
|
362
|
+
return dir_map[dir_rel_path]
|
|
363
|
+
node = _make_dir_node(dir_name, dir_rel_path)
|
|
364
|
+
parent_children.append(node)
|
|
365
|
+
dir_map[dir_rel_path] = node
|
|
366
|
+
return node
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _build_file_tree(
|
|
370
|
+
root: Path,
|
|
371
|
+
symbols_by_file: dict[str, list[tuple[str, str, int]]],
|
|
372
|
+
file_cluster_counts: dict[str, dict[int, int]],
|
|
373
|
+
cluster_labels: dict[int, str],
|
|
374
|
+
include_functions: bool,
|
|
375
|
+
) -> StructureNode:
|
|
376
|
+
"""Build the dir -> file -> container(/function) tree from the symbol map.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
root: Project root (for path relativization).
|
|
380
|
+
symbols_by_file: {abs_file_path: [(name, kind, start_line), ...]}
|
|
381
|
+
file_cluster_counts: {abs_file_path: {cluster_id: symbol_count}}
|
|
382
|
+
(empty dict when cluster data is unavailable)
|
|
383
|
+
cluster_labels: {cluster_id: label} for area annotation
|
|
384
|
+
include_functions: when False (the overview default) standalone module-level
|
|
385
|
+
functions are NOT emitted as nodes — they buried the
|
|
386
|
+
"main modules" answer (a function-heavy file dumped dozens
|
|
387
|
+
of nodes, hiding the module breadth). Their rows still
|
|
388
|
+
count in the file's symbol_count. Classes/interfaces/types
|
|
389
|
+
(structural landmarks) are always kept. True restores them.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Root StructureNode (kind='dir') representing `root`.
|
|
393
|
+
"""
|
|
394
|
+
root_node = _make_dir_node(root.name or str(root), None)
|
|
395
|
+
# dir_map: rel_path_str -> StructureNode, for fast lookup when inserting
|
|
396
|
+
dir_map: dict[str, StructureNode] = {}
|
|
397
|
+
|
|
398
|
+
for abs_file, syms in sorted(symbols_by_file.items()):
|
|
399
|
+
# Compute relative path from repo root.
|
|
400
|
+
try:
|
|
401
|
+
rel_file = Path(abs_file).relative_to(root)
|
|
402
|
+
except ValueError:
|
|
403
|
+
# File outside root — use the absolute path as display name.
|
|
404
|
+
rel_file = Path(abs_file)
|
|
405
|
+
|
|
406
|
+
rel_file_str = str(rel_file)
|
|
407
|
+
parts = rel_file.parts # e.g. ('subdir', 'helper.py')
|
|
408
|
+
|
|
409
|
+
# Navigate / create dir nodes for all parent directories.
|
|
410
|
+
current_dir_node = root_node
|
|
411
|
+
for i, part in enumerate(parts[:-1]):
|
|
412
|
+
# Build the rel_path for this intermediate dir
|
|
413
|
+
dir_rel = "/".join(parts[:i + 1])
|
|
414
|
+
current_dir_node = _get_or_create_dir(
|
|
415
|
+
current_dir_node["children"],
|
|
416
|
+
part,
|
|
417
|
+
dir_rel,
|
|
418
|
+
dir_map,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Create file node
|
|
422
|
+
file_node = _make_file_node(parts[-1], rel_file_str)
|
|
423
|
+
|
|
424
|
+
# Slice 2: annotate file node with its plurality functional area.
|
|
425
|
+
# Uses the pre-fetched cluster counts to avoid per-file DB queries.
|
|
426
|
+
file_counts = file_cluster_counts.get(abs_file, {})
|
|
427
|
+
file_node["area"] = _plurality_area(file_counts, cluster_labels)
|
|
428
|
+
|
|
429
|
+
# Partition symbols into containers, methods, and top-level functions.
|
|
430
|
+
# containers: {container_name -> StructureNode}
|
|
431
|
+
containers: dict[str, StructureNode] = {}
|
|
432
|
+
|
|
433
|
+
for name, kind, _line in syms:
|
|
434
|
+
if _is_container(kind):
|
|
435
|
+
container_node = _make_container_node(name)
|
|
436
|
+
containers[name] = container_node
|
|
437
|
+
file_node["children"].append(container_node)
|
|
438
|
+
elif _is_method_or_member(name, kind):
|
|
439
|
+
# Extract owner from the qualified name to find the container to credit —
|
|
440
|
+
# qualified method names ('Owner.method') carry their owner as the prefix.
|
|
441
|
+
owner = name.split(".")[0] if "." in name else None
|
|
442
|
+
if owner and owner in containers:
|
|
443
|
+
containers[owner]["members"] += 1
|
|
444
|
+
containers[owner]["symbol_count"] += 1
|
|
445
|
+
# If owner not found (e.g. method extracted before its class row appears),
|
|
446
|
+
# the symbol is silently discarded — NOT added as a node — since a lone
|
|
447
|
+
# method node without a container parent would break the tree contract.
|
|
448
|
+
elif include_functions:
|
|
449
|
+
# Top-level function (or other non-container, non-method kind).
|
|
450
|
+
# Suppressed in the overview default — see include_functions docstring.
|
|
451
|
+
func_node = _make_function_node(name)
|
|
452
|
+
file_node["children"].append(func_node)
|
|
453
|
+
|
|
454
|
+
# Methods are NOT separate nodes but their rows ARE part of the file's total —
|
|
455
|
+
# using len(syms) (not just containers+functions) keeps the file symbol count
|
|
456
|
+
# consistent with what `seam status` and the index report.
|
|
457
|
+
file_node["symbol_count"] = len(syms)
|
|
458
|
+
|
|
459
|
+
current_dir_node["children"].append(file_node)
|
|
460
|
+
|
|
461
|
+
# Propagate symbol_count upward: each dir's symbol_count = sum of its children.
|
|
462
|
+
_propagate_symbol_counts(root_node)
|
|
463
|
+
|
|
464
|
+
# Slice 2: propagate area upward through dir nodes (plurality of child file areas).
|
|
465
|
+
_propagate_dir_areas(root_node)
|
|
466
|
+
|
|
467
|
+
return root_node
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _propagate_symbol_counts(node: StructureNode) -> int:
|
|
471
|
+
"""Recursively compute and set symbol_count for dir nodes.
|
|
472
|
+
|
|
473
|
+
Returns the node's symbol_count (post-propagation) for the parent's sum.
|
|
474
|
+
File nodes already have their symbol_count set correctly; dir nodes get the
|
|
475
|
+
sum of all direct children's symbol_counts.
|
|
476
|
+
|
|
477
|
+
WHY bottom-up: we build children before parents; propagation must happen
|
|
478
|
+
after the full subtree is assembled.
|
|
479
|
+
"""
|
|
480
|
+
if node["kind"] == "dir":
|
|
481
|
+
total = sum(_propagate_symbol_counts(child) for child in node["children"])
|
|
482
|
+
node["symbol_count"] = total
|
|
483
|
+
return node["symbol_count"]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _propagate_dir_areas(node: StructureNode) -> str | None:
|
|
487
|
+
"""Recursively set area on dir nodes by collecting their children's areas.
|
|
488
|
+
|
|
489
|
+
A dir's area = plurality of its DIRECT children's areas (file or nested dir).
|
|
490
|
+
File nodes have their area already set; this pass only updates dir nodes.
|
|
491
|
+
|
|
492
|
+
Returns the node's area (post-propagation) for the parent's collection.
|
|
493
|
+
WHY bottom-up: same reasoning as _propagate_symbol_counts — children first.
|
|
494
|
+
"""
|
|
495
|
+
if node["kind"] == "dir":
|
|
496
|
+
# Recurse into children first, then collect their areas.
|
|
497
|
+
child_areas: list[str | None] = []
|
|
498
|
+
for child in node["children"]:
|
|
499
|
+
child_areas.append(_propagate_dir_areas(child))
|
|
500
|
+
node["area"] = _dir_plurality_area(child_areas)
|
|
501
|
+
return node["area"]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ── Slice 3: depth + node caps ───────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _apply_depth_cap(node: StructureNode, max_depth: int, cur_depth: int = 0) -> int:
|
|
508
|
+
"""Recursively drop children beyond max_depth; return count of dropped nodes.
|
|
509
|
+
|
|
510
|
+
WHY recursive (not BFS): depth is a per-path property — recursion gives
|
|
511
|
+
each path its own depth counter without a separate queue.
|
|
512
|
+
|
|
513
|
+
Nodes at depth == max_depth have their children list cleared; all descendants
|
|
514
|
+
of those cut nodes are counted as truncated.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
node: The current StructureNode (mutated in-place).
|
|
518
|
+
max_depth: Maximum allowed depth (root = 0).
|
|
519
|
+
cur_depth: Current depth (starts at 0 for the root node passed in).
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Number of nodes dropped by this operation.
|
|
523
|
+
"""
|
|
524
|
+
if cur_depth >= max_depth:
|
|
525
|
+
# Drop all children of this node and count them (including their descendants).
|
|
526
|
+
dropped = _count_all_nodes(node["children"])
|
|
527
|
+
node["children"] = []
|
|
528
|
+
return dropped
|
|
529
|
+
|
|
530
|
+
total_dropped = 0
|
|
531
|
+
for child in node["children"]:
|
|
532
|
+
total_dropped += _apply_depth_cap(child, max_depth, cur_depth + 1)
|
|
533
|
+
return total_dropped
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _count_all_nodes(nodes: list) -> int:
|
|
537
|
+
"""Count all nodes in a list of subtrees (non-recursive BFS for speed)."""
|
|
538
|
+
total = 0
|
|
539
|
+
stack = list(nodes)
|
|
540
|
+
while stack:
|
|
541
|
+
n = stack.pop()
|
|
542
|
+
total += 1
|
|
543
|
+
stack.extend(n.get("children", []))
|
|
544
|
+
return total
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _apply_node_cap(root: StructureNode, max_nodes: int) -> int:
|
|
548
|
+
"""Cap total non-root nodes to max_nodes using BFS order; return dropped count.
|
|
549
|
+
|
|
550
|
+
BFS ensures nodes closest to the root (highest-value structural info) survive.
|
|
551
|
+
When a parent is included but its children would exceed the cap, all of that
|
|
552
|
+
parent's children are dropped together (partial-children creates confusing gaps).
|
|
553
|
+
|
|
554
|
+
WHY drop whole sibling groups: including 3 of 5 containers in a file would
|
|
555
|
+
imply the file has fewer symbols than it does. Better to drop the whole file's
|
|
556
|
+
containers than show a misleading partial view.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
root: Root StructureNode (never itself dropped — always returned).
|
|
560
|
+
max_nodes: Maximum number of non-root nodes to include.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Number of nodes removed by the cap.
|
|
564
|
+
"""
|
|
565
|
+
if max_nodes <= 0:
|
|
566
|
+
# 0 or negative = UNLIMITED (no cap), matching the seam_impact `limit=0`
|
|
567
|
+
# convention. WHY: an operator setting MAX_NODES=-1/0 expects "no bound",
|
|
568
|
+
# not a silently-emptied tree (the previous behaviour was a footgun).
|
|
569
|
+
return 0
|
|
570
|
+
|
|
571
|
+
# BFS: process level by level. A node's children are included only if
|
|
572
|
+
# there is room for ALL of them; otherwise, that node's children are cleared
|
|
573
|
+
# and the count is accumulated as truncated.
|
|
574
|
+
included = 0
|
|
575
|
+
queue: list[StructureNode] = [root]
|
|
576
|
+
truncated = 0
|
|
577
|
+
|
|
578
|
+
while queue:
|
|
579
|
+
next_queue: list[StructureNode] = []
|
|
580
|
+
for node in queue:
|
|
581
|
+
children = node["children"]
|
|
582
|
+
if not children:
|
|
583
|
+
continue
|
|
584
|
+
child_count = len(children)
|
|
585
|
+
if included + child_count <= max_nodes:
|
|
586
|
+
# All children fit — keep them and queue for next level.
|
|
587
|
+
included += child_count
|
|
588
|
+
next_queue.extend(children)
|
|
589
|
+
else:
|
|
590
|
+
# Not enough room for this node's children — drop all of them.
|
|
591
|
+
# WHY whole group: see docstring above.
|
|
592
|
+
truncated += _count_all_nodes(children)
|
|
593
|
+
node["children"] = []
|
|
594
|
+
queue = next_queue
|
|
595
|
+
|
|
596
|
+
return truncated
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def build_structure(
|
|
603
|
+
conn: sqlite3.Connection,
|
|
604
|
+
root: Path,
|
|
605
|
+
*,
|
|
606
|
+
path: Path | None = None,
|
|
607
|
+
max_depth: int | None = None,
|
|
608
|
+
max_nodes: int | None = None,
|
|
609
|
+
include_functions: bool = False,
|
|
610
|
+
) -> StructureResult:
|
|
611
|
+
"""Build a directory -> file -> container/function structure tree.
|
|
612
|
+
|
|
613
|
+
Reads the index in one JOIN query (files + symbols), then builds the tree
|
|
614
|
+
in memory. Never raises — returns a safe empty tree on any error.
|
|
615
|
+
|
|
616
|
+
Slice 2: Each file node is annotated with a functional 'area' label drawn
|
|
617
|
+
from the Seam clustering data (clusters.label). Dir nodes get the plurality
|
|
618
|
+
of their children's areas. When no cluster data exists (pre-v4 index or
|
|
619
|
+
no clustering run), area stays None for all nodes.
|
|
620
|
+
|
|
621
|
+
Slice 3: Scoping (path) and bounds (max_depth, max_nodes):
|
|
622
|
+
- path: when provided, only files under this path are included. An unknown
|
|
623
|
+
or out-of-tree path yields an empty tree (no crash, no error).
|
|
624
|
+
- max_depth: nodes at depth > max_depth are dropped; truncated += count.
|
|
625
|
+
- max_nodes: total non-root nodes capped BFS-order; truncated += excess.
|
|
626
|
+
Defaults come from config (SEAM_STRUCTURE_MAX_DEPTH, SEAM_STRUCTURE_MAX_NODES).
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
conn: Open SQLite connection to the Seam index (read-only).
|
|
630
|
+
root: Project root (Path) — used to relativize file paths.
|
|
631
|
+
path: Optional scope path. Only files under this dir are included.
|
|
632
|
+
max_depth: Maximum nesting depth (root=0). None uses config default.
|
|
633
|
+
max_nodes: Maximum total non-root nodes. None uses config default.
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
StructureResult with:
|
|
637
|
+
tree: Root 'dir' node representing `root` (or `path` if scoped).
|
|
638
|
+
truncated: Count of nodes omitted by depth/node caps.
|
|
639
|
+
"""
|
|
640
|
+
# Resolve effective caps from config defaults when not explicitly supplied.
|
|
641
|
+
effective_max_depth: int = max_depth if max_depth is not None else config.SEAM_STRUCTURE_MAX_DEPTH
|
|
642
|
+
effective_max_nodes: int = max_nodes if max_nodes is not None else config.SEAM_STRUCTURE_MAX_NODES
|
|
643
|
+
|
|
644
|
+
# Resolve `root` HERE (one syscall) so path matching is correct regardless of how
|
|
645
|
+
# the caller passed it. `seam init` stores symlink-resolved file paths, so an
|
|
646
|
+
# unresolved root (e.g. macOS /var vs /private/var) would make relative_to() fail
|
|
647
|
+
# for every file and leak absolute paths into the tree. The CLI/start already
|
|
648
|
+
# resolve, but this makes build_structure robust to any caller.
|
|
649
|
+
root = root.resolve()
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
raw_symbols = _fetch_all_symbols(conn)
|
|
653
|
+
except Exception:
|
|
654
|
+
logger.warning("build_structure: _fetch_all_symbols raised", exc_info=True)
|
|
655
|
+
raw_symbols = []
|
|
656
|
+
|
|
657
|
+
# Slice 3 path scoping: filter symbols to only those in the requested subtree.
|
|
658
|
+
# A RELATIVE scope is resolved against `root` (NOT cwd) so `seam structure /repo
|
|
659
|
+
# --scope src/` and the MCP `path="src/"` both mean "<root>/src" regardless of the
|
|
660
|
+
# process working directory. An absolute scope is honoured as-is.
|
|
661
|
+
#
|
|
662
|
+
# The scope is resolved ONCE (it's a single path, not per-row), so it lands in the
|
|
663
|
+
# same symlink-resolved space as `root` and the stored file paths. The per-row
|
|
664
|
+
# comparison is then a pure STRING prefix — no Path.resolve() per symbol, which would
|
|
665
|
+
# be an O(rows) stat() storm on the MCP hot path for a large index.
|
|
666
|
+
scope_abs: Path | None
|
|
667
|
+
if path is not None:
|
|
668
|
+
scope_abs = (path if path.is_absolute() else root / path).resolve()
|
|
669
|
+
scope_str = str(scope_abs)
|
|
670
|
+
root_str = str(root)
|
|
671
|
+
if scope_str != root_str and not scope_str.startswith(root_str + os.sep):
|
|
672
|
+
# Out-of-tree path — degrade to empty tree, never raise. WARNING (not DEBUG):
|
|
673
|
+
# a mistyped --scope returns an empty tree, which is silent without this log.
|
|
674
|
+
logger.warning(
|
|
675
|
+
"build_structure: scope path %s is outside root %s; returning empty tree",
|
|
676
|
+
scope_str, root_str,
|
|
677
|
+
)
|
|
678
|
+
raw_symbols = []
|
|
679
|
+
scope_abs = None
|
|
680
|
+
else:
|
|
681
|
+
scope_prefix = scope_str + os.sep
|
|
682
|
+
raw_symbols = [
|
|
683
|
+
r for r in raw_symbols
|
|
684
|
+
if r[0] == scope_str or r[0].startswith(scope_prefix)
|
|
685
|
+
]
|
|
686
|
+
else:
|
|
687
|
+
scope_abs = None
|
|
688
|
+
|
|
689
|
+
# Group symbols by file path.
|
|
690
|
+
# {abs_file_path: [(name, kind, start_line), ...]}
|
|
691
|
+
symbols_by_file: dict[str, list[tuple[str, str, int]]] = {}
|
|
692
|
+
for file_path, name, kind, start_line in raw_symbols:
|
|
693
|
+
symbols_by_file.setdefault(file_path, []).append((name, kind, start_line))
|
|
694
|
+
|
|
695
|
+
# Slice 2: fetch cluster data for area annotation.
|
|
696
|
+
# Gracefully degrade to empty dicts when clusters are unavailable.
|
|
697
|
+
cluster_labels: dict[int, str] = {}
|
|
698
|
+
file_cluster_counts: dict[str, dict[int, int]] = {}
|
|
699
|
+
if _clusters_available(conn):
|
|
700
|
+
cluster_labels = _fetch_cluster_labels(conn)
|
|
701
|
+
file_cluster_counts = _fetch_file_cluster_counts(conn)
|
|
702
|
+
|
|
703
|
+
# Choose the tree root: when scoped, root at the scope dir (rel to repo root).
|
|
704
|
+
# WHY: if path=subdir, the tree should show "subdir/" as root, not the full repo.
|
|
705
|
+
tree_root = root
|
|
706
|
+
if scope_abs is not None:
|
|
707
|
+
tree_root = scope_abs
|
|
708
|
+
|
|
709
|
+
try:
|
|
710
|
+
tree = _build_file_tree(
|
|
711
|
+
tree_root, symbols_by_file, file_cluster_counts, cluster_labels, include_functions
|
|
712
|
+
)
|
|
713
|
+
except Exception:
|
|
714
|
+
logger.warning("build_structure: _build_file_tree raised", exc_info=True)
|
|
715
|
+
tree = _make_dir_node(tree_root.name or str(tree_root), None)
|
|
716
|
+
|
|
717
|
+
# Slice 3 bounds enforcement — applied AFTER tree build so propagated counts
|
|
718
|
+
# are correct on the surviving nodes. Track total truncated across both caps.
|
|
719
|
+
truncated = 0
|
|
720
|
+
|
|
721
|
+
# 1. Depth cap: drop nodes beyond effective_max_depth.
|
|
722
|
+
truncated += _apply_depth_cap(tree, effective_max_depth, cur_depth=0)
|
|
723
|
+
|
|
724
|
+
# 2. Node cap: BFS-order trim to effective_max_nodes non-root nodes.
|
|
725
|
+
truncated += _apply_node_cap(tree, effective_max_nodes)
|
|
726
|
+
|
|
727
|
+
return StructureResult(tree=tree, truncated=truncated)
|