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,443 @@
|
|
|
1
|
+
"""``devmemory checkpoint`` - the version-creation pipeline.
|
|
2
|
+
|
|
3
|
+
Each stage is recorded in a :class:`RunLog` so a failure is always traceable to a
|
|
4
|
+
stage and an integration. Cloud/optional steps (snapshot, Databricks) never fail
|
|
5
|
+
the run - local history is the source of truth.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from devmemory.adapters.metrics import MetricsAdapter
|
|
15
|
+
from devmemory.adapters.tests import TestAdapter
|
|
16
|
+
from devmemory.domain.enums import VersionStatus
|
|
17
|
+
from devmemory.domain.errors import (
|
|
18
|
+
CheckpointNotFoundError,
|
|
19
|
+
DevMemoryError,
|
|
20
|
+
GitRepositoryNotFoundError,
|
|
21
|
+
)
|
|
22
|
+
from devmemory.domain.models import (
|
|
23
|
+
CheckpointReference,
|
|
24
|
+
DevelopmentEvent,
|
|
25
|
+
DevelopmentVersion,
|
|
26
|
+
Metric,
|
|
27
|
+
TestOutcome,
|
|
28
|
+
)
|
|
29
|
+
from devmemory.environment import collect_environment
|
|
30
|
+
from devmemory.logging import get_logger
|
|
31
|
+
from devmemory.pipeline.feature_detect import detect_feature
|
|
32
|
+
from devmemory.pipeline.regression import RegressionThresholds, detect_regressions
|
|
33
|
+
from devmemory.pipeline.runlog import RunLog, StageRecord
|
|
34
|
+
from devmemory.pipeline.status_rules import derive_status
|
|
35
|
+
from devmemory.services.analysis import analyze_version
|
|
36
|
+
from devmemory.services.context import ProjectContext
|
|
37
|
+
from devmemory.services.databricks_sync import push_version
|
|
38
|
+
from devmemory.services.features import refresh_feature_status
|
|
39
|
+
from devmemory.services.memory import MemoryQuery, previous_attempts
|
|
40
|
+
from devmemory.services.versions import create_version_from_event
|
|
41
|
+
from devmemory.storage.artifacts import ArtifactStore
|
|
42
|
+
from devmemory.storage.graph_impacts import GraphImpactRepository
|
|
43
|
+
from devmemory.storage.versions import VersionRepository
|
|
44
|
+
|
|
45
|
+
_log = get_logger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CheckpointRequest(BaseModel):
|
|
49
|
+
intent: str | None = None
|
|
50
|
+
feature: str | None = None
|
|
51
|
+
agent: str | None = None
|
|
52
|
+
status: VersionStatus | None = None
|
|
53
|
+
tests_passed: int | None = None
|
|
54
|
+
tests_failed: int | None = None
|
|
55
|
+
tests_skipped: int | None = None
|
|
56
|
+
run_tests: bool = True
|
|
57
|
+
"""Run the configured test command (unless explicit counts were given)."""
|
|
58
|
+
metrics: list[Metric] = []
|
|
59
|
+
metrics_file: str | None = None
|
|
60
|
+
errors: list[str] = []
|
|
61
|
+
snapshot: bool = True
|
|
62
|
+
allow_no_entire: bool = False
|
|
63
|
+
force: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CheckpointResult(BaseModel):
|
|
67
|
+
version: DevelopmentVersion
|
|
68
|
+
run_log: RunLog
|
|
69
|
+
created: bool
|
|
70
|
+
warnings: list[str] = []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_checkpoint(ctx: ProjectContext, request: CheckpointRequest) -> CheckpointResult:
|
|
74
|
+
run = RunLog()
|
|
75
|
+
warnings: list[str] = []
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
with run.stage("verify_repository") as st:
|
|
79
|
+
if not ctx.git.is_repository():
|
|
80
|
+
raise GitRepositoryNotFoundError
|
|
81
|
+
if not ctx.git.has_commits():
|
|
82
|
+
raise DevMemoryError(
|
|
83
|
+
"The repository has no commits yet.",
|
|
84
|
+
hint="Commit your development change, then run `devmemory checkpoint`.",
|
|
85
|
+
)
|
|
86
|
+
st.data["repo_root"] = str(ctx.paths.repo_root)
|
|
87
|
+
|
|
88
|
+
with run.stage("resolve_commit") as st:
|
|
89
|
+
commit = ctx.git.commit("HEAD")
|
|
90
|
+
parent = commit.parent
|
|
91
|
+
branch = ctx.git.current_branch()
|
|
92
|
+
st.data |= {"commit": commit.sha, "parent": parent, "branch": branch}
|
|
93
|
+
|
|
94
|
+
with run.stage("check_working_tree") as st:
|
|
95
|
+
state = ctx.git.working_tree_state()
|
|
96
|
+
if state.has_uncommitted_changes:
|
|
97
|
+
msg = (
|
|
98
|
+
f"{len(state.staged) + len(state.unstaged)} uncommitted change(s); "
|
|
99
|
+
"the version records the committed state only."
|
|
100
|
+
)
|
|
101
|
+
warnings.append(msg)
|
|
102
|
+
st.status = "degraded"
|
|
103
|
+
st.detail = msg
|
|
104
|
+
|
|
105
|
+
with run.stage("check_idempotency") as st:
|
|
106
|
+
existing = VersionRepository(ctx.db).find_by_commit(ctx.config.project_id, commit.sha)
|
|
107
|
+
if existing is not None and not request.force:
|
|
108
|
+
st.detail = f"{existing.version_id} already records this commit"
|
|
109
|
+
run.version_id = existing.version_id
|
|
110
|
+
run.finish(outcome="success")
|
|
111
|
+
run.write(ctx.paths.runs_dir)
|
|
112
|
+
return CheckpointResult(
|
|
113
|
+
version=existing, run_log=run, created=False, warnings=warnings
|
|
114
|
+
)
|
|
115
|
+
st.data["existing"] = existing.version_id if existing else None
|
|
116
|
+
|
|
117
|
+
with run.stage("resolve_entire_checkpoint") as st:
|
|
118
|
+
checkpoint = _resolve_checkpoint(ctx, commit.sha, commit.committed_at, branch, st)
|
|
119
|
+
if checkpoint is None and not request.allow_no_entire:
|
|
120
|
+
raise CheckpointNotFoundError(
|
|
121
|
+
"No Entire checkpoint could be associated with this commit.",
|
|
122
|
+
hint=(
|
|
123
|
+
"DevMemory versions are checkpoint-aware. Ensure Entire is enabled and "
|
|
124
|
+
"the AI session was captured, or pass --allow-no-entire."
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
if checkpoint is None:
|
|
128
|
+
warnings.append("recorded without Entire checkpoint context (--allow-no-entire)")
|
|
129
|
+
st.status = "degraded"
|
|
130
|
+
elif checkpoint.is_uncertain:
|
|
131
|
+
warnings.append(
|
|
132
|
+
f"checkpoint association is uncertain "
|
|
133
|
+
f"(confidence {checkpoint.association_confidence:.2f})"
|
|
134
|
+
)
|
|
135
|
+
st.status = "degraded"
|
|
136
|
+
|
|
137
|
+
with run.stage("collect_changes") as st:
|
|
138
|
+
changed_files = ctx.git.changed_files(parent, commit.sha)
|
|
139
|
+
st.data |= {
|
|
140
|
+
"files": len(changed_files),
|
|
141
|
+
"additions": sum(f.additions for f in changed_files),
|
|
142
|
+
"deletions": sum(f.deletions for f in changed_files),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
with run.stage("collect_environment"):
|
|
146
|
+
environment = collect_environment(ctx.paths.repo_root)
|
|
147
|
+
|
|
148
|
+
with run.stage("detect_feature") as st:
|
|
149
|
+
intent = request.intent or (checkpoint.intent if checkpoint else None)
|
|
150
|
+
feature = detect_feature(
|
|
151
|
+
explicit=request.feature,
|
|
152
|
+
intent=intent,
|
|
153
|
+
commit_subject=commit.subject,
|
|
154
|
+
)
|
|
155
|
+
st.data["feature"] = feature[0] if feature else None
|
|
156
|
+
|
|
157
|
+
repo = VersionRepository(ctx.db)
|
|
158
|
+
previous = repo.previous_relevant(
|
|
159
|
+
ctx.config.project_id,
|
|
160
|
+
before_number=(
|
|
161
|
+
existing.version_number if existing else repo.count(ctx.config.project_id) + 1
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
with run.stage("collect_tests") as st:
|
|
166
|
+
tests = _collect_tests(ctx, request, st)
|
|
167
|
+
|
|
168
|
+
with run.stage("collect_metrics") as st:
|
|
169
|
+
metrics = _collect_metrics(ctx, request, previous, st)
|
|
170
|
+
|
|
171
|
+
with run.stage("check_previous_attempts") as st:
|
|
172
|
+
prior = previous_attempts(
|
|
173
|
+
ctx,
|
|
174
|
+
MemoryQuery(
|
|
175
|
+
files=[f.path for f in changed_files],
|
|
176
|
+
feature=feature[0] if feature else None,
|
|
177
|
+
intent=intent,
|
|
178
|
+
limit=3,
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
for a in prior:
|
|
182
|
+
if a.version_id in {v.version_id for v in [previous] if v}:
|
|
183
|
+
continue
|
|
184
|
+
warnings.append(
|
|
185
|
+
f"similar prior attempt {a.version_id.upper()} [{a.status}] - "
|
|
186
|
+
f"{a.result}" + (f" ({a.recommendation})" if a.recommendation else "")
|
|
187
|
+
)
|
|
188
|
+
st.data["matches"] = [a.version_id for a in prior]
|
|
189
|
+
if prior:
|
|
190
|
+
st.status = "degraded"
|
|
191
|
+
|
|
192
|
+
with run.stage("detect_regression") as st:
|
|
193
|
+
regressions = detect_regressions(
|
|
194
|
+
metrics=metrics,
|
|
195
|
+
tests=tests,
|
|
196
|
+
previous=previous,
|
|
197
|
+
thresholds=RegressionThresholds(**ctx.config.regression.model_dump()),
|
|
198
|
+
)
|
|
199
|
+
for r in regressions:
|
|
200
|
+
warnings.append(f"regression [{r.severity}] {r.detail}")
|
|
201
|
+
st.data["count"] = len(regressions)
|
|
202
|
+
if regressions:
|
|
203
|
+
st.status = "degraded"
|
|
204
|
+
|
|
205
|
+
with run.stage("determine_status") as st:
|
|
206
|
+
status = derive_status(
|
|
207
|
+
explicit=request.status,
|
|
208
|
+
tests=tests,
|
|
209
|
+
errors=request.errors,
|
|
210
|
+
regressions=regressions,
|
|
211
|
+
has_changes=bool(changed_files),
|
|
212
|
+
)
|
|
213
|
+
st.data["status"] = status.value
|
|
214
|
+
|
|
215
|
+
with run.stage("build_event"):
|
|
216
|
+
event = DevelopmentEvent(
|
|
217
|
+
project_id=ctx.config.project_id,
|
|
218
|
+
occurred_at=datetime.now(UTC),
|
|
219
|
+
run_id=run.run_id,
|
|
220
|
+
intent=intent,
|
|
221
|
+
agent=request.agent or (checkpoint.agent if checkpoint else None),
|
|
222
|
+
model=checkpoint.model if checkpoint else None,
|
|
223
|
+
feature=feature[0] if feature else None,
|
|
224
|
+
feature_derived_from=feature[1] if feature else None,
|
|
225
|
+
commit=commit,
|
|
226
|
+
parent_commit=parent,
|
|
227
|
+
branch=branch,
|
|
228
|
+
changed_files=changed_files,
|
|
229
|
+
checkpoint=checkpoint,
|
|
230
|
+
status=status,
|
|
231
|
+
tests=tests,
|
|
232
|
+
metrics=metrics,
|
|
233
|
+
errors=request.errors,
|
|
234
|
+
environment=environment,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
with run.stage("persist_version") as st:
|
|
238
|
+
version = create_version_from_event(
|
|
239
|
+
ctx, event, force=request.force, regressions=regressions
|
|
240
|
+
)
|
|
241
|
+
run.version_id = version.version_id
|
|
242
|
+
run.project_state_changed = True
|
|
243
|
+
st.data["version"] = version.version_id
|
|
244
|
+
|
|
245
|
+
with run.stage("refresh_feature") as st:
|
|
246
|
+
if version.feature_id:
|
|
247
|
+
updated = refresh_feature_status(ctx, version.feature_id)
|
|
248
|
+
st.data["feature_status"] = updated.status.value if updated else None
|
|
249
|
+
else:
|
|
250
|
+
st.status = "skipped"
|
|
251
|
+
|
|
252
|
+
with run.stage("collect_graph_impact") as st:
|
|
253
|
+
if not ctx.config.graph.enabled:
|
|
254
|
+
st.status = "skipped"
|
|
255
|
+
elif not ctx.graph.is_available:
|
|
256
|
+
st.status = "skipped"
|
|
257
|
+
st.detail = "entire-graph not installed (`entire plugin install graph`)"
|
|
258
|
+
else:
|
|
259
|
+
try:
|
|
260
|
+
impact = ctx.graph.commit_impact(version.git_commit)
|
|
261
|
+
if impact is None:
|
|
262
|
+
st.status = "degraded"
|
|
263
|
+
st.detail = "graph analysis returned nothing"
|
|
264
|
+
else:
|
|
265
|
+
GraphImpactRepository(ctx.db).set(version.version_id, impact)
|
|
266
|
+
st.data |= {
|
|
267
|
+
"entities": impact.entity_count,
|
|
268
|
+
"max_dependents": impact.max_dependents,
|
|
269
|
+
}
|
|
270
|
+
except (OSError, RuntimeError) as exc: # never fatal
|
|
271
|
+
st.status = "degraded"
|
|
272
|
+
st.detail = str(exc)
|
|
273
|
+
|
|
274
|
+
with run.stage("generate_analysis") as st:
|
|
275
|
+
if not ctx.config.analysis.enabled:
|
|
276
|
+
st.status = "skipped"
|
|
277
|
+
else:
|
|
278
|
+
try:
|
|
279
|
+
analysis = analyze_version(ctx, version.version_id)
|
|
280
|
+
version.analysis = analysis
|
|
281
|
+
st.data |= {"provider": analysis.provider, "risk": analysis.risk}
|
|
282
|
+
except DevMemoryError as exc: # never fatal - facts are already stored
|
|
283
|
+
st.status = "degraded"
|
|
284
|
+
st.detail = exc.message
|
|
285
|
+
|
|
286
|
+
with run.stage("publish_databricks") as st:
|
|
287
|
+
try:
|
|
288
|
+
sync = push_version(ctx, version)
|
|
289
|
+
st.data |= {
|
|
290
|
+
"configured": sync.configured,
|
|
291
|
+
"pushed": sync.pushed,
|
|
292
|
+
"queued": sync.queued,
|
|
293
|
+
}
|
|
294
|
+
if not sync.configured:
|
|
295
|
+
st.status = "skipped"
|
|
296
|
+
st.detail = "queued to outbox (Databricks not configured)"
|
|
297
|
+
elif sync.failed:
|
|
298
|
+
st.status = "degraded"
|
|
299
|
+
st.detail = sync.detail
|
|
300
|
+
except DevMemoryError as exc: # never fatal
|
|
301
|
+
st.status = "degraded"
|
|
302
|
+
st.detail = exc.message
|
|
303
|
+
|
|
304
|
+
with run.stage("create_artifact") as st:
|
|
305
|
+
if not request.snapshot or not ctx.config.artifacts.enabled:
|
|
306
|
+
st.status = "skipped"
|
|
307
|
+
else:
|
|
308
|
+
try:
|
|
309
|
+
artifact = ArtifactStore(ctx.paths.artifacts_dir, ctx.git).create_snapshot(
|
|
310
|
+
version_id=version.version_id,
|
|
311
|
+
commit_sha=version.git_commit,
|
|
312
|
+
exclude=ctx.config.artifacts.exclude,
|
|
313
|
+
)
|
|
314
|
+
VersionRepository(ctx.db).add_artifact(artifact)
|
|
315
|
+
version.artifacts = [artifact]
|
|
316
|
+
st.data |= {"path": artifact.path, "bytes": artifact.size_bytes}
|
|
317
|
+
except DevMemoryError as exc:
|
|
318
|
+
st.status = "degraded"
|
|
319
|
+
st.detail = exc.message
|
|
320
|
+
warnings.append(f"snapshot skipped: {exc.message}")
|
|
321
|
+
|
|
322
|
+
run.finish(outcome="success")
|
|
323
|
+
except DevMemoryError as exc:
|
|
324
|
+
run.finish(outcome="error", error=exc.message)
|
|
325
|
+
run.write(ctx.paths.runs_dir)
|
|
326
|
+
raise
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
run.finish(outcome="error", error=f"{type(exc).__name__}: {exc}")
|
|
329
|
+
run.write(ctx.paths.runs_dir)
|
|
330
|
+
raise DevMemoryError(f"checkpoint failed during the pipeline: {exc}") from exc
|
|
331
|
+
|
|
332
|
+
run.write(ctx.paths.runs_dir)
|
|
333
|
+
_log.info(
|
|
334
|
+
"checkpoint.done",
|
|
335
|
+
version=version.version_id,
|
|
336
|
+
status=version.status.value,
|
|
337
|
+
run_id=run.run_id,
|
|
338
|
+
)
|
|
339
|
+
return CheckpointResult(version=version, run_log=run, created=True, warnings=warnings)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --- stage helpers -----------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _resolve_checkpoint(
|
|
346
|
+
ctx: ProjectContext,
|
|
347
|
+
commit_sha: str,
|
|
348
|
+
committed_at: datetime | None,
|
|
349
|
+
branch: str | None,
|
|
350
|
+
stage: StageRecord,
|
|
351
|
+
) -> CheckpointReference | None:
|
|
352
|
+
if not ctx.entire.is_installed():
|
|
353
|
+
stage.detail = "Entire CLI not installed"
|
|
354
|
+
return None
|
|
355
|
+
ref = ctx.entire.resolve_for_commit(commit_sha, committed_at=committed_at, branch=branch)
|
|
356
|
+
if ref is not None:
|
|
357
|
+
stage.data = {
|
|
358
|
+
"checkpoint": ref.checkpoint_id,
|
|
359
|
+
"method": ref.association_method.value,
|
|
360
|
+
"confidence": ref.association_confidence,
|
|
361
|
+
}
|
|
362
|
+
return ref
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _collect_tests(
|
|
366
|
+
ctx: ProjectContext,
|
|
367
|
+
request: CheckpointRequest,
|
|
368
|
+
stage: StageRecord,
|
|
369
|
+
) -> TestOutcome | None:
|
|
370
|
+
if request.tests_passed is not None or request.tests_failed is not None:
|
|
371
|
+
passed = request.tests_passed or 0
|
|
372
|
+
failed = request.tests_failed or 0
|
|
373
|
+
skipped = request.tests_skipped or 0
|
|
374
|
+
stage.detail = "manual counts"
|
|
375
|
+
stage.data |= {"passed": passed, "failed": failed}
|
|
376
|
+
return TestOutcome(
|
|
377
|
+
command="(manual)",
|
|
378
|
+
total=passed + failed + skipped,
|
|
379
|
+
passed=passed,
|
|
380
|
+
failed=failed,
|
|
381
|
+
skipped=skipped,
|
|
382
|
+
exit_code=0 if failed == 0 else 1,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
command = ctx.config.tests.command
|
|
386
|
+
if not command or not request.run_tests:
|
|
387
|
+
stage.status = "skipped"
|
|
388
|
+
stage.detail = "no test command configured" if not command else "--no-run-tests"
|
|
389
|
+
return None
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
outcome = TestAdapter(ctx.paths.repo_root).run(
|
|
393
|
+
command,
|
|
394
|
+
parser=ctx.config.tests.parser,
|
|
395
|
+
junit_xml=ctx.config.tests.junit_xml,
|
|
396
|
+
timeout=ctx.config.tests.timeout_seconds,
|
|
397
|
+
)
|
|
398
|
+
except DevMemoryError as exc:
|
|
399
|
+
# A *collection* failure is a DevMemory problem, not a development result -
|
|
400
|
+
# record the version without tests rather than aborting the run.
|
|
401
|
+
stage.status = "degraded"
|
|
402
|
+
stage.detail = exc.message
|
|
403
|
+
return None
|
|
404
|
+
stage.data |= {"passed": outcome.passed, "failed": outcome.failed, "exit": outcome.exit_code}
|
|
405
|
+
return outcome
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _collect_metrics(
|
|
409
|
+
ctx: ProjectContext,
|
|
410
|
+
request: CheckpointRequest,
|
|
411
|
+
previous: DevelopmentVersion | None,
|
|
412
|
+
stage: StageRecord,
|
|
413
|
+
) -> list[Metric]:
|
|
414
|
+
metrics = list(request.metrics)
|
|
415
|
+
file = request.metrics_file or ctx.config.metrics.file
|
|
416
|
+
command = ctx.config.metrics.command
|
|
417
|
+
if file or command:
|
|
418
|
+
try:
|
|
419
|
+
collected = MetricsAdapter(ctx.paths.repo_root).collect(
|
|
420
|
+
file=file,
|
|
421
|
+
command=command,
|
|
422
|
+
directions=ctx.config.metrics.directions,
|
|
423
|
+
)
|
|
424
|
+
except DevMemoryError as exc:
|
|
425
|
+
stage.status = "degraded"
|
|
426
|
+
stage.detail = exc.message
|
|
427
|
+
collected = []
|
|
428
|
+
by_name = {m.name for m in metrics}
|
|
429
|
+
metrics.extend(m for m in collected if m.name not in by_name)
|
|
430
|
+
|
|
431
|
+
# Backfill `before` from the previous version's `after` for the same metric.
|
|
432
|
+
prev_after = {m.name: m.after for m in previous.metrics} if previous else {}
|
|
433
|
+
for m in metrics:
|
|
434
|
+
if m.before is None and m.name in prev_after:
|
|
435
|
+
m.before = prev_after[m.name]
|
|
436
|
+
|
|
437
|
+
stage.data["names"] = [m.name for m in metrics]
|
|
438
|
+
if not metrics:
|
|
439
|
+
stage.status = "skipped"
|
|
440
|
+
return metrics
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
__all__ = ["CheckpointRequest", "CheckpointResult", "run_checkpoint"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Lightweight feature attribution.
|
|
2
|
+
|
|
3
|
+
Phase 4 only uses cheap signals - an explicit flag, a conventional-commit scope,
|
|
4
|
+
or a keyword in the intent. LLM classification is a later phase.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_CC_SCOPE = re.compile(r"^\s*\w+\(([^)]+)\)\s*:", re.IGNORECASE)
|
|
12
|
+
|
|
13
|
+
_KEYWORD_FEATURES: dict[str, tuple[str, ...]] = {
|
|
14
|
+
"Authentication": ("auth", "login", "jwt", "token", "oauth", "session", "password", "signup"),
|
|
15
|
+
"Payments": ("payment", "billing", "checkout", "stripe", "invoice", "subscription"),
|
|
16
|
+
"Notifications": ("notification", "email", "webhook", "push notification", "alerting"),
|
|
17
|
+
"Search": ("search", "index", "query", "elasticsearch", "full-text"),
|
|
18
|
+
"API": ("endpoint", "route", "rest api", "graphql", "api gateway"),
|
|
19
|
+
"Inference": ("inference", "serving", "latency", "throughput", "tensorrt", "onnx"),
|
|
20
|
+
"Training": ("training", "train", "epoch", "learning rate", "optimizer", "loss"),
|
|
21
|
+
"Data Pipeline": ("etl", "pipeline", "ingest", "preprocessing", "dataset"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_feature(
|
|
26
|
+
*,
|
|
27
|
+
explicit: str | None,
|
|
28
|
+
intent: str | None,
|
|
29
|
+
commit_subject: str | None,
|
|
30
|
+
) -> tuple[str, str] | None:
|
|
31
|
+
"""Return ``(feature_name, derived_from)`` or ``None``."""
|
|
32
|
+
if explicit:
|
|
33
|
+
return explicit, "cli"
|
|
34
|
+
|
|
35
|
+
for text, source in ((commit_subject, "commit"), (intent, "intent")):
|
|
36
|
+
if not text:
|
|
37
|
+
continue
|
|
38
|
+
match = _CC_SCOPE.match(text)
|
|
39
|
+
if match:
|
|
40
|
+
return _titleize(match.group(1)), source
|
|
41
|
+
|
|
42
|
+
haystack = f"{intent or ''} {commit_subject or ''}".lower()
|
|
43
|
+
for feature, keywords in _KEYWORD_FEATURES.items():
|
|
44
|
+
if any(kw in haystack for kw in keywords):
|
|
45
|
+
return feature, "intent"
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _titleize(scope: str) -> str:
|
|
50
|
+
return scope.replace("-", " ").replace("_", " ").strip().title()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["detect_feature"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Regression detection: compare a version against the previous relevant one.
|
|
2
|
+
|
|
3
|
+
A regression is a measurable deterioration - a metric moving the wrong way past a
|
|
4
|
+
threshold, or tests that used to pass now failing. Purely rule-based and
|
|
5
|
+
direction-aware; the AI analysis layer explains *why*, it does not decide *if*.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from devmemory.domain.enums import MetricDirection
|
|
13
|
+
from devmemory.domain.models import DevelopmentVersion, Metric, Regression, TestOutcome
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RegressionThresholds(BaseModel):
|
|
17
|
+
metric_pct: float = 2.0
|
|
18
|
+
"""Adverse move larger than this percent of the baseline counts."""
|
|
19
|
+
metric_abs_floor: float = 1e-9
|
|
20
|
+
"""Ignore moves smaller than this in absolute terms (noise)."""
|
|
21
|
+
high_pct: float = 15.0
|
|
22
|
+
"""Adverse move at or above this percent is severity HIGH."""
|
|
23
|
+
medium_pct: float = 6.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_regressions(
|
|
27
|
+
*,
|
|
28
|
+
metrics: list[Metric],
|
|
29
|
+
tests: TestOutcome | None,
|
|
30
|
+
previous: DevelopmentVersion | None,
|
|
31
|
+
thresholds: RegressionThresholds | None = None,
|
|
32
|
+
) -> list[Regression]:
|
|
33
|
+
th = thresholds or RegressionThresholds()
|
|
34
|
+
out: list[Regression] = []
|
|
35
|
+
out.extend(_metric_regressions(metrics, previous, th))
|
|
36
|
+
test_reg = _test_regression(tests, previous, th)
|
|
37
|
+
if test_reg is not None:
|
|
38
|
+
out.append(test_reg)
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _metric_regressions(
|
|
43
|
+
metrics: list[Metric], previous: DevelopmentVersion | None, th: RegressionThresholds
|
|
44
|
+
) -> list[Regression]:
|
|
45
|
+
prev_by_name = {m.name: m for m in previous.metrics} if previous else {}
|
|
46
|
+
out: list[Regression] = []
|
|
47
|
+
for m in metrics:
|
|
48
|
+
before = m.before if m.before is not None else _prev_after(prev_by_name.get(m.name))
|
|
49
|
+
after = m.after
|
|
50
|
+
if before is None or after is None or before == 0:
|
|
51
|
+
continue
|
|
52
|
+
delta = after - before
|
|
53
|
+
if abs(delta) < th.metric_abs_floor:
|
|
54
|
+
continue
|
|
55
|
+
adverse = delta < 0 if m.direction is MetricDirection.HIGHER_IS_BETTER else delta > 0
|
|
56
|
+
if m.direction is MetricDirection.NEUTRAL or not adverse:
|
|
57
|
+
continue
|
|
58
|
+
pct = abs(delta) / abs(before) * 100.0
|
|
59
|
+
if pct < th.metric_pct:
|
|
60
|
+
continue
|
|
61
|
+
out.append(
|
|
62
|
+
Regression(
|
|
63
|
+
kind="metric",
|
|
64
|
+
metric=m.name,
|
|
65
|
+
before=before,
|
|
66
|
+
after=after,
|
|
67
|
+
change_percent=round(
|
|
68
|
+
-pct if m.direction is MetricDirection.HIGHER_IS_BETTER else pct, 2
|
|
69
|
+
),
|
|
70
|
+
severity=_severity(pct, th),
|
|
71
|
+
detail=(
|
|
72
|
+
f"{m.name} moved from {before:g} to {after:g} "
|
|
73
|
+
f"({pct:.1f}% {'drop' if m.direction is MetricDirection.HIGHER_IS_BETTER else 'increase'})"
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _test_regression(
|
|
81
|
+
tests: TestOutcome | None, previous: DevelopmentVersion | None, th: RegressionThresholds
|
|
82
|
+
) -> Regression | None:
|
|
83
|
+
if tests is None or not tests.ran:
|
|
84
|
+
return None
|
|
85
|
+
prev = previous.tests if previous else None
|
|
86
|
+
now_failing = tests.failed + tests.errors
|
|
87
|
+
|
|
88
|
+
if prev is not None and prev.ran:
|
|
89
|
+
prev_failing = prev.failed + prev.errors
|
|
90
|
+
if now_failing > prev_failing:
|
|
91
|
+
increase = now_failing - prev_failing
|
|
92
|
+
newly = sorted(set(tests.failing) - set(prev.failing))
|
|
93
|
+
return Regression(
|
|
94
|
+
kind="test",
|
|
95
|
+
metric="tests",
|
|
96
|
+
before=float(prev_failing),
|
|
97
|
+
after=float(now_failing),
|
|
98
|
+
change_percent=None,
|
|
99
|
+
severity="HIGH" if increase >= 3 else "MEDIUM",
|
|
100
|
+
detail=(
|
|
101
|
+
f"failing tests {prev_failing} -> {now_failing}"
|
|
102
|
+
+ (f" (new: {', '.join(newly[:5])})" if newly else "")
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
if tests.passed < prev.passed and now_failing >= prev_failing:
|
|
106
|
+
return Regression(
|
|
107
|
+
kind="test",
|
|
108
|
+
metric="tests",
|
|
109
|
+
before=float(prev.passed),
|
|
110
|
+
after=float(tests.passed),
|
|
111
|
+
change_percent=None,
|
|
112
|
+
severity="MEDIUM",
|
|
113
|
+
detail=f"passing tests {prev.passed} -> {tests.passed}",
|
|
114
|
+
)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
# No prior test data: only flag if this run itself is clearly broken.
|
|
118
|
+
if now_failing > 0 and tests.passed == 0:
|
|
119
|
+
return Regression(
|
|
120
|
+
kind="test",
|
|
121
|
+
metric="tests",
|
|
122
|
+
after=float(now_failing),
|
|
123
|
+
severity="HIGH",
|
|
124
|
+
detail=f"{now_failing} failing, 0 passing",
|
|
125
|
+
)
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _prev_after(metric: Metric | None) -> float | None:
|
|
130
|
+
return metric.after if metric is not None else None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _severity(pct: float, th: RegressionThresholds) -> str:
|
|
134
|
+
if pct >= th.high_pct:
|
|
135
|
+
return "HIGH"
|
|
136
|
+
if pct >= th.medium_pct:
|
|
137
|
+
return "MEDIUM"
|
|
138
|
+
return "LOW"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["RegressionThresholds", "detect_regressions"]
|