gdmcode 0.1.0__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 (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
@@ -0,0 +1,456 @@
1
+ """ArtifactStore — persist, version, and search complex agent outputs.
2
+
3
+ Artifact types: diagram, report, plan, coverage, diff, custom.
4
+
5
+ All mutations are wrapped in explicit SQLite transactions.
6
+ ArtifactStore is deliberately a thin wrapper around GdmDatabase, following
7
+ the same internal pattern as GdmDatabase typed methods (direct _conn access
8
+ under _lock) rather than using the auto-committing execute() helper.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import hashlib
14
+ import re
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timezone
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ import sqlite3 as _sqlite3
22
+ from src.memory.db import GdmDatabase
23
+
24
+ __all__ = [
25
+ "Artifact",
26
+ "ArtifactDiff",
27
+ "ArtifactNotFoundError",
28
+ "ArtifactStore",
29
+ "ArtifactVersion",
30
+ ]
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Secret redaction patterns
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
37
+ (re.compile(r"xai-[A-Za-z0-9]{32,}"), "[REDACTED:xai-key]"),
38
+ (re.compile(r"sk-[A-Za-z0-9]{40,}"), "[REDACTED:openai-key]"),
39
+ (re.compile(r"AIza[A-Za-z0-9\-_]{35}"), "[REDACTED:gcp-key]"),
40
+ (re.compile(r"ghp_[A-Za-z0-9]{36}"), "[REDACTED:gh-token]"),
41
+ (re.compile(r"(?i)(password\s*[:=]\s*)\S+"), r"\1[REDACTED]"),
42
+ ]
43
+
44
+ _VALID_TYPES = frozenset({"diagram", "report", "plan", "coverage", "diff", "custom"})
45
+ _VALID_VISIBILITY = frozenset({"private", "project", "public"})
46
+
47
+
48
+ def _redact_secrets(text: str) -> str:
49
+ for pattern, replacement in _SECRET_PATTERNS:
50
+ text = pattern.sub(replacement, text)
51
+ return text
52
+
53
+
54
+ def _render_html(artifact: "Artifact", content: str) -> str:
55
+ import html as _html
56
+ escaped = _html.escape(content)
57
+ return (
58
+ f"<!DOCTYPE html><html><head>"
59
+ f"<title>{_html.escape(artifact.name)}</title>"
60
+ f"</head><body><pre>{escaped}</pre></body></html>"
61
+ )
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Data classes
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ @dataclass
70
+ class ArtifactVersion:
71
+ version_id: str
72
+ artifact_id: str
73
+ version_num: int
74
+ content: str
75
+ content_hash: str
76
+ byte_size: int
77
+ event_id: str | None
78
+ created_at: str
79
+
80
+
81
+ @dataclass
82
+ class Artifact:
83
+ artifact_id: str
84
+ name: str
85
+ type: str
86
+ description: str | None
87
+ session_id: str | None
88
+ created_at: str
89
+ updated_at: str
90
+ visibility: str
91
+ # Latest version fields (populated by queries that JOIN artifact_versions)
92
+ version_id: str = ""
93
+ version_num: int = 1
94
+ content: str = ""
95
+ content_hash: str = ""
96
+ byte_size: int = 0
97
+ event_id: str | None = None
98
+ version_ts: str = ""
99
+
100
+ @classmethod
101
+ def from_row(cls, row: "_sqlite3.Row") -> "Artifact":
102
+ d = dict(row)
103
+ return cls(
104
+ artifact_id=d["artifact_id"],
105
+ name=d["name"],
106
+ type=d["type"],
107
+ description=d.get("description"),
108
+ session_id=d.get("session_id"),
109
+ created_at=d["created_at"],
110
+ updated_at=d["updated_at"],
111
+ visibility=d.get("visibility", "private"),
112
+ version_id=d.get("version_id", ""),
113
+ version_num=d.get("version_num", 1),
114
+ content=d.get("content", ""),
115
+ content_hash=d.get("content_hash", ""),
116
+ byte_size=d.get("byte_size", 0),
117
+ event_id=d.get("event_id"),
118
+ version_ts=d.get("version_ts", ""),
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class ArtifactDiff:
124
+ artifact_a: Artifact
125
+ artifact_b: Artifact
126
+ unified_diff: str
127
+
128
+
129
+ class ArtifactNotFoundError(KeyError):
130
+ pass
131
+
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # ArtifactStore
135
+ # ---------------------------------------------------------------------------
136
+
137
+
138
+ class ArtifactStore:
139
+ """Persist, version, and search structured artifact outputs.
140
+
141
+ Example::
142
+
143
+ store = ArtifactStore(db)
144
+ art = store.save("arch-diagram", "diagram", "```mermaid\\n...```")
145
+ art2 = store.get("arch-diagram") # by name
146
+ diff = store.diff(art.artifact_id, art2.artifact_id)
147
+ results = store.search("mermaid")
148
+ raw = store.export(art.artifact_id)
149
+ """
150
+
151
+ def __init__(self, db: "GdmDatabase") -> None:
152
+ self._db = db
153
+
154
+ # ------------------------------------------------------------------
155
+ # Save (create or version)
156
+ # ------------------------------------------------------------------
157
+
158
+ def save(
159
+ self,
160
+ name: str,
161
+ type: str,
162
+ content: str,
163
+ *,
164
+ description: str | None = None,
165
+ session_id: str | None = None,
166
+ event_id: str | None = None,
167
+ links: list[dict[str, str]] | None = None,
168
+ visibility: str = "private",
169
+ ) -> "Artifact":
170
+ """Create or add a new version of an artifact.
171
+
172
+ If an artifact with *name* already exists, a new version row is added.
173
+ Returns the Artifact populated with the new version's fields.
174
+
175
+ Args:
176
+ name: human-readable slug (unique across the DB)
177
+ type: one of diagram / report / plan / coverage / diff / custom
178
+ content: artifact body text
179
+ description: optional summary
180
+ session_id: originating session
181
+ event_id: which turn event produced this version
182
+ links: list of {link_type, ref_value} dicts
183
+ visibility: private | project | public
184
+ """
185
+ if type not in _VALID_TYPES:
186
+ raise ValueError(f"Invalid artifact type {type!r}. Choose from: {sorted(_VALID_TYPES)}")
187
+ if visibility not in _VALID_VISIBILITY:
188
+ raise ValueError(f"Invalid visibility {visibility!r}.")
189
+
190
+ content_hash = hashlib.sha256(content.encode()).hexdigest()
191
+ now = datetime.now(timezone.utc).isoformat()
192
+
193
+ with self._db._lock:
194
+ conn = self._db._conn
195
+ try:
196
+ conn.execute("BEGIN")
197
+
198
+ # Upsert artifact metadata
199
+ row = conn.execute(
200
+ "SELECT artifact_id FROM artifacts WHERE name=?", (name,)
201
+ ).fetchone()
202
+ if row:
203
+ artifact_id = row["artifact_id"]
204
+ conn.execute(
205
+ "UPDATE artifacts SET type=?, description=?, updated_at=?"
206
+ " WHERE artifact_id=?",
207
+ (type, description, now, artifact_id),
208
+ )
209
+ else:
210
+ artifact_id = str(uuid.uuid4())
211
+ conn.execute(
212
+ "INSERT INTO artifacts"
213
+ " (artifact_id, name, type, description, session_id, visibility)"
214
+ " VALUES (?, ?, ?, ?, ?, ?)",
215
+ (artifact_id, name, type, description, session_id, visibility),
216
+ )
217
+
218
+ # Next version number
219
+ vrow = conn.execute(
220
+ "SELECT COALESCE(MAX(version_num), 0) AS v"
221
+ " FROM artifact_versions WHERE artifact_id=?",
222
+ (artifact_id,),
223
+ ).fetchone()
224
+ next_v = (vrow["v"] if vrow else 0) + 1
225
+ version_id = str(uuid.uuid4())
226
+
227
+ conn.execute(
228
+ "INSERT INTO artifact_versions"
229
+ " (version_id, artifact_id, version_num, content,"
230
+ " content_hash, byte_size, event_id)"
231
+ " VALUES (?, ?, ?, ?, ?, ?, ?)",
232
+ (
233
+ version_id,
234
+ artifact_id,
235
+ next_v,
236
+ content,
237
+ content_hash,
238
+ len(content.encode()),
239
+ event_id,
240
+ ),
241
+ )
242
+
243
+ for link in links or []:
244
+ conn.execute(
245
+ "INSERT INTO artifact_links (artifact_id, link_type, ref_value)"
246
+ " VALUES (?, ?, ?)",
247
+ (artifact_id, link["link_type"], link["ref_value"]),
248
+ )
249
+
250
+ conn.commit()
251
+ except Exception:
252
+ try:
253
+ conn.execute("ROLLBACK")
254
+ except Exception:
255
+ pass
256
+ raise
257
+
258
+ return self.get(artifact_id)
259
+
260
+ # ------------------------------------------------------------------
261
+ # Get
262
+ # ------------------------------------------------------------------
263
+
264
+ def get(self, artifact_id_or_name: str) -> "Artifact":
265
+ """Return the artifact (with latest version content) by ID or name.
266
+
267
+ Raises:
268
+ ArtifactNotFoundError: if no artifact matches.
269
+ """
270
+ row = self._db.execute_one(
271
+ """
272
+ SELECT a.*, av.version_id, av.version_num, av.content, av.content_hash,
273
+ av.byte_size, av.event_id, av.created_at AS version_ts
274
+ FROM artifacts a
275
+ JOIN artifact_versions av ON av.artifact_id = a.artifact_id
276
+ WHERE (a.artifact_id=? OR a.name=?)
277
+ ORDER BY av.version_num DESC LIMIT 1
278
+ """,
279
+ (artifact_id_or_name, artifact_id_or_name),
280
+ )
281
+ if row is None:
282
+ raise ArtifactNotFoundError(
283
+ f"No artifact found: {artifact_id_or_name!r}"
284
+ )
285
+ return Artifact.from_row(row)
286
+
287
+ # ------------------------------------------------------------------
288
+ # List
289
+ # ------------------------------------------------------------------
290
+
291
+ def list(
292
+ self,
293
+ *,
294
+ type: str | None = None,
295
+ session_id: str | None = None,
296
+ limit: int = 50,
297
+ ) -> list["Artifact"]:
298
+ """List artifacts (latest version of each) with optional filters."""
299
+ where_clauses: list[str] = []
300
+ params: list[Any] = []
301
+ if type:
302
+ where_clauses.append("a.type=?")
303
+ params.append(type)
304
+ if session_id:
305
+ where_clauses.append("a.session_id=?")
306
+ params.append(session_id)
307
+ clause = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
308
+ rows = self._db.execute_all(
309
+ f"""
310
+ SELECT a.*, av.version_num, av.content, av.content_hash,
311
+ av.byte_size, av.event_id, av.created_at AS version_ts
312
+ FROM artifacts a
313
+ JOIN artifact_versions av ON av.artifact_id = a.artifact_id
314
+ AND av.version_num = (
315
+ SELECT MAX(v2.version_num)
316
+ FROM artifact_versions v2
317
+ WHERE v2.artifact_id = a.artifact_id
318
+ )
319
+ {clause}
320
+ ORDER BY a.updated_at DESC LIMIT ?
321
+ """,
322
+ tuple(params) + (limit,),
323
+ )
324
+ return [Artifact.from_row(r) for r in rows]
325
+
326
+ # ------------------------------------------------------------------
327
+ # Diff
328
+ # ------------------------------------------------------------------
329
+
330
+ def diff(self, artifact_id_a: str, artifact_id_b: str) -> "ArtifactDiff":
331
+ """Return a unified diff of two artifacts' latest versions.
332
+
333
+ Raises:
334
+ TypeError: if the two artifacts have different types.
335
+ """
336
+ a = self.get(artifact_id_a)
337
+ b = self.get(artifact_id_b)
338
+ if a.type != b.type:
339
+ raise TypeError(
340
+ f"Cannot diff artifacts of different types: "
341
+ f"{a.name!r} is {a.type!r} but {b.name!r} is {b.type!r}. "
342
+ f"Use 'raw' export to compare content manually."
343
+ )
344
+ lines_a = a.content.splitlines(keepends=True)
345
+ lines_b = b.content.splitlines(keepends=True)
346
+ unified = "".join(
347
+ difflib.unified_diff(
348
+ lines_a,
349
+ lines_b,
350
+ fromfile=f"{a.name} v{a.version_num}",
351
+ tofile=f"{b.name} v{b.version_num}",
352
+ )
353
+ )
354
+ return ArtifactDiff(artifact_a=a, artifact_b=b, unified_diff=unified)
355
+
356
+ # ------------------------------------------------------------------
357
+ # Search (FTS5)
358
+ # ------------------------------------------------------------------
359
+
360
+ def search(self, query: str, limit: int = 20) -> list["Artifact"]:
361
+ """Full-text search across artifact name, description, and content.
362
+
363
+ Returns an empty list when no results match (never raises).
364
+ """
365
+ try:
366
+ # Sanitize query for FTS5: special chars like '-' are FTS5 operators
367
+ # (e.g. auth-architecture = "auth NOT architecture"). Wrap in phrase.
368
+ if re.search(r"[^\w\s]", query):
369
+ fts_query = '"' + query.replace('"', '""') + '"'
370
+ else:
371
+ fts_query = query
372
+ rows = self._db.execute_all(
373
+ """
374
+ SELECT a.*, av.version_num, av.content, av.content_hash,
375
+ av.byte_size, av.event_id, av.created_at AS version_ts
376
+ FROM artifact_fts fts
377
+ JOIN artifact_versions av ON av.rowid = fts.rowid
378
+ JOIN artifacts a ON a.artifact_id = av.artifact_id
379
+ WHERE artifact_fts MATCH ?
380
+ ORDER BY rank LIMIT ?
381
+ """,
382
+ (fts_query, limit),
383
+ )
384
+ except Exception:
385
+ return []
386
+ return [Artifact.from_row(r) for r in rows]
387
+
388
+ # ------------------------------------------------------------------
389
+ # Export
390
+ # ------------------------------------------------------------------
391
+
392
+ def export(
393
+ self,
394
+ artifact_id: str,
395
+ fmt: str = "raw",
396
+ *,
397
+ redact_secrets: bool = True,
398
+ ) -> str:
399
+ """Export artifact content with provenance header.
400
+
401
+ Args:
402
+ artifact_id: stable artifact UUID or name
403
+ fmt: "raw" (default) or "html"
404
+ redact_secrets: scan content for secret patterns (default True)
405
+
406
+ Raises:
407
+ ValueError: if fmt is not "raw" or "html".
408
+ """
409
+ artifact = self.get(artifact_id)
410
+ content = artifact.content
411
+ if redact_secrets:
412
+ content = _redact_secrets(content)
413
+
414
+ now_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
415
+ provenance = (
416
+ f"# gdm artifact export\n"
417
+ f"# artifact_id: {artifact.artifact_id}\n"
418
+ f"# name: {artifact.name}\n"
419
+ f"# type: {artifact.type}\n"
420
+ f"# version: {artifact.version_num}\n"
421
+ f"# session: {artifact.session_id or 'n/a'}\n"
422
+ f"# exported_at: {now_iso}\n"
423
+ f"# redacted: {str(redact_secrets).lower()}\n"
424
+ )
425
+
426
+ if fmt == "raw":
427
+ return provenance + "\n" + content
428
+ if fmt == "html":
429
+ return _render_html(artifact, provenance + "\n" + content)
430
+ raise ValueError(f"Unknown export format {fmt!r}. Use 'raw' or 'html'.")
431
+
432
+ # ------------------------------------------------------------------
433
+ # Helpers
434
+ # ------------------------------------------------------------------
435
+
436
+ def version_history(self, artifact_id_or_name: str) -> list["ArtifactVersion"]:
437
+ """Return all versions of an artifact ordered oldest-first."""
438
+ art = self.get(artifact_id_or_name)
439
+ rows = self._db.execute_all(
440
+ "SELECT * FROM artifact_versions"
441
+ " WHERE artifact_id=? ORDER BY version_num ASC",
442
+ (art.artifact_id,),
443
+ )
444
+ return [
445
+ ArtifactVersion(
446
+ version_id=r["version_id"],
447
+ artifact_id=r["artifact_id"],
448
+ version_num=r["version_num"],
449
+ content=r["content"],
450
+ content_hash=r["content_hash"],
451
+ byte_size=r["byte_size"],
452
+ event_id=r["event_id"],
453
+ created_at=r["created_at"],
454
+ )
455
+ for r in rows
456
+ ]
@@ -0,0 +1,75 @@
1
+ """Verification evidence graph for edit confidence."""
2
+ from __future__ import annotations
3
+ import dataclasses
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING, Any
6
+ if TYPE_CHECKING:
7
+ from src.memory.db import GdmDatabase
8
+
9
+ class EvidenceKind(str, Enum):
10
+ TEST = "TEST"; LINT = "LINT"; TYPE_CHECK = "TYPE_CHECK"; REVIEW = "REVIEW"
11
+ RUN = "RUN"; SECURITY = "SECURITY"; PERF = "PERF"; COVERAGE = "COVERAGE"
12
+
13
+ class Verdict(str, Enum):
14
+ PASS = "PASS"; FAIL = "FAIL"; SKIPPED = "SKIPPED"; PENDING = "PENDING"; WARNING = "WARNING"
15
+
16
+ _DEFAULT_REQUIRED: frozenset = frozenset({EvidenceKind.TEST, EvidenceKind.LINT})
17
+ _SAFE: frozenset = frozenset({Verdict.PASS, Verdict.SKIPPED})
18
+
19
+ @dataclasses.dataclass
20
+ class EditNode:
21
+ node_id: str; session_id: str; turn_index: int; file_path: str; patch_ref: Any; created_at: Any
22
+
23
+ @dataclasses.dataclass
24
+ class EvidenceNode:
25
+ evidence_id: str; node_id: str; kind: EvidenceKind; verdict: Verdict
26
+ detail: Any; tool: str | None; duration_ms: int | None; created_at: Any
27
+
28
+ class VerificationGraph:
29
+ def __init__(self, db: "GdmDatabase") -> None:
30
+ self._db = db
31
+
32
+ def add_edit(self, session_id, turn_index, file_path, *, patch_ref=None, depends_on=None):
33
+ n = self._db.add_edit_node(session_id=session_id, turn_index=turn_index,
34
+ file_path=file_path, patch_ref=patch_ref)
35
+ if depends_on:
36
+ seen: set = set()
37
+ for pid in depends_on:
38
+ if pid not in seen:
39
+ self._db.add_graph_edge(pid, n.node_id); seen.add(pid)
40
+ return n
41
+
42
+ def add_evidence(self, node_id, kind, verdict, *, detail=None, tool=None, duration_ms=None):
43
+ return self._db.add_evidence_node(node_id=node_id, kind=kind, verdict=verdict,
44
+ detail=detail, tool=tool, duration_ms=duration_ms)
45
+
46
+ def verdict_summary(self, node_id):
47
+ latest = {}
48
+ for ev in self._db.get_evidence_for_node(node_id):
49
+ latest[ev.kind] = ev.verdict
50
+ return {k: latest.get(k, Verdict.PENDING) for k in EvidenceKind}
51
+
52
+ def is_safe_to_build_on(self, node_id, required_kinds=None):
53
+ rk = required_kinds if required_kinds is not None else _DEFAULT_REQUIRED
54
+ s = self.verdict_summary(node_id)
55
+ return all(s.get(k, Verdict.PENDING) in _SAFE for k in rk)
56
+
57
+ def check_edit_preconditions(self, session_id, file_path, required_kinds=None):
58
+ return [n.node_id
59
+ for n in self._db.get_edit_nodes_for_session_file(session_id, file_path)
60
+ if not self.is_safe_to_build_on(n.node_id, required_kinds=required_kinds)]
61
+
62
+ def invalidate_dependents(self, node_id, reason=None):
63
+ detail = {"reason": reason} if reason else {"source": node_id}
64
+ visited, queue, affected = {node_id}, [node_id], []
65
+ while queue:
66
+ for dep in self._db.get_dependents(queue.pop()):
67
+ if dep not in visited:
68
+ visited.add(dep)
69
+ self._db.add_evidence_node(node_id=dep, kind=EvidenceKind.TEST,
70
+ verdict=Verdict.FAIL, detail=detail)
71
+ affected.append(dep); queue.append(dep)
72
+ return affected
73
+
74
+ def get_node(self, node_id):
75
+ return self._db.get_edit_node(node_id)