module-spec 0.0.0-stage → 0.2.0

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 (79) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +132 -2
  3. package/bin/module-spec.js +193 -0
  4. package/lib/affected.js +54 -0
  5. package/lib/baseline.js +34 -0
  6. package/lib/branch.js +26 -0
  7. package/lib/check.js +53 -0
  8. package/lib/checks/api.js +36 -0
  9. package/lib/checks/clones.js +33 -0
  10. package/lib/checks/imports.js +101 -0
  11. package/lib/checks/manifests.js +52 -0
  12. package/lib/checks/ports.js +32 -0
  13. package/lib/checks/tables.js +37 -0
  14. package/lib/config.js +56 -0
  15. package/lib/files.js +56 -0
  16. package/lib/languages/go/go.mod +5 -0
  17. package/lib/languages/go/index.js +37 -0
  18. package/lib/languages/go/mutate.js +56 -0
  19. package/lib/languages/go/read.js +40 -0
  20. package/lib/languages/go/read_project.go +433 -0
  21. package/lib/languages/go/scaffold.js +28 -0
  22. package/lib/languages/go/templates/module/internal/shell/create.go +14 -0
  23. package/lib/languages/go/templates/module/{{pkg}}.go +15 -0
  24. package/lib/languages/go/templates/module/{{pkg}}_test.go +13 -0
  25. package/lib/languages/go/templates/port/internal/adapters/in_memory_{{portSnake}}.go +10 -0
  26. package/lib/languages/go/templates/port/internal/ports/{{portSnake}}.go +5 -0
  27. package/lib/languages/go/templates/port/{{portSnake}}_contract_test.go +20 -0
  28. package/lib/languages/index.js +66 -0
  29. package/lib/languages/python/environment.js +15 -0
  30. package/lib/languages/python/index.js +35 -0
  31. package/lib/languages/python/mutate.js +72 -0
  32. package/lib/languages/python/package.js +16 -0
  33. package/lib/languages/python/read.js +81 -0
  34. package/lib/languages/python/read_project.py +145 -0
  35. package/lib/languages/python/scaffold.js +19 -0
  36. package/lib/languages/python/templates/module/__init__.py +3 -0
  37. package/lib/languages/python/templates/module/shell/__init__.py +0 -0
  38. package/lib/languages/python/templates/module/shell/create.py +16 -0
  39. package/lib/languages/python/templates/module/tests/__init__.py +0 -0
  40. package/lib/languages/python/templates/module/tests/test_{{snake}}.py +5 -0
  41. package/lib/languages/python/templates/port/adapters/in_memory_{{portSnake}}.py +9 -0
  42. package/lib/languages/python/templates/port/ports/{{portSnake}}.py +5 -0
  43. package/lib/languages/python/templates/port/tests/test_{{portSnake}}_contract.py +11 -0
  44. package/lib/languages/typescript/index.js +37 -0
  45. package/lib/languages/typescript/mutate.js +95 -0
  46. package/lib/languages/typescript/project.js +24 -0
  47. package/lib/languages/typescript/read.js +220 -0
  48. package/lib/languages/typescript/scaffold.js +60 -0
  49. package/lib/languages/typescript/templates/module/index.ts +2 -0
  50. package/lib/languages/typescript/templates/module/shell/create{{Name}}.ts +8 -0
  51. package/lib/languages/typescript/templates/module-test.node.ts +7 -0
  52. package/lib/languages/typescript/templates/module-test.vitest.ts +6 -0
  53. package/lib/languages/typescript/templates/port/adapters/inMemory{{Port}}.ts +5 -0
  54. package/lib/languages/typescript/templates/port/ports/{{Port}}.ts +1 -0
  55. package/lib/languages/typescript/templates/port-test.node.ts +12 -0
  56. package/lib/languages/typescript/templates/port-test.vitest.ts +12 -0
  57. package/lib/languages/typescript/templates/view-module/__tests__/{{name}}.test.tsx +9 -0
  58. package/lib/languages/typescript/templates/view-module/core/model.ts +25 -0
  59. package/lib/languages/typescript/templates/view-module/index.ts +4 -0
  60. package/lib/languages/typescript/templates/view-module/shell/create{{Name}}Model.ts +20 -0
  61. package/lib/languages/typescript/templates/view-module/view/{{Name}}Page.tsx +14 -0
  62. package/lib/modules.js +104 -0
  63. package/lib/mutation.js +124 -0
  64. package/lib/place.js +25 -0
  65. package/lib/report.js +42 -0
  66. package/lib/rules.js +145 -0
  67. package/lib/scaffold.js +57 -0
  68. package/lib/scans.js +62 -0
  69. package/lib/templating.js +28 -0
  70. package/lib/tools.js +17 -0
  71. package/manifest.schema.json +35 -0
  72. package/package.json +38 -4
  73. package/spec/adopting.md +70 -0
  74. package/spec/backend.md +176 -0
  75. package/spec/common.md +163 -0
  76. package/spec/frontend.md +181 -0
  77. package/spec/roles.md +93 -0
  78. package/spec/tests.md +146 -0
  79. package/spec/workflow.md +50 -0
