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.
- package/CHANGELOG.md +97 -0
- package/README.md +132 -2
- package/bin/module-spec.js +193 -0
- package/lib/affected.js +54 -0
- package/lib/baseline.js +34 -0
- package/lib/branch.js +26 -0
- package/lib/check.js +53 -0
- package/lib/checks/api.js +36 -0
- package/lib/checks/clones.js +33 -0
- package/lib/checks/imports.js +101 -0
- package/lib/checks/manifests.js +52 -0
- package/lib/checks/ports.js +32 -0
- package/lib/checks/tables.js +37 -0
- package/lib/config.js +56 -0
- package/lib/files.js +56 -0
- package/lib/languages/go/go.mod +5 -0
- package/lib/languages/go/index.js +37 -0
- package/lib/languages/go/mutate.js +56 -0
- package/lib/languages/go/read.js +40 -0
- package/lib/languages/go/read_project.go +433 -0
- package/lib/languages/go/scaffold.js +28 -0
- package/lib/languages/go/templates/module/internal/shell/create.go +14 -0
- package/lib/languages/go/templates/module/{{pkg}}.go +15 -0
- package/lib/languages/go/templates/module/{{pkg}}_test.go +13 -0
- package/lib/languages/go/templates/port/internal/adapters/in_memory_{{portSnake}}.go +10 -0
- package/lib/languages/go/templates/port/internal/ports/{{portSnake}}.go +5 -0
- package/lib/languages/go/templates/port/{{portSnake}}_contract_test.go +20 -0
- package/lib/languages/index.js +66 -0
- package/lib/languages/python/environment.js +15 -0
- package/lib/languages/python/index.js +35 -0
- package/lib/languages/python/mutate.js +72 -0
- package/lib/languages/python/package.js +16 -0
- package/lib/languages/python/read.js +81 -0
- package/lib/languages/python/read_project.py +145 -0
- package/lib/languages/python/scaffold.js +19 -0
- package/lib/languages/python/templates/module/__init__.py +3 -0
- package/lib/languages/python/templates/module/shell/__init__.py +0 -0
- package/lib/languages/python/templates/module/shell/create.py +16 -0
- package/lib/languages/python/templates/module/tests/__init__.py +0 -0
- package/lib/languages/python/templates/module/tests/test_{{snake}}.py +5 -0
- package/lib/languages/python/templates/port/adapters/in_memory_{{portSnake}}.py +9 -0
- package/lib/languages/python/templates/port/ports/{{portSnake}}.py +5 -0
- package/lib/languages/python/templates/port/tests/test_{{portSnake}}_contract.py +11 -0
- package/lib/languages/typescript/index.js +37 -0
- package/lib/languages/typescript/mutate.js +95 -0
- package/lib/languages/typescript/project.js +24 -0
- package/lib/languages/typescript/read.js +220 -0
- package/lib/languages/typescript/scaffold.js +60 -0
- package/lib/languages/typescript/templates/module/index.ts +2 -0
- package/lib/languages/typescript/templates/module/shell/create{{Name}}.ts +8 -0
- package/lib/languages/typescript/templates/module-test.node.ts +7 -0
- package/lib/languages/typescript/templates/module-test.vitest.ts +6 -0
- package/lib/languages/typescript/templates/port/adapters/inMemory{{Port}}.ts +5 -0
- package/lib/languages/typescript/templates/port/ports/{{Port}}.ts +1 -0
- package/lib/languages/typescript/templates/port-test.node.ts +12 -0
- package/lib/languages/typescript/templates/port-test.vitest.ts +12 -0
- package/lib/languages/typescript/templates/view-module/__tests__/{{name}}.test.tsx +9 -0
- package/lib/languages/typescript/templates/view-module/core/model.ts +25 -0
- package/lib/languages/typescript/templates/view-module/index.ts +4 -0
- package/lib/languages/typescript/templates/view-module/shell/create{{Name}}Model.ts +20 -0
- package/lib/languages/typescript/templates/view-module/view/{{Name}}Page.tsx +14 -0
- package/lib/modules.js +104 -0
- package/lib/mutation.js +124 -0
- package/lib/place.js +25 -0
- package/lib/report.js +42 -0
- package/lib/rules.js +145 -0
- package/lib/scaffold.js +57 -0
- package/lib/scans.js +62 -0
- package/lib/templating.js +28 -0
- package/lib/tools.js +17 -0
- package/manifest.schema.json +35 -0
- package/package.json +38 -4
- package/spec/adopting.md +70 -0
- package/spec/backend.md +176 -0
- package/spec/common.md +163 -0
- package/spec/frontend.md +181 -0
- package/spec/roles.md +93 -0
- package/spec/tests.md +146 -0
- 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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
+
}
|