doit-toolkit-cli 0.1.10__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.

Potentially problematic release.


This version of doit-toolkit-cli might be problematic. Click here for more details.

Files changed (135) hide show
  1. doit_cli/__init__.py +1356 -0
  2. doit_cli/cli/__init__.py +26 -0
  3. doit_cli/cli/analytics_command.py +616 -0
  4. doit_cli/cli/context_command.py +213 -0
  5. doit_cli/cli/diagram_command.py +304 -0
  6. doit_cli/cli/fixit_command.py +641 -0
  7. doit_cli/cli/hooks_command.py +211 -0
  8. doit_cli/cli/init_command.py +613 -0
  9. doit_cli/cli/memory_command.py +293 -0
  10. doit_cli/cli/roadmapit_command.py +10 -0
  11. doit_cli/cli/status_command.py +117 -0
  12. doit_cli/cli/sync_prompts_command.py +248 -0
  13. doit_cli/cli/validate_command.py +196 -0
  14. doit_cli/cli/verify_command.py +204 -0
  15. doit_cli/cli/workflow_mixin.py +224 -0
  16. doit_cli/cli/xref_command.py +555 -0
  17. doit_cli/formatters/__init__.py +8 -0
  18. doit_cli/formatters/base.py +38 -0
  19. doit_cli/formatters/json_formatter.py +126 -0
  20. doit_cli/formatters/markdown_formatter.py +97 -0
  21. doit_cli/formatters/rich_formatter.py +257 -0
  22. doit_cli/main.py +51 -0
  23. doit_cli/models/__init__.py +139 -0
  24. doit_cli/models/agent.py +74 -0
  25. doit_cli/models/analytics_models.py +384 -0
  26. doit_cli/models/context_config.py +464 -0
  27. doit_cli/models/crossref_models.py +182 -0
  28. doit_cli/models/diagram_models.py +363 -0
  29. doit_cli/models/fixit_models.py +355 -0
  30. doit_cli/models/hook_config.py +125 -0
  31. doit_cli/models/project.py +91 -0
  32. doit_cli/models/results.py +121 -0
  33. doit_cli/models/search_models.py +228 -0
  34. doit_cli/models/status_models.py +195 -0
  35. doit_cli/models/sync_models.py +146 -0
  36. doit_cli/models/template.py +77 -0
  37. doit_cli/models/validation_models.py +175 -0
  38. doit_cli/models/workflow_models.py +319 -0
  39. doit_cli/prompts/__init__.py +5 -0
  40. doit_cli/prompts/fixit_prompts.py +344 -0
  41. doit_cli/prompts/interactive.py +390 -0
  42. doit_cli/rules/__init__.py +5 -0
  43. doit_cli/rules/builtin_rules.py +160 -0
  44. doit_cli/services/__init__.py +79 -0
  45. doit_cli/services/agent_detector.py +168 -0
  46. doit_cli/services/analytics_service.py +218 -0
  47. doit_cli/services/architecture_generator.py +290 -0
  48. doit_cli/services/backup_service.py +204 -0
  49. doit_cli/services/config_loader.py +113 -0
  50. doit_cli/services/context_loader.py +1123 -0
  51. doit_cli/services/coverage_calculator.py +142 -0
  52. doit_cli/services/crossref_service.py +237 -0
  53. doit_cli/services/cycle_time_calculator.py +134 -0
  54. doit_cli/services/date_inferrer.py +349 -0
  55. doit_cli/services/diagram_service.py +337 -0
  56. doit_cli/services/drift_detector.py +109 -0
  57. doit_cli/services/entity_parser.py +301 -0
  58. doit_cli/services/er_diagram_generator.py +197 -0
  59. doit_cli/services/fixit_service.py +699 -0
  60. doit_cli/services/github_service.py +192 -0
  61. doit_cli/services/hook_manager.py +258 -0
  62. doit_cli/services/hook_validator.py +528 -0
  63. doit_cli/services/input_validator.py +322 -0
  64. doit_cli/services/memory_search.py +527 -0
  65. doit_cli/services/mermaid_validator.py +334 -0
  66. doit_cli/services/prompt_transformer.py +91 -0
  67. doit_cli/services/prompt_writer.py +133 -0
  68. doit_cli/services/query_interpreter.py +428 -0
  69. doit_cli/services/report_exporter.py +219 -0
  70. doit_cli/services/report_generator.py +256 -0
  71. doit_cli/services/requirement_parser.py +112 -0
  72. doit_cli/services/roadmap_summarizer.py +209 -0
  73. doit_cli/services/rule_engine.py +443 -0
  74. doit_cli/services/scaffolder.py +215 -0
  75. doit_cli/services/score_calculator.py +172 -0
  76. doit_cli/services/section_parser.py +204 -0
  77. doit_cli/services/spec_scanner.py +327 -0
  78. doit_cli/services/state_manager.py +355 -0
  79. doit_cli/services/status_reporter.py +143 -0
  80. doit_cli/services/task_parser.py +347 -0
  81. doit_cli/services/template_manager.py +710 -0
  82. doit_cli/services/template_reader.py +158 -0
  83. doit_cli/services/user_journey_generator.py +214 -0
  84. doit_cli/services/user_story_parser.py +232 -0
  85. doit_cli/services/validation_service.py +188 -0
  86. doit_cli/services/validator.py +232 -0
  87. doit_cli/services/velocity_tracker.py +173 -0
  88. doit_cli/services/workflow_engine.py +405 -0
  89. doit_cli/templates/agent-file-template.md +28 -0
  90. doit_cli/templates/checklist-template.md +39 -0
  91. doit_cli/templates/commands/doit.checkin.md +363 -0
  92. doit_cli/templates/commands/doit.constitution.md +187 -0
  93. doit_cli/templates/commands/doit.documentit.md +485 -0
  94. doit_cli/templates/commands/doit.fixit.md +181 -0
  95. doit_cli/templates/commands/doit.implementit.md +265 -0
  96. doit_cli/templates/commands/doit.planit.md +262 -0
  97. doit_cli/templates/commands/doit.reviewit.md +355 -0
  98. doit_cli/templates/commands/doit.roadmapit.md +389 -0
  99. doit_cli/templates/commands/doit.scaffoldit.md +458 -0
  100. doit_cli/templates/commands/doit.specit.md +521 -0
  101. doit_cli/templates/commands/doit.taskit.md +304 -0
  102. doit_cli/templates/commands/doit.testit.md +277 -0
  103. doit_cli/templates/config/context.yaml +134 -0
  104. doit_cli/templates/config/hooks.yaml +93 -0
  105. doit_cli/templates/config/validation-rules.yaml +64 -0
  106. doit_cli/templates/github-issue-templates/epic.yml +78 -0
  107. doit_cli/templates/github-issue-templates/feature.yml +116 -0
  108. doit_cli/templates/github-issue-templates/task.yml +129 -0
  109. doit_cli/templates/hooks/.gitkeep +0 -0
  110. doit_cli/templates/hooks/post-commit.sh +25 -0
  111. doit_cli/templates/hooks/post-merge.sh +75 -0
  112. doit_cli/templates/hooks/pre-commit.sh +17 -0
  113. doit_cli/templates/hooks/pre-push.sh +18 -0
  114. doit_cli/templates/memory/completed_roadmap.md +50 -0
  115. doit_cli/templates/memory/constitution.md +125 -0
  116. doit_cli/templates/memory/roadmap.md +61 -0
  117. doit_cli/templates/plan-template.md +146 -0
  118. doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
  119. doit_cli/templates/scripts/bash/common.sh +156 -0
  120. doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
  121. doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
  122. doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
  123. doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
  124. doit_cli/templates/scripts/powershell/common.ps1 +137 -0
  125. doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
  126. doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
  127. doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
  128. doit_cli/templates/spec-template.md +159 -0
  129. doit_cli/templates/tasks-template.md +313 -0
  130. doit_cli/templates/vscode-settings.json +14 -0
  131. doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
  132. doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
  133. doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
  134. doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
  135. doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,555 @@
