jitcatch 0.2.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.
jitcatch/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """JiTCatch: Just-in-Time catching test generation (after Becker et al., FSE '26)."""
2
+ __version__ = "0.2.0"
jitcatch/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from .base import Adapter, TestPlacement
7
+ from .golang import GoAdapter
8
+ from .java import JavaAdapter
9
+ from .javascript import TypeScriptAdapter
10
+ from .python import PythonAdapter
11
+
12
+ ADAPTER_CLASSES: dict[str, type[Adapter]] = {
13
+ "python": PythonAdapter,
14
+ "typescript": TypeScriptAdapter,
15
+ "java": JavaAdapter,
16
+ "go": GoAdapter,
17
+ }
18
+
19
+
20
+ def build_adapters(settings: dict[str, Any], repo: Path) -> list[Adapter]:
21
+ out = []
22
+ for name, cls in ADAPTER_CLASSES.items():
23
+ s = settings.get(name, {})
24
+ if s.get("enabled", True):
25
+ out.append(cls(s, repo))
26
+ return out
27
+
28
+
29
+ def adapter_for(adapters: list[Adapter], rel_path: str) -> Adapter | None:
30
+ for a in adapters:
31
+ if a.handles(rel_path):
32
+ return a
33
+ return None
34
+
35
+
36
+ __all__ = ["Adapter", "TestPlacement", "build_adapters", "adapter_for", "ADAPTER_CLASSES"]
@@ -0,0 +1,137 @@
1
+ """Language adapter interface: where generated tests go, how they run, and how outcomes are classified."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ..models import Outcome, TestRun
13
+
14
+ SKIP_DIRS = {".git", ".jitcatch", "node_modules", ".venv", "venv", "target", "build", "dist", "__pycache__", "vendor"}
15
+
16
+
17
+ @dataclass
18
+ class TestPlacement:
19
+ __test__ = False
20
+ rel_path: str # repo-relative path of the generated test file
21
+ instructions: str # language/framework-specific constraints for the LLM
22
+ test_name_prefix: str = "" # e.g. Go test function prefix used to select the test
23
+
24
+
25
+ class Adapter:
26
+ name: str = "base"
27
+ fence_langs: tuple[str, ...] = ()
28
+ source_exts: tuple[str, ...] = ()
29
+
30
+ def __init__(self, settings: dict[str, Any], repo: Path):
31
+ self.settings = settings
32
+ self.repo = repo
33
+
34
+ # -- classification ---------------------------------------------------------------
35
+ def handles(self, rel_path: str) -> bool:
36
+ return rel_path.endswith(self.source_exts) and not self.is_test_file(rel_path)
37
+
38
+ def is_test_file(self, rel_path: str) -> bool:
39
+ raise NotImplementedError
40
+
41
+ # -- placement & prompting --------------------------------------------------------
42
+ def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
43
+ raise NotImplementedError
44
+
45
+ def related_test(self, source_rel: str) -> str | None:
46
+ """Repo-relative path of an existing test that most likely tests `source_rel`."""
47
+ stem = Path(source_rel).stem.lower()
48
+ best: tuple[int, str] | None = None
49
+ src_dir = Path(source_rel).parent
50
+ for dirpath, dirnames, filenames in os.walk(self.repo):
51
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
52
+ for fn in filenames:
53
+ rel = str(Path(dirpath, fn).relative_to(self.repo))
54
+ if not self.is_test_file(rel) or "jitcatch" in fn.lower():
55
+ continue
56
+ name = fn.lower()
57
+ if stem and stem in name:
58
+ # prefer tests closest to the source file
59
+ distance = len(set(Path(rel).parent.parts) ^ set(src_dir.parts))
60
+ score = distance
61
+ if best is None or score < best[0]:
62
+ best = (score, rel)
63
+ return best[1] if best else None
64
+
65
+ def any_test_example(self) -> str | None:
66
+ for dirpath, dirnames, filenames in os.walk(self.repo):
67
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
68
+ for fn in sorted(filenames):
69
+ rel = str(Path(dirpath, fn).relative_to(self.repo))
70
+ if self.is_test_file(rel) and "jitcatch" not in fn.lower():
71
+ return rel
72
+ return None
73
+
74
+ # -- execution --------------------------------------------------------------------
75
+ def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
76
+ raise NotImplementedError
77
+
78
+ def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
79
+ """Run the project's existing tests relevant to `source_rel`. None = unsupported."""
80
+ return None
81
+
82
+ def available(self) -> tuple[bool, str]:
83
+ """Is the toolchain usable? Returns (ok, reason)."""
84
+ return True, ""
85
+
86
+ def can_test(self, rel_path: str) -> tuple[bool, str]:
87
+ """Can this particular file be tested (e.g. its package has a supported runner)? Returns (ok, reason)."""
88
+ return True, ""
89
+
90
+
91
+ # -- helpers ------------------------------------------------------------------------------
92
+
93
+ def run_cmd(cmd: list[str], cwd: Path, timeout: float, env_extra: dict[str, str] | None = None) -> tuple[int, str, float, bool]:
94
+ """Run a command; returns (exit_code, combined_output, seconds, timed_out)."""
95
+ env = dict(os.environ)
96
+ env.update({"CI": "1", "NO_COLOR": "1", "FORCE_COLOR": "0", "PYTHONDONTWRITEBYTECODE": "1"})
97
+ if env_extra:
98
+ env.update(env_extra)
99
+ t0 = time.monotonic()
100
+ try:
101
+ proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8",
102
+ errors="replace", timeout=timeout, env=env)
103
+ out = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "")
104
+ return proc.returncode, strip_ansi(out), time.monotonic() - t0, False
105
+ except subprocess.TimeoutExpired as e:
106
+ out = (e.stdout or b"")
107
+ out = out.decode("utf-8", "replace") if isinstance(out, bytes) else out
108
+ return -1, strip_ansi(out) + f"\n[jitcatch] TIMEOUT after {timeout}s", time.monotonic() - t0, True
109
+ except FileNotFoundError as e:
110
+ return -2, f"[jitcatch] command not found: {e}", time.monotonic() - t0, False
111
+
112
+
113
+ _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
114
+
115
+
116
+ def strip_ansi(s: str) -> str:
117
+ return _ANSI.sub("", s)
118
+
119
+
120
+ def make_run(outcome: Outcome, code: int, out: str, secs: float, cmd: list[str]) -> TestRun:
121
+ return TestRun(outcome=outcome, exit_code=code, output=truncate_output(out), duration_s=round(secs, 2), command=cmd)
122
+
123
+
124
+ def truncate_output(out: str, head: int = 3000, tail: int = 12000) -> str:
125
+ if len(out) <= head + tail:
126
+ return out
127
+ return out[:head] + f"\n... [{len(out) - head - tail} chars truncated] ...\n" + out[-tail:]
128
+
129
+
130
+ def nearest_dir_with(start: Path, stop: Path, names: tuple[str, ...]) -> Path | None:
131
+ d = start
132
+ while True:
133
+ if any((d / n).exists() for n in names):
134
+ return d
135
+ if d == stop or d.parent == d:
136
+ return None
137
+ d = d.parent
@@ -0,0 +1,88 @@
1
+ """Go adapter (go test)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from ..models import Outcome, TestRun
9
+ from .base import Adapter, TestPlacement, make_run, nearest_dir_with, run_cmd
10
+
11
+ _PKG_RE = re.compile(r"^\s*package\s+(\w+)", re.M)
12
+
13
+
14
+ class GoAdapter(Adapter):
15
+ name = "go"
16
+ fence_langs = ("go", "golang")
17
+ source_exts = (".go",)
18
+
19
+ def is_test_file(self, rel_path: str) -> bool:
20
+ return rel_path.endswith("_test.go")
21
+
22
+ def handles(self, rel_path: str) -> bool:
23
+ return super().handles(rel_path) and "/vendor/" not in f"/{rel_path}" and "/testdata/" not in f"/{rel_path}"
24
+
25
+ def related_test(self, source_rel: str) -> str | None:
26
+ p = PurePosixPath(source_rel)
27
+ cand = p.with_name(p.stem + "_test.go")
28
+ if (self.repo / cand).exists():
29
+ return str(cand)
30
+ for f in sorted((self.repo / p.parent).glob("*_test.go")):
31
+ if "jitcatch" not in f.name:
32
+ return str(f.relative_to(self.repo))
33
+ return None
34
+
35
+ def available(self) -> tuple[bool, str]:
36
+ code, _, _, _ = run_cmd(["go", "version"], self.repo, 60)
37
+ return (code == 0, "" if code == 0 else "go not available")
38
+
39
+ def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
40
+ p = PurePosixPath(source_rel)
41
+ m = _PKG_RE.search(source_text)
42
+ pkg = m.group(1) if m else "main"
43
+ rel = str(p.parent / f"{p.stem}_jitcatch_{test_id}_test.go")
44
+ prefix = f"TestJitcatch{test_id.upper()}"
45
+ instr = (
46
+ f"Framework: Go's standard `testing` package. The file must start with `package {pkg}` (same package, "
47
+ f"so unexported identifiers are accessible) and every test function name must start with `{prefix}` "
48
+ f"(e.g. `func {prefix}Something(t *testing.T)`). Only import the standard library and modules already "
49
+ "in go.mod. Do not use network, sleeps, randomness or time.Now; avoid reflect."
50
+ )
51
+ return TestPlacement(rel_path=rel, instructions=instr, test_name_prefix=prefix)
52
+
53
+ def _module(self, base: Path, rel: str) -> Path:
54
+ return nearest_dir_with((base / rel).parent, base, ("go.mod",)) or base
55
+
56
+ def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
57
+ mod = self._module(worktree, test_rel)
58
+ pkg_dir = os.path.relpath(worktree / PurePosixPath(test_rel).parent, mod)
59
+ target = "./" + pkg_dir.replace(os.sep, "/") if pkg_dir != "." else "."
60
+ cmd = ["go", "test", target, "-run", f"^{placement.test_name_prefix}", "-count=1", "-v",
61
+ *self.settings.get("extra_args", [])]
62
+ code, out, secs, timed_out = run_cmd(cmd, mod, timeout)
63
+ return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
64
+
65
+ def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
66
+ mod = self._module(worktree, source_rel)
67
+ pkg_dir = os.path.relpath(worktree / PurePosixPath(source_rel).parent, mod)
68
+ target = "./" + pkg_dir.replace(os.sep, "/") if pkg_dir != "." else "."
69
+ cmd = ["go", "test", target, "-count=1"]
70
+ code, out, secs, timed_out = run_cmd(cmd, mod, timeout)
71
+ outcome = Outcome.PASS if code == 0 else self._classify(code, out, timed_out)
72
+ return make_run(outcome, code, out, secs, cmd)
73
+
74
+ @staticmethod
75
+ def _classify(code: int, out: str, timed_out: bool) -> Outcome:
76
+ if timed_out or code == -2:
77
+ return Outcome.INFRA
78
+ if re.search(r"\[(build|setup) failed\]|^# \S+|cannot find package|no required module provides", out, re.M):
79
+ return Outcome.ERROR
80
+ if code == 0:
81
+ if "no tests to run" in out or "[no test files]" in out:
82
+ return Outcome.NO_TESTS
83
+ return Outcome.PASS
84
+ if re.search(r"^--- FAIL|^panic:|^FAIL\s", out, re.M):
85
+ return Outcome.FAIL
86
+ if re.search(r"go: (downloading|error loading)|dial tcp|proxy\.golang\.org", out):
87
+ return Outcome.INFRA
88
+ return Outcome.ERROR
@@ -0,0 +1,136 @@
1
+ """Java adapter for JUnit 4/5 via Maven or Gradle."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from ..models import Outcome, TestRun
9
+ from .base import Adapter, TestPlacement, make_run, nearest_dir_with, run_cmd
10
+
11
+ _PKG_RE = re.compile(r"^\s*package\s+([\w.]+)\s*;", re.M)
12
+ _BUILD_FILES = ("pom.xml", "build.gradle", "build.gradle.kts")
13
+
14
+
15
+ class JavaAdapter(Adapter):
16
+ name = "java"
17
+ fence_langs = ("java",)
18
+ source_exts = (".java",)
19
+
20
+ def is_test_file(self, rel_path: str) -> bool:
21
+ p = PurePosixPath(rel_path)
22
+ return "test" in p.parts or bool(re.search(r"(Test|Tests|IT)\.java$", p.name))
23
+
24
+ def handles(self, rel_path: str) -> bool:
25
+ return super().handles(rel_path) and "/src/main/java/" in f"/{rel_path}"
26
+
27
+ def module_dir(self, base: Path, rel: str) -> Path:
28
+ return nearest_dir_with((base / rel).parent, base, _BUILD_FILES) or base
29
+
30
+ def build_tool(self, module: Path) -> str:
31
+ forced = self.settings.get("build", "auto")
32
+ if forced in ("maven", "gradle"):
33
+ return forced
34
+ if (module / "pom.xml").exists():
35
+ return "maven"
36
+ if (module / "build.gradle").exists() or (module / "build.gradle.kts").exists():
37
+ return "gradle"
38
+ return "maven"
39
+
40
+ def junit_version(self, module: Path) -> int:
41
+ text = ""
42
+ for n in _BUILD_FILES:
43
+ p = module / n
44
+ if p.exists():
45
+ text += p.read_text(encoding="utf-8", errors="replace")
46
+ if module != self.repo:
47
+ for n in _BUILD_FILES:
48
+ p = self.repo / n
49
+ if p.exists():
50
+ text += p.read_text(encoding="utf-8", errors="replace")
51
+ if re.search(r"junit-jupiter|org\.junit\.jupiter|useJUnitPlatform", text):
52
+ return 5
53
+ if re.search(r"junit:junit|<artifactId>junit</artifactId>", text):
54
+ return 4
55
+ return 5
56
+
57
+ def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
58
+ src = PurePosixPath(source_rel)
59
+ m = _PKG_RE.search(source_text)
60
+ pkg = m.group(1) if m else ""
61
+ module_rel = PurePosixPath(os.path.relpath(self.module_dir(self.repo, source_rel), self.repo))
62
+ if str(module_rel) == ".":
63
+ module_rel = PurePosixPath("")
64
+ cls = f"{src.stem}JitcatchTest{test_id.upper()}"
65
+ rel = str(module_rel / "src/test/java" / PurePosixPath(*pkg.split(".")) / f"{cls}.java") if pkg else \
66
+ str(module_rel / "src/test/java" / f"{cls}.java")
67
+ junit = self.junit_version(self.module_dir(self.repo, source_rel))
68
+ fw = ("JUnit 5 (org.junit.jupiter.api.Test, org.junit.jupiter.api.Assertions.*)" if junit == 5
69
+ else "JUnit 4 (org.junit.Test, org.junit.Assert.*)")
70
+ instr = (
71
+ f"Framework: {fw}. The file must declare `package {pkg};` and a single public class named exactly "
72
+ f"`{cls}`. Only use libraries already on the test classpath (see the example test). Do not use "
73
+ "reflection, network, sleeps, randomness or the current time; test through the public API only."
74
+ )
75
+ return TestPlacement(rel_path=rel, instructions=instr, test_name_prefix=(f"{pkg}.{cls}" if pkg else cls))
76
+
77
+ def _wrapper(self, module: Path, tool: str) -> list[str]:
78
+ if tool == "maven":
79
+ for d in (module, self.repo):
80
+ if (d / "mvnw").exists():
81
+ return [str(d / "mvnw")]
82
+ return ["mvn"]
83
+ for d in (module, self.repo):
84
+ if (d / "gradlew").exists():
85
+ return [str(d / "gradlew")]
86
+ return ["gradle"]
87
+
88
+ def available(self) -> tuple[bool, str]:
89
+ code, _, _, _ = run_cmd(["java", "-version"], self.repo, 60)
90
+ return (code == 0, "" if code == 0 else "java not available")
91
+
92
+ def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
93
+ module = self.module_dir(worktree, test_rel)
94
+ tool = self.build_tool(module)
95
+ exe = self._wrapper(module, tool)
96
+ fqcn = placement.test_name_prefix
97
+ if tool == "maven":
98
+ cmd = [*exe, "-B", "--no-transfer-progress", "test", f"-Dtest={fqcn}",
99
+ "-Dsurefire.failIfNoSpecifiedTests=false", "-DfailIfNoTests=false", "-Djacoco.skip=true",
100
+ *self.settings.get("extra_args", [])]
101
+ else:
102
+ cmd = [*exe, "test", "--tests", fqcn, "--console=plain", "--no-daemon", *self.settings.get("extra_args", [])]
103
+ code, out, secs, timed_out = run_cmd(cmd, module, timeout)
104
+ return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
105
+
106
+ def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
107
+ module = self.module_dir(worktree, source_rel)
108
+ tool = self.build_tool(module)
109
+ exe = self._wrapper(module, tool)
110
+ if tool == "maven":
111
+ cmd = [*exe, "-B", "--no-transfer-progress", "test", "-Djacoco.skip=true"]
112
+ else:
113
+ cmd = [*exe, "test", "--console=plain", "--no-daemon"]
114
+ code, out, secs, timed_out = run_cmd(cmd, module, timeout)
115
+ outcome = Outcome.PASS if code == 0 else self._classify(code, out, timed_out)
116
+ return make_run(outcome, code, out, secs, cmd)
117
+
118
+ @staticmethod
119
+ def _classify(code: int, out: str, timed_out: bool) -> Outcome:
120
+ if timed_out or code == -2:
121
+ return Outcome.INFRA
122
+ if re.search(r"Could not resolve dependencies|Could not transfer artifact|Could not GET|"
123
+ r"Could not resolve all (files|dependencies)|Unable to access jarfile", out):
124
+ return Outcome.INFRA
125
+ if re.search(r"No tests found for given includes|No tests were executed|Tests run: 0,", out) and \
126
+ not re.search(r"Tests run: [1-9]", out):
127
+ return Outcome.NO_TESTS if code == 0 or "No tests" in out else Outcome.ERROR
128
+ if code == 0:
129
+ return Outcome.PASS
130
+ if re.search(r"Tests run: \d+, Failures: [1-9]|Tests run: \d+, Failures: \d+, Errors: [1-9]|"
131
+ r"\d+ tests? completed, \d+ failed|FAILED\s*$", out, re.M) and \
132
+ not re.search(r"COMPILATION ERROR|Compilation failed|compile\w*Java FAILED", out):
133
+ return Outcome.FAIL
134
+ if re.search(r"COMPILATION ERROR|Compilation failed|compile\w*Java FAILED|error: cannot find symbol", out):
135
+ return Outcome.ERROR
136
+ return Outcome.FAIL
@@ -0,0 +1,165 @@
1
+ """TypeScript / JavaScript adapter for Jest and Vitest."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from ..models import Outcome, TestRun
10
+ from .base import SKIP_DIRS, Adapter, TestPlacement, make_run, nearest_dir_with, run_cmd
11
+
12
+ _TEST_RE = re.compile(r"\.(test|spec)\.[cm]?[jt]sx?$")
13
+
14
+
15
+ class TypeScriptAdapter(Adapter):
16
+ name = "typescript"
17
+ fence_langs = ("typescript", "ts", "tsx", "javascript", "js", "jsx")
18
+ source_exts = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs")
19
+
20
+ def is_test_file(self, rel_path: str) -> bool:
21
+ p = PurePosixPath(rel_path)
22
+ return bool(_TEST_RE.search(p.name)) or "__tests__" in p.parts
23
+
24
+ def handles(self, rel_path: str) -> bool:
25
+ if not super().handles(rel_path) or rel_path.endswith(".d.ts"):
26
+ return False
27
+ n = PurePosixPath(rel_path).name
28
+ return not re.match(r"^(jest|vitest|vite|webpack|rollup|babel|eslint|prettier|tsup)\.config\.", n)
29
+
30
+ # -- package / runner discovery ----------------------------------------------------
31
+ def package_dir(self, base: Path, source_rel: str) -> Path:
32
+ d = nearest_dir_with((base / source_rel).parent, base, ("package.json",))
33
+ return d or base
34
+
35
+ def runner(self, pkg_dir: Path) -> str:
36
+ return self.detect_runner(pkg_dir) or "jest"
37
+
38
+ def detect_runner(self, pkg_dir: Path) -> str | None:
39
+ """Jest or Vitest for this package (falling back to the repo root), or None if neither is set up."""
40
+ forced = self.settings.get("runner", "auto")
41
+ if forced in ("jest", "vitest"):
42
+ return forced
43
+ deps: dict[str, str] = {}
44
+ pj = pkg_dir / "package.json"
45
+ if pj.exists():
46
+ try:
47
+ data = json.loads(pj.read_text(encoding="utf-8"))
48
+ for k in ("dependencies", "devDependencies", "peerDependencies"):
49
+ deps.update(data.get(k) or {})
50
+ except (json.JSONDecodeError, OSError):
51
+ pass
52
+ names = {p.name for p in pkg_dir.iterdir()} if pkg_dir.is_dir() else set()
53
+ if "vitest" in deps or any(n.startswith("vitest.config.") for n in names):
54
+ return "vitest"
55
+ if "jest" in deps or any(n.startswith("jest.config.") for n in names):
56
+ return "jest"
57
+ # monorepo: look at the repository root as a fallback
58
+ if pkg_dir != self.repo:
59
+ return self.detect_runner(self.repo)
60
+ return None
61
+
62
+ def available(self) -> tuple[bool, str]:
63
+ code, out, _, _ = run_cmd(["npx", "--version"], self.repo, 60)
64
+ if code != 0:
65
+ return False, "npx not available"
66
+ if self.settings.get("runner", "auto") in ("jest", "vitest"):
67
+ return True, ""
68
+ pkgs = self._package_dirs()
69
+ if not any(self.detect_runner(d) for d in pkgs):
70
+ return False, ("no Jest or Vitest found in any package.json (other runners, e.g. Playwright, "
71
+ "are not supported; set [adapters.typescript] runner to force one)")
72
+ return True, ""
73
+
74
+ def can_test(self, rel_path: str) -> tuple[bool, str]:
75
+ pkg = self.package_dir(self.repo, rel_path)
76
+ if self.detect_runner(pkg):
77
+ return True, ""
78
+ where = os.path.relpath(pkg, self.repo).replace(os.sep, "/")
79
+ return False, f"no Jest or Vitest configured for package '{where}'"
80
+
81
+ def _package_dirs(self) -> list[Path]:
82
+ out: list[Path] = []
83
+ for dirpath, dirnames, filenames in os.walk(self.repo):
84
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
85
+ if "package.json" in filenames:
86
+ out.append(Path(dirpath))
87
+ return out
88
+
89
+ # -- placement --------------------------------------------------------------------
90
+ def placement(self, source_rel: str, test_id: str, source_text: str) -> TestPlacement:
91
+ src = PurePosixPath(source_rel)
92
+ related = self.related_test(source_rel)
93
+ test_dir = PurePosixPath(related).parent if related else src.parent
94
+ ext = {".tsx": ".tsx", ".ts": ".ts", ".mts": ".mts", ".cts": ".cts", ".jsx": ".jsx",
95
+ ".mjs": ".mjs", ".cjs": ".cjs"}.get(src.suffix, ".js")
96
+ rel = str(test_dir / f"{src.stem}.jitcatch-{test_id}.test{ext}")
97
+ runner = self.runner(self.package_dir(self.repo, source_rel))
98
+ rel_import = os.path.relpath(str(src.with_suffix("")), str(test_dir)).replace(os.sep, "/")
99
+ if not rel_import.startswith("."):
100
+ rel_import = "./" + rel_import
101
+ if runner == "vitest":
102
+ fw = ("Framework: Vitest. Import test helpers explicitly: "
103
+ "`import { describe, it, expect, vi } from 'vitest'`.")
104
+ else:
105
+ fw = ("Framework: Jest. Use the Jest globals (describe/it/expect/jest) — mirror the example test's "
106
+ "imports (e.g. '@jest/globals') if it has any.")
107
+ instr = (
108
+ f"{fw} The test file is located at `{rel}`; import the module under test with the relative path "
109
+ f"'{rel_import}' (keep the import style and extension conventions of the example test if shown). "
110
+ "Do not use network, timers, randomness or the current date. Do not access private/#private members."
111
+ )
112
+ return TestPlacement(rel_path=rel, instructions=instr)
113
+
114
+ # -- execution --------------------------------------------------------------------
115
+ def _cmd(self, runner: str, rel_to_pkg: str | None) -> list[str]:
116
+ extra = list(self.settings.get("extra_args", []))
117
+ if runner == "vitest":
118
+ cmd = ["npx", "--no-install", "vitest", "run"]
119
+ if rel_to_pkg:
120
+ cmd += [rel_to_pkg]
121
+ return cmd + extra
122
+ cmd = ["npx", "--no-install", "jest", "--ci", "--colors=false"]
123
+ if rel_to_pkg:
124
+ cmd += ["--runTestsByPath", rel_to_pkg]
125
+ else:
126
+ cmd += ["--testPathIgnorePatterns", r"\.jitcatch-[0-9a-f]+\.test\."]
127
+ return cmd + extra
128
+
129
+ def run_test(self, worktree: Path, test_rel: str, placement: TestPlacement, timeout: float) -> TestRun:
130
+ pkg = self.package_dir(worktree, test_rel)
131
+ runner = self.runner(pkg)
132
+ rel_to_pkg = os.path.relpath(worktree / test_rel, pkg).replace(os.sep, "/")
133
+ cmd = self._cmd(runner, rel_to_pkg)
134
+ code, out, secs, timed_out = run_cmd(cmd, pkg, timeout)
135
+ return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
136
+
137
+ def run_existing_tests(self, worktree: Path, source_rel: str, timeout: float) -> TestRun | None:
138
+ pkg = self.package_dir(worktree, source_rel)
139
+ runner = self.runner(pkg)
140
+ cmd = self._cmd(runner, None)
141
+ if runner == "vitest":
142
+ cmd += ["--exclude", "**/*.jitcatch-*.test.*", "--passWithNoTests"]
143
+ else:
144
+ cmd += ["--passWithNoTests"]
145
+ code, out, secs, timed_out = run_cmd(cmd, pkg, timeout)
146
+ return make_run(self._classify(code, out, timed_out), code, out, secs, cmd)
147
+
148
+ @staticmethod
149
+ def _classify(code: int, out: str, timed_out: bool) -> Outcome:
150
+ if timed_out or code == -2:
151
+ return Outcome.INFRA
152
+ if re.search(r"could not determine executable to run|npm ERR! could not|command not found|"
153
+ r"Cannot find module '(jest|vitest)", out):
154
+ return Outcome.INFRA
155
+ if re.search(r"No tests found|No test files found|no tests? (were )?found", out, re.I):
156
+ return Outcome.NO_TESTS
157
+ if code == 0:
158
+ return Outcome.PASS
159
+ # suite-level failures: compile / import / transform errors before any test executed
160
+ if re.search(r"Test suite failed to run|Failed to load url|Transform failed|"
161
+ r"error TS\d+|SyntaxError:|Failed to resolve import|Cannot find module|"
162
+ r"FAIL\s+\S+\s+\[\s*\S+\s*\]", out):
163
+ if not re.search(r"(✕|×|●\s.+›|AssertionError|expect\()", out):
164
+ return Outcome.ERROR
165
+ return Outcome.FAIL