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,150 @@
|
|
|
1
|
+
"""Metrics adapter: collect arbitrary project metrics for a version.
|
|
2
|
+
|
|
3
|
+
Metrics come from a JSON file the project writes, or the JSON stdout of a
|
|
4
|
+
command. Two shapes are accepted per metric:
|
|
5
|
+
|
|
6
|
+
{"accuracy": 93.4} -> after only
|
|
7
|
+
{"accuracy": {"before": 89.2, "after": 93.4, "unit": "%"}}
|
|
8
|
+
|
|
9
|
+
The previous version's ``after`` fills any missing ``before`` (done by the
|
|
10
|
+
pipeline, not here). Direction comes from config, then a name heuristic.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from devmemory.domain.enums import MetricDirection
|
|
20
|
+
from devmemory.domain.errors import CollectionError
|
|
21
|
+
from devmemory.domain.models import Metric
|
|
22
|
+
from devmemory.logging import get_logger
|
|
23
|
+
|
|
24
|
+
_log = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
_LOWER_IS_BETTER = (
|
|
27
|
+
"latency",
|
|
28
|
+
"duration",
|
|
29
|
+
"time_ms",
|
|
30
|
+
"response_time",
|
|
31
|
+
"loss",
|
|
32
|
+
"val_loss",
|
|
33
|
+
"train_loss",
|
|
34
|
+
"error_rate",
|
|
35
|
+
"errors",
|
|
36
|
+
"failures",
|
|
37
|
+
"cost",
|
|
38
|
+
"memory",
|
|
39
|
+
"mem_mb",
|
|
40
|
+
"p50",
|
|
41
|
+
"p90",
|
|
42
|
+
"p95",
|
|
43
|
+
"p99",
|
|
44
|
+
"size_bytes",
|
|
45
|
+
"bundle_size",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MetricsAdapter:
|
|
50
|
+
def __init__(self, repo_path: Path | str) -> None:
|
|
51
|
+
self._cwd = Path(repo_path).resolve()
|
|
52
|
+
|
|
53
|
+
def collect(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
file: str | None = None,
|
|
57
|
+
command: str | None = None,
|
|
58
|
+
directions: dict[str, MetricDirection] | None = None,
|
|
59
|
+
) -> list[Metric]:
|
|
60
|
+
raw = self._load(file=file, command=command)
|
|
61
|
+
if not raw:
|
|
62
|
+
return []
|
|
63
|
+
directions = directions or {}
|
|
64
|
+
metrics: list[Metric] = []
|
|
65
|
+
for name, value in raw.items():
|
|
66
|
+
metric = _to_metric(str(name), value)
|
|
67
|
+
if metric is None:
|
|
68
|
+
continue
|
|
69
|
+
metric.direction = directions.get(metric.name, guess_direction(metric.name))
|
|
70
|
+
metrics.append(metric)
|
|
71
|
+
_log.info("metrics.collected", count=len(metrics), names=[m.name for m in metrics])
|
|
72
|
+
return metrics
|
|
73
|
+
|
|
74
|
+
def _load(self, *, file: str | None, command: str | None) -> dict[str, object]:
|
|
75
|
+
if file:
|
|
76
|
+
path = (self._cwd / file).resolve()
|
|
77
|
+
if not path.is_file():
|
|
78
|
+
raise CollectionError(f"metrics file not found: {path}")
|
|
79
|
+
return _as_dict(path.read_text(encoding="utf-8"), source=str(path))
|
|
80
|
+
if command:
|
|
81
|
+
try:
|
|
82
|
+
proc = subprocess.run( # noqa: S602 - user-configured command
|
|
83
|
+
command,
|
|
84
|
+
cwd=self._cwd,
|
|
85
|
+
shell=True,
|
|
86
|
+
capture_output=True,
|
|
87
|
+
text=True,
|
|
88
|
+
timeout=300,
|
|
89
|
+
)
|
|
90
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
91
|
+
raise CollectionError(f"metrics command failed: {command}: {exc}") from exc
|
|
92
|
+
if proc.returncode != 0:
|
|
93
|
+
raise CollectionError(
|
|
94
|
+
f"metrics command exited {proc.returncode}: {proc.stderr.strip()[:200]}"
|
|
95
|
+
)
|
|
96
|
+
return _as_dict(proc.stdout, source=f"`{command}`")
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def guess_direction(name: str) -> MetricDirection:
|
|
101
|
+
lowered = name.lower().replace("-", "_")
|
|
102
|
+
if any(hint in lowered for hint in _LOWER_IS_BETTER):
|
|
103
|
+
return MetricDirection.LOWER_IS_BETTER
|
|
104
|
+
return MetricDirection.HIGHER_IS_BETTER
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _as_dict(text: str, *, source: str) -> dict[str, object]:
|
|
108
|
+
try:
|
|
109
|
+
data = json.loads(text)
|
|
110
|
+
except json.JSONDecodeError as exc:
|
|
111
|
+
raise CollectionError(f"metrics from {source} are not valid JSON: {exc}") from exc
|
|
112
|
+
if isinstance(data, dict) and "metrics" in data and isinstance(data["metrics"], dict):
|
|
113
|
+
data = data["metrics"]
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
raise CollectionError(f"metrics from {source} must be a JSON object")
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _to_metric(name: str, value: object) -> Metric | None:
|
|
120
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
121
|
+
return Metric(name=name, after=float(value))
|
|
122
|
+
if isinstance(value, dict):
|
|
123
|
+
after = _num(value.get("after")) or _num(value.get("value"))
|
|
124
|
+
before = _num(value.get("before"))
|
|
125
|
+
if after is None and before is None:
|
|
126
|
+
return None
|
|
127
|
+
return Metric(
|
|
128
|
+
name=name,
|
|
129
|
+
before=before,
|
|
130
|
+
after=after,
|
|
131
|
+
unit=str(value["unit"]) if value.get("unit") is not None else None,
|
|
132
|
+
metadata={
|
|
133
|
+
k: v for k, v in value.items() if k not in ("before", "after", "value", "unit")
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _num(value: object) -> float | None:
|
|
140
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
141
|
+
return float(value)
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
try:
|
|
144
|
+
return float(value)
|
|
145
|
+
except ValueError:
|
|
146
|
+
return None
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
__all__ = ["MetricsAdapter", "guess_direction"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Test adapter: run the project's configured test command and normalize the result.
|
|
2
|
+
|
|
3
|
+
DevMemory does not care which framework you use. It runs a shell command and
|
|
4
|
+
parses what comes back - a JUnit XML report if one is configured, otherwise the
|
|
5
|
+
stdout of pytest / go test / cargo test / a generic "N passed, M failed" line.
|
|
6
|
+
The *outcome* is a fact; whether that outcome is good is decided elsewhere.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from xml.etree import ElementTree
|
|
17
|
+
|
|
18
|
+
from devmemory.domain.errors import CollectionError
|
|
19
|
+
from devmemory.domain.models import TestOutcome
|
|
20
|
+
from devmemory.logging import get_logger
|
|
21
|
+
|
|
22
|
+
_log = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
_OUTPUT_CAP = 20_000
|
|
25
|
+
|
|
26
|
+
# pytest summary: "3 failed, 128 passed, 2 skipped, 1 error in 4.20s"
|
|
27
|
+
_PYTEST_TOKEN = re.compile(r"(\d+)\s+(passed|failed|skipped|error|errors|xfailed|xpassed)")
|
|
28
|
+
_PYTEST_FAIL_LINE = re.compile(r"^(?:FAILED|ERROR)\s+(\S+)", re.MULTILINE)
|
|
29
|
+
|
|
30
|
+
# go test: "ok pkg 0.1s" / "--- FAIL: TestFoo"
|
|
31
|
+
_GO_FAIL = re.compile(r"^--- FAIL:\s+(\S+)", re.MULTILINE)
|
|
32
|
+
_GO_PASS = re.compile(r"^--- PASS:\s+(\S+)", re.MULTILINE)
|
|
33
|
+
|
|
34
|
+
# generic: "12 passed", "3 failing", "Tests: 5 failed, 40 passed"
|
|
35
|
+
_GENERIC = re.compile(
|
|
36
|
+
r"(?P<n>\d+)\s+(?P<kind>passed|passing|failed|failing|skipped|pending|errors?)",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestAdapter:
|
|
42
|
+
__test__ = False # this runs tests; it is not itself a pytest test case
|
|
43
|
+
|
|
44
|
+
def __init__(self, repo_path: Path | str) -> None:
|
|
45
|
+
self._cwd = Path(repo_path).resolve()
|
|
46
|
+
|
|
47
|
+
def run(
|
|
48
|
+
self,
|
|
49
|
+
command: str,
|
|
50
|
+
*,
|
|
51
|
+
parser: str = "auto",
|
|
52
|
+
junit_xml: str | None = None,
|
|
53
|
+
timeout: int = 900,
|
|
54
|
+
) -> TestOutcome:
|
|
55
|
+
"""Execute ``command`` and return a normalized :class:`TestOutcome`."""
|
|
56
|
+
start = time.perf_counter()
|
|
57
|
+
try:
|
|
58
|
+
proc = subprocess.run( # noqa: S602 - user-configured command, run in their repo
|
|
59
|
+
command,
|
|
60
|
+
cwd=self._cwd,
|
|
61
|
+
shell=True,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
errors="replace",
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
)
|
|
68
|
+
except subprocess.TimeoutExpired as exc:
|
|
69
|
+
raise CollectionError(
|
|
70
|
+
f"test command timed out after {timeout}s: {command}",
|
|
71
|
+
) from exc
|
|
72
|
+
except OSError as exc:
|
|
73
|
+
raise CollectionError(f"could not run test command {command!r}: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
duration = round(time.perf_counter() - start, 2)
|
|
76
|
+
combined = f"{proc.stdout}\n{proc.stderr}"
|
|
77
|
+
|
|
78
|
+
if junit_xml:
|
|
79
|
+
outcome = self._from_junit(self._cwd / junit_xml)
|
|
80
|
+
else:
|
|
81
|
+
outcome = self._parse(combined, parser)
|
|
82
|
+
|
|
83
|
+
outcome.command = command
|
|
84
|
+
outcome.exit_code = proc.returncode
|
|
85
|
+
outcome.duration_seconds = duration
|
|
86
|
+
outcome.output = _tail(combined, _OUTPUT_CAP)
|
|
87
|
+
|
|
88
|
+
# A command that exited non-zero with no parsed failures still failed.
|
|
89
|
+
if proc.returncode != 0 and outcome.failed == 0 and outcome.errors == 0:
|
|
90
|
+
outcome.errors = max(outcome.errors, 1)
|
|
91
|
+
|
|
92
|
+
_log.info(
|
|
93
|
+
"tests.collected",
|
|
94
|
+
command=command,
|
|
95
|
+
passed=outcome.passed,
|
|
96
|
+
failed=outcome.failed,
|
|
97
|
+
exit_code=proc.returncode,
|
|
98
|
+
)
|
|
99
|
+
return outcome
|
|
100
|
+
|
|
101
|
+
# -- parsing --------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _parse(self, text: str, parser: str) -> TestOutcome:
|
|
104
|
+
chosen = parser
|
|
105
|
+
if chosen == "auto":
|
|
106
|
+
chosen = self._sniff(text)
|
|
107
|
+
if chosen == "pytest":
|
|
108
|
+
return self._parse_pytest(text)
|
|
109
|
+
if chosen == "go":
|
|
110
|
+
return self._parse_go(text)
|
|
111
|
+
if chosen == "junitxml":
|
|
112
|
+
raise CollectionError("parser=junitxml requires tests.junit_xml to be set")
|
|
113
|
+
return self._parse_generic(text)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _sniff(text: str) -> str:
|
|
117
|
+
head = text[:4000].lower()
|
|
118
|
+
if "=== test session starts ===" in head or "\npassed" in head or "pytest" in head:
|
|
119
|
+
return "pytest"
|
|
120
|
+
if "--- fail:" in head or "--- pass:" in head or "\nok " in head:
|
|
121
|
+
return "go"
|
|
122
|
+
return "generic"
|
|
123
|
+
|
|
124
|
+
def _parse_pytest(self, text: str) -> TestOutcome:
|
|
125
|
+
# Take the last summary line so a mid-run "1 failed" doesn't shadow the total.
|
|
126
|
+
summary_line = ""
|
|
127
|
+
for line in text.splitlines():
|
|
128
|
+
if _PYTEST_TOKEN.search(line) and (
|
|
129
|
+
"passed" in line or "failed" in line or "error" in line
|
|
130
|
+
):
|
|
131
|
+
summary_line = line
|
|
132
|
+
counts = {"passed": 0, "failed": 0, "skipped": 0, "errors": 0}
|
|
133
|
+
for count, kind in _PYTEST_TOKEN.findall(summary_line or text):
|
|
134
|
+
key = "errors" if kind.startswith("error") else kind
|
|
135
|
+
if key in counts:
|
|
136
|
+
counts[key] = max(counts[key], int(count))
|
|
137
|
+
failing = _PYTEST_FAIL_LINE.findall(text)
|
|
138
|
+
total = sum(counts.values())
|
|
139
|
+
return TestOutcome(
|
|
140
|
+
framework="pytest",
|
|
141
|
+
total=total,
|
|
142
|
+
passed=counts["passed"],
|
|
143
|
+
failed=counts["failed"],
|
|
144
|
+
skipped=counts["skipped"],
|
|
145
|
+
errors=counts["errors"],
|
|
146
|
+
failing=failing[:50],
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _parse_go(self, text: str) -> TestOutcome:
|
|
150
|
+
failing = _GO_FAIL.findall(text)
|
|
151
|
+
passing = _GO_PASS.findall(text)
|
|
152
|
+
return TestOutcome(
|
|
153
|
+
framework="go",
|
|
154
|
+
total=len(failing) + len(passing),
|
|
155
|
+
passed=len(passing),
|
|
156
|
+
failed=len(failing),
|
|
157
|
+
failing=failing[:50],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def _parse_generic(self, text: str) -> TestOutcome:
|
|
161
|
+
counts = {"passed": 0, "failed": 0, "skipped": 0, "errors": 0}
|
|
162
|
+
for m in _GENERIC.finditer(text):
|
|
163
|
+
kind = m.group("kind").lower()
|
|
164
|
+
n = int(m.group("n"))
|
|
165
|
+
if kind in ("passed", "passing"):
|
|
166
|
+
counts["passed"] = max(counts["passed"], n)
|
|
167
|
+
elif kind in ("failed", "failing"):
|
|
168
|
+
counts["failed"] = max(counts["failed"], n)
|
|
169
|
+
elif kind in ("skipped", "pending"):
|
|
170
|
+
counts["skipped"] = max(counts["skipped"], n)
|
|
171
|
+
elif kind.startswith("error"):
|
|
172
|
+
counts["errors"] = max(counts["errors"], n)
|
|
173
|
+
return TestOutcome(
|
|
174
|
+
framework="generic",
|
|
175
|
+
total=sum(counts.values()),
|
|
176
|
+
passed=counts["passed"],
|
|
177
|
+
failed=counts["failed"],
|
|
178
|
+
skipped=counts["skipped"],
|
|
179
|
+
errors=counts["errors"],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _from_junit(self, path: Path) -> TestOutcome:
|
|
183
|
+
if not path.is_file():
|
|
184
|
+
raise CollectionError(f"JUnit report not found: {path}")
|
|
185
|
+
try:
|
|
186
|
+
root = ElementTree.parse(path).getroot() # noqa: S314 - our own CI's report
|
|
187
|
+
except ElementTree.ParseError as exc:
|
|
188
|
+
raise CollectionError(f"could not parse JUnit report {path}: {exc}") from exc
|
|
189
|
+
|
|
190
|
+
suites = [root] if root.tag == "testsuite" else root.findall(".//testsuite")
|
|
191
|
+
total = failures = errors = skipped = 0
|
|
192
|
+
failing: list[str] = []
|
|
193
|
+
for suite in suites:
|
|
194
|
+
total += int(suite.get("tests", 0))
|
|
195
|
+
failures += int(suite.get("failures", 0))
|
|
196
|
+
errors += int(suite.get("errors", 0))
|
|
197
|
+
skipped += int(suite.get("skipped", 0))
|
|
198
|
+
for case in suite.findall("testcase"):
|
|
199
|
+
if case.find("failure") is not None or case.find("error") is not None:
|
|
200
|
+
name = case.get("name", "")
|
|
201
|
+
classname = case.get("classname", "")
|
|
202
|
+
failing.append(f"{classname}::{name}" if classname else name)
|
|
203
|
+
return TestOutcome(
|
|
204
|
+
framework="junit",
|
|
205
|
+
total=total,
|
|
206
|
+
passed=total - failures - errors - skipped,
|
|
207
|
+
failed=failures,
|
|
208
|
+
errors=errors,
|
|
209
|
+
skipped=skipped,
|
|
210
|
+
failing=failing[:50],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _tail(text: str, cap: int) -> str:
|
|
215
|
+
text = text.strip()
|
|
216
|
+
return text if len(text) <= cap else "…(truncated)…\n" + text[-cap:]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def split_command(command: str) -> list[str]:
|
|
220
|
+
"""POSIX-ish split for display; execution always uses the shell."""
|
|
221
|
+
try:
|
|
222
|
+
return shlex.split(command, posix=True)
|
|
223
|
+
except ValueError:
|
|
224
|
+
return command.split()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["TestAdapter", "split_command"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""AI analysis layer.
|
|
2
|
+
|
|
3
|
+
Analysis is *interpretation*, stored separately from facts and never able to
|
|
4
|
+
overwrite them. Providers are tried in a fallback chain; ``rules`` (deterministic,
|
|
5
|
+
offline) is always the tail.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from devmemory.analysis.base import AnalysisInput, AnalysisProvider, fact_guard
|
|
11
|
+
from devmemory.analysis.chain import build_providers, run_analysis
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AnalysisInput",
|
|
15
|
+
"AnalysisProvider",
|
|
16
|
+
"build_providers",
|
|
17
|
+
"fact_guard",
|
|
18
|
+
"run_analysis",
|
|
19
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The analysis contract: a normalized input, a provider interface, a fact guard.
|
|
2
|
+
|
|
3
|
+
Analysis is *interpretation*. It is stored in its own table and can only ever
|
|
4
|
+
populate the interpretive fields (``summary``, ``reasoning``, ``recommendation``,
|
|
5
|
+
``warnings``, ``risk``). It can never touch a fact - Git line counts, test
|
|
6
|
+
results, metric values, the Entire checkpoint - because those live on the
|
|
7
|
+
``DevelopmentVersion`` and are never handed to a provider for writing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import abc
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from devmemory.domain.models import Analysis
|
|
17
|
+
from devmemory.logging import redact_secrets
|
|
18
|
+
|
|
19
|
+
_RISK_LEVELS = ("low", "medium", "high")
|
|
20
|
+
_MAX_SUMMARY = 1200
|
|
21
|
+
_MAX_FIELD = 2000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MetricDelta(BaseModel):
|
|
25
|
+
name: str
|
|
26
|
+
before: float | None
|
|
27
|
+
after: float | None
|
|
28
|
+
direction: str
|
|
29
|
+
improved: bool
|
|
30
|
+
worsened: bool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AttemptRef(BaseModel):
|
|
34
|
+
version_id: str
|
|
35
|
+
status: str
|
|
36
|
+
result: str
|
|
37
|
+
matched_on: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ImpactRef(BaseModel):
|
|
41
|
+
entity: str
|
|
42
|
+
change_type: str
|
|
43
|
+
dependents: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AnalysisInput(BaseModel):
|
|
47
|
+
"""Everything a provider is allowed to see. Normalized facts, no raw source
|
|
48
|
+
unless ``diff_excerpt`` was explicitly enabled in config."""
|
|
49
|
+
|
|
50
|
+
version_id: str
|
|
51
|
+
intent: str | None
|
|
52
|
+
feature: str | None
|
|
53
|
+
agent: str | None
|
|
54
|
+
model: str | None
|
|
55
|
+
status: str
|
|
56
|
+
is_adverse: bool
|
|
57
|
+
files_changed: int
|
|
58
|
+
lines_added: int
|
|
59
|
+
lines_removed: int
|
|
60
|
+
changed_paths: list[str] = Field(default_factory=list)
|
|
61
|
+
test_summary: str | None = None
|
|
62
|
+
tests_passed: int | None = None
|
|
63
|
+
tests_failed: int | None = None
|
|
64
|
+
metric_deltas: list[MetricDelta] = Field(default_factory=list)
|
|
65
|
+
regressions: list[str] = Field(default_factory=list)
|
|
66
|
+
previous_attempts: list[AttemptRef] = Field(default_factory=list)
|
|
67
|
+
impact_hotspots: list[ImpactRef] = Field(default_factory=list)
|
|
68
|
+
diff_excerpt: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AnalysisProvider(abc.ABC):
|
|
72
|
+
"""One way to turn an :class:`AnalysisInput` into an :class:`Analysis`."""
|
|
73
|
+
|
|
74
|
+
#: stable identifier, also stored on the Analysis row
|
|
75
|
+
name: str = "base"
|
|
76
|
+
|
|
77
|
+
@abc.abstractmethod
|
|
78
|
+
def analyze(self, data: AnalysisInput) -> Analysis | None:
|
|
79
|
+
"""Return an Analysis, or ``None`` to fall through to the next provider."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fact_guard(analysis: Analysis, data: AnalysisInput, *, provider: str) -> Analysis:
|
|
83
|
+
"""Sanitize a provider's Analysis so it cannot misrepresent the facts.
|
|
84
|
+
|
|
85
|
+
- ``provider`` / ``model`` are set by us, never by the model.
|
|
86
|
+
- ``risk`` is clamped to low|medium|high and can never sit *below* what the
|
|
87
|
+
recorded status implies (an adverse version is at least ``medium``).
|
|
88
|
+
- all free text is secret-scrubbed and length-capped.
|
|
89
|
+
"""
|
|
90
|
+
risk = (analysis.risk or "").strip().lower()
|
|
91
|
+
if risk not in _RISK_LEVELS:
|
|
92
|
+
risk = "medium" if data.is_adverse else "low"
|
|
93
|
+
|
|
94
|
+
warnings = [redact_secrets(w)[:_MAX_FIELD] for w in analysis.warnings if w.strip()]
|
|
95
|
+
if data.is_adverse and risk == "low":
|
|
96
|
+
risk = "medium"
|
|
97
|
+
warnings.append(
|
|
98
|
+
"Risk raised to 'medium': this version is recorded as "
|
|
99
|
+
f"{data.status} with {len(data.regressions)} regression(s)."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return analysis.model_copy(
|
|
103
|
+
update={
|
|
104
|
+
"summary": redact_secrets(analysis.summary)[:_MAX_SUMMARY],
|
|
105
|
+
"reasoning": _clip(analysis.reasoning),
|
|
106
|
+
"recommendation": _clip(analysis.recommendation),
|
|
107
|
+
"warnings": warnings,
|
|
108
|
+
"risk": risk,
|
|
109
|
+
"provider": provider,
|
|
110
|
+
"model": analysis.model,
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _clip(text: str | None) -> str | None:
|
|
116
|
+
if not text or not text.strip():
|
|
117
|
+
return None
|
|
118
|
+
return redact_secrets(text)[:_MAX_FIELD]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"AnalysisInput",
|
|
123
|
+
"AnalysisProvider",
|
|
124
|
+
"AttemptRef",
|
|
125
|
+
"ImpactRef",
|
|
126
|
+
"MetricDelta",
|
|
127
|
+
"fact_guard",
|
|
128
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The provider fallback chain.
|
|
2
|
+
|
|
3
|
+
Try each configured provider in order; the first that returns an Analysis wins,
|
|
4
|
+
after passing through :func:`fact_guard`. ``rules`` is always appended as the
|
|
5
|
+
guaranteed tail, so :func:`run_analysis` never raises and never returns ``None``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from devmemory.analysis.base import AnalysisInput, AnalysisProvider, fact_guard
|
|
11
|
+
from devmemory.analysis.llm import LLMProvider
|
|
12
|
+
from devmemory.analysis.rules import RulesProvider
|
|
13
|
+
from devmemory.domain.models import Analysis
|
|
14
|
+
from devmemory.logging import get_logger
|
|
15
|
+
|
|
16
|
+
_log = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
_LLM_PROVIDERS = {"anthropic", "openai", "gemini"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_providers(names: list[str], *, model: str | None = None) -> list[AnalysisProvider]:
|
|
22
|
+
providers: list[AnalysisProvider] = []
|
|
23
|
+
seen: set[str] = set()
|
|
24
|
+
for raw in names:
|
|
25
|
+
name = raw.strip().lower()
|
|
26
|
+
if not name or name in seen:
|
|
27
|
+
continue
|
|
28
|
+
seen.add(name)
|
|
29
|
+
if name == "rules":
|
|
30
|
+
providers.append(RulesProvider())
|
|
31
|
+
elif name in _LLM_PROVIDERS:
|
|
32
|
+
providers.append(LLMProvider(name, model))
|
|
33
|
+
else:
|
|
34
|
+
_log.warning("analysis.unknown_provider", provider=name)
|
|
35
|
+
if not any(isinstance(p, RulesProvider) for p in providers):
|
|
36
|
+
providers.append(RulesProvider()) # guaranteed tail
|
|
37
|
+
return providers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_analysis(data: AnalysisInput, providers: list[AnalysisProvider]) -> Analysis:
|
|
41
|
+
for provider in providers:
|
|
42
|
+
try:
|
|
43
|
+
result = provider.analyze(data)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
_log.warning("analysis.provider_error", provider=provider.name, error=str(exc))
|
|
46
|
+
continue
|
|
47
|
+
if result is not None:
|
|
48
|
+
return fact_guard(result, data, provider=provider.name)
|
|
49
|
+
# build_providers guarantees a RulesProvider tail, so this is unreachable
|
|
50
|
+
return fact_guard(RulesProvider().analyze(data), data, provider="rules")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["build_providers", "run_analysis"]
|