1
+ """Cross-reference commands for spec-task traceability."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ..models.crossref_models import CoverageReport, CoverageStatus
12
+ from ..services.crossref_service import CrossReferenceService
13
+ from ..services.spec_scanner import NotADoitProjectError
14
+
15
+ console = Console()
16
+
17
+ # Create the xref subcommand group
18
+ xref_app = typer.Typer(
19
+ name="xref",
20
+ help="Cross-reference commands for spec-task traceability",
21
+ add_completion=False,
22
+ )
23
+
24
+
25
+ def _detect_spec_from_branch() -> Optional[str]:
26
+ """Try to detect spec name from current git branch.
27
+
28
+ Returns:
29
+ Spec name if branch matches pattern XXX-feature-name, None otherwise.
30
+ """
31
+ import subprocess
32
+
33
+ try:
34
+ result = subprocess.run(
35
+ ["git", "branch", "--show-current"],
36
+ capture_output=True,
37
+ text=True,
38
+ )
39
+ if result.returncode == 0:
40
+ branch = result.stdout.strip()
41
+ # Check if branch matches spec pattern (e.g., 033-feature-name)
42
+ if branch and branch[0].isdigit():
43
+ return branch
44
+ except (FileNotFoundError, subprocess.SubprocessError):
45
+ pass
46
+ return None
47
+
48
+
49
+ def _format_coverage_rich(report: CoverageReport, console: Console) -> None:
50
+ """Format coverage report as Rich table."""
51
+ console.print()
52
+ console.print(
53
+ f"[bold]Requirement Coverage: {Path(report.spec_path).parent.name}[/bold]"
54
+ )
55
+ console.print("=" * 50)
56
+ console.print()
57
+
58
+ table = Table(show_header=True, header_style="bold")
59
+ table.add_column("Requirement", style="cyan")
60
+ table.add_column("Tasks", justify="right")
61
+ table.add_column("Status")
62
+
63
+ for rc in report.requirements:
64
+ status_icon = {
65
+ CoverageStatus.COVERED: "[green]\u2713 Covered[/green]",
66
+ CoverageStatus.PARTIAL: "[yellow]\u25d0 Partial[/yellow]",
67
+ CoverageStatus.UNCOVERED: "[red]\u26a0 Uncovered[/red]",
68
+ }.get(rc.status, "Unknown")
69
+
70
+ table.add_row(
71
+ rc.requirement.id,
72
+ str(rc.task_count),
73
+ status_icon,
74
+ )
75
+
76
+ console.print(table)
77
+ console.print()
78
+
79
+ # Summary
80
+ coverage_color = "green" if report.coverage_percent >= 100 else "yellow"
81
+ if report.coverage_percent < 50:
82
+ coverage_color = "red"
83
+
84
+ console.print(
85
+ f"[{coverage_color}]Coverage: {report.coverage_percent:.0f}% "
86
+ f"({report.covered_count}/{report.total_count})[/{coverage_color}]"
87
+ )
88
+
89
+ # Show orphaned references if any
90
+ if report.orphaned_references:
91
+ console.print()
92
+ console.print("[red]Orphaned References:[/red]")
93
+ for task, ref_id in report.orphaned_references:
94
+ console.print(f" - Task at line {task.line_number} references {ref_id}")
95
+
96
+
97
+ def _format_coverage_json(report: CoverageReport) -> str:
98
+ """Format coverage report as JSON."""
99
+ data = {
100
+ "spec": Path(report.spec_path).parent.name,
101
+ "requirements": [
102
+ {
103
+ "id": rc.requirement.id,
104
+ "task_count": rc.task_count,
105
+ "covered": rc.is_covered,
106
+ "status": rc.status.value,
107
+ }
108
+ for rc in report.requirements
109
+ ],
110
+ "coverage_percent": round(report.coverage_percent, 1),
111
+ "covered_count": report.covered_count,
112
+ "total_count": report.total_count,
113
+ "orphaned_references": [
114
+ {"task_line": task.line_number, "reference": ref_id}
115
+ for task, ref_id in report.orphaned_references
116
+ ],
117
+ }
118
+ return json.dumps(data, indent=2)
119
+
120
+
121
+ def _format_coverage_markdown(report: CoverageReport) -> str:
122
+ """Format coverage report as Markdown."""
123
+ lines = [
124
+ f"# Requirement Coverage: {Path(report.spec_path).parent.name}",
125
+ "",
126
+ "| Requirement | Tasks | Status |",
127
+ "|-------------|-------|--------|",
128
+ ]
129
+
130
+ for rc in report.requirements:
131
+ status = {
132
+ CoverageStatus.COVERED: "\u2713 Covered",
133
+ CoverageStatus.PARTIAL: "\u25d0 Partial",
134
+ CoverageStatus.UNCOVERED: "\u26a0 Uncovered",
135
+ }.get(rc.status, "Unknown")
136
+
137
+ lines.append(f"| {rc.requirement.id} | {rc.task_count} | {status} |")
138
+
139
+ lines.extend(
140
+ [
141
+ "",
142
+ f"**Coverage**: {report.coverage_percent:.0f}% "
143
+ f"({report.covered_count}/{report.total_count})",
144
+ ]
145
+ )
146
+
147
+ if report.orphaned_references:
148
+ lines.extend(["", "## Orphaned References", ""])
149
+ for task, ref_id in report.orphaned_references:
150
+ lines.append(f"- Task at line {task.line_number} references `{ref_id}`")
151
+
152
+ return "\n".join(lines)
153
+
154
+
155
+ @xref_app.command(name="coverage")
156
+ def coverage_command(
157
+ spec_name: Optional[str] = typer.Argument(
158
+ None, help="Spec directory name (default: auto-detect from branch)"
159
+ ),
160
+ output_format: str = typer.Option(
161
+ "rich", "--format", "-f", help="Output format: rich, json, markdown"
162
+ ),
163
+ strict: bool = typer.Option(
164
+ False, "--strict", "-s", help="Treat uncovered requirements as errors"
165
+ ),
166
+ output_file: Optional[Path] = typer.Option(
167
+ None, "--output", "-o", help="Write output to file"
168
+ ),
169
+ ) -> None:
170
+ """Generate a coverage report showing requirement-to-task mapping.
171
+
172
+ Shows which requirements have implementing tasks and identifies
173
+ any uncovered requirements or orphaned task references.
174
+
175
+ Exit codes:
176
+ 0 - All requirements covered
177
+ 1 - Uncovered requirements (with --strict) or errors
178
+ 2 - Spec not found or invalid
179
+ """
180
+ try:
181
+ # Auto-detect spec from branch if not provided
182
+ if not spec_name:
183
+ spec_name = _detect_spec_from_branch()
184
+ if not spec_name:
185
+ console.print(
186
+ "[red]Error:[/red] Could not detect spec. "
187
+ "Please provide spec name or run from a feature branch."
188
+ )
189
+ raise typer.Exit(code=2)
190
+
191
+ # Validate format
192
+ valid_formats = ["rich", "json", "markdown"]
193
+ if output_format not in valid_formats:
194
+ console.print(
195
+ f"[red]Error:[/red] Invalid format '{output_format}'. "
196
+ f"Valid: {', '.join(valid_formats)}"
197
+ )
198
+ raise typer.Exit(code=2)
199
+
200
+ # Get coverage report
201
+ service = CrossReferenceService()
202
+ try:
203
+ report = service.get_coverage(spec_name=spec_name)
204
+ except FileNotFoundError as e:
205
+ console.print(f"[red]Error:[/red] {e}")
206
+ raise typer.Exit(code=2)
207
+
208
+ # Format output
209
+ if output_format == "json":
210
+ output_str = _format_coverage_json(report)
211
+ elif output_format == "markdown":
212
+ output_str = _format_coverage_markdown(report)
213
+ else:
214
+ output_str = None # Rich output handled separately
215
+
216
+ # Write to file or stdout
217
+ if output_file:
218
+ if output_format == "rich":
219
+ # For rich format to file, use markdown instead
220
+ output_str = _format_coverage_markdown(report)
221
+ output_file.write_text(output_str)
222
+ console.print(f"[green]Report written to {output_file}[/green]")
223
+ elif output_format == "rich":
224
+ _format_coverage_rich(report, console)
225
+ else:
226
+ print(output_str)
227
+
228
+ # Determine exit code
229
+ has_orphaned = len(report.orphaned_references) > 0
230
+ has_uncovered = report.uncovered_count > 0
231
+
232
+ if has_orphaned:
233
+ raise typer.Exit(code=1)
234
+ if strict and has_uncovered:
235
+ raise typer.Exit(code=1)
236
+
237
+ raise typer.Exit(code=0)
238
+
239
+ except NotADoitProjectError as e:
240
+ console.print(f"[red]Error:[/red] {e}")
241
+ raise typer.Exit(code=2)
242
+
243
+
244
+ @xref_app.command(name="locate")
245
+ def locate_command(
246
+ requirement_id: str = typer.Argument(..., help="Requirement ID (e.g., FR-001)"),
247
+ spec: Optional[str] = typer.Option(
248
+ None, "--spec", "-s", help="Spec name or path (default: auto-detect)"
249
+ ),
250
+ output_format: str = typer.Option(
251
+ "rich", "--format", "-f", help="Output format: rich, json, line"
252
+ ),
253
+ ) -> None:
254
+ """Find the definition of a requirement in spec.md.
255
+
256
+ Exit codes:
257
+ 0 - Requirement found
258
+ 1 - Requirement not found
259
+ 2 - Spec file not found
260
+ """
261
+ try:
262
+ # Determine spec name
263
+ spec_name = spec or _detect_spec_from_branch()
264
+ if not spec_name:
265
+ console.print(
266
+ "[red]Error:[/red] Could not detect spec. "
267
+ "Please provide --spec or run from a feature branch."
268
+ )
269
+ raise typer.Exit(code=2)
270
+
271
+ # Validate format
272
+ valid_formats = ["rich", "json", "line"]
273
+ if output_format not in valid_formats:
274
+ console.print(
275
+ f"[red]Error:[/red] Invalid format '{output_format}'. "
276
+ f"Valid: {', '.join(valid_formats)}"
277
+ )
278
+ raise typer.Exit(code=2)
279
+
280
+ # Find requirement
281
+ service = CrossReferenceService()
282
+ try:
283
+ req = service.locate_requirement(requirement_id, spec_name=spec_name)
284
+ except (FileNotFoundError, ValueError) as e:
285
+ console.print(f"[red]Error:[/red] {e}")
286
+ raise typer.Exit(code=2)
287
+
288
+ if req is None:
289
+ console.print(
290
+ f"[yellow]Requirement {requirement_id} not found in spec.[/yellow]"
291
+ )
292
+ raise typer.Exit(code=1)
293
+
294
+ # Format output
295
+ if output_format == "json":
296
+ data = {
297
+ "id": req.id,
298
+ "description": req.description,
299
+ "file": req.spec_path,
300
+ "line": req.line_number,
301
+ }
302
+ print(json.dumps(data, indent=2))
303
+ elif output_format == "line":
304
+ # Just file:line for IDE integration
305
+ print(f"{req.spec_path}:{req.line_number}")
306
+ else:
307
+ # Rich format
308
+ console.print(f"[cyan]{req.id}[/cyan]: {req.description}")
309
+ console.print(f"Location: [dim]{req.spec_path}:{req.line_number}[/dim]")
310
+
311
+ raise typer.Exit(code=0)
312
+
313
+ except NotADoitProjectError as e:
314
+ console.print(f"[red]Error:[/red] {e}")
315
+ raise typer.Exit(code=2)
316
+
317
+
318
+ @xref_app.command(name="tasks")
319
+ def tasks_command(
320
+ requirement_id: str = typer.Argument(..., help="Requirement ID (e.g., FR-001)"),
321
+ spec: Optional[str] = typer.Option(
322
+ None, "--spec", "-s", help="Spec name (default: auto-detect)"
323
+ ),
324
+ output_format: str = typer.Option(
325
+ "rich", "--format", "-f", help="Output format: rich, json, markdown"
326
+ ),
327
+ ) -> None:
328
+ """List all tasks that implement a specific requirement.
329
+
330
+ Exit codes:
331
+ 0 - Tasks found
332
+ 1 - No tasks found for requirement
333
+ 2 - Requirement or spec not found
334
+ """
335
+ try:
336
+ # Determine spec name
337
+ spec_name = spec or _detect_spec_from_branch()
338
+ if not spec_name:
339
+ console.print(
340
+ "[red]Error:[/red] Could not detect spec. "
341
+ "Please provide --spec or run from a feature branch."
342
+ )
343
+ raise typer.Exit(code=2)
344
+
345
+ # Validate format
346
+ valid_formats = ["rich", "json", "markdown"]
347
+ if output_format not in valid_formats:
348
+ console.print(
349
+ f"[red]Error:[/red] Invalid format '{output_format}'. "
350
+ f"Valid: {', '.join(valid_formats)}"
351
+ )
352
+ raise typer.Exit(code=2)
353
+
354
+ # Get tasks
355
+ service = CrossReferenceService()
356
+ try:
357
+ tasks = service.get_tasks_for_requirement(requirement_id, spec_name=spec_name)
358
+ except (FileNotFoundError, ValueError) as e:
359
+ console.print(f"[red]Error:[/red] {e}")
360
+ raise typer.Exit(code=2)
361
+
362
+ if not tasks:
363
+ console.print(
364
+ f"[yellow]No tasks found implementing {requirement_id}[/yellow]"
365
+ )
366
+ raise typer.Exit(code=1)
367
+
368
+ # Format output
369
+ if output_format == "json":
370
+ data = {
371
+ "requirement_id": requirement_id,
372
+ "tasks": [
373
+ {
374
+ "line": t.line_number,
375
+ "completed": t.completed,
376
+ "description": t.description,
377
+ }
378
+ for t in tasks
379
+ ],
380
+ "count": len(tasks),
381
+ "completed_count": sum(1 for t in tasks if t.completed),
382
+ }
383
+ print(json.dumps(data, indent=2))
384
+ elif output_format == "markdown":
385
+ lines = [
386
+ f"# Tasks implementing {requirement_id}",
387
+ "",
388
+ "| Line | Status | Description |",
389
+ "|------|--------|-------------|",
390
+ ]
391
+ for t in tasks:
392
+ status = "[x]" if t.completed else "[ ]"
393
+ lines.append(f"| {t.line_number} | {status} | {t.description} |")
394
+
395
+ completed = sum(1 for t in tasks if t.completed)
396
+ lines.extend(
397
+ ["", f"Found {len(tasks)} tasks ({completed} complete, {len(tasks) - completed} pending)"]
398
+ )
399
+ print("\n".join(lines))
400
+ else:
401
+ # Rich format
402
+ console.print(f"\n[bold]Tasks implementing {requirement_id}:[/bold]\n")
403
+
404
+ table = Table(show_header=True, header_style="bold")
405
+ table.add_column("Line", justify="right")
406
+ table.add_column("Status")
407
+ table.add_column("Description")
408
+
409
+ for t in tasks:
410
+ status = "[green][x][/green]" if t.completed else "[ ]"
411
+ table.add_row(str(t.line_number), status, t.description)
412
+
413
+ console.print(table)
414
+
415
+ completed = sum(1 for t in tasks if t.completed)
416
+ console.print(
417
+ f"\nFound {len(tasks)} tasks ({completed} complete, {len(tasks) - completed} pending)"
418
+ )
419
+
420
+ raise typer.Exit(code=0)
421
+
422
+ except NotADoitProjectError as e:
423
+ console.print(f"[red]Error:[/red] {e}")
424
+ raise typer.Exit(code=2)
425
+
426
+
427
+ @xref_app.command(name="validate")
428
+ def validate_command(
429
+ spec_name: Optional[str] = typer.Argument(
430
+ None, help="Spec directory name (default: auto-detect from branch)"
431
+ ),
432
+ strict: bool = typer.Option(
433
+ False, "--strict", "-s", help="Treat warnings as errors"
434
+ ),
435
+ output_format: str = typer.Option(
436
+ "rich", "--format", "-f", help="Output format: rich, json"
437
+ ),
438
+ ) -> None:
439
+ """Validate cross-reference integrity between spec and tasks.
440
+
441
+ Checks for:
442
+ - Orphaned task references (tasks referencing non-existent requirements)
443
+ - Uncovered requirements (requirements with no linked tasks)
444
+
445
+ Exit codes:
446
+ 0 - All cross-references valid
447
+ 1 - Validation errors found
448
+ 2 - Files not found
449
+ """
450
+ try:
451
+ # Auto-detect spec from branch if not provided
452
+ if not spec_name:
453
+ spec_name = _detect_spec_from_branch()
454
+ if not spec_name:
455
+ console.print(
456
+ "[red]Error:[/red] Could not detect spec. "
457
+ "Please provide spec name or run from a feature branch."
458
+ )
459
+ raise typer.Exit(code=2)
460
+
461
+ # Validate format
462
+ valid_formats = ["rich", "json"]
463
+ if output_format not in valid_formats:
464
+ console.print(
465
+ f"[red]Error:[/red] Invalid format '{output_format}'. "
466
+ f"Valid: {', '.join(valid_formats)}"
467
+ )
468
+ raise typer.Exit(code=2)
469
+
470
+ # Validate references
471
+ service = CrossReferenceService()
472
+ try:
473
+ uncovered, orphaned = service.validate_references(spec_name=spec_name)
474
+ report = service.get_coverage(spec_name=spec_name)
475
+ except FileNotFoundError as e:
476
+ console.print(f"[red]Error:[/red] {e}")
477
+ raise typer.Exit(code=2)
478
+
479
+ # Count issues
480
+ error_count = len(orphaned) # Orphaned references are always errors
481
+ warning_count = len(uncovered) # Uncovered are warnings (errors in strict)
482
+
483
+ if strict:
484
+ error_count += warning_count
485
+ warning_count = 0
486
+
487
+ # Format output
488
+ if output_format == "json":
489
+ data = {
490
+ "spec": spec_name,
491
+ "valid": error_count == 0 and warning_count == 0,
492
+ "errors": error_count,
493
+ "warnings": warning_count,
494
+ "orphaned_references": [
495
+ {"task_line": t.line_number, "reference": ref}
496
+ for t, ref in orphaned
497
+ ],
498
+ "uncovered_requirements": uncovered,
499
+ }
500
+ print(json.dumps(data, indent=2))
501
+ else:
502
+ # Rich format
503
+ console.print(f"\n[bold]Cross-Reference Validation: {spec_name}[/bold]")
504
+ console.print("=" * 50)
505
+ console.print()
506
+
507
+ # Show results
508
+ if not orphaned:
509
+ console.print(
510
+ f"[green]\u2713[/green] All {report.total_count} requirement "
511
+ "references are valid"
512
+ )
513
+ else:
514
+ console.print("[red]\u2717 Orphaned References:[/red]")
515
+ for task, ref_id in orphaned:
516
+ console.print(
517
+ f" - Task at line {task.line_number} references "
518
+ f"non-existent {ref_id}"
519
+ )
520
+
521
+ if uncovered:
522
+ severity = "[red]\u2717" if strict else "[yellow]\u26a0"
523
+ console.print(
524
+ f"\n{severity} {len(uncovered)} requirements have no linked tasks: "
525
+ f"{', '.join(uncovered)}[/]"
526
+ )
527
+ elif report.total_count > 0:
528
+ console.print(
529
+ f"[green]\u2713[/green] All requirements have linked tasks"
530
+ )
531
+
532
+ # Summary
533
+ console.print()
534
+ if error_count == 0 and warning_count == 0:
535
+ console.print("[green]Validation: PASS[/green]")
536
+ elif error_count > 0:
537
+ console.print(
538
+ f"[red]Validation: FAIL ({error_count} errors, "
539
+ f"{warning_count} warnings)[/red]"
540
+ )
541
+ else:
542
+ console.print(
543
+ f"[yellow]Validation: WARN (0 errors, "
544
+ f"{warning_count} warnings)[/yellow]"
545
+ )
546
+
547
+ # Determine exit code
548
+ if error_count > 0:
549
+ raise typer.Exit(code=1)
550
+
551
+ raise typer.Exit(code=0)
552
+
553
+ except NotADoitProjectError as e:
554
+ console.print(f"[red]Error:[/red] {e}")
555
+ raise typer.Exit(code=2)
@@ -0,0 +1,8 @@
1
+ """Formatters for status output."""
2
+
3
+ from .base import StatusFormatter
4
+ from .json_formatter import JsonFormatter
5
+ from .markdown_formatter import MarkdownFormatter
6
+ from .rich_formatter import RichFormatter
7
+
8
+ __all__ = ["StatusFormatter", "JsonFormatter", "MarkdownFormatter", "RichFormatter"]
@@ -0,0 +1,38 @@
1
+ """Base formatter for status output."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ..models.status_models import StatusReport
6
+
7
+
8
+ class StatusFormatter(ABC):
9
+ """Abstract base class for status output formatters.
10
+
11
+ Subclasses implement format() to produce output in different formats
12
+ (rich terminal, JSON, markdown, etc.).
13
+ """
14
+
15
+ @abstractmethod
16
+ def format(self, report: StatusReport, verbose: bool = False) -> str:
17
+ """Format the status report for output.
18
+
19
+ Args:
20
+ report: The StatusReport to format.
21
+ verbose: Include detailed validation errors.
22
+
23
+ Returns:
24
+ Formatted string representation.
25
+ """
26
+ pass
27
+
28
+ def format_to_console(self, report: StatusReport, verbose: bool = False) -> None:
29
+ """Format and print directly to console.
30
+
31
+ Default implementation calls format() and prints.
32
+ Subclasses may override for richer output (e.g., Rich tables).
33
+
34
+ Args:
35
+ report: The StatusReport to format.
36
+ verbose: Include detailed validation errors.
37
+ """
38
+ print(self.format(report, verbose))