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,153 @@
1
+ """
2
+ Command Router - Route commands based on operational mode.
3
+
4
+ This module provides routing logic to execute commands differently based on
5
+ the operational mode (CI/CD vs CoPilot).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from beartype import beartype
14
+ from icontract import ensure, require
15
+
16
+ from specfact_cli.agents.registry import get_agent
17
+ from specfact_cli.modes.detector import OperationalMode, detect_mode
18
+
19
+
20
+ @dataclass
21
+ class RoutingResult:
22
+ """Result of command routing."""
23
+
24
+ execution_mode: str # "direct" or "agent"
25
+ mode: OperationalMode
26
+ command: str
27
+
28
+
29
+ class CommandRouter:
30
+ """Routes commands based on operational mode."""
31
+
32
+ @beartype
33
+ @require(lambda command: bool(command), "Command must be non-empty")
34
+ @require(lambda mode: isinstance(mode, OperationalMode), "Mode must be OperationalMode")
35
+ @ensure(lambda result: result.execution_mode in ("direct", "agent"), "Execution mode must be direct or agent")
36
+ @ensure(lambda result: result.mode in (OperationalMode.CICD, OperationalMode.COPILOT), "Mode must be valid")
37
+ def route(self, command: str, mode: OperationalMode, context: dict[str, Any] | None = None) -> RoutingResult:
38
+ """
39
+ Route a command based on operational mode.
40
+
41
+ Args:
42
+ command: Command name (e.g., "import from-code")
43
+ mode: Operational mode (CI/CD or CoPilot)
44
+ context: Optional context dictionary for command execution
45
+
46
+ Returns:
47
+ RoutingResult with execution mode and mode information
48
+
49
+ Examples:
50
+ >>> router = CommandRouter()
51
+ >>> result = router.route("import from-code", OperationalMode.CICD)
52
+ >>> result.execution_mode
53
+ 'direct'
54
+ >>> result = router.route("import from-code", OperationalMode.COPILOT)
55
+ >>> result.execution_mode
56
+ 'agent'
57
+ """
58
+ if mode == OperationalMode.CICD:
59
+ return RoutingResult(execution_mode="direct", mode=mode, command=command)
60
+ # CoPilot mode uses agent routing (Phase 4.1)
61
+ # Check if agent is available for this command
62
+ agent = get_agent(command)
63
+ if agent:
64
+ return RoutingResult(execution_mode="agent", mode=mode, command=command)
65
+ # Fallback to direct execution if no agent available
66
+ return RoutingResult(execution_mode="direct", mode=mode, command=command)
67
+
68
+ @beartype
69
+ @require(lambda command: bool(command), "Command must be non-empty")
70
+ @ensure(lambda result: result.mode in (OperationalMode.CICD, OperationalMode.COPILOT), "Mode must be valid")
71
+ def route_with_auto_detect(
72
+ self, command: str, explicit_mode: OperationalMode | None = None, context: dict[str, Any] | None = None
73
+ ) -> RoutingResult:
74
+ """
75
+ Route a command with automatic mode detection.
76
+
77
+ Args:
78
+ command: Command name (e.g., "import from-code")
79
+ explicit_mode: Optional explicit mode override
80
+ context: Optional context dictionary for command execution
81
+
82
+ Returns:
83
+ RoutingResult with execution mode and detected mode information
84
+
85
+ Examples:
86
+ >>> router = CommandRouter()
87
+ >>> result = router.route_with_auto_detect("import from-code")
88
+ >>> result.execution_mode in ("direct", "agent")
89
+ True
90
+ """
91
+ mode = detect_mode(explicit_mode=explicit_mode)
92
+ return self.route(command, mode, context)
93
+
94
+ @beartype
95
+ @require(lambda mode: isinstance(mode, OperationalMode), "Mode must be OperationalMode")
96
+ def should_use_agent(self, mode: OperationalMode) -> bool:
97
+ """
98
+ Check if command should use agent routing.
99
+
100
+ Args:
101
+ mode: Operational mode
102
+
103
+ Returns:
104
+ True if agent routing should be used, False for direct execution
105
+
106
+ Examples:
107
+ >>> router = CommandRouter()
108
+ >>> router.should_use_agent(OperationalMode.CICD)
109
+ False
110
+ >>> router.should_use_agent(OperationalMode.COPILOT)
111
+ True
112
+ """
113
+ return mode == OperationalMode.COPILOT
114
+
115
+ @beartype
116
+ @require(lambda mode: isinstance(mode, OperationalMode), "Mode must be OperationalMode")
117
+ def should_use_direct(self, mode: OperationalMode) -> bool:
118
+ """
119
+ Check if command should use direct execution.
120
+
121
+ Args:
122
+ mode: Operational mode
123
+
124
+ Returns:
125
+ True if direct execution should be used, False for agent routing
126
+
127
+ Examples:
128
+ >>> router = CommandRouter()
129
+ >>> router.should_use_direct(OperationalMode.CICD)
130
+ True
131
+ >>> router.should_use_direct(OperationalMode.COPILOT)
132
+ False
133
+ """
134
+ return mode == OperationalMode.CICD
135
+
136
+
137
+ def get_router() -> CommandRouter:
138
+ """
139
+ Get the global command router instance.
140
+
141
+ Returns:
142
+ CommandRouter instance
143
+
144
+ Examples:
145
+ >>> router = get_router()
146
+ >>> isinstance(router, CommandRouter)
147
+ True
148
+ """
149
+ return _router
150
+
151
+
152
+ # Global router instance
153
+ _router = CommandRouter()
@@ -0,0 +1,11 @@
1
+ """
2
+ Sync operations for SpecFact CLI.
3
+
4
+ This module provides bidirectional synchronization between Spec-Kit artifacts,
5
+ repository changes, and SpecFact plans.
6
+ """
7
+
8
+ from specfact_cli.sync.repository_sync import RepositorySync, RepositorySyncResult
9
+ from specfact_cli.sync.speckit_sync import SpecKitSync, SyncResult
10
+
11
+ __all__ = ["RepositorySync", "RepositorySyncResult", "SpecKitSync", "SyncResult"]
@@ -0,0 +1,279 @@
1
+ """
2
+ Repository sync implementation.
3
+
4
+ This module provides synchronization of repository code changes to SpecFact artifacts.
5
+ It detects code changes, updates plan artifacts, and tracks deviations from manual plans.
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.analyzers.code_analyzer import CodeAnalyzer
19
+ from specfact_cli.comparators.plan_comparator import PlanComparator
20
+ from specfact_cli.models.plan import PlanBundle
21
+ from specfact_cli.validators.schema import validate_plan_bundle
22
+
23
+
24
+ @dataclass
25
+ class RepositorySyncResult:
26
+ """
27
+ Result of repository sync operation.
28
+
29
+ Attributes:
30
+ status: Sync status ("success" | "deviation_detected" | "error")
31
+ code_changes: List of detected code changes
32
+ plan_updates: List of plan artifact updates
33
+ deviations: List of deviations from manual plan
34
+ """
35
+
36
+ status: str
37
+ code_changes: list[dict[str, Any]]
38
+ plan_updates: list[dict[str, Any]]
39
+ deviations: list[dict[str, Any]]
40
+
41
+ @beartype
42
+ def __post_init__(self) -> None:
43
+ """Validate RepositorySyncResult after initialization."""
44
+ valid_statuses = ["success", "deviation_detected", "error"]
45
+ if self.status not in valid_statuses:
46
+ msg = f"Status must be one of {valid_statuses}, got {self.status}"
47
+ raise ValueError(msg)
48
+
49
+
50
+ class RepositorySync:
51
+ """
52
+ Sync code changes to SpecFact artifacts.
53
+
54
+ Monitors repository code changes, updates plan artifacts based on detected
55
+ features/stories, and tracks deviations from manual plans.
56
+ """
57
+
58
+ @beartype
59
+ def __init__(self, repo_path: Path, target: Path | None = None, confidence_threshold: float = 0.5) -> None:
60
+ """
61
+ Initialize repository sync.
62
+
63
+ Args:
64
+ repo_path: Path to repository root
65
+ target: Target directory for artifacts (default: .specfact)
66
+ confidence_threshold: Minimum confidence threshold for feature detection
67
+ """
68
+ self.repo_path = Path(repo_path).resolve()
69
+ self.target = Path(target).resolve() if target else self.repo_path / ".specfact"
70
+ self.confidence_threshold = confidence_threshold
71
+ self.hash_store: dict[str, str] = {}
72
+ self.analyzer = CodeAnalyzer(self.repo_path, confidence_threshold)
73
+
74
+ @beartype
75
+ @require(lambda repo_path: repo_path.exists(), "Repository path must exist")
76
+ @require(lambda repo_path: repo_path.is_dir(), "Repository path must be a directory")
77
+ @ensure(lambda result: isinstance(result, RepositorySyncResult), "Must return RepositorySyncResult")
78
+ @ensure(lambda result: result.status in ["success", "deviation_detected", "error"], "Status must be valid")
79
+ def sync_repository_changes(self, repo_path: Path | None = None) -> RepositorySyncResult:
80
+ """
81
+ Sync code changes to SpecFact artifacts.
82
+
83
+ Args:
84
+ repo_path: Path to repository (default: self.repo_path)
85
+
86
+ Returns:
87
+ Repository sync result with code changes, plan updates, and deviations
88
+ """
89
+ if repo_path is None:
90
+ repo_path = self.repo_path
91
+
92
+ # 1. Detect code changes
93
+ code_changes = self.detect_code_changes(repo_path)
94
+
95
+ # 2. Update plan artifacts based on code changes
96
+ plan_updates = self.update_plan_artifacts(code_changes, self.target)
97
+
98
+ # 3. Track deviations from manual plans
99
+ deviations = self.track_deviations(code_changes, self.target)
100
+
101
+ # Determine status
102
+ status = "deviation_detected" if deviations else "success"
103
+
104
+ return RepositorySyncResult(
105
+ status=status,
106
+ code_changes=code_changes,
107
+ plan_updates=plan_updates,
108
+ deviations=deviations,
109
+ )
110
+
111
+ @beartype
112
+ @require(lambda repo_path: repo_path.exists(), "Repository path must exist")
113
+ @ensure(lambda result: isinstance(result, list), "Must return list")
114
+ def detect_code_changes(self, repo_path: Path) -> list[dict[str, Any]]:
115
+ """
116
+ Detect code changes in repository.
117
+
118
+ Monitors source files in src/ directory and detects modifications
119
+ based on file hashing.
120
+
121
+ Args:
122
+ repo_path: Path to repository
123
+
124
+ Returns:
125
+ List of detected code changes
126
+ """
127
+ changes: list[dict[str, Any]] = []
128
+
129
+ # Monitor source files in src/ directory
130
+ src_dir = repo_path / "src"
131
+ if src_dir.exists():
132
+ for source_file in src_dir.rglob("*.py"):
133
+ if source_file.is_file():
134
+ relative_path = str(source_file.relative_to(repo_path))
135
+ current_hash = self._get_file_hash(source_file)
136
+ stored_hash = self.hash_store.get(relative_path, "")
137
+
138
+ if current_hash != stored_hash:
139
+ changes.append(
140
+ {
141
+ "file": source_file,
142
+ "hash": current_hash,
143
+ "type": "modified" if stored_hash else "new",
144
+ "relative_path": relative_path,
145
+ }
146
+ )
147
+
148
+ return changes
149
+
150
+ @beartype
151
+ @ensure(lambda result: isinstance(result, list), "Must return list")
152
+ def update_plan_artifacts(self, code_changes: list[dict[str, Any]], target: Path) -> list[dict[str, Any]]:
153
+ """
154
+ Update plan artifacts based on code changes.
155
+
156
+ Analyzes code changes to extract features/stories and updates
157
+ plan bundle if auto-generated plan exists.
158
+
159
+ Args:
160
+ code_changes: List of detected code changes
161
+ target: Target directory for artifacts
162
+
163
+ Returns:
164
+ List of plan updates
165
+ """
166
+ updates: list[dict[str, Any]] = []
167
+
168
+ if not code_changes:
169
+ return updates
170
+
171
+ # Analyze code changes using CodeAnalyzer
172
+ # For now, analyze entire repository if there are changes
173
+ # (could be optimized to only analyze changed files)
174
+ try:
175
+ auto_plan = self.analyzer.analyze()
176
+ if auto_plan and auto_plan.features:
177
+ # Write auto-generated plan to reports directory
178
+ reports_dir = target / "reports" / "repository"
179
+ reports_dir.mkdir(parents=True, exist_ok=True)
180
+ auto_plan_file = reports_dir / "auto-generated-plan.yaml"
181
+
182
+ from specfact_cli.generators.plan_generator import PlanGenerator
183
+
184
+ generator = PlanGenerator()
185
+ generator.generate(auto_plan, auto_plan_file)
186
+
187
+ updates.append(
188
+ {
189
+ "plan_file": auto_plan_file,
190
+ "features": len(auto_plan.features),
191
+ "stories": sum(len(f.stories) for f in auto_plan.features),
192
+ "updated": True,
193
+ }
194
+ )
195
+ except Exception:
196
+ # If analysis fails, continue without update
197
+ pass
198
+
199
+ return updates
200
+
201
+ @beartype
202
+ @ensure(lambda result: isinstance(result, list), "Must return list")
203
+ def track_deviations(self, code_changes: list[dict[str, Any]], target: Path) -> list[dict[str, Any]]:
204
+ """
205
+ Track deviations from manual plans.
206
+
207
+ Compares detected features/stories from code changes against
208
+ manual plan bundle and identifies deviations.
209
+
210
+ Args:
211
+ code_changes: List of detected code changes
212
+ target: Target directory for artifacts
213
+
214
+ Returns:
215
+ List of deviation dictionaries
216
+ """
217
+ deviations: list[dict[str, Any]] = []
218
+
219
+ # Load manual plan
220
+ manual_plan_file = target / "plans" / "main.bundle.yaml"
221
+ if not manual_plan_file.exists():
222
+ return deviations
223
+
224
+ # Validate and load manual plan
225
+ is_valid, _error, manual_plan = validate_plan_bundle(manual_plan_file)
226
+ if not is_valid or manual_plan is None:
227
+ return deviations
228
+
229
+ # Type guard: manual_plan is not None after check
230
+ assert isinstance(manual_plan, PlanBundle)
231
+
232
+ # Generate auto plan from current code
233
+ try:
234
+ auto_plan = self.analyzer.analyze()
235
+ if not auto_plan or not auto_plan.features:
236
+ return deviations
237
+
238
+ # Compare manual vs auto plan using PlanComparator
239
+ comparator = PlanComparator()
240
+ comparison = comparator.compare(manual_plan, auto_plan)
241
+
242
+ # Convert comparison deviations to sync deviations
243
+ for deviation in comparison.deviations:
244
+ deviations.append(
245
+ {
246
+ "type": deviation.type.value if hasattr(deviation.type, "value") else str(deviation.type),
247
+ "severity": (
248
+ deviation.severity.value
249
+ if hasattr(deviation.severity, "value")
250
+ else str(deviation.severity)
251
+ ),
252
+ "description": deviation.description,
253
+ "location": deviation.location or "",
254
+ "fix_hint": deviation.suggestion or "",
255
+ }
256
+ )
257
+ except Exception:
258
+ # If comparison fails, continue without deviations
259
+ pass
260
+
261
+ return deviations
262
+
263
+ @beartype
264
+ def _get_file_hash(self, file_path: Path) -> str:
265
+ """
266
+ Get file hash for change detection.
267
+
268
+ Args:
269
+ file_path: Path to file
270
+
271
+ Returns:
272
+ SHA256 hash of file contents
273
+ """
274
+ if not file_path.exists():
275
+ return ""
276
+
277
+ with file_path.open("rb") as f:
278
+ content = f.read()
279
+ return hashlib.sha256(content).hexdigest()