foundry-mcp 0.3.3__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 (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +123 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2081 @@
1
+ """
2
+ Validation operations for SDD spec files.
3
+ Provides spec validation, auto-fix capabilities, and statistics.
4
+
5
+ Security Note:
6
+ This module uses size limits from foundry_mcp.core.security to protect
7
+ against resource exhaustion attacks. See docs/mcp_best_practices/04-validation-input-hygiene.md
8
+ """
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Callable
13
+ import json
14
+ import re
15
+ import copy
16
+ from datetime import datetime, timezone
17
+
18
+ from foundry_mcp.core.security import (
19
+ MAX_INPUT_SIZE,
20
+ MAX_ARRAY_LENGTH,
21
+ MAX_STRING_LENGTH,
22
+ MAX_NESTED_DEPTH,
23
+ )
24
+
25
+
26
+ # Validation result data structures
27
+
28
+
29
+ @dataclass
30
+ class Diagnostic:
31
+ """
32
+ Structured diagnostic for MCP consumption.
33
+
34
+ Provides a machine-readable format for validation findings
35
+ that can be easily processed by MCP tools.
36
+ """
37
+
38
+ code: str # Diagnostic code (e.g., "MISSING_FILE_PATH", "INVALID_STATUS")
39
+ message: str # Human-readable description
40
+ severity: str # "error", "warning", "info"
41
+ category: str # Category for grouping (e.g., "metadata", "structure", "counts")
42
+ location: Optional[str] = None # Node ID or path where issue occurred
43
+ suggested_fix: Optional[str] = None # Suggested fix description
44
+ auto_fixable: bool = False # Whether this can be auto-fixed
45
+
46
+
47
+ @dataclass
48
+ class ValidationResult:
49
+ """
50
+ Complete validation result for a spec file.
51
+ """
52
+
53
+ spec_id: str
54
+ is_valid: bool
55
+ diagnostics: List[Diagnostic] = field(default_factory=list)
56
+ error_count: int = 0
57
+ warning_count: int = 0
58
+ info_count: int = 0
59
+
60
+
61
+ @dataclass
62
+ class FixAction:
63
+ """
64
+ Represents a candidate auto-fix operation.
65
+ """
66
+
67
+ id: str
68
+ description: str
69
+ category: str
70
+ severity: str
71
+ auto_apply: bool
72
+ preview: str
73
+ apply: Callable[[Dict[str, Any]], None]
74
+
75
+
76
+ @dataclass
77
+ class FixReport:
78
+ """
79
+ Outcome of applying a set of fix actions.
80
+ """
81
+
82
+ spec_path: Optional[str] = None
83
+ backup_path: Optional[str] = None
84
+ applied_actions: List[FixAction] = field(default_factory=list)
85
+ skipped_actions: List[FixAction] = field(default_factory=list)
86
+ before_state: Optional[Dict[str, Any]] = None
87
+ after_state: Optional[Dict[str, Any]] = None
88
+
89
+
90
+ @dataclass
91
+ class SpecStats:
92
+ """
93
+ Statistics for a spec file.
94
+ """
95
+
96
+ spec_id: str
97
+ title: str
98
+ version: str
99
+ status: str
100
+ totals: Dict[str, int] = field(default_factory=dict)
101
+ status_counts: Dict[str, int] = field(default_factory=dict)
102
+ max_depth: int = 0
103
+ avg_tasks_per_phase: float = 0.0
104
+ verification_coverage: float = 0.0
105
+ progress: float = 0.0
106
+ file_size_kb: float = 0.0
107
+
108
+
109
+ # Constants
110
+
111
+ STATUS_FIELDS = {"pending", "in_progress", "completed", "blocked"}
112
+ VALID_NODE_TYPES = {"spec", "phase", "group", "task", "subtask", "verify"}
113
+ VALID_STATUSES = {"pending", "in_progress", "completed", "blocked"}
114
+ VALID_TASK_CATEGORIES = {
115
+ "investigation",
116
+ "implementation",
117
+ "refactoring",
118
+ "decision",
119
+ "research",
120
+ }
121
+ VALID_VERIFICATION_TYPES = {"test", "fidelity"}
122
+
123
+
124
+ # Validation functions
125
+
126
+
127
+ def validate_spec_input(
128
+ raw_input: str | bytes,
129
+ *,
130
+ max_size: Optional[int] = None,
131
+ ) -> tuple[Optional[Dict[str, Any]], Optional[ValidationResult]]:
132
+ """
133
+ Validate and parse raw spec input with size checks.
134
+
135
+ Performs size validation before JSON parsing to prevent resource
136
+ exhaustion attacks from oversized payloads.
137
+
138
+ Args:
139
+ raw_input: Raw JSON string or bytes to validate
140
+ max_size: Maximum allowed size in bytes (default: MAX_INPUT_SIZE)
141
+
142
+ Returns:
143
+ Tuple of (parsed_data, error_result):
144
+ - On success: (dict, None)
145
+ - On failure: (None, ValidationResult with error)
146
+
147
+ Example:
148
+ >>> spec_data, error = validate_spec_input(json_string)
149
+ >>> if error:
150
+ ... return error_response(error.diagnostics[0].message)
151
+ >>> result = validate_spec(spec_data)
152
+ """
153
+ effective_max_size = max_size if max_size is not None else MAX_INPUT_SIZE
154
+
155
+ # Convert to bytes if string for consistent size checking
156
+ if isinstance(raw_input, str):
157
+ input_bytes = raw_input.encode("utf-8")
158
+ else:
159
+ input_bytes = raw_input
160
+
161
+ # Check input size
162
+ if len(input_bytes) > effective_max_size:
163
+ error_result = ValidationResult(
164
+ spec_id="unknown",
165
+ is_valid=False,
166
+ error_count=1,
167
+ )
168
+ error_result.diagnostics.append(
169
+ Diagnostic(
170
+ code="INPUT_TOO_LARGE",
171
+ message=f"Input size ({len(input_bytes):,} bytes) exceeds maximum allowed ({effective_max_size:,} bytes)",
172
+ severity="error",
173
+ category="security",
174
+ suggested_fix=f"Reduce input size to under {effective_max_size:,} bytes",
175
+ )
176
+ )
177
+ return None, error_result
178
+
179
+ # Try to parse JSON
180
+ try:
181
+ if isinstance(raw_input, bytes):
182
+ spec_data = json.loads(raw_input.decode("utf-8"))
183
+ else:
184
+ spec_data = json.loads(raw_input)
185
+ except json.JSONDecodeError as e:
186
+ error_result = ValidationResult(
187
+ spec_id="unknown",
188
+ is_valid=False,
189
+ error_count=1,
190
+ )
191
+ error_result.diagnostics.append(
192
+ Diagnostic(
193
+ code="INVALID_JSON",
194
+ message=f"Failed to parse JSON: {e}",
195
+ severity="error",
196
+ category="structure",
197
+ )
198
+ )
199
+ return None, error_result
200
+
201
+ # Spec data must be a dict
202
+ if not isinstance(spec_data, dict):
203
+ error_result = ValidationResult(
204
+ spec_id="unknown",
205
+ is_valid=False,
206
+ error_count=1,
207
+ )
208
+ error_result.diagnostics.append(
209
+ Diagnostic(
210
+ code="INVALID_SPEC_TYPE",
211
+ message=f"Spec must be a JSON object, got {type(spec_data).__name__}",
212
+ severity="error",
213
+ category="structure",
214
+ )
215
+ )
216
+ return None, error_result
217
+
218
+ return spec_data, None
219
+
220
+
221
+ def validate_spec(spec_data: Dict[str, Any]) -> ValidationResult:
222
+ """
223
+ Validate a spec file and return structured diagnostics.
224
+
225
+ Args:
226
+ spec_data: Parsed JSON spec data
227
+
228
+ Returns:
229
+ ValidationResult with all diagnostics
230
+
231
+ Note:
232
+ For raw JSON input, use validate_spec_input() first to perform
233
+ size validation before parsing.
234
+ """
235
+ spec_id = spec_data.get("spec_id", "unknown")
236
+ result = ValidationResult(spec_id=spec_id, is_valid=True)
237
+
238
+ # Check overall structure size (defense in depth)
239
+ _validate_size_limits(spec_data, result)
240
+
241
+ # Run all validation checks
242
+ _validate_structure(spec_data, result)
243
+
244
+ hierarchy = spec_data.get("hierarchy", {})
245
+ if hierarchy:
246
+ _validate_hierarchy(hierarchy, result)
247
+ _validate_nodes(hierarchy, result)
248
+ _validate_task_counts(hierarchy, result)
249
+ _validate_dependencies(hierarchy, result)
250
+ _validate_metadata(hierarchy, result)
251
+
252
+ # Count diagnostics by severity
253
+ for diag in result.diagnostics:
254
+ if diag.severity == "error":
255
+ result.error_count += 1
256
+ elif diag.severity == "warning":
257
+ result.warning_count += 1
258
+ else:
259
+ result.info_count += 1
260
+
261
+ result.is_valid = result.error_count == 0
262
+ return result
263
+
264
+
265
+ def _iter_valid_nodes(
266
+ hierarchy: Dict[str, Any],
267
+ result: ValidationResult,
268
+ report_invalid: bool = True,
269
+ ):
270
+ """
271
+ Iterate over hierarchy yielding only valid (dict) nodes.
272
+
273
+ Args:
274
+ hierarchy: The hierarchy dict to iterate
275
+ result: ValidationResult to append errors to
276
+ report_invalid: Whether to report invalid nodes as errors (default True,
277
+ set False if already reported by another function)
278
+
279
+ Yields:
280
+ Tuples of (node_id, node) where node is a valid dict
281
+ """
282
+ for node_id, node in hierarchy.items():
283
+ if not isinstance(node, dict):
284
+ if report_invalid:
285
+ result.diagnostics.append(
286
+ Diagnostic(
287
+ code="INVALID_NODE_STRUCTURE",
288
+ message=f"Node '{node_id}' is not a valid object (got {type(node).__name__})",
289
+ severity="error",
290
+ category="node",
291
+ location=str(node_id),
292
+ suggested_fix="Ensure all hierarchy values are valid node objects",
293
+ )
294
+ )
295
+ continue
296
+ yield node_id, node
297
+
298
+
299
+ def _validate_size_limits(spec_data: Dict[str, Any], result: ValidationResult) -> None:
300
+ """Validate size limits on spec data structures (defense in depth)."""
301
+
302
+ def count_items(obj: Any, depth: int = 0) -> tuple[int, int]:
303
+ """Count total items and max depth in nested structure."""
304
+ if depth > MAX_NESTED_DEPTH:
305
+ return 0, depth
306
+
307
+ if isinstance(obj, dict):
308
+ total = len(obj)
309
+ max_d = depth
310
+ for v in obj.values():
311
+ sub_count, sub_depth = count_items(v, depth + 1)
312
+ total += sub_count
313
+ max_d = max(max_d, sub_depth)
314
+ return total, max_d
315
+ elif isinstance(obj, list):
316
+ total = len(obj)
317
+ max_d = depth
318
+ for item in obj:
319
+ sub_count, sub_depth = count_items(item, depth + 1)
320
+ total += sub_count
321
+ max_d = max(max_d, sub_depth)
322
+ return total, max_d
323
+ else:
324
+ return 1, depth
325
+
326
+ # Check hierarchy nesting depth
327
+ hierarchy = spec_data.get("hierarchy", {})
328
+ if hierarchy:
329
+ _, max_depth = count_items(hierarchy)
330
+ if max_depth > MAX_NESTED_DEPTH:
331
+ result.diagnostics.append(
332
+ Diagnostic(
333
+ code="EXCESSIVE_NESTING",
334
+ message=f"Hierarchy nesting depth ({max_depth}) exceeds maximum ({MAX_NESTED_DEPTH})",
335
+ severity="warning",
336
+ category="security",
337
+ suggested_fix="Flatten hierarchy structure to reduce nesting depth",
338
+ )
339
+ )
340
+
341
+ # Check array lengths in common locations
342
+ children = hierarchy.get("children", [])
343
+ if len(children) > MAX_ARRAY_LENGTH:
344
+ result.diagnostics.append(
345
+ Diagnostic(
346
+ code="EXCESSIVE_ARRAY_LENGTH",
347
+ message=f"Root children array ({len(children)} items) exceeds maximum ({MAX_ARRAY_LENGTH})",
348
+ severity="warning",
349
+ category="security",
350
+ location="hierarchy.children",
351
+ suggested_fix="Split large phase/task lists into smaller groups",
352
+ )
353
+ )
354
+
355
+ # Check journal array length
356
+ journal = spec_data.get("journal", [])
357
+ if len(journal) > MAX_ARRAY_LENGTH:
358
+ result.diagnostics.append(
359
+ Diagnostic(
360
+ code="EXCESSIVE_JOURNAL_LENGTH",
361
+ message=f"Journal array ({len(journal)} entries) exceeds maximum ({MAX_ARRAY_LENGTH})",
362
+ severity="warning",
363
+ category="security",
364
+ location="journal",
365
+ suggested_fix="Archive old journal entries or split into separate files",
366
+ )
367
+ )
368
+
369
+
370
+ def _validate_structure(spec_data: Dict[str, Any], result: ValidationResult) -> None:
371
+ """Validate top-level structure and required fields."""
372
+ required_fields = ["spec_id", "generated", "last_updated", "hierarchy"]
373
+
374
+ for field_name in required_fields:
375
+ if field_name not in spec_data:
376
+ result.diagnostics.append(
377
+ Diagnostic(
378
+ code="MISSING_REQUIRED_FIELD",
379
+ message=f"Missing required field '{field_name}'",
380
+ severity="error",
381
+ category="structure",
382
+ suggested_fix=f"Add required field '{field_name}' to spec",
383
+ auto_fixable=False,
384
+ )
385
+ )
386
+
387
+ # Validate spec_id format
388
+ spec_id = spec_data.get("spec_id")
389
+ if spec_id and not _is_valid_spec_id(spec_id):
390
+ result.diagnostics.append(
391
+ Diagnostic(
392
+ code="INVALID_SPEC_ID_FORMAT",
393
+ message=f"spec_id '{spec_id}' doesn't follow format: {{feature}}-{{YYYY-MM-DD}}-{{nnn}}",
394
+ severity="warning",
395
+ category="structure",
396
+ location="spec_id",
397
+ )
398
+ )
399
+
400
+ # Validate date fields
401
+ for field_name in ["generated", "last_updated"]:
402
+ value = spec_data.get(field_name)
403
+ if value and not _is_valid_iso8601(value):
404
+ result.diagnostics.append(
405
+ Diagnostic(
406
+ code="INVALID_DATE_FORMAT",
407
+ message=f"'{field_name}' should be in ISO 8601 format",
408
+ severity="warning",
409
+ category="structure",
410
+ location=field_name,
411
+ suggested_fix="Normalize timestamp to ISO 8601 format",
412
+ auto_fixable=True,
413
+ )
414
+ )
415
+
416
+ # Check hierarchy is dict
417
+ hierarchy = spec_data.get("hierarchy")
418
+ if hierarchy is not None and not isinstance(hierarchy, dict):
419
+ result.diagnostics.append(
420
+ Diagnostic(
421
+ code="INVALID_HIERARCHY_TYPE",
422
+ message="'hierarchy' must be a dictionary",
423
+ severity="error",
424
+ category="structure",
425
+ )
426
+ )
427
+ elif hierarchy is not None and len(hierarchy) == 0:
428
+ result.diagnostics.append(
429
+ Diagnostic(
430
+ code="EMPTY_HIERARCHY",
431
+ message="'hierarchy' is empty",
432
+ severity="error",
433
+ category="structure",
434
+ )
435
+ )
436
+
437
+
438
+ def _validate_hierarchy(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
439
+ """Validate hierarchy integrity: parent/child references, no orphans, no cycles."""
440
+ # Check spec-root exists
441
+ if "spec-root" not in hierarchy:
442
+ result.diagnostics.append(
443
+ Diagnostic(
444
+ code="MISSING_SPEC_ROOT",
445
+ message="Missing 'spec-root' node in hierarchy",
446
+ severity="error",
447
+ category="hierarchy",
448
+ )
449
+ )
450
+ return
451
+
452
+ root = hierarchy["spec-root"]
453
+ if root.get("parent") is not None:
454
+ result.diagnostics.append(
455
+ Diagnostic(
456
+ code="INVALID_ROOT_PARENT",
457
+ message="'spec-root' must have parent: null",
458
+ severity="error",
459
+ category="hierarchy",
460
+ location="spec-root",
461
+ )
462
+ )
463
+
464
+ # Validate parent references
465
+ for node_id, node in _iter_valid_nodes(hierarchy, result):
466
+ parent_id = node.get("parent")
467
+
468
+ if node_id != "spec-root" and parent_id is None:
469
+ result.diagnostics.append(
470
+ Diagnostic(
471
+ code="NULL_PARENT",
472
+ message=f"Node '{node_id}' has null parent (only spec-root should)",
473
+ severity="error",
474
+ category="hierarchy",
475
+ location=node_id,
476
+ )
477
+ )
478
+
479
+ if parent_id and parent_id not in hierarchy:
480
+ result.diagnostics.append(
481
+ Diagnostic(
482
+ code="MISSING_PARENT",
483
+ message=f"Node '{node_id}' references non-existent parent '{parent_id}'",
484
+ severity="error",
485
+ category="hierarchy",
486
+ location=node_id,
487
+ )
488
+ )
489
+
490
+ # Validate child references
491
+ for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
492
+ children = node.get("children", [])
493
+
494
+ if not isinstance(children, list):
495
+ result.diagnostics.append(
496
+ Diagnostic(
497
+ code="INVALID_CHILDREN_TYPE",
498
+ message=f"Node '{node_id}' children field must be a list",
499
+ severity="error",
500
+ category="hierarchy",
501
+ location=node_id,
502
+ )
503
+ )
504
+ continue
505
+
506
+ for child_id in children:
507
+ if child_id not in hierarchy:
508
+ result.diagnostics.append(
509
+ Diagnostic(
510
+ code="MISSING_CHILD",
511
+ message=f"Node '{node_id}' references non-existent child '{child_id}'",
512
+ severity="error",
513
+ category="hierarchy",
514
+ location=node_id,
515
+ )
516
+ )
517
+ else:
518
+ child_node = hierarchy[child_id]
519
+ if child_node.get("parent") != node_id:
520
+ result.diagnostics.append(
521
+ Diagnostic(
522
+ code="PARENT_CHILD_MISMATCH",
523
+ message=f"'{node_id}' lists '{child_id}' as child, but '{child_id}' has parent='{child_node.get('parent')}'",
524
+ severity="error",
525
+ category="hierarchy",
526
+ location=node_id,
527
+ suggested_fix="Align parent references with children list",
528
+ auto_fixable=True,
529
+ )
530
+ )
531
+
532
+ # Check for orphaned nodes
533
+ reachable = set()
534
+
535
+ def traverse(node_id: str) -> None:
536
+ if node_id in reachable:
537
+ return
538
+ reachable.add(node_id)
539
+ node = hierarchy.get(node_id, {})
540
+ for child_id in node.get("children", []):
541
+ if child_id in hierarchy:
542
+ traverse(child_id)
543
+
544
+ traverse("spec-root")
545
+
546
+ orphaned = set(hierarchy.keys()) - reachable
547
+ if orphaned:
548
+ orphan_list = ", ".join(sorted(orphaned))
549
+ result.diagnostics.append(
550
+ Diagnostic(
551
+ code="ORPHANED_NODES",
552
+ message=f"Found {len(orphaned)} orphaned node(s) not reachable from spec-root: {orphan_list}",
553
+ severity="error",
554
+ category="hierarchy",
555
+ suggested_fix="Attach orphaned nodes to spec-root or remove them",
556
+ auto_fixable=True,
557
+ )
558
+ )
559
+
560
+ # Check for cycles
561
+ visited = set()
562
+ rec_stack = set()
563
+
564
+ def has_cycle(node_id: str) -> bool:
565
+ visited.add(node_id)
566
+ rec_stack.add(node_id)
567
+
568
+ node = hierarchy.get(node_id, {})
569
+ for child_id in node.get("children", []):
570
+ if child_id not in visited:
571
+ if has_cycle(child_id):
572
+ return True
573
+ elif child_id in rec_stack:
574
+ return True
575
+
576
+ rec_stack.remove(node_id)
577
+ return False
578
+
579
+ if has_cycle("spec-root"):
580
+ result.diagnostics.append(
581
+ Diagnostic(
582
+ code="CYCLE_DETECTED",
583
+ message="Cycle detected in hierarchy tree",
584
+ severity="error",
585
+ category="hierarchy",
586
+ )
587
+ )
588
+
589
+
590
+ def _validate_nodes(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
591
+ """Validate node structure and required fields."""
592
+ required_fields = [
593
+ "type",
594
+ "title",
595
+ "status",
596
+ "parent",
597
+ "children",
598
+ "total_tasks",
599
+ "completed_tasks",
600
+ "metadata",
601
+ ]
602
+
603
+ for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
604
+ # Check required fields
605
+ for field_name in required_fields:
606
+ if field_name not in node:
607
+ result.diagnostics.append(
608
+ Diagnostic(
609
+ code="MISSING_NODE_FIELD",
610
+ message=f"Node '{node_id}' missing required field '{field_name}'",
611
+ severity="error",
612
+ category="node",
613
+ location=node_id,
614
+ suggested_fix="Add missing required fields with sensible defaults",
615
+ auto_fixable=True,
616
+ )
617
+ )
618
+
619
+ # Validate type
620
+ node_type = node.get("type")
621
+ if node_type and node_type not in VALID_NODE_TYPES:
622
+ result.diagnostics.append(
623
+ Diagnostic(
624
+ code="INVALID_NODE_TYPE",
625
+ message=f"Node '{node_id}' has invalid type '{node_type}'",
626
+ severity="error",
627
+ category="node",
628
+ location=node_id,
629
+ suggested_fix="Normalize node type to valid value",
630
+ auto_fixable=True,
631
+ )
632
+ )
633
+
634
+ # Validate status
635
+ status = node.get("status")
636
+ if status and status not in VALID_STATUSES:
637
+ result.diagnostics.append(
638
+ Diagnostic(
639
+ code="INVALID_STATUS",
640
+ message=f"Node '{node_id}' has invalid status '{status}'",
641
+ severity="error",
642
+ category="node",
643
+ location=node_id,
644
+ suggested_fix="Normalize status to pending/in_progress/completed/blocked",
645
+ auto_fixable=True,
646
+ )
647
+ )
648
+
649
+ # Check title is not empty
650
+ title = node.get("title")
651
+ if title is not None and not str(title).strip():
652
+ result.diagnostics.append(
653
+ Diagnostic(
654
+ code="EMPTY_TITLE",
655
+ message=f"Node '{node_id}' has empty title",
656
+ severity="warning",
657
+ category="node",
658
+ location=node_id,
659
+ suggested_fix="Generate title from node ID",
660
+ auto_fixable=True,
661
+ )
662
+ )
663
+
664
+ # Validate dependencies structure
665
+ if "dependencies" in node:
666
+ deps = node["dependencies"]
667
+ if not isinstance(deps, dict):
668
+ result.diagnostics.append(
669
+ Diagnostic(
670
+ code="INVALID_DEPENDENCIES_TYPE",
671
+ message=f"Node '{node_id}' dependencies must be a dictionary",
672
+ severity="error",
673
+ category="dependency",
674
+ location=node_id,
675
+ suggested_fix="Create dependencies dict with blocks/blocked_by/depends arrays",
676
+ auto_fixable=True,
677
+ )
678
+ )
679
+ else:
680
+ for dep_key in ["blocks", "blocked_by", "depends"]:
681
+ if dep_key in deps and not isinstance(deps[dep_key], list):
682
+ result.diagnostics.append(
683
+ Diagnostic(
684
+ code="INVALID_DEPENDENCY_FIELD",
685
+ message=f"Node '{node_id}' dependencies.{dep_key} must be a list",
686
+ severity="error",
687
+ category="dependency",
688
+ location=node_id,
689
+ )
690
+ )
691
+
692
+
693
+ def _validate_task_counts(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
694
+ """Validate task count accuracy and propagation."""
695
+ for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
696
+ total_tasks = node.get("total_tasks", 0)
697
+ completed_tasks = node.get("completed_tasks", 0)
698
+ children = node.get("children", [])
699
+
700
+ # Completed can't exceed total
701
+ if completed_tasks > total_tasks:
702
+ result.diagnostics.append(
703
+ Diagnostic(
704
+ code="COMPLETED_EXCEEDS_TOTAL",
705
+ message=f"Node '{node_id}' has completed_tasks ({completed_tasks}) > total_tasks ({total_tasks})",
706
+ severity="error",
707
+ category="counts",
708
+ location=node_id,
709
+ suggested_fix="Recalculate total/completed task rollups for parent nodes",
710
+ auto_fixable=True,
711
+ )
712
+ )
713
+
714
+ # If node has children, verify counts match sum
715
+ if children:
716
+ child_total = 0
717
+ child_completed = 0
718
+
719
+ for child_id in children:
720
+ if child_id in hierarchy:
721
+ child_node = hierarchy[child_id]
722
+ child_total += child_node.get("total_tasks", 0)
723
+ child_completed += child_node.get("completed_tasks", 0)
724
+
725
+ if total_tasks != child_total:
726
+ result.diagnostics.append(
727
+ Diagnostic(
728
+ code="TOTAL_TASKS_MISMATCH",
729
+ message=f"Node '{node_id}' total_tasks ({total_tasks}) doesn't match sum of children ({child_total})",
730
+ severity="error",
731
+ category="counts",
732
+ location=node_id,
733
+ suggested_fix="Recalculate total/completed task rollups",
734
+ auto_fixable=True,
735
+ )
736
+ )
737
+
738
+ if completed_tasks != child_completed:
739
+ result.diagnostics.append(
740
+ Diagnostic(
741
+ code="COMPLETED_TASKS_MISMATCH",
742
+ message=f"Node '{node_id}' completed_tasks ({completed_tasks}) doesn't match sum of children ({child_completed})",
743
+ severity="error",
744
+ category="counts",
745
+ location=node_id,
746
+ suggested_fix="Recalculate total/completed task rollups",
747
+ auto_fixable=True,
748
+ )
749
+ )
750
+ else:
751
+ # Leaf nodes should have total_tasks = 1
752
+ node_type = node.get("type")
753
+ if node_type in ["task", "subtask", "verify"]:
754
+ if total_tasks != 1:
755
+ result.diagnostics.append(
756
+ Diagnostic(
757
+ code="INVALID_LEAF_COUNT",
758
+ message=f"Leaf node '{node_id}' (type={node_type}) should have total_tasks=1, has {total_tasks}",
759
+ severity="warning",
760
+ category="counts",
761
+ location=node_id,
762
+ suggested_fix="Set leaf node total_tasks to 1",
763
+ auto_fixable=True,
764
+ )
765
+ )
766
+
767
+
768
+ def _validate_dependencies(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
769
+ """Validate dependency graph and bidirectional consistency."""
770
+ for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
771
+ if "dependencies" not in node:
772
+ continue
773
+
774
+ deps = node["dependencies"]
775
+ if not isinstance(deps, dict):
776
+ continue
777
+
778
+ # Check dependency references exist
779
+ for dep_type in ["blocks", "blocked_by", "depends"]:
780
+ if dep_type not in deps:
781
+ continue
782
+
783
+ for dep_id in deps[dep_type]:
784
+ if dep_id not in hierarchy:
785
+ result.diagnostics.append(
786
+ Diagnostic(
787
+ code="MISSING_DEPENDENCY_TARGET",
788
+ message=f"Node '{node_id}' {dep_type} references non-existent node '{dep_id}'",
789
+ severity="error",
790
+ category="dependency",
791
+ location=node_id,
792
+ )
793
+ )
794
+
795
+ # Check bidirectional consistency for blocks/blocked_by
796
+ for blocked_id in deps.get("blocks", []):
797
+ if blocked_id in hierarchy:
798
+ blocked_node = hierarchy[blocked_id]
799
+ blocked_deps = blocked_node.get("dependencies", {})
800
+ if isinstance(blocked_deps, dict):
801
+ if node_id not in blocked_deps.get("blocked_by", []):
802
+ result.diagnostics.append(
803
+ Diagnostic(
804
+ code="BIDIRECTIONAL_INCONSISTENCY",
805
+ message=f"'{node_id}' blocks '{blocked_id}', but '{blocked_id}' doesn't list '{node_id}' in blocked_by",
806
+ severity="error",
807
+ category="dependency",
808
+ location=node_id,
809
+ suggested_fix="Synchronize bidirectional dependency relationships",
810
+ auto_fixable=True,
811
+ )
812
+ )
813
+
814
+ for blocker_id in deps.get("blocked_by", []):
815
+ if blocker_id in hierarchy:
816
+ blocker_node = hierarchy[blocker_id]
817
+ blocker_deps = blocker_node.get("dependencies", {})
818
+ if isinstance(blocker_deps, dict):
819
+ if node_id not in blocker_deps.get("blocks", []):
820
+ result.diagnostics.append(
821
+ Diagnostic(
822
+ code="BIDIRECTIONAL_INCONSISTENCY",
823
+ message=f"'{node_id}' blocked_by '{blocker_id}', but '{blocker_id}' doesn't list '{node_id}' in blocks",
824
+ severity="error",
825
+ category="dependency",
826
+ location=node_id,
827
+ suggested_fix="Synchronize bidirectional dependency relationships",
828
+ auto_fixable=True,
829
+ )
830
+ )
831
+
832
+
833
+ def _validate_metadata(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
834
+ """Validate type-specific metadata requirements."""
835
+ for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
836
+ node_type = node.get("type")
837
+ metadata = node.get("metadata", {})
838
+
839
+ if not isinstance(metadata, dict):
840
+ result.diagnostics.append(
841
+ Diagnostic(
842
+ code="INVALID_METADATA_TYPE",
843
+ message=f"Node '{node_id}' metadata must be a dictionary",
844
+ severity="error",
845
+ category="metadata",
846
+ location=node_id,
847
+ )
848
+ )
849
+ continue
850
+
851
+ # Verify nodes
852
+ if node_type == "verify":
853
+ verification_type = metadata.get("verification_type")
854
+
855
+ if not verification_type:
856
+ result.diagnostics.append(
857
+ Diagnostic(
858
+ code="MISSING_VERIFICATION_TYPE",
859
+ message=f"Verify node '{node_id}' missing metadata.verification_type",
860
+ severity="error",
861
+ category="metadata",
862
+ location=node_id,
863
+ suggested_fix="Set verification_type to 'test' or 'fidelity'",
864
+ auto_fixable=True,
865
+ )
866
+ )
867
+ elif verification_type not in VALID_VERIFICATION_TYPES:
868
+ result.diagnostics.append(
869
+ Diagnostic(
870
+ code="INVALID_VERIFICATION_TYPE",
871
+ message=f"Verify node '{node_id}' verification_type must be 'test' or 'fidelity'",
872
+ severity="error",
873
+ category="metadata",
874
+ location=node_id,
875
+ )
876
+ )
877
+
878
+ # Task nodes
879
+ if node_type == "task":
880
+ task_category = metadata.get("task_category", "implementation")
881
+
882
+ if (
883
+ "task_category" in metadata
884
+ and task_category not in VALID_TASK_CATEGORIES
885
+ ):
886
+ result.diagnostics.append(
887
+ Diagnostic(
888
+ code="INVALID_TASK_CATEGORY",
889
+ message=f"Task node '{node_id}' has invalid task_category '{task_category}'",
890
+ severity="error",
891
+ category="metadata",
892
+ location=node_id,
893
+ suggested_fix=f"Set task_category to one of: {', '.join(VALID_TASK_CATEGORIES)}",
894
+ auto_fixable=True,
895
+ )
896
+ )
897
+
898
+ # file_path required for implementation and refactoring
899
+ if task_category in ["implementation", "refactoring"]:
900
+ if "file_path" not in metadata:
901
+ result.diagnostics.append(
902
+ Diagnostic(
903
+ code="MISSING_FILE_PATH",
904
+ message=f"Task node '{node_id}' with category '{task_category}' missing metadata.file_path",
905
+ severity="error",
906
+ category="metadata",
907
+ location=node_id,
908
+ suggested_fix="Add metadata.file_path for implementation tasks",
909
+ auto_fixable=True,
910
+ )
911
+ )
912
+
913
+
914
+ # Fix action functions
915
+
916
+
917
+ def get_fix_actions(
918
+ result: ValidationResult, spec_data: Dict[str, Any]
919
+ ) -> List[FixAction]:
920
+ """
921
+ Generate fix actions from validation diagnostics.
922
+
923
+ Args:
924
+ result: ValidationResult with diagnostics
925
+ spec_data: Original spec data
926
+
927
+ Returns:
928
+ List of FixAction objects that can be applied
929
+ """
930
+ actions: List[FixAction] = []
931
+ seen_ids = set()
932
+ hierarchy = spec_data.get("hierarchy", {})
933
+
934
+ for diag in result.diagnostics:
935
+ if not diag.auto_fixable:
936
+ continue
937
+
938
+ action = _build_fix_action(diag, spec_data, hierarchy)
939
+ if action and action.id not in seen_ids:
940
+ actions.append(action)
941
+ seen_ids.add(action.id)
942
+
943
+ return actions
944
+
945
+
946
+ def _build_fix_action(
947
+ diag: Diagnostic, spec_data: Dict[str, Any], hierarchy: Dict[str, Any]
948
+ ) -> Optional[FixAction]:
949
+ """Build a fix action for a diagnostic."""
950
+ code = diag.code
951
+
952
+ if code == "INVALID_DATE_FORMAT":
953
+ return _build_date_fix(diag, spec_data)
954
+
955
+ if code == "PARENT_CHILD_MISMATCH":
956
+ return _build_hierarchy_align_fix(diag, hierarchy)
957
+
958
+ if code == "ORPHANED_NODES":
959
+ return _build_orphan_fix(diag, hierarchy)
960
+
961
+ if code == "MISSING_NODE_FIELD":
962
+ return _build_missing_fields_fix(diag, hierarchy)
963
+
964
+ if code == "INVALID_NODE_TYPE":
965
+ return _build_type_normalize_fix(diag, hierarchy)
966
+
967
+ if code == "INVALID_STATUS":
968
+ return _build_status_normalize_fix(diag, hierarchy)
969
+
970
+ if code == "EMPTY_TITLE":
971
+ return _build_title_generate_fix(diag, hierarchy)
972
+
973
+ if code in [
974
+ "TOTAL_TASKS_MISMATCH",
975
+ "COMPLETED_TASKS_MISMATCH",
976
+ "COMPLETED_EXCEEDS_TOTAL",
977
+ "INVALID_LEAF_COUNT",
978
+ ]:
979
+ return _build_counts_fix(diag, spec_data)
980
+
981
+ if code == "BIDIRECTIONAL_INCONSISTENCY":
982
+ return _build_bidirectional_fix(diag, hierarchy)
983
+
984
+ if code == "INVALID_DEPENDENCIES_TYPE":
985
+ return _build_deps_structure_fix(diag, hierarchy)
986
+
987
+ if code == "MISSING_VERIFICATION_TYPE":
988
+ return _build_verification_type_fix(diag, hierarchy)
989
+
990
+ if code == "MISSING_FILE_PATH":
991
+ return _build_file_path_fix(diag, hierarchy)
992
+
993
+ if code == "INVALID_TASK_CATEGORY":
994
+ return _build_task_category_fix(diag, hierarchy)
995
+
996
+ return None
997
+
998
+
999
+ def _build_date_fix(diag: Diagnostic, spec_data: Dict[str, Any]) -> Optional[FixAction]:
1000
+ """Build fix for date normalization."""
1001
+ field_name = diag.location
1002
+ if not field_name:
1003
+ return None
1004
+
1005
+ def apply(data: Dict[str, Any]) -> None:
1006
+ value = data.get(field_name)
1007
+ normalized = _normalize_timestamp(value)
1008
+ if normalized:
1009
+ data[field_name] = normalized
1010
+
1011
+ return FixAction(
1012
+ id=f"date.normalize:{field_name}",
1013
+ description=f"Normalize {field_name} to ISO 8601",
1014
+ category="structure",
1015
+ severity=diag.severity,
1016
+ auto_apply=True,
1017
+ preview=f"Normalize timestamp field: {field_name}",
1018
+ apply=apply,
1019
+ )
1020
+
1021
+
1022
+ def _build_hierarchy_align_fix(
1023
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1024
+ ) -> Optional[FixAction]:
1025
+ """Build fix for parent/child alignment."""
1026
+ # Parse node IDs from message
1027
+ match = re.search(r"'([^']+)' lists '([^']+)' as child", diag.message)
1028
+ if not match:
1029
+ return None
1030
+
1031
+ parent_id = match.group(1)
1032
+ child_id = match.group(2)
1033
+
1034
+ def apply(data: Dict[str, Any]) -> None:
1035
+ hier = data.get("hierarchy", {})
1036
+ parent = hier.get(parent_id)
1037
+ child = hier.get(child_id)
1038
+ if parent and child:
1039
+ children = parent.setdefault("children", [])
1040
+ if child_id not in children:
1041
+ children.append(child_id)
1042
+ child["parent"] = parent_id
1043
+
1044
+ return FixAction(
1045
+ id=f"hierarchy.align:{parent_id}->{child_id}",
1046
+ description=f"Align {child_id} parent reference with {parent_id}",
1047
+ category="hierarchy",
1048
+ severity=diag.severity,
1049
+ auto_apply=True,
1050
+ preview=f"Align {child_id} parent reference with {parent_id}",
1051
+ apply=apply,
1052
+ )
1053
+
1054
+
1055
+ def _build_orphan_fix(
1056
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1057
+ ) -> Optional[FixAction]:
1058
+ """Build fix for orphaned nodes."""
1059
+ match = re.search(r"not reachable from spec-root:\s*(.+)$", diag.message)
1060
+ if not match:
1061
+ return None
1062
+
1063
+ orphan_list_str = match.group(1)
1064
+ orphan_ids = [nid.strip() for nid in orphan_list_str.split(",")]
1065
+
1066
+ def apply(data: Dict[str, Any]) -> None:
1067
+ hier = data.get("hierarchy", {})
1068
+ spec_root = hier.get("spec-root")
1069
+ if not spec_root:
1070
+ return
1071
+
1072
+ root_children = spec_root.setdefault("children", [])
1073
+ for orphan_id in orphan_ids:
1074
+ if orphan_id in hier:
1075
+ hier[orphan_id]["parent"] = "spec-root"
1076
+ if orphan_id not in root_children:
1077
+ root_children.append(orphan_id)
1078
+
1079
+ return FixAction(
1080
+ id=f"hierarchy.attach_orphans:{len(orphan_ids)}",
1081
+ description=f"Attach {len(orphan_ids)} orphaned node(s) to spec-root",
1082
+ category="hierarchy",
1083
+ severity=diag.severity,
1084
+ auto_apply=True,
1085
+ preview=f"Attach {len(orphan_ids)} orphaned node(s) to spec-root",
1086
+ apply=apply,
1087
+ )
1088
+
1089
+
1090
+ def _build_missing_fields_fix(
1091
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1092
+ ) -> Optional[FixAction]:
1093
+ """Build fix for missing node fields."""
1094
+ node_id = diag.location
1095
+ if not node_id:
1096
+ return None
1097
+
1098
+ def apply(data: Dict[str, Any]) -> None:
1099
+ hier = data.get("hierarchy", {})
1100
+ node = hier.get(node_id)
1101
+ if not node:
1102
+ return
1103
+
1104
+ if "type" not in node:
1105
+ node["type"] = "task"
1106
+ if "title" not in node:
1107
+ node["title"] = node_id.replace("-", " ").title()
1108
+ if "status" not in node:
1109
+ node["status"] = "pending"
1110
+ if "parent" not in node:
1111
+ node["parent"] = "spec-root"
1112
+ if "children" not in node:
1113
+ node["children"] = []
1114
+ if "total_tasks" not in node:
1115
+ node["total_tasks"] = (
1116
+ 1 if node.get("type") in {"task", "subtask", "verify"} else 0
1117
+ )
1118
+ if "completed_tasks" not in node:
1119
+ node["completed_tasks"] = 0
1120
+ if "metadata" not in node:
1121
+ node["metadata"] = {}
1122
+
1123
+ return FixAction(
1124
+ id=f"node.add_missing_fields:{node_id}",
1125
+ description=f"Add missing fields to {node_id}",
1126
+ category="node",
1127
+ severity=diag.severity,
1128
+ auto_apply=True,
1129
+ preview=f"Add missing required fields to {node_id}",
1130
+ apply=apply,
1131
+ )
1132
+
1133
+
1134
+ def _build_type_normalize_fix(
1135
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1136
+ ) -> Optional[FixAction]:
1137
+ """Build fix for invalid node type."""
1138
+ node_id = diag.location
1139
+ if not node_id:
1140
+ return None
1141
+
1142
+ def apply(data: Dict[str, Any]) -> None:
1143
+ hier = data.get("hierarchy", {})
1144
+ node = hier.get(node_id)
1145
+ if not node:
1146
+ return
1147
+ node["type"] = _normalize_node_type(node.get("type", ""))
1148
+
1149
+ return FixAction(
1150
+ id=f"node.normalize_type:{node_id}",
1151
+ description=f"Normalize type for {node_id}",
1152
+ category="node",
1153
+ severity=diag.severity,
1154
+ auto_apply=True,
1155
+ preview=f"Normalize node type for {node_id}",
1156
+ apply=apply,
1157
+ )
1158
+
1159
+
1160
+ def _build_status_normalize_fix(
1161
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1162
+ ) -> Optional[FixAction]:
1163
+ """Build fix for invalid status."""
1164
+ node_id = diag.location
1165
+ if not node_id:
1166
+ return None
1167
+
1168
+ def apply(data: Dict[str, Any]) -> None:
1169
+ hier = data.get("hierarchy", {})
1170
+ node = hier.get(node_id)
1171
+ if not node:
1172
+ return
1173
+ node["status"] = _normalize_status(node.get("status"))
1174
+
1175
+ return FixAction(
1176
+ id=f"status.normalize:{node_id}",
1177
+ description=f"Normalize status for {node_id}",
1178
+ category="node",
1179
+ severity=diag.severity,
1180
+ auto_apply=True,
1181
+ preview=f"Normalize status for {node_id}",
1182
+ apply=apply,
1183
+ )
1184
+
1185
+
1186
+ def _build_title_generate_fix(
1187
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1188
+ ) -> Optional[FixAction]:
1189
+ """Build fix for empty title."""
1190
+ node_id = diag.location
1191
+ if not node_id:
1192
+ return None
1193
+
1194
+ def apply(data: Dict[str, Any]) -> None:
1195
+ hier = data.get("hierarchy", {})
1196
+ node = hier.get(node_id)
1197
+ if not node:
1198
+ return
1199
+ node["title"] = node_id.replace("-", " ").replace("_", " ").title()
1200
+
1201
+ return FixAction(
1202
+ id=f"node.generate_title:{node_id}",
1203
+ description=f"Generate title for {node_id}",
1204
+ category="node",
1205
+ severity=diag.severity,
1206
+ auto_apply=True,
1207
+ preview=f"Generate title from node ID for {node_id}",
1208
+ apply=apply,
1209
+ )
1210
+
1211
+
1212
+ def _build_counts_fix(
1213
+ diag: Diagnostic, spec_data: Dict[str, Any]
1214
+ ) -> Optional[FixAction]:
1215
+ """Build fix for task count issues."""
1216
+
1217
+ def apply(data: Dict[str, Any]) -> None:
1218
+ _recalculate_counts(data)
1219
+
1220
+ return FixAction(
1221
+ id="counts.recalculate",
1222
+ description="Recalculate task count rollups",
1223
+ category="counts",
1224
+ severity=diag.severity,
1225
+ auto_apply=True,
1226
+ preview="Recalculate total/completed task rollups across the hierarchy",
1227
+ apply=apply,
1228
+ )
1229
+
1230
+
1231
+ def _build_bidirectional_fix(
1232
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1233
+ ) -> Optional[FixAction]:
1234
+ """Build fix for bidirectional dependency inconsistency."""
1235
+ # Parse node IDs from message
1236
+ blocks_match = re.search(r"'([^']+)' blocks '([^']+)'", diag.message)
1237
+ blocked_by_match = re.search(r"'([^']+)' blocked_by '([^']+)'", diag.message)
1238
+
1239
+ if blocks_match:
1240
+ blocker_id = blocks_match.group(1)
1241
+ blocked_id = blocks_match.group(2)
1242
+ elif blocked_by_match:
1243
+ blocked_id = blocked_by_match.group(1)
1244
+ blocker_id = blocked_by_match.group(2)
1245
+ else:
1246
+ return None
1247
+
1248
+ def apply(data: Dict[str, Any]) -> None:
1249
+ hier = data.get("hierarchy", {})
1250
+ blocker = hier.get(blocker_id)
1251
+ blocked = hier.get(blocked_id)
1252
+ if not blocker or not blocked:
1253
+ return
1254
+
1255
+ # Ensure dependencies structure
1256
+ if not isinstance(blocker.get("dependencies"), dict):
1257
+ blocker["dependencies"] = {"blocks": [], "blocked_by": [], "depends": []}
1258
+ if not isinstance(blocked.get("dependencies"), dict):
1259
+ blocked["dependencies"] = {"blocks": [], "blocked_by": [], "depends": []}
1260
+
1261
+ blocker_deps = blocker["dependencies"]
1262
+ blocked_deps = blocked["dependencies"]
1263
+
1264
+ # Ensure all fields exist
1265
+ for field in ["blocks", "blocked_by", "depends"]:
1266
+ blocker_deps.setdefault(field, [])
1267
+ blocked_deps.setdefault(field, [])
1268
+
1269
+ # Sync relationship
1270
+ if blocked_id not in blocker_deps["blocks"]:
1271
+ blocker_deps["blocks"].append(blocked_id)
1272
+ if blocker_id not in blocked_deps["blocked_by"]:
1273
+ blocked_deps["blocked_by"].append(blocker_id)
1274
+
1275
+ return FixAction(
1276
+ id=f"dependency.sync_bidirectional:{blocker_id}-{blocked_id}",
1277
+ description=f"Sync bidirectional dependency: {blocker_id} blocks {blocked_id}",
1278
+ category="dependency",
1279
+ severity=diag.severity,
1280
+ auto_apply=True,
1281
+ preview=f"Sync bidirectional dependency: {blocker_id} blocks {blocked_id}",
1282
+ apply=apply,
1283
+ )
1284
+
1285
+
1286
+ def _build_deps_structure_fix(
1287
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1288
+ ) -> Optional[FixAction]:
1289
+ """Build fix for missing dependencies structure."""
1290
+ node_id = diag.location
1291
+ if not node_id:
1292
+ return None
1293
+
1294
+ def apply(data: Dict[str, Any]) -> None:
1295
+ hier = data.get("hierarchy", {})
1296
+ node = hier.get(node_id)
1297
+ if not node:
1298
+ return
1299
+ if not isinstance(node.get("dependencies"), dict):
1300
+ node["dependencies"] = {"blocks": [], "blocked_by": [], "depends": []}
1301
+
1302
+ return FixAction(
1303
+ id=f"dependency.create_structure:{node_id}",
1304
+ description=f"Create dependencies structure for {node_id}",
1305
+ category="dependency",
1306
+ severity=diag.severity,
1307
+ auto_apply=True,
1308
+ preview=f"Create dependencies structure for {node_id}",
1309
+ apply=apply,
1310
+ )
1311
+
1312
+
1313
+ def _build_verification_type_fix(
1314
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1315
+ ) -> Optional[FixAction]:
1316
+ """Build fix for missing verification type."""
1317
+ node_id = diag.location
1318
+ if not node_id:
1319
+ return None
1320
+
1321
+ def apply(data: Dict[str, Any]) -> None:
1322
+ hier = data.get("hierarchy", {})
1323
+ node = hier.get(node_id)
1324
+ if not node:
1325
+ return
1326
+ metadata = node.setdefault("metadata", {})
1327
+ if "verification_type" not in metadata:
1328
+ metadata["verification_type"] = "test"
1329
+
1330
+ return FixAction(
1331
+ id=f"metadata.fix_verification_type:{node_id}",
1332
+ description=f"Set verification_type to 'test' for {node_id}",
1333
+ category="metadata",
1334
+ severity=diag.severity,
1335
+ auto_apply=True,
1336
+ preview=f"Set verification_type to 'test' for {node_id}",
1337
+ apply=apply,
1338
+ )
1339
+
1340
+
1341
+ def _build_file_path_fix(
1342
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1343
+ ) -> Optional[FixAction]:
1344
+ """Build fix for missing file path."""
1345
+ node_id = diag.location
1346
+ if not node_id:
1347
+ return None
1348
+
1349
+ def apply(data: Dict[str, Any]) -> None:
1350
+ hier = data.get("hierarchy", {})
1351
+ node = hier.get(node_id)
1352
+ if not node:
1353
+ return
1354
+ metadata = node.setdefault("metadata", {})
1355
+ if "file_path" not in metadata:
1356
+ metadata["file_path"] = f"{node_id}.py" # Default placeholder
1357
+
1358
+ return FixAction(
1359
+ id=f"metadata.add_file_path:{node_id}",
1360
+ description=f"Add placeholder file_path for {node_id}",
1361
+ category="metadata",
1362
+ severity=diag.severity,
1363
+ auto_apply=True,
1364
+ preview=f"Add placeholder file_path for {node_id}",
1365
+ apply=apply,
1366
+ )
1367
+
1368
+
1369
+ def _build_task_category_fix(
1370
+ diag: Diagnostic, hierarchy: Dict[str, Any]
1371
+ ) -> Optional[FixAction]:
1372
+ """Build fix for invalid task category."""
1373
+ node_id = diag.location
1374
+ if not node_id:
1375
+ return None
1376
+
1377
+ def apply(data: Dict[str, Any]) -> None:
1378
+ hier = data.get("hierarchy", {})
1379
+ node = hier.get(node_id)
1380
+ if not node:
1381
+ return
1382
+ metadata = node.setdefault("metadata", {})
1383
+ # Default to implementation
1384
+ metadata["task_category"] = "implementation"
1385
+
1386
+ return FixAction(
1387
+ id=f"metadata.fix_task_category:{node_id}",
1388
+ description=f"Set task_category to 'implementation' for {node_id}",
1389
+ category="metadata",
1390
+ severity=diag.severity,
1391
+ auto_apply=True,
1392
+ preview=f"Set task_category to 'implementation' for {node_id}",
1393
+ apply=apply,
1394
+ )
1395
+
1396
+
1397
+ def apply_fixes(
1398
+ actions: List[FixAction],
1399
+ spec_path: str,
1400
+ *,
1401
+ dry_run: bool = False,
1402
+ create_backup: bool = True,
1403
+ capture_diff: bool = False,
1404
+ ) -> FixReport:
1405
+ """
1406
+ Apply fix actions to a spec file.
1407
+
1408
+ Args:
1409
+ actions: List of FixAction objects to apply
1410
+ spec_path: Path to spec file
1411
+ dry_run: If True, don't actually save changes
1412
+ create_backup: If True, create backup before modifying
1413
+ capture_diff: If True, capture before/after state
1414
+
1415
+ Returns:
1416
+ FixReport with results
1417
+ """
1418
+ report = FixReport(spec_path=spec_path)
1419
+
1420
+ if dry_run:
1421
+ report.skipped_actions.extend(actions)
1422
+ return report
1423
+
1424
+ try:
1425
+ with open(spec_path, "r") as f:
1426
+ data = json.load(f)
1427
+ except (OSError, json.JSONDecodeError):
1428
+ return report
1429
+
1430
+ # Capture before state
1431
+ if capture_diff:
1432
+ report.before_state = copy.deepcopy(data)
1433
+
1434
+ # Create backup
1435
+ if create_backup:
1436
+ backup_path = Path(spec_path).with_suffix(".json.backup")
1437
+ try:
1438
+ with open(backup_path, "w") as f:
1439
+ json.dump(data, f, indent=2)
1440
+ report.backup_path = str(backup_path)
1441
+ except OSError:
1442
+ pass
1443
+
1444
+ # Apply each action
1445
+ for action in actions:
1446
+ try:
1447
+ action.apply(data)
1448
+ report.applied_actions.append(action)
1449
+ except Exception:
1450
+ report.skipped_actions.append(action)
1451
+
1452
+ # Recalculate counts after all fixes
1453
+ if report.applied_actions:
1454
+ _recalculate_counts(data)
1455
+
1456
+ # Capture after state
1457
+ if capture_diff:
1458
+ report.after_state = copy.deepcopy(data)
1459
+
1460
+ # Save changes
1461
+ try:
1462
+ with open(spec_path, "w") as f:
1463
+ json.dump(data, f, indent=2)
1464
+ except OSError:
1465
+ pass
1466
+
1467
+ return report
1468
+
1469
+
1470
+ # Statistics functions
1471
+
1472
+
1473
+ def calculate_stats(
1474
+ spec_data: Dict[str, Any], file_path: Optional[str] = None
1475
+ ) -> SpecStats:
1476
+ """
1477
+ Calculate statistics for a spec file.
1478
+
1479
+ Args:
1480
+ spec_data: Parsed JSON spec data
1481
+ file_path: Optional path to spec file for size calculation
1482
+
1483
+ Returns:
1484
+ SpecStats with calculated metrics
1485
+ """
1486
+ hierarchy = spec_data.get("hierarchy", {}) or {}
1487
+
1488
+ totals = {
1489
+ "nodes": len(hierarchy),
1490
+ "tasks": 0,
1491
+ "phases": 0,
1492
+ "verifications": 0,
1493
+ }
1494
+
1495
+ status_counts = {status: 0 for status in STATUS_FIELDS}
1496
+ max_depth = 0
1497
+
1498
+ def traverse(node_id: str, depth: int) -> None:
1499
+ nonlocal max_depth
1500
+ node = hierarchy.get(node_id, {})
1501
+ node_type = node.get("type")
1502
+
1503
+ max_depth = max(max_depth, depth)
1504
+
1505
+ if node_type in {"task", "subtask"}:
1506
+ totals["tasks"] += 1
1507
+ status = node.get("status", "").lower().replace(" ", "_").replace("-", "_")
1508
+ if status in status_counts:
1509
+ status_counts[status] += 1
1510
+ elif node_type == "phase":
1511
+ totals["phases"] += 1
1512
+ elif node_type == "verify":
1513
+ totals["verifications"] += 1
1514
+
1515
+ for child_id in node.get("children", []) or []:
1516
+ if child_id in hierarchy:
1517
+ traverse(child_id, depth + 1)
1518
+
1519
+ if "spec-root" in hierarchy:
1520
+ traverse("spec-root", 0)
1521
+
1522
+ total_tasks = totals["tasks"]
1523
+ phase_count = totals["phases"] or 1
1524
+ avg_tasks_per_phase = round(total_tasks / phase_count, 2)
1525
+
1526
+ root = hierarchy.get("spec-root", {})
1527
+ root_total_tasks = root.get("total_tasks", total_tasks)
1528
+ root_completed = root.get("completed_tasks", 0)
1529
+
1530
+ verification_count = totals["verifications"]
1531
+ verification_coverage = (verification_count / total_tasks) if total_tasks else 0.0
1532
+ progress = (root_completed / root_total_tasks) if root_total_tasks else 0.0
1533
+
1534
+ file_size = 0.0
1535
+ if file_path:
1536
+ try:
1537
+ file_size = Path(file_path).stat().st_size / 1024
1538
+ except OSError:
1539
+ file_size = 0.0
1540
+
1541
+ return SpecStats(
1542
+ spec_id=spec_data.get("spec_id", "unknown"),
1543
+ title=spec_data.get("title", ""),
1544
+ version=spec_data.get("version", ""),
1545
+ status=root.get("status", "unknown"),
1546
+ totals=totals,
1547
+ status_counts=status_counts,
1548
+ max_depth=max_depth,
1549
+ avg_tasks_per_phase=avg_tasks_per_phase,
1550
+ verification_coverage=verification_coverage,
1551
+ progress=progress,
1552
+ file_size_kb=file_size,
1553
+ )
1554
+
1555
+
1556
+ # Helper functions
1557
+
1558
+
1559
+ def _is_valid_spec_id(spec_id: str) -> bool:
1560
+ """Check if spec_id follows the recommended format."""
1561
+ pattern = r"^[a-z0-9-]+-\d{4}-\d{2}-\d{2}-\d{3}$"
1562
+ return bool(re.match(pattern, spec_id))
1563
+
1564
+
1565
+ def _is_valid_iso8601(value: str) -> bool:
1566
+ """Check if value is valid ISO 8601 date."""
1567
+ try:
1568
+ # Try parsing with Z suffix
1569
+ if value.endswith("Z"):
1570
+ datetime.fromisoformat(value.replace("Z", "+00:00"))
1571
+ else:
1572
+ datetime.fromisoformat(value)
1573
+ return True
1574
+ except ValueError:
1575
+ return False
1576
+
1577
+
1578
+ def _normalize_timestamp(value: Any) -> Optional[str]:
1579
+ """Normalize timestamp to ISO 8601 format."""
1580
+ if not value:
1581
+ return None
1582
+
1583
+ text = str(value).strip()
1584
+ candidate = text.replace("Z", "")
1585
+
1586
+ for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M"):
1587
+ try:
1588
+ dt = datetime.strptime(candidate, fmt)
1589
+ return dt.replace(tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
1590
+ except ValueError:
1591
+ continue
1592
+
1593
+ try:
1594
+ dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
1595
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
1596
+ except ValueError:
1597
+ return None
1598
+
1599
+
1600
+ def _normalize_status(value: Any) -> str:
1601
+ """Normalize status value."""
1602
+ if not value:
1603
+ return "pending"
1604
+
1605
+ text = str(value).strip().lower().replace("-", "_").replace(" ", "_")
1606
+ mapping = {
1607
+ "inprogress": "in_progress",
1608
+ "in__progress": "in_progress",
1609
+ "todo": "pending",
1610
+ "to_do": "pending",
1611
+ "complete": "completed",
1612
+ "done": "completed",
1613
+ }
1614
+ text = mapping.get(text, text)
1615
+
1616
+ if text in VALID_STATUSES:
1617
+ return text
1618
+
1619
+ return "pending"
1620
+
1621
+
1622
+ def _normalize_node_type(value: Any) -> str:
1623
+ """Normalize node type value."""
1624
+ if not value:
1625
+ return "task"
1626
+
1627
+ text = str(value).strip().lower().replace(" ", "_").replace("-", "_")
1628
+ mapping = {
1629
+ "tasks": "task",
1630
+ "sub_task": "subtask",
1631
+ "verification": "verify",
1632
+ "validate": "verify",
1633
+ }
1634
+ text = mapping.get(text, text)
1635
+
1636
+ if text in VALID_NODE_TYPES:
1637
+ return text
1638
+
1639
+ return "task"
1640
+
1641
+
1642
+ def _recalculate_counts(spec_data: Dict[str, Any]) -> None:
1643
+ """Recalculate task counts for all nodes in hierarchy."""
1644
+ hierarchy = spec_data.get("hierarchy", {})
1645
+ if not hierarchy:
1646
+ return
1647
+
1648
+ # Process bottom-up: leaves first, then parents
1649
+ def calculate_node(node_id: str) -> tuple:
1650
+ """Return (total_tasks, completed_tasks) for a node."""
1651
+ node = hierarchy.get(node_id, {})
1652
+ children = node.get("children", [])
1653
+ node_type = node.get("type", "")
1654
+ status = node.get("status", "")
1655
+
1656
+ if not children:
1657
+ # Leaf node
1658
+ if node_type in {"task", "subtask", "verify"}:
1659
+ total = 1
1660
+ completed = 1 if status == "completed" else 0
1661
+ else:
1662
+ total = 0
1663
+ completed = 0
1664
+ else:
1665
+ # Parent node: sum children
1666
+ total = 0
1667
+ completed = 0
1668
+ for child_id in children:
1669
+ if child_id in hierarchy:
1670
+ child_total, child_completed = calculate_node(child_id)
1671
+ total += child_total
1672
+ completed += child_completed
1673
+
1674
+ node["total_tasks"] = total
1675
+ node["completed_tasks"] = completed
1676
+ return total, completed
1677
+
1678
+ if "spec-root" in hierarchy:
1679
+ calculate_node("spec-root")
1680
+
1681
+
1682
+ # Verification management functions
1683
+
1684
+ # Valid verification results
1685
+ VERIFICATION_RESULTS = ("PASSED", "FAILED", "PARTIAL")
1686
+
1687
+
1688
+ def add_verification(
1689
+ spec_data: Dict[str, Any],
1690
+ verify_id: str,
1691
+ result: str,
1692
+ command: Optional[str] = None,
1693
+ output: Optional[str] = None,
1694
+ issues: Optional[str] = None,
1695
+ notes: Optional[str] = None,
1696
+ ) -> tuple[bool, Optional[str]]:
1697
+ """
1698
+ Add verification result to a verify node.
1699
+
1700
+ Records verification results including test outcomes, command output,
1701
+ and issues found during verification.
1702
+
1703
+ Args:
1704
+ spec_data: The loaded spec data dict (modified in place).
1705
+ verify_id: Verification node ID (e.g., verify-1-1).
1706
+ result: Verification result (PASSED, FAILED, PARTIAL).
1707
+ command: Optional command that was run for verification.
1708
+ output: Optional command output or test results.
1709
+ issues: Optional issues found during verification.
1710
+ notes: Optional additional notes about the verification.
1711
+
1712
+ Returns:
1713
+ Tuple of (success, error_message).
1714
+ On success: (True, None)
1715
+ On failure: (False, "error message")
1716
+ """
1717
+ # Validate result
1718
+ result_upper = result.upper().strip()
1719
+ if result_upper not in VERIFICATION_RESULTS:
1720
+ return (
1721
+ False,
1722
+ f"Invalid result '{result}'. Must be one of: {', '.join(VERIFICATION_RESULTS)}",
1723
+ )
1724
+
1725
+ # Get hierarchy
1726
+ hierarchy = spec_data.get("hierarchy")
1727
+ if not hierarchy or not isinstance(hierarchy, dict):
1728
+ return False, "Invalid spec data: missing or invalid hierarchy"
1729
+
1730
+ # Find the verify node
1731
+ node = hierarchy.get(verify_id)
1732
+ if node is None:
1733
+ return False, f"Verification node '{verify_id}' not found"
1734
+
1735
+ # Validate node type
1736
+ node_type = node.get("type")
1737
+ if node_type != "verify":
1738
+ return False, f"Node '{verify_id}' is type '{node_type}', expected 'verify'"
1739
+
1740
+ # Get or create metadata
1741
+ metadata = node.get("metadata")
1742
+ if metadata is None:
1743
+ metadata = {}
1744
+ node["metadata"] = metadata
1745
+
1746
+ # Build verification result entry
1747
+ verification_entry: Dict[str, Any] = {
1748
+ "result": result_upper,
1749
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
1750
+ }
1751
+
1752
+ if command:
1753
+ verification_entry["command"] = command.strip()
1754
+
1755
+ if output:
1756
+ # Truncate output if very long
1757
+ max_output_len = MAX_STRING_LENGTH
1758
+ output_text = output.strip()
1759
+ if len(output_text) > max_output_len:
1760
+ output_text = output_text[:max_output_len] + "\n... (truncated)"
1761
+ verification_entry["output"] = output_text
1762
+
1763
+ if issues:
1764
+ verification_entry["issues"] = issues.strip()
1765
+
1766
+ if notes:
1767
+ verification_entry["notes"] = notes.strip()
1768
+
1769
+ # Add to verification history (keep last N entries)
1770
+ verification_history = metadata.get("verification_history", [])
1771
+ if not isinstance(verification_history, list):
1772
+ verification_history = []
1773
+
1774
+ verification_history.append(verification_entry)
1775
+
1776
+ # Keep only last 10 entries
1777
+ if len(verification_history) > 10:
1778
+ verification_history = verification_history[-10:]
1779
+
1780
+ metadata["verification_history"] = verification_history
1781
+
1782
+ # Update latest result fields for quick access
1783
+ metadata["last_result"] = result_upper
1784
+ metadata["last_verified_at"] = verification_entry["timestamp"]
1785
+
1786
+ return True, None
1787
+
1788
+
1789
+ def execute_verification(
1790
+ spec_data: Dict[str, Any],
1791
+ verify_id: str,
1792
+ record: bool = False,
1793
+ timeout: int = 300,
1794
+ cwd: Optional[str] = None,
1795
+ ) -> Dict[str, Any]:
1796
+ """
1797
+ Execute verification command and capture results.
1798
+
1799
+ Runs the verification command defined in a verify node's metadata
1800
+ and captures output, exit code, and result status.
1801
+
1802
+ Args:
1803
+ spec_data: The loaded spec data dict.
1804
+ verify_id: Verification node ID (e.g., verify-1-1).
1805
+ record: If True, automatically record result to spec using add_verification().
1806
+ timeout: Command timeout in seconds (default: 300).
1807
+ cwd: Working directory for command execution (default: current directory).
1808
+
1809
+ Returns:
1810
+ Dict with execution results:
1811
+ - success: Whether execution completed (not result status)
1812
+ - spec_id: The specification ID
1813
+ - verify_id: The verification ID
1814
+ - result: Execution result (PASSED, FAILED, PARTIAL)
1815
+ - command: Command that was executed
1816
+ - output: Combined stdout/stderr output
1817
+ - exit_code: Command exit code
1818
+ - recorded: Whether result was recorded to spec
1819
+ - error: Error message if execution failed
1820
+
1821
+ Example:
1822
+ >>> result = execute_verification(spec_data, "verify-1-1", record=True)
1823
+ >>> if result["success"]:
1824
+ ... print(f"Verification {result['result']}: {result['exit_code']}")
1825
+ """
1826
+ import subprocess
1827
+
1828
+ response: Dict[str, Any] = {
1829
+ "success": False,
1830
+ "spec_id": spec_data.get("spec_id", "unknown"),
1831
+ "verify_id": verify_id,
1832
+ "result": None,
1833
+ "command": None,
1834
+ "output": None,
1835
+ "exit_code": None,
1836
+ "recorded": False,
1837
+ "error": None,
1838
+ }
1839
+
1840
+ # Get hierarchy
1841
+ hierarchy = spec_data.get("hierarchy")
1842
+ if not hierarchy or not isinstance(hierarchy, dict):
1843
+ response["error"] = "Invalid spec data: missing or invalid hierarchy"
1844
+ return response
1845
+
1846
+ # Find the verify node
1847
+ node = hierarchy.get(verify_id)
1848
+ if node is None:
1849
+ response["error"] = f"Verification node '{verify_id}' not found"
1850
+ return response
1851
+
1852
+ # Validate node type
1853
+ node_type = node.get("type")
1854
+ if node_type != "verify":
1855
+ response["error"] = (
1856
+ f"Node '{verify_id}' is type '{node_type}', expected 'verify'"
1857
+ )
1858
+ return response
1859
+
1860
+ # Get command from metadata
1861
+ metadata = node.get("metadata", {})
1862
+ command = metadata.get("command")
1863
+
1864
+ if not command:
1865
+ response["error"] = f"No command defined in verify node '{verify_id}' metadata"
1866
+ return response
1867
+
1868
+ response["command"] = command
1869
+
1870
+ # Execute the command
1871
+ try:
1872
+ proc = subprocess.run(
1873
+ command,
1874
+ shell=True,
1875
+ capture_output=True,
1876
+ text=True,
1877
+ timeout=timeout,
1878
+ cwd=cwd,
1879
+ )
1880
+
1881
+ exit_code = proc.returncode
1882
+ stdout = proc.stdout or ""
1883
+ stderr = proc.stderr or ""
1884
+
1885
+ # Combine output
1886
+ output_parts = []
1887
+ if stdout.strip():
1888
+ output_parts.append(stdout.strip())
1889
+ if stderr.strip():
1890
+ output_parts.append(f"[stderr]\n{stderr.strip()}")
1891
+ output = "\n".join(output_parts) if output_parts else "(no output)"
1892
+
1893
+ # Truncate if too long
1894
+ if len(output) > MAX_STRING_LENGTH:
1895
+ output = output[:MAX_STRING_LENGTH] + "\n... (truncated)"
1896
+
1897
+ response["exit_code"] = exit_code
1898
+ response["output"] = output
1899
+
1900
+ # Determine result based on exit code
1901
+ if exit_code == 0:
1902
+ result = "PASSED"
1903
+ else:
1904
+ result = "FAILED"
1905
+
1906
+ response["result"] = result
1907
+ response["success"] = True
1908
+
1909
+ # Optionally record result to spec
1910
+ if record:
1911
+ record_success, record_error = add_verification(
1912
+ spec_data=spec_data,
1913
+ verify_id=verify_id,
1914
+ result=result,
1915
+ command=command,
1916
+ output=output,
1917
+ )
1918
+ if record_success:
1919
+ response["recorded"] = True
1920
+ else:
1921
+ response["recorded"] = False
1922
+ # Don't fail the whole operation, just note the recording failed
1923
+ if response.get("error"):
1924
+ response["error"] += f"; Recording failed: {record_error}"
1925
+ else:
1926
+ response["error"] = f"Recording failed: {record_error}"
1927
+
1928
+ except subprocess.TimeoutExpired:
1929
+ response["error"] = f"Command timed out after {timeout} seconds"
1930
+ response["result"] = "FAILED"
1931
+ response["exit_code"] = -1
1932
+ response["output"] = f"Command timed out after {timeout} seconds"
1933
+
1934
+ except subprocess.SubprocessError as e:
1935
+ response["error"] = f"Command execution failed: {e}"
1936
+ response["result"] = "FAILED"
1937
+
1938
+ except Exception as e:
1939
+ response["error"] = f"Unexpected error: {e}"
1940
+ response["result"] = "FAILED"
1941
+
1942
+ return response
1943
+
1944
+
1945
+ def format_verification_summary(
1946
+ verification_data: Dict[str, Any] | List[Dict[str, Any]],
1947
+ ) -> Dict[str, Any]:
1948
+ """
1949
+ Format verification results into a human-readable summary.
1950
+
1951
+ Processes verification results (from execute_verification or JSON input)
1952
+ and produces a structured summary with counts and formatted text.
1953
+
1954
+ Args:
1955
+ verification_data: Either:
1956
+ - A single verification result dict (from execute_verification)
1957
+ - A list of verification result dicts
1958
+ - A dict with "verifications" key containing a list
1959
+
1960
+ Returns:
1961
+ Dict with formatted summary:
1962
+ - summary: Human-readable summary text
1963
+ - total_verifications: Total number of verifications
1964
+ - passed: Number of passed verifications
1965
+ - failed: Number of failed verifications
1966
+ - partial: Number of partial verifications
1967
+ - results: List of individual result summaries
1968
+
1969
+ Example:
1970
+ >>> results = [
1971
+ ... execute_verification(spec_data, "verify-1"),
1972
+ ... execute_verification(spec_data, "verify-2"),
1973
+ ... ]
1974
+ >>> summary = format_verification_summary(results)
1975
+ >>> print(summary["summary"])
1976
+ """
1977
+ # Normalize input to a list of verification results
1978
+ verifications: List[Dict[str, Any]] = []
1979
+
1980
+ if isinstance(verification_data, list):
1981
+ verifications = verification_data
1982
+ elif isinstance(verification_data, dict):
1983
+ if "verifications" in verification_data:
1984
+ verifications = verification_data.get("verifications", [])
1985
+ else:
1986
+ # Single verification result
1987
+ verifications = [verification_data]
1988
+
1989
+ # Count results by type
1990
+ passed = 0
1991
+ failed = 0
1992
+ partial = 0
1993
+ results: List[Dict[str, Any]] = []
1994
+
1995
+ for v in verifications:
1996
+ if not isinstance(v, dict):
1997
+ continue
1998
+
1999
+ result = (v.get("result") or "").upper()
2000
+ verify_id = v.get("verify_id", "unknown")
2001
+ command = v.get("command", "")
2002
+ output = v.get("output", "")
2003
+ error = v.get("error")
2004
+
2005
+ # Count by result type
2006
+ if result == "PASSED":
2007
+ passed += 1
2008
+ status_icon = "✓"
2009
+ elif result == "FAILED":
2010
+ failed += 1
2011
+ status_icon = "✗"
2012
+ elif result == "PARTIAL":
2013
+ partial += 1
2014
+ status_icon = "◐"
2015
+ else:
2016
+ status_icon = "?"
2017
+
2018
+ # Build individual result summary
2019
+ result_entry: Dict[str, Any] = {
2020
+ "verify_id": verify_id,
2021
+ "result": result or "UNKNOWN",
2022
+ "status_icon": status_icon,
2023
+ "command": command,
2024
+ }
2025
+
2026
+ if error:
2027
+ result_entry["error"] = error
2028
+
2029
+ # Truncate output for summary
2030
+ if output:
2031
+ output_preview = output[:200].strip()
2032
+ if len(output) > 200:
2033
+ output_preview += "..."
2034
+ result_entry["output_preview"] = output_preview
2035
+
2036
+ results.append(result_entry)
2037
+
2038
+ # Calculate totals
2039
+ total = len(results)
2040
+
2041
+ # Build summary text
2042
+ summary_lines = []
2043
+ summary_lines.append(f"Verification Summary: {total} total")
2044
+ summary_lines.append(f" ✓ Passed: {passed}")
2045
+ summary_lines.append(f" ✗ Failed: {failed}")
2046
+ if partial > 0:
2047
+ summary_lines.append(f" ◐ Partial: {partial}")
2048
+ summary_lines.append("")
2049
+
2050
+ # Add individual results
2051
+ if results:
2052
+ summary_lines.append("Results:")
2053
+ for r in results:
2054
+ icon = r["status_icon"]
2055
+ vid = r["verify_id"]
2056
+ res = r["result"]
2057
+ cmd = r.get("command", "")
2058
+
2059
+ line = f" {icon} {vid}: {res}"
2060
+ if cmd:
2061
+ # Truncate command for display
2062
+ cmd_display = cmd[:50]
2063
+ if len(cmd) > 50:
2064
+ cmd_display += "..."
2065
+ line += f" ({cmd_display})"
2066
+
2067
+ summary_lines.append(line)
2068
+
2069
+ if r.get("error"):
2070
+ summary_lines.append(f" Error: {r['error']}")
2071
+
2072
+ summary_text = "\n".join(summary_lines)
2073
+
2074
+ return {
2075
+ "summary": summary_text,
2076
+ "total_verifications": total,
2077
+ "passed": passed,
2078
+ "failed": failed,
2079
+ "partial": partial,
2080
+ "results": results,
2081
+ }