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,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()
|