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.
- doit_cli/__init__.py +1356 -0
- doit_cli/cli/__init__.py +26 -0
- doit_cli/cli/analytics_command.py +616 -0
- doit_cli/cli/context_command.py +213 -0
- doit_cli/cli/diagram_command.py +304 -0
- doit_cli/cli/fixit_command.py +641 -0
- doit_cli/cli/hooks_command.py +211 -0
- doit_cli/cli/init_command.py +613 -0
- doit_cli/cli/memory_command.py +293 -0
- doit_cli/cli/roadmapit_command.py +10 -0
- doit_cli/cli/status_command.py +117 -0
- doit_cli/cli/sync_prompts_command.py +248 -0
- doit_cli/cli/validate_command.py +196 -0
- doit_cli/cli/verify_command.py +204 -0
- doit_cli/cli/workflow_mixin.py +224 -0
- doit_cli/cli/xref_command.py +555 -0
- doit_cli/formatters/__init__.py +8 -0
- doit_cli/formatters/base.py +38 -0
- doit_cli/formatters/json_formatter.py +126 -0
- doit_cli/formatters/markdown_formatter.py +97 -0
- doit_cli/formatters/rich_formatter.py +257 -0
- doit_cli/main.py +51 -0
- doit_cli/models/__init__.py +139 -0
- doit_cli/models/agent.py +74 -0
- doit_cli/models/analytics_models.py +384 -0
- doit_cli/models/context_config.py +464 -0
- doit_cli/models/crossref_models.py +182 -0
- doit_cli/models/diagram_models.py +363 -0
- doit_cli/models/fixit_models.py +355 -0
- doit_cli/models/hook_config.py +125 -0
- doit_cli/models/project.py +91 -0
- doit_cli/models/results.py +121 -0
- doit_cli/models/search_models.py +228 -0
- doit_cli/models/status_models.py +195 -0
- doit_cli/models/sync_models.py +146 -0
- doit_cli/models/template.py +77 -0
- doit_cli/models/validation_models.py +175 -0
- doit_cli/models/workflow_models.py +319 -0
- doit_cli/prompts/__init__.py +5 -0
- doit_cli/prompts/fixit_prompts.py +344 -0
- doit_cli/prompts/interactive.py +390 -0
- doit_cli/rules/__init__.py +5 -0
- doit_cli/rules/builtin_rules.py +160 -0
- doit_cli/services/__init__.py +79 -0
- doit_cli/services/agent_detector.py +168 -0
- doit_cli/services/analytics_service.py +218 -0
- doit_cli/services/architecture_generator.py +290 -0
- doit_cli/services/backup_service.py +204 -0
- doit_cli/services/config_loader.py +113 -0
- doit_cli/services/context_loader.py +1123 -0
- doit_cli/services/coverage_calculator.py +142 -0
- doit_cli/services/crossref_service.py +237 -0
- doit_cli/services/cycle_time_calculator.py +134 -0
- doit_cli/services/date_inferrer.py +349 -0
- doit_cli/services/diagram_service.py +337 -0
- doit_cli/services/drift_detector.py +109 -0
- doit_cli/services/entity_parser.py +301 -0
- doit_cli/services/er_diagram_generator.py +197 -0
- doit_cli/services/fixit_service.py +699 -0
- doit_cli/services/github_service.py +192 -0
- doit_cli/services/hook_manager.py +258 -0
- doit_cli/services/hook_validator.py +528 -0
- doit_cli/services/input_validator.py +322 -0
- doit_cli/services/memory_search.py +527 -0
- doit_cli/services/mermaid_validator.py +334 -0
- doit_cli/services/prompt_transformer.py +91 -0
- doit_cli/services/prompt_writer.py +133 -0
- doit_cli/services/query_interpreter.py +428 -0
- doit_cli/services/report_exporter.py +219 -0
- doit_cli/services/report_generator.py +256 -0
- doit_cli/services/requirement_parser.py +112 -0
- doit_cli/services/roadmap_summarizer.py +209 -0
- doit_cli/services/rule_engine.py +443 -0
- doit_cli/services/scaffolder.py +215 -0
- doit_cli/services/score_calculator.py +172 -0
- doit_cli/services/section_parser.py +204 -0
- doit_cli/services/spec_scanner.py +327 -0
- doit_cli/services/state_manager.py +355 -0
- doit_cli/services/status_reporter.py +143 -0
- doit_cli/services/task_parser.py +347 -0
- doit_cli/services/template_manager.py +710 -0
- doit_cli/services/template_reader.py +158 -0
- doit_cli/services/user_journey_generator.py +214 -0
- doit_cli/services/user_story_parser.py +232 -0
- doit_cli/services/validation_service.py +188 -0
- doit_cli/services/validator.py +232 -0
- doit_cli/services/velocity_tracker.py +173 -0
- doit_cli/services/workflow_engine.py +405 -0
- doit_cli/templates/agent-file-template.md +28 -0
- doit_cli/templates/checklist-template.md +39 -0
- doit_cli/templates/commands/doit.checkin.md +363 -0
- doit_cli/templates/commands/doit.constitution.md +187 -0
- doit_cli/templates/commands/doit.documentit.md +485 -0
- doit_cli/templates/commands/doit.fixit.md +181 -0
- doit_cli/templates/commands/doit.implementit.md +265 -0
- doit_cli/templates/commands/doit.planit.md +262 -0
- doit_cli/templates/commands/doit.reviewit.md +355 -0
- doit_cli/templates/commands/doit.roadmapit.md +389 -0
- doit_cli/templates/commands/doit.scaffoldit.md +458 -0
- doit_cli/templates/commands/doit.specit.md +521 -0
- doit_cli/templates/commands/doit.taskit.md +304 -0
- doit_cli/templates/commands/doit.testit.md +277 -0
- doit_cli/templates/config/context.yaml +134 -0
- doit_cli/templates/config/hooks.yaml +93 -0
- doit_cli/templates/config/validation-rules.yaml +64 -0
- doit_cli/templates/github-issue-templates/epic.yml +78 -0
- doit_cli/templates/github-issue-templates/feature.yml +116 -0
- doit_cli/templates/github-issue-templates/task.yml +129 -0
- doit_cli/templates/hooks/.gitkeep +0 -0
- doit_cli/templates/hooks/post-commit.sh +25 -0
- doit_cli/templates/hooks/post-merge.sh +75 -0
- doit_cli/templates/hooks/pre-commit.sh +17 -0
- doit_cli/templates/hooks/pre-push.sh +18 -0
- doit_cli/templates/memory/completed_roadmap.md +50 -0
- doit_cli/templates/memory/constitution.md +125 -0
- doit_cli/templates/memory/roadmap.md +61 -0
- doit_cli/templates/plan-template.md +146 -0
- doit_cli/templates/scripts/bash/check-prerequisites.sh +166 -0
- doit_cli/templates/scripts/bash/common.sh +156 -0
- doit_cli/templates/scripts/bash/create-new-feature.sh +297 -0
- doit_cli/templates/scripts/bash/setup-plan.sh +61 -0
- doit_cli/templates/scripts/bash/update-agent-context.sh +675 -0
- doit_cli/templates/scripts/powershell/check-prerequisites.ps1 +148 -0
- doit_cli/templates/scripts/powershell/common.ps1 +137 -0
- doit_cli/templates/scripts/powershell/create-new-feature.ps1 +283 -0
- doit_cli/templates/scripts/powershell/setup-plan.ps1 +61 -0
- doit_cli/templates/scripts/powershell/update-agent-context.ps1 +406 -0
- doit_cli/templates/spec-template.md +159 -0
- doit_cli/templates/tasks-template.md +313 -0
- doit_cli/templates/vscode-settings.json +14 -0
- doit_toolkit_cli-0.1.10.dist-info/METADATA +324 -0
- doit_toolkit_cli-0.1.10.dist-info/RECORD +135 -0
- doit_toolkit_cli-0.1.10.dist-info/WHEEL +4 -0
- doit_toolkit_cli-0.1.10.dist-info/entry_points.txt +2 -0
- doit_toolkit_cli-0.1.10.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Calculator for requirement coverage metrics."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models.crossref_models import (
|
|
7
|
+
CoverageReport,
|
|
8
|
+
Requirement,
|
|
9
|
+
RequirementCoverage,
|
|
10
|
+
Task,
|
|
11
|
+
)
|
|
12
|
+
from .requirement_parser import RequirementParser
|
|
13
|
+
from .task_parser import TaskParser
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CoverageCalculator:
|
|
17
|
+
"""Calculates coverage metrics for requirements.
|
|
18
|
+
|
|
19
|
+
This service matches requirements from spec.md to tasks from tasks.md
|
|
20
|
+
and computes coverage percentages and identifies gaps.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
requirement_parser: Optional[RequirementParser] = None,
|
|
26
|
+
task_parser: Optional[TaskParser] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize calculator with optional parsers.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
requirement_parser: Parser for spec.md. Created if not provided.
|
|
32
|
+
task_parser: Parser for tasks.md. Created if not provided.
|
|
33
|
+
"""
|
|
34
|
+
self.requirement_parser = requirement_parser or RequirementParser()
|
|
35
|
+
self.task_parser = task_parser or TaskParser()
|
|
36
|
+
|
|
37
|
+
def calculate(
|
|
38
|
+
self,
|
|
39
|
+
spec_path: Path,
|
|
40
|
+
tasks_path: Optional[Path] = None,
|
|
41
|
+
) -> CoverageReport:
|
|
42
|
+
"""Calculate coverage for a specification.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
spec_path: Path to spec.md file.
|
|
46
|
+
tasks_path: Path to tasks.md file. Defaults to tasks.md in same directory.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
CoverageReport with all coverage metrics.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
FileNotFoundError: If spec.md doesn't exist.
|
|
53
|
+
"""
|
|
54
|
+
spec_path = Path(spec_path)
|
|
55
|
+
if not spec_path.exists():
|
|
56
|
+
raise FileNotFoundError(f"Spec file not found: {spec_path}")
|
|
57
|
+
|
|
58
|
+
# Default tasks path to same directory as spec
|
|
59
|
+
if tasks_path is None:
|
|
60
|
+
tasks_path = spec_path.parent / "tasks.md"
|
|
61
|
+
|
|
62
|
+
# Parse requirements from spec
|
|
63
|
+
requirements = self.requirement_parser.parse(spec_path)
|
|
64
|
+
|
|
65
|
+
# Parse tasks (empty list if tasks.md doesn't exist)
|
|
66
|
+
tasks: list[Task] = []
|
|
67
|
+
if tasks_path.exists():
|
|
68
|
+
tasks = self.task_parser.parse(tasks_path)
|
|
69
|
+
|
|
70
|
+
# Build coverage data
|
|
71
|
+
return self._build_coverage_report(
|
|
72
|
+
spec_path=str(spec_path),
|
|
73
|
+
requirements=requirements,
|
|
74
|
+
tasks=tasks,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _build_coverage_report(
|
|
78
|
+
self,
|
|
79
|
+
spec_path: str,
|
|
80
|
+
requirements: list[Requirement],
|
|
81
|
+
tasks: list[Task],
|
|
82
|
+
) -> CoverageReport:
|
|
83
|
+
"""Build a CoverageReport from parsed data.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
spec_path: Path to spec.md file.
|
|
87
|
+
requirements: List of requirements from spec.
|
|
88
|
+
tasks: List of tasks from tasks.md.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
CoverageReport with requirement-task mappings.
|
|
92
|
+
"""
|
|
93
|
+
# Build map of requirement ID -> requirement
|
|
94
|
+
req_map: dict[str, Requirement] = {r.id: r for r in requirements}
|
|
95
|
+
|
|
96
|
+
# Build requirement coverage entries
|
|
97
|
+
coverage_list: list[RequirementCoverage] = []
|
|
98
|
+
for req in requirements:
|
|
99
|
+
# Find tasks that reference this requirement
|
|
100
|
+
matching_tasks = [t for t in tasks if req.id in t.requirement_ids]
|
|
101
|
+
coverage_list.append(
|
|
102
|
+
RequirementCoverage(
|
|
103
|
+
requirement=req,
|
|
104
|
+
tasks=matching_tasks,
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Find orphaned references (tasks referencing non-existent requirements)
|
|
109
|
+
orphaned: list[tuple[Task, str]] = []
|
|
110
|
+
for task in tasks:
|
|
111
|
+
for ref in task.references:
|
|
112
|
+
if ref.requirement_id not in req_map:
|
|
113
|
+
orphaned.append((task, ref.requirement_id))
|
|
114
|
+
|
|
115
|
+
return CoverageReport(
|
|
116
|
+
spec_path=spec_path,
|
|
117
|
+
requirements=coverage_list,
|
|
118
|
+
orphaned_references=orphaned,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def calculate_for_feature(
|
|
122
|
+
self,
|
|
123
|
+
feature_dir: Path,
|
|
124
|
+
) -> CoverageReport:
|
|
125
|
+
"""Calculate coverage for a feature directory.
|
|
126
|
+
|
|
127
|
+
Convenience method that finds spec.md and tasks.md in the feature directory.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
feature_dir: Path to feature directory (e.g., specs/033-my-feature/)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
CoverageReport for the feature.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
FileNotFoundError: If spec.md doesn't exist in feature directory.
|
|
137
|
+
"""
|
|
138
|
+
feature_dir = Path(feature_dir)
|
|
139
|
+
spec_path = feature_dir / "spec.md"
|
|
140
|
+
tasks_path = feature_dir / "tasks.md"
|
|
141
|
+
|
|
142
|
+
return self.calculate(spec_path, tasks_path)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Cross-reference service for spec-task traceability."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..models.crossref_models import (
|
|
7
|
+
CoverageReport,
|
|
8
|
+
Requirement,
|
|
9
|
+
RequirementCoverage,
|
|
10
|
+
Task,
|
|
11
|
+
)
|
|
12
|
+
from .coverage_calculator import CoverageCalculator
|
|
13
|
+
from .requirement_parser import RequirementParser
|
|
14
|
+
from .task_parser import TaskParser
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CrossReferenceService:
|
|
18
|
+
"""Service for managing cross-references between specs and tasks.
|
|
19
|
+
|
|
20
|
+
This service provides a unified API for:
|
|
21
|
+
- Getting coverage reports
|
|
22
|
+
- Finding tasks for requirements
|
|
23
|
+
- Finding requirements for tasks
|
|
24
|
+
- Locating requirement definitions
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
project_root: Optional[Path] = None,
|
|
30
|
+
specs_dir: str = "specs",
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Initialize the service.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
project_root: Root directory of the project. Defaults to cwd.
|
|
36
|
+
specs_dir: Name of the specs directory.
|
|
37
|
+
"""
|
|
38
|
+
self.project_root = Path(project_root) if project_root else Path.cwd()
|
|
39
|
+
self.specs_dir = specs_dir
|
|
40
|
+
self.requirement_parser = RequirementParser()
|
|
41
|
+
self.task_parser = TaskParser()
|
|
42
|
+
self.coverage_calculator = CoverageCalculator(
|
|
43
|
+
self.requirement_parser,
|
|
44
|
+
self.task_parser,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def get_coverage(
|
|
48
|
+
self,
|
|
49
|
+
spec_name: Optional[str] = None,
|
|
50
|
+
spec_path: Optional[Path] = None,
|
|
51
|
+
) -> CoverageReport:
|
|
52
|
+
"""Get coverage report for a specification.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
spec_name: Name of spec directory (e.g., "033-my-feature").
|
|
56
|
+
spec_path: Direct path to spec.md. Overrides spec_name.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
CoverageReport for the specification.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
FileNotFoundError: If spec doesn't exist.
|
|
63
|
+
ValueError: If neither spec_name nor spec_path provided.
|
|
64
|
+
"""
|
|
65
|
+
if spec_path:
|
|
66
|
+
return self.coverage_calculator.calculate(spec_path)
|
|
67
|
+
|
|
68
|
+
if spec_name:
|
|
69
|
+
feature_dir = self.project_root / self.specs_dir / spec_name
|
|
70
|
+
return self.coverage_calculator.calculate_for_feature(feature_dir)
|
|
71
|
+
|
|
72
|
+
raise ValueError("Either spec_name or spec_path must be provided")
|
|
73
|
+
|
|
74
|
+
def get_tasks_for_requirement(
|
|
75
|
+
self,
|
|
76
|
+
requirement_id: str,
|
|
77
|
+
spec_name: Optional[str] = None,
|
|
78
|
+
tasks_path: Optional[Path] = None,
|
|
79
|
+
) -> list[Task]:
|
|
80
|
+
"""Get all tasks that implement a specific requirement.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
requirement_id: The FR-XXX ID to search for.
|
|
84
|
+
spec_name: Name of spec directory.
|
|
85
|
+
tasks_path: Direct path to tasks.md.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of tasks that reference the requirement.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If neither spec_name nor tasks_path provided.
|
|
92
|
+
"""
|
|
93
|
+
if tasks_path:
|
|
94
|
+
return self.task_parser.get_tasks_for_requirement(
|
|
95
|
+
requirement_id, tasks_path
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if spec_name:
|
|
99
|
+
feature_dir = self.project_root / self.specs_dir / spec_name
|
|
100
|
+
tasks_file = feature_dir / "tasks.md"
|
|
101
|
+
if not tasks_file.exists():
|
|
102
|
+
return []
|
|
103
|
+
return self.task_parser.get_tasks_for_requirement(
|
|
104
|
+
requirement_id, tasks_file
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
raise ValueError("Either spec_name or tasks_path must be provided")
|
|
108
|
+
|
|
109
|
+
def locate_requirement(
|
|
110
|
+
self,
|
|
111
|
+
requirement_id: str,
|
|
112
|
+
spec_name: Optional[str] = None,
|
|
113
|
+
spec_path: Optional[Path] = None,
|
|
114
|
+
) -> Optional[Requirement]:
|
|
115
|
+
"""Find a requirement definition in a spec.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
requirement_id: The FR-XXX ID to find.
|
|
119
|
+
spec_name: Name of spec directory.
|
|
120
|
+
spec_path: Direct path to spec.md.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Requirement if found, None otherwise.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If neither spec_name nor spec_path provided.
|
|
127
|
+
"""
|
|
128
|
+
if spec_path:
|
|
129
|
+
return self.requirement_parser.get_requirement(requirement_id, spec_path)
|
|
130
|
+
|
|
131
|
+
if spec_name:
|
|
132
|
+
feature_dir = self.project_root / self.specs_dir / spec_name
|
|
133
|
+
spec_file = feature_dir / "spec.md"
|
|
134
|
+
if not spec_file.exists():
|
|
135
|
+
return None
|
|
136
|
+
return self.requirement_parser.get_requirement(requirement_id, spec_file)
|
|
137
|
+
|
|
138
|
+
raise ValueError("Either spec_name or spec_path must be provided")
|
|
139
|
+
|
|
140
|
+
def get_requirement_coverage(
|
|
141
|
+
self,
|
|
142
|
+
requirement_id: str,
|
|
143
|
+
spec_name: Optional[str] = None,
|
|
144
|
+
spec_path: Optional[Path] = None,
|
|
145
|
+
) -> Optional[RequirementCoverage]:
|
|
146
|
+
"""Get coverage details for a specific requirement.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
requirement_id: The FR-XXX ID to check.
|
|
150
|
+
spec_name: Name of spec directory.
|
|
151
|
+
spec_path: Direct path to spec.md.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
RequirementCoverage if requirement exists, None otherwise.
|
|
155
|
+
"""
|
|
156
|
+
report = self.get_coverage(spec_name=spec_name, spec_path=spec_path)
|
|
157
|
+
return report.get_requirement_coverage(requirement_id)
|
|
158
|
+
|
|
159
|
+
def validate_references(
|
|
160
|
+
self,
|
|
161
|
+
spec_name: Optional[str] = None,
|
|
162
|
+
spec_path: Optional[Path] = None,
|
|
163
|
+
) -> tuple[list[str], list[tuple[Task, str]]]:
|
|
164
|
+
"""Validate cross-references and find issues.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
spec_name: Name of spec directory.
|
|
168
|
+
spec_path: Direct path to spec.md.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Tuple of (uncovered_requirement_ids, orphaned_references).
|
|
172
|
+
orphaned_references is list of (task, invalid_requirement_id) tuples.
|
|
173
|
+
"""
|
|
174
|
+
report = self.get_coverage(spec_name=spec_name, spec_path=spec_path)
|
|
175
|
+
|
|
176
|
+
# Find uncovered requirements
|
|
177
|
+
uncovered = [r.id for r in report.get_uncovered_requirements()]
|
|
178
|
+
|
|
179
|
+
return uncovered, report.orphaned_references
|
|
180
|
+
|
|
181
|
+
def get_all_requirements(
|
|
182
|
+
self,
|
|
183
|
+
spec_name: Optional[str] = None,
|
|
184
|
+
spec_path: Optional[Path] = None,
|
|
185
|
+
) -> list[Requirement]:
|
|
186
|
+
"""Get all requirements from a spec.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
spec_name: Name of spec directory.
|
|
190
|
+
spec_path: Direct path to spec.md.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
List of all requirements in the spec.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: If neither spec_name nor spec_path provided.
|
|
197
|
+
"""
|
|
198
|
+
if spec_path:
|
|
199
|
+
return self.requirement_parser.parse(spec_path)
|
|
200
|
+
|
|
201
|
+
if spec_name:
|
|
202
|
+
feature_dir = self.project_root / self.specs_dir / spec_name
|
|
203
|
+
spec_file = feature_dir / "spec.md"
|
|
204
|
+
return self.requirement_parser.parse(spec_file)
|
|
205
|
+
|
|
206
|
+
raise ValueError("Either spec_name or spec_path must be provided")
|
|
207
|
+
|
|
208
|
+
def get_all_tasks(
|
|
209
|
+
self,
|
|
210
|
+
spec_name: Optional[str] = None,
|
|
211
|
+
tasks_path: Optional[Path] = None,
|
|
212
|
+
) -> list[Task]:
|
|
213
|
+
"""Get all tasks from a tasks file.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
spec_name: Name of spec directory.
|
|
217
|
+
tasks_path: Direct path to tasks.md.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
List of all tasks. Empty list if tasks.md doesn't exist.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ValueError: If neither spec_name nor tasks_path provided.
|
|
224
|
+
"""
|
|
225
|
+
if tasks_path:
|
|
226
|
+
if not tasks_path.exists():
|
|
227
|
+
return []
|
|
228
|
+
return self.task_parser.parse(tasks_path)
|
|
229
|
+
|
|
230
|
+
if spec_name:
|
|
231
|
+
feature_dir = self.project_root / self.specs_dir / spec_name
|
|
232
|
+
tasks_file = feature_dir / "tasks.md"
|
|
233
|
+
if not tasks_file.exists():
|
|
234
|
+
return []
|
|
235
|
+
return self.task_parser.parse(tasks_file)
|
|
236
|
+
|
|
237
|
+
raise ValueError("Either spec_name or tasks_path must be provided")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Cycle time calculator service for spec analytics.
|
|
2
|
+
|
|
3
|
+
Provides statistical analysis of spec completion cycle times.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..models.analytics_models import (
|
|
10
|
+
CycleTimeRecord,
|
|
11
|
+
CycleTimeStats,
|
|
12
|
+
SpecMetadata,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CycleTimeCalculator:
|
|
17
|
+
"""Calculator for cycle time statistics.
|
|
18
|
+
|
|
19
|
+
Processes SpecMetadata to extract cycle time records and
|
|
20
|
+
calculate statistical summaries.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, specs: list[SpecMetadata]):
|
|
24
|
+
"""Initialize calculator with spec metadata.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
specs: List of SpecMetadata objects to analyze
|
|
28
|
+
"""
|
|
29
|
+
self.specs = specs
|
|
30
|
+
self._records: Optional[list[CycleTimeRecord]] = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def records(self) -> list[CycleTimeRecord]:
|
|
34
|
+
"""Get all cycle time records from completed specs.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of CycleTimeRecord for specs with both dates
|
|
38
|
+
"""
|
|
39
|
+
if self._records is None:
|
|
40
|
+
self._records = []
|
|
41
|
+
for spec in self.specs:
|
|
42
|
+
record = CycleTimeRecord.from_metadata(spec)
|
|
43
|
+
if record:
|
|
44
|
+
self._records.append(record)
|
|
45
|
+
return self._records
|
|
46
|
+
|
|
47
|
+
def calculate_stats(
|
|
48
|
+
self,
|
|
49
|
+
days: Optional[int] = None,
|
|
50
|
+
since: Optional[date] = None,
|
|
51
|
+
) -> Optional[CycleTimeStats]:
|
|
52
|
+
"""Calculate cycle time statistics.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
days: Filter to specs completed in last N days
|
|
56
|
+
since: Filter to specs completed since this date
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
CycleTimeStats or None if no matching records
|
|
60
|
+
"""
|
|
61
|
+
filtered = self.filter_records(days=days, since=since)
|
|
62
|
+
return CycleTimeStats.calculate(filtered)
|
|
63
|
+
|
|
64
|
+
def filter_records(
|
|
65
|
+
self,
|
|
66
|
+
days: Optional[int] = None,
|
|
67
|
+
since: Optional[date] = None,
|
|
68
|
+
) -> list[CycleTimeRecord]:
|
|
69
|
+
"""Filter cycle time records by time period.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
days: Filter to specs completed in last N days
|
|
73
|
+
since: Filter to specs completed since this date (overrides days)
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Filtered list of CycleTimeRecord objects
|
|
77
|
+
"""
|
|
78
|
+
records = self.records
|
|
79
|
+
|
|
80
|
+
if since is not None:
|
|
81
|
+
records = [r for r in records if r.end_date >= since]
|
|
82
|
+
elif days is not None:
|
|
83
|
+
cutoff = date.today() - timedelta(days=days)
|
|
84
|
+
records = [r for r in records if r.end_date >= cutoff]
|
|
85
|
+
|
|
86
|
+
# Sort by end date (most recent first)
|
|
87
|
+
return sorted(records, key=lambda r: r.end_date, reverse=True)
|
|
88
|
+
|
|
89
|
+
def get_recent_completions(self, limit: int = 10) -> list[CycleTimeRecord]:
|
|
90
|
+
"""Get most recently completed specs.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
limit: Maximum number of records to return
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
List of CycleTimeRecord sorted by completion date
|
|
97
|
+
"""
|
|
98
|
+
sorted_records = sorted(
|
|
99
|
+
self.records,
|
|
100
|
+
key=lambda r: r.end_date,
|
|
101
|
+
reverse=True,
|
|
102
|
+
)
|
|
103
|
+
return sorted_records[:limit]
|
|
104
|
+
|
|
105
|
+
def get_slowest_completions(self, limit: int = 5) -> list[CycleTimeRecord]:
|
|
106
|
+
"""Get specs with longest cycle times.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
limit: Maximum number of records to return
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
List of CycleTimeRecord sorted by cycle time descending
|
|
113
|
+
"""
|
|
114
|
+
sorted_records = sorted(
|
|
115
|
+
self.records,
|
|
116
|
+
key=lambda r: r.days_to_complete,
|
|
117
|
+
reverse=True,
|
|
118
|
+
)
|
|
119
|
+
return sorted_records[:limit]
|
|
120
|
+
|
|
121
|
+
def get_fastest_completions(self, limit: int = 5) -> list[CycleTimeRecord]:
|
|
122
|
+
"""Get specs with shortest cycle times.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
limit: Maximum number of records to return
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
List of CycleTimeRecord sorted by cycle time ascending
|
|
129
|
+
"""
|
|
130
|
+
sorted_records = sorted(
|
|
131
|
+
self.records,
|
|
132
|
+
key=lambda r: r.days_to_complete,
|
|
133
|
+
)
|
|
134
|
+
return sorted_records[:limit]
|