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,528 @@
|
|
|
1
|
+
"""Hook validation service for workflow enforcement."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ..models.hook_config import HookConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValidationResult:
|
|
15
|
+
"""Result of a hook validation check."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
success: bool,
|
|
20
|
+
message: str = "",
|
|
21
|
+
suggestion: str = "",
|
|
22
|
+
):
|
|
23
|
+
self.success = success
|
|
24
|
+
self.message = message
|
|
25
|
+
self.suggestion = suggestion
|
|
26
|
+
|
|
27
|
+
def __bool__(self) -> bool:
|
|
28
|
+
return self.success
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HookValidator:
|
|
32
|
+
"""Validates workflow compliance for Git hooks."""
|
|
33
|
+
|
|
34
|
+
# Regex pattern for feature branch naming (e.g., 025-feature-name)
|
|
35
|
+
BRANCH_PATTERN = re.compile(r"^(\d{3})-(.+)$")
|
|
36
|
+
|
|
37
|
+
# Protected branches that skip validation
|
|
38
|
+
DEFAULT_PROTECTED_BRANCHES = ["main", "develop", "master"]
|
|
39
|
+
|
|
40
|
+
# Allowed spec statuses for code commits
|
|
41
|
+
ALLOWED_SPEC_STATUSES = ["In Progress", "Complete", "Approved"]
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
project_root: Optional[Path] = None,
|
|
46
|
+
config: Optional[HookConfig] = None,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize the validator.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
project_root: Root directory of the project (defaults to cwd)
|
|
52
|
+
config: Hook configuration (loads from file if not provided)
|
|
53
|
+
"""
|
|
54
|
+
self.project_root = project_root or Path.cwd()
|
|
55
|
+
self.config = config or self._load_config()
|
|
56
|
+
self.specs_dir = self.project_root / "specs"
|
|
57
|
+
self.logs_dir = self.project_root / ".doit" / "logs"
|
|
58
|
+
|
|
59
|
+
def _load_config(self) -> HookConfig:
|
|
60
|
+
"""Load configuration from file or return defaults."""
|
|
61
|
+
config_path = self.project_root / ".doit" / "config" / "hooks.yaml"
|
|
62
|
+
if config_path.exists():
|
|
63
|
+
return HookConfig.load_from_file(config_path)
|
|
64
|
+
return HookConfig.load_default()
|
|
65
|
+
|
|
66
|
+
def get_current_branch(self) -> Optional[str]:
|
|
67
|
+
"""Get the current Git branch name.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Branch name or None if not on a branch (detached HEAD)
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
cwd=self.project_root,
|
|
78
|
+
)
|
|
79
|
+
if result.returncode == 0:
|
|
80
|
+
branch = result.stdout.strip()
|
|
81
|
+
if branch == "HEAD":
|
|
82
|
+
return None # Detached HEAD state
|
|
83
|
+
return branch
|
|
84
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
85
|
+
pass
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def get_staged_files(self) -> list[str]:
|
|
89
|
+
"""Get list of staged files for commit.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of staged file paths relative to project root
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git", "diff", "--cached", "--name-only"],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
cwd=self.project_root,
|
|
100
|
+
)
|
|
101
|
+
if result.returncode == 0:
|
|
102
|
+
return [f for f in result.stdout.strip().split("\n") if f]
|
|
103
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
104
|
+
pass
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
def is_protected_branch(self, branch: str) -> bool:
|
|
108
|
+
"""Check if branch is protected (skip validation).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
branch: Branch name to check
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if branch should skip validation
|
|
115
|
+
"""
|
|
116
|
+
# Check default protected branches
|
|
117
|
+
if branch in self.DEFAULT_PROTECTED_BRANCHES:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Check configured exempt branches
|
|
121
|
+
exempt = self.config.pre_commit.exempt_branches
|
|
122
|
+
for pattern in exempt:
|
|
123
|
+
if fnmatch.fnmatch(branch, pattern):
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def is_exempt_path(self, file_path: str) -> bool:
|
|
129
|
+
"""Check if file path is exempt from validation.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
file_path: File path to check
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
True if file should skip validation
|
|
136
|
+
"""
|
|
137
|
+
exempt = self.config.pre_commit.exempt_paths
|
|
138
|
+
for pattern in exempt:
|
|
139
|
+
if fnmatch.fnmatch(file_path, pattern):
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def all_files_exempt(self, files: list[str]) -> bool:
|
|
144
|
+
"""Check if all staged files are exempt from validation.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
files: List of file paths
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
True if all files match exempt patterns
|
|
151
|
+
"""
|
|
152
|
+
if not files:
|
|
153
|
+
return True
|
|
154
|
+
return all(self.is_exempt_path(f) for f in files)
|
|
155
|
+
|
|
156
|
+
def extract_branch_spec_name(self, branch: str) -> Optional[str]:
|
|
157
|
+
"""Extract spec directory name from branch name.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
branch: Git branch name
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Spec directory name or None if pattern doesn't match
|
|
164
|
+
"""
|
|
165
|
+
match = self.BRANCH_PATTERN.match(branch)
|
|
166
|
+
if match:
|
|
167
|
+
return branch # Full branch name is the spec directory
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def get_spec_path(self, branch: str) -> Optional[Path]:
|
|
171
|
+
"""Get the expected spec.md path for a branch.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
branch: Git branch name
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Path to expected spec.md or None if branch pattern doesn't match
|
|
178
|
+
"""
|
|
179
|
+
spec_name = self.extract_branch_spec_name(branch)
|
|
180
|
+
if spec_name:
|
|
181
|
+
return self.specs_dir / spec_name / "spec.md"
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def spec_exists(self, branch: str) -> bool:
|
|
185
|
+
"""Check if spec.md exists for the branch.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
branch: Git branch name
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
True if spec.md exists
|
|
192
|
+
"""
|
|
193
|
+
spec_path = self.get_spec_path(branch)
|
|
194
|
+
return spec_path is not None and spec_path.exists()
|
|
195
|
+
|
|
196
|
+
def get_spec_status(self, branch: str) -> Optional[str]:
|
|
197
|
+
"""Get the status field from spec.md.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
branch: Git branch name
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Status string or None if not found
|
|
204
|
+
"""
|
|
205
|
+
spec_path = self.get_spec_path(branch)
|
|
206
|
+
if not spec_path or not spec_path.exists():
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
content = spec_path.read_text()
|
|
211
|
+
# Look for **Status**: value pattern
|
|
212
|
+
match = re.search(r"\*\*Status\*\*:\s*(\w+(?:\s+\w+)*)", content)
|
|
213
|
+
if match:
|
|
214
|
+
return match.group(1).strip()
|
|
215
|
+
except (OSError, IOError):
|
|
216
|
+
pass
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
def is_spec_status_valid(self, status: Optional[str]) -> bool:
|
|
220
|
+
"""Check if spec status allows code commits.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
status: Spec status string
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if status allows code commits
|
|
227
|
+
"""
|
|
228
|
+
if status is None:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
# Check configured allowed statuses or use defaults
|
|
232
|
+
allowed = self.config.pre_commit.allowed_statuses
|
|
233
|
+
if allowed:
|
|
234
|
+
return status in allowed
|
|
235
|
+
return status in self.ALLOWED_SPEC_STATUSES
|
|
236
|
+
|
|
237
|
+
def has_code_changes(self, files: list[str]) -> bool:
|
|
238
|
+
"""Check if staged files include code (not just spec/docs).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
files: List of staged file paths
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
True if any file is not a spec/doc file
|
|
245
|
+
"""
|
|
246
|
+
spec_doc_patterns = ["specs/**", "docs/**", "*.md", ".github/**"]
|
|
247
|
+
for f in files:
|
|
248
|
+
is_spec_doc = any(fnmatch.fnmatch(f, p) for p in spec_doc_patterns)
|
|
249
|
+
if not is_spec_doc:
|
|
250
|
+
return True
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
def validate_pre_commit(self) -> ValidationResult:
|
|
254
|
+
"""Validate pre-commit hook requirements.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
ValidationResult with success status and messages
|
|
258
|
+
"""
|
|
259
|
+
# Check if pre-commit validation is enabled
|
|
260
|
+
if not self.config.pre_commit.enabled:
|
|
261
|
+
return ValidationResult(True, "Pre-commit validation disabled")
|
|
262
|
+
|
|
263
|
+
# Get current branch
|
|
264
|
+
branch = self.get_current_branch()
|
|
265
|
+
if branch is None:
|
|
266
|
+
return ValidationResult(
|
|
267
|
+
True,
|
|
268
|
+
"Detached HEAD state - skipping validation",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Check if protected branch
|
|
272
|
+
if self.is_protected_branch(branch):
|
|
273
|
+
return ValidationResult(
|
|
274
|
+
True,
|
|
275
|
+
f"Protected branch '{branch}' - skipping validation",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Get staged files
|
|
279
|
+
staged_files = self.get_staged_files()
|
|
280
|
+
|
|
281
|
+
# Check if all files are exempt
|
|
282
|
+
if self.all_files_exempt(staged_files):
|
|
283
|
+
return ValidationResult(
|
|
284
|
+
True,
|
|
285
|
+
"All staged files match exempt patterns - skipping validation",
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# Check if require_spec is enabled
|
|
289
|
+
if not self.config.pre_commit.require_spec:
|
|
290
|
+
return ValidationResult(True, "Spec requirement disabled")
|
|
291
|
+
|
|
292
|
+
# Extract spec name from branch
|
|
293
|
+
spec_name = self.extract_branch_spec_name(branch)
|
|
294
|
+
if spec_name is None:
|
|
295
|
+
# Branch doesn't follow naming convention - warn but allow
|
|
296
|
+
return ValidationResult(
|
|
297
|
+
True,
|
|
298
|
+
f"Branch '{branch}' doesn't follow naming convention (###-feature-name)",
|
|
299
|
+
"Consider using standard branch naming for better workflow tracking",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Check if spec exists
|
|
303
|
+
spec_path = self.get_spec_path(branch)
|
|
304
|
+
if not self.spec_exists(branch):
|
|
305
|
+
return ValidationResult(
|
|
306
|
+
False,
|
|
307
|
+
f"Missing specification for branch: {branch}",
|
|
308
|
+
f"Expected: {spec_path}\n\nTo fix: Run `doit specit \"Your feature description\"` first\n\nOr bypass with: git commit --no-verify (not recommended)",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
# Check spec status for code changes
|
|
312
|
+
if self.has_code_changes(staged_files):
|
|
313
|
+
status = self.get_spec_status(branch)
|
|
314
|
+
if not self.is_spec_status_valid(status):
|
|
315
|
+
allowed = self.config.pre_commit.allowed_statuses or self.ALLOWED_SPEC_STATUSES
|
|
316
|
+
return ValidationResult(
|
|
317
|
+
False,
|
|
318
|
+
f"Specification has invalid status: {status or 'Unknown'}",
|
|
319
|
+
f"Allowed statuses: {', '.join(allowed)}\n\nTo fix: Update spec.md status to 'In Progress' before committing code",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# Run spec validation if enabled
|
|
323
|
+
if self.config.pre_commit.validate_spec:
|
|
324
|
+
validation_result = self._validate_spec_quality(spec_path)
|
|
325
|
+
if not validation_result.success:
|
|
326
|
+
return validation_result
|
|
327
|
+
|
|
328
|
+
return ValidationResult(True, "Pre-commit validation passed")
|
|
329
|
+
|
|
330
|
+
def _validate_spec_quality(self, spec_path: Path) -> ValidationResult:
|
|
331
|
+
"""Validate spec quality using validation rules.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
spec_path: Path to the spec.md file.
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
ValidationResult with success status and messages.
|
|
338
|
+
"""
|
|
339
|
+
try:
|
|
340
|
+
from .validation_service import ValidationService
|
|
341
|
+
|
|
342
|
+
service = ValidationService()
|
|
343
|
+
result = service.validate_file(spec_path)
|
|
344
|
+
|
|
345
|
+
threshold = self.config.pre_commit.validate_spec_threshold
|
|
346
|
+
|
|
347
|
+
if result.error_count > 0:
|
|
348
|
+
# Format issues summary
|
|
349
|
+
issues_summary = []
|
|
350
|
+
for issue in result.issues:
|
|
351
|
+
if issue.severity.value == "error":
|
|
352
|
+
issues_summary.append(f" - {issue.message}")
|
|
353
|
+
|
|
354
|
+
return ValidationResult(
|
|
355
|
+
False,
|
|
356
|
+
f"Specification validation failed with {result.error_count} error(s)\n\n"
|
|
357
|
+
+ "\n".join(issues_summary[:5]) # Show first 5 errors
|
|
358
|
+
+ (f"\n ... and {result.error_count - 5} more" if result.error_count > 5 else ""),
|
|
359
|
+
f"Quality score: {result.quality_score}/100 (threshold: {threshold})\n\n"
|
|
360
|
+
f"To fix: Run `doit validate {spec_path}` to see all issues\n\n"
|
|
361
|
+
"Or bypass with: git commit --no-verify (not recommended)",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
if result.quality_score < threshold:
|
|
365
|
+
return ValidationResult(
|
|
366
|
+
False,
|
|
367
|
+
f"Specification quality score ({result.quality_score}) below threshold ({threshold})",
|
|
368
|
+
f"Warnings: {result.warning_count}\n\n"
|
|
369
|
+
f"To fix: Run `doit validate {spec_path}` to see issues and improve the spec\n\n"
|
|
370
|
+
"Or bypass with: git commit --no-verify (not recommended)",
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
except ImportError:
|
|
374
|
+
# ValidationService not available - skip validation
|
|
375
|
+
pass
|
|
376
|
+
except Exception as e:
|
|
377
|
+
# Log error but don't block commit for validation failures
|
|
378
|
+
pass
|
|
379
|
+
|
|
380
|
+
return ValidationResult(True, "Spec validation passed")
|
|
381
|
+
|
|
382
|
+
def validate_pre_push(self) -> ValidationResult:
|
|
383
|
+
"""Validate pre-push hook requirements.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
ValidationResult with success status and messages
|
|
387
|
+
"""
|
|
388
|
+
# Check if pre-push validation is enabled
|
|
389
|
+
if not self.config.pre_push.enabled:
|
|
390
|
+
return ValidationResult(True, "Pre-push validation disabled")
|
|
391
|
+
|
|
392
|
+
# Get current branch
|
|
393
|
+
branch = self.get_current_branch()
|
|
394
|
+
if branch is None:
|
|
395
|
+
return ValidationResult(
|
|
396
|
+
True,
|
|
397
|
+
"Detached HEAD state - skipping validation",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Check if protected branch
|
|
401
|
+
if self.is_protected_branch(branch):
|
|
402
|
+
return ValidationResult(
|
|
403
|
+
True,
|
|
404
|
+
f"Protected branch '{branch}' - skipping validation",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Extract spec name from branch
|
|
408
|
+
spec_name = self.extract_branch_spec_name(branch)
|
|
409
|
+
if spec_name is None:
|
|
410
|
+
return ValidationResult(
|
|
411
|
+
True,
|
|
412
|
+
f"Branch '{branch}' doesn't follow naming convention",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Check for required artifacts
|
|
416
|
+
spec_dir = self.specs_dir / spec_name
|
|
417
|
+
|
|
418
|
+
# Check spec.md
|
|
419
|
+
if self.config.pre_push.require_spec:
|
|
420
|
+
spec_path = spec_dir / "spec.md"
|
|
421
|
+
if not spec_path.exists():
|
|
422
|
+
return ValidationResult(
|
|
423
|
+
False,
|
|
424
|
+
f"Missing required artifact: spec.md",
|
|
425
|
+
f"Expected: {spec_path}\n\nTo fix: Run `doit specit \"Your feature description\"` first",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Check plan.md
|
|
429
|
+
if self.config.pre_push.require_plan:
|
|
430
|
+
plan_path = spec_dir / "plan.md"
|
|
431
|
+
if not plan_path.exists():
|
|
432
|
+
return ValidationResult(
|
|
433
|
+
False,
|
|
434
|
+
f"Missing required artifact: plan.md",
|
|
435
|
+
f"Expected: {plan_path}\n\nTo fix: Run `/doit.planit` to create implementation plan",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# Check tasks.md
|
|
439
|
+
if self.config.pre_push.require_tasks:
|
|
440
|
+
tasks_path = spec_dir / "tasks.md"
|
|
441
|
+
if not tasks_path.exists():
|
|
442
|
+
return ValidationResult(
|
|
443
|
+
False,
|
|
444
|
+
f"Missing required artifact: tasks.md",
|
|
445
|
+
f"Expected: {tasks_path}\n\nTo fix: Run `/doit.taskit` to generate task breakdown",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return ValidationResult(True, "Pre-push validation passed")
|
|
449
|
+
|
|
450
|
+
def log_bypass(self, hook_type: str, commit_hash: Optional[str] = None) -> None:
|
|
451
|
+
"""Log a hook bypass event.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
hook_type: Type of hook that was bypassed (pre-commit, pre-push)
|
|
455
|
+
commit_hash: Optional commit hash for post-commit logging
|
|
456
|
+
"""
|
|
457
|
+
if not self.config.logging.log_bypasses:
|
|
458
|
+
return
|
|
459
|
+
|
|
460
|
+
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
log_path = self.logs_dir / "hook-bypasses.log"
|
|
462
|
+
|
|
463
|
+
# Get context
|
|
464
|
+
branch = self.get_current_branch() or "unknown"
|
|
465
|
+
user = self._get_git_user()
|
|
466
|
+
timestamp = datetime.now().isoformat(timespec="seconds")
|
|
467
|
+
|
|
468
|
+
# Format log entry
|
|
469
|
+
entry = f"{timestamp} | hook: {hook_type} | branch: {branch}"
|
|
470
|
+
if commit_hash:
|
|
471
|
+
entry += f" | commit: {commit_hash}"
|
|
472
|
+
if user:
|
|
473
|
+
entry += f" | user: {user}"
|
|
474
|
+
entry += "\n"
|
|
475
|
+
|
|
476
|
+
# Append to log
|
|
477
|
+
with open(log_path, "a") as f:
|
|
478
|
+
f.write(entry)
|
|
479
|
+
|
|
480
|
+
def _get_git_user(self) -> Optional[str]:
|
|
481
|
+
"""Get the current Git user email."""
|
|
482
|
+
try:
|
|
483
|
+
result = subprocess.run(
|
|
484
|
+
["git", "config", "user.email"],
|
|
485
|
+
capture_output=True,
|
|
486
|
+
text=True,
|
|
487
|
+
cwd=self.project_root,
|
|
488
|
+
)
|
|
489
|
+
if result.returncode == 0:
|
|
490
|
+
return result.stdout.strip()
|
|
491
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
492
|
+
pass
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def get_bypass_report(self) -> list[dict]:
|
|
496
|
+
"""Get bypass events from the log.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
List of bypass event dictionaries
|
|
500
|
+
"""
|
|
501
|
+
log_path = self.logs_dir / "hook-bypasses.log"
|
|
502
|
+
if not log_path.exists():
|
|
503
|
+
return []
|
|
504
|
+
|
|
505
|
+
events = []
|
|
506
|
+
try:
|
|
507
|
+
with open(log_path) as f:
|
|
508
|
+
for line in f:
|
|
509
|
+
line = line.strip()
|
|
510
|
+
if not line:
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
# Parse log entry
|
|
514
|
+
event = {}
|
|
515
|
+
parts = line.split(" | ")
|
|
516
|
+
for part in parts:
|
|
517
|
+
if ": " in part:
|
|
518
|
+
key, value = part.split(": ", 1)
|
|
519
|
+
event[key] = value
|
|
520
|
+
else:
|
|
521
|
+
# First part is timestamp
|
|
522
|
+
event["timestamp"] = part
|
|
523
|
+
|
|
524
|
+
events.append(event)
|
|
525
|
+
except (OSError, IOError):
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
return events
|