devmemory-cli 0.1.0.dev0__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 (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,408 @@
1
+ """Git adapter - the only place DevMemory shells out to ``git``.
2
+
3
+ Design rules (see docs/IMPLEMENTATION_STRATEGY.md sec. 4):
4
+
5
+ * subprocess against the ``git`` CLI, no GitPython
6
+ * every call runs with an explicit ``cwd`` and a hardened environment
7
+ * ``-c core.autocrlf=false`` and ``--no-pager`` so output is stable on Windows
8
+ * NUL-delimited (``-z``) parsing wherever git offers it
9
+ * paths are returned POSIX-style; the working tree is never modified here
10
+ (restore lives in a separate, guarded adapter method added in Phase 9)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ from devmemory.domain.enums import ChangeType
22
+ from devmemory.domain.errors import GitError, GitRepositoryNotFoundError
23
+ from devmemory.domain.models import (
24
+ ChangedFile,
25
+ CommitInfo,
26
+ DiffStat,
27
+ WorkingTreeState,
28
+ )
29
+ from devmemory.logging import get_logger
30
+
31
+ _log = get_logger(__name__)
32
+
33
+ _NUL = "\x00"
34
+ _FIELD_SEP = "\x1f" # unit separator - unlikely in commit metadata
35
+
36
+ # git show -s format: sha, parents, author name/email/date, committer name/email/date, subject, body
37
+ _COMMIT_FORMAT = _FIELD_SEP.join(["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%s", "%b"])
38
+
39
+
40
+ class GitAdapter:
41
+ """Read-only git operations for one repository."""
42
+
43
+ def __init__(self, repo_path: Path | str, *, git_binary: str | None = None) -> None:
44
+ self._cwd = Path(repo_path).resolve()
45
+ self._git = git_binary or shutil.which("git") or "git"
46
+
47
+ # -- process plumbing ----------------------------------------------------
48
+
49
+ def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
50
+ cmd = [
51
+ self._git,
52
+ "-c",
53
+ "core.autocrlf=false",
54
+ "-c",
55
+ "core.quotepath=false",
56
+ "--no-pager",
57
+ *args,
58
+ ]
59
+ env = {
60
+ **os.environ,
61
+ "GIT_OPTIONAL_LOCKS": "0",
62
+ "GIT_TERMINAL_PROMPT": "0",
63
+ "GIT_PAGER": "cat",
64
+ "LC_ALL": "C",
65
+ }
66
+ try:
67
+ proc = subprocess.run( # noqa: S603 - fixed binary, arg list, no shell
68
+ cmd,
69
+ cwd=self._cwd,
70
+ env=env,
71
+ capture_output=True,
72
+ text=True,
73
+ encoding="utf-8",
74
+ errors="replace",
75
+ timeout=120,
76
+ )
77
+ except FileNotFoundError as exc:
78
+ raise GitError(f"git executable not found: {self._git}") from exc
79
+ except subprocess.TimeoutExpired as exc:
80
+ raise GitError(f"git {' '.join(args)} timed out") from exc
81
+
82
+ if check and proc.returncode != 0:
83
+ raise GitError(
84
+ f"git {' '.join(args)} failed ({proc.returncode}): {proc.stderr.strip()}"
85
+ )
86
+ return proc
87
+
88
+ def _out(self, *args: str) -> str:
89
+ return self._run(*args).stdout.strip()
90
+
91
+ def _raw(self, *args: str) -> str:
92
+ """stdout with no stripping - for NUL-delimited (`-z`) output."""
93
+ return self._run(*args).stdout
94
+
95
+ def _run_bytes(self, *args: str) -> bytes:
96
+ cmd = [self._git, "-c", "core.autocrlf=false", "--no-pager", *args]
97
+ env = {**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"}
98
+ try:
99
+ proc = subprocess.run( # noqa: S603 - fixed binary, arg list, no shell
100
+ cmd, cwd=self._cwd, env=env, capture_output=True, timeout=300
101
+ )
102
+ except (FileNotFoundError, subprocess.SubprocessError) as exc:
103
+ raise GitError(f"git {' '.join(args)} failed: {exc}") from exc
104
+ if proc.returncode != 0:
105
+ raise GitError(
106
+ f"git {' '.join(args)} failed ({proc.returncode}): "
107
+ f"{proc.stderr.decode(errors='replace').strip()}"
108
+ )
109
+ return proc.stdout
110
+
111
+ # -- repository ---------------------------------------------------------
112
+
113
+ def is_repository(self) -> bool:
114
+ proc = self._run("rev-parse", "--is-inside-work-tree", check=False)
115
+ return proc.returncode == 0 and proc.stdout.strip() == "true"
116
+
117
+ def require_repository(self) -> None:
118
+ if not self.is_repository():
119
+ raise GitRepositoryNotFoundError(
120
+ f"{self._cwd} is not inside a git repository.",
121
+ )
122
+
123
+ def repo_root(self) -> Path:
124
+ self.require_repository()
125
+ return Path(self._out("rev-parse", "--show-toplevel"))
126
+
127
+ def git_version(self) -> str | None:
128
+ proc = self._run("--version", check=False)
129
+ return proc.stdout.strip() or None if proc.returncode == 0 else None
130
+
131
+ # -- refs & commits ---------------------------------------------------
132
+
133
+ def has_commits(self) -> bool:
134
+ return self._run("rev-parse", "--verify", "HEAD", check=False).returncode == 0
135
+
136
+ def current_branch(self) -> str | None:
137
+ """Current branch name, or ``None`` when detached or on an unborn branch."""
138
+ proc = self._run("branch", "--show-current", check=False)
139
+ branch = proc.stdout.strip() if proc.returncode == 0 else ""
140
+ return branch or None
141
+
142
+ def resolve(self, rev: str) -> str:
143
+ """Resolve a revision (``HEAD``, ``v1``, a short sha, ...) to a full sha."""
144
+ proc = self._run("rev-parse", "--verify", f"{rev}^{{commit}}", check=False)
145
+ if proc.returncode != 0:
146
+ raise GitError(f"cannot resolve revision {rev!r}")
147
+ return proc.stdout.strip()
148
+
149
+ def head_sha(self) -> str | None:
150
+ return self.resolve("HEAD") if self.has_commits() else None
151
+
152
+ def commit(self, rev: str = "HEAD") -> CommitInfo:
153
+ """Full metadata for one commit, including parsed trailers."""
154
+ sha = self.resolve(rev)
155
+ raw = self._run("show", "-s", f"--format={_COMMIT_FORMAT}", sha).stdout
156
+ parts = raw.split(_FIELD_SEP)
157
+ # %b can itself contain newlines; everything after the 9th sep is the body.
158
+ while len(parts) < 10:
159
+ parts.append("")
160
+ fields = parts[:9]
161
+ body = _FIELD_SEP.join(parts[9:]).strip("\n")
162
+
163
+ return CommitInfo(
164
+ sha=fields[0].strip(),
165
+ parents=fields[1].split() if fields[1].strip() else [],
166
+ author_name=fields[2],
167
+ author_email=fields[3],
168
+ authored_at=_parse_iso(fields[4]),
169
+ committer_name=fields[5],
170
+ committer_email=fields[6],
171
+ committed_at=_parse_iso(fields[7]),
172
+ subject=fields[8],
173
+ body=body,
174
+ trailers=self._trailers(sha),
175
+ )
176
+
177
+ def _trailers(self, sha: str) -> dict[str, list[str]]:
178
+ raw = self._run(
179
+ "show",
180
+ "-s",
181
+ "--format=%(trailers:only=true,unfold=true,key_value_separator=%x1f)",
182
+ sha,
183
+ ).stdout
184
+ trailers: dict[str, list[str]] = {}
185
+ for line in raw.splitlines():
186
+ if _FIELD_SEP not in line:
187
+ continue
188
+ key, _, value = line.partition(_FIELD_SEP)
189
+ trailers.setdefault(key.strip(), []).append(value.strip())
190
+ return trailers
191
+
192
+ def entire_checkpoint_trailer(self, rev: str = "HEAD") -> str | None:
193
+ """The ``Entire-Checkpoint`` trailer value for a commit, if present."""
194
+ sha = self.resolve(rev)
195
+ value = self._out(
196
+ "show",
197
+ "-s",
198
+ "--format=%(trailers:key=Entire-Checkpoint,valueonly=true,unfold=true)",
199
+ sha,
200
+ ).strip()
201
+ return value or None
202
+
203
+ def parent_sha(self, rev: str = "HEAD") -> str | None:
204
+ info = self.commit(rev)
205
+ return info.parent
206
+
207
+ # -- diffs -----------------------------------------------------------
208
+
209
+ def changed_files(self, base: str | None, head: str) -> list[ChangedFile]:
210
+ """Files changed between ``base`` and ``head`` (or introduced by ``head``).
211
+
212
+ Parses ``git`` NUL-delimited (`-z`) output, in which name-status entries
213
+ are ``<code>\\0<path>\\0`` (or ``<code>\\0<old>\\0<new>\\0`` for R/C) and
214
+ numstat entries are ``<add>\\t<del>\\t<path>\\0`` (with an empty path and
215
+ two following tokens for renames).
216
+ """
217
+ head_sha = self.resolve(head)
218
+ if base is None:
219
+ status_raw = self._raw(
220
+ "show", "--first-parent", "-M", "-C", "--name-status", "--format=", "-z", head_sha
221
+ )
222
+ numstat_raw = self._raw("show", "--numstat", "--format=", "-z", head_sha)
223
+ else:
224
+ base_sha = self.resolve(base)
225
+ status_raw = self._raw("diff", "-M", "-C", "--name-status", "-z", base_sha, head_sha)
226
+ numstat_raw = self._raw("diff", "--numstat", "-z", base_sha, head_sha)
227
+
228
+ stats = _parse_numstat_z(numstat_raw)
229
+ result: list[ChangedFile] = []
230
+ for code, old_path, path in _parse_name_status_z(status_raw):
231
+ add, dele, binary = stats.get(path, (0, 0, False))
232
+ result.append(
233
+ ChangedFile(
234
+ path=path,
235
+ old_path=old_path,
236
+ change_type=ChangeType.from_git_status(code),
237
+ additions=add,
238
+ deletions=dele,
239
+ binary=binary,
240
+ )
241
+ )
242
+ return result
243
+
244
+ def diff_text(self, base: str | None, head: str, *, paths: list[str] | None = None) -> str:
245
+ head_sha = self.resolve(head)
246
+ if base is None:
247
+ args = ["show", "--no-color", "--first-parent", "--format=", head_sha]
248
+ else:
249
+ args = ["diff", "--no-color", self.resolve(base), head_sha]
250
+ if paths:
251
+ args += ["--", *paths]
252
+ return self._run(*args).stdout
253
+
254
+ def diff_stat(self, base: str | None, head: str) -> DiffStat:
255
+ return DiffStat.from_files(self.changed_files(base, head))
256
+
257
+ def show_file(self, rev: str, path: str) -> str | None:
258
+ proc = self._run("show", f"{self.resolve(rev)}:{path}", check=False)
259
+ return proc.stdout if proc.returncode == 0 else None
260
+
261
+ # -- history --------------------------------------------------------
262
+
263
+ def commits_between(self, base: str | None, head: str, *, limit: int = 200) -> list[str]:
264
+ rev_range = head if base is None else f"{base}..{head}"
265
+ out = self._out("rev-list", f"--max-count={limit}", rev_range)
266
+ return out.splitlines() if out else []
267
+
268
+ def cat_ref_blob(self, ref: str, path: str) -> str | None:
269
+ """Read a blob from an arbitrary ref/tree (used for Entire checkpoint refs)."""
270
+ proc = self._run("cat-file", "-p", f"{ref}:{path}", check=False)
271
+ return proc.stdout if proc.returncode == 0 else None
272
+
273
+ def list_refs(self, pattern: str) -> list[tuple[str, str]]:
274
+ """``(sha, refname)`` for refs matching a glob (e.g. ``refs/entire/checkpoints/``)."""
275
+ out = self._out("for-each-ref", "--format=%(objectname)%09%(refname)", pattern)
276
+ pairs: list[tuple[str, str]] = []
277
+ for line in out.splitlines():
278
+ if "\t" in line:
279
+ sha, name = line.split("\t", 1)
280
+ pairs.append((sha.strip(), name.strip()))
281
+ return pairs
282
+
283
+ # -- working tree --------------------------------------------------
284
+
285
+ def working_tree_state(self) -> WorkingTreeState:
286
+ branch = self.current_branch()
287
+ state = WorkingTreeState(
288
+ branch=branch,
289
+ detached=branch is None and self.has_commits(),
290
+ head=self.head_sha(),
291
+ )
292
+ for entry in _split_nul(self._raw("status", "--porcelain=v1", "-z")):
293
+ if len(entry) < 4:
294
+ continue
295
+ x, y, name = entry[0], entry[1], entry[3:]
296
+ if x == "?" and y == "?":
297
+ state.untracked.append(name)
298
+ continue
299
+ if x not in (" ", "?"):
300
+ state.staged.append(name)
301
+ if y not in (" ", "?"):
302
+ state.unstaged.append(name)
303
+ return state
304
+
305
+ def is_dirty(self) -> bool:
306
+ return not self.working_tree_state().is_clean
307
+
308
+ # -- snapshots & restore (the only mutating operations) --------------
309
+
310
+ def archive_tar(self, rev: str) -> bytes:
311
+ """The committed tree at ``rev`` as an uncompressed tar (bytes)."""
312
+ return self._run_bytes("archive", "--format=tar", self.resolve(rev))
313
+
314
+ def create_tag(self, name: str, rev: str = "HEAD", *, message: str | None = None) -> None:
315
+ args = ["tag", name, self.resolve(rev)]
316
+ if message:
317
+ args = ["tag", "-a", name, "-m", message, self.resolve(rev)]
318
+ self._run(*args)
319
+
320
+ def stash_create(self) -> str | None:
321
+ """Object id of a commit capturing the current dirty state, or ``None`` if clean.
322
+
323
+ ``git stash create`` records but does not touch the working tree or the
324
+ stash list - a pure safety reference.
325
+ """
326
+ out = self._out("stash", "create", "devmemory: pre-restore safety")
327
+ return out or None
328
+
329
+ def checkout_detached(self, rev: str) -> str:
330
+ """Move HEAD to ``rev`` in detached state, keeping local changes out of the way."""
331
+ sha = self.resolve(rev)
332
+ self._run("checkout", "--detach", "--force", sha)
333
+ return sha
334
+
335
+ def reset_hard(self, rev: str) -> str:
336
+ sha = self.resolve(rev)
337
+ self._run("reset", "--hard", sha)
338
+ return sha
339
+
340
+ def restore_worktree_paths(self, rev: str, paths: list[str]) -> None:
341
+ if paths:
342
+ self._run("checkout", self.resolve(rev), "--", *paths)
343
+
344
+
345
+ # --- helpers ----------------------------------------------------------------------
346
+
347
+
348
+ def _parse_iso(value: str) -> datetime | None:
349
+ value = value.strip()
350
+ if not value:
351
+ return None
352
+ try:
353
+ return datetime.fromisoformat(value)
354
+ except ValueError:
355
+ return None
356
+
357
+
358
+ def _split_nul(raw: str) -> list[str]:
359
+ return [chunk for chunk in raw.split(_NUL) if chunk != ""]
360
+
361
+
362
+ def _parse_name_status_z(raw: str) -> list[tuple[str, str | None, str]]:
363
+ """``[(code, old_path | None, path), ...]`` from ``--name-status -z`` output."""
364
+ tokens = _split_nul(raw)
365
+ entries: list[tuple[str, str | None, str]] = []
366
+ i = 0
367
+ while i < len(tokens):
368
+ code = tokens[i]
369
+ i += 1
370
+ if not code or i >= len(tokens):
371
+ break
372
+ if code[:1].upper() in ("R", "C") and i + 1 < len(tokens) + 1:
373
+ old_path = tokens[i]
374
+ path = tokens[i + 1] if i + 1 < len(tokens) else old_path
375
+ i += 2
376
+ entries.append((code, old_path, path))
377
+ else:
378
+ entries.append((code, None, tokens[i]))
379
+ i += 1
380
+ return entries
381
+
382
+
383
+ def _parse_numstat_z(raw: str) -> dict[str, tuple[int, int, bool]]:
384
+ """``{path: (additions, deletions, binary)}`` from ``--numstat -z`` output."""
385
+ tokens = _split_nul(raw)
386
+ stats: dict[str, tuple[int, int, bool]] = {}
387
+ i = 0
388
+ while i < len(tokens):
389
+ bits = tokens[i].split("\t")
390
+ if len(bits) < 3:
391
+ i += 1
392
+ continue
393
+ add_s, del_s, path = bits[0], bits[1], bits[2]
394
+ i += 1
395
+ if path == "" and i + 1 < len(tokens):
396
+ # Rename: the following two tokens are old, new.
397
+ path = tokens[i + 1]
398
+ i += 2
399
+ binary = add_s == "-" or del_s == "-"
400
+ stats[path] = (
401
+ 0 if binary else int(add_s or 0),
402
+ 0 if binary else int(del_s or 0),
403
+ binary,
404
+ )
405
+ return stats
406
+
407
+
408
+ __all__ = ["GitAdapter"]
@@ -0,0 +1,251 @@
1
+ """Change-impact analysis via the Entire ``graph`` plugin (optional).
2
+
3
+ ``entire graph`` builds a deterministic, no-egress local code graph. We use its
4
+ ``commit`` analysis: an entity-level change list (added / removed / renamed /
5
+ signature-changed / body-changed) with a dependent count, so a signature change
6
+ that many callers depend on stands out.
7
+
8
+ The plugin is opt-in (`entire plugin install graph`, `graph.enabled = true`).
9
+ Everything here degrades to ``None`` when it is missing or slow - it never blocks
10
+ a checkpoint.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ from pathlib import Path
20
+
21
+ from pydantic import BaseModel, Field, computed_field
22
+
23
+ from devmemory.logging import get_logger
24
+
25
+ _log = get_logger(__name__)
26
+
27
+ # entity change types the plugin emits, ordered by how much they usually matter
28
+ _SEVERITY = {
29
+ "removed": 4,
30
+ "signature_changed": 3,
31
+ "renamed": 2,
32
+ "added": 1,
33
+ "body_changed": 1,
34
+ }
35
+
36
+
37
+ class GraphStatus(BaseModel):
38
+ installed: bool = False
39
+ version: str | None = None
40
+ binary_path: str | None = None
41
+ detail: str | None = None
42
+
43
+
44
+ class ChangedEntity(BaseModel):
45
+ path: str
46
+ language: str | None = None
47
+ file_status: str # A | M | D | R
48
+ change_type: str # added | removed | renamed | signature_changed | body_changed
49
+ kind: str # function | method | type | section | ...
50
+ name: str
51
+ dependents_count: int = 0
52
+ old_signature: str | None = None
53
+ new_signature: str | None = None
54
+ line: int | None = None
55
+
56
+ @property
57
+ def is_risky(self) -> bool:
58
+ return self.change_type in ("removed", "signature_changed") and self.dependents_count > 0
59
+
60
+
61
+ class GraphImpact(BaseModel):
62
+ version_id: str | None = None
63
+ base_commit: str
64
+ head_commit: str
65
+ entities: list[ChangedEntity] = Field(default_factory=list)
66
+ generated_at: str | None = None
67
+
68
+ @computed_field # type: ignore[prop-decorator]
69
+ @property
70
+ def entity_count(self) -> int:
71
+ return len(self.entities)
72
+
73
+ @computed_field # type: ignore[prop-decorator]
74
+ @property
75
+ def max_dependents(self) -> int:
76
+ return max((e.dependents_count for e in self.entities), default=0)
77
+
78
+ @property
79
+ def hotspots(self) -> list[ChangedEntity]:
80
+ ranked = sorted(
81
+ self.entities,
82
+ key=lambda e: (_SEVERITY.get(e.change_type, 0), e.dependents_count),
83
+ reverse=True,
84
+ )
85
+ return [
86
+ e
87
+ for e in ranked
88
+ if e.dependents_count > 0 or e.change_type in ("removed", "signature_changed")
89
+ ][:10]
90
+
91
+
92
+ class GraphAdapter:
93
+ def __init__(
94
+ self,
95
+ repo_path: Path | str,
96
+ *,
97
+ binary: str | None = None,
98
+ timeout: int = 90,
99
+ max_seconds: int = 120,
100
+ ) -> None:
101
+ self._cwd = Path(repo_path).resolve()
102
+ self._binary = binary or _find_binary()
103
+ self._timeout = timeout
104
+ self._max_seconds = max_seconds
105
+
106
+ @property
107
+ def binary_path(self) -> str | None:
108
+ return self._binary
109
+
110
+ @property
111
+ def is_available(self) -> bool:
112
+ return self._binary is not None
113
+
114
+ def probe(self) -> GraphStatus:
115
+ if self._binary is None:
116
+ return GraphStatus(
117
+ installed=False,
118
+ detail="entire-graph not found; run `entire plugin install graph`",
119
+ )
120
+ proc = self._run("version", "--json", timeout=15)
121
+ version: str | None = None
122
+ if proc is not None and proc.returncode == 0 and proc.stdout.strip():
123
+ try:
124
+ version = json.loads(proc.stdout).get("version")
125
+ except (json.JSONDecodeError, AttributeError):
126
+ version = None
127
+ return GraphStatus(installed=True, version=version, binary_path=self._binary)
128
+
129
+ def commit_impact(self, rev: str) -> GraphImpact | None:
130
+ """Entity-level change list for ``rev`` versus its first parent."""
131
+ if self._binary is None:
132
+ return None
133
+ proc = self._run(
134
+ "commit",
135
+ "--json",
136
+ "--max-seconds",
137
+ str(self._max_seconds),
138
+ rev,
139
+ timeout=self._timeout,
140
+ )
141
+ if proc is None or proc.returncode != 0 or not proc.stdout.strip():
142
+ if proc is not None:
143
+ _log.warning("graph.commit_failed", rev=rev, stderr=proc.stderr[:400])
144
+ return None
145
+ try:
146
+ data = json.loads(proc.stdout)
147
+ except json.JSONDecodeError:
148
+ _log.warning("graph.bad_json", rev=rev)
149
+ return None
150
+ return _parse_commit(data)
151
+
152
+ # -- process plumbing ------------------------------------------------
153
+
154
+ def _run(self, *args: str, timeout: int = 90) -> subprocess.CompletedProcess[str] | None:
155
+ if self._binary is None:
156
+ return None
157
+ try:
158
+ return subprocess.run( # noqa: S603 - resolved binary, arg list, no shell
159
+ [self._binary, *args, "--repo", str(self._cwd)],
160
+ cwd=self._cwd,
161
+ capture_output=True,
162
+ text=True,
163
+ encoding="utf-8",
164
+ errors="replace",
165
+ timeout=timeout,
166
+ check=False,
167
+ )
168
+ except (OSError, subprocess.SubprocessError) as exc:
169
+ _log.warning("graph.run_failed", args=list(args), error=str(exc))
170
+ return None
171
+
172
+
173
+ def _parse_commit(data: object) -> GraphImpact | None:
174
+ if not isinstance(data, dict):
175
+ return None
176
+ files = data.get("files")
177
+ if not isinstance(files, list):
178
+ return None
179
+ entities: list[ChangedEntity] = []
180
+ for f in files:
181
+ if not isinstance(f, dict):
182
+ continue
183
+ path = str(f.get("path", ""))
184
+ language = f.get("language")
185
+ file_status = str(f.get("status", "M"))
186
+ for change in f.get("changes", []) or []:
187
+ if not isinstance(change, dict):
188
+ continue
189
+ entities.append(
190
+ ChangedEntity(
191
+ path=path,
192
+ language=language if isinstance(language, str) else None,
193
+ file_status=file_status,
194
+ change_type=str(change.get("type", "body_changed")),
195
+ kind=str(change.get("kind", "symbol")),
196
+ name=str(change.get("name", "?")),
197
+ dependents_count=int(change.get("dependents_count") or 0),
198
+ old_signature=_opt_str(change.get("old_signature")),
199
+ new_signature=_opt_str(change.get("new_signature")),
200
+ line=_opt_int(
201
+ change.get("after_start_line") or change.get("before_start_line")
202
+ ),
203
+ )
204
+ )
205
+ return GraphImpact(
206
+ base_commit=str(data.get("base", "")),
207
+ head_commit=str(data.get("head", "")),
208
+ entities=entities,
209
+ )
210
+
211
+
212
+ def _opt_str(value: object) -> str | None:
213
+ return value if isinstance(value, str) and value else None
214
+
215
+
216
+ def _opt_int(value: object) -> int | None:
217
+ return value if isinstance(value, int) else None
218
+
219
+
220
+ def _find_binary() -> str | None:
221
+ found = shutil.which("entire-graph")
222
+ if found:
223
+ return found
224
+
225
+ candidates: list[Path] = []
226
+ home = Path.home()
227
+ local_appdata = os.environ.get("LOCALAPPDATA")
228
+ xdg_data = os.environ.get("XDG_DATA_HOME")
229
+ plugin_env = os.environ.get("ENTIRE_PLUGIN_DIR")
230
+
231
+ for base in (
232
+ Path(plugin_env) if plugin_env else None,
233
+ Path(local_appdata) / "entire" / "plugins" if local_appdata else None,
234
+ Path(xdg_data) / "entire" / "plugins" if xdg_data else None,
235
+ home / "AppData" / "Local" / "entire" / "plugins",
236
+ home / ".local" / "share" / "entire" / "plugins",
237
+ ):
238
+ if base is None:
239
+ continue
240
+ candidates.append(base / "bin" / "entire-graph.exe")
241
+ candidates.append(base / "bin" / "entire-graph")
242
+ candidates.append(base / "pkg" / "graph" / "entire-graph.exe")
243
+ candidates.append(base / "pkg" / "graph" / "entire-graph")
244
+
245
+ for path in candidates:
246
+ if path.is_file():
247
+ return str(path)
248
+ return None
249
+
250
+
251
+ __all__ = ["ChangedEntity", "GraphAdapter", "GraphImpact", "GraphStatus"]