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,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"]