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.
- specfact_cli/__init__.py +1 -1
- specfact_cli/agents/analyze_agent.py +2 -3
- specfact_cli/analyzers/__init__.py +2 -1
- specfact_cli/analyzers/ambiguity_scanner.py +601 -0
- specfact_cli/analyzers/code_analyzer.py +462 -30
- specfact_cli/analyzers/constitution_evidence_extractor.py +491 -0
- specfact_cli/analyzers/contract_extractor.py +419 -0
- specfact_cli/analyzers/control_flow_analyzer.py +281 -0
- specfact_cli/analyzers/requirement_extractor.py +337 -0
- specfact_cli/analyzers/test_pattern_extractor.py +330 -0
- specfact_cli/cli.py +151 -206
- specfact_cli/commands/constitution.py +281 -0
- specfact_cli/commands/enforce.py +42 -34
- specfact_cli/commands/import_cmd.py +481 -152
- specfact_cli/commands/init.py +224 -55
- specfact_cli/commands/plan.py +2133 -547
- specfact_cli/commands/repro.py +100 -78
- specfact_cli/commands/sync.py +701 -186
- specfact_cli/enrichers/constitution_enricher.py +765 -0
- specfact_cli/enrichers/plan_enricher.py +294 -0
- specfact_cli/importers/speckit_converter.py +364 -48
- specfact_cli/importers/speckit_scanner.py +65 -0
- specfact_cli/models/plan.py +42 -0
- specfact_cli/resources/mappings/node-async.yaml +49 -0
- specfact_cli/resources/mappings/python-async.yaml +47 -0
- specfact_cli/resources/mappings/speckit-default.yaml +82 -0
- specfact_cli/resources/prompts/specfact-enforce.md +185 -0
- specfact_cli/resources/prompts/specfact-import-from-code.md +626 -0
- specfact_cli/resources/prompts/specfact-plan-add-feature.md +188 -0
- specfact_cli/resources/prompts/specfact-plan-add-story.md +212 -0
- specfact_cli/resources/prompts/specfact-plan-compare.md +571 -0
- specfact_cli/resources/prompts/specfact-plan-init.md +531 -0
- specfact_cli/resources/prompts/specfact-plan-promote.md +352 -0
- specfact_cli/resources/prompts/specfact-plan-review.md +1276 -0
- specfact_cli/resources/prompts/specfact-plan-select.md +401 -0
- specfact_cli/resources/prompts/specfact-plan-update-feature.md +242 -0
- specfact_cli/resources/prompts/specfact-plan-update-idea.md +211 -0
- specfact_cli/resources/prompts/specfact-repro.md +268 -0
- specfact_cli/resources/prompts/specfact-sync.md +497 -0
- specfact_cli/resources/schemas/deviation.schema.json +61 -0
- specfact_cli/resources/schemas/plan.schema.json +204 -0
- specfact_cli/resources/schemas/protocol.schema.json +53 -0
- specfact_cli/resources/templates/github-action.yml.j2 +140 -0
- specfact_cli/resources/templates/plan.bundle.yaml.j2 +141 -0
- specfact_cli/resources/templates/pr-template.md.j2 +58 -0
- specfact_cli/resources/templates/protocol.yaml.j2 +24 -0
- specfact_cli/resources/templates/telemetry.yaml.example +35 -0
- specfact_cli/sync/__init__.py +10 -1
- specfact_cli/sync/watcher.py +268 -0
- specfact_cli/telemetry.py +440 -0
- specfact_cli/utils/acceptance_criteria.py +127 -0
- specfact_cli/utils/enrichment_parser.py +445 -0
- specfact_cli/utils/feature_keys.py +12 -3
- specfact_cli/utils/ide_setup.py +170 -0
- specfact_cli/utils/structure.py +179 -2
- specfact_cli/utils/yaml_utils.py +33 -0
- specfact_cli/validators/repro_checker.py +22 -1
- specfact_cli/validators/schema.py +15 -4
- specfact_cli-0.6.8.dist-info/METADATA +456 -0
- specfact_cli-0.6.8.dist-info/RECORD +99 -0
- {specfact_cli-0.4.2.dist-info → specfact_cli-0.6.8.dist-info}/entry_points.txt +1 -0
- specfact_cli-0.6.8.dist-info/licenses/LICENSE.md +202 -0
- specfact_cli-0.4.2.dist-info/METADATA +0 -370
- specfact_cli-0.4.2.dist-info/RECORD +0 -62
- specfact_cli-0.4.2.dist-info/licenses/LICENSE.md +0 -61
- {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
|