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