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.

Files changed (60) hide show
  1. specfact_cli/__init__.py +14 -0
  2. specfact_cli/agents/__init__.py +23 -0
  3. specfact_cli/agents/analyze_agent.py +392 -0
  4. specfact_cli/agents/base.py +95 -0
  5. specfact_cli/agents/plan_agent.py +202 -0
  6. specfact_cli/agents/registry.py +176 -0
  7. specfact_cli/agents/sync_agent.py +133 -0
  8. specfact_cli/analyzers/__init__.py +10 -0
  9. specfact_cli/analyzers/code_analyzer.py +775 -0
  10. specfact_cli/cli.py +397 -0
  11. specfact_cli/commands/__init__.py +7 -0
  12. specfact_cli/commands/enforce.py +87 -0
  13. specfact_cli/commands/import_cmd.py +355 -0
  14. specfact_cli/commands/init.py +119 -0
  15. specfact_cli/commands/plan.py +1090 -0
  16. specfact_cli/commands/repro.py +172 -0
  17. specfact_cli/commands/sync.py +408 -0
  18. specfact_cli/common/__init__.py +24 -0
  19. specfact_cli/common/logger_setup.py +673 -0
  20. specfact_cli/common/logging_utils.py +41 -0
  21. specfact_cli/common/text_utils.py +52 -0
  22. specfact_cli/common/utils.py +48 -0
  23. specfact_cli/comparators/__init__.py +10 -0
  24. specfact_cli/comparators/plan_comparator.py +391 -0
  25. specfact_cli/generators/__init__.py +13 -0
  26. specfact_cli/generators/plan_generator.py +105 -0
  27. specfact_cli/generators/protocol_generator.py +115 -0
  28. specfact_cli/generators/report_generator.py +200 -0
  29. specfact_cli/generators/workflow_generator.py +111 -0
  30. specfact_cli/importers/__init__.py +6 -0
  31. specfact_cli/importers/speckit_converter.py +773 -0
  32. specfact_cli/importers/speckit_scanner.py +704 -0
  33. specfact_cli/models/__init__.py +32 -0
  34. specfact_cli/models/deviation.py +105 -0
  35. specfact_cli/models/enforcement.py +150 -0
  36. specfact_cli/models/plan.py +97 -0
  37. specfact_cli/models/protocol.py +28 -0
  38. specfact_cli/modes/__init__.py +18 -0
  39. specfact_cli/modes/detector.py +126 -0
  40. specfact_cli/modes/router.py +153 -0
  41. specfact_cli/sync/__init__.py +11 -0
  42. specfact_cli/sync/repository_sync.py +279 -0
  43. specfact_cli/sync/speckit_sync.py +388 -0
  44. specfact_cli/utils/__init__.py +57 -0
  45. specfact_cli/utils/console.py +69 -0
  46. specfact_cli/utils/feature_keys.py +213 -0
  47. specfact_cli/utils/git.py +241 -0
  48. specfact_cli/utils/ide_setup.py +381 -0
  49. specfact_cli/utils/prompts.py +179 -0
  50. specfact_cli/utils/structure.py +496 -0
  51. specfact_cli/utils/yaml_utils.py +200 -0
  52. specfact_cli/validators/__init__.py +19 -0
  53. specfact_cli/validators/fsm.py +260 -0
  54. specfact_cli/validators/repro_checker.py +320 -0
  55. specfact_cli/validators/schema.py +200 -0
  56. specfact_cli-0.4.0.dist-info/METADATA +332 -0
  57. specfact_cli-0.4.0.dist-info/RECORD +60 -0
  58. specfact_cli-0.4.0.dist-info/WHEEL +4 -0
  59. specfact_cli-0.4.0.dist-info/entry_points.txt +2 -0
  60. 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"))