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,419 @@
1
+ """Contract extractor for extracting API contracts from code signatures and validation logic.
2
+
3
+ Extracts contracts from function signatures, type hints, and validation logic,
4
+ generating OpenAPI/JSON Schema, icontract decorators, and contract test templates.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from typing import Any
11
+
12
+ from beartype import beartype
13
+ from icontract import ensure, require
14
+
15
+
16
+ class ContractExtractor:
17
+ """
18
+ Extracts API contracts from function signatures, type hints, and validation logic.
19
+
20
+ Generates:
21
+ - Request/Response schemas from type hints
22
+ - Preconditions from input validation
23
+ - Postconditions from output validation
24
+ - Error contracts from exception handling
25
+ - OpenAPI/JSON Schema definitions
26
+ - icontract decorators
27
+ - Contract test templates
28
+ """
29
+
30
+ @beartype
31
+ def __init__(self) -> None:
32
+ """Initialize contract extractor."""
33
+
34
+ @beartype
35
+ @require(
36
+ lambda method_node: isinstance(method_node, (ast.FunctionDef, ast.AsyncFunctionDef)),
37
+ "Method must be function node",
38
+ )
39
+ @ensure(lambda result: isinstance(result, dict), "Must return dict")
40
+ def extract_function_contracts(self, method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, Any]:
41
+ """
42
+ Extract contracts from a function signature.
43
+
44
+ Args:
45
+ method_node: AST node for the function/method
46
+
47
+ Returns:
48
+ Dictionary containing:
49
+ - parameters: List of parameter schemas
50
+ - return_type: Return type schema
51
+ - preconditions: List of preconditions
52
+ - postconditions: List of postconditions
53
+ - error_contracts: List of error contracts
54
+ """
55
+ contracts: dict[str, Any] = {
56
+ "parameters": [],
57
+ "return_type": None,
58
+ "preconditions": [],
59
+ "postconditions": [],
60
+ "error_contracts": [],
61
+ }
62
+
63
+ # Extract parameters
64
+ contracts["parameters"] = self._extract_parameters(method_node)
65
+
66
+ # Extract return type
67
+ contracts["return_type"] = self._extract_return_type(method_node)
68
+
69
+ # Extract validation logic
70
+ contracts["preconditions"] = self._extract_preconditions(method_node)
71
+ contracts["postconditions"] = self._extract_postconditions(method_node)
72
+ contracts["error_contracts"] = self._extract_error_contracts(method_node)
73
+
74
+ return contracts
75
+
76
+ @beartype
77
+ @ensure(lambda result: isinstance(result, list), "Must return list")
78
+ def _extract_parameters(self, method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict[str, Any]]:
79
+ """Extract parameter schemas from function signature."""
80
+ parameters: list[dict[str, Any]] = []
81
+
82
+ for arg in method_node.args.args:
83
+ param: dict[str, Any] = {
84
+ "name": arg.arg,
85
+ "type": self._ast_to_type_string(arg.annotation) if arg.annotation else "Any",
86
+ "required": True,
87
+ "default": None,
88
+ }
89
+
90
+ # Check if parameter has default value
91
+ # Default args are in method_node.args.defaults, aligned with last N args
92
+ arg_index = method_node.args.args.index(arg)
93
+ defaults_start = len(method_node.args.args) - len(method_node.args.defaults)
94
+ if arg_index >= defaults_start:
95
+ default_index = arg_index - defaults_start
96
+ if default_index < len(method_node.args.defaults):
97
+ param["required"] = False
98
+ param["default"] = self._ast_to_value_string(method_node.args.defaults[default_index])
99
+
100
+ parameters.append(param)
101
+
102
+ # Handle *args
103
+ if method_node.args.vararg:
104
+ parameters.append(
105
+ {
106
+ "name": method_node.args.vararg.arg,
107
+ "type": "list[Any]",
108
+ "required": False,
109
+ "variadic": True,
110
+ }
111
+ )
112
+
113
+ # Handle **kwargs
114
+ if method_node.args.kwarg:
115
+ parameters.append(
116
+ {
117
+ "name": method_node.args.kwarg.arg,
118
+ "type": "dict[str, Any]",
119
+ "required": False,
120
+ "keyword_variadic": True,
121
+ }
122
+ )
123
+
124
+ return parameters
125
+
126
+ @beartype
127
+ @ensure(lambda result: result is None or isinstance(result, dict), "Must return None or dict")
128
+ def _extract_return_type(self, method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, Any] | None:
129
+ """Extract return type schema from function signature."""
130
+ if not method_node.returns:
131
+ return {"type": "None", "nullable": False}
132
+
133
+ return {
134
+ "type": self._ast_to_type_string(method_node.returns),
135
+ "nullable": False,
136
+ }
137
+
138
+ @beartype
139
+ @ensure(lambda result: isinstance(result, list), "Must return list")
140
+ def _extract_preconditions(self, method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
141
+ """Extract preconditions from validation logic in function body."""
142
+ preconditions: list[str] = []
143
+
144
+ if not method_node.body:
145
+ return preconditions
146
+
147
+ for node in method_node.body:
148
+ # Check for assertion statements
149
+ if isinstance(node, ast.Assert):
150
+ condition = self._ast_to_condition_string(node.test)
151
+ preconditions.append(f"Requires: {condition}")
152
+
153
+ # Check for validation decorators (would need to check decorator_list)
154
+ # For now, we'll extract from docstrings and assertions
155
+
156
+ # Check for isinstance checks
157
+ if isinstance(node, ast.If):
158
+ condition = self._ast_to_condition_string(node.test)
159
+ # Check if it's a validation check (isinstance, type check, etc.)
160
+ if "isinstance" in condition or "type" in condition.lower():
161
+ preconditions.append(f"Requires: {condition}")
162
+
163
+ return preconditions
164
+
165
+ @beartype
166
+ @ensure(lambda result: isinstance(result, list), "Must return list")
167
+ def _extract_postconditions(self, method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
168
+ """Extract postconditions from return value validation."""
169
+ postconditions: list[str] = []
170
+
171
+ if not method_node.body:
172
+ return postconditions
173
+
174
+ # Check for return statements with validation
175
+ for node in ast.walk(ast.Module(body=list(method_node.body), type_ignores=[])):
176
+ if isinstance(node, ast.Return) and node.value:
177
+ return_type = self._ast_to_type_string(method_node.returns) if method_node.returns else "Any"
178
+ postconditions.append(f"Ensures: returns {return_type}")
179
+
180
+ return postconditions
181
+
182
+ @beartype
183
+ @ensure(lambda result: isinstance(result, list), "Must return list")
184
+ def _extract_error_contracts(self, method_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[dict[str, Any]]:
185
+ """Extract error contracts from exception handling."""
186
+ error_contracts: list[dict[str, Any]] = []
187
+
188
+ if not method_node.body:
189
+ return error_contracts
190
+
191
+ for node in method_node.body:
192
+ if isinstance(node, ast.Try):
193
+ for handler in node.handlers:
194
+ exception_type = "Exception"
195
+ if handler.type:
196
+ exception_type = self._ast_to_type_string(handler.type)
197
+
198
+ error_contracts.append(
199
+ {
200
+ "exception_type": exception_type,
201
+ "condition": self._ast_to_condition_string(handler.type)
202
+ if handler.type
203
+ else "Any exception",
204
+ }
205
+ )
206
+
207
+ # Check for raise statements
208
+ for child in ast.walk(node):
209
+ if (
210
+ isinstance(child, ast.Raise)
211
+ and child.exc
212
+ and isinstance(child.exc, ast.Call)
213
+ and isinstance(child.exc.func, ast.Name)
214
+ ):
215
+ error_contracts.append(
216
+ {
217
+ "exception_type": child.exc.func.id,
218
+ "condition": "Error condition",
219
+ }
220
+ )
221
+
222
+ return error_contracts
223
+
224
+ @beartype
225
+ @ensure(lambda result: isinstance(result, str), "Must return string")
226
+ def _ast_to_type_string(self, node: ast.AST | None) -> str:
227
+ """Convert AST type annotation node to string representation."""
228
+ if node is None:
229
+ return "Any"
230
+
231
+ # Use ast.unparse if available (Python 3.9+)
232
+ if hasattr(ast, "unparse"):
233
+ try:
234
+ return ast.unparse(node)
235
+ except Exception:
236
+ pass
237
+
238
+ # Fallback: manual conversion
239
+ if isinstance(node, ast.Name):
240
+ return node.id
241
+ if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name):
242
+ # Handle generics like List[str], Dict[str, int], Optional[str]
243
+ container = node.value.id
244
+ if isinstance(node.slice, ast.Tuple):
245
+ args = [self._ast_to_type_string(el) for el in node.slice.elts]
246
+ return f"{container}[{', '.join(args)}]"
247
+ if isinstance(node.slice, ast.Name):
248
+ return f"{container}[{node.slice.id}]"
249
+ return f"{container}[...]"
250
+ if isinstance(node, ast.Constant):
251
+ return str(node.value)
252
+
253
+ return "Any"
254
+
255
+ @beartype
256
+ @ensure(lambda result: isinstance(result, str), "Must return string")
257
+ def _ast_to_value_string(self, node: ast.AST) -> str:
258
+ """Convert AST value node to string representation."""
259
+ if isinstance(node, ast.Constant):
260
+ return repr(node.value)
261
+ if isinstance(node, ast.Name):
262
+ return node.id
263
+ if isinstance(node, ast.NameConstant): # Python < 3.8
264
+ return str(node.value)
265
+
266
+ # Use ast.unparse if available
267
+ if hasattr(ast, "unparse"):
268
+ try:
269
+ return ast.unparse(node)
270
+ except Exception:
271
+ pass
272
+
273
+ return "..."
274
+
275
+ @beartype
276
+ @ensure(lambda result: isinstance(result, str), "Must return string")
277
+ def _ast_to_condition_string(self, node: ast.AST) -> str:
278
+ """Convert AST condition node to string representation."""
279
+ # Use ast.unparse if available
280
+ if hasattr(ast, "unparse"):
281
+ try:
282
+ return ast.unparse(node)
283
+ except Exception:
284
+ pass
285
+
286
+ # Fallback: basic conversion
287
+ if isinstance(node, ast.Compare):
288
+ left = self._ast_to_condition_string(node.left) if hasattr(node, "left") else "..."
289
+ ops = [self._op_to_string(op) for op in node.ops]
290
+ comparators = [self._ast_to_condition_string(comp) for comp in node.comparators]
291
+ return f"{left} {' '.join(ops)} {' '.join(comparators)}"
292
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
293
+ args = [self._ast_to_condition_string(arg) for arg in node.args]
294
+ return f"{node.func.id}({', '.join(args)})"
295
+ if isinstance(node, ast.Name):
296
+ return node.id
297
+ if isinstance(node, ast.Constant):
298
+ return repr(node.value)
299
+
300
+ return "..."
301
+
302
+ @beartype
303
+ @ensure(lambda result: isinstance(result, str), "Must return string")
304
+ def _op_to_string(self, op: ast.cmpop) -> str:
305
+ """Convert AST comparison operator to string."""
306
+ op_map = {
307
+ ast.Eq: "==",
308
+ ast.NotEq: "!=",
309
+ ast.Lt: "<",
310
+ ast.LtE: "<=",
311
+ ast.Gt: ">",
312
+ ast.GtE: ">=",
313
+ ast.Is: "is",
314
+ ast.IsNot: "is not",
315
+ ast.In: "in",
316
+ ast.NotIn: "not in",
317
+ }
318
+ return op_map.get(type(op), "??")
319
+
320
+ @beartype
321
+ @require(lambda contracts: isinstance(contracts, dict), "Contracts must be dict")
322
+ @ensure(lambda result: isinstance(result, dict), "Must return dict")
323
+ def generate_json_schema(self, contracts: dict[str, Any]) -> dict[str, Any]:
324
+ """
325
+ Generate JSON Schema from contracts.
326
+
327
+ Args:
328
+ contracts: Contract dictionary from extract_function_contracts()
329
+
330
+ Returns:
331
+ JSON Schema dictionary
332
+ """
333
+ schema: dict[str, Any] = {
334
+ "type": "object",
335
+ "properties": {},
336
+ "required": [],
337
+ }
338
+
339
+ # Add parameter properties
340
+ for param in contracts.get("parameters", []):
341
+ param_name = param["name"]
342
+ param_type = param.get("type", "Any")
343
+ schema["properties"][param_name] = self._type_to_json_schema(param_type)
344
+
345
+ if param.get("required", True):
346
+ schema["required"].append(param_name)
347
+
348
+ return schema
349
+
350
+ @beartype
351
+ @ensure(lambda result: isinstance(result, dict), "Must return dict")
352
+ def _type_to_json_schema(self, type_str: str) -> dict[str, Any]:
353
+ """Convert Python type string to JSON Schema type."""
354
+ type_str = type_str.strip()
355
+
356
+ # Basic types
357
+ if type_str == "str":
358
+ return {"type": "string"}
359
+ if type_str == "int":
360
+ return {"type": "integer"}
361
+ if type_str == "float":
362
+ return {"type": "number"}
363
+ if type_str == "bool":
364
+ return {"type": "boolean"}
365
+ if type_str == "None" or type_str == "NoneType":
366
+ return {"type": "null"}
367
+
368
+ # Optional types
369
+ if type_str.startswith("Optional[") or (type_str.startswith("Union[") and "None" in type_str):
370
+ inner_type = type_str.split("[")[1].rstrip("]").split(",")[0].strip()
371
+ if "None" in inner_type:
372
+ inner_type = next(
373
+ (t.strip() for t in type_str.split("[")[1].rstrip("]").split(",") if "None" not in t),
374
+ inner_type,
375
+ )
376
+ return {"anyOf": [self._type_to_json_schema(inner_type), {"type": "null"}]}
377
+
378
+ # List types
379
+ if type_str.startswith(("list[", "List[")):
380
+ inner_type = type_str.split("[")[1].rstrip("]")
381
+ return {"type": "array", "items": self._type_to_json_schema(inner_type)}
382
+
383
+ # Dict types
384
+ if type_str.startswith(("dict[", "Dict[")):
385
+ parts = type_str.split("[")[1].rstrip("]").split(",")
386
+ if len(parts) >= 2:
387
+ value_type = parts[1].strip()
388
+ return {"type": "object", "additionalProperties": self._type_to_json_schema(value_type)}
389
+
390
+ # Default: any type
391
+ return {"type": "object"}
392
+
393
+ @beartype
394
+ @require(lambda contracts: isinstance(contracts, dict), "Contracts must be dict")
395
+ @ensure(lambda result: isinstance(result, str), "Must return string")
396
+ def generate_icontract_decorator(self, contracts: dict[str, Any], function_name: str) -> str:
397
+ """
398
+ Generate icontract decorator code from contracts.
399
+
400
+ Args:
401
+ contracts: Contract dictionary from extract_function_contracts()
402
+ function_name: Name of the function
403
+
404
+ Returns:
405
+ Python code string with icontract decorators
406
+ """
407
+ decorators: list[str] = []
408
+
409
+ # Generate @require decorators from preconditions
410
+ for precondition in contracts.get("preconditions", []):
411
+ condition = precondition.replace("Requires: ", "")
412
+ decorators.append(f'@require(lambda: {condition}, "{precondition}")')
413
+
414
+ # Generate @ensure decorators from postconditions
415
+ for postcondition in contracts.get("postconditions", []):
416
+ condition = postcondition.replace("Ensures: ", "")
417
+ decorators.append(f'@ensure(lambda result: {condition}, "{postcondition}")')
418
+
419
+ return "\n".join(decorators) if decorators else ""