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,41 @@
|
|
|
1
|
+
"""Logging helpers with graceful fallback when SpecFact CLI common module is unavailable."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
from icontract import ensure, require
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@beartype
|
|
12
|
+
@require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string")
|
|
13
|
+
@require(lambda level: isinstance(level, str) and len(level) > 0, "Level must be non-empty string")
|
|
14
|
+
@ensure(lambda result: isinstance(result, logging.Logger), "Must return Logger instance")
|
|
15
|
+
def get_bridge_logger(name: str, level: str = "INFO") -> logging.Logger:
|
|
16
|
+
"""
|
|
17
|
+
Retrieve a configured logger.
|
|
18
|
+
|
|
19
|
+
If the SpecFact CLI `common.logger_setup` module is available we reuse it, otherwise
|
|
20
|
+
we create a standard library logger to keep the bridge self-contained.
|
|
21
|
+
"""
|
|
22
|
+
logger = _try_common_logger(name, level)
|
|
23
|
+
if logger is not None:
|
|
24
|
+
return logger
|
|
25
|
+
|
|
26
|
+
fallback_logger = logging.getLogger(name)
|
|
27
|
+
if not fallback_logger.handlers:
|
|
28
|
+
handler = logging.StreamHandler()
|
|
29
|
+
formatter = logging.Formatter(fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s")
|
|
30
|
+
handler.setFormatter(formatter)
|
|
31
|
+
fallback_logger.addHandler(handler)
|
|
32
|
+
fallback_logger.setLevel(level.upper())
|
|
33
|
+
return fallback_logger
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _try_common_logger(name: str, level: str) -> logging.Logger | None:
|
|
37
|
+
try:
|
|
38
|
+
from specfact_cli.common.logger_setup import LoggerSetup # type: ignore[import]
|
|
39
|
+
except ImportError:
|
|
40
|
+
return None
|
|
41
|
+
return LoggerSetup.create_logger(name, log_level=level)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text utility functions for SpecFact CLI modules
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import textwrap
|
|
7
|
+
|
|
8
|
+
from beartype import beartype
|
|
9
|
+
from icontract import ensure, require
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TextUtils:
|
|
13
|
+
"""A utility class for text manipulation."""
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
@beartype
|
|
17
|
+
@require(lambda max_length: max_length > 0, "Max length must be positive")
|
|
18
|
+
@ensure(lambda result: result is None or isinstance(result, str), "Must return None or string")
|
|
19
|
+
def shorten_text(text: str | None, max_length: int = 50) -> str | None:
|
|
20
|
+
"""Shorten text to a maximum length, appending '...' if truncated."""
|
|
21
|
+
if text is None:
|
|
22
|
+
return None
|
|
23
|
+
return text if len(text) <= max_length else text[:max_length] + "..."
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
@beartype
|
|
27
|
+
@require(lambda code: isinstance(code, str), "Code must be string")
|
|
28
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
29
|
+
def clean_code(code: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Extract code from markdown triple-backtick fences. If multiple fenced
|
|
32
|
+
blocks are present, only the first block is returned. Language hints
|
|
33
|
+
(e.g. ```python) are stripped. Leading indentation inside the block is
|
|
34
|
+
removed via ``textwrap.dedent`` to make assertions deterministic.
|
|
35
|
+
"""
|
|
36
|
+
# Use regex that handles both real newline and literal \n inside the string.
|
|
37
|
+
pattern = r"```(?:[a-zA-Z0-9_-]+)?(?:\\n|\n)([\s\S]*?)```"
|
|
38
|
+
match = re.search(pattern, code)
|
|
39
|
+
|
|
40
|
+
if not match:
|
|
41
|
+
return code.strip()
|
|
42
|
+
|
|
43
|
+
content = match.group(1)
|
|
44
|
+
|
|
45
|
+
# Dedent and strip
|
|
46
|
+
content = textwrap.dedent(content).strip()
|
|
47
|
+
# If the cleaned content ends with a *literal* "\\n" sequence (common when the
|
|
48
|
+
# source string itself contains escaped new-line characters), remove it so that
|
|
49
|
+
# callers do not have to account for this artefact in expectations.
|
|
50
|
+
if content.endswith("\\n"):
|
|
51
|
+
content = content[:-2]
|
|
52
|
+
return content
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Utility helpers for the Spec-Kit compatibility layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from beartype import beartype
|
|
11
|
+
from icontract import ensure, require
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@beartype
|
|
15
|
+
@require(lambda path: isinstance(path, Path) and path.exists(), "Path must exist")
|
|
16
|
+
@ensure(lambda result: isinstance(result, str) and result.startswith("sha256:"), "Must return sha256 hash string")
|
|
17
|
+
def compute_sha256(path: Path) -> str:
|
|
18
|
+
"""Compute the SHA256 hash of a file."""
|
|
19
|
+
hasher = hashlib.sha256()
|
|
20
|
+
with path.open("rb") as handle:
|
|
21
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
22
|
+
hasher.update(chunk)
|
|
23
|
+
return f"sha256:{hasher.hexdigest()}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@beartype
|
|
27
|
+
@require(lambda path: isinstance(path, Path), "Path must be Path instance")
|
|
28
|
+
def ensure_directory(path: Path) -> None:
|
|
29
|
+
"""Ensure that the directory for *path* exists."""
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@beartype
|
|
34
|
+
@require(lambda path: isinstance(path, Path), "Path must be Path instance")
|
|
35
|
+
def dump_json(data: Any, path: Path) -> None:
|
|
36
|
+
"""Write *data* as formatted JSON to *path*."""
|
|
37
|
+
ensure_directory(path)
|
|
38
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
39
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@beartype
|
|
43
|
+
@require(lambda path: isinstance(path, Path) and path.exists(), "Path must exist")
|
|
44
|
+
@ensure(lambda result: result is not None, "Must return parsed content")
|
|
45
|
+
def load_json(path: Path) -> Any:
|
|
46
|
+
"""Load JSON data from *path*."""
|
|
47
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
48
|
+
return json.load(handle)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comparators module for SpecFact CLI.
|
|
3
|
+
|
|
4
|
+
This module provides classes for comparing specifications, plans, and protocols
|
|
5
|
+
to detect deviations and generate comparison reports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from specfact_cli.comparators.plan_comparator import PlanComparator
|
|
9
|
+
|
|
10
|
+
__all__ = ["PlanComparator"]
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Plan comparator for detecting deviations between plan bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from beartype import beartype
|
|
6
|
+
from icontract import ensure, require
|
|
7
|
+
|
|
8
|
+
from specfact_cli.models.deviation import Deviation, DeviationReport, DeviationSeverity, DeviationType
|
|
9
|
+
from specfact_cli.models.plan import PlanBundle
|
|
10
|
+
from specfact_cli.utils.feature_keys import normalize_feature_key
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PlanComparator:
|
|
14
|
+
"""
|
|
15
|
+
Compares two plan bundles to detect deviations.
|
|
16
|
+
|
|
17
|
+
Identifies differences between manual (source of truth) and auto-derived
|
|
18
|
+
(reverse-engineered from code) plan bundles.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
@beartype
|
|
22
|
+
@require(lambda manual_plan: isinstance(manual_plan, PlanBundle), "Manual plan must be PlanBundle instance")
|
|
23
|
+
@require(lambda auto_plan: isinstance(auto_plan, PlanBundle), "Auto plan must be PlanBundle instance")
|
|
24
|
+
@require(
|
|
25
|
+
lambda manual_label: isinstance(manual_label, str) and len(manual_label) > 0,
|
|
26
|
+
"Manual label must be non-empty string",
|
|
27
|
+
)
|
|
28
|
+
@require(
|
|
29
|
+
lambda auto_label: isinstance(auto_label, str) and len(auto_label) > 0, "Auto label must be non-empty string"
|
|
30
|
+
)
|
|
31
|
+
@ensure(lambda result: isinstance(result, DeviationReport), "Must return DeviationReport")
|
|
32
|
+
@ensure(lambda result: len(result.manual_plan) > 0, "Manual plan label must be non-empty")
|
|
33
|
+
@ensure(lambda result: len(result.auto_plan) > 0, "Auto plan label must be non-empty")
|
|
34
|
+
def compare(
|
|
35
|
+
self,
|
|
36
|
+
manual_plan: PlanBundle,
|
|
37
|
+
auto_plan: PlanBundle,
|
|
38
|
+
manual_label: str = "manual_plan",
|
|
39
|
+
auto_label: str = "auto_plan",
|
|
40
|
+
) -> DeviationReport:
|
|
41
|
+
"""
|
|
42
|
+
Compare two plan bundles and generate deviation report.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
manual_plan: Manually created plan (source of truth)
|
|
46
|
+
auto_plan: Auto-derived plan from code analysis
|
|
47
|
+
manual_label: Label for manual plan (e.g., file path)
|
|
48
|
+
auto_label: Label for auto plan (e.g., file path)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
DeviationReport with all detected deviations
|
|
52
|
+
"""
|
|
53
|
+
deviations: list[Deviation] = []
|
|
54
|
+
|
|
55
|
+
# Compare ideas
|
|
56
|
+
deviations.extend(self._compare_ideas(manual_plan, auto_plan))
|
|
57
|
+
|
|
58
|
+
# Compare business context
|
|
59
|
+
deviations.extend(self._compare_business(manual_plan, auto_plan))
|
|
60
|
+
|
|
61
|
+
# Compare product
|
|
62
|
+
deviations.extend(self._compare_product(manual_plan, auto_plan))
|
|
63
|
+
|
|
64
|
+
# Compare features
|
|
65
|
+
deviations.extend(self._compare_features(manual_plan, auto_plan))
|
|
66
|
+
|
|
67
|
+
# Build summary statistics
|
|
68
|
+
summary: dict[str, int] = {}
|
|
69
|
+
for deviation in deviations:
|
|
70
|
+
deviation_type = deviation.type.value
|
|
71
|
+
summary[deviation_type] = summary.get(deviation_type, 0) + 1
|
|
72
|
+
|
|
73
|
+
return DeviationReport(
|
|
74
|
+
manual_plan=manual_label,
|
|
75
|
+
auto_plan=auto_label,
|
|
76
|
+
deviations=deviations,
|
|
77
|
+
summary=summary,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _compare_ideas(self, manual: PlanBundle, auto: PlanBundle) -> list[Deviation]:
|
|
81
|
+
"""Compare idea sections of two plans."""
|
|
82
|
+
deviations: list[Deviation] = []
|
|
83
|
+
|
|
84
|
+
# Check if both have ideas
|
|
85
|
+
if manual.idea is None and auto.idea is None:
|
|
86
|
+
return deviations
|
|
87
|
+
|
|
88
|
+
if manual.idea is None and auto.idea is not None:
|
|
89
|
+
deviations.append(
|
|
90
|
+
Deviation(
|
|
91
|
+
type=DeviationType.EXTRA_IMPLEMENTATION,
|
|
92
|
+
severity=DeviationSeverity.LOW,
|
|
93
|
+
description="Auto plan has Idea section but manual plan does not",
|
|
94
|
+
location="idea",
|
|
95
|
+
fix_hint="Consider removing auto-derived Idea or adding it to manual plan",
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return deviations
|
|
99
|
+
|
|
100
|
+
if manual.idea is not None and auto.idea is None:
|
|
101
|
+
deviations.append(
|
|
102
|
+
Deviation(
|
|
103
|
+
type=DeviationType.MISSING_FEATURE,
|
|
104
|
+
severity=DeviationSeverity.MEDIUM,
|
|
105
|
+
description="Manual plan has Idea section but auto plan does not",
|
|
106
|
+
location="idea",
|
|
107
|
+
fix_hint="Add Idea section to auto-derived plan",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return deviations
|
|
111
|
+
|
|
112
|
+
# Both have ideas, compare fields
|
|
113
|
+
if manual.idea is not None and auto.idea is not None:
|
|
114
|
+
if manual.idea.title != auto.idea.title:
|
|
115
|
+
deviations.append(
|
|
116
|
+
Deviation(
|
|
117
|
+
type=DeviationType.MISMATCH,
|
|
118
|
+
severity=DeviationSeverity.LOW,
|
|
119
|
+
description=f"Idea title differs: manual='{manual.idea.title}', auto='{auto.idea.title}'",
|
|
120
|
+
location="idea.title",
|
|
121
|
+
fix_hint="Update auto plan title to match manual plan",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if manual.idea.narrative != auto.idea.narrative:
|
|
126
|
+
deviations.append(
|
|
127
|
+
Deviation(
|
|
128
|
+
type=DeviationType.MISMATCH,
|
|
129
|
+
severity=DeviationSeverity.LOW,
|
|
130
|
+
description="Idea narrative differs between plans",
|
|
131
|
+
location="idea.narrative",
|
|
132
|
+
fix_hint="Update narrative to match manual plan",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return deviations
|
|
137
|
+
|
|
138
|
+
def _compare_business(self, manual: PlanBundle, auto: PlanBundle) -> list[Deviation]:
|
|
139
|
+
"""Compare business context sections."""
|
|
140
|
+
deviations: list[Deviation] = []
|
|
141
|
+
|
|
142
|
+
if manual.business is not None and auto.business is None:
|
|
143
|
+
deviations.append(
|
|
144
|
+
Deviation(
|
|
145
|
+
type=DeviationType.MISSING_BUSINESS_CONTEXT,
|
|
146
|
+
severity=DeviationSeverity.MEDIUM,
|
|
147
|
+
description="Manual plan has Business context but auto plan does not",
|
|
148
|
+
location="business",
|
|
149
|
+
fix_hint="Add business context to auto-derived plan",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return deviations
|
|
154
|
+
|
|
155
|
+
def _compare_product(self, manual: PlanBundle, auto: PlanBundle) -> list[Deviation]:
|
|
156
|
+
"""Compare product sections (themes, releases)."""
|
|
157
|
+
deviations: list[Deviation] = []
|
|
158
|
+
|
|
159
|
+
# Compare themes
|
|
160
|
+
manual_themes = set(manual.product.themes)
|
|
161
|
+
auto_themes = set(auto.product.themes)
|
|
162
|
+
|
|
163
|
+
missing_themes = manual_themes - auto_themes
|
|
164
|
+
extra_themes = auto_themes - manual_themes
|
|
165
|
+
|
|
166
|
+
for theme in missing_themes:
|
|
167
|
+
deviations.append(
|
|
168
|
+
Deviation(
|
|
169
|
+
type=DeviationType.MISMATCH,
|
|
170
|
+
severity=DeviationSeverity.LOW,
|
|
171
|
+
description=f"Product theme '{theme}' in manual plan but not in auto plan",
|
|
172
|
+
location="product.themes",
|
|
173
|
+
fix_hint=f"Add theme '{theme}' to auto plan",
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
for theme in extra_themes:
|
|
178
|
+
deviations.append(
|
|
179
|
+
Deviation(
|
|
180
|
+
type=DeviationType.MISMATCH,
|
|
181
|
+
severity=DeviationSeverity.LOW,
|
|
182
|
+
description=f"Product theme '{theme}' in auto plan but not in manual plan",
|
|
183
|
+
location="product.themes",
|
|
184
|
+
fix_hint=f"Remove theme '{theme}' from auto plan or add to manual",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return deviations
|
|
189
|
+
|
|
190
|
+
def _compare_features(self, manual: PlanBundle, auto: PlanBundle) -> list[Deviation]:
|
|
191
|
+
"""Compare features between two plans using normalized keys."""
|
|
192
|
+
deviations: list[Deviation] = []
|
|
193
|
+
|
|
194
|
+
# Build feature maps by normalized key for comparison
|
|
195
|
+
manual_features_by_norm = {normalize_feature_key(f.key): f for f in manual.features}
|
|
196
|
+
auto_features_by_norm = {normalize_feature_key(f.key): f for f in auto.features}
|
|
197
|
+
|
|
198
|
+
# Also build by original key for display
|
|
199
|
+
manual_features = {f.key: f for f in manual.features}
|
|
200
|
+
auto_features = {f.key: f for f in auto.features}
|
|
201
|
+
|
|
202
|
+
# Check for missing features (in manual but not in auto) using normalized keys
|
|
203
|
+
for norm_key in manual_features_by_norm:
|
|
204
|
+
if norm_key not in auto_features_by_norm:
|
|
205
|
+
manual_feature = manual_features_by_norm[norm_key]
|
|
206
|
+
# Higher severity if feature has stories
|
|
207
|
+
severity = DeviationSeverity.HIGH if manual_feature.stories else DeviationSeverity.MEDIUM
|
|
208
|
+
|
|
209
|
+
deviations.append(
|
|
210
|
+
Deviation(
|
|
211
|
+
type=DeviationType.MISSING_FEATURE,
|
|
212
|
+
severity=severity,
|
|
213
|
+
description=f"Feature '{manual_feature.key}' ({manual_feature.title}) in manual plan but not implemented",
|
|
214
|
+
location=f"features[{manual_feature.key}]",
|
|
215
|
+
fix_hint=f"Implement feature '{manual_feature.key}' or update manual plan",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Check for extra features (in auto but not in manual) using normalized keys
|
|
220
|
+
for norm_key in auto_features_by_norm:
|
|
221
|
+
if norm_key not in manual_features_by_norm:
|
|
222
|
+
auto_feature = auto_features_by_norm[norm_key]
|
|
223
|
+
# Higher severity if feature has many stories or high confidence
|
|
224
|
+
severity = DeviationSeverity.MEDIUM
|
|
225
|
+
if len(auto_feature.stories) > 3 or auto_feature.confidence >= 0.8:
|
|
226
|
+
severity = DeviationSeverity.HIGH
|
|
227
|
+
elif len(auto_feature.stories) == 0 or auto_feature.confidence < 0.5:
|
|
228
|
+
severity = DeviationSeverity.LOW
|
|
229
|
+
|
|
230
|
+
deviations.append(
|
|
231
|
+
Deviation(
|
|
232
|
+
type=DeviationType.EXTRA_IMPLEMENTATION,
|
|
233
|
+
severity=severity,
|
|
234
|
+
description=f"Feature '{auto_feature.key}' ({auto_feature.title}) found in code but not in manual plan",
|
|
235
|
+
location=f"features[{auto_feature.key}]",
|
|
236
|
+
fix_hint=f"Add feature '{auto_feature.key}' to manual plan or remove from code",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Compare common features using normalized keys
|
|
241
|
+
common_norm_keys = set(manual_features_by_norm.keys()) & set(auto_features_by_norm.keys())
|
|
242
|
+
for norm_key in common_norm_keys:
|
|
243
|
+
manual_feature = manual_features_by_norm[norm_key]
|
|
244
|
+
auto_feature = auto_features_by_norm[norm_key]
|
|
245
|
+
key = manual_feature.key # Use manual key for display
|
|
246
|
+
|
|
247
|
+
# Compare feature titles
|
|
248
|
+
if manual_feature.title != auto_feature.title:
|
|
249
|
+
deviations.append(
|
|
250
|
+
Deviation(
|
|
251
|
+
type=DeviationType.MISMATCH,
|
|
252
|
+
severity=DeviationSeverity.LOW,
|
|
253
|
+
description=f"Feature '{key}' title differs: manual='{manual_feature.title}', auto='{auto_feature.title}'",
|
|
254
|
+
location=f"features[{key}].title",
|
|
255
|
+
fix_hint="Update feature title in code or manual plan",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Compare stories
|
|
260
|
+
deviations.extend(self._compare_stories(manual_feature, auto_feature, key))
|
|
261
|
+
|
|
262
|
+
return deviations
|
|
263
|
+
|
|
264
|
+
def _compare_stories(self, manual_feature, auto_feature, feature_key: str) -> list[Deviation]:
|
|
265
|
+
"""Compare stories within a feature with enhanced detection."""
|
|
266
|
+
deviations: list[Deviation] = []
|
|
267
|
+
|
|
268
|
+
# Build story maps by key
|
|
269
|
+
manual_stories = {s.key: s for s in manual_feature.stories}
|
|
270
|
+
auto_stories = {s.key: s for s in auto_feature.stories}
|
|
271
|
+
|
|
272
|
+
# Check for missing stories
|
|
273
|
+
for key in manual_stories:
|
|
274
|
+
if key not in auto_stories:
|
|
275
|
+
manual_story = manual_stories[key]
|
|
276
|
+
# Higher severity if story has high value points or is not a draft
|
|
277
|
+
value_points = manual_story.value_points or 0
|
|
278
|
+
severity = (
|
|
279
|
+
DeviationSeverity.HIGH
|
|
280
|
+
if (value_points >= 8 or not manual_story.draft)
|
|
281
|
+
else DeviationSeverity.MEDIUM
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
deviations.append(
|
|
285
|
+
Deviation(
|
|
286
|
+
type=DeviationType.MISSING_STORY,
|
|
287
|
+
severity=severity,
|
|
288
|
+
description=f"Story '{key}' ({manual_story.title}) in manual plan but not implemented",
|
|
289
|
+
location=f"features[{feature_key}].stories[{key}]",
|
|
290
|
+
fix_hint=f"Implement story '{key}' or update manual plan",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Check for extra stories
|
|
295
|
+
for key in auto_stories:
|
|
296
|
+
if key not in manual_stories:
|
|
297
|
+
auto_story = auto_stories[key]
|
|
298
|
+
# Medium severity if story has high confidence or value points
|
|
299
|
+
value_points = auto_story.value_points or 0
|
|
300
|
+
severity = (
|
|
301
|
+
DeviationSeverity.MEDIUM
|
|
302
|
+
if (auto_story.confidence >= 0.8 or value_points >= 8)
|
|
303
|
+
else DeviationSeverity.LOW
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
deviations.append(
|
|
307
|
+
Deviation(
|
|
308
|
+
type=DeviationType.EXTRA_IMPLEMENTATION,
|
|
309
|
+
severity=severity,
|
|
310
|
+
description=f"Story '{key}' ({auto_story.title}) found in code but not in manual plan",
|
|
311
|
+
location=f"features[{feature_key}].stories[{key}]",
|
|
312
|
+
fix_hint=f"Add story '{key}' to manual plan or remove from code",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# Compare common stories
|
|
317
|
+
common_keys = set(manual_stories.keys()) & set(auto_stories.keys())
|
|
318
|
+
for key in common_keys:
|
|
319
|
+
manual_story = manual_stories[key]
|
|
320
|
+
auto_story = auto_stories[key]
|
|
321
|
+
|
|
322
|
+
# Title mismatch
|
|
323
|
+
if manual_story.title != auto_story.title:
|
|
324
|
+
deviations.append(
|
|
325
|
+
Deviation(
|
|
326
|
+
type=DeviationType.MISMATCH,
|
|
327
|
+
severity=DeviationSeverity.LOW,
|
|
328
|
+
description=f"Story '{key}' title differs: manual='{manual_story.title}', auto='{auto_story.title}'",
|
|
329
|
+
location=f"features[{feature_key}].stories[{key}].title",
|
|
330
|
+
fix_hint="Update story title in code or manual plan",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Acceptance criteria drift
|
|
335
|
+
manual_acceptance = set(manual_story.acceptance or [])
|
|
336
|
+
auto_acceptance = set(auto_story.acceptance or [])
|
|
337
|
+
if manual_acceptance != auto_acceptance:
|
|
338
|
+
missing_criteria = manual_acceptance - auto_acceptance
|
|
339
|
+
extra_criteria = auto_acceptance - manual_acceptance
|
|
340
|
+
|
|
341
|
+
if missing_criteria:
|
|
342
|
+
deviations.append(
|
|
343
|
+
Deviation(
|
|
344
|
+
type=DeviationType.ACCEPTANCE_DRIFT,
|
|
345
|
+
severity=DeviationSeverity.HIGH,
|
|
346
|
+
description=f"Story '{key}' missing acceptance criteria: {', '.join(missing_criteria)}",
|
|
347
|
+
location=f"features[{feature_key}].stories[{key}].acceptance",
|
|
348
|
+
fix_hint=f"Ensure all acceptance criteria are implemented: {', '.join(missing_criteria)}",
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if extra_criteria:
|
|
353
|
+
deviations.append(
|
|
354
|
+
Deviation(
|
|
355
|
+
type=DeviationType.ACCEPTANCE_DRIFT,
|
|
356
|
+
severity=DeviationSeverity.MEDIUM,
|
|
357
|
+
description=f"Story '{key}' has extra acceptance criteria in code: {', '.join(extra_criteria)}",
|
|
358
|
+
location=f"features[{feature_key}].stories[{key}].acceptance",
|
|
359
|
+
fix_hint=f"Update manual plan to include: {', '.join(extra_criteria)}",
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Story points mismatch (if significant)
|
|
364
|
+
manual_points = manual_story.story_points or 0
|
|
365
|
+
auto_points = auto_story.story_points or 0
|
|
366
|
+
if abs(manual_points - auto_points) >= 3:
|
|
367
|
+
deviations.append(
|
|
368
|
+
Deviation(
|
|
369
|
+
type=DeviationType.MISMATCH,
|
|
370
|
+
severity=DeviationSeverity.MEDIUM,
|
|
371
|
+
description=f"Story '{key}' story points differ significantly: manual={manual_points}, auto={auto_points}",
|
|
372
|
+
location=f"features[{feature_key}].stories[{key}].story_points",
|
|
373
|
+
fix_hint="Re-evaluate story complexity or update manual plan",
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Value points mismatch (if significant)
|
|
378
|
+
manual_value = manual_story.value_points or 0
|
|
379
|
+
auto_value = auto_story.value_points or 0
|
|
380
|
+
if abs(manual_value - auto_value) >= 5:
|
|
381
|
+
deviations.append(
|
|
382
|
+
Deviation(
|
|
383
|
+
type=DeviationType.MISMATCH,
|
|
384
|
+
severity=DeviationSeverity.MEDIUM,
|
|
385
|
+
description=f"Story '{key}' value points differ significantly: manual={manual_value}, auto={auto_value}",
|
|
386
|
+
location=f"features[{feature_key}].stories[{key}].value_points",
|
|
387
|
+
fix_hint="Re-evaluate business value or update manual plan",
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
return deviations
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Generators for plan bundles, protocols, reports, and workflows."""
|
|
2
|
+
|
|
3
|
+
from specfact_cli.generators.plan_generator import PlanGenerator
|
|
4
|
+
from specfact_cli.generators.protocol_generator import ProtocolGenerator
|
|
5
|
+
from specfact_cli.generators.report_generator import ReportGenerator
|
|
6
|
+
from specfact_cli.generators.workflow_generator import WorkflowGenerator
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"PlanGenerator",
|
|
10
|
+
"ProtocolGenerator",
|
|
11
|
+
"ReportGenerator",
|
|
12
|
+
"WorkflowGenerator",
|
|
13
|
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Plan bundle generator using direct YAML serialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from beartype import beartype
|
|
8
|
+
from icontract import ensure, require
|
|
9
|
+
from jinja2 import Environment, FileSystemLoader
|
|
10
|
+
|
|
11
|
+
from specfact_cli.models.plan import PlanBundle
|
|
12
|
+
from specfact_cli.utils.yaml_utils import dump_yaml, yaml_to_string
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanGenerator:
|
|
16
|
+
"""
|
|
17
|
+
Generator for plan bundle YAML files.
|
|
18
|
+
|
|
19
|
+
Uses direct YAML serialization for reliable output.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@beartype
|
|
23
|
+
def __init__(self, templates_dir: Path | None = None) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Initialize plan generator.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
templates_dir: Directory containing Jinja2 templates (default: resources/templates)
|
|
29
|
+
"""
|
|
30
|
+
if templates_dir is None:
|
|
31
|
+
# Default to resources/templates relative to project root
|
|
32
|
+
templates_dir = Path(__file__).parent.parent.parent.parent / "resources" / "templates"
|
|
33
|
+
|
|
34
|
+
self.templates_dir = Path(templates_dir)
|
|
35
|
+
self.env = Environment(
|
|
36
|
+
loader=FileSystemLoader(self.templates_dir),
|
|
37
|
+
trim_blocks=True,
|
|
38
|
+
lstrip_blocks=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@beartype
|
|
42
|
+
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
|
|
43
|
+
@require(lambda output_path: output_path is not None, "Output path must not be None")
|
|
44
|
+
@ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
|
|
45
|
+
def generate(self, plan_bundle: PlanBundle, output_path: Path) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Generate plan bundle YAML file from model.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
plan_bundle: PlanBundle model to generate from
|
|
51
|
+
output_path: Path to write the generated YAML file
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
IOError: If unable to write output file
|
|
55
|
+
"""
|
|
56
|
+
# Convert model to dict, excluding None values
|
|
57
|
+
plan_data = plan_bundle.model_dump(exclude_none=True)
|
|
58
|
+
|
|
59
|
+
# Write to file using YAML dump
|
|
60
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
dump_yaml(plan_data, output_path)
|
|
62
|
+
|
|
63
|
+
@beartype
|
|
64
|
+
@require(
|
|
65
|
+
lambda template_name: isinstance(template_name, str) and len(template_name) > 0,
|
|
66
|
+
"Template name must be non-empty string",
|
|
67
|
+
)
|
|
68
|
+
@require(lambda context: isinstance(context, dict), "Context must be dictionary")
|
|
69
|
+
@require(lambda output_path: output_path is not None, "Output path must not be None")
|
|
70
|
+
@ensure(lambda output_path: output_path.exists(), "Output file must exist after generation")
|
|
71
|
+
def generate_from_template(self, template_name: str, context: dict, output_path: Path) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Generate file from custom template.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
template_name: Name of the template file
|
|
77
|
+
context: Context dictionary for template rendering
|
|
78
|
+
output_path: Path to write the generated file
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
FileNotFoundError: If template file doesn't exist
|
|
82
|
+
IOError: If unable to write output file
|
|
83
|
+
"""
|
|
84
|
+
template = self.env.get_template(template_name)
|
|
85
|
+
rendered = template.render(**context)
|
|
86
|
+
|
|
87
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
output_path.write_text(rendered, encoding="utf-8")
|
|
89
|
+
|
|
90
|
+
@beartype
|
|
91
|
+
@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Must be PlanBundle instance")
|
|
92
|
+
@ensure(lambda result: isinstance(result, str), "Must return string")
|
|
93
|
+
@ensure(lambda result: len(result) > 0, "Result must be non-empty")
|
|
94
|
+
def render_string(self, plan_bundle: PlanBundle) -> str:
|
|
95
|
+
"""
|
|
96
|
+
Render plan bundle to YAML string without writing to file.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
plan_bundle: PlanBundle model to render
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Rendered YAML string
|
|
103
|
+
"""
|
|
104
|
+
plan_data = plan_bundle.model_dump(exclude_none=True)
|
|
105
|
+
return yaml_to_string(plan_data)
|