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,19 @@
1
+ """
2
+ SpecFact CLI validators.
3
+
4
+ This package contains validation logic for schemas, contracts,
5
+ protocols, and plans.
6
+ """
7
+
8
+ from specfact_cli.validators.fsm import FSMValidator
9
+ from specfact_cli.validators.repro_checker import ReproChecker, ReproReport
10
+ from specfact_cli.validators.schema import SchemaValidator, validate_plan_bundle, validate_protocol
11
+
12
+ __all__ = [
13
+ "FSMValidator",
14
+ "ReproChecker",
15
+ "ReproReport",
16
+ "SchemaValidator",
17
+ "validate_plan_bundle",
18
+ "validate_protocol",
19
+ ]
@@ -0,0 +1,260 @@
1
+ """
2
+ FSM (Finite State Machine) validation module.
3
+
4
+ This module provides validators for state machine protocols and transitions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import networkx as nx
12
+ from beartype import beartype
13
+ from icontract import ensure, require
14
+
15
+ from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport
16
+ from specfact_cli.models.protocol import Protocol
17
+ from specfact_cli.utils.yaml_utils import load_yaml
18
+
19
+
20
+ class FSMValidator:
21
+ """FSM validator for protocol validation."""
22
+
23
+ @beartype
24
+ @require(
25
+ lambda protocol, protocol_path: protocol is not None or protocol_path is not None,
26
+ "Either protocol or protocol_path must be provided",
27
+ )
28
+ @require(
29
+ lambda protocol_path: protocol_path is None or protocol_path.exists(), "Protocol path must exist if provided"
30
+ )
31
+ def __init__(
32
+ self,
33
+ protocol: Protocol | None = None,
34
+ protocol_path: Path | None = None,
35
+ guard_functions: dict | None = None,
36
+ ) -> None:
37
+ """
38
+ Initialize FSM validator.
39
+
40
+ Args:
41
+ protocol: Protocol model to validate
42
+ protocol_path: Path to protocol YAML file (must exist if provided)
43
+ guard_functions: Optional dict of guard function implementations
44
+
45
+ Raises:
46
+ ValueError: If neither protocol nor protocol_path is provided
47
+ """
48
+
49
+ if protocol is None:
50
+ # Load protocol from file
51
+ data = load_yaml(protocol_path) # type: ignore
52
+ self.protocol = Protocol(**data)
53
+ else:
54
+ self.protocol = protocol
55
+
56
+ self.guard_functions = guard_functions if guard_functions is not None else {}
57
+ self.graph = self._build_graph()
58
+
59
+ def _build_graph(self) -> nx.DiGraph:
60
+ """
61
+ Build directed graph from protocol transitions.
62
+
63
+ Returns:
64
+ NetworkX directed graph
65
+ """
66
+ graph = nx.DiGraph()
67
+
68
+ # Add all states as nodes
69
+ for state in self.protocol.states:
70
+ graph.add_node(state)
71
+
72
+ # Add transitions as edges
73
+ for transition in self.protocol.transitions:
74
+ graph.add_edge(
75
+ transition.from_state,
76
+ transition.to_state,
77
+ event=transition.on_event,
78
+ guard=transition.guard,
79
+ )
80
+
81
+ return graph
82
+
83
+ @beartype
84
+ @ensure(lambda result: isinstance(result, ValidationReport), "Must return ValidationReport")
85
+ def validate(self) -> ValidationReport:
86
+ """
87
+ Validate the FSM protocol.
88
+
89
+ Returns:
90
+ Validation report with any deviations found
91
+ """
92
+ report = ValidationReport()
93
+
94
+ # Check 1: Start state exists
95
+ if self.protocol.start not in self.protocol.states:
96
+ report.add_deviation(
97
+ Deviation(
98
+ type=DeviationType.FSM_MISMATCH,
99
+ severity=DeviationSeverity.HIGH,
100
+ description=f"Start state '{self.protocol.start}' not in states list",
101
+ location="protocol.start",
102
+ fix_hint=f"Add '{self.protocol.start}' to states list",
103
+ )
104
+ )
105
+
106
+ # Check 2: All transition states exist
107
+ for transition in self.protocol.transitions:
108
+ if transition.from_state not in self.protocol.states:
109
+ report.add_deviation(
110
+ Deviation(
111
+ type=DeviationType.FSM_MISMATCH,
112
+ severity=DeviationSeverity.HIGH,
113
+ description=f"Transition from unknown state: '{transition.from_state}'",
114
+ location=f"transition[{transition.from_state} → {transition.to_state}]",
115
+ fix_hint=f"Add '{transition.from_state}' to states list",
116
+ )
117
+ )
118
+
119
+ if transition.to_state not in self.protocol.states:
120
+ report.add_deviation(
121
+ Deviation(
122
+ type=DeviationType.FSM_MISMATCH,
123
+ severity=DeviationSeverity.HIGH,
124
+ description=f"Transition to unknown state: '{transition.to_state}'",
125
+ location=f"transition[{transition.from_state} → {transition.to_state}]",
126
+ fix_hint=f"Add '{transition.to_state}' to states list",
127
+ )
128
+ )
129
+
130
+ # Check 3: Reachability - all states reachable from start
131
+ if report.passed: # Only if no critical errors so far
132
+ reachable = nx.descendants(self.graph, self.protocol.start)
133
+ reachable.add(self.protocol.start)
134
+
135
+ unreachable = set(self.protocol.states) - reachable
136
+ if unreachable:
137
+ for state in unreachable:
138
+ report.add_deviation(
139
+ Deviation(
140
+ type=DeviationType.FSM_MISMATCH,
141
+ severity=DeviationSeverity.MEDIUM,
142
+ description=f"State '{state}' is not reachable from start state",
143
+ location=f"state[{state}]",
144
+ fix_hint=f"Add transition path from '{self.protocol.start}' to '{state}'",
145
+ )
146
+ )
147
+
148
+ # Check 4: Guards are defined
149
+ for transition in self.protocol.transitions:
150
+ if transition.guard:
151
+ # Check protocol guards first
152
+ if transition.guard not in self.protocol.guards and transition.guard not in self.guard_functions:
153
+ # LOW severity if guard functions can be provided externally
154
+ report.add_deviation(
155
+ Deviation(
156
+ type=DeviationType.FSM_MISMATCH,
157
+ severity=DeviationSeverity.LOW,
158
+ description=f"Guard '{transition.guard}' not defined in protocol or guard_functions",
159
+ location=f"transition[{transition.from_state} → {transition.to_state}]",
160
+ fix_hint=f"Add guard definition for '{transition.guard}' in protocol.guards or pass guard_functions",
161
+ )
162
+ )
163
+
164
+ # Check 5: Detect cycles (informational)
165
+ try:
166
+ cycles = list(nx.simple_cycles(self.graph))
167
+ if cycles:
168
+ for cycle in cycles:
169
+ report.add_deviation(
170
+ Deviation(
171
+ type=DeviationType.FSM_MISMATCH,
172
+ severity=DeviationSeverity.LOW,
173
+ description=f"Cycle detected: {' → '.join(cycle)}",
174
+ location="protocol.transitions",
175
+ fix_hint="Cycles may be intentional for workflows, verify this is expected",
176
+ )
177
+ )
178
+ except nx.NetworkXNoCycle:
179
+ pass # No cycles is fine
180
+
181
+ return report
182
+
183
+ @beartype
184
+ @require(lambda from_state: isinstance(from_state, str) and len(from_state) > 0, "State must be non-empty string")
185
+ @ensure(lambda result: isinstance(result, set), "Must return set")
186
+ @ensure(lambda result: all(isinstance(s, str) for s in result), "All items must be strings")
187
+ def get_reachable_states(self, from_state: str) -> set[str]:
188
+ """
189
+ Get all states reachable from given state.
190
+
191
+ Args:
192
+ from_state: Starting state
193
+
194
+ Returns:
195
+ Set of reachable state names
196
+ """
197
+ if from_state not in self.protocol.states:
198
+ return set()
199
+
200
+ reachable = nx.descendants(self.graph, from_state)
201
+ reachable.add(from_state)
202
+ return reachable
203
+
204
+ @beartype
205
+ @require(lambda state: isinstance(state, str) and len(state) > 0, "State must be non-empty string")
206
+ @ensure(lambda result: isinstance(result, list), "Must return list")
207
+ @ensure(lambda result: all(isinstance(t, dict) for t in result), "All items must be dictionaries")
208
+ def get_transitions_from(self, state: str) -> list[dict]:
209
+ """
210
+ Get all transitions from given state.
211
+
212
+ Args:
213
+ state: State name
214
+
215
+ Returns:
216
+ List of transition dictionaries
217
+ """
218
+ if state not in self.protocol.states:
219
+ return []
220
+
221
+ transitions = []
222
+ for successor in self.graph.successors(state):
223
+ edge_data = self.graph.get_edge_data(state, successor)
224
+ transitions.append(
225
+ {
226
+ "from_state": state,
227
+ "to_state": successor,
228
+ "event": edge_data.get("event"),
229
+ "guard": edge_data.get("guard"),
230
+ }
231
+ )
232
+
233
+ return transitions
234
+
235
+ @beartype
236
+ @require(
237
+ lambda from_state: isinstance(from_state, str) and len(from_state) > 0, "From state must be non-empty string"
238
+ )
239
+ @require(lambda on_event: isinstance(on_event, str) and len(on_event) > 0, "Event must be non-empty string")
240
+ @require(lambda to_state: isinstance(to_state, str) and len(to_state) > 0, "To state must be non-empty string")
241
+ @ensure(lambda result: isinstance(result, bool), "Must return boolean")
242
+ def is_valid_transition(self, from_state: str, on_event: str, to_state: str) -> bool:
243
+ """
244
+ Check if transition is valid.
245
+
246
+ Args:
247
+ from_state: Source state
248
+ on_event: Event that triggers the transition
249
+ to_state: Target state
250
+
251
+ Returns:
252
+ True if transition exists with the given event
253
+ """
254
+ # Check if edge exists
255
+ if not self.graph.has_edge(from_state, to_state):
256
+ return False
257
+
258
+ # Check if the event matches
259
+ edge_data = self.graph.get_edge_data(from_state, to_state)
260
+ return edge_data.get("event") == on_event
@@ -0,0 +1,320 @@
1
+ """
2
+ Reproducibility checker - Runs various validation tools and aggregates results.
3
+
4
+ This module provides functionality to run linting, type checking, contract
5
+ exploration, and test suites with time budgets and result aggregation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from beartype import beartype
19
+ from icontract import ensure, require
20
+ from rich.console import Console
21
+
22
+ console = Console()
23
+
24
+
25
+ class CheckStatus(Enum):
26
+ """Status of a validation check."""
27
+
28
+ PENDING = "pending"
29
+ RUNNING = "running"
30
+ PASSED = "passed"
31
+ FAILED = "failed"
32
+ TIMEOUT = "timeout"
33
+ SKIPPED = "skipped"
34
+
35
+
36
+ @dataclass
37
+ class CheckResult:
38
+ """Result of a single validation check."""
39
+
40
+ name: str
41
+ tool: str
42
+ status: CheckStatus
43
+ duration: float | None = None
44
+ exit_code: int | None = None
45
+ output: str = ""
46
+ error: str = ""
47
+ timeout: bool = False
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ """Convert result to dictionary."""
51
+ return {
52
+ "name": self.name,
53
+ "tool": self.tool,
54
+ "status": self.status.value,
55
+ "duration": self.duration,
56
+ "exit_code": self.exit_code,
57
+ "timeout": self.timeout,
58
+ "output_length": len(self.output),
59
+ "error_length": len(self.error),
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class ReproReport:
65
+ """Aggregated report of all validation checks."""
66
+
67
+ checks: list[CheckResult] = field(default_factory=list)
68
+ total_duration: float = 0.0
69
+ total_checks: int = 0
70
+ passed_checks: int = 0
71
+ failed_checks: int = 0
72
+ timeout_checks: int = 0
73
+ skipped_checks: int = 0
74
+ budget_exceeded: bool = False
75
+
76
+ @beartype
77
+ @require(lambda result: isinstance(result, CheckResult), "Must be CheckResult instance")
78
+ def add_check(self, result: CheckResult) -> None:
79
+ """Add a check result to the report."""
80
+ self.checks.append(result)
81
+ self.total_checks += 1
82
+
83
+ if result.duration:
84
+ self.total_duration += result.duration
85
+
86
+ if result.status == CheckStatus.PASSED:
87
+ self.passed_checks += 1
88
+ elif result.status == CheckStatus.FAILED:
89
+ self.failed_checks += 1
90
+ elif result.status == CheckStatus.TIMEOUT:
91
+ self.timeout_checks += 1
92
+ elif result.status == CheckStatus.SKIPPED:
93
+ self.skipped_checks += 1
94
+
95
+ @beartype
96
+ @ensure(lambda result: result in (0, 1, 2), "Exit code must be 0, 1, or 2")
97
+ def get_exit_code(self) -> int:
98
+ """
99
+ Get exit code for the repro command.
100
+
101
+ Returns:
102
+ 0 = all passed, 1 = some failed, 2 = budget exceeded
103
+ """
104
+ if self.budget_exceeded or self.timeout_checks > 0:
105
+ return 2
106
+ if self.failed_checks > 0:
107
+ return 1
108
+ return 0
109
+
110
+ def to_dict(self) -> dict[str, Any]:
111
+ """Convert report to dictionary."""
112
+ return {
113
+ "total_duration": self.total_duration,
114
+ "total_checks": self.total_checks,
115
+ "passed_checks": self.passed_checks,
116
+ "failed_checks": self.failed_checks,
117
+ "timeout_checks": self.timeout_checks,
118
+ "skipped_checks": self.skipped_checks,
119
+ "budget_exceeded": self.budget_exceeded,
120
+ "checks": [check.to_dict() for check in self.checks],
121
+ }
122
+
123
+
124
+ class ReproChecker:
125
+ """
126
+ Runs validation checks with time budgets and result aggregation.
127
+
128
+ Executes various tools (ruff, semgrep, basedpyright, crosshair, pytest)
129
+ and aggregates their results into a comprehensive report.
130
+ """
131
+
132
+ @beartype
133
+ @require(lambda budget: budget > 0, "Budget must be positive")
134
+ @ensure(lambda self: self.budget > 0, "Budget must be positive after init")
135
+ def __init__(self, repo_path: Path | None = None, budget: int = 120, fail_fast: bool = False) -> None:
136
+ """
137
+ Initialize reproducibility checker.
138
+
139
+ Args:
140
+ repo_path: Path to repository (default: current directory)
141
+ budget: Total time budget in seconds (must be > 0)
142
+ fail_fast: Stop on first failure
143
+ """
144
+ self.repo_path = Path(repo_path) if repo_path else Path(".")
145
+ self.budget = budget
146
+ self.fail_fast = fail_fast
147
+ self.report = ReproReport()
148
+ self.start_time = time.time()
149
+
150
+ @beartype
151
+ @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string")
152
+ @require(lambda tool: isinstance(tool, str) and len(tool) > 0, "Tool must be non-empty string")
153
+ @require(lambda command: isinstance(command, list) and len(command) > 0, "Command must be non-empty list")
154
+ @require(lambda timeout: timeout is None or timeout > 0, "Timeout must be positive if provided")
155
+ @ensure(lambda result: isinstance(result, CheckResult), "Must return CheckResult")
156
+ @ensure(lambda result: result.duration is None or result.duration >= 0, "Duration must be non-negative")
157
+ def run_check(
158
+ self,
159
+ name: str,
160
+ tool: str,
161
+ command: list[str],
162
+ timeout: int | None = None,
163
+ skip_if_missing: bool = True,
164
+ ) -> CheckResult:
165
+ """
166
+ Run a single validation check.
167
+
168
+ Args:
169
+ name: Human-readable check name
170
+ tool: Tool name (for display)
171
+ command: Command to execute
172
+ timeout: Per-check timeout (default: budget / number of checks, must be > 0 if provided)
173
+ skip_if_missing: Skip check if tool not found
174
+
175
+ Returns:
176
+ CheckResult with status and output
177
+ """
178
+ result = CheckResult(name=name, tool=tool, status=CheckStatus.PENDING)
179
+
180
+ # Check if tool exists (cross-platform)
181
+ if skip_if_missing:
182
+ tool_path = shutil.which(command[0])
183
+ if tool_path is None:
184
+ result.status = CheckStatus.SKIPPED
185
+ result.error = f"Tool '{command[0]}' not found in PATH, skipping"
186
+ return result
187
+
188
+ # Check budget
189
+ elapsed = time.time() - self.start_time
190
+ if elapsed >= self.budget:
191
+ self.report.budget_exceeded = True
192
+ result.status = CheckStatus.TIMEOUT
193
+ result.timeout = True
194
+ result.error = f"Budget exceeded ({self.budget}s)"
195
+ return result
196
+
197
+ # Calculate timeout for this check
198
+ remaining_budget = self.budget - elapsed
199
+ check_timeout = min(timeout or (remaining_budget / 2), remaining_budget)
200
+
201
+ # Run command
202
+ result.status = CheckStatus.RUNNING
203
+ start = time.time()
204
+
205
+ try:
206
+ proc = subprocess.run(
207
+ command,
208
+ cwd=self.repo_path,
209
+ capture_output=True,
210
+ text=True,
211
+ timeout=check_timeout,
212
+ check=False,
213
+ )
214
+
215
+ result.duration = time.time() - start
216
+ result.exit_code = proc.returncode
217
+ result.output = proc.stdout
218
+ result.error = proc.stderr
219
+
220
+ if proc.returncode == 0:
221
+ result.status = CheckStatus.PASSED
222
+ else:
223
+ result.status = CheckStatus.FAILED
224
+
225
+ except subprocess.TimeoutExpired:
226
+ result.duration = time.time() - start
227
+ result.status = CheckStatus.TIMEOUT
228
+ result.timeout = True
229
+ result.error = f"Check timed out after {check_timeout}s"
230
+
231
+ except Exception as e:
232
+ result.duration = time.time() - start
233
+ result.status = CheckStatus.FAILED
234
+ result.error = str(e)
235
+
236
+ return result
237
+
238
+ @beartype
239
+ @ensure(lambda result: isinstance(result, ReproReport), "Must return ReproReport")
240
+ @ensure(lambda result: result.total_checks >= 0, "Total checks must be non-negative")
241
+ @ensure(
242
+ lambda result: result.total_checks
243
+ == result.passed_checks + result.failed_checks + result.timeout_checks + result.skipped_checks,
244
+ "Total checks must equal sum of all status types",
245
+ )
246
+ def run_all_checks(self) -> ReproReport:
247
+ """
248
+ Run all validation checks.
249
+
250
+ Returns:
251
+ ReproReport with aggregated results
252
+ """
253
+ # Check if semgrep config exists
254
+ semgrep_config = self.repo_path / "tools" / "semgrep" / "async.yml"
255
+ semgrep_enabled = semgrep_config.exists()
256
+
257
+ # Check if test directories exist
258
+ contracts_tests = self.repo_path / "tests" / "contracts"
259
+ smoke_tests = self.repo_path / "tests" / "smoke"
260
+ src_dir = self.repo_path / "src"
261
+
262
+ checks: list[tuple[str, str, list[str], int | None, bool]] = [
263
+ ("Linting (ruff)", "ruff", ["ruff", "check", "."], None, True),
264
+ ]
265
+
266
+ # Add semgrep only if config exists
267
+ if semgrep_enabled:
268
+ checks.append(
269
+ (
270
+ "Async patterns (semgrep)",
271
+ "semgrep",
272
+ ["semgrep", "--config", str(semgrep_config.relative_to(self.repo_path)), "."],
273
+ 30,
274
+ True,
275
+ )
276
+ )
277
+
278
+ checks.extend(
279
+ [
280
+ ("Type checking (basedpyright)", "basedpyright", ["basedpyright", "."], None, True),
281
+ ]
282
+ )
283
+
284
+ # Add CrossHair only if src/ exists
285
+ if src_dir.exists():
286
+ checks.append(("Contract exploration (CrossHair)", "crosshair", ["crosshair", "check", "src/"], 60, True))
287
+
288
+ # Add property tests only if directory exists
289
+ if contracts_tests.exists():
290
+ checks.append(
291
+ (
292
+ "Property tests (pytest contracts)",
293
+ "pytest",
294
+ ["pytest", "tests/contracts/", "-v"],
295
+ 30,
296
+ True,
297
+ )
298
+ )
299
+
300
+ # Add smoke tests only if directory exists
301
+ if smoke_tests.exists():
302
+ checks.append(("Smoke tests (pytest smoke)", "pytest", ["pytest", "tests/smoke/", "-v"], 30, True))
303
+
304
+ for check_args in checks:
305
+ # Check budget before starting
306
+ elapsed = time.time() - self.start_time
307
+ if elapsed >= self.budget:
308
+ self.report.budget_exceeded = True
309
+ break
310
+
311
+ # Run check
312
+ result = self.run_check(*check_args)
313
+ self.report.add_check(result)
314
+
315
+ # Fail fast if requested
316
+ if self.fail_fast and result.status == CheckStatus.FAILED:
317
+ break
318
+
319
+ self.report.total_duration = time.time() - self.start_time
320
+ return self.report