seam-code 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seam/__init__.py +3 -0
- seam/_data/schema.sql +225 -0
- seam/_web/assets/index-BL_tqprR.js +216 -0
- seam/_web/assets/index-GTKUhVyD.css +1 -0
- seam/_web/index.html +13 -0
- seam/analysis/__init__.py +14 -0
- seam/analysis/affected.py +254 -0
- seam/analysis/builtins.py +966 -0
- seam/analysis/byte_budget.py +217 -0
- seam/analysis/changes.py +709 -0
- seam/analysis/cluster_naming.py +260 -0
- seam/analysis/clustering.py +216 -0
- seam/analysis/confidence.py +699 -0
- seam/analysis/embeddings.py +195 -0
- seam/analysis/flows.py +708 -0
- seam/analysis/impact.py +444 -0
- seam/analysis/imports.py +994 -0
- seam/analysis/imports_ext.py +780 -0
- seam/analysis/imports_resolve.py +176 -0
- seam/analysis/processes.py +453 -0
- seam/analysis/relevance.py +155 -0
- seam/analysis/rwr.py +129 -0
- seam/analysis/staleness.py +328 -0
- seam/analysis/steer.py +282 -0
- seam/analysis/synthesis.py +253 -0
- seam/analysis/synthesis_channels.py +433 -0
- seam/analysis/testpaths.py +103 -0
- seam/analysis/traversal.py +470 -0
- seam/cli/__init__.py +0 -0
- seam/cli/install.py +232 -0
- seam/cli/main.py +2602 -0
- seam/cli/output.py +137 -0
- seam/cli/read.py +244 -0
- seam/cli/serve.py +145 -0
- seam/config.py +551 -0
- seam/indexer/__init__.py +0 -0
- seam/indexer/cluster_index.py +425 -0
- seam/indexer/db.py +496 -0
- seam/indexer/embedding_index.py +183 -0
- seam/indexer/field_access.py +536 -0
- seam/indexer/field_access_c_cpp.py +643 -0
- seam/indexer/field_access_ext.py +708 -0
- seam/indexer/field_access_ext2.py +408 -0
- seam/indexer/field_access_go_rust.py +737 -0
- seam/indexer/field_access_php_swift.py +888 -0
- seam/indexer/field_access_ts.py +626 -0
- seam/indexer/graph.py +321 -0
- seam/indexer/graph_c.py +562 -0
- seam/indexer/graph_c_cpp.py +39 -0
- seam/indexer/graph_common.py +644 -0
- seam/indexer/graph_cpp.py +615 -0
- seam/indexer/graph_csharp.py +651 -0
- seam/indexer/graph_go.py +723 -0
- seam/indexer/graph_go_rust.py +39 -0
- seam/indexer/graph_java.py +689 -0
- seam/indexer/graph_java_csharp.py +38 -0
- seam/indexer/graph_php.py +914 -0
- seam/indexer/graph_python.py +628 -0
- seam/indexer/graph_ruby.py +748 -0
- seam/indexer/graph_rust.py +653 -0
- seam/indexer/graph_scope_infer.py +902 -0
- seam/indexer/graph_scope_infer_ext.py +723 -0
- seam/indexer/graph_scope_infer_ext2.py +992 -0
- seam/indexer/graph_swift.py +1014 -0
- seam/indexer/graph_swift_infer.py +515 -0
- seam/indexer/graph_typescript.py +663 -0
- seam/indexer/migrations.py +816 -0
- seam/indexer/parser.py +204 -0
- seam/indexer/pipeline.py +197 -0
- seam/indexer/signatures.py +634 -0
- seam/indexer/signatures_ext.py +780 -0
- seam/indexer/sync.py +287 -0
- seam/indexer/synthesis_index.py +291 -0
- seam/indexer/tokenize.py +79 -0
- seam/installer/__init__.py +67 -0
- seam/installer/claude.py +97 -0
- seam/installer/codex.py +94 -0
- seam/installer/core.py +127 -0
- seam/installer/cursor.py +61 -0
- seam/installer/guide.py +110 -0
- seam/installer/jsonfile.py +85 -0
- seam/installer/markdownfile.py +146 -0
- seam/installer/tomlfile.py +72 -0
- seam/query/__init__.py +0 -0
- seam/query/clusters.py +206 -0
- seam/query/comments.py +217 -0
- seam/query/context.py +293 -0
- seam/query/engine.py +940 -0
- seam/query/fts.py +328 -0
- seam/query/names.py +470 -0
- seam/query/pack.py +433 -0
- seam/query/semantic.py +339 -0
- seam/query/structure.py +727 -0
- seam/server/__init__.py +0 -0
- seam/server/graph_api.py +437 -0
- seam/server/handler_common.py +323 -0
- seam/server/impact_handler.py +615 -0
- seam/server/mcp.py +556 -0
- seam/server/tools.py +697 -0
- seam/server/trace_handler.py +184 -0
- seam/server/web.py +922 -0
- seam/watcher/__init__.py +0 -0
- seam/watcher/__main__.py +56 -0
- seam/watcher/daemon.py +237 -0
- seam_code-0.3.0.dist-info/METADATA +318 -0
- seam_code-0.3.0.dist-info/RECORD +109 -0
- seam_code-0.3.0.dist-info/WHEEL +4 -0
- seam_code-0.3.0.dist-info/entry_points.txt +2 -0
- seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|