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,146 @@
1
+ """LEAF: pure text/Markdown file operations for the installer (stdlib only).
2
+
3
+ Two kinds of write the guidance installer needs, neither expressible through the
4
+ JSON/TOML leaves:
5
+
6
+ * **owned files** — files Seam fully owns (a Claude Code skill `SKILL.md`, a
7
+ Cursor `seam.mdc`). `write_file` / `remove_file` treat the whole file as ours.
8
+ * **shared files** — files that hold the user's own content too (`AGENTS.md`,
9
+ `CLAUDE.md`). `upsert_block` / `remove_block` edit ONLY a marker-delimited
10
+ region (`<!-- seam:start -->` … `<!-- seam:end -->`) and never touch the rest.
11
+
12
+ Every write is atomic (temp + os.replace): an agent reads these files at startup,
13
+ so a crash mid-write must never leave a half-written instruction file behind.
14
+
15
+ No Seam dependencies → targets compose these without import cycles.
16
+ """
17
+
18
+ import os
19
+ import re
20
+ import tempfile
21
+ from pathlib import Path
22
+
23
+
24
+ def read_text(path: Path) -> str | None:
25
+ """Return the file's text, or None if it is absent or unreadable.
26
+
27
+ None is the "treat as empty / nothing to remove" signal for callers — a
28
+ missing or undecodable file is not an error here (we degrade, never raise).
29
+ """
30
+ if not path.exists():
31
+ return None
32
+ try:
33
+ return path.read_text(encoding="utf-8")
34
+ except (OSError, UnicodeDecodeError):
35
+ return None
36
+
37
+
38
+ def atomic_write_text(path: Path, text: str) -> None:
39
+ """Write `text` to `path` atomically; create parent dirs as needed."""
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
42
+ try:
43
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
44
+ handle.write(text)
45
+ os.replace(tmp, path)
46
+ except BaseException:
47
+ # Never leave the temp file behind on failure (disk-full, signal, etc.).
48
+ Path(tmp).unlink(missing_ok=True)
49
+ raise
50
+
51
+
52
+ # ── owned files (SKILL.md, seam.mdc) ──────────────────────────────────────────
53
+
54
+
55
+ def write_file(path: Path, content: str) -> str:
56
+ """Write a Seam-owned file. Returns created | updated | unchanged.
57
+
58
+ Idempotent: identical content (after trailing-newline normalisation) is a
59
+ no-op so re-running `seam install` does not churn the file's mtime.
60
+ """
61
+ normalized = content if content.endswith("\n") else content + "\n"
62
+ if read_text(path) == normalized:
63
+ return "unchanged"
64
+ action = "updated" if path.exists() else "created"
65
+ atomic_write_text(path, normalized)
66
+ return action
67
+
68
+
69
+ def remove_file(path: Path) -> str:
70
+ """Delete a Seam-owned file if present. Returns removed | not_present."""
71
+ if path.exists():
72
+ path.unlink()
73
+ return "removed"
74
+ return "not_present"
75
+
76
+
77
+ # ── shared files (AGENTS.md, CLAUDE.md) — marker-delimited block ──────────────
78
+
79
+
80
+ def _markers(marker: str) -> tuple[str, str]:
81
+ """The start/end HTML-comment sentinels for a marker name.
82
+
83
+ HTML comments render invisibly in Markdown across every agent, so the block
84
+ is unobtrusive in a file the user also reads.
85
+ """
86
+ return f"<!-- {marker}:start -->", f"<!-- {marker}:end -->"
87
+
88
+
89
+ def _block_re(marker: str) -> re.Pattern[str]:
90
+ """Regex matching the whole start…end region (inclusive), non-greedy."""
91
+ start, end = _markers(marker)
92
+ return re.compile(re.escape(start) + r".*?" + re.escape(end), re.DOTALL)
93
+
94
+
95
+ def wrap_block(content: str, marker: str) -> str:
96
+ """Wrap `content` in the marker sentinels — the canonical block form."""
97
+ start, end = _markers(marker)
98
+ return f"{start}\n{content.strip()}\n{end}"
99
+
100
+
101
+ def upsert_block(path: Path, content: str, *, marker: str) -> str:
102
+ """Insert or replace the marked block in a shared file.
103
+
104
+ Returns created | updated | unchanged. Replaces an existing block IN PLACE
105
+ (never duplicates) and appends a new one without disturbing foreign content.
106
+ """
107
+ block = wrap_block(content, marker)
108
+ existing = read_text(path)
109
+
110
+ if existing is None:
111
+ atomic_write_text(path, block + "\n")
112
+ return "created"
113
+
114
+ pattern = _block_re(marker)
115
+ if pattern.search(existing):
116
+ # Replace via a function so backslashes/group-refs in `block` stay literal.
117
+ new_text = pattern.sub(lambda _m: block, existing, count=1)
118
+ if new_text == existing:
119
+ return "unchanged"
120
+ atomic_write_text(path, new_text)
121
+ return "updated"
122
+
123
+ base = existing.rstrip("\n")
124
+ new_text = f"{base}\n\n{block}\n" if base else f"{block}\n"
125
+ atomic_write_text(path, new_text)
126
+ return "updated"
127
+
128
+
129
+ def remove_block(path: Path, *, marker: str) -> str:
130
+ """Remove the marked block from a shared file. Returns removed | not_present.
131
+
132
+ Preserves all foreign content and collapses the blank lines left behind so
133
+ the file does not accrete empty space across install/uninstall cycles.
134
+ """
135
+ existing = read_text(path)
136
+ if existing is None:
137
+ return "not_present"
138
+
139
+ pattern = _block_re(marker)
140
+ if not pattern.search(existing):
141
+ return "not_present"
142
+
143
+ new_text = pattern.sub("", existing, count=1)
144
+ new_text = re.sub(r"\n{3,}", "\n\n", new_text).strip("\n")
145
+ atomic_write_text(path, (new_text + "\n") if new_text else "")
146
+ return "removed"
@@ -0,0 +1,72 @@
1
+ """LEAF: TOML config-file operations for the Codex target.
2
+
3
+ Codex stores MCP servers in ~/.codex/config.toml as [mcp_servers.<name>] tables.
4
+ Stdlib `tomllib` is read-only, so we use `tomlkit` — crucially, it round-trips a
5
+ user's existing comments and formatting, so installing a Seam entry never mangles
6
+ a hand-tuned config. No Seam dependencies (leaf).
7
+ """
8
+
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import tomlkit
15
+ from tomlkit import TOMLDocument
16
+
17
+
18
+ def load_toml(path: Path) -> TOMLDocument | None:
19
+ """Parse the TOML document at `path`, or None if absent OR unparseable."""
20
+ if not path.exists():
21
+ return None
22
+ try:
23
+ return tomlkit.parse(path.read_text(encoding="utf-8"))
24
+ except (tomlkit.exceptions.TOMLKitError, OSError, UnicodeDecodeError):
25
+ return None
26
+
27
+
28
+ def atomic_write_toml(path: Path, doc: TOMLDocument) -> None:
29
+ """Write `doc` to `path` atomically (temp + os.replace); create parent dirs."""
30
+ path.parent.mkdir(parents=True, exist_ok=True)
31
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
32
+ try:
33
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
34
+ handle.write(tomlkit.dumps(doc))
35
+ os.replace(tmp, path)
36
+ except BaseException:
37
+ Path(tmp).unlink(missing_ok=True)
38
+ raise
39
+
40
+
41
+ def get_server_table(doc: TOMLDocument, server: str) -> dict[str, Any] | None:
42
+ """Return [mcp_servers.<server>] as a plain dict for comparison, or None if absent."""
43
+ servers = doc.get("mcp_servers")
44
+ if not isinstance(servers, dict) or server not in servers:
45
+ return None
46
+ # Unwrap tomlkit items to plain Python so equality checks are value-based.
47
+ return {k: _plain(v) for k, v in servers[server].items()}
48
+
49
+
50
+ def set_server_table(doc: TOMLDocument, server: str, table: dict[str, Any]) -> None:
51
+ """Set [mcp_servers.<server>] = table, creating the parent table if needed."""
52
+ if "mcp_servers" not in doc:
53
+ doc["mcp_servers"] = tomlkit.table()
54
+ doc["mcp_servers"][server] = table
55
+
56
+
57
+ def delete_server_table(doc: TOMLDocument, server: str) -> bool:
58
+ """Delete [mcp_servers.<server>]. Return True iff it existed."""
59
+ servers = doc.get("mcp_servers")
60
+ if isinstance(servers, dict) and server in servers:
61
+ del servers[server]
62
+ return True
63
+ return False
64
+
65
+
66
+ def _plain(value: Any) -> Any:
67
+ """Recursively unwrap tomlkit containers to plain dict/list for equality checks."""
68
+ if isinstance(value, dict):
69
+ return {k: _plain(v) for k, v in value.items()}
70
+ if isinstance(value, list):
71
+ return [_plain(v) for v in value]
72
+ return value
seam/query/__init__.py ADDED
File without changes
seam/query/clusters.py ADDED
@@ -0,0 +1,206 @@
1
+ """Read-only query layer for cluster data (Phase 2 — community detection).
2
+
3
+ Provides three entry points:
4
+ list_clusters(conn) → [{id, label, size}] — all clusters
5
+ cluster_members(conn, id) → [{name, file, line, kind}] — members of one cluster
6
+ cluster_peers(conn, symbol) → (cluster_id, label, peer_names) | None
7
+
8
+ No server or CLI imports — pure query/read layer.
9
+ Callers (handler, CLI, engine) are responsible for path relativization.
10
+
11
+ Pre-v4 guard: when the clusters table or cluster_id column is missing
12
+ (index built before Phase 2), all functions return empty results + a one-time
13
+ warning. This mirrors the _comments_table_exists guard in query/comments.py.
14
+ """
15
+
16
+ import logging
17
+ import sqlite3
18
+ from typing import TypedDict
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # One-time warning state — avoids log spam when MCP server calls repeatedly
23
+ _pre_v4_warned = False
24
+
25
+
26
+ # ── Output TypedDicts ─────────────────────────────────────────────────────────
27
+
28
+
29
+ class ClusterRow(TypedDict):
30
+ """One cluster summary row returned by list_clusters()."""
31
+ id: int
32
+ label: str
33
+ size: int
34
+
35
+
36
+ class MemberRow(TypedDict):
37
+ """One symbol member row returned by cluster_members()."""
38
+ name: str
39
+ file: str
40
+ line: int
41
+ kind: str
42
+
43
+
44
+ # ── Internal guards ───────────────────────────────────────────────────────────
45
+
46
+
47
+ def _clusters_table_exists(conn: sqlite3.Connection) -> bool:
48
+ """Return True if the clusters table exists in this database.
49
+
50
+ WHY: The MCP server opens a bare connect() (no schema script), so a pre-v4
51
+ index won't have the clusters table. Querying it would raise OperationalError.
52
+ We detect the missing table and degrade gracefully to empty results.
53
+ """
54
+ row = conn.execute(
55
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='clusters' LIMIT 1"
56
+ ).fetchone()
57
+ return row is not None
58
+
59
+
60
+ def _cluster_id_column_exists(conn: sqlite3.Connection) -> bool:
61
+ """Return True if symbols.cluster_id column exists."""
62
+ col_names = {row["name"] for row in conn.execute("PRAGMA table_info(symbols)").fetchall()}
63
+ return "cluster_id" in col_names
64
+
65
+
66
+ def _warn_pre_v4_once() -> None:
67
+ """Log a one-time warning when a pre-v4 index is detected."""
68
+ global _pre_v4_warned
69
+ if not _pre_v4_warned:
70
+ logger.warning(
71
+ "seam_clusters: clusters table or cluster_id column missing "
72
+ "(index predates Phase 2 clustering) — run 'seam init' to populate. "
73
+ "Returning empty results."
74
+ )
75
+ _pre_v4_warned = True
76
+
77
+
78
+ # ── Public API ────────────────────────────────────────────────────────────────
79
+
80
+
81
+ def list_clusters(conn: sqlite3.Connection) -> list[ClusterRow]:
82
+ """Return all cluster summary rows.
83
+
84
+ Args:
85
+ conn: Open read-only SQLite connection.
86
+
87
+ Returns:
88
+ List of ClusterRow dicts sorted by id. Empty list when no clusters exist
89
+ or when the index predates Phase 2 (pre-v4 index).
90
+ """
91
+ if not _clusters_table_exists(conn):
92
+ _warn_pre_v4_once()
93
+ return []
94
+
95
+ rows = conn.execute(
96
+ "SELECT id, label, size FROM clusters ORDER BY id"
97
+ ).fetchall()
98
+
99
+ return [ClusterRow(id=row["id"], label=row["label"], size=row["size"]) for row in rows]
100
+
101
+
102
+ def cluster_members(conn: sqlite3.Connection, cluster_id: int) -> list[MemberRow]:
103
+ """Return the member symbols of a specific cluster.
104
+
105
+ Args:
106
+ conn: Open read-only SQLite connection.
107
+ cluster_id: The clusters.id value to look up.
108
+
109
+ Returns:
110
+ List of MemberRow dicts sorted by name. Empty list when the cluster
111
+ doesn't exist or the index predates Phase 2.
112
+ """
113
+ if not _clusters_table_exists(conn) or not _cluster_id_column_exists(conn):
114
+ _warn_pre_v4_once()
115
+ return []
116
+
117
+ rows = conn.execute(
118
+ """
119
+ SELECT s.name, f.path AS file, s.start_line AS line, s.kind
120
+ FROM symbols s
121
+ JOIN files f ON f.id = s.file_id
122
+ WHERE s.cluster_id = ?
123
+ ORDER BY s.name
124
+ """,
125
+ (cluster_id,),
126
+ ).fetchall()
127
+
128
+ return [
129
+ MemberRow(
130
+ name=row["name"],
131
+ file=row["file"],
132
+ line=row["line"],
133
+ kind=row["kind"],
134
+ )
135
+ for row in rows
136
+ ]
137
+
138
+
139
+ def cluster_peers(
140
+ conn: sqlite3.Connection,
141
+ symbol: str,
142
+ ) -> tuple[int, str, list[str]] | None:
143
+ """Return the cluster context for a symbol: (cluster_id, label, peer_names).
144
+
145
+ Peers are all members of the same cluster EXCLUDING the queried symbol itself.
146
+
147
+ Args:
148
+ conn: Open read-only SQLite connection.
149
+ symbol: Symbol name to look up.
150
+
151
+ Returns:
152
+ (cluster_id, label, peer_names) if the symbol is clustered.
153
+ None if the symbol is not in the index, has no cluster assignment,
154
+ or the index predates Phase 2.
155
+ """
156
+ if not _clusters_table_exists(conn) or not _cluster_id_column_exists(conn):
157
+ _warn_pre_v4_once()
158
+ return None
159
+
160
+ # Resolve the symbol the same way context() does: lowest id wins when ambiguous.
161
+ # WHY (issue #5): if two rows share a name, the one with the lowest id may be
162
+ # unclustered (cluster_id=NULL) while a higher-id row is clustered. Using a JOIN
163
+ # on clusters would silently skip the NULL row and return the wrong definition's
164
+ # cluster. We resolve the symbol first, THEN fetch the cluster info — consistent
165
+ # with engine.py::context() which uses `ORDER BY s.id LIMIT 1` on a plain query.
166
+ sym_row = conn.execute(
167
+ "SELECT id, cluster_id FROM symbols WHERE name = ? ORDER BY id LIMIT 1",
168
+ (symbol,),
169
+ ).fetchone()
170
+
171
+ if sym_row is None:
172
+ # Symbol not in the index at all
173
+ return None
174
+
175
+ if sym_row["cluster_id"] is None:
176
+ # Symbol exists but is unclustered (below min_size or no edges)
177
+ return None
178
+
179
+ cluster_id_val: int = sym_row["cluster_id"]
180
+
181
+ # Fetch the cluster label
182
+ cluster_row = conn.execute(
183
+ "SELECT label FROM clusters WHERE id = ?",
184
+ (cluster_id_val,),
185
+ ).fetchone()
186
+
187
+ if cluster_row is None:
188
+ # Orphan cluster_id (shouldn't happen; be defensive)
189
+ return None
190
+
191
+ cluster_id: int = cluster_id_val
192
+ label: str = cluster_row["label"]
193
+
194
+ # Fetch all other members of the same cluster (peers)
195
+ peer_rows = conn.execute(
196
+ """
197
+ SELECT DISTINCT s.name
198
+ FROM symbols s
199
+ WHERE s.cluster_id = ? AND s.name != ?
200
+ ORDER BY s.name
201
+ """,
202
+ (cluster_id, symbol),
203
+ ).fetchall()
204
+
205
+ peers = [r["name"] for r in peer_rows]
206
+ return cluster_id, label, peers
seam/query/comments.py ADDED
@@ -0,0 +1,217 @@
1
+ """Read-only query layer for semantic comments (WHY/HACK/NOTE/TODO/FIXME).
2
+
3
+ Provides why() — the single entry point for comment lookup by:
4
+ - file path (return all comments in that file)
5
+ - file + line (return comments within ±RADIUS lines)
6
+ - symbol name (return comments in/above the symbol's body)
7
+
8
+ No server or CLI imports; this module lives at the query layer only.
9
+ Callers (handler, CLI) are responsible for path resolution and relativization.
10
+ """
11
+
12
+ import logging
13
+ import sqlite3
14
+ from typing import TypedDict
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ── Constants (documented; not env-driven — fixed marker set decision) ────────
19
+
20
+ # Proximity radius: when querying by line, return comments within ±RADIUS lines.
21
+ # Example: line=20, RADIUS=15 -> window [5, 35].
22
+ RADIUS: int = 15
23
+
24
+ # Lead lines: for symbol lookup, extend the search range this many lines ABOVE
25
+ # the symbol's start_line to capture "pre-symbol" rationale comments.
26
+ # Example: start_line=10, LEAD=5 -> search from line 5.
27
+ LEAD: int = 5
28
+
29
+
30
+ # ── Output TypedDict ──────────────────────────────────────────────────────────
31
+
32
+
33
+ class CommentHit(TypedDict):
34
+ """One semantic comment result returned by why().
35
+
36
+ file — absolute path (DB-stored); handler/CLI relativizes before output.
37
+ line — 1-based line number in the source file.
38
+ marker — normalized UPPERCASE: WHY | HACK | NOTE | TODO | FIXME.
39
+ text — comment body after the marker (and optional colon), stripped.
40
+ """
41
+
42
+ file: str
43
+ line: int
44
+ marker: str
45
+ text: str
46
+
47
+
48
+ # ── Internal helpers ──────────────────────────────────────────────────────────
49
+
50
+
51
+ def _comments_table_exists(conn: sqlite3.Connection) -> bool:
52
+ """Return True if the comments table exists in this database.
53
+
54
+ WHY: connect() (used by `seam start`, `seam status`, `seam why`) opens a bare
55
+ connection and does NOT run the schema script — only init_db() does. An index
56
+ created before this slice (schema v2) therefore has no comments table on those
57
+ connections. Querying it would raise OperationalError, but the MCP/CLI contract
58
+ is an empty list ("no recorded rationale"), not an error. We detect the missing
59
+ table and degrade gracefully, logging a one-time hint to re-index.
60
+ """
61
+ row = conn.execute(
62
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name='comments' LIMIT 1"
63
+ ).fetchone()
64
+ return row is not None
65
+
66
+
67
+ def _fetch_by_file_id(
68
+ conn: sqlite3.Connection,
69
+ file_id: int,
70
+ file_path: str,
71
+ low: int | None = None,
72
+ high: int | None = None,
73
+ ) -> list[CommentHit]:
74
+ """Fetch comments for a file_id, optionally filtering to a line range [low, high].
75
+
76
+ Results are sorted by line number (ascending).
77
+ """
78
+ if low is not None and high is not None:
79
+ rows = conn.execute(
80
+ """
81
+ SELECT line, marker, text
82
+ FROM comments
83
+ WHERE file_id = ? AND line BETWEEN ? AND ?
84
+ ORDER BY line
85
+ """,
86
+ (file_id, low, high),
87
+ ).fetchall()
88
+ else:
89
+ rows = conn.execute(
90
+ """
91
+ SELECT line, marker, text
92
+ FROM comments
93
+ WHERE file_id = ?
94
+ ORDER BY line
95
+ """,
96
+ (file_id,),
97
+ ).fetchall()
98
+
99
+ return [
100
+ CommentHit(file=file_path, line=row["line"], marker=row["marker"], text=row["text"])
101
+ for row in rows
102
+ ]
103
+
104
+
105
+ def _resolve_file_id(conn: sqlite3.Connection, file_path: str) -> tuple[int, str] | None:
106
+ """Look up a file_id + stored path by exact path match.
107
+
108
+ Returns (file_id, stored_path) or None if not found.
109
+ The stored_path is what the DB has (resolved absolute path at index time).
110
+ """
111
+ row = conn.execute(
112
+ "SELECT id, path FROM files WHERE path = ?", (file_path,)
113
+ ).fetchone()
114
+ if row is None:
115
+ return None
116
+ return row["id"], row["path"]
117
+
118
+
119
+ def _resolve_symbol(
120
+ conn: sqlite3.Connection, symbol_name: str
121
+ ) -> tuple[int, str, int, int] | None:
122
+ """Resolve a symbol name to (file_id, file_path, start_line, end_line).
123
+
124
+ When multiple symbols share the same name, returns the first by (file path, symbol id)
125
+ — deterministic ordering consistent with engine.context().
126
+
127
+ Returns None if the symbol is not in the index.
128
+ """
129
+ row = conn.execute(
130
+ """
131
+ SELECT s.file_id, f.path, s.start_line, s.end_line
132
+ FROM symbols s
133
+ JOIN files f ON f.id = s.file_id
134
+ WHERE s.name = ?
135
+ ORDER BY f.path, s.id
136
+ LIMIT 1
137
+ """,
138
+ (symbol_name,),
139
+ ).fetchone()
140
+ if row is None:
141
+ return None
142
+ return row["file_id"], row["path"], row["start_line"], row["end_line"]
143
+
144
+
145
+ # ── Public API ────────────────────────────────────────────────────────────────
146
+
147
+
148
+ def why(
149
+ conn: sqlite3.Connection,
150
+ *,
151
+ file: str | None = None,
152
+ line: int | None = None,
153
+ symbol: str | None = None,
154
+ ) -> list[CommentHit]:
155
+ """Return semantic comments near a location or symbol.
156
+
157
+ Lookup modes (at least one of file/symbol is required):
158
+ file only → all comments for that file (exact path match vs DB).
159
+ file + line → comments within ±RADIUS lines of `line`.
160
+ symbol → comments in [start_line - LEAD, end_line] of the symbol.
161
+
162
+ Args:
163
+ conn: Open read-only SQLite connection to the Seam index.
164
+ file: Absolute file path to look up (must match files.path exactly).
165
+ line: 1-based line number (only meaningful with `file`; ignored when
166
+ `symbol` is given — symbol mode uses the symbol's own line range).
167
+ symbol: Symbol name to look up (resolved via symbols table).
168
+
169
+ Returns:
170
+ List of CommentHit dicts sorted by line number. Empty list when the
171
+ file/symbol is not indexed or has no semantic comments.
172
+
173
+ Raises:
174
+ ValueError: When neither `file` nor `symbol` is provided.
175
+
176
+ Note on path matching: The DB stores resolved absolute paths. The caller
177
+ (handler/CLI) must resolve the user-supplied path to an absolute path
178
+ before passing it here. This function does exact string matching on files.path.
179
+ """
180
+ if file is None and symbol is None:
181
+ raise ValueError("why() requires at least one of: file, symbol")
182
+
183
+ # Pre-1b indexes (schema v2) opened via connect() have no comments table.
184
+ # Degrade to the empty-list contract instead of raising OperationalError.
185
+ if not _comments_table_exists(conn):
186
+ logger.warning(
187
+ "seam_why: comments table missing (index predates this feature) — "
188
+ "run 'seam init' to enable semantic comments. Returning no results."
189
+ )
190
+ return []
191
+
192
+ # Symbol mode: resolve symbol -> file + line range, then query comments
193
+ if symbol is not None:
194
+ resolved = _resolve_symbol(conn, symbol)
195
+ if resolved is None:
196
+ return []
197
+ file_id, file_path, start_line, end_line = resolved
198
+ # Extend the range above the symbol by LEAD lines to capture pre-symbol rationale.
199
+ # Clamp low to 1 so we never search for line < 1.
200
+ low = max(1, start_line - LEAD)
201
+ return _fetch_by_file_id(conn, file_id, file_path, low=low, high=end_line)
202
+
203
+ # File-only or file+line mode
204
+ resolved_file = _resolve_file_id(conn, file) # type: ignore[arg-type]
205
+ if resolved_file is None:
206
+ return []
207
+
208
+ file_id, file_path = resolved_file
209
+
210
+ if line is not None:
211
+ # Proximity query: [line - RADIUS, line + RADIUS]
212
+ low = max(1, line - RADIUS)
213
+ high = line + RADIUS
214
+ return _fetch_by_file_id(conn, file_id, file_path, low=low, high=high)
215
+
216
+ # File-only: all comments for this file
217
+ return _fetch_by_file_id(conn, file_id, file_path)