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
seam/analysis/changes.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""detect_changes — map a git diff to affected symbols and produce a pre-commit risk report.
|
|
2
|
+
|
|
3
|
+
This module owns the git boundary for Seam. It shells out to git, parses the
|
|
4
|
+
unified diff, maps changed line ranges to symbols in the index, runs impact
|
|
5
|
+
analysis on the changed symbols, and rolls up an overall risk level.
|
|
6
|
+
|
|
7
|
+
Public interface
|
|
8
|
+
----------------
|
|
9
|
+
``detect_changes(conn, base_ref, scope, repo_root) -> ChangeReport``
|
|
10
|
+
|
|
11
|
+
scope values:
|
|
12
|
+
"working" -> ``git diff`` (unstaged working tree vs index)
|
|
13
|
+
"staged" -> ``git diff --cached``
|
|
14
|
+
"branch" -> ``git diff <base_ref>...HEAD``
|
|
15
|
+
|
|
16
|
+
``ChangeReport`` (TypedDict):
|
|
17
|
+
changed_symbols — list of ChangedSymbol (symbol + file + changed_lines)
|
|
18
|
+
new_files — list of str (added/untracked absolute paths)
|
|
19
|
+
affected — list of AffectedSymbol (symbol + tier + confidence + file)
|
|
20
|
+
risk_level — "low" | "medium" | "high" | "critical"
|
|
21
|
+
ambiguous_warning — bool (True when dominant edges are AMBIGUOUS, see rollup rule)
|
|
22
|
+
scope — the scope used ("working" | "staged" | "branch")
|
|
23
|
+
base_ref — the base ref used (only relevant for scope="branch")
|
|
24
|
+
|
|
25
|
+
Risk rollup rule (documented exactly):
|
|
26
|
+
1. Collect every Reached symbol from impact() across all changed symbols.
|
|
27
|
+
2. Compute the highest tier reached: d=1 -> WILL_BREAK, d=2 -> LIKELY_AFFECTED, d=3+ -> MAY_NEED_TESTING.
|
|
28
|
+
3. Map to risk_level:
|
|
29
|
+
WILL_BREAK -> "critical"
|
|
30
|
+
LIKELY_AFFECTED -> "high"
|
|
31
|
+
MAY_NEED_TESTING -> "medium"
|
|
32
|
+
no dependents -> "low"
|
|
33
|
+
4. AMBIGUOUS attenuation: if ANY reached symbol has AMBIGUOUS confidence,
|
|
34
|
+
set ambiguous_warning=True. When ALL reached symbols are AMBIGUOUS
|
|
35
|
+
(no EXTRACTED or INFERRED), cap the risk_level at "medium" (uncertain
|
|
36
|
+
inputs cap the verdict's confidence). When some are AMBIGUOUS and some
|
|
37
|
+
are not, keep the raw risk_level but still set ambiguous_warning=True.
|
|
38
|
+
|
|
39
|
+
Non-git directory / git command failure:
|
|
40
|
+
Raises ``NotAGitRepoError`` (a subclass of ValueError) with a clear message.
|
|
41
|
+
The handler and CLI catch this and render a user-friendly message, NOT a raw traceback.
|
|
42
|
+
|
|
43
|
+
Path storage contract (VERIFIED):
|
|
44
|
+
The indexer (cli/main.py init_cmd) resolves the project root with Path(path).resolve()
|
|
45
|
+
before calling index_one_file, which passes the already-resolved Path to upsert_file.
|
|
46
|
+
upsert_file stores str(filepath) — the resolved absolute path.
|
|
47
|
+
Therefore detect_changes MUST resolve repo_root so that
|
|
48
|
+
``str(repo_root / fd.path)`` matches what was indexed.
|
|
49
|
+
Both detect_changes and _get_untracked_files use the same resolved root.
|
|
50
|
+
|
|
51
|
+
Import hierarchy:
|
|
52
|
+
analysis.changes imports from analysis.impact, analysis.traversal, config.
|
|
53
|
+
NO imports from server or cli.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
import logging
|
|
57
|
+
import os
|
|
58
|
+
import re
|
|
59
|
+
import sqlite3
|
|
60
|
+
import subprocess
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import TypedDict
|
|
63
|
+
|
|
64
|
+
import seam.config as config
|
|
65
|
+
from seam.analysis.impact import impact
|
|
66
|
+
from seam.analysis.traversal import (
|
|
67
|
+
CONFIDENCE_AMBIGUOUS,
|
|
68
|
+
CONFIDENCE_INFERRED,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
logger = logging.getLogger(__name__)
|
|
72
|
+
|
|
73
|
+
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# Valid scope values.
|
|
76
|
+
VALID_SCOPES = {"working", "staged", "branch"}
|
|
77
|
+
|
|
78
|
+
# Default base ref for branch scope.
|
|
79
|
+
DEFAULT_BASE_REF = "main"
|
|
80
|
+
|
|
81
|
+
# Max depth for impact analysis from changed symbols.
|
|
82
|
+
_IMPACT_MAX_DEPTH = 3
|
|
83
|
+
|
|
84
|
+
# Timeout (seconds) for all git subprocess calls. Prevents the MCP server from
|
|
85
|
+
# hanging indefinitely if git blocks (e.g. lock contention, slow NFS mount).
|
|
86
|
+
_GIT_TIMEOUT_SECONDS = 30
|
|
87
|
+
|
|
88
|
+
# Risk level strings (ordered from least to most severe).
|
|
89
|
+
RISK_LOW = "low"
|
|
90
|
+
RISK_MEDIUM = "medium"
|
|
91
|
+
RISK_HIGH = "high"
|
|
92
|
+
RISK_CRITICAL = "critical"
|
|
93
|
+
|
|
94
|
+
# Maps highest tier name to risk_level (before attenuation).
|
|
95
|
+
_TIER_TO_RISK: dict[str, str] = {
|
|
96
|
+
"WILL_BREAK": RISK_CRITICAL,
|
|
97
|
+
"LIKELY_AFFECTED": RISK_HIGH,
|
|
98
|
+
"MAY_NEED_TESTING": RISK_MEDIUM,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Risk level ordering for capping logic.
|
|
102
|
+
_RISK_ORDER: list[str] = [RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Custom exceptions ─────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NotAGitRepoError(ValueError):
|
|
109
|
+
"""Raised when the repo_root is not a git repository or git is unavailable.
|
|
110
|
+
|
|
111
|
+
This is intentionally a ValueError subclass so callers can catch it
|
|
112
|
+
without importing this module's exception class. The handler and CLI
|
|
113
|
+
must catch this to render a user-friendly message instead of a traceback.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── Public TypedDicts ─────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ChangedSymbol(TypedDict):
|
|
121
|
+
"""A symbol whose definition spans one or more changed lines."""
|
|
122
|
+
name: str
|
|
123
|
+
file: str # absolute path
|
|
124
|
+
kind: str
|
|
125
|
+
start_line: int
|
|
126
|
+
end_line: int
|
|
127
|
+
changed_lines: list[int] # changed line numbers that overlap this symbol's range
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AffectedSymbol(TypedDict):
|
|
131
|
+
"""A symbol downstream/upstream of a changed symbol, from impact analysis."""
|
|
132
|
+
name: str
|
|
133
|
+
file: str | None # absolute path if indexed, else None
|
|
134
|
+
tier: str # WILL_BREAK | LIKELY_AFFECTED | MAY_NEED_TESTING
|
|
135
|
+
confidence: str # EXTRACTED | INFERRED | AMBIGUOUS
|
|
136
|
+
distance: int
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ChangeReport(TypedDict):
|
|
140
|
+
"""Full result of detect_changes()."""
|
|
141
|
+
changed_symbols: list[ChangedSymbol]
|
|
142
|
+
new_files: list[str] # absolute paths of added/untracked files
|
|
143
|
+
affected: list[AffectedSymbol]
|
|
144
|
+
risk_level: str # low | medium | high | critical
|
|
145
|
+
ambiguous_warning: bool # True when AMBIGUOUS edges dominate (see rollup rule)
|
|
146
|
+
scope: str
|
|
147
|
+
base_ref: str
|
|
148
|
+
partial: bool # True when changed symbols exceeded the cap (SEAM_MAX_IMPACT_SYMBOLS)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── Git helpers ────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _run_git(args: list[str], cwd: Path) -> str:
|
|
155
|
+
"""Run a git command and return stdout as a string.
|
|
156
|
+
|
|
157
|
+
Raises NotAGitRepoError on:
|
|
158
|
+
- 'not a git repository' in stderr
|
|
159
|
+
- FileNotFoundError (git not installed)
|
|
160
|
+
- CalledProcessError with returncode != 0 (e.g. invalid ref)
|
|
161
|
+
|
|
162
|
+
Other OSError variants bubble as-is (unexpected system error).
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run( # noqa: S603 — controlled git invocation, no shell=True
|
|
166
|
+
["git", *args],
|
|
167
|
+
cwd=str(cwd),
|
|
168
|
+
capture_output=True,
|
|
169
|
+
text=True,
|
|
170
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
171
|
+
)
|
|
172
|
+
except subprocess.TimeoutExpired as exc:
|
|
173
|
+
raise NotAGitRepoError(
|
|
174
|
+
f"git command timed out after {_GIT_TIMEOUT_SECONDS}s"
|
|
175
|
+
) from exc
|
|
176
|
+
except FileNotFoundError as exc:
|
|
177
|
+
raise NotAGitRepoError(
|
|
178
|
+
"git command not found. Make sure git is installed and in PATH."
|
|
179
|
+
) from exc
|
|
180
|
+
|
|
181
|
+
if result.returncode != 0:
|
|
182
|
+
stderr_lower = result.stderr.lower()
|
|
183
|
+
if "not a git repository" in stderr_lower:
|
|
184
|
+
raise NotAGitRepoError(
|
|
185
|
+
f"'{cwd}' is not a git repository (or its parents)."
|
|
186
|
+
)
|
|
187
|
+
raise NotAGitRepoError(
|
|
188
|
+
f"git {args[0]!r} failed (exit {result.returncode}): {result.stderr.strip()}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return result.stdout
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _get_diff(scope: str, base_ref: str, repo_root: Path) -> str:
|
|
195
|
+
"""Return the raw unified diff text for the given scope.
|
|
196
|
+
|
|
197
|
+
scope=working -> git diff (unstaged)
|
|
198
|
+
scope=staged -> git diff --cached
|
|
199
|
+
scope=branch -> git diff <base_ref>...HEAD
|
|
200
|
+
"""
|
|
201
|
+
if scope == "working":
|
|
202
|
+
return _run_git(["diff"], repo_root)
|
|
203
|
+
if scope == "staged":
|
|
204
|
+
return _run_git(["diff", "--cached"], repo_root)
|
|
205
|
+
# scope == "branch"
|
|
206
|
+
return _run_git(["diff", f"{base_ref}...HEAD"], repo_root)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _get_untracked_files(repo_root: Path) -> list[str]:
|
|
210
|
+
"""Return absolute paths of untracked files (scope=working only).
|
|
211
|
+
|
|
212
|
+
Uses 'git ls-files --others --exclude-standard' to list untracked files.
|
|
213
|
+
Returns empty list on any git error (non-fatal for the main report).
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
output = _run_git(
|
|
217
|
+
["ls-files", "--others", "--exclude-standard"],
|
|
218
|
+
repo_root,
|
|
219
|
+
)
|
|
220
|
+
except NotAGitRepoError:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
paths: list[str] = []
|
|
224
|
+
for line in output.splitlines():
|
|
225
|
+
line = line.strip()
|
|
226
|
+
if line:
|
|
227
|
+
# repo_root is already resolved (detect_changes resolves it at the top),
|
|
228
|
+
# so (repo_root / line) produces a resolved absolute path consistent with DB storage.
|
|
229
|
+
paths.append(str(repo_root / line))
|
|
230
|
+
return paths
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── Unified diff parser ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
# Regex patterns for diff parsing.
|
|
236
|
+
_HUNK_HEADER_RE = re.compile(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
237
|
+
_FILE_HEADER_RE = re.compile(r"^\+\+\+ b/(.+)$")
|
|
238
|
+
_DEV_NULL_RE = re.compile(r"^\+\+\+ /dev/null$")
|
|
239
|
+
_ADDED_FILE_RE = re.compile(r"^new file mode")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class _FileDiff:
|
|
243
|
+
"""Parsed result for one file in a unified diff."""
|
|
244
|
+
__slots__ = ("path", "is_new_file", "changed_lines")
|
|
245
|
+
|
|
246
|
+
def __init__(self, path: str, is_new_file: bool, changed_lines: list[int]) -> None:
|
|
247
|
+
self.path = path # relative path from repo root
|
|
248
|
+
self.is_new_file = is_new_file
|
|
249
|
+
self.changed_lines = changed_lines # new-file line numbers of added/changed lines
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_unified_diff(diff_text: str) -> list[_FileDiff]:
|
|
253
|
+
"""Parse a unified diff into per-file changed line ranges.
|
|
254
|
+
|
|
255
|
+
Parses @@ hunk headers to extract new-file line numbers for all added
|
|
256
|
+
lines ('+' prefix). Deleted-only lines do not contribute new-file
|
|
257
|
+
line numbers. Modified hunks contribute the new-file side.
|
|
258
|
+
|
|
259
|
+
Returns a list of _FileDiff — one entry per file that appears in the diff.
|
|
260
|
+
Files deleted entirely ('+++ /dev/null') are skipped (no new-file lines).
|
|
261
|
+
"""
|
|
262
|
+
results: list[_FileDiff] = []
|
|
263
|
+
current_path: str | None = None
|
|
264
|
+
current_lines: list[int] = []
|
|
265
|
+
current_is_new: bool = False
|
|
266
|
+
current_line_no: int = 0
|
|
267
|
+
# git emits 'new file mode' BEFORE the '+++ b/path' header, so we cannot
|
|
268
|
+
# set current_is_new directly in the '+++ b/' handler (it arrives later).
|
|
269
|
+
# Use a pending flag that the '+++ b/' handler reads and clears on parse.
|
|
270
|
+
pending_new_file: bool = False
|
|
271
|
+
|
|
272
|
+
for line in diff_text.splitlines():
|
|
273
|
+
# New file path header
|
|
274
|
+
m = _FILE_HEADER_RE.match(line)
|
|
275
|
+
if m:
|
|
276
|
+
# Flush the previous file if any.
|
|
277
|
+
if current_path is not None:
|
|
278
|
+
results.append(_FileDiff(current_path, current_is_new, current_lines))
|
|
279
|
+
current_path = m.group(1)
|
|
280
|
+
current_lines = []
|
|
281
|
+
# Apply the pending new-file flag set by 'new file mode' above this line.
|
|
282
|
+
current_is_new = pending_new_file
|
|
283
|
+
pending_new_file = False
|
|
284
|
+
current_line_no = 0
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# Deleted file (no new lines to map)
|
|
288
|
+
if _DEV_NULL_RE.match(line):
|
|
289
|
+
if current_path is not None:
|
|
290
|
+
results.append(_FileDiff(current_path, current_is_new, current_lines))
|
|
291
|
+
current_path = None
|
|
292
|
+
current_lines = []
|
|
293
|
+
current_is_new = False
|
|
294
|
+
pending_new_file = False
|
|
295
|
+
current_line_no = 0
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Detect new file marker (appears between --- and +++ headers).
|
|
299
|
+
# Set pending_new_file so the subsequent '+++ b/path' handler picks it up
|
|
300
|
+
# without being reset (the '+++ b/' handler reads and clears it).
|
|
301
|
+
if _ADDED_FILE_RE.match(line):
|
|
302
|
+
pending_new_file = True
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
# Hunk header: @@ -old +new[,count] @@
|
|
306
|
+
hm = _HUNK_HEADER_RE.match(line)
|
|
307
|
+
if hm:
|
|
308
|
+
start = int(hm.group(1))
|
|
309
|
+
current_line_no = start
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Content lines — only track new-file side.
|
|
313
|
+
if current_path is None:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
317
|
+
# Added or modified line in the new file — record it.
|
|
318
|
+
current_lines.append(current_line_no)
|
|
319
|
+
current_line_no += 1
|
|
320
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
321
|
+
# Deleted line — does NOT advance new-file line counter.
|
|
322
|
+
pass
|
|
323
|
+
elif line.startswith(" "):
|
|
324
|
+
# Context line — present in both old and new file (always has leading space).
|
|
325
|
+
# Empty strings are inter-hunk artifacts, not context lines; skip them.
|
|
326
|
+
current_line_no += 1
|
|
327
|
+
|
|
328
|
+
# Flush the last file.
|
|
329
|
+
if current_path is not None:
|
|
330
|
+
results.append(_FileDiff(current_path, current_is_new, current_lines))
|
|
331
|
+
|
|
332
|
+
return results
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ── Symbol lookup ──────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _lookup_symbols_for_file_lines(
|
|
339
|
+
conn: sqlite3.Connection,
|
|
340
|
+
abs_path: str,
|
|
341
|
+
changed_lines: list[int],
|
|
342
|
+
) -> list[ChangedSymbol]:
|
|
343
|
+
"""Find all symbols in a file whose range overlaps any changed line.
|
|
344
|
+
|
|
345
|
+
A symbol overlaps if any changed line satisfies:
|
|
346
|
+
symbol.start_line <= line <= symbol.end_line
|
|
347
|
+
|
|
348
|
+
Uses a join on files.path to find the file_id, then queries symbols.
|
|
349
|
+
Returns a list of ChangedSymbol dicts (one per matching symbol).
|
|
350
|
+
|
|
351
|
+
Lines not inside ANY symbol (module-level code) result in a synthetic
|
|
352
|
+
ChangedSymbol entry attributed to the file path itself (not dropped).
|
|
353
|
+
"""
|
|
354
|
+
if not changed_lines:
|
|
355
|
+
return []
|
|
356
|
+
|
|
357
|
+
# Resolve the file_id for this absolute path.
|
|
358
|
+
row = conn.execute("SELECT id FROM files WHERE path = ?", (abs_path,)).fetchone()
|
|
359
|
+
if row is None:
|
|
360
|
+
# File not in the index — return a file-level attribution.
|
|
361
|
+
return _make_file_level_entry(abs_path, changed_lines)
|
|
362
|
+
|
|
363
|
+
file_id = row["id"]
|
|
364
|
+
|
|
365
|
+
# Find all symbols in this file with their line ranges.
|
|
366
|
+
symbols = conn.execute(
|
|
367
|
+
"SELECT name, kind, start_line, end_line FROM symbols WHERE file_id = ?",
|
|
368
|
+
(file_id,),
|
|
369
|
+
).fetchall()
|
|
370
|
+
|
|
371
|
+
if not symbols:
|
|
372
|
+
# File is indexed but no symbols — return file-level attribution.
|
|
373
|
+
return _make_file_level_entry(abs_path, changed_lines)
|
|
374
|
+
|
|
375
|
+
changed_set = set(changed_lines)
|
|
376
|
+
results: list[ChangedSymbol] = []
|
|
377
|
+
covered_lines: set[int] = set()
|
|
378
|
+
|
|
379
|
+
for sym in symbols:
|
|
380
|
+
# Find which changed lines fall within this symbol's range.
|
|
381
|
+
sym_lines = [
|
|
382
|
+
ln for ln in changed_lines
|
|
383
|
+
if sym["start_line"] <= ln <= sym["end_line"]
|
|
384
|
+
]
|
|
385
|
+
if sym_lines:
|
|
386
|
+
results.append(
|
|
387
|
+
ChangedSymbol(
|
|
388
|
+
name=sym["name"],
|
|
389
|
+
file=abs_path,
|
|
390
|
+
kind=sym["kind"],
|
|
391
|
+
start_line=sym["start_line"],
|
|
392
|
+
end_line=sym["end_line"],
|
|
393
|
+
changed_lines=sym_lines,
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
covered_lines.update(sym_lines)
|
|
397
|
+
|
|
398
|
+
# Lines not covered by any symbol → file-level attribution.
|
|
399
|
+
uncovered = sorted(changed_set - covered_lines)
|
|
400
|
+
if uncovered:
|
|
401
|
+
results.extend(_make_file_level_entry(abs_path, uncovered))
|
|
402
|
+
|
|
403
|
+
return results
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _make_file_level_entry(abs_path: str, lines: list[int]) -> list[ChangedSymbol]:
|
|
407
|
+
"""Create a module-level ChangedSymbol attribution for lines outside any symbol."""
|
|
408
|
+
return [
|
|
409
|
+
ChangedSymbol(
|
|
410
|
+
name=f"<module:{Path(abs_path).name}>",
|
|
411
|
+
file=abs_path,
|
|
412
|
+
kind="module",
|
|
413
|
+
start_line=0,
|
|
414
|
+
end_line=0,
|
|
415
|
+
changed_lines=lines,
|
|
416
|
+
)
|
|
417
|
+
]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _lookup_symbols_for_new_file(
|
|
421
|
+
conn: sqlite3.Connection,
|
|
422
|
+
abs_path: str,
|
|
423
|
+
) -> list[ChangedSymbol]:
|
|
424
|
+
"""Return all symbols from an added/new file that has been indexed.
|
|
425
|
+
|
|
426
|
+
If the file is not yet in the index (brand-new, untracked), returns a
|
|
427
|
+
single file-level ChangedSymbol to mark the file as new (not invisible).
|
|
428
|
+
"""
|
|
429
|
+
row = conn.execute("SELECT id FROM files WHERE path = ?", (abs_path,)).fetchone()
|
|
430
|
+
if row is None:
|
|
431
|
+
# New file not yet indexed — surface as file-level entry so it's not invisible.
|
|
432
|
+
return [
|
|
433
|
+
ChangedSymbol(
|
|
434
|
+
name=f"<new:{Path(abs_path).name}>",
|
|
435
|
+
file=abs_path,
|
|
436
|
+
kind="module",
|
|
437
|
+
start_line=0,
|
|
438
|
+
end_line=0,
|
|
439
|
+
changed_lines=[],
|
|
440
|
+
)
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
file_id = row["id"]
|
|
444
|
+
symbols = conn.execute(
|
|
445
|
+
"SELECT name, kind, start_line, end_line FROM symbols WHERE file_id = ?",
|
|
446
|
+
(file_id,),
|
|
447
|
+
).fetchall()
|
|
448
|
+
|
|
449
|
+
results: list[ChangedSymbol] = []
|
|
450
|
+
for sym in symbols:
|
|
451
|
+
results.append(
|
|
452
|
+
ChangedSymbol(
|
|
453
|
+
name=sym["name"],
|
|
454
|
+
file=abs_path,
|
|
455
|
+
kind=sym["kind"],
|
|
456
|
+
start_line=sym["start_line"],
|
|
457
|
+
end_line=sym["end_line"],
|
|
458
|
+
changed_lines=[], # new file — all lines are "changed"
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if not results:
|
|
463
|
+
# File indexed but no symbols extracted.
|
|
464
|
+
results = _make_file_level_entry(abs_path, [])
|
|
465
|
+
|
|
466
|
+
return results
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ── Impact rollup ─────────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _collect_impact(
|
|
473
|
+
conn: sqlite3.Connection,
|
|
474
|
+
changed_symbol_names: list[str],
|
|
475
|
+
cap: int | None = None,
|
|
476
|
+
) -> tuple[list[AffectedSymbol], bool]:
|
|
477
|
+
"""Run impact(upstream) for each changed symbol name, collect unique affected symbols.
|
|
478
|
+
|
|
479
|
+
Returns a tuple (affected_symbols, was_truncated) where was_truncated is True
|
|
480
|
+
when the number of real changed symbols exceeded the cap — the caller uses this
|
|
481
|
+
to set ChangeReport.partial so agents know the risk verdict covers only a subset.
|
|
482
|
+
|
|
483
|
+
The cap defaults to config.SEAM_MAX_IMPACT_SYMBOLS (env-configurable). Passing
|
|
484
|
+
an explicit cap is supported for testing without needing to monkeypatch config.
|
|
485
|
+
|
|
486
|
+
Uses a deduplication dict keyed by symbol name. When the same symbol
|
|
487
|
+
appears via multiple changed sources, we keep the highest-tier (most severe)
|
|
488
|
+
entry (lowest distance = most severe).
|
|
489
|
+
|
|
490
|
+
Only runs impact on indexable symbol names (skips module-level <module:...> entries
|
|
491
|
+
that are not real indexed symbols — the impact engine gracefully handles unknown
|
|
492
|
+
symbols, but it's wasteful to call for every <module:file.py> entry).
|
|
493
|
+
"""
|
|
494
|
+
# Resolve cap: prefer explicit arg (test injection), fall back to config.
|
|
495
|
+
max_symbols = cap if cap is not None else config.SEAM_MAX_IMPACT_SYMBOLS
|
|
496
|
+
|
|
497
|
+
# Filter out synthetic module-level names.
|
|
498
|
+
real_names = [n for n in changed_symbol_names if not n.startswith("<")]
|
|
499
|
+
if not real_names:
|
|
500
|
+
return [], False
|
|
501
|
+
|
|
502
|
+
# Cap changed symbols to avoid unbounded impact() calls on very large diffs.
|
|
503
|
+
# Processes the first max_symbols in list order (deterministic).
|
|
504
|
+
# A warning is logged when the cap is hit — visible via SEAM_LOG_LEVEL=WARNING.
|
|
505
|
+
was_truncated = len(real_names) > max_symbols
|
|
506
|
+
if was_truncated:
|
|
507
|
+
logger.warning(
|
|
508
|
+
"detect_changes: %d changed symbols exceeds cap %d; impact computed on first %d",
|
|
509
|
+
len(real_names),
|
|
510
|
+
max_symbols,
|
|
511
|
+
max_symbols,
|
|
512
|
+
)
|
|
513
|
+
real_names = real_names[:max_symbols]
|
|
514
|
+
|
|
515
|
+
# Deduplicate by (name, lowest distance) across all seeds.
|
|
516
|
+
# key=name -> AffectedSymbol (keep the one with lowest distance / worst tier).
|
|
517
|
+
best: dict[str, AffectedSymbol] = {}
|
|
518
|
+
|
|
519
|
+
for name in real_names:
|
|
520
|
+
# WHY no repo_root: seam_changes uses name-count resolution intentionally so its
|
|
521
|
+
# risk verdicts (WILL_BREAK/etc) stay byte-identical to the documented contract.
|
|
522
|
+
# Import-promotion would silently lower risk tiers for homonyms — a behavior change
|
|
523
|
+
# that would invalidate the existing risk rollup contract. Omitting repo_root
|
|
524
|
+
# preserves backward-compatible risk verdicts. (Decision — do not "fix".)
|
|
525
|
+
result = impact(conn, target=name, direction="upstream", max_depth=_IMPACT_MAX_DEPTH)
|
|
526
|
+
if not result.get("found"):
|
|
527
|
+
continue
|
|
528
|
+
tier_group = result.get("upstream", {})
|
|
529
|
+
for tier, entries in tier_group.items():
|
|
530
|
+
for entry in entries:
|
|
531
|
+
aname = entry["name"]
|
|
532
|
+
candidate = AffectedSymbol(
|
|
533
|
+
name=aname,
|
|
534
|
+
file=entry.get("file"),
|
|
535
|
+
tier=tier,
|
|
536
|
+
confidence=entry.get("confidence", CONFIDENCE_INFERRED),
|
|
537
|
+
distance=entry.get("distance", 1),
|
|
538
|
+
)
|
|
539
|
+
existing = best.get(aname)
|
|
540
|
+
if existing is None or candidate["distance"] < existing["distance"]:
|
|
541
|
+
best[aname] = candidate
|
|
542
|
+
|
|
543
|
+
return list(best.values()), was_truncated
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _compute_risk_level(
|
|
547
|
+
affected: list[AffectedSymbol],
|
|
548
|
+
) -> tuple[str, bool]:
|
|
549
|
+
"""Compute overall risk_level and ambiguous_warning from affected symbols.
|
|
550
|
+
|
|
551
|
+
Returns (risk_level, ambiguous_warning).
|
|
552
|
+
|
|
553
|
+
Risk rollup rule (exact):
|
|
554
|
+
1. Find the highest tier across all affected symbols:
|
|
555
|
+
WILL_BREAK > LIKELY_AFFECTED > MAY_NEED_TESTING > (none)
|
|
556
|
+
2. Map that tier to risk_level:
|
|
557
|
+
WILL_BREAK -> critical
|
|
558
|
+
LIKELY_AFFECTED -> high
|
|
559
|
+
MAY_NEED_TESTING -> medium
|
|
560
|
+
(none) -> low
|
|
561
|
+
3. AMBIGUOUS attenuation:
|
|
562
|
+
a. If ALL affected symbols have AMBIGUOUS confidence → cap risk_level at "medium".
|
|
563
|
+
b. If ANY symbol has AMBIGUOUS confidence (but not all) → keep raw risk, set warning.
|
|
564
|
+
c. Set ambiguous_warning=True in cases (a) and (b).
|
|
565
|
+
"""
|
|
566
|
+
if not affected:
|
|
567
|
+
return RISK_LOW, False
|
|
568
|
+
|
|
569
|
+
# Tier severity order (highest first) for finding the worst tier.
|
|
570
|
+
tier_severity = {"WILL_BREAK": 3, "LIKELY_AFFECTED": 2, "MAY_NEED_TESTING": 1}
|
|
571
|
+
|
|
572
|
+
highest_severity = 0
|
|
573
|
+
highest_tier: str | None = None
|
|
574
|
+
all_ambiguous = True
|
|
575
|
+
any_ambiguous = False
|
|
576
|
+
|
|
577
|
+
for sym in affected:
|
|
578
|
+
sev = tier_severity.get(sym["tier"], 0)
|
|
579
|
+
if sev > highest_severity:
|
|
580
|
+
highest_severity = sev
|
|
581
|
+
highest_tier = sym["tier"]
|
|
582
|
+
|
|
583
|
+
conf = sym["confidence"]
|
|
584
|
+
if conf == CONFIDENCE_AMBIGUOUS:
|
|
585
|
+
any_ambiguous = True
|
|
586
|
+
else:
|
|
587
|
+
all_ambiguous = False
|
|
588
|
+
|
|
589
|
+
# Base risk from highest tier.
|
|
590
|
+
if highest_tier is None:
|
|
591
|
+
base_risk = RISK_LOW
|
|
592
|
+
else:
|
|
593
|
+
base_risk = _TIER_TO_RISK.get(highest_tier, RISK_LOW)
|
|
594
|
+
|
|
595
|
+
ambiguous_warning = any_ambiguous
|
|
596
|
+
|
|
597
|
+
# Attenuation: if ALL edges are AMBIGUOUS, cap at "medium".
|
|
598
|
+
if all_ambiguous and any_ambiguous:
|
|
599
|
+
# Cap risk_level at medium (uncertain inputs limit verdict confidence).
|
|
600
|
+
risk_idx = _RISK_ORDER.index(base_risk)
|
|
601
|
+
medium_idx = _RISK_ORDER.index(RISK_MEDIUM)
|
|
602
|
+
final_risk = _RISK_ORDER[min(risk_idx, medium_idx)]
|
|
603
|
+
else:
|
|
604
|
+
final_risk = base_risk
|
|
605
|
+
|
|
606
|
+
return final_risk, ambiguous_warning
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# ── Public interface ───────────────────────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def detect_changes(
|
|
613
|
+
conn: sqlite3.Connection,
|
|
614
|
+
base_ref: str = DEFAULT_BASE_REF,
|
|
615
|
+
scope: str = "working",
|
|
616
|
+
repo_root: Path | None = None,
|
|
617
|
+
) -> ChangeReport:
|
|
618
|
+
"""Map git diff to affected symbols and compute an overall pre-commit risk level.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
conn: Open SQLite connection (read-only; no writes).
|
|
622
|
+
base_ref: Git ref to compare against for scope="branch" (e.g. "main").
|
|
623
|
+
Ignored for scope="working" and scope="staged".
|
|
624
|
+
scope: One of "working", "staged", "branch". Default: "working".
|
|
625
|
+
repo_root: Absolute path to the git repository root. If None, uses the
|
|
626
|
+
current working directory (not recommended in production;
|
|
627
|
+
pass an explicit path from the CLI or handler).
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
ChangeReport TypedDict (see module docstring for field descriptions).
|
|
631
|
+
|
|
632
|
+
Raises:
|
|
633
|
+
NotAGitRepoError: if repo_root is not a git repo or git fails.
|
|
634
|
+
ValueError: if scope is not one of the valid values.
|
|
635
|
+
"""
|
|
636
|
+
if scope not in VALID_SCOPES:
|
|
637
|
+
raise ValueError(f"scope must be one of {sorted(VALID_SCOPES)}; got {scope!r}")
|
|
638
|
+
|
|
639
|
+
# Always resolve repo_root so that `str(repo_root / fd.path)` produces the
|
|
640
|
+
# same resolved absolute path the indexer stored in the DB.
|
|
641
|
+
# The indexer (cli/main.py init) calls Path(path).resolve() before indexing, so
|
|
642
|
+
# DB paths are fully resolved. On macOS, /tmp is a symlink to /private/tmp —
|
|
643
|
+
# an unresolved root would silently miss every DB lookup for files under /tmp.
|
|
644
|
+
repo_root = (repo_root or Path(os.getcwd())).resolve()
|
|
645
|
+
|
|
646
|
+
# Get the unified diff for the requested scope.
|
|
647
|
+
diff_text = _get_diff(scope, base_ref, repo_root)
|
|
648
|
+
|
|
649
|
+
# Parse diff into per-file changed line ranges.
|
|
650
|
+
file_diffs: list[_FileDiff] = _parse_unified_diff(diff_text)
|
|
651
|
+
|
|
652
|
+
changed_symbols: list[ChangedSymbol] = []
|
|
653
|
+
new_files: list[str] = []
|
|
654
|
+
|
|
655
|
+
for fd in file_diffs:
|
|
656
|
+
# repo_root is already resolved above; joining with the relative diff path
|
|
657
|
+
# produces a resolved absolute path that matches what the indexer stored.
|
|
658
|
+
abs_path = str(repo_root / fd.path)
|
|
659
|
+
|
|
660
|
+
if fd.is_new_file:
|
|
661
|
+
# New file: surface its symbols (or a file-level entry if not indexed yet).
|
|
662
|
+
new_files.append(abs_path)
|
|
663
|
+
syms = _lookup_symbols_for_new_file(conn, abs_path)
|
|
664
|
+
changed_symbols.extend(syms)
|
|
665
|
+
else:
|
|
666
|
+
# Modified file: map changed lines to owning symbols.
|
|
667
|
+
syms = _lookup_symbols_for_file_lines(conn, abs_path, fd.changed_lines)
|
|
668
|
+
changed_symbols.extend(syms)
|
|
669
|
+
|
|
670
|
+
# For working scope, also include untracked files.
|
|
671
|
+
if scope == "working":
|
|
672
|
+
untracked = _get_untracked_files(repo_root)
|
|
673
|
+
for abs_path in untracked:
|
|
674
|
+
if abs_path not in new_files:
|
|
675
|
+
new_files.append(abs_path)
|
|
676
|
+
syms = _lookup_symbols_for_new_file(conn, abs_path)
|
|
677
|
+
changed_symbols.extend(syms)
|
|
678
|
+
|
|
679
|
+
# Run impact on all changed symbol names (deduplication inside _collect_impact).
|
|
680
|
+
# The function returns (affected_list, was_truncated) — partial=True when cap was hit.
|
|
681
|
+
changed_names = [s["name"] for s in changed_symbols]
|
|
682
|
+
affected, partial = _collect_impact(conn, changed_names)
|
|
683
|
+
|
|
684
|
+
# Compute overall risk level.
|
|
685
|
+
risk_level, ambiguous_warning = _compute_risk_level(affected)
|
|
686
|
+
|
|
687
|
+
logger.debug(
|
|
688
|
+
"detect_changes(scope=%r, base_ref=%r, repo_root=%s) -> "
|
|
689
|
+
"%d changed_symbols, %d affected, risk=%s, partial=%s, ambiguous_warning=%s",
|
|
690
|
+
scope,
|
|
691
|
+
base_ref,
|
|
692
|
+
repo_root,
|
|
693
|
+
len(changed_symbols),
|
|
694
|
+
len(affected),
|
|
695
|
+
risk_level,
|
|
696
|
+
partial,
|
|
697
|
+
ambiguous_warning,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
return ChangeReport(
|
|
701
|
+
changed_symbols=changed_symbols,
|
|
702
|
+
new_files=new_files,
|
|
703
|
+
affected=affected,
|
|
704
|
+
risk_level=risk_level,
|
|
705
|
+
ambiguous_warning=ambiguous_warning,
|
|
706
|
+
scope=scope,
|
|
707
|
+
base_ref=base_ref,
|
|
708
|
+
partial=partial,
|
|
709
|
+
)
|