@@ -0,0 +1,72 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { scratchCopy } from "../../files.js";
5
+ import { pythonOf, toolBeside } from "./environment.js";
6
+ import { pythonPackageOf } from "./package.js";
7
+
8
+ const OUTPUT_LIMIT = 512 * 1024 * 1024;
9
+ /** mutmut's statuses: a timeout counts as caught, and a mutant no test reaches counts as live. */
10
+ const CAUGHT = new Set(["killed", "timeout"]);
11
+ const LIVE = new Set(["survived", "no tests"]);
12
+
13
+ /** setup.cfg's [mutmut] section, with any section the project had replaced, and any [tool.mutmut] in pyproject.toml dropped. */
14
+ function configure(copy, { sourcePath, files, tests }) {
15
+ const list = (items) => items.map((item) => `\n ${item}`).join("");
16
+ const section = `[mutmut]\nsource_paths=${list([sourcePath])}\nonly_mutate=${list(files)}\npytest_add_cli_args_test_selection=${list(tests)}\n`;
17
+ const setupCfg = path.join(copy, "setup.cfg");
18
+ const own = fs.existsSync(setupCfg) ? fs.readFileSync(setupCfg, "utf8").replace(/^\[mutmut\][^[]*/m, "") : "";
19
+ fs.writeFileSync(setupCfg, `${own}\n${section}`);
20
+ const pyproject = path.join(copy, "pyproject.toml");
21
+ if (fs.existsSync(pyproject)) fs.writeFileSync(pyproject, fs.readFileSync(pyproject, "utf8").replace(/^\[tool\.mutmut\][^[]*/m, ""));
22
+ }
23
+
24
+ function mutmut(tool, args, cwd) {
25
+ const run = spawnSync(tool, args, { cwd, encoding: "utf8", maxBuffer: OUTPUT_LIMIT });
26
+ if (run.error || run.status !== 0) {
27
+ throw new Error(`mutmut ${args[0]} failed; the project's environment needs mutmut:\n${run.stderr || run.stdout || run.error}`);
28
+ }
29
+ return run.stdout;
30
+ }
31
+
32
+ /**
33
+ * Where a live mutant is, from `mutmut show`: its diff numbers lines from the function's start, so the
34
+ * function's original lines are found in the file to place it.
35
+ */
36
+ function locate(copy, shown) {
37
+ const lines = shown.split("\n");
38
+ const file = lines.find((line) => line.startsWith("--- ")).slice(4);
39
+ const body = lines.slice(lines.findIndex((line) => line.startsWith("@@")) + 1);
40
+ const before = body.filter((line) => line.startsWith(" ") || line.startsWith("-"));
41
+ const original = before.map((line) => line.slice(1));
42
+ const source = fs.readFileSync(path.join(copy, file), "utf8").split("\n");
43
+ const start = source.findIndex((_, index) => original.every((line, offset) => source[index + offset] === line));
44
+ const removed = before.findIndex((line) => line.startsWith("-"));
45
+ const added = body.find((line) => line.startsWith("+"));
46
+ return {
47
+ file,
48
+ line: start + removed + 1,
49
+ detail: added ? added.slice(1).trim() : `removed: ${original[removed].trim()}`,
50
+ };
51
+ }
52
+
53
+ /** Mutation testing by mutmut from the project's environment, in a scratch copy, since mutmut writes beside the code. */
54
+ export async function mutate({ root, config, module, files, tests }) {
55
+ const tool = toolBeside(pythonOf(root, config), "mutmut");
56
+ const { sourceRoot, topPackage } = pythonPackageOf(root, module.dir);
57
+ const copy = scratchCopy(root);
58
+ try {
59
+ configure(copy, { sourcePath: `${path.posix.join(sourceRoot, topPackage)}/`, files, tests });
60
+ mutmut(tool, ["run"], copy);
61
+ const statuses = mutmut(tool, ["results", "--all", "true"], copy)
62
+ .split("\n")
63
+ .map((line) => /^\s*(\S+): (.+)$/.exec(line))
64
+ .filter(Boolean);
65
+ return {
66
+ killed: statuses.filter(([, , status]) => CAUGHT.has(status)).length,
67
+ survivors: statuses.filter(([, , status]) => LIVE.has(status)).map(([, name]) => locate(copy, mutmut(tool, ["show", name], copy))),
68
+ };
69
+ } finally {
70
+ fs.rmSync(copy, { recursive: true, force: true });
71
+ }
72
+ }
@@ -0,0 +1,16 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Where a Python folder sits in its package tree, the way Python itself decides: the package climbs
6
+ * while the parent folder has an __init__.py, and the folder above the top package is the source root.
7
+ */
8
+ export function pythonPackageOf(root, dir) {
9
+ let top = dir;
10
+ while (path.posix.dirname(top) !== "." && fs.existsSync(path.join(root, path.posix.dirname(top), "__init__.py"))) {
11
+ top = path.posix.dirname(top);
12
+ }
13
+ const sourceRoot = path.posix.dirname(top);
14
+ const inside = sourceRoot === "." ? dir : dir.slice(sourceRoot.length + 1);
15
+ return { sourceRoot, dotted: inside.split("/").join("."), topPackage: inside.split("/")[0] };
16
+ }
@@ -0,0 +1,81 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { toPosix } from "../../files.js";
5
+ import { pythonOf, toolBeside } from "./environment.js";
6
+ import { pythonPackageOf } from "./package.js";
7
+
8
+ const READ_PROJECT = path.join(path.dirname(fileURLToPath(import.meta.url)), "read_project.py");
9
+ const OUTPUT_LIMIT = 512 * 1024 * 1024;
10
+
11
+ const doorOf = (module) => `${module.dir}/${module.language.door}`;
12
+
13
+ /** The plan's lists cut down to `files`. */
14
+ function planWithin(plan, files) {
15
+ const own = new Set(files);
16
+ return Object.fromEntries(Object.entries(plan).map(([scan, value]) => [scan, Array.isArray(value) ? value.filter((file) => own.has(file)) : value]));
17
+ }
18
+
19
+ /** Python reads one source root at a time, since grimp builds one import graph per root. */
20
+ function inspectAll(root, config, modules, plan) {
21
+ const bySourceRoot = Map.groupBy(modules, (module) => pythonPackageOf(root, module.dir).sourceRoot);
22
+ const merged = { edges: [], sightings: [], all: {} };
23
+ for (const [sourceRoot, own] of bySourceRoot) {
24
+ const read = inspect(root, config, sourceRoot, own, planWithin(plan, own.flatMap((module) => module.files)));
25
+ merged.edges.push(...read.edges);
26
+ merged.sightings.push(...read.sightings);
27
+ Object.assign(merged.all, read.all);
28
+ }
29
+ return merged;
30
+ }
31
+
32
+ function inspect(root, config, sourceRoot, modules, plan) {
33
+ const request = {
34
+ projectRoot: root,
35
+ sourceRoot,
36
+ packages: [...new Set(modules.map((module) => pythonPackageOf(root, module.dir).topPackage))],
37
+ plan,
38
+ doors: modules.map(doorOf),
39
+ };
40
+ const run = spawnSync(pythonOf(root, config), ["-I", READ_PROJECT], {
41
+ cwd: root,
42
+ input: JSON.stringify(request),
43
+ encoding: "utf8",
44
+ maxBuffer: OUTPUT_LIMIT,
45
+ });
46
+ if (run.status !== 0) {
47
+ throw new Error(`reading the Python modules failed; the project's environment needs import-linter, which brings grimp:\n${run.stderr}`);
48
+ }
49
+ return JSON.parse(run.stdout);
50
+ }
51
+
52
+ /** The complexity scan, by ruff's C901. */
53
+ function complexitySightings(root, config, plan) {
54
+ if (plan.complexity.length === 0) return [];
55
+ const run = spawnSync(
56
+ toolBeside(pythonOf(root, config), "ruff"),
57
+ ["check", "--isolated", "--select", "C901", "--config", `lint.mccabe.max-complexity = ${plan.maxComplexity}`,
58
+ "--output-format", "json", ...plan.complexity],
59
+ { cwd: root, encoding: "utf8", maxBuffer: OUTPUT_LIMIT },
60
+ );
61
+ if (run.error || run.status > 1) throw new Error(`Python modules need ruff for the complexity check:\n${run.stderr ?? run.error}`);
62
+ return JSON.parse(run.stdout).map((finding) => ({
63
+ scan: "complexity",
64
+ file: toPosix(path.relative(root, finding.filename)),
65
+ line: finding.location.row,
66
+ detail: finding.message.replaceAll("`", ""),
67
+ }));
68
+ }
69
+
70
+ /** @type {import("../index.js").Language["analyze"]} */
71
+ export async function analyze({ root, config, modules, plan }) {
72
+ const read = inspectAll(root, config, modules, plan);
73
+ const findings = [];
74
+ const api = new Map();
75
+ for (const module of modules) {
76
+ const names = read.all[doorOf(module)];
77
+ if (names) api.set(module.name, names);
78
+ else findings.push({ id: "C4", file: doorOf(module), line: null, detail: "declares no __all__, so its door has no fixed list of exports" });
79
+ }
80
+ return { edges: read.edges, sightings: [...read.sightings, ...complexitySightings(root, config, plan)], findings, api };
81
+ }
@@ -0,0 +1,145 @@
1
+ """Reads a Python project for module-spec: import edges through grimp, what the scan plan's scans see, and __all__.
2
+
3
+ Takes one JSON request on stdin and prints one JSON answer on stdout.
4
+ """
5
+
6
+ import ast
7
+ import functools
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ ASYNC_NODES = (ast.AsyncFunctionDef, ast.Await, ast.AsyncFor, ast.AsyncWith)
13
+ ENV_NAMES = {"environ", "getenv", "putenv", "environb", "getenvb"}
14
+ # Builtins that reach the outside without an import, so the import check cannot see them.
15
+ IO_BUILTINS = {"open", "input"}
16
+ # pytest fixtures whose only job is to patch.
17
+ PATCH_FIXTURES = {"monkeypatch", "mocker"}
18
+
19
+
20
+ def file_of(source_root, module_name):
21
+ base = source_root.joinpath(*module_name.split("."))
22
+ if base.with_suffix(".py").is_file():
23
+ return base.with_suffix(".py")
24
+ if (base / "__init__.py").is_file():
25
+ return base / "__init__.py"
26
+ return None
27
+
28
+
29
+ def names_type_checking(test):
30
+ return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING")
31
+
32
+
33
+ @functools.cache
34
+ def type_checking_lines(file):
35
+ """The lines of imports under `if TYPE_CHECKING:`, which never run."""
36
+ lines = set()
37
+ for node in ast.walk(ast.parse(file.read_text(encoding="utf-8"))):
38
+ if isinstance(node, ast.If) and names_type_checking(node.test):
39
+ lines.update(inner.lineno for statement in node.body for inner in ast.walk(statement) if isinstance(inner, (ast.Import, ast.ImportFrom)))
40
+ return lines
41
+
42
+
43
+ def read_edges(project_root, source_root, packages):
44
+ import grimp
45
+
46
+ graph = grimp.build_graph(*packages, include_external_packages=True, cache_dir=None)
47
+ edges = []
48
+ for importer in sorted(graph.modules):
49
+ importer_file = file_of(source_root, importer)
50
+ if importer_file is None:
51
+ continue
52
+ for imported in sorted(graph.find_modules_directly_imported_by(importer)):
53
+ details = graph.get_import_details(importer=importer, imported=imported)
54
+ edge = {"from": importer_file.relative_to(project_root).as_posix(), "line": details[0]["line_number"] if details else None}
55
+ target = file_of(source_root, imported)
56
+ if target is None:
57
+ edge["external"] = imported.split(".")[0]
58
+ edge["std"] = edge["external"] in sys.stdlib_module_names
59
+ else:
60
+ edge["to"] = target.relative_to(project_root).as_posix()
61
+ if details and all(detail["line_number"] in type_checking_lines(importer_file) for detail in details):
62
+ edge["typeOnly"] = True
63
+ edges.append(edge)
64
+ return edges
65
+
66
+
67
+ def parsed(project_root, file):
68
+ return ast.parse((project_root / file).read_text(encoding="utf-8"), filename=file)
69
+
70
+
71
+ def sightings(project_root, files, scan, seen):
72
+ """Every node in `files` that `seen` reports, as a sighting of `scan`. `seen` returns a detail, or None."""
73
+ return [
74
+ {"scan": scan, "file": file, "line": node.lineno, "detail": detail}
75
+ for file in files
76
+ for node in ast.walk(parsed(project_root, file))
77
+ if (detail := seen(node))
78
+ ]
79
+
80
+
81
+ def waits(node):
82
+ return "waits" if isinstance(node, ASYNC_NODES) else None
83
+
84
+
85
+ def uses_io_builtin(node):
86
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in IO_BUILTINS:
87
+ return f"uses {node.func.id}"
88
+ return None
89
+
90
+
91
+ def patch_of(node):
92
+ """The patching tool a node uses, or None: unittest.mock's patch, or a patching pytest fixture."""
93
+ if isinstance(node, ast.ImportFrom) and node.module == "unittest.mock" and any(alias.name == "patch" for alias in node.names):
94
+ return "patches with unittest.mock.patch"
95
+ if isinstance(node, ast.Attribute) and node.attr == "patch":
96
+ owner = node.value
97
+ if (isinstance(owner, ast.Name) and owner.id == "mock") or (isinstance(owner, ast.Attribute) and owner.attr == "mock"):
98
+ return "patches with mock.patch"
99
+ if isinstance(node, ast.arg) and node.arg in PATCH_FIXTURES:
100
+ return f"patches with {node.arg}"
101
+ return None
102
+
103
+
104
+ def reads_env(node):
105
+ if isinstance(node, ast.Attribute):
106
+ found = isinstance(node.value, ast.Name) and node.value.id == "os" and node.attr in ENV_NAMES
107
+ elif isinstance(node, ast.ImportFrom):
108
+ found = node.module == "os" and any(alias.name in ENV_NAMES for alias in node.names)
109
+ else:
110
+ found = False
111
+ return "reads os.environ" if found else None
112
+
113
+
114
+ def all_names(project_root, door):
115
+ for node in parsed(project_root, door).body:
116
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target] if isinstance(node, ast.AnnAssign) else []
117
+ if any(isinstance(target, ast.Name) and target.id == "__all__" for target in targets):
118
+ if isinstance(node.value, (ast.List, ast.Tuple)):
119
+ return sorted(element.value for element in node.value.elts if isinstance(element, ast.Constant))
120
+ return None
121
+
122
+
123
+ def main():
124
+ request = json.load(sys.stdin)
125
+ project_root = Path(request["projectRoot"]).resolve()
126
+ source_root = (project_root / request["sourceRoot"]).resolve()
127
+ sys.path.insert(0, str(source_root))
128
+ plan = request["plan"]
129
+ json.dump(
130
+ {
131
+ "edges": read_edges(project_root, source_root, request["packages"]),
132
+ "sightings": [
133
+ *sightings(project_root, plan["wait"], "wait", waits),
134
+ *sightings(project_root, plan["io"], "io", uses_io_builtin),
135
+ *sightings(project_root, plan["env"], "env", reads_env),
136
+ *sightings(project_root, plan["patch"], "patch", patch_of),
137
+ ],
138
+ "all": {door: all_names(project_root, door) for door in request["doors"]},
139
+ },
140
+ sys.stdout,
141
+ )
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
@@ -0,0 +1,19 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { copyTemplates, writeNew } from "../../templating.js";
4
+ import { pythonPackageOf } from "./package.js";
5
+
6
+ const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates");
7
+
8
+ export function newModule(root, dir, values) {
9
+ const written = [];
10
+ copyTemplates(root, path.join(TEMPLATES, "module"), dir, { ...values, package: pythonPackageOf(root, dir).dotted }, written);
11
+ return written;
12
+ }
13
+
14
+ export function newPort(root, dir, values) {
15
+ const written = [];
16
+ for (const part of ["ports", "adapters", "tests"]) writeNew(root, path.join(dir, part, "__init__.py"), "", written);
17
+ copyTemplates(root, path.join(TEMPLATES, "port"), dir, { ...values, package: pythonPackageOf(root, dir).dotted }, written);
18
+ return written;
19
+ }
@@ -0,0 +1,3 @@
1
+ from .shell.create import {{Name}}, {{Name}}Deps, create_{{snake}}
2
+
3
+ __all__ = ["{{Name}}", "{{Name}}Deps", "create_{{snake}}"]
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class {{Name}}Deps:
6
+ pass
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class {{Name}}:
11
+ pass
12
+
13
+
14
+ def create_{{snake}}(deps: {{Name}}Deps) -> {{Name}}:
15
+ del deps
16
+ return {{Name}}()
@@ -0,0 +1,5 @@
1
+ from {{package}} import {{Name}}, {{Name}}Deps, create_{{snake}}
2
+
3
+
4
+ def test_create_{{snake}}_builds_it_from_its_deps() -> None:
5
+ assert create_{{snake}}({{Name}}Deps()) == {{Name}}()
@@ -0,0 +1,9 @@
1
+ from ..ports.{{portSnake}} import {{Port}}
2
+
3
+
4
+ class InMemory{{Port}}:
5
+ pass
6
+
7
+
8
+ def in_memory_{{portSnake}}() -> {{Port}}:
9
+ return InMemory{{Port}}()
@@ -0,0 +1,5 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class {{Port}}(Protocol):
5
+ pass
@@ -0,0 +1,11 @@
1
+ """One test per promise {{Port}} makes. Every adapter joins the fixture's params, so the fake cannot drift from the real one."""
2
+
3
+ import pytest
4
+
5
+ from {{package}}.adapters.in_memory_{{portSnake}} import in_memory_{{portSnake}}
6
+ from {{package}}.ports.{{portSnake}} import {{Port}}
7
+
8
+
9
+ @pytest.fixture(params=[in_memory_{{portSnake}}])
10
+ def {{portSnake}}(request: pytest.FixtureRequest) -> {{Port}}:
11
+ return request.param()
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { mutate } from "./mutate.js";
4
+ import { analyze } from "./read.js";
5
+ import { newModule, newPort, newViewModule } from "./scaffold.js";
6
+
7
+ const SOURCE = /\.(ts|tsx|mts|cts)$/;
8
+ const TEST = /\.test\.[cm]?[jt]sx?$/;
9
+ const CONTRACT_TEST = /\.contract\.test\.[cm]?[jt]sx?$/;
10
+
11
+ /** @type {import("../index.js").Language} */
12
+ export default {
13
+ name: "typescript",
14
+ aliases: ["ts"],
15
+ door: "index.ts",
16
+ isDoor: (inner) => inner === "index.ts",
17
+ partsDir: "",
18
+ isSource: (file) => SOURCE.test(file) && !file.endsWith(".d.ts"),
19
+ isTest: (segments) => segments.includes("__tests__") || TEST.test(segments.at(-1)),
20
+ isContractTest: (name) => CONTRACT_TEST.test(name),
21
+ contractTestFor: (file) => (file === "index.ts" ? null : `${file.replace(/\.[^.]+$/, "")}.contract.test.ts`),
22
+ stdlib: {
23
+ io: [
24
+ "fs", "fs/promises", "net", "http", "https", "http2", "child_process", "dgram", "dns", "dns/promises",
25
+ "tls", "cluster", "worker_threads", "readline", "readline/promises", "os", "process", "sqlite", "inspector", "vm",
26
+ ],
27
+ ui: [],
28
+ },
29
+ viewUi: ["react"],
30
+ configDefaults: {},
31
+ detect: (root) => ["package.json", "tsconfig.json"].some((file) => fs.existsSync(path.join(root, file))),
32
+ analyze,
33
+ mutate,
34
+ newModule,
35
+ newPort,
36
+ newViewModule,
37
+ };
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { kitPackageDir } from "../../tools.js";
7
+ import { environmentForTests, testRunnerOf } from "./project.js";
8
+
9
+ const OUTPUT_LIMIT = 512 * 1024 * 1024;
10
+ /** Stryker's statuses: a timeout counts as caught, and a mutant that never compiled or ran counts as neither. */
11
+ const CAUGHT = new Set(["Killed", "Timeout"]);
12
+ const LIVE = new Set(["Survived", "NoCoverage"]);
13
+
14
+ /** The shell command that runs only `tests`, with the project's own runner. Stryker runs it once per mutant. */
15
+ function testCommand(root, tests) {
16
+ const files = tests.map((file) => JSON.stringify(file)).join(" ");
17
+ return testRunnerOf(root) === "vitest" ? `node_modules/.bin/vitest run ${files}` : `${JSON.stringify(process.execPath)} --test ${files}`;
18
+ }
19
+
20
+ const oneLine = (text) => {
21
+ const flat = text.replace(/\s+/g, " ").trim();
22
+ return flat.length > 80 ? `${flat.slice(0, 77)}...` : flat;
23
+ };
24
+
25
+ /**
26
+ * How Stryker runs the module's own tests. Under vitest, Stryker's vitest runner keeps its workers alive and runs
27
+ * only the tests that cover each mutant, where a command would start vitest again for every mutant. `testFiles`
28
+ * keeps other modules' tests out, so they cannot kill this module's mutants.
29
+ */
30
+ function runnerConfig(root, tests) {
31
+ if (testRunnerOf(root) !== "vitest") {
32
+ return { testRunner: "command", commandRunner: { command: testCommand(root, tests) }, coverageAnalysis: "off", plugins: [] };
33
+ }
34
+ return {
35
+ testRunner: "vitest",
36
+ vitest: { related: false },
37
+ testFiles: tests,
38
+ coverageAnalysis: "perTest",
39
+ plugins: [fileURLToPath(import.meta.resolve("@stryker-mutator/vitest-runner"))],
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Under vitest, Stryker keeps each mutant's verdict and the tests that covered it, so a rerun tests again only the
45
+ * mutants whose code or covering tests changed. The command runner reports no tests, so a weakened test would keep
46
+ * its old verdict there, and it reruns everything. The file sits under node_modules/.cache, which no project commits.
47
+ */
48
+ function incrementalConfig(root, module) {
49
+ if (testRunnerOf(root) !== "vitest") return {};
50
+ const file = `${module.dir.replaceAll("/", "_")}.json`;
51
+ return { incremental: true, incrementalFile: path.join(root, "node_modules", ".cache", "module-spec", "mutate", file) };
52
+ }
53
+
54
+ /** Mutation testing by the kit's own StrykerJS, in a sandbox under the system temp folder. */
55
+ export async function mutate({ root, module, files, tests }) {
56
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-stryker-"));
57
+ try {
58
+ const report = path.join(work, "report.json");
59
+ const configFile = path.join(work, "stryker.config.json");
60
+ fs.writeFileSync(configFile, JSON.stringify({
61
+ ...runnerConfig(root, tests),
62
+ mutate: files,
63
+ reporters: ["json"],
64
+ jsonReporter: { fileName: report },
65
+ tempDirName: path.join(work, "sandbox"),
66
+ cleanTempDir: "always",
67
+ checkers: [],
68
+ // Stryker rewrites tsconfig.json with the project's own TypeScript, which from version 7 has no JS API. The kit's
69
+ // runners read TypeScript without it, so the rewrite is skipped by naming a file that does not exist.
70
+ tsconfigFile: "module-spec-skips-the-tsconfig-rewrite.json",
71
+ logLevel: "error",
72
+ ...incrementalConfig(root, module),
73
+ }));
74
+ const dir = kitPackageDir("@stryker-mutator/core");
75
+ const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.stryker);
76
+ const run = spawnSync(process.execPath, [bin, "run", configFile], {
77
+ cwd: root,
78
+ env: environmentForTests(),
79
+ encoding: "utf8",
80
+ maxBuffer: OUTPUT_LIMIT,
81
+ });
82
+ if (run.status !== 0 || !fs.existsSync(report)) throw new Error(`Stryker failed:\n${run.stderr || run.stdout}`);
83
+ const mutants = Object.entries(JSON.parse(fs.readFileSync(report, "utf8")).files).flatMap(([file, { mutants }]) =>
84
+ mutants.map((mutant) => ({ ...mutant, file })),
85
+ );
86
+ return {
87
+ killed: mutants.filter((mutant) => CAUGHT.has(mutant.status)).length,
88
+ survivors: mutants
89
+ .filter((mutant) => LIVE.has(mutant.status))
90
+ .map((mutant) => ({ file: mutant.file, line: mutant.location.start.line, detail: oneLine(`${mutant.mutatorName}: ${mutant.replacement ?? ""}`) })),
91
+ };
92
+ } finally {
93
+ fs.rmSync(work, { recursive: true, force: true });
94
+ }
95
+ }
@@ -0,0 +1,24 @@
1
+ import path from "node:path";
2
+ import { readIfExists } from "../../files.js";
3
+
4
+ /** Every package the project's package.json depends on, dev or not, with its version range. */
5
+ export function dependenciesOf(root) {
6
+ const packageJson = JSON.parse(readIfExists(path.join(root, "package.json")) ?? "{}");
7
+ return { ...packageJson.dependencies, ...packageJson.devDependencies };
8
+ }
9
+
10
+ /** The project's test runner, so the kit writes and runs tests with what the project already uses. */
11
+ export function testRunnerOf(root) {
12
+ return dependenciesOf(root).vitest ? "vitest" : "node";
13
+ }
14
+
15
+ /**
16
+ * This process's environment without node:test's marker. Under a running `node --test`, a nested
17
+ * `node --test` sees NODE_TEST_CONTEXT, reports to a parent that is not listening, and exits 0 even when a
18
+ * test fails.
19
+ */
20
+ export function environmentForTests() {
21
+ const env = { ...process.env };
22
+ delete env.NODE_TEST_CONTEXT;
23
+ return env;
24
+ }