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