specfact-cli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of specfact-cli might be problematic. Click here for more details.
- specfact_cli/__init__.py +14 -0
- specfact_cli/agents/__init__.py +23 -0
- specfact_cli/agents/analyze_agent.py +392 -0
- specfact_cli/agents/base.py +95 -0
- specfact_cli/agents/plan_agent.py +202 -0
- specfact_cli/agents/registry.py +176 -0
- specfact_cli/agents/sync_agent.py +133 -0
- specfact_cli/analyzers/__init__.py +10 -0
- specfact_cli/analyzers/code_analyzer.py +775 -0
- specfact_cli/cli.py +397 -0
- specfact_cli/commands/__init__.py +7 -0
- specfact_cli/commands/enforce.py +87 -0
- specfact_cli/commands/import_cmd.py +355 -0
- specfact_cli/commands/init.py +119 -0
- specfact_cli/commands/plan.py +1090 -0
- specfact_cli/commands/repro.py +172 -0
- specfact_cli/commands/sync.py +408 -0
- specfact_cli/common/__init__.py +24 -0
- specfact_cli/common/logger_setup.py +673 -0
- specfact_cli/common/logging_utils.py +41 -0
- specfact_cli/common/text_utils.py +52 -0
- specfact_cli/common/utils.py +48 -0
- specfact_cli/comparators/__init__.py +10 -0
- specfact_cli/comparators/plan_comparator.py +391 -0
- specfact_cli/generators/__init__.py +13 -0
- specfact_cli/generators/plan_generator.py +105 -0
- specfact_cli/generators/protocol_generator.py +115 -0
- specfact_cli/generators/report_generator.py +200 -0
- specfact_cli/generators/workflow_generator.py +111 -0
- specfact_cli/importers/__init__.py +6 -0
- specfact_cli/importers/speckit_converter.py +773 -0
- specfact_cli/importers/speckit_scanner.py +704 -0
- specfact_cli/models/__init__.py +32 -0
- specfact_cli/models/deviation.py +105 -0
- specfact_cli/models/enforcement.py +150 -0
- specfact_cli/models/plan.py +97 -0
- specfact_cli/models/protocol.py +28 -0
- specfact_cli/modes/__init__.py +18 -0
- specfact_cli/modes/detector.py +126 -0
- specfact_cli/modes/router.py +153 -0
- specfact_cli/sync/__init__.py +11 -0
- specfact_cli/sync/repository_sync.py +279 -0
- specfact_cli/sync/speckit_sync.py +388 -0
- specfact_cli/utils/__init__.py +57 -0
- specfact_cli/utils/console.py +69 -0
- specfact_cli/utils/feature_keys.py +213 -0
- specfact_cli/utils/git.py +241 -0
- specfact_cli/utils/ide_setup.py +381 -0
- specfact_cli/utils/prompts.py +179 -0
- specfact_cli/utils/structure.py +496 -0
- specfact_cli/utils/yaml_utils.py +200 -0
- specfact_cli/validators/__init__.py +19 -0
- specfact_cli/validators/fsm.py +260 -0
- specfact_cli/validators/repro_checker.py +320 -0
- specfact_cli/validators/schema.py +200 -0
- specfact_cli-0.4.0.dist-info/METADATA +332 -0
- specfact_cli-0.4.0.dist-info/RECORD +60 -0
- specfact_cli-0.4.0.dist-info/WHEEL +4 -0
- specfact_cli-0.4.0.dist-info/entry_points.txt +2 -0
- specfact_cli-0.4.0.dist-info/licenses/LICENSE.md +55 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spec-Kit bidirectional sync implementation.
|
|
3
|
+
|
|
4
|
+
This module provides bidirectional synchronization between Spec-Kit markdown artifacts
|
|
5
|
+
and SpecFact plans/protocols. It detects changes, merges updates, and resolves conflicts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from beartype import beartype
|
|
16
|
+
from icontract import ensure, require
|
|
17
|
+
|
|
18
|
+
from specfact_cli.importers.speckit_converter import SpecKitConverter
|
|
19
|
+
from specfact_cli.importers.speckit_scanner import SpecKitScanner
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SyncResult:
|
|
24
|
+
"""
|
|
25
|
+
Result of sync operation.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
status: Sync status ("success" | "conflict" | "error")
|
|
29
|
+
changes: List of detected changes
|
|
30
|
+
conflicts: List of conflicts (if any)
|
|
31
|
+
merged: Merged artifacts
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
status: str
|
|
35
|
+
changes: list[dict[str, Any]]
|
|
36
|
+
conflicts: list[dict[str, Any]]
|
|
37
|
+
merged: dict[str, Any]
|
|
38
|
+
|
|
39
|
+
@beartype
|
|
40
|
+
def __post_init__(self) -> None:
|
|
41
|
+
"""Validate SyncResult after initialization."""
|
|
42
|
+
valid_statuses = ["success", "conflict", "error"]
|
|
43
|
+
if self.status not in valid_statuses:
|
|
44
|
+
msg = f"Status must be one of {valid_statuses}, got {self.status}"
|
|
45
|
+
raise ValueError(msg)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SpecKitSync:
|
|
49
|
+
"""
|
|
50
|
+
Bidirectional sync between Spec-Kit and SpecFact.
|
|
51
|
+
|
|
52
|
+
Synchronizes changes between Spec-Kit markdown artifacts (generated by Spec-Kit
|
|
53
|
+
slash commands) and SpecFact plan bundles/protocols.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
@beartype
|
|
57
|
+
def __init__(self, repo_path: Path) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Initialize Spec-Kit sync.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
repo_path: Path to repository root
|
|
63
|
+
"""
|
|
64
|
+
self.repo_path = Path(repo_path).resolve()
|
|
65
|
+
self.scanner = SpecKitScanner(self.repo_path)
|
|
66
|
+
self.converter = SpecKitConverter(self.repo_path)
|
|
67
|
+
self.hash_store: dict[str, str] = {}
|
|
68
|
+
|
|
69
|
+
@beartype
|
|
70
|
+
@require(lambda repo_path: repo_path.exists(), "Repository path must exist")
|
|
71
|
+
@require(lambda repo_path: repo_path.is_dir(), "Repository path must be a directory")
|
|
72
|
+
@ensure(lambda result: isinstance(result, SyncResult), "Must return SyncResult")
|
|
73
|
+
@ensure(lambda result: result.status in ["success", "conflict", "error"], "Status must be valid")
|
|
74
|
+
def sync_bidirectional(self, repo_path: Path | None = None) -> SyncResult:
|
|
75
|
+
"""
|
|
76
|
+
Sync changes between Spec-Kit and SpecFact artifacts bidirectionally.
|
|
77
|
+
|
|
78
|
+
Note: Spec-Kit is a workflow tool that generates markdown artifacts through
|
|
79
|
+
slash commands. This method synchronizes the **artifacts that Spec-Kit commands
|
|
80
|
+
have already generated**, not run Spec-Kit commands ourselves.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
repo_path: Path to repository (default: self.repo_path)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Sync result with changes, conflicts, and merged artifacts
|
|
87
|
+
"""
|
|
88
|
+
if repo_path is None:
|
|
89
|
+
repo_path = self.repo_path
|
|
90
|
+
|
|
91
|
+
# 1. Detect changes in Spec-Kit artifacts
|
|
92
|
+
speckit_changes = self.detect_speckit_changes(repo_path)
|
|
93
|
+
|
|
94
|
+
# 2. Detect changes in SpecFact artifacts
|
|
95
|
+
specfact_changes = self.detect_specfact_changes(repo_path)
|
|
96
|
+
|
|
97
|
+
# 3. Merge bidirectional changes
|
|
98
|
+
merged = self.merge_changes(speckit_changes, specfact_changes)
|
|
99
|
+
|
|
100
|
+
# 4. Detect conflicts
|
|
101
|
+
conflicts = self.detect_conflicts(speckit_changes, specfact_changes)
|
|
102
|
+
|
|
103
|
+
# 5. Resolve conflicts if any
|
|
104
|
+
if conflicts:
|
|
105
|
+
resolved = self.resolve_conflicts(conflicts)
|
|
106
|
+
merged = self.apply_resolved_conflicts(merged, resolved)
|
|
107
|
+
|
|
108
|
+
return SyncResult(
|
|
109
|
+
status="conflict" if conflicts else "success",
|
|
110
|
+
changes=[speckit_changes, specfact_changes],
|
|
111
|
+
conflicts=conflicts,
|
|
112
|
+
merged=merged,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@beartype
|
|
116
|
+
@require(lambda repo_path: repo_path.exists(), "Repository path must exist")
|
|
117
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dict")
|
|
118
|
+
def detect_speckit_changes(self, repo_path: Path) -> dict[str, Any]:
|
|
119
|
+
"""
|
|
120
|
+
Detect changes in Spec-Kit artifacts.
|
|
121
|
+
|
|
122
|
+
Monitors modern Spec-Kit format:
|
|
123
|
+
- `.specify/memory/constitution.md` (from `/speckit.constitution`)
|
|
124
|
+
- `specs/[###-feature-name]/spec.md` (from `/speckit.specify`)
|
|
125
|
+
- `specs/[###-feature-name]/plan.md` (from `/speckit.plan`)
|
|
126
|
+
- `specs/[###-feature-name]/tasks.md` (from `/speckit.tasks`)
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
repo_path: Path to repository
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Dictionary of detected changes keyed by file path
|
|
133
|
+
"""
|
|
134
|
+
changes: dict[str, Any] = {}
|
|
135
|
+
|
|
136
|
+
# Check for modern Spec-Kit format (.specify directory)
|
|
137
|
+
specify_dir = repo_path / ".specify"
|
|
138
|
+
if specify_dir.exists():
|
|
139
|
+
# Monitor .specify/memory/ files
|
|
140
|
+
memory_dir = repo_path / ".specify" / "memory"
|
|
141
|
+
if memory_dir.exists():
|
|
142
|
+
for memory_file in memory_dir.glob("*.md"):
|
|
143
|
+
relative_path = str(memory_file.relative_to(repo_path))
|
|
144
|
+
current_hash = self._get_file_hash(memory_file)
|
|
145
|
+
stored_hash = self.hash_store.get(relative_path, "")
|
|
146
|
+
|
|
147
|
+
if current_hash != stored_hash:
|
|
148
|
+
changes[relative_path] = {
|
|
149
|
+
"file": memory_file,
|
|
150
|
+
"hash": current_hash,
|
|
151
|
+
"type": "modified" if stored_hash else "new",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Monitor specs/ directory for feature specifications
|
|
155
|
+
specs_dir = repo_path / "specs"
|
|
156
|
+
if specs_dir.exists():
|
|
157
|
+
for spec_dir in specs_dir.iterdir():
|
|
158
|
+
if spec_dir.is_dir():
|
|
159
|
+
for spec_file in spec_dir.glob("*.md"):
|
|
160
|
+
relative_path = str(spec_file.relative_to(repo_path))
|
|
161
|
+
current_hash = self._get_file_hash(spec_file)
|
|
162
|
+
stored_hash = self.hash_store.get(relative_path, "")
|
|
163
|
+
|
|
164
|
+
if current_hash != stored_hash:
|
|
165
|
+
changes[relative_path] = {
|
|
166
|
+
"file": spec_file,
|
|
167
|
+
"hash": current_hash,
|
|
168
|
+
"type": "modified" if stored_hash else "new",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return changes
|
|
172
|
+
|
|
173
|
+
@beartype
|
|
174
|
+
@require(lambda repo_path: repo_path.exists(), "Repository path must exist")
|
|
175
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dict")
|
|
176
|
+
def detect_specfact_changes(self, repo_path: Path) -> dict[str, Any]:
|
|
177
|
+
"""
|
|
178
|
+
Detect changes in SpecFact artifacts.
|
|
179
|
+
|
|
180
|
+
Monitors:
|
|
181
|
+
- `.specfact/plans/*.yaml`
|
|
182
|
+
- `.specfact/protocols/*.yaml`
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
repo_path: Path to repository
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Dictionary of detected changes keyed by file path
|
|
189
|
+
"""
|
|
190
|
+
changes: dict[str, Any] = {}
|
|
191
|
+
|
|
192
|
+
# Monitor .specfact/plans/ files
|
|
193
|
+
plans_dir = repo_path / ".specfact" / "plans"
|
|
194
|
+
if plans_dir.exists():
|
|
195
|
+
for plan_file in plans_dir.glob("*.yaml"):
|
|
196
|
+
relative_path = str(plan_file.relative_to(repo_path))
|
|
197
|
+
current_hash = self._get_file_hash(plan_file)
|
|
198
|
+
stored_hash = self.hash_store.get(relative_path, "")
|
|
199
|
+
|
|
200
|
+
if current_hash != stored_hash:
|
|
201
|
+
changes[relative_path] = {
|
|
202
|
+
"file": plan_file,
|
|
203
|
+
"hash": current_hash,
|
|
204
|
+
"type": "modified" if stored_hash else "new",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Monitor .specfact/protocols/ files
|
|
208
|
+
protocols_dir = repo_path / ".specfact" / "protocols"
|
|
209
|
+
if protocols_dir.exists():
|
|
210
|
+
for protocol_file in protocols_dir.glob("*.yaml"):
|
|
211
|
+
relative_path = str(protocol_file.relative_to(repo_path))
|
|
212
|
+
current_hash = self._get_file_hash(protocol_file)
|
|
213
|
+
stored_hash = self.hash_store.get(relative_path, "")
|
|
214
|
+
|
|
215
|
+
if current_hash != stored_hash:
|
|
216
|
+
changes[relative_path] = {
|
|
217
|
+
"file": protocol_file,
|
|
218
|
+
"hash": current_hash,
|
|
219
|
+
"type": "modified" if stored_hash else "new",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return changes
|
|
223
|
+
|
|
224
|
+
@beartype
|
|
225
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dict")
|
|
226
|
+
def merge_changes(self, speckit_changes: dict[str, Any], specfact_changes: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
"""
|
|
228
|
+
Merge changes from both sources.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
speckit_changes: Spec-Kit detected changes
|
|
232
|
+
specfact_changes: SpecFact detected changes
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Merged changes dictionary
|
|
236
|
+
"""
|
|
237
|
+
merged: dict[str, Any] = {}
|
|
238
|
+
|
|
239
|
+
# Merge Spec-Kit changes
|
|
240
|
+
for key, change in speckit_changes.items():
|
|
241
|
+
merged[key] = {
|
|
242
|
+
"source": "speckit",
|
|
243
|
+
**change,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# Merge SpecFact changes
|
|
247
|
+
for key, change in specfact_changes.items():
|
|
248
|
+
if key in merged:
|
|
249
|
+
# Conflict detected
|
|
250
|
+
merged[key]["conflict"] = True
|
|
251
|
+
merged[key]["specfact_change"] = change
|
|
252
|
+
else:
|
|
253
|
+
merged[key] = {
|
|
254
|
+
"source": "specfact",
|
|
255
|
+
**change,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return merged
|
|
259
|
+
|
|
260
|
+
@beartype
|
|
261
|
+
@ensure(lambda result: isinstance(result, list), "Must return list")
|
|
262
|
+
def detect_conflicts(
|
|
263
|
+
self, speckit_changes: dict[str, Any], specfact_changes: dict[str, Any]
|
|
264
|
+
) -> list[dict[str, Any]]:
|
|
265
|
+
"""
|
|
266
|
+
Detect conflicts between Spec-Kit and SpecFact changes.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
speckit_changes: Spec-Kit detected changes
|
|
270
|
+
specfact_changes: SpecFact detected changes
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
List of conflict dictionaries
|
|
274
|
+
"""
|
|
275
|
+
conflicts: list[dict[str, Any]] = []
|
|
276
|
+
|
|
277
|
+
for key in set(speckit_changes.keys()) & set(specfact_changes.keys()):
|
|
278
|
+
conflicts.append(
|
|
279
|
+
{
|
|
280
|
+
"key": key,
|
|
281
|
+
"speckit_change": speckit_changes[key],
|
|
282
|
+
"specfact_change": specfact_changes[key],
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return conflicts
|
|
287
|
+
|
|
288
|
+
@beartype
|
|
289
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dict")
|
|
290
|
+
def resolve_conflicts(self, conflicts: list[dict[str, Any]]) -> dict[str, Any]:
|
|
291
|
+
"""
|
|
292
|
+
Resolve conflicts with merge strategy.
|
|
293
|
+
|
|
294
|
+
Strategy:
|
|
295
|
+
- Priority: SpecFact > Spec-Kit for artifacts (specs/*)
|
|
296
|
+
- Priority: Spec-Kit > SpecFact for memory files (.specify/memory/)
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
conflicts: List of conflict dictionaries
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Resolved conflicts dictionary
|
|
303
|
+
"""
|
|
304
|
+
resolved: dict[str, Any] = {}
|
|
305
|
+
|
|
306
|
+
for conflict in conflicts:
|
|
307
|
+
file_key = conflict["key"]
|
|
308
|
+
file_type = self._get_file_type(file_key)
|
|
309
|
+
|
|
310
|
+
if file_type == "artifact":
|
|
311
|
+
# SpecFact takes priority for artifacts
|
|
312
|
+
resolved[file_key] = {
|
|
313
|
+
"resolution": "specfact_priority",
|
|
314
|
+
"source": "specfact",
|
|
315
|
+
"data": conflict["specfact_change"],
|
|
316
|
+
}
|
|
317
|
+
elif file_type == "memory":
|
|
318
|
+
# Spec-Kit takes priority for memory files
|
|
319
|
+
resolved[file_key] = {
|
|
320
|
+
"resolution": "speckit_priority",
|
|
321
|
+
"source": "speckit",
|
|
322
|
+
"data": conflict["speckit_change"],
|
|
323
|
+
}
|
|
324
|
+
else:
|
|
325
|
+
# Default: SpecFact priority
|
|
326
|
+
resolved[file_key] = {
|
|
327
|
+
"resolution": "specfact_priority",
|
|
328
|
+
"source": "specfact",
|
|
329
|
+
"data": conflict["specfact_change"],
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return resolved
|
|
333
|
+
|
|
334
|
+
@beartype
|
|
335
|
+
@ensure(lambda result: isinstance(result, dict), "Must return dict")
|
|
336
|
+
def apply_resolved_conflicts(self, merged: dict[str, Any], resolved: dict[str, Any]) -> dict[str, Any]:
|
|
337
|
+
"""
|
|
338
|
+
Apply resolved conflicts to merged changes.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
merged: Merged changes dictionary
|
|
342
|
+
resolved: Resolved conflicts dictionary
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Updated merged changes dictionary
|
|
346
|
+
"""
|
|
347
|
+
for key, resolution in resolved.items():
|
|
348
|
+
if key in merged:
|
|
349
|
+
merged[key]["conflict"] = False
|
|
350
|
+
merged[key]["resolution"] = resolution["resolution"]
|
|
351
|
+
merged[key]["source"] = resolution["source"]
|
|
352
|
+
|
|
353
|
+
return merged
|
|
354
|
+
|
|
355
|
+
@beartype
|
|
356
|
+
def _get_file_hash(self, file_path: Path) -> str:
|
|
357
|
+
"""
|
|
358
|
+
Get file hash for change detection.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
file_path: Path to file
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
SHA256 hash of file contents
|
|
365
|
+
"""
|
|
366
|
+
if not file_path.exists():
|
|
367
|
+
return ""
|
|
368
|
+
|
|
369
|
+
with file_path.open("rb") as f:
|
|
370
|
+
content = f.read()
|
|
371
|
+
return hashlib.sha256(content).hexdigest()
|
|
372
|
+
|
|
373
|
+
@beartype
|
|
374
|
+
def _get_file_type(self, file_path: str) -> str:
|
|
375
|
+
"""
|
|
376
|
+
Determine file type for conflict resolution.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
file_path: Relative file path
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
File type ("artifact" | "memory" | "other")
|
|
383
|
+
"""
|
|
384
|
+
if "/memory/" in file_path or file_path.startswith(".specify/memory/"):
|
|
385
|
+
return "memory"
|
|
386
|
+
if "/specs/" in file_path or file_path.startswith("specs/"):
|
|
387
|
+
return "artifact"
|
|
388
|
+
return "other"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SpecFact CLI utilities.
|
|
3
|
+
|
|
4
|
+
This package contains utility functions for git operations,
|
|
5
|
+
YAML processing, console output, and interactive prompts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from specfact_cli.utils.console import console, print_validation_report
|
|
9
|
+
from specfact_cli.utils.feature_keys import (
|
|
10
|
+
convert_feature_keys,
|
|
11
|
+
find_feature_by_normalized_key,
|
|
12
|
+
normalize_feature_key,
|
|
13
|
+
to_classname_key,
|
|
14
|
+
to_sequential_key,
|
|
15
|
+
to_underscore_key,
|
|
16
|
+
)
|
|
17
|
+
from specfact_cli.utils.git import GitOperations
|
|
18
|
+
from specfact_cli.utils.prompts import (
|
|
19
|
+
display_summary,
|
|
20
|
+
print_error,
|
|
21
|
+
print_info,
|
|
22
|
+
print_section,
|
|
23
|
+
print_success,
|
|
24
|
+
print_warning,
|
|
25
|
+
prompt_confirm,
|
|
26
|
+
prompt_dict,
|
|
27
|
+
prompt_list,
|
|
28
|
+
prompt_text,
|
|
29
|
+
)
|
|
30
|
+
from specfact_cli.utils.yaml_utils import YAMLUtils, dump_yaml, load_yaml, string_to_yaml, yaml_to_string
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"GitOperations",
|
|
34
|
+
"YAMLUtils",
|
|
35
|
+
"console",
|
|
36
|
+
"convert_feature_keys",
|
|
37
|
+
"display_summary",
|
|
38
|
+
"dump_yaml",
|
|
39
|
+
"find_feature_by_normalized_key",
|
|
40
|
+
"load_yaml",
|
|
41
|
+
"normalize_feature_key",
|
|
42
|
+
"print_error",
|
|
43
|
+
"print_info",
|
|
44
|
+
"print_section",
|
|
45
|
+
"print_success",
|
|
46
|
+
"print_validation_report",
|
|
47
|
+
"print_warning",
|
|
48
|
+
"prompt_confirm",
|
|
49
|
+
"prompt_dict",
|
|
50
|
+
"prompt_list",
|
|
51
|
+
"prompt_text",
|
|
52
|
+
"string_to_yaml",
|
|
53
|
+
"to_classname_key",
|
|
54
|
+
"to_sequential_key",
|
|
55
|
+
"to_underscore_key",
|
|
56
|
+
"yaml_to_string",
|
|
57
|
+
]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Console output utilities.
|
|
3
|
+
|
|
4
|
+
This module provides helpers for rich console output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from beartype import beartype
|
|
10
|
+
from icontract import require
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from specfact_cli.models.deviation import DeviationSeverity, ValidationReport
|
|
16
|
+
|
|
17
|
+
# Shared console instance
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@beartype
|
|
22
|
+
@require(lambda report: isinstance(report, ValidationReport), "Report must be ValidationReport instance")
|
|
23
|
+
def print_validation_report(report: ValidationReport) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Print a formatted validation report.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
report: Validation report to print
|
|
29
|
+
"""
|
|
30
|
+
# Create summary table
|
|
31
|
+
table = Table(title="Validation Summary")
|
|
32
|
+
table.add_column("Severity", style="cyan")
|
|
33
|
+
table.add_column("Count", justify="right")
|
|
34
|
+
|
|
35
|
+
if report.high_count > 0:
|
|
36
|
+
table.add_row("HIGH", str(report.high_count), style="bold red")
|
|
37
|
+
if report.medium_count > 0:
|
|
38
|
+
table.add_row("MEDIUM", str(report.medium_count), style="yellow")
|
|
39
|
+
if report.low_count > 0:
|
|
40
|
+
table.add_row("LOW", str(report.low_count), style="blue")
|
|
41
|
+
|
|
42
|
+
console.print(table)
|
|
43
|
+
|
|
44
|
+
# Print deviations
|
|
45
|
+
if report.deviations:
|
|
46
|
+
console.print("\n[bold]Deviations:[/bold]\n")
|
|
47
|
+
|
|
48
|
+
for i, deviation in enumerate(report.deviations, 1):
|
|
49
|
+
severity_color = {
|
|
50
|
+
DeviationSeverity.HIGH: "bold red",
|
|
51
|
+
DeviationSeverity.MEDIUM: "yellow",
|
|
52
|
+
DeviationSeverity.LOW: "blue",
|
|
53
|
+
}[deviation.severity]
|
|
54
|
+
|
|
55
|
+
console.print(f"[{severity_color}]{i}. [{deviation.severity}][/{severity_color}] {deviation.description}")
|
|
56
|
+
|
|
57
|
+
if deviation.location:
|
|
58
|
+
console.print(f" [dim]Location: {deviation.location}[/dim]")
|
|
59
|
+
|
|
60
|
+
if hasattr(deviation, "fix_hint") and deviation.fix_hint:
|
|
61
|
+
console.print(f" [green]→ Suggestion: {deviation.fix_hint}[/green]")
|
|
62
|
+
|
|
63
|
+
console.print()
|
|
64
|
+
|
|
65
|
+
# Print overall result
|
|
66
|
+
if report.passed:
|
|
67
|
+
console.print(Panel("[bold green]✓ Validation PASSED[/bold green]", border_style="green"))
|
|
68
|
+
else:
|
|
69
|
+
console.print(Panel("[bold red]✗ Validation FAILED[/bold red]", border_style="red"))
|