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