From 4a9d0c0a5c6f9d06f2084240abf9e5b46b68fb4b Mon Sep 17 00:00:00 2001 From: "Robert C. Martin" Date: Thu, 1 Oct 2026 10:46:47 -0500 Subject: [PATCH] Copy relative importers into the worker so Node loads the mutant. Node resolves a relative import from the real path of the module. A symlinked test was loading the original source, so every mutant survived. Modules that reach the file under test are now real copies in the overlay. --- README.md | 2 +- src/mutator/workers.py | 128 +++++++++++++++++++++++++++++++++++++++++ tests/test_engine.py | 48 ++++++++++++++++ 3 files changed, 177 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c6d2177..c255eca 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Exit `0` when every executed mutant was killed. Exit `2` when the baseline tests Mutants of one file run at the same time. The default is one worker per core. `--max-workers` sets the cap. The run uses the smaller of that cap, the number of cores, and the number of selected sites. Files are still taken one at a time. -Each worker is a directory under `target/mutation-workers`. It is a symlink overlay of the project: the file being mutated is a private copy, and the rest of the tree is linked. The original files stay as they are. The overlay is removed when that file's mutants finish. +Each worker is a directory under `target/mutation-workers`. The file being mutated is a private copy. A module that reaches that file through a relative import is copied too, so Node resolves the import to the private copy. The rest of the tree is linked. The original files stay as they are. The overlay is removed when that file's mutants finish. The baseline still runs once, in the real tree, before any worker starts. The mutant command then runs inside the worker, in the same directory it would have used in the real tree, so a relative path such as `src` or `./pkg` refers to the overlay. A copy left under `target/mutator-backup/` by an interrupted older run is restored before a non-scan run. diff --git a/src/mutator/workers.py b/src/mutator/workers.py index 1b20e1f..bff3bc1 100644 --- a/src/mutator/workers.py +++ b/src/mutator/workers.py @@ -9,6 +9,7 @@ the file's mutants finish. from __future__ import annotations import os +import re import sys import uuid from concurrent.futures import ThreadPoolExecutor @@ -129,6 +130,130 @@ def _link_children(worker_dir: Path, real_dir: Path, skip: set[str]) -> None: symlink(destination, child) +# Node, and Vite in front of it, resolve a relative import from the real path +# of the module. A symlinked test therefore loads the original source. +_SPECIFIER = re.compile( + r"""(?:from|import|require)\s*\(?\s*['"](\.[^'"]+)['"]""", +) +_EXTENSIONS = (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") + + +def _specifiers(text: str) -> list[str]: + found = [] + for match in _SPECIFIER.finditer(text): + specifier = match.group(1).split("?", 1)[0].split("#", 1)[0] + if specifier.startswith("."): + found.append(specifier) + return found + + +def _stem(base: Path) -> Path: + if base.suffix in _EXTENSIONS: + return Path(str(base)[: -len(base.suffix)]) + return base + + +def _candidates(directory: Path, specifier: str) -> list[Path]: + base = directory / specifier + stem = _stem(base) + names = [base] + for ext in _EXTENSIONS: + names.append(Path(str(stem) + ext)) + for ext in _EXTENSIONS: + names.append(stem / f"index{ext}") + return names + + +def _inside(root: Path, path: Path) -> Path | None: + try: + relative = path.resolve().relative_to(root.resolve()) + except ValueError: + return None + return root / relative + + +def _imported_files(importer: Path, root: Path) -> set[Path]: + try: + text = importer.read_text(encoding="utf-8") + except (OSError, UnicodeError): + return set() + found = set() + for specifier in _specifiers(text): + for candidate in _candidates(importer.parent, specifier): + local = _inside(root, candidate) + if local is not None and local.is_file(): + found.add(local.resolve()) + return found + + +def _keep_dir(name: str) -> bool: + return name not in SKIP_LINK and name != "node_modules" + + +def _source_files(root: Path): + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [name for name in dirnames if _keep_dir(name)] + for name in filenames: + if Path(name).suffix in _EXTENSIONS: + yield Path(dirpath) / name + + +def importers_of(root: Path, relative: str) -> set[str]: + """Modules that reach ``relative`` through a relative import. + + Those modules have to be real files in the worker. A symlink would send + Node back to the original tree. + """ + + target = (root / relative).resolve() + reverse: dict[Path, set[Path]] = {} + for source in _source_files(root): + for imported in _imported_files(source, root): + reverse.setdefault(imported, set()).add(source.resolve()) + found: set[Path] = set() + pending = [target] + while pending: + current = pending.pop() + for importer in reverse.get(current, ()): + if importer in found or importer == target: + continue + found.add(importer) + pending.append(importer) + root_resolved = root.resolve() + return {path.relative_to(root_resolved).as_posix() for path in found} + + +def _expand_directory(link: Path, real_dir: Path) -> None: + """Replace a linked directory so a copied module is not written through it.""" + + if not real_dir.is_dir(): + return + link.unlink() + link.mkdir() + for child in real_dir.iterdir(): + if child.name in SKIP_LINK: + continue + symlink(link / child.name, child) + + +def _copy_importer(worker: Path, root: Path, relative: str) -> None: + real = root + current = worker + for segment in relative.split("/")[:-1]: + real = real / segment + current = current / segment + if current.is_symlink(): + _expand_directory(current, real) + elif not current.exists(): + current.mkdir(parents=True) + destination = worker / relative + if destination.is_symlink(): + destination.unlink() + elif destination.exists(): + return + destination.write_bytes((root / relative).read_bytes()) + + def _overlay(worker: Path, root: Path, relative: str, original: bytes) -> None: """Real directories along the source path. Every sibling is a link.""" @@ -146,12 +271,15 @@ def create_workers( ) -> list[Path]: created = [] first = relative.split("/", 1)[0] + importers = importers_of(root, relative) for index in range(count): worker = base / f"worker-{index}" worker.mkdir(parents=True, exist_ok=True) _copy_configs(worker, root, relative) _link_children(worker, root, {first}) _overlay(worker, root, relative, original) + for importer in importers: + _copy_importer(worker, root, importer) created.append(worker) return created diff --git a/tests/test_engine.py b/tests/test_engine.py index 666d776..b24fc7c 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,4 +1,6 @@ import os +import shutil +import subprocess import threading from mutator.engine import mutate_file, restore_backups @@ -184,6 +186,52 @@ def test_workers_keep_a_private_copy_and_link_the_rest(tmp_path): assert not base.exists() +def test_node_imports_the_worker_copy_through_a_relative_specifier(tmp_path): + source = tmp_path / "src" / "find.mjs" + source.parent.mkdir() + source.write_text('export const value = "real"\n', encoding="utf-8") + beside = tmp_path / "src" / "find.test.mjs" + beside.write_text('import { value } from "./find.mjs";\nconsole.log(value)\n', encoding="utf-8") + (tmp_path / "src" / "notes.mjs").write_text("export const notes = 1\n", encoding="utf-8") + main = tmp_path / "src" / "main.mjs" + main.write_text('import { value } from "./find.mjs";\nexport const main = value\n', encoding="utf-8") + (tmp_path / "src" / "app.test.mjs").write_text('import { main } from "./main.mjs";\n', encoding="utf-8") + distant = tmp_path / "tests" / "use.mjs" + distant.parent.mkdir() + distant.write_text('import { value } from "../src/find.mjs";\nconsole.log(value)\n', encoding="utf-8") + (tmp_path / "tests" / "other.test.mjs").write_text("export const n = 1\n", encoding="utf-8") + + base = new_run_dir(tmp_path) + try: + worker = create_workers(base, tmp_path, "src/find.mjs", source.read_bytes(), 1)[0] + private = worker / "src" / "find.mjs" + private.write_text('export const value = "worker"\n', encoding="utf-8") + for relative in ("src/find.test.mjs", "src/main.mjs", "src/app.test.mjs", "tests/use.mjs"): + copied = worker / relative + assert copied.is_file() + assert not copied.is_symlink() + assert (worker / "src" / "notes.mjs").is_symlink() + assert not (worker / "tests").is_symlink() + assert (worker / "tests" / "other.test.mjs").is_symlink() + if shutil.which("node"): + for script in ("src/find.test.mjs", "tests/use.mjs"): + completed = subprocess.run( + ["node", script], + cwd=worker, + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0 + assert completed.stdout.strip() == "worker" + finally: + delete_tree(base) + + assert beside.read_text(encoding="utf-8").startswith("import") + assert distant.read_text(encoding="utf-8").startswith("import") + assert source.read_text(encoding="utf-8") == 'export const value = "real"\n' + + def test_a_mutation_run_leaves_the_project_tree_unchanged(tmp_path): path = tmp_path / "src" / "demo.py" path.parent.mkdir()