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,155 @@
1
+ """Relevance ranking + self-reference classification for seam_impact output (E2/E3).
2
+
3
+ LEAF MODULE — pure functions over plain dicts/strings. Imports only stdlib.
4
+ No database access, no config, never raises. Mirrors the leaf discipline of
5
+ seam/query/names.py and seam/analysis/builtins.py so the ranking rules can be
6
+ unit-tested exhaustively without fixtures.
7
+
8
+ WHY this module exists (the usability gap it closes):
9
+ When seam_impact analyses a CLASS, expand_impact_seeds fans the seed out to
10
+ every member, so the upstream walk surfaces the class's OWN sibling methods
11
+ (Foo.a "depends on" Foo.b) as direct dependents. Alphabetical ordering floats
12
+ these self-references above the EXTERNAL callers an agent actually cares about,
13
+ and under the per-tier cap the externals fall off the bottom. The 2026-06-07
14
+ neutral re-benchmark confirmed this empirically: recall improved (more real
15
+ dependents found) but usability did not, because the right answer was below the
16
+ cut line. This module ranks external dependents ahead of self-references BEFORE
17
+ the cap, so the cap drops self-refs first — exactly as the handler already drops
18
+ test dependents before production ones.
19
+
20
+ Definitions:
21
+ container — the class/struct a symbol belongs to. Derived from a qualified
22
+ name's first segment ("Foo.bar" -> "Foo"; "pkg.Foo.bar" -> "pkg").
23
+ self-reference — an entry that belongs to the target's own container (the target
24
+ class itself, or any of its members). These are the entries an
25
+ agent is already editing when they change the target.
26
+
27
+ Conservatism contract (carried from the rest of the codebase):
28
+ When classification is uncertain, treat the entry as EXTERNAL. Never hide a real
29
+ external dependent by mis-flagging it self-ref. Never raise on the read path.
30
+ """
31
+
32
+ from typing import Any
33
+
34
+
35
+ def owning_container(name: str) -> str | None:
36
+ """Return the container segment of a qualified symbol name, or None if bare.
37
+
38
+ The container is everything before the LAST dot — consistent with bare_name()
39
+ in seam/query/names.py, which takes everything after the last dot.
40
+
41
+ Examples:
42
+ "Foo.bar" -> "Foo"
43
+ "pkg.Foo.bar" -> "pkg.Foo"
44
+ "bar" -> None (no dot — a bare name has no container)
45
+ "" -> None (empty)
46
+ ".bar" -> None (leading dot — empty container is not a container)
47
+ "Foo." -> "Foo" (trailing dot — degenerate but container is "Foo")
48
+
49
+ WHY everything-before-last-dot (not first segment):
50
+ Symbols are stored as "Container.member". For a deeply-qualified name like
51
+ "pkg.Foo.bar" the member is "bar" and its container is "pkg.Foo" — matching
52
+ how get_member_names builds "Class." prefixes. Using the first segment would
53
+ misclassify members of a dotted-package container.
54
+
55
+ Never raises.
56
+ """
57
+ if not name or "." not in name:
58
+ return None
59
+ prefix, _, _ = name.rpartition(".")
60
+ # A leading-dot name (".bar") has an empty prefix → not a real container.
61
+ return prefix or None
62
+
63
+
64
+ def classify_self_ref(
65
+ entry_name: str,
66
+ container: str | None,
67
+ self_names: set[str],
68
+ ) -> bool:
69
+ """Return True when an impact entry belongs to the target's own container.
70
+
71
+ An entry is a self-reference when EITHER:
72
+ 1. Its name is in self_names (the container itself or a bare member name), OR
73
+ 2. Its owning container equals the target container (catches every qualified
74
+ member form — "Foo.bar" — regardless of whether Tier B inference qualified
75
+ the edge target).
76
+
77
+ The two checks are complementary: the owning_container check handles qualified
78
+ entries ("Foo.bar"), while self_names handles the container name itself ("Foo")
79
+ and BARE member entries ("bar", whose owning_container is None).
80
+
81
+ Args:
82
+ entry_name: the dependent symbol's name (bare or qualified).
83
+ container: the target's container, or None when the target is a free
84
+ function / bare name with no container. None -> always False
85
+ (a target with no container can have no self-references).
86
+ self_names: {container} ∪ {bare member names}. Bare members must be listed
87
+ explicitly because owning_container("bar") is None.
88
+
89
+ Never raises. Returns False on the safe (external) side when container is None.
90
+ """
91
+ if not container:
92
+ return False
93
+ if entry_name in self_names:
94
+ return True
95
+ return owning_container(entry_name) == container
96
+
97
+
98
+ def relevance_key(
99
+ entry: dict[str, Any],
100
+ container: str | None,
101
+ self_names: set[str],
102
+ ) -> tuple[bool, bool]:
103
+ """Sort key ordering external-production entries first, self-references last.
104
+
105
+ Returns (is_self_ref, is_test). Python sorts False < True, so the ascending
106
+ order is:
107
+ (False, False) — external production ← kept first under the cap
108
+ (False, True) — external test
109
+ (True, False) — self-ref production
110
+ (True, True) — self-ref test ← dropped first under the cap
111
+
112
+ This SUPERSEDES the prior single-key production-before-test sort by adding
113
+ self-reference as the PRIMARY key while keeping is_test as the secondary key.
114
+ Used with a STABLE sort, so the analysis layer's ascending-distance/alphabetical
115
+ order is preserved within each group and results stay deterministic.
116
+ """
117
+ is_self = classify_self_ref(entry.get("name", ""), container, self_names)
118
+ is_test = bool(entry.get("is_test", False))
119
+ return (is_self, is_test)
120
+
121
+
122
+ def order_by_relevance(
123
+ entries: list[dict[str, Any]],
124
+ container: str | None,
125
+ self_names: set[str],
126
+ ) -> list[dict[str, Any]]:
127
+ """Return entries stably re-ordered external-first, self-references last.
128
+
129
+ Stable: within the external and self-reference groups the input order
130
+ (the analysis layer's ascending-distance/alphabetical order) is preserved,
131
+ so the closest external dependents survive an entries[:limit] cap.
132
+ Never mutates the input list.
133
+ """
134
+ return sorted(entries, key=lambda e: relevance_key(e, container, self_names))
135
+
136
+
137
+ def partition_self_refs(
138
+ entries: list[dict[str, Any]],
139
+ container: str | None,
140
+ self_names: set[str],
141
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
142
+ """Split entries into (external, self_refs), preserving input order in each.
143
+
144
+ Used by the "hide" self-ref mode: externals are kept in the output, self_refs
145
+ are dropped and only their COUNT is surfaced (mirrors the hidden_tests
146
+ mechanism). Never mutates the input list.
147
+ """
148
+ external: list[dict[str, Any]] = []
149
+ self_refs: list[dict[str, Any]] = []
150
+ for entry in entries:
151
+ if classify_self_ref(entry.get("name", ""), container, self_names):
152
+ self_refs.append(entry)
153
+ else:
154
+ external.append(entry)
155
+ return external, self_refs
seam/analysis/rwr.py ADDED
@@ -0,0 +1,129 @@
1
+ """Pure personalized-PageRank (Random-Walk-with-Restart) module — E3 neighbor ranking.
2
+
3
+ Public API:
4
+ personalized_pagerank(adjacency, seeds, *, restart, iters, tol) -> dict[str, float]
5
+
6
+ This is a DEEP PURE module: graph + seed set in → relevance scores out.
7
+ No SQLite, no file I/O, no config, no side effects (mirrors clustering.py).
8
+
9
+ `seeds` is a SET because a method symbol stored as "Class.method" can have call edges keyed under
10
+ the bare "method" (Seam's qualified/bare asymmetry). Personalizing the walk over BOTH forms (the
11
+ symbol's edge_match_names) ensures neighbours reachable via either form are scored relative to the
12
+ same logical seed.
13
+
14
+ WHY RWR for neighbor ranking (vs. raw degree):
15
+ Ranking a symbol's 1-hop neighbors by how RELEVANT they are TO THAT SYMBOL needs more than
16
+ degree centrality. RWR restarts the walk at the seed every step with probability `restart`,
17
+ so mass concentrates on nodes the seed actually reaches and re-reaches. A neighbor woven into
18
+ the seed's local neighborhood (it shares callers/callees with the seed → same functional
19
+ cluster) accumulates more score than a globally-popular but topically-distant neighbor. That
20
+ "closeness to the seed" is exactly what a context bundle wants to keep when it must cap.
21
+
22
+ Algorithm (power-iterated personalized PageRank):
23
+ r_{k+1}[n] = restart * teleport[n] + (1 - restart) * Σ_{m: n ∈ adj[m]} r_k[m] / outdeg(m)
24
+ where teleport is all mass on `seed` (personalization). Dangling/isolated mass (a node with
25
+ no neighbors) is sent back to the seed so probability is conserved. Undirected adjacency is
26
+ assumed (the caller symmetrizes), matching CodeGraph's computeGraphRelevance.
27
+
28
+ Determinism is load-bearing: nodes are processed in sorted order and the math is exact-rational-
29
+ free float arithmetic with a fixed iteration cap, so the same graph always yields the same scores.
30
+
31
+ Degenerate inputs (never raises):
32
+ - empty adjacency -> {}
33
+ - no seed present in adjacency -> {} (cannot personalize → caller skips ranking)
34
+ - seeds present but isolated (no edges) -> all mass on the seed set
35
+ """
36
+
37
+ import logging
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # Module constants (implementation details, not user-facing knobs):
42
+ # restart 0.15 = the canonical PageRank teleport probability; the exact value is not sensitive
43
+ # for RANKING (only the relative order of scores matters). 30 iterations + 1e-6 L1 tolerance
44
+ # converge comfortably on the bounded subgraphs E3 feeds in (~hundreds of nodes).
45
+ _DEFAULT_RESTART = 0.15
46
+ _DEFAULT_ITERS = 30
47
+ _DEFAULT_TOL = 1e-6
48
+
49
+
50
+ def personalized_pagerank(
51
+ adjacency: dict[str, set[str]],
52
+ seeds: set[str],
53
+ *,
54
+ restart: float = _DEFAULT_RESTART,
55
+ iters: int = _DEFAULT_ITERS,
56
+ tol: float = _DEFAULT_TOL,
57
+ ) -> dict[str, float]:
58
+ """Personalized PageRank (RWR) scores for every node, personalized to the `seeds` set.
59
+
60
+ Args:
61
+ adjacency: node name -> set of neighbor names. Assumed UNDIRECTED (caller symmetrizes).
62
+ Neighbor names not present as keys are ignored (dangling targets).
63
+ seeds: the nodes the walk restarts at (the symbol's edge_match_names — qualified +
64
+ bare). Teleport mass is split uniformly across the seeds present in the graph.
65
+ restart: teleport-to-seeds probability per step (0 < restart < 1).
66
+ iters: max power iterations.
67
+ tol: L1-convergence threshold; iteration stops early once the score vector moves
68
+ less than this between steps.
69
+
70
+ Returns:
71
+ dict node -> score (scores sum to ~1.0). Higher = more relevant to the seed set.
72
+ Returns {} when no seed is present in the graph (caller then skips ranking). Never raises.
73
+ """
74
+ try:
75
+ # Restrict to nodes that are keys; drop edges to unknown nodes so out-degree and the
76
+ # back-distribution stay consistent (mirrors clustering.py's "edges referencing unknown
77
+ # nodes are safely ignored").
78
+ nodes = sorted(adjacency.keys())
79
+ if not nodes:
80
+ return {}
81
+ node_set = set(nodes)
82
+ # Symmetric, self-loop-free neighbor lists restricted to known nodes.
83
+ nbrs: dict[str, list[str]] = {
84
+ n: sorted({m for m in adjacency.get(n, ()) if m in node_set and m != n})
85
+ for n in nodes
86
+ }
87
+
88
+ # Teleport distribution: uniform over the seeds that actually exist in the graph.
89
+ present_seeds = sorted(s for s in seeds if s in node_set)
90
+ if not present_seeds:
91
+ return {} # cannot personalize → caller falls back to min_id order
92
+ teleport: dict[str, float] = {n: 0.0 for n in nodes}
93
+ seed_share = 1.0 / len(present_seeds)
94
+ for s in present_seeds:
95
+ teleport[s] = seed_share
96
+
97
+ # Start all mass on the seed set (teleport vector).
98
+ rank: dict[str, float] = dict(teleport)
99
+ walk = 1.0 - restart
100
+
101
+ for _ in range(iters):
102
+ nxt: dict[str, float] = {n: 0.0 for n in nodes}
103
+ dangling_mass = 0.0
104
+ # Push each node's current mass to its neighbors (walk component).
105
+ for n in nodes:
106
+ deg = len(nbrs[n])
107
+ if deg == 0:
108
+ # Isolated/dangling node: its walk mass has nowhere to go → redirect to the
109
+ # seed set below (conserves total probability instead of leaking it).
110
+ dangling_mass += rank[n]
111
+ continue
112
+ share = rank[n] / deg
113
+ for m in nbrs[n]:
114
+ nxt[m] += share
115
+ # Apply the (1 - restart) walk weight, then add the restart teleport + the dangling
116
+ # mass, both distributed over the seed set.
117
+ for n in nodes:
118
+ nxt[n] = walk * nxt[n] + (restart + walk * dangling_mass) * teleport[n]
119
+
120
+ # L1 convergence check.
121
+ delta = sum(abs(nxt[n] - rank[n]) for n in nodes)
122
+ rank = nxt
123
+ if delta < tol:
124
+ break
125
+
126
+ return rank
127
+ except Exception: # noqa: BLE001 — pure module must never raise; degrade to no-ranking.
128
+ logger.debug("personalized_pagerank: degraded for seeds=%r", seeds, exc_info=True)
129
+ return {}
@@ -0,0 +1,328 @@
1
+ """Index staleness detector — single source of truth for "is this index stale".
2
+
3
+ Given an open DB connection + the project root, returns a StalenessVerdict.
4
+ Encapsulates the "is this index stale" logic behind one clean interface and is
5
+ the single source of truth — `seam status` and the MCP read tools both use it.
6
+
7
+ Algorithm (bounded-scan + per-process TTL cache):
8
+ 1. Query the N most recently indexed REAL files (path NOT LIKE ':%'), ordered
9
+ by indexed_at DESC, LIMIT SEAM_STALENESS_SCAN_CAP.
10
+ 2. For each file in that set, compare on-disk st_mtime vs stored mtime.
11
+ - Newer on-disk mtime → file was modified since last index → stale.
12
+ - OSError (file missing / permission denied) on a real file path → deleted → stale.
13
+ 3. If the watcher is alive (watcher_alive=True), file drift is NOT reported as
14
+ stale — the watcher self-heals file changes. HOWEVER, if synthesized edges
15
+ exist in the DB (SELECT COUNT(*) > 0 WHERE synthesized_by IS NOT NULL), still
16
+ report stale because the watcher never recomputes synthesized edges or clusters.
17
+ 4. Cache the verdict in a module-level dict keyed by (resolved db_path, resolved root)
18
+ for SEAM_STALENESS_TTL_SECONDS to avoid re-stat on every MCP read call.
19
+
20
+ Conservatism direction: on any IO/DB error, return stale=False (do NOT cry wolf).
21
+ WHY: a false-positive stale banner erodes agent trust; a missed staleness is a
22
+ pre-existing condition (agents already lived without the banner).
23
+
24
+ Never raises. All IO wrapped in try/except. Degrades gracefully on pre-v12 indexes
25
+ (the synthesized_by guard handles the missing-column case).
26
+
27
+ Mirrors the leaf discipline of seam/analysis/affected.py:
28
+ - imports only stdlib + seam/config
29
+ - takes a conn + root (not pure-leaf, but bounded/safe IO only)
30
+ - never raises, never mutates the DB
31
+ """
32
+
33
+ import logging
34
+ import os
35
+ import sqlite3
36
+ import time
37
+ from pathlib import Path
38
+ from typing import TypedDict
39
+
40
+ import seam.config as config
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # ── Public types ───────────────────────────────────────────────────────────────
45
+
46
+
47
+ class StalenessVerdict(TypedDict):
48
+ """Result shape returned by check_staleness().
49
+
50
+ Fields:
51
+ stale — True when the index is believed to be stale; False otherwise.
52
+ When False, callers omit the banner entirely (absence = fresh).
53
+ reason — Human/agent-readable cause of staleness. Empty string when fresh.
54
+ hint — The specific remedy. Empty string when fresh.
55
+ File drift → "Run 'seam sync' to reconcile the index."
56
+ Synthesized-edge drift → "Run 'seam init' or 'seam sync' ..."
57
+ """
58
+
59
+ stale: bool
60
+ reason: str
61
+ hint: str
62
+
63
+
64
+ # ── Hints (single source of truth for hint text) ───────────────────────────────
65
+
66
+ _HINT_FILE_DRIFT = "Run 'seam sync' to reconcile the index."
67
+ _HINT_SYNTH_DRIFT = (
68
+ "Run 'seam init' or 'seam sync' to refresh synthesized edges and clusters."
69
+ )
70
+
71
+ # ── Per-process TTL verdict cache ──────────────────────────────────────────────
72
+
73
+ # Keyed by (db_path_str, root_str) → (cached_at: float, verdict: StalenessVerdict).
74
+ # Module-level dict is safe: one dict per process, bounded by number of unique DB paths
75
+ # (typically 1–2 in any real server process). No eviction needed; TTL guards freshness.
76
+ _cache: dict[str, tuple[float, StalenessVerdict]] = {}
77
+
78
+
79
+ def _cache_key(conn: sqlite3.Connection, root: Path) -> str:
80
+ """Build a string cache key from the DB path and root path."""
81
+ try:
82
+ raw_db_path = conn.execute("PRAGMA database_list").fetchone()[2]
83
+ # Normalize the DB path too (not just root) so two spellings of the same DB
84
+ # can't produce duplicate cache entries.
85
+ db_path = str(Path(raw_db_path).resolve()) if raw_db_path else "<unknown>"
86
+ except Exception: # noqa: BLE001 — closed/invalid conn → no cache
87
+ db_path = "<unknown>"
88
+ return f"{db_path}||{root.resolve()}"
89
+
90
+
91
+ def _cache_get(key: str) -> StalenessVerdict | None:
92
+ """Return a cached verdict if still within TTL, else None."""
93
+ entry = _cache.get(key)
94
+ if entry is None:
95
+ return None
96
+ cached_at, verdict = entry
97
+ ttl = config.SEAM_STALENESS_TTL_SECONDS
98
+ if ttl > 0 and (time.time() - cached_at) < ttl:
99
+ return verdict
100
+ # Expired — evict.
101
+ _cache.pop(key, None)
102
+ return None
103
+
104
+
105
+ def _cache_put(key: str, verdict: StalenessVerdict) -> None:
106
+ """Store a verdict in the cache."""
107
+ _cache[key] = (time.time(), verdict)
108
+
109
+
110
+ # ── Watcher liveness probe ─────────────────────────────────────────────────────
111
+
112
+
113
+ def _watcher_is_alive(pid_file: Path) -> int | None:
114
+ """Return the PID if a live watcher process is recorded, else None.
115
+
116
+ Reads the PID file and probes the process with os.kill(pid, 0). A stale
117
+ PID file (process gone) returns None so callers can safely overwrite it.
118
+
119
+ Extracted here from cli/main.py to be importable by the handler layer
120
+ without creating a seam.server → seam.cli import cycle.
121
+ """
122
+ if not pid_file.exists():
123
+ return None
124
+ try:
125
+ pid = int(pid_file.read_text().strip())
126
+ except (OSError, ValueError):
127
+ return None
128
+ try:
129
+ os.kill(pid, 0) # signal 0 = liveness probe, doesn't actually signal
130
+ except OSError:
131
+ return None # no such process (or not ours) — treat as dead
132
+ return pid
133
+
134
+
135
+ # ── Synthesized-edge check ─────────────────────────────────────────────────────
136
+
137
+
138
+ def _has_synthesized_edges(conn: sqlite3.Connection) -> bool:
139
+ """Return True if the DB has any synthesized edges (synthesized_by IS NOT NULL).
140
+
141
+ WHY: the watcher never recomputes synthesized edges or clusters. Even with a
142
+ live watcher, a synthesis-enabled index becomes stale for synthesized data.
143
+
144
+ Guard for pre-v12 indexes: if the synthesized_by column doesn't exist, return
145
+ False (conservatism: treat as no synthesized edges, don't report staleness).
146
+ """
147
+ try:
148
+ row = conn.execute(
149
+ "SELECT COUNT(*) FROM edges WHERE synthesized_by IS NOT NULL"
150
+ ).fetchone()
151
+ return bool(row and row[0] > 0)
152
+ except Exception: # noqa: BLE001 — pre-v12 DB (no synthesized_by column), or closed conn
153
+ return False
154
+
155
+
156
+ # ── Core detection ─────────────────────────────────────────────────────────────
157
+
158
+
159
+ def _scan_for_drift(
160
+ conn: sqlite3.Connection,
161
+ scan_cap: int,
162
+ ) -> tuple[int, int]:
163
+ """Scan the N most recently indexed real files for mtime drift and deletions.
164
+
165
+ Returns (changed_count, deleted_count).
166
+
167
+ WHY bounded scan: only the newest `scan_cap` files are stat'd. A stale file
168
+ outside the cap window is NOT detected — this is the documented limitation.
169
+ On a repo with 10k files, checking all of them on every MCP read call would
170
+ add ~50-100ms per call on spinning disk.
171
+ """
172
+ rows = conn.execute(
173
+ """
174
+ SELECT path, mtime
175
+ FROM files
176
+ WHERE path NOT LIKE ':%'
177
+ ORDER BY indexed_at DESC
178
+ LIMIT ?
179
+ """,
180
+ (scan_cap,),
181
+ ).fetchall()
182
+
183
+ changed = 0
184
+ deleted = 0
185
+ for row in rows:
186
+ stored_path, stored_mtime = row[0], row[1]
187
+ p = Path(stored_path)
188
+ try:
189
+ disk_mtime = p.stat().st_mtime
190
+ if disk_mtime > stored_mtime:
191
+ changed += 1
192
+ except OSError:
193
+ # File is gone (deleted) or permission denied.
194
+ # Both count as "stale" — the index references something that changed.
195
+ deleted += 1
196
+
197
+ # Observability: when the scan fills the cap, the verdict covers only the newest
198
+ # `scan_cap` files — a stale file OUTSIDE that window reads identical to a clean
199
+ # repo. Log it so a "fresh" verdict on a large repo is not silently partial.
200
+ if len(rows) >= scan_cap:
201
+ logger.info(
202
+ "check_staleness: scan hit cap (%d files) — verdict covers only the "
203
+ "newest %d indexed files; older drift is not detected",
204
+ scan_cap,
205
+ scan_cap,
206
+ )
207
+ return changed, deleted
208
+
209
+
210
+ def check_staleness(
211
+ conn: sqlite3.Connection,
212
+ *,
213
+ root: Path,
214
+ watcher_alive: bool = False,
215
+ scan_cap: int | None = None,
216
+ respect_knob: bool = True,
217
+ ) -> StalenessVerdict:
218
+ """Determine whether the index is stale relative to the current on-disk state.
219
+
220
+ Args:
221
+ conn: Open SQLite connection (read-only; never mutated).
222
+ root: Absolute project root path. Used for cache key and watcher
223
+ PID-file location derivation.
224
+ watcher_alive: When True, the watcher is running and self-heals file drift.
225
+ File mtime drift is then NOT reported as stale (but
226
+ synthesized-edge drift still is).
227
+ scan_cap: Override for SEAM_STALENESS_SCAN_CAP (used by tests to
228
+ exercise the boundary without setting the config).
229
+ respect_knob: When True (default), SEAM_STALENESS_CHECK=off short-circuits
230
+ to a fresh verdict (the MCP-banner gate). The `seam status`
231
+ CLI passes respect_knob=False so its freshness field is
232
+ computed regardless of the banner knob — the knob gates the
233
+ MCP banner, NOT the unrelated CLI freshness feature, which
234
+ predates it and must not be silently disabled by it.
235
+
236
+ Returns:
237
+ StalenessVerdict with stale, reason, hint.
238
+
239
+ Never raises. On any error, returns stale=False (conservative default; do NOT
240
+ cry wolf when freshness cannot be determined).
241
+ """
242
+ # Banner gate: knob off → no IO, no banner, byte-identical to pre-feature.
243
+ # The handlers skip calling this when the knob is off; this guard is the safety
244
+ # net for direct callers. `seam status` opts out (respect_knob=False) so the
245
+ # CLI freshness field stays live even when the MCP banner is disabled.
246
+ if respect_knob and config.SEAM_STALENESS_CHECK != "on":
247
+ return StalenessVerdict(stale=False, reason="", hint="")
248
+
249
+ # Resolve scan_cap: test override takes precedence; otherwise use config.
250
+ effective_cap = scan_cap if scan_cap is not None else config.SEAM_STALENESS_SCAN_CAP
251
+
252
+ # Per-process TTL cache: a known-stale verdict is reused within the TTL so a
253
+ # burst of read-tool calls doesn't re-scan a repo we already know is stale.
254
+ cache_key = _cache_key(conn, root)
255
+ cached = _cache_get(cache_key)
256
+ if cached is not None:
257
+ logger.debug("check_staleness: returning cached verdict (stale=%s)", cached["stale"])
258
+ return cached
259
+
260
+ try:
261
+ verdict = _check_staleness_impl(conn, watcher_alive=watcher_alive, scan_cap=effective_cap)
262
+ except Exception: # noqa: BLE001 — never propagate to the read-tool caller
263
+ # Conservative default: stale=False means we don't cry wolf on unexpected errors.
264
+ # WARNING (not debug): a throw here is genuinely unexpected and silently defeats
265
+ # a CORRECTNESS feature — an operator must be able to see that the check broke
266
+ # rather than mistake "check failed" for "verified fresh". The deliberate
267
+ # no-watcher / no-drift skips stay quiet; only the unexpected path is loud.
268
+ logger.warning("check_staleness: unexpected error; returning stale=False", exc_info=True)
269
+ return StalenessVerdict(stale=False, reason="", hint="")
270
+
271
+ # Cache ONLY stale verdicts (the safe asymmetry). A fresh verdict is NOT cached:
272
+ # caching stale=False would mask a file edited within the TTL window — the exact
273
+ # false-safe this feature exists to prevent (and would also mask a watcher that
274
+ # died mid-session). Re-verifying a fresh repo is cheap (bounded by scan_cap stats);
275
+ # persisting a known-stale verdict is the only direction that is safe to cache.
276
+ if verdict["stale"]:
277
+ _cache_put(cache_key, verdict)
278
+ return verdict
279
+
280
+
281
+ def _check_staleness_impl(
282
+ conn: sqlite3.Connection,
283
+ *,
284
+ watcher_alive: bool,
285
+ scan_cap: int,
286
+ ) -> StalenessVerdict:
287
+ """Inner implementation — called from check_staleness under a try/except."""
288
+ # ── Case 1: watcher is alive ──────────────────────────────────────────────
289
+ if watcher_alive:
290
+ # The watcher self-heals file drift in real time, so file-mtime drift is
291
+ # NOT reported as stale. But synthesized edges / clusters are NEVER
292
+ # recomputed by the watcher — check for those separately.
293
+ if _has_synthesized_edges(conn):
294
+ return StalenessVerdict(
295
+ stale=True,
296
+ reason=(
297
+ "Synthesized edges and clusters may be stale — "
298
+ "the file watcher does not recompute them."
299
+ ),
300
+ hint=_HINT_SYNTH_DRIFT,
301
+ )
302
+ # File drift is expected (watcher handles it); nothing else to check.
303
+ return StalenessVerdict(stale=False, reason="", hint="")
304
+
305
+ # ── Case 2: no watcher — scan for file drift ──────────────────────────────
306
+ try:
307
+ changed, deleted = _scan_for_drift(conn, scan_cap=scan_cap)
308
+ except Exception: # noqa: BLE001 — e.g. closed conn; be conservative
309
+ logger.debug("check_staleness: _scan_for_drift failed; defaulting to stale=False", exc_info=True)
310
+ return StalenessVerdict(stale=False, reason="", hint="")
311
+
312
+ if deleted > 0 and changed > 0:
313
+ reason = (
314
+ f"{changed} indexed file(s) changed on disk and "
315
+ f"{deleted} tracked file(s) were deleted since last index."
316
+ )
317
+ return StalenessVerdict(stale=True, reason=reason, hint=_HINT_FILE_DRIFT)
318
+
319
+ if deleted > 0:
320
+ reason = f"{deleted} tracked file(s) were deleted since last index."
321
+ return StalenessVerdict(stale=True, reason=reason, hint=_HINT_FILE_DRIFT)
322
+
323
+ if changed > 0:
324
+ reason = f"{changed} indexed file(s) changed on disk since last index."
325
+ return StalenessVerdict(stale=True, reason=reason, hint=_HINT_FILE_DRIFT)
326
+
327
+ # No drift found in the scanned window.
328
+ return StalenessVerdict(stale=False, reason="", hint="")