spec2test 0.1.1__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 (57) hide show
  1. spec2test/__init__.py +0 -0
  2. spec2test/adapters/__init__.py +0 -0
  3. spec2test/adapters/api/__init__.py +0 -0
  4. spec2test/adapters/api/codegen.py +197 -0
  5. spec2test/adapters/api/runner.py +249 -0
  6. spec2test/adapters/common/__init__.py +0 -0
  7. spec2test/adapters/common/classify.py +69 -0
  8. spec2test/adapters/common/process.py +209 -0
  9. spec2test/adapters/common/validate.py +266 -0
  10. spec2test/adapters/ui/__init__.py +0 -0
  11. spec2test/adapters/ui/codegen.py +345 -0
  12. spec2test/adapters/ui/runner.py +403 -0
  13. spec2test/cli.py +1187 -0
  14. spec2test/core/__init__.py +0 -0
  15. spec2test/core/config.py +131 -0
  16. spec2test/core/exceptions.py +98 -0
  17. spec2test/core/extract.py +497 -0
  18. spec2test/core/extraction_store.py +150 -0
  19. spec2test/core/gate.py +23 -0
  20. spec2test/core/generator.py +311 -0
  21. spec2test/core/ids.py +44 -0
  22. spec2test/core/llm.py +286 -0
  23. spec2test/core/logging_config.py +96 -0
  24. spec2test/core/metrics.py +132 -0
  25. spec2test/core/mutation_signal.py +52 -0
  26. spec2test/core/mutations_registry.py +35 -0
  27. spec2test/core/registry.py +53 -0
  28. spec2test/core/report.py +479 -0
  29. spec2test/core/requirements.py +130 -0
  30. spec2test/core/result_cache.py +135 -0
  31. spec2test/core/review.py +168 -0
  32. spec2test/core/schema.py +154 -0
  33. spec2test/core/sources/__init__.py +4 -0
  34. spec2test/core/sources/base.py +20 -0
  35. spec2test/core/sources/clickup.py +84 -0
  36. spec2test/core/sources/file.py +28 -0
  37. spec2test/core/sources/jira.py +133 -0
  38. spec2test/core/sources/registry.py +19 -0
  39. spec2test/core/ui_contract.py +122 -0
  40. spec2test/dashboard/__init__.py +0 -0
  41. spec2test/dashboard/main.py +598 -0
  42. spec2test/dashboard/static/htmx.min.js +1 -0
  43. spec2test/dashboard/templates/_case_row.html +19 -0
  44. spec2test/dashboard/templates/_draft_row.html +25 -0
  45. spec2test/dashboard/templates/_extraction_panel.html +39 -0
  46. spec2test/dashboard/templates/_requirement_box.html +32 -0
  47. spec2test/dashboard/templates/_review_panel.html +20 -0
  48. spec2test/dashboard/templates/_run_panel.html +49 -0
  49. spec2test/dashboard/templates/_traceability.html +21 -0
  50. spec2test/dashboard/templates/base.html +364 -0
  51. spec2test/dashboard/templates/index.html +18 -0
  52. spec2test-0.1.1.dist-info/METADATA +397 -0
  53. spec2test-0.1.1.dist-info/RECORD +57 -0
  54. spec2test-0.1.1.dist-info/WHEEL +5 -0
  55. spec2test-0.1.1.dist-info/entry_points.txt +2 -0
  56. spec2test-0.1.1.dist-info/licenses/LICENSE +201 -0
  57. spec2test-0.1.1.dist-info/top_level.txt +1 -0
