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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""``devmemory checkpoint`` - record the current commit as a development version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devmemory.cli._render import console, hint, status_text, success, warn
|
|
11
|
+
from devmemory.domain.enums import MetricDirection, VersionStatus
|
|
12
|
+
from devmemory.domain.errors import DevMemoryError
|
|
13
|
+
from devmemory.domain.models import Metric
|
|
14
|
+
from devmemory.pipeline.checkpoint import CheckpointRequest, run_checkpoint
|
|
15
|
+
from devmemory.services.context import ProjectContext
|
|
16
|
+
|
|
17
|
+
_LOWER_IS_BETTER_HINTS = (
|
|
18
|
+
"latency",
|
|
19
|
+
"duration",
|
|
20
|
+
"time",
|
|
21
|
+
"loss",
|
|
22
|
+
"error",
|
|
23
|
+
"cost",
|
|
24
|
+
"memory",
|
|
25
|
+
"p50",
|
|
26
|
+
"p95",
|
|
27
|
+
"p99",
|
|
28
|
+
"rt",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def checkpoint_command(
|
|
33
|
+
intent: Annotated[
|
|
34
|
+
str | None,
|
|
35
|
+
typer.Option("--intent", help="What was requested (defaults to the checkpoint)."),
|
|
36
|
+
] = None,
|
|
37
|
+
feature: Annotated[
|
|
38
|
+
str | None, typer.Option("--feature", "-f", help="Feature area this change belongs to.")
|
|
39
|
+
] = None,
|
|
40
|
+
agent: Annotated[
|
|
41
|
+
str | None, typer.Option("--agent", help="AI agent, if not read from the checkpoint.")
|
|
42
|
+
] = None,
|
|
43
|
+
status: Annotated[
|
|
44
|
+
str | None,
|
|
45
|
+
typer.Option("--status", help="Override the derived status (SUCCESS, REGRESSION, ...)."),
|
|
46
|
+
] = None,
|
|
47
|
+
tests_passed: Annotated[
|
|
48
|
+
int | None, typer.Option("--tests-passed", help="Passing test count (skips the runner).")
|
|
49
|
+
] = None,
|
|
50
|
+
tests_failed: Annotated[
|
|
51
|
+
int | None, typer.Option("--tests-failed", help="Failing test count (skips the runner).")
|
|
52
|
+
] = None,
|
|
53
|
+
run_tests: Annotated[
|
|
54
|
+
bool,
|
|
55
|
+
typer.Option("--run-tests/--no-run-tests", help="Run the configured test command."),
|
|
56
|
+
] = True,
|
|
57
|
+
metrics: Annotated[
|
|
58
|
+
list[str] | None,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--metric", "-m", help="Metric as name=after or name=before:after (repeatable)."
|
|
61
|
+
),
|
|
62
|
+
] = None,
|
|
63
|
+
metrics_file: Annotated[
|
|
64
|
+
str | None,
|
|
65
|
+
typer.Option("--metrics-file", help="Path to a JSON metrics file to read."),
|
|
66
|
+
] = None,
|
|
67
|
+
errors: Annotated[
|
|
68
|
+
list[str] | None, typer.Option("--error", "-e", help="An error encountered (repeatable).")
|
|
69
|
+
] = None,
|
|
70
|
+
snapshot: Annotated[
|
|
71
|
+
bool,
|
|
72
|
+
typer.Option("--snapshot/--no-snapshot", help="Archive the source tree for this version."),
|
|
73
|
+
] = True,
|
|
74
|
+
allow_no_entire: Annotated[
|
|
75
|
+
bool,
|
|
76
|
+
typer.Option("--allow-no-entire", help="Record even without an Entire checkpoint."),
|
|
77
|
+
] = False,
|
|
78
|
+
force: Annotated[
|
|
79
|
+
bool, typer.Option("--force", help="Re-record the version for this commit in place.")
|
|
80
|
+
] = False,
|
|
81
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit the result as JSON.")] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Turn the current git commit + its Entire/test/metric context into a Development Version."""
|
|
84
|
+
request = CheckpointRequest(
|
|
85
|
+
intent=intent,
|
|
86
|
+
feature=feature,
|
|
87
|
+
agent=agent,
|
|
88
|
+
status=_parse_status(status),
|
|
89
|
+
tests_passed=tests_passed,
|
|
90
|
+
tests_failed=tests_failed,
|
|
91
|
+
run_tests=run_tests,
|
|
92
|
+
metrics=[_parse_metric(m) for m in (metrics or [])],
|
|
93
|
+
metrics_file=metrics_file,
|
|
94
|
+
errors=list(errors or []),
|
|
95
|
+
snapshot=snapshot,
|
|
96
|
+
allow_no_entire=allow_no_entire,
|
|
97
|
+
force=force,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
with ProjectContext.load() as ctx:
|
|
101
|
+
result = run_checkpoint(ctx, request)
|
|
102
|
+
|
|
103
|
+
if as_json:
|
|
104
|
+
console.print_json(
|
|
105
|
+
json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"created": result.created,
|
|
108
|
+
"version": result.version.model_dump(mode="json"),
|
|
109
|
+
"run_id": result.run_log.run_id,
|
|
110
|
+
"warnings": result.warnings,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
v = result.version
|
|
117
|
+
if not result.created:
|
|
118
|
+
warn(f"{v.version_id} already records commit {v.git_commit[:12]} - nothing to do.")
|
|
119
|
+
hint("Pass --force to re-record it.")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
console.print()
|
|
123
|
+
console.print(
|
|
124
|
+
f"[bold]{v.version_id}[/bold] ", status_text(v.status), f" [dim]{v.git_commit[:12]}[/dim]"
|
|
125
|
+
)
|
|
126
|
+
if v.intent:
|
|
127
|
+
console.print(f" intent {v.intent}")
|
|
128
|
+
if v.primary_checkpoint:
|
|
129
|
+
cp = v.primary_checkpoint
|
|
130
|
+
marker = (
|
|
131
|
+
""
|
|
132
|
+
if not cp.is_uncertain
|
|
133
|
+
else f" [yellow](confidence {cp.association_confidence:.2f})[/yellow]"
|
|
134
|
+
)
|
|
135
|
+
console.print(
|
|
136
|
+
f" entire {cp.checkpoint_id} [dim]{cp.association_method.value}[/dim]{marker}"
|
|
137
|
+
)
|
|
138
|
+
if v.agent:
|
|
139
|
+
console.print(f" agent {v.agent}" + (f" [dim]{v.model}[/dim]" if v.model else ""))
|
|
140
|
+
if v.feature_id:
|
|
141
|
+
console.print(f" feature {v.feature_id.split(':', 1)[-1]}")
|
|
142
|
+
console.print(
|
|
143
|
+
f" changes {v.files_changed} files "
|
|
144
|
+
f"[green]+{v.lines_added}[/green] [red]-{v.lines_removed}[/red]"
|
|
145
|
+
)
|
|
146
|
+
if v.tests and v.tests.ran:
|
|
147
|
+
console.print(f" tests {v.tests.passed} passed / {v.tests.failed} failed")
|
|
148
|
+
for m in v.metrics:
|
|
149
|
+
arrow = "→"
|
|
150
|
+
console.print(
|
|
151
|
+
f" {m.name:<8} {m.before} {arrow} {m.after}" + (f" {m.unit}" if m.unit else "")
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for w in result.warnings:
|
|
155
|
+
warn(w)
|
|
156
|
+
success(f"recorded {v.version_id}")
|
|
157
|
+
console.print(f"[dim]run log: {result.run_log.run_id}.json[/dim]")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parse_status(raw: str | None) -> VersionStatus | None:
|
|
161
|
+
if raw is None:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
return VersionStatus(raw.strip().upper())
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
raise DevMemoryError(
|
|
167
|
+
f"Unknown status {raw!r}.",
|
|
168
|
+
hint=f"One of: {', '.join(s.value for s in VersionStatus)}",
|
|
169
|
+
) from exc
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_metric(raw: str) -> Metric:
|
|
173
|
+
if "=" not in raw:
|
|
174
|
+
raise DevMemoryError(f"Bad --metric {raw!r}; expected name=after or name=before:after.")
|
|
175
|
+
name, _, value = raw.partition("=")
|
|
176
|
+
name = name.strip()
|
|
177
|
+
before: float | None = None
|
|
178
|
+
if ":" in value:
|
|
179
|
+
before_s, _, after_s = value.partition(":")
|
|
180
|
+
before = _to_float(before_s, raw)
|
|
181
|
+
after = _to_float(after_s, raw)
|
|
182
|
+
else:
|
|
183
|
+
after = _to_float(value, raw)
|
|
184
|
+
direction = (
|
|
185
|
+
MetricDirection.LOWER_IS_BETTER
|
|
186
|
+
if any(h in name.lower() for h in _LOWER_IS_BETTER_HINTS)
|
|
187
|
+
else MetricDirection.HIGHER_IS_BETTER
|
|
188
|
+
)
|
|
189
|
+
return Metric(name=name, before=before, after=after, direction=direction)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _to_float(text: str, raw: str) -> float:
|
|
193
|
+
try:
|
|
194
|
+
return float(text.strip())
|
|
195
|
+
except ValueError as exc:
|
|
196
|
+
raise DevMemoryError(f"Bad number in --metric {raw!r}.") from exc
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
__all__ = ["checkpoint_command"]
|
devmemory/cli/compare.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""``devmemory diff`` and ``devmemory compare`` - what changed between two versions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from devmemory.cli._render import console, status_text
|
|
14
|
+
from devmemory.services.context import ProjectContext
|
|
15
|
+
from devmemory.services.versions import version_diff
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def diff_command(
|
|
19
|
+
from_ref: Annotated[str, typer.Argument(metavar="FROM", help="Base version (v6 / 6 / sha).")],
|
|
20
|
+
to_ref: Annotated[str, typer.Argument(metavar="TO", help="Target version.")],
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Print the exact git diff between two development versions."""
|
|
23
|
+
with ProjectContext.load() as ctx:
|
|
24
|
+
result = version_diff(ctx, from_ref, to_ref)
|
|
25
|
+
console.print(
|
|
26
|
+
f"[dim]{result.from_version_id.upper()} {result.from_commit[:10]} → "
|
|
27
|
+
f"{result.to_version_id.upper()} {result.to_commit[:10]}[/dim]\n"
|
|
28
|
+
)
|
|
29
|
+
console.print(Syntax(result.diff_text or "(no changes)", "diff", theme="ansi_dark"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compare_command(
|
|
33
|
+
from_ref: Annotated[str, typer.Argument(metavar="FROM")],
|
|
34
|
+
to_ref: Annotated[str, typer.Argument(metavar="TO")],
|
|
35
|
+
show_diff: Annotated[bool, typer.Option("--diff", help="Also print the git diff.")] = False,
|
|
36
|
+
as_json: Annotated[bool, typer.Option("--json")] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Compare two versions: files, lines, metrics, tests, status."""
|
|
39
|
+
with ProjectContext.load() as ctx:
|
|
40
|
+
result = version_diff(ctx, from_ref, to_ref)
|
|
41
|
+
|
|
42
|
+
if as_json:
|
|
43
|
+
console.print_json(json.dumps(result.model_dump(mode="json")))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
console.print()
|
|
47
|
+
console.print(
|
|
48
|
+
f"[bold]{result.from_version_id.upper()}[/bold] → [bold]{result.to_version_id.upper()}[/bold]"
|
|
49
|
+
f" {status_text(result.status_from)} → {status_text(result.status_to)}"
|
|
50
|
+
)
|
|
51
|
+
console.print(
|
|
52
|
+
f" {result.stat.files_changed} files "
|
|
53
|
+
f"[green]+{result.stat.additions}[/green] [red]-{result.stat.deletions}[/red]\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if result.files:
|
|
57
|
+
ft = Table(box=None, show_header=False, pad_edge=False)
|
|
58
|
+
ft.add_column(no_wrap=True)
|
|
59
|
+
ft.add_column(overflow="fold")
|
|
60
|
+
ft.add_column(justify="right", no_wrap=True)
|
|
61
|
+
for f in result.files:
|
|
62
|
+
mark = {
|
|
63
|
+
"added": "[green]A[/green]",
|
|
64
|
+
"modified": "[yellow]M[/yellow]",
|
|
65
|
+
"deleted": "[red]D[/red]",
|
|
66
|
+
}.get(f.change_type.value, "[cyan]" + f.change_type.value[0].upper() + "[/cyan]")
|
|
67
|
+
ft.add_row(
|
|
68
|
+
mark,
|
|
69
|
+
f.path,
|
|
70
|
+
"binary"
|
|
71
|
+
if f.binary
|
|
72
|
+
else f"[green]+{f.additions}[/green] [red]-{f.deletions}[/red]",
|
|
73
|
+
)
|
|
74
|
+
console.print(Panel(ft, title="files", title_align="left", border_style="dim"))
|
|
75
|
+
|
|
76
|
+
if result.metric_changes:
|
|
77
|
+
mt = Table(box=None, show_header=False, pad_edge=False)
|
|
78
|
+
mt.add_column(no_wrap=True)
|
|
79
|
+
mt.add_column()
|
|
80
|
+
for name, change in result.metric_changes.items():
|
|
81
|
+
before, after, delta = change["before"], change["after"], change["delta"]
|
|
82
|
+
arrow = f"{before} → {after}" if before is not None else str(after)
|
|
83
|
+
tag = ""
|
|
84
|
+
if delta is not None:
|
|
85
|
+
tag = f" [{'green' if delta >= 0 else 'red'}]({'+' if delta >= 0 else ''}{delta:g})[/]"
|
|
86
|
+
mt.add_row(name, arrow + tag)
|
|
87
|
+
console.print(Panel(mt, title="metrics", title_align="left", border_style="dim"))
|
|
88
|
+
|
|
89
|
+
tc = result.test_changes
|
|
90
|
+
if tc.get("passed") is not None:
|
|
91
|
+
console.print(f" tests: passed {_signed(tc['passed'])}, failed {_signed(tc['failed'])}")
|
|
92
|
+
|
|
93
|
+
if show_diff and result.diff_text:
|
|
94
|
+
console.print()
|
|
95
|
+
console.print(Syntax(result.diff_text, "diff", theme="ansi_dark"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _signed(n: int | None) -> str:
|
|
99
|
+
if n is None:
|
|
100
|
+
return "?"
|
|
101
|
+
return f"+{n}" if n >= 0 else str(n)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["compare_command", "diff_command"]
|
devmemory/cli/doctor.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""``devmemory doctor`` - check the environment and the project setup.
|
|
2
|
+
|
|
3
|
+
Reports credential *presence*, never values.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import sqlite3
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from devmemory.cli._render import console
|
|
19
|
+
from devmemory.config import DevMemoryConfig, resolve_databricks_credentials, resolve_llm_api_key
|
|
20
|
+
from devmemory.paths import find_project_paths
|
|
21
|
+
from devmemory.storage.db import Database, discover_migrations
|
|
22
|
+
|
|
23
|
+
_OK = "[green]ok[/green]"
|
|
24
|
+
_WARN = "[yellow]warn[/yellow]"
|
|
25
|
+
_MISS = "[dim]absent[/dim]"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def doctor_command(
|
|
29
|
+
strict: Annotated[
|
|
30
|
+
bool, typer.Option("--strict", help="Exit non-zero if any check is not ok.")
|
|
31
|
+
] = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Diagnose the DevMemory environment: toolchain, project, storage, integrations."""
|
|
34
|
+
rows: list[tuple[str, str, str]] = []
|
|
35
|
+
|
|
36
|
+
# -- toolchain -----------------------------------------------------
|
|
37
|
+
py = platform.python_version()
|
|
38
|
+
rows.append(("python", _OK if _pyver_ok(py) else _WARN, f"{py} ({platform.system()})"))
|
|
39
|
+
rows.append(_tool("git", "git", ["--version"]))
|
|
40
|
+
|
|
41
|
+
from devmemory.adapters.graph import _find_binary as _find_graph
|
|
42
|
+
|
|
43
|
+
entire = shutil.which("entire") or _fallback("entire")
|
|
44
|
+
rows.append(
|
|
45
|
+
("entire cli", _OK if entire else _WARN, entire or "not found (checkpoints optional)")
|
|
46
|
+
)
|
|
47
|
+
graph = _find_graph()
|
|
48
|
+
rows.append(
|
|
49
|
+
("entire graph", _OK if graph else _MISS, graph or "not installed (impact optional)")
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# -- project -----------------------------------------------------
|
|
53
|
+
paths = find_project_paths()
|
|
54
|
+
if paths is None:
|
|
55
|
+
rows.append(("project", _WARN, "no .devmemory/ here - run `devmemory init`"))
|
|
56
|
+
_render(rows)
|
|
57
|
+
raise typer.Exit(1 if strict else 0)
|
|
58
|
+
|
|
59
|
+
rows.append(("project root", _OK, str(paths.repo_root)))
|
|
60
|
+
try:
|
|
61
|
+
config = DevMemoryConfig.load(paths)
|
|
62
|
+
rows.append(("config", _OK, f"{config.project_name} ({config.project_id})"))
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
rows.append(("config", _WARN, str(exc)))
|
|
65
|
+
config = None
|
|
66
|
+
|
|
67
|
+
# -- storage ---------------------------------------------------
|
|
68
|
+
try:
|
|
69
|
+
db = Database(paths.db)
|
|
70
|
+
applied = db.schema_version()
|
|
71
|
+
latest = len(discover_migrations())
|
|
72
|
+
state = _OK if applied == latest else _WARN
|
|
73
|
+
rows.append(("database", state, f"schema {applied}/{latest}"))
|
|
74
|
+
db.close()
|
|
75
|
+
except sqlite3.DatabaseError as exc:
|
|
76
|
+
rows.append(("database", _WARN, str(exc)))
|
|
77
|
+
|
|
78
|
+
outbox = list(paths.outbox_dir.glob("*.json")) if paths.outbox_dir.is_dir() else []
|
|
79
|
+
rows.append(("databricks outbox", _OK if not outbox else _WARN, f"{len(outbox)} queued"))
|
|
80
|
+
|
|
81
|
+
# -- integrations (presence only) ----------------------------
|
|
82
|
+
if config is not None:
|
|
83
|
+
rows.append(_llm_row(config))
|
|
84
|
+
dbx = resolve_databricks_credentials()
|
|
85
|
+
rows.append(
|
|
86
|
+
(
|
|
87
|
+
"databricks creds",
|
|
88
|
+
_OK if dbx else _MISS,
|
|
89
|
+
"DATABRICKS_HOST/TOKEN/WAREHOUSE_ID set" if dbx else "not set (analytics stay local)",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_render(rows)
|
|
94
|
+
if strict and any(state == _WARN for _, state, _ in rows):
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render(rows: list[tuple[str, str, str]]) -> None:
|
|
99
|
+
table = Table(show_header=False, box=None, pad_edge=False)
|
|
100
|
+
table.add_column(style="bold cyan", no_wrap=True)
|
|
101
|
+
table.add_column(no_wrap=True)
|
|
102
|
+
table.add_column(overflow="fold")
|
|
103
|
+
for name, state, detail in rows:
|
|
104
|
+
table.add_row(name, state, detail)
|
|
105
|
+
console.print()
|
|
106
|
+
console.print(table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _pyver_ok(version: str) -> bool:
|
|
110
|
+
major, minor, *_ = (int(p) for p in version.split("."))
|
|
111
|
+
return (major, minor) >= (3, 11)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _tool(label: str, exe: str, args: list[str]) -> tuple[str, str, str]:
|
|
115
|
+
path = shutil.which(exe)
|
|
116
|
+
if path is None:
|
|
117
|
+
return (label, _WARN, "not found")
|
|
118
|
+
try:
|
|
119
|
+
out = subprocess.run( # noqa: S603 - fixed exe, arg list, no shell
|
|
120
|
+
[path, *args], capture_output=True, text=True, timeout=5, check=False
|
|
121
|
+
)
|
|
122
|
+
first = (out.stdout or out.stderr).strip().splitlines()
|
|
123
|
+
return (label, _OK, first[0] if first else path)
|
|
124
|
+
except (OSError, subprocess.SubprocessError):
|
|
125
|
+
return (label, _WARN, "error running")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fallback(name: str) -> str | None:
|
|
129
|
+
home = Path.home()
|
|
130
|
+
for candidate in (
|
|
131
|
+
home / ".local" / "bin" / name,
|
|
132
|
+
home / ".local" / "bin" / f"{name}.exe",
|
|
133
|
+
home / "AppData" / "Local" / "entire" / "plugins" / "bin" / f"{name}.exe",
|
|
134
|
+
home / ".local" / "share" / "entire" / "plugins" / "bin" / name,
|
|
135
|
+
):
|
|
136
|
+
if candidate.is_file():
|
|
137
|
+
return str(candidate)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _llm_row(config: DevMemoryConfig) -> tuple[str, str, str]:
|
|
142
|
+
configured = [p for p in config.analysis.providers if p != "rules"]
|
|
143
|
+
have = [p for p in configured if resolve_llm_api_key(p)]
|
|
144
|
+
if not configured:
|
|
145
|
+
return ("llm analysis", _OK, "rules only (no LLM configured)")
|
|
146
|
+
if have:
|
|
147
|
+
return ("llm analysis", _OK, f"key present for: {', '.join(have)}")
|
|
148
|
+
return ("llm analysis", _WARN, f"providers {configured} configured but no key in env")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = ["doctor_command"]
|
devmemory/cli/history.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""``devmemory history`` - the version timeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from devmemory.cli._render import console, status_text
|
|
12
|
+
from devmemory.services.context import ProjectContext
|
|
13
|
+
from devmemory.services.versions import list_versions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def history_command(
|
|
17
|
+
limit: Annotated[int, typer.Option("--limit", "-n", help="Most recent N versions.")] = 25,
|
|
18
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit as JSON.")] = False,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""List development versions, newest last."""
|
|
21
|
+
with ProjectContext.load() as ctx:
|
|
22
|
+
versions = list_versions(ctx, limit=limit, ascending=False)
|
|
23
|
+
versions.reverse()
|
|
24
|
+
|
|
25
|
+
if as_json:
|
|
26
|
+
console.print_json(json.dumps([v.model_dump(mode="json") for v in versions]))
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
if not versions:
|
|
30
|
+
console.print("[dim]No versions yet. Run `devmemory checkpoint` after a commit.[/dim]")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
table = Table(box=None, pad_edge=False, header_style="dim")
|
|
34
|
+
table.add_column("", style="bold", no_wrap=True)
|
|
35
|
+
table.add_column("status", no_wrap=True)
|
|
36
|
+
table.add_column("agent", no_wrap=True, max_width=14)
|
|
37
|
+
table.add_column("feature", no_wrap=True, max_width=18)
|
|
38
|
+
table.add_column("Δ", justify="right", no_wrap=True)
|
|
39
|
+
table.add_column("metrics", no_wrap=True, max_width=22)
|
|
40
|
+
table.add_column("intent", overflow="ellipsis", max_width=48)
|
|
41
|
+
|
|
42
|
+
for v in versions:
|
|
43
|
+
metrics = " ".join(f"{m.name}={m.after}" for m in v.metrics[:2])
|
|
44
|
+
table.add_row(
|
|
45
|
+
v.version_id.upper(),
|
|
46
|
+
status_text(v.status),
|
|
47
|
+
(v.agent or "-"),
|
|
48
|
+
(v.feature_id.split(":", 1)[-1] if v.feature_id else "-"),
|
|
49
|
+
f"[green]+{v.lines_added}[/green]/[red]-{v.lines_removed}[/red]",
|
|
50
|
+
metrics or "-",
|
|
51
|
+
v.intent or "-",
|
|
52
|
+
)
|
|
53
|
+
console.print(table)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["history_command"]
|
devmemory/cli/impact.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""``devmemory impact <version>`` - the change blast-radius for one version.
|
|
2
|
+
|
|
3
|
+
Uses the Entire ``graph`` plugin. Enable it with `entire plugin install graph`
|
|
4
|
+
and set `graph.enabled = true` to also collect impact during `devmemory
|
|
5
|
+
checkpoint`; otherwise this command computes it on demand.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from devmemory.cli._render import console, hint, warn
|
|
18
|
+
from devmemory.services.context import ProjectContext
|
|
19
|
+
from devmemory.services.impact import version_impact
|
|
20
|
+
from devmemory.services.versions import get_version
|
|
21
|
+
|
|
22
|
+
_CHANGE_STYLE = {
|
|
23
|
+
"removed": "bold red",
|
|
24
|
+
"signature_changed": "yellow",
|
|
25
|
+
"renamed": "cyan",
|
|
26
|
+
"added": "green",
|
|
27
|
+
"body_changed": "dim",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def impact_command(
|
|
32
|
+
version: Annotated[str, typer.Argument(help="Version ref: v7, 7, or a commit prefix.")],
|
|
33
|
+
as_json: Annotated[bool, typer.Option("--json", help="Emit as JSON.")] = False,
|
|
34
|
+
no_compute: Annotated[
|
|
35
|
+
bool,
|
|
36
|
+
typer.Option("--stored-only", help="Only show a stored result; do not run the analysis."),
|
|
37
|
+
] = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Show the entity-level change list and dependent counts for a version."""
|
|
40
|
+
with ProjectContext.load() as ctx:
|
|
41
|
+
v = get_version(ctx, version)
|
|
42
|
+
available = ctx.graph.is_available
|
|
43
|
+
impact = version_impact(ctx, version, compute_if_missing=not no_compute)
|
|
44
|
+
|
|
45
|
+
if as_json:
|
|
46
|
+
console.print_json(json.dumps(impact.model_dump(mode="json") if impact else None))
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if impact is None:
|
|
50
|
+
if not available:
|
|
51
|
+
warn("The Entire `graph` plugin is not installed.")
|
|
52
|
+
hint("Install it: entire plugin install graph")
|
|
53
|
+
else:
|
|
54
|
+
warn(f"No impact analysis available for {v.version_id.upper()}.")
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
console.print()
|
|
58
|
+
console.print(
|
|
59
|
+
f"[bold cyan]{v.version_id.upper()}[/bold cyan] "
|
|
60
|
+
f"[dim]{impact.base_commit[:12]} → {impact.head_commit[:12] or 'HEAD'}[/dim]"
|
|
61
|
+
)
|
|
62
|
+
console.print(
|
|
63
|
+
f"{impact.entity_count} changed entities · max dependents {impact.max_dependents}\n"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if impact.hotspots:
|
|
67
|
+
table = Table(title="Hotspots", title_justify="left", box=None, header_style="dim")
|
|
68
|
+
table.add_column("change")
|
|
69
|
+
table.add_column("entity")
|
|
70
|
+
table.add_column("file", overflow="fold")
|
|
71
|
+
table.add_column("deps", justify="right")
|
|
72
|
+
for e in impact.hotspots:
|
|
73
|
+
style = _CHANGE_STYLE.get(e.change_type, "white")
|
|
74
|
+
table.add_row(
|
|
75
|
+
f"[{style}]{e.change_type}[/{style}]",
|
|
76
|
+
f"{e.kind} {e.name}",
|
|
77
|
+
e.path,
|
|
78
|
+
str(e.dependents_count),
|
|
79
|
+
)
|
|
80
|
+
console.print(table)
|
|
81
|
+
console.print()
|
|
82
|
+
|
|
83
|
+
risky = [e for e in impact.entities if e.is_risky]
|
|
84
|
+
if risky:
|
|
85
|
+
lines = "\n".join(
|
|
86
|
+
f" [bold red]{e.change_type}[/bold red] {e.name} "
|
|
87
|
+
f"[dim]({e.path}, {e.dependents_count} dependents)[/dim]"
|
|
88
|
+
for e in risky
|
|
89
|
+
)
|
|
90
|
+
console.print(
|
|
91
|
+
Panel(lines, title="review before keeping", title_align="left", border_style="red")
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = ["impact_command"]
|