specfact-cli 0.4.2__py3-none-any.whl → 0.6.8__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.
Files changed (66) hide show
  1. specfact_cli/__init__.py +1 -1
  2. specfact_cli/agents/analyze_agent.py +2 -3
  3. specfact_cli/analyzers/__init__.py +2 -1
  4. specfact_cli/analyzers/ambiguity_scanner.py +601 -0
  5. specfact_cli/analyzers/code_analyzer.py +462 -30
  6. specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
  7. specfact_cli/analyzers/contract_extractor.py +419 -0
  8. specfact_cli/analyzers/control_flow_analyzer.py +281 -0
  9. specfact_cli/analyzers/requirement_extractor.py +337 -0
  10. specfact_cli/analyzers/test_pattern_extractor.py +330 -0
  11. specfact_cli/cli.py +151 -206
  12. specfact_cli/commands/constitution.py +281 -0
  13. specfact_cli/commands/enforce.py +42 -34
  14. specfact_cli/commands/import_cmd.py +481 -152
  15. specfact_cli/commands/init.py +224 -55
  16. specfact_cli/commands/plan.py +2133 -547
  17. specfact_cli/commands/repro.py +100 -78
  18. specfact_cli/commands/sync.py +701 -186
  19. specfact_cli/enrichers/constitution_enricher.py +765 -0
  20. specfact_cli/enrichers/plan_enricher.py +294 -0
  21. specfact_cli/importers/speckit_converter.py +364 -48
  22. specfact_cli/importers/speckit_scanner.py +65 -0
  23. specfact_cli/models/plan.py +42 -0
  24. specfact_cli/resources/mappings/node-async.yaml +49 -0
  25. specfact_cli/resources/mappings/python-async.yaml +47 -0
  26. specfact_cli/resources/mappings/speckit-default.yaml +82 -0
  27. specfact_cli/resources/prompts/specfact-enforce.md +185 -0
  28. specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
  29. specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
  30. specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
  31. specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
  32. specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
  33. specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
  34. specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
  35. specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
  36. specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
  37. specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
  38. specfact_cli/resources/prompts/specfact-repro.md +268 -0
  39. specfact_cli/resources/prompts/specfact-sync.md +497 -0
  40. specfact_cli/resources/schemas/deviation.schema.json +61 -0
  41. specfact_cli/resources/schemas/plan.schema.json +204 -0
  42. specfact_cli/resources/schemas/protocol.schema.json +53 -0
  43. specfact_cli/resources/templates/github-action.yml.j2 +140 -0
  44. specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
  45. specfact_cli/resources/templates/pr-template.md.j2 +58 -0
  46. specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
  47. specfact_cli/resources/templates/telemetry.yaml.example +35 -0
  48. specfact_cli/sync/__init__.py +10 -1
  49. specfact_cli/sync/watcher.py +268 -0
  50. specfact_cli/telemetry.py +440 -0
  51. specfact_cli/utils/acceptance_criteria.py +127 -0
  52. specfact_cli/utils/enrichment_parser.py +445 -0
  53. specfact_cli/utils/feature_keys.py +12 -3
  54. specfact_cli/utils/ide_setup.py +170 -0
  55. specfact_cli/utils/structure.py +179 -2
  56. specfact_cli/utils/yaml_utils.py +33 -0
  57. specfact_cli/validators/repro_checker.py +22 -1
  58. specfact_cli/validators/schema.py +15 -4
  59. specfact_cli-0.6.8.dist-info/METADATA +456 -0
  60. specfact_cli-0.6.8.dist-info/RECORD +99 -0
  61. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
  62. specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
  63. specfact_cli-0.4.2.dist-info/METADATA +0 -370
  64. specfact_cli-0.4.2.dist-info/RECORD +0 -62
  65. specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
  66. {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/WHEEL +0 -0
@@ -0,0 +1,330 @@
1
+ """Test pattern extractor for generating testable acceptance criteria.
2
+
3
+ Extracts test patterns from existing test files (pytest, unittest) and converts
4
+ them to Given/When/Then format acceptance criteria.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from pathlib import Path
11
+
12
+ from beartype import beartype
13
+ from icontract import ensure, require
14
+
15
+
16
+ class TestPatternExtractor:
17
+ """
18
+ Extracts test patterns from test files and converts them to acceptance criteria.
19
+
20
+ Supports pytest and unittest test frameworks.
21
+ """
22
+
23
+ @beartype
24
+ @require(lambda repo_path: repo_path is not None and isinstance(repo_path, Path), "Repo path must be Path")
25
+ def __init__(self, repo_path: Path) -> None:
26
+ """
27
+ Initialize test pattern extractor.
28
+
29
+ Args:
30
+ repo_path: Path to repository root
31
+ """
32
+ self.repo_path = Path(repo_path)
33
+ self.test_files: list[Path] = []
34
+ self._discover_test_files()
35
+
36
+ def _discover_test_files(self) -> None:
37
+ """Discover all test files in the repository."""
38
+ # Common test file patterns
39
+ test_patterns = [
40
+ "test_*.py",
41
+ "*_test.py",
42
+ "tests/**/test_*.py",
43
+ "tests/**/*_test.py",
44
+ ]
45
+
46
+ for pattern in test_patterns:
47
+ if "**" in pattern:
48
+ # Recursive pattern
49
+ base_pattern = pattern.split("**")[0].rstrip("/")
50
+ suffix_pattern = pattern.split("**")[1].lstrip("/")
51
+ if (self.repo_path / base_pattern).exists():
52
+ self.test_files.extend((self.repo_path / base_pattern).rglob(suffix_pattern))
53
+ else:
54
+ # Simple pattern
55
+ self.test_files.extend(self.repo_path.glob(pattern))
56
+
57
+ # Remove duplicates and filter out __pycache__
58
+ self.test_files = [f for f in set(self.test_files) if "__pycache__" not in str(f) and f.is_file()]
59
+
60
+ @beartype
61
+ @ensure(lambda result: isinstance(result, list), "Must return list")
62
+ def extract_test_patterns_for_class(self, class_name: str, module_path: Path | None = None) -> list[str]:
63
+ """
64
+ Extract test patterns for a specific class.
65
+
66
+ Args:
67
+ class_name: Name of the class to find tests for
68
+ module_path: Optional path to the source module (for better matching)
69
+
70
+ Returns:
71
+ List of testable acceptance criteria in Given/When/Then format
72
+ """
73
+ acceptance_criteria: list[str] = []
74
+
75
+ for test_file in self.test_files:
76
+ try:
77
+ test_patterns = self._parse_test_file(test_file, class_name, module_path)
78
+ acceptance_criteria.extend(test_patterns)
79
+ except Exception:
80
+ # Skip files that can't be parsed
81
+ continue
82
+
83
+ return acceptance_criteria
84
+
85
+ @beartype
86
+ def _parse_test_file(self, test_file: Path, class_name: str, module_path: Path | None) -> list[str]:
87
+ """Parse a test file and extract test patterns for the given class."""
88
+ try:
89
+ content = test_file.read_text(encoding="utf-8")
90
+ tree = ast.parse(content, filename=str(test_file))
91
+ except Exception:
92
+ return []
93
+
94
+ acceptance_criteria: list[str] = []
95
+
96
+ for node in ast.walk(tree):
97
+ if isinstance(node, ast.FunctionDef) and node.name.startswith("test_"):
98
+ # Found a test function
99
+ test_pattern = self._extract_test_pattern(node, class_name)
100
+ if test_pattern:
101
+ acceptance_criteria.append(test_pattern)
102
+
103
+ return acceptance_criteria
104
+
105
+ @beartype
106
+ def _extract_test_pattern(self, test_node: ast.FunctionDef, class_name: str) -> str | None:
107
+ """
108
+ Extract test pattern from a test function and convert to Given/When/Then format.
109
+
110
+ Args:
111
+ test_node: AST node for the test function
112
+ class_name: Name of the class being tested
113
+
114
+ Returns:
115
+ Testable acceptance criterion in Given/When/Then format, or None
116
+ """
117
+ # Extract test name (remove "test_" prefix)
118
+ test_name = test_node.name.replace("test_", "").replace("_", " ")
119
+
120
+ # Find assertions in the test
121
+ assertions = self._find_assertions(test_node)
122
+
123
+ if not assertions:
124
+ return None
125
+
126
+ # Extract Given/When/Then from test structure
127
+ given = self._extract_given(test_node, class_name)
128
+ when = self._extract_when(test_node, test_name)
129
+ then = self._extract_then(assertions)
130
+
131
+ if given and when and then:
132
+ return f"Given {given}, When {when}, Then {then}"
133
+
134
+ return None
135
+
136
+ @beartype
137
+ def _find_assertions(self, node: ast.FunctionDef) -> list[ast.AST]:
138
+ """Find all assertion statements in a test function."""
139
+ assertions: list[ast.AST] = []
140
+
141
+ for child in ast.walk(node):
142
+ if isinstance(child, ast.Assert):
143
+ assertions.append(child)
144
+ elif (
145
+ isinstance(child, ast.Call)
146
+ and isinstance(child.func, ast.Attribute)
147
+ and child.func.attr.startswith("assert")
148
+ ):
149
+ # Check for pytest assertions (assert_equal, assert_true, etc.)
150
+ assertions.append(child)
151
+
152
+ return assertions
153
+
154
+ @beartype
155
+ def _extract_given(self, test_node: ast.FunctionDef, class_name: str) -> str:
156
+ """Extract Given clause from test setup."""
157
+ # Look for setup code (fixtures, mocks, initializations)
158
+ given_parts: list[str] = []
159
+
160
+ # Check for pytest fixtures
161
+ for decorator in test_node.decorator_list:
162
+ if (
163
+ isinstance(decorator, ast.Call)
164
+ and isinstance(decorator.func, ast.Name)
165
+ and (decorator.func.id == "pytest.fixture" or decorator.func.id == "fixture")
166
+ ):
167
+ given_parts.append("test fixtures are available")
168
+
169
+ # Default: assume class instance is available
170
+ if not given_parts:
171
+ given_parts.append(f"{class_name} instance is available")
172
+
173
+ return " and ".join(given_parts) if given_parts else "system is initialized"
174
+
175
+ @beartype
176
+ def _extract_when(self, test_node: ast.FunctionDef, test_name: str) -> str:
177
+ """Extract When clause from test action."""
178
+ # Extract action from test name or function body
179
+ action = test_name.replace("_", " ")
180
+
181
+ # Try to find method calls in the test
182
+ for node in ast.walk(test_node):
183
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
184
+ method_name = node.func.attr
185
+ if not method_name.startswith("assert") and not method_name.startswith("_"):
186
+ action = f"{method_name} is called"
187
+ break
188
+
189
+ return action if action else "action is performed"
190
+
191
+ @beartype
192
+ def _extract_then(self, assertions: list[ast.AST]) -> str:
193
+ """Extract Then clause from assertions."""
194
+ if not assertions:
195
+ return "expected result is achieved"
196
+
197
+ # Extract expected outcomes from assertions
198
+ outcomes: list[str] = []
199
+
200
+ for assertion in assertions:
201
+ if isinstance(assertion, ast.Assert):
202
+ # Simple assert statement
203
+ outcome = self._extract_assertion_outcome(assertion)
204
+ if outcome:
205
+ outcomes.append(outcome)
206
+ elif isinstance(assertion, ast.Call):
207
+ # Pytest assertion (assert_equal, assert_true, etc.)
208
+ outcome = self._extract_pytest_assertion_outcome(assertion)
209
+ if outcome:
210
+ outcomes.append(outcome)
211
+
212
+ return " and ".join(outcomes) if outcomes else "expected result is achieved"
213
+
214
+ @beartype
215
+ def _extract_assertion_outcome(self, assertion: ast.Assert) -> str | None:
216
+ """Extract outcome from a simple assert statement."""
217
+ if isinstance(assertion.test, ast.Compare):
218
+ # Comparison assertion (==, !=, <, >, etc.)
219
+ left = ast.unparse(assertion.test.left) if hasattr(ast, "unparse") else str(assertion.test.left)
220
+ ops = [op.__class__.__name__ for op in assertion.test.ops]
221
+ comparators = [
222
+ ast.unparse(comp) if hasattr(ast, "unparse") else str(comp) for comp in assertion.test.comparators
223
+ ]
224
+
225
+ if ops and comparators:
226
+ op_map = {
227
+ "Eq": "equals",
228
+ "NotEq": "does not equal",
229
+ "Lt": "is less than",
230
+ "LtE": "is less than or equal to",
231
+ "Gt": "is greater than",
232
+ "GtE": "is greater than or equal to",
233
+ }
234
+ op_name = op_map.get(ops[0], "matches")
235
+ return f"{left} {op_name} {comparators[0]}"
236
+
237
+ return None
238
+
239
+ @beartype
240
+ def _extract_pytest_assertion_outcome(self, call: ast.Call) -> str | None:
241
+ """Extract outcome from a pytest assertion call."""
242
+ if isinstance(call.func, ast.Attribute):
243
+ attr_name = call.func.attr
244
+
245
+ if attr_name == "assert_equal" and len(call.args) >= 2:
246
+ return f"{ast.unparse(call.args[0]) if hasattr(ast, 'unparse') else str(call.args[0])} equals {ast.unparse(call.args[1]) if hasattr(ast, 'unparse') else str(call.args[1])}"
247
+ if attr_name == "assert_true" and len(call.args) >= 1:
248
+ return f"{ast.unparse(call.args[0]) if hasattr(ast, 'unparse') else str(call.args[0])} is true"
249
+ if attr_name == "assert_false" and len(call.args) >= 1:
250
+ return f"{ast.unparse(call.args[0]) if hasattr(ast, 'unparse') else str(call.args[0])} is false"
251
+ if attr_name == "assert_in" and len(call.args) >= 2:
252
+ return f"{ast.unparse(call.args[0]) if hasattr(ast, 'unparse') else str(call.args[0])} is in {ast.unparse(call.args[1]) if hasattr(ast, 'unparse') else str(call.args[1])}"
253
+
254
+ return None
255
+
256
+ @beartype
257
+ @ensure(lambda result: isinstance(result, list), "Must return list")
258
+ def infer_from_code_patterns(self, method_node: ast.FunctionDef, class_name: str) -> list[str]:
259
+ """
260
+ Infer testable acceptance criteria from code patterns when tests are missing.
261
+
262
+ Args:
263
+ method_node: AST node for the method
264
+ class_name: Name of the class containing the method
265
+
266
+ Returns:
267
+ List of testable acceptance criteria in Given/When/Then format
268
+ """
269
+ acceptance_criteria: list[str] = []
270
+
271
+ # Extract method name and purpose
272
+ method_name = method_node.name
273
+
274
+ # Pattern 1: Validation logic → "Must verify [validation rule]"
275
+ if any(keyword in method_name.lower() for keyword in ["validate", "check", "verify", "is_valid"]):
276
+ validation_target = (
277
+ method_name.replace("validate", "")
278
+ .replace("check", "")
279
+ .replace("verify", "")
280
+ .replace("is_valid", "")
281
+ .strip()
282
+ )
283
+ if validation_target:
284
+ acceptance_criteria.append(
285
+ f"Given {class_name} instance, When {method_name} is called, Then {validation_target} is validated"
286
+ )
287
+
288
+ # Pattern 2: Error handling → "Must handle [error condition]"
289
+ if any(keyword in method_name.lower() for keyword in ["handle", "catch", "error", "exception"]):
290
+ error_type = method_name.replace("handle", "").replace("catch", "").strip()
291
+ acceptance_criteria.append(
292
+ f"Given error condition occurs, When {method_name} is called, Then {error_type or 'error'} is handled"
293
+ )
294
+
295
+ # Pattern 3: Success paths → "Must return [expected result]"
296
+ # Check return type hints
297
+ if method_node.returns:
298
+ return_type = ast.unparse(method_node.returns) if hasattr(ast, "unparse") else str(method_node.returns)
299
+ acceptance_criteria.append(
300
+ f"Given {class_name} instance, When {method_name} is called, Then {return_type} is returned"
301
+ )
302
+
303
+ # Pattern 4: Type hints → "Must accept [type] and return [type]"
304
+ if method_node.args.args:
305
+ param_types: list[str] = []
306
+ for arg in method_node.args.args:
307
+ if arg.annotation:
308
+ param_type = ast.unparse(arg.annotation) if hasattr(ast, "unparse") else str(arg.annotation)
309
+ param_types.append(f"{arg.arg}: {param_type}")
310
+
311
+ if param_types:
312
+ params_str = ", ".join(param_types)
313
+ return_type_str = (
314
+ ast.unparse(method_node.returns)
315
+ if method_node.returns and hasattr(ast, "unparse")
316
+ else str(method_node.returns)
317
+ if method_node.returns
318
+ else "result"
319
+ )
320
+ acceptance_criteria.append(
321
+ f"Given {class_name} instance with {params_str}, When {method_name} is called, Then {return_type_str} is returned"
322
+ )
323
+
324
+ # Default: Generic acceptance criterion
325
+ if not acceptance_criteria:
326
+ acceptance_criteria.append(
327
+ f"Given {class_name} instance, When {method_name} is called, Then method executes successfully"
328
+ )
329
+
330
+ return acceptance_criteria