spec2test/__init__.py ADDED
File without changes
File without changes
File without changes
@@ -0,0 +1,197 @@
1
+ """adapters/api/codegen.py — LLM writes pytest+httpx source for one APPROVED
2
+ TestCase -> ast.parse + assertion-presence + import-whitelist validation ->
3
+ retry <= MAX_CODEGEN_ATTEMPTS -> quarantine. See
4
+ implementation/01-architecture.md 'Codegen strategy' and 'Codegen:
5
+ code-execution trust boundary'.
6
+
7
+ case_id / requirement_id / target_type are injected as module-level
8
+ constants AFTER the model's output validates - never asked of the model,
9
+ same pattern as core/generator.py never asking the model for
10
+ id/requirement_id (02-components.md). runner.py recovers them by static
11
+ ast parsing, never by importing/exec'ing generated code outside pytest's
12
+ own subprocess.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from pydantic import BaseModel
20
+
21
+ from spec2test.adapters.common.validate import categorize_error, validate_common
22
+ from spec2test.core.config import settings
23
+ from spec2test.core.exceptions import CodegenValidationError
24
+ from spec2test.core.gate import require_approved
25
+ from spec2test.core.llm import LLMHandle
26
+ from spec2test.core.logging_config import get_logger
27
+ from spec2test.core.metrics import record_codegen_attempt
28
+ from spec2test.core.schema import TestCase
29
+
30
+ log = get_logger("codegen.api")
31
+
32
+ # Total attempts including the first draft - mirrors core/generator.py's
33
+ # MAX_REPAIRS=2 (1 initial + 2 repairs = 3 total attempts), and matches the
34
+ # M4 exit criterion "retry <=3, quarantine" read as a 3-attempt budget.
35
+ MAX_CODEGEN_ATTEMPTS = 3
36
+
37
+ # Hardening against the code-execution trust boundary (01-architecture.md
38
+ # 'Codegen: code-execution trust boundary'): anything outside this set -
39
+ # os, subprocess, socket, etc. - fails validation and is quarantined,
40
+ # never executed.
41
+ IMPORT_WHITELIST = {"pytest", "httpx"}
42
+
43
+ GENERATED_DIR = Path("artifacts/generated")
44
+ QUARANTINE_DIR = Path("artifacts/quarantine")
45
+
46
+ SYSTEM_PROMPT = """You write a single self-contained pytest test function \
47
+ that verifies one approved API test case, for a FastAPI service reachable \
48
+ at {base_url}.
49
+
50
+ Rules, all mandatory:
51
+ - Start the file with `import httpx` on its own line. Add `import pytest` \
52
+ too if you use it (e.g. pytest.raises). These are the ONLY imports allowed \
53
+ - never `os`, `json`, `re`, or anything else. httpx.Response.json() gives \
54
+ you parsed JSON without importing json. Every name you reference below \
55
+ (httpx.Client, etc.) must come from one of these two import lines - do not \
56
+ call httpx.anything without `import httpx` actually present in the file.
57
+ - Define exactly one function, named `test_<snake_case_of_the_case_id>`, \
58
+ taking no fixture arguments - create the client inside the function body: \
59
+ `with httpx.Client(base_url="{base_url}") as client:`.
60
+ - The function body must issue the HTTP calls described in the steps, in \
61
+ order (is_setup steps first), matching the exact method and path in each \
62
+ step's action, with data as the JSON request body, then assert the \
63
+ expected result. Use real `assert` statements checking status codes and \
64
+ response body fields - never a test with no assertions, never a trivially \
65
+ always-true assertion like `assert True`.
66
+ - Any identifier value you invent for a NEW entity this test creates (e.g. \
67
+ a book's isbn, a member's email) must be derived at runtime from the \
68
+ module-level constant `TESTFORGE_CASE_ID` (already defined above your \
69
+ function in this file - reference it directly, do not redefine it), never \
70
+ a fixed literal like "1234567890123" or "m@example.com". Two different \
71
+ generated tests must never be able to collide on the same entity. Example \
72
+ patterns: `isbn = str(abs(hash(TESTFORGE_CASE_ID)))[:13].ljust(13, "0")` \
73
+ for a 13-digit isbn, `email = f"member+{{TESTFORGE_CASE_ID}}@example.com"` \
74
+ for an email. Values the requirement itself pins down exactly (e.g. an \
75
+ expected due_date offset, a loan limit) are NOT invented identifiers - \
76
+ leave those as specified. If the test creates MORE THAN ONE new entity of \
77
+ the SAME kind (e.g. two distinct new books), each one's derived value must \
78
+ be distinguishable from the others' - append the step order or a short \
79
+ suffix before hashing (e.g. `hash(TESTFORGE_CASE_ID + "-2")` for the \
80
+ second one), never derive two entities of the same kind from the exact \
81
+ same expression.
82
+ - Output ONLY the Python source code. No markdown fences, no explanation, \
83
+ no comments describing what you're doing.
84
+ """
85
+
86
+
87
+ class GeneratedScript(BaseModel):
88
+ source: str
89
+
90
+
91
+ def _build_prompt(tc: TestCase) -> list[tuple[str, str]]:
92
+ steps_desc = "\n".join(
93
+ f" {s.order}. [{'setup' if s.is_setup else 'verify'}] {s.action}" + (f" data={s.data}" if s.data else "")
94
+ for s in tc.steps
95
+ )
96
+ user = (
97
+ f"case_id (for the function name, and available at runtime as the "
98
+ f"TESTFORGE_CASE_ID constant for deriving unique entity values - "
99
+ f"do not print it literally): {tc.id}\n"
100
+ f"title: {tc.title}\n"
101
+ f"requirement_excerpt: {tc.requirement_excerpt}\n"
102
+ f"preconditions: {tc.preconditions}\n"
103
+ f"steps:\n{steps_desc}\n"
104
+ f"expected_result: {tc.expected_result}\n"
105
+ )
106
+ return [("system", SYSTEM_PROMPT.format(base_url=settings.target_base_url)), ("user", user)]
107
+
108
+
109
+ def _validate(source: str) -> list[str]:
110
+ """Every check here now lives in adapters/common/validate.py, shared
111
+ with the UI adapter. Extracted in M5 Phase 3 BEFORE the UI adapter was
112
+ written, specifically so it couldn't start from a hand-copied
113
+ pre-fix version of these checks - three of them are fixes for real
114
+ bugs that had already shipped once (module-scoped assertion check,
115
+ missing-import check, dynamic-import bypass). See that module's
116
+ docstring for the full history."""
117
+ _tree, errors = validate_common(source, IMPORT_WHITELIST)
118
+ return errors
119
+
120
+
121
+ def _inject_metadata(source: str, tc: TestCase) -> str:
122
+ header = (
123
+ "# Generated by spec2test - DO NOT EDIT BY HAND\n"
124
+ f"TESTFORGE_CASE_ID = {tc.id!r}\n"
125
+ f"TESTFORGE_REQUIREMENT_ID = {tc.requirement_id!r}\n"
126
+ f"TESTFORGE_TARGET_TYPE = {tc.target_type.value!r}\n\n"
127
+ )
128
+ return header + source
129
+
130
+
131
+ def generate_script(tc: TestCase, llm_handle: LLMHandle) -> Path:
132
+ """Hybrid loop: draft -> validate -> retry <= MAX_CODEGEN_ATTEMPTS ->
133
+ quarantine. Raises GateViolationError (via require_approved) if tc isn't
134
+ APPROVED (NFR-2) - this is the first line, before any LLM call. Raises
135
+ CodegenValidationError, and writes the last attempt + its errors to
136
+ artifacts/quarantine/, if nothing validates within the attempt budget -
137
+ the quarantined file is never executed by anything downstream."""
138
+ require_approved(tc)
139
+
140
+ base_messages = _build_prompt(tc)
141
+ last_errors: list[str] = []
142
+ last_source = ""
143
+
144
+ for attempt in range(1, MAX_CODEGEN_ATTEMPTS + 1):
145
+ messages = list(base_messages)
146
+ if last_errors:
147
+ errors_text = "\n".join(f"- {e}" for e in last_errors)
148
+ messages.append(
149
+ ("user", f"Your previous attempt had these problems - fix them and resend the FULL source:\n{errors_text}")
150
+ )
151
+
152
+ result = llm_handle.invoke_structured(GeneratedScript, messages)
153
+ # Inject before validating, not after: the prompt tells the model
154
+ # TESTFORGE_CASE_ID is "already defined above your function" (true
155
+ # only once this header is prepended), so validating the raw
156
+ # pre-injection source flags a correct script as referencing an
157
+ # unbound name. _inject_metadata is a pure, deterministic prepend
158
+ # of three fixed literals - injecting first can only add names the
159
+ # validator resolves, never hide a real unresolved-reference bug.
160
+ last_source = _inject_metadata(result.source, tc)
161
+ last_errors = _validate(last_source)
162
+
163
+ log.info(
164
+ "codegen_attempt",
165
+ extra={"event": "codegen_attempt", "case_id": tc.id, "attempt": attempt, "valid": not last_errors},
166
+ )
167
+
168
+ # Distinct event per implementation/06-observability.md's event
169
+ # taxonomy ("import_whitelist_violation ... always WARNING, case
170
+ # quarantined") - separate from the generic codegen_attempt/
171
+ # quarantined events so import-safety incidents are greppable/
172
+ # alertable on their own, not buried in ordinary validation noise.
173
+ import_errors = [
174
+ e for e in last_errors if e.startswith("disallowed import:") or e.startswith("disallowed dynamic import")
175
+ ]
176
+ if import_errors:
177
+ log.warning(
178
+ "import_whitelist_violation",
179
+ extra={"event": "import_whitelist_violation", "case_id": tc.id, "attempt": attempt, "violations": import_errors},
180
+ )
181
+
182
+ if not last_errors:
183
+ GENERATED_DIR.mkdir(parents=True, exist_ok=True)
184
+ path = GENERATED_DIR / f"{tc.id}.py"
185
+ path.write_text(last_source, encoding="utf-8")
186
+ log.info("codegen_success", extra={"event": "codegen_success", "case_id": tc.id, "path": str(path)})
187
+ record_codegen_attempt(succeeded=True)
188
+ return path
189
+
190
+ QUARANTINE_DIR.mkdir(parents=True, exist_ok=True)
191
+ (QUARANTINE_DIR / f"{tc.id}.py").write_text(last_source, encoding="utf-8")
192
+ (QUARANTINE_DIR / f"{tc.id}.errors.txt").write_text("\n".join(last_errors), encoding="utf-8")
193
+ log.error("codegen_quarantined", extra={"event": "quarantined", "case_id": tc.id, "errors": last_errors})
194
+ record_codegen_attempt(succeeded=False, quarantine_reason=categorize_error(last_errors[0]) if last_errors else "other")
195
+ raise CodegenValidationError(
196
+ f"TestCase {tc.id} quarantined after {MAX_CODEGEN_ATTEMPTS} attempts, never executed: {last_errors}"
197
+ )
@@ -0,0 +1,249 @@
1
+ """adapters/api/runner.py — executes generated pytest+httpx scripts and maps
2
+ results to TestResult. Recovers case_id/requirement_id/target_type by
3
+ STATIC ast parsing of each script's injected metadata constants (see
4
+ codegen.py's _inject_metadata) - never by importing/exec'ing the file
5
+ outside of pytest's own subprocess. See implementation/02-components.md
6
+ 'adapters/api/codegen.py + runner.py'.
7
+
8
+ Found during M5 planning (B1/B3 in the deep bug sweep) - two gaps against
9
+ implementation/05-test-data-and-state-strategy.md's LOCKED design, neither
10
+ caught by the existing suite because the corpus was too small to trigger
11
+ them:
12
+ - The doc requires "POST /_test/reset ... called once before each test
13
+ file" (file-level isolation). Nothing here ever called it - the only
14
+ reset in the whole codebase was cli.py's single pre-batch call. Two
15
+ book-creating scripts in one run collided on 409 DUPLICATE_ISBN, a
16
+ false failure attributed to the wrong requirement.
17
+ - settings.test_exec_timeout_s was defined and never passed to
18
+ subprocess.run() - no execution timeout existed at all. Harmless for
19
+ httpx (rarely hangs); a real risk once adapters/ui/runner.py reuses
20
+ this pattern for Playwright, where a hung selector wait can block
21
+ forever.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ import json
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ import time
32
+ from pathlib import Path
33
+
34
+ import httpx
35
+
36
+ from spec2test.adapters.common.classify import classify_pytest_outcome
37
+ from spec2test.adapters.common.process import run_with_tree_kill_timeout
38
+ from spec2test.core.config import settings
39
+ from spec2test.core.exceptions import TestForgeError
40
+ from spec2test.core.logging_config import get_logger
41
+ from spec2test.core.metrics import record_test_result
42
+ from spec2test.core.schema import TargetType, TestResult
43
+
44
+ log = get_logger("runner.api")
45
+
46
+ _METADATA_NAMES = {"TESTFORGE_CASE_ID", "TESTFORGE_REQUIREMENT_ID", "TESTFORGE_TARGET_TYPE"}
47
+
48
+
49
+ def _read_metadata(path: Path) -> dict[str, str]:
50
+ tree = ast.parse(path.read_text(encoding="utf-8"))
51
+ meta: dict[str, str] = {}
52
+ for node in tree.body:
53
+ if not isinstance(node, ast.Assign) or len(node.targets) != 1:
54
+ continue
55
+ target = node.targets[0]
56
+ if isinstance(target, ast.Name) and target.id in _METADATA_NAMES and isinstance(node.value, ast.Constant):
57
+ meta[target.id] = node.value.value
58
+ missing = _METADATA_NAMES - meta.keys()
59
+ if missing:
60
+ raise ValueError(f"{path} is missing spec2test metadata constants: {sorted(missing)}")
61
+ return meta
62
+
63
+
64
+ def _reset_sandbox() -> None:
65
+ """B1 fix: file-level reset before each script, per the LOCKED design in
66
+ implementation/05-test-data-and-state-strategy.md ("called once before
67
+ each test file"). Previously never called here - cli.py's single
68
+ pre-batch reset was the only one in the codebase, so entity ids/state
69
+ from an earlier script leaked into every later one in the same run.
70
+
71
+ httpx errors are caught and re-raised as TestForgeError, matching the
72
+ precedent in cli.py's _wait_healthy - found during a deeper review pass
73
+ that an unwrapped httpx.ConnectError/HTTPStatusError here would escape
74
+ cli.py's TestForgeError-only exception handler as a raw traceback
75
+ instead of the one-line message the exception taxonomy promises. B1
76
+ calling this once PER SCRIPT (not once per batch) multiplies the
77
+ exposure to a transient failure here relative to before."""
78
+ try:
79
+ httpx.post(f"{settings.target_base_url}{settings.test_reset_path}", timeout=10).raise_for_status()
80
+ except httpx.HTTPError as exc:
81
+ raise TestForgeError(f"could not reset sandbox at {settings.target_base_url}: {exc}") from exc
82
+
83
+
84
+ def _execute_once(path: Path, run_id: str, meta: dict[str, str]) -> tuple[TestResult, bool]:
85
+ """Runs the script exactly once via pytest. Returns (result, retryable) -
86
+ retryable is True only for a genuine reported test failure (pytest ran
87
+ the test and it failed), never for a timeout or a collection-error/crash
88
+ (report_path never written) - retrying either of those doesn't test
89
+ "is this flaky", it just wastes the timeout budget again on something
90
+ that isn't going to change. Split out of _run_one so the flaky-retry
91
+ wrapper (gap list item 6) can call this twice without duplicating the
92
+ execution/parsing logic."""
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ report_path = Path(tmp) / "report.json"
95
+ start = time.monotonic()
96
+ try:
97
+ proc = run_with_tree_kill_timeout(
98
+ [sys.executable, "-m", "pytest", str(path), "-q", "--json-report", f"--json-report-file={report_path}"],
99
+ timeout_s=settings.test_exec_timeout_s,
100
+ )
101
+ except subprocess.TimeoutExpired:
102
+ # B3 fix: settings.test_exec_timeout_s was defined but never
103
+ # passed to subprocess.run() - no execution timeout existed at
104
+ # all. Harmless for httpx; a real risk for Playwright (a hung
105
+ # selector wait blocks forever) - see
106
+ # adapters/common/process.py's run_with_tree_kill_timeout,
107
+ # which is what actually matters for that case: bare
108
+ # subprocess.run(timeout=...) only kills the immediate pytest
109
+ # child, not a grandchild browser process it launched, and that
110
+ # gap produced real orphaned Chromium processes in M5. Reported
111
+ # as a normal FAILED result, not a crash that would abort the
112
+ # rest of the batch - same "one bad case doesn't zero out the
113
+ # run" discipline as cli.py's per-case quarantine handling.
114
+ duration_ms = int(settings.test_exec_timeout_s * 1000)
115
+ result = TestResult(
116
+ test_case_id=meta["TESTFORGE_CASE_ID"],
117
+ requirement_id=meta["TESTFORGE_REQUIREMENT_ID"],
118
+ target_type=TargetType(meta["TESTFORGE_TARGET_TYPE"]),
119
+ status="FAILED",
120
+ duration_ms=duration_ms,
121
+ failure_message=f"test exceeded {settings.test_exec_timeout_s}s execution timeout",
122
+ script_path=str(path),
123
+ run_id=run_id,
124
+ )
125
+ log.warning(
126
+ "test_execution_timeout",
127
+ extra={
128
+ "event": "test_execution_timeout",
129
+ "case_id": result.test_case_id,
130
+ "requirement_id": result.requirement_id,
131
+ "timeout_s": settings.test_exec_timeout_s,
132
+ },
133
+ )
134
+ return result, False
135
+ duration_ms = int((time.monotonic() - start) * 1000)
136
+
137
+ failure_message: str | None = None
138
+ if report_path.exists():
139
+ report = json.loads(report_path.read_text(encoding="utf-8"))
140
+ tests = report.get("tests", [])
141
+ outcome = tests[0]["outcome"] if tests else "error"
142
+ longrepr = (tests[0].get("call") or {}).get("longrepr") if tests else None
143
+ status, is_assertion_failure, recognized = classify_pytest_outcome(outcome, longrepr)
144
+ if not recognized:
145
+ log.warning(
146
+ "failure_classification_unrecognized_format",
147
+ extra={"event": "failure_classification_unrecognized_format", "longrepr_tail": (longrepr or "")[-500:]},
148
+ )
149
+ if status != "PASSED":
150
+ failure_message = longrepr
151
+ # ERROR is never retryable - a TypeError/NameError/etc. is
152
+ # deterministic based on the code, not runtime flakiness;
153
+ # retrying a broken script wastes a full timeout budget for no
154
+ # chance of a different outcome. See R-23.
155
+ retryable = bool(tests) and is_assertion_failure
156
+ else:
157
+ # pytest itself didn't run (collection error, crash before the
158
+ # report plugin could write) - surface raw output rather than
159
+ # silently reporting a pass. ERROR, not FAILED: pytest never
160
+ # even ran the test body, so this can never be a genuine
161
+ # assertion outcome. Not retryable, same reasoning as above.
162
+ status = "ERROR"
163
+ failure_message = (proc.stdout + proc.stderr)[-4000:]
164
+ retryable = False
165
+
166
+ result = TestResult(
167
+ test_case_id=meta["TESTFORGE_CASE_ID"],
168
+ requirement_id=meta["TESTFORGE_REQUIREMENT_ID"],
169
+ target_type=TargetType(meta["TESTFORGE_TARGET_TYPE"]),
170
+ status=status,
171
+ duration_ms=duration_ms,
172
+ failure_message=failure_message,
173
+ script_path=str(path),
174
+ run_id=run_id,
175
+ )
176
+ # retryable is already False whenever status != "FAILED" (classify_pytest_outcome's
177
+ # own invariant: is_assertion_failure is only ever True alongside "FAILED").
178
+ return result, retryable
179
+
180
+
181
+ def _run_one(path: Path, run_id: str) -> TestResult:
182
+ meta = _read_metadata(path)
183
+ _reset_sandbox()
184
+
185
+ result, retryable = _execute_once(path, run_id, meta)
186
+
187
+ # Gap list item 6: TestResult.status has always included "FLAKY" as a
188
+ # schema option, but nothing ever implemented the retry-and-reclassify
189
+ # step the original design intended - only a genuinely reported test
190
+ # failure (never a timeout, never a collection crash - see _execute_once)
191
+ # gets one retry. If the retry PASSES, the result is reclassified FLAKY,
192
+ # not PASSED - a test that fails once and passes once is not the same
193
+ # as a test that reliably passes, and silently reporting PASSED would
194
+ # hide real instability. If the retry ALSO fails, nothing changes: a
195
+ # mutation-caught failure is deterministic and will fail again on
196
+ # retry, staying correctly attributed as FAILED, not misclassified as
197
+ # flaky just because a retry happened.
198
+ if retryable:
199
+ first_failure_message = result.failure_message
200
+ log.info(
201
+ "test_retry_after_failure",
202
+ extra={"event": "test_retry_after_failure", "case_id": result.test_case_id, "requirement_id": result.requirement_id},
203
+ )
204
+ # Reset again before the retry - API scripts create their own
205
+ # entities (see module docstring's DUPLICATE_ISBN collision), so
206
+ # without this the retry runs against state the failed first
207
+ # attempt already mutated. That's not a re-run under the same
208
+ # conditions; a collision there would be misattributed as FLAKY or
209
+ # a second, differently-caused FAILED rather than reflecting the
210
+ # actual determinism of the test.
211
+ _reset_sandbox()
212
+ retry_result, _ = _execute_once(path, run_id, meta)
213
+ if retry_result.status == "PASSED":
214
+ result = retry_result.model_copy(
215
+ update={
216
+ "status": "FLAKY",
217
+ "failure_message": f"passed on retry; first attempt failed: {first_failure_message}",
218
+ }
219
+ )
220
+ log.warning(
221
+ "test_flaky",
222
+ extra={"event": "test_flaky", "case_id": result.test_case_id, "requirement_id": result.requirement_id},
223
+ )
224
+ else:
225
+ result = retry_result # consistently failing, not flaky - report the retry's own detail verbatim
226
+
227
+ log.info(
228
+ "test_executed",
229
+ extra={
230
+ "event": "test_executed",
231
+ "case_id": result.test_case_id,
232
+ "requirement_id": result.requirement_id,
233
+ "status": result.status,
234
+ "duration_ms": result.duration_ms,
235
+ },
236
+ )
237
+ record_test_result(result.status)
238
+ return result
239
+
240
+
241
+ def run(paths: list[Path], run_id: str) -> list[TestResult]:
242
+ """Runs each generated script in its own pytest subprocess - isolation,
243
+ so one script's collection error or crash can't take down the batch or
244
+ poison another script's process state. run_id is the caller's real CLI
245
+ session id (cli.py's own per-invocation run_id, threaded down through
246
+ core/registry.py) - not minted here anymore, so every case run within
247
+ one `report`/`mutate` invocation shares one true session id instead of
248
+ each adapter batch getting its own disconnected uuid."""
249
+ return [_run_one(path, run_id) for path in paths]
File without changes
@@ -0,0 +1,69 @@
1
+ """Shared FAILED-vs-ERROR classification for both adapters' runners — see
2
+ implementation/04-failure-analysis.md R-23.
3
+
4
+ A pytest result that isn't "passed" can mean two very different things: the
5
+ test's own assertion caught a real problem (AssertionError - a genuine
6
+ signal), or the generated script itself is broken (TypeError, NameError, a
7
+ bad Playwright locator call, etc. - not a real signal, would fail
8
+ identically against a perfectly correct app). Collapsing both into the same
9
+ "FAILED" string is exactly the class of bug this project has already found
10
+ and fixed twice before in adapters/common/validate.py's docstring (a
11
+ missing import, a bare __import__ call) - both times because a script that
12
+ errors out LOOKS like a script that correctly caught a mutation.
13
+
14
+ Extracted here, not duplicated per-adapter, for the same reason
15
+ validate.py's checks are shared: a hand-copied version in one adapter would
16
+ drift from a fix made in the other.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+
23
+ _FOOTER_PATTERN = re.compile(r":\s*([A-Za-z_][\w.]*)\s*$")
24
+
25
+
26
+ def classify_pytest_outcome(outcome: str, longrepr: str | None) -> tuple[str, bool, bool]:
27
+ """Returns (status, is_assertion_failure, recognized_format).
28
+
29
+ status is "PASSED", "FAILED", or "ERROR" - never anything else.
30
+ is_assertion_failure is True only for a genuine AssertionError, which is
31
+ what callers should use to decide retry eligibility (retrying a broken
32
+ script is pointless - it's deterministic based on the code, not runtime
33
+ flakiness). recognized_format is False only in the fail-safe path below
34
+ - callers should log a warning when it's False, since it means pytest's
35
+ output didn't look like what this function expects.
36
+
37
+ Reads the last non-blank line of pytest's default --tb=long longrepr,
38
+ which always has the stable footer "{path}:{lineno}: {ExceptionClassName}"
39
+ for a call-phase failure (verified against real pytest output for a
40
+ bare assert, a message-carrying assert, AttributeError, and a custom
41
+ exception class). Deliberately does NOT parse the JSON report's
42
+ structured `call.crash.message` field - it omits the "AssertionError:"
43
+ prefix entirely for a bare `assert False` with no custom message, which
44
+ would misclassify the single most common generated-test shape as ERROR.
45
+
46
+ Fails safe to FAILED+retryable (today's pre-fix behavior) if the footer
47
+ doesn't match the expected shape - never invents a new false-ERROR for
48
+ an unanticipated pytest output format."""
49
+ if outcome == "passed":
50
+ return "PASSED", False, True
51
+
52
+ if not longrepr:
53
+ return "ERROR", False, True
54
+
55
+ last_line = ""
56
+ for line in reversed(longrepr.splitlines()):
57
+ if line.strip():
58
+ last_line = line.strip()
59
+ break
60
+
61
+ match = _FOOTER_PATTERN.search(last_line)
62
+ if not match:
63
+ return "FAILED", True, False # unrecognized format - fail safe to today's behavior
64
+
65
+ exception_name = match.group(1).rsplit(".", 1)[-1]
66
+ if exception_name == "AssertionError":
67
+ return "FAILED", True, True
68
+
69
+ return "ERROR", False, True