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,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
+ )