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,220 @@
|
|
|
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 ts from "typescript";
|
|
6
|
+
import { toPosix } from "../../files.js";
|
|
7
|
+
import { kitPackageDir } from "../../tools.js";
|
|
8
|
+
|
|
9
|
+
const NOT_A_PACKAGE = /^(\.|\/|#)/;
|
|
10
|
+
const OUTPUT_LIMIT = 512 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function packageNameOf(specifier) {
|
|
13
|
+
const parts = specifier.split("/");
|
|
14
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cruiseConfig(root) {
|
|
18
|
+
const options = {
|
|
19
|
+
doNotFollow: { path: "node_modules" },
|
|
20
|
+
exclude: { path: "(^|/)(node_modules|dist|build|coverage)/" },
|
|
21
|
+
tsPreCompilationDeps: true,
|
|
22
|
+
enhancedResolveOptions: {
|
|
23
|
+
exportsFields: ["exports"],
|
|
24
|
+
conditionNames: ["import", "require", "node", "default", "types"],
|
|
25
|
+
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
if (fs.existsSync(path.join(root, "tsconfig.json"))) options.tsConfig = { fileName: "tsconfig.json" };
|
|
29
|
+
return { options };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The specifiers a file names only in `import type` or `export type` statements. Those leave no import behind
|
|
34
|
+
* when the code runs. `import { type T }` is not among them: Node still loads that module, only empty.
|
|
35
|
+
*/
|
|
36
|
+
function erasedSpecifiers(root, file) {
|
|
37
|
+
const source = ts.createSourceFile(file, fs.readFileSync(path.join(root, file), "utf8"), ts.ScriptTarget.Latest);
|
|
38
|
+
const erased = new Set();
|
|
39
|
+
const loaded = new Set();
|
|
40
|
+
for (const statement of source.statements) {
|
|
41
|
+
const isImport = ts.isImportDeclaration(statement);
|
|
42
|
+
if ((!isImport && !ts.isExportDeclaration(statement)) || !statement.moduleSpecifier) continue;
|
|
43
|
+
const typeOnly = isImport ? statement.importClause?.isTypeOnly : statement.isTypeOnly;
|
|
44
|
+
(typeOnly ? erased : loaded).add(statement.moduleSpecifier.text);
|
|
45
|
+
}
|
|
46
|
+
return new Set([...erased].filter((specifier) => !loaded.has(specifier)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Import edges, read by the kit's own dependency-cruiser. */
|
|
50
|
+
function readEdges(root, dirs) {
|
|
51
|
+
const dir = kitPackageDir("dependency-cruiser");
|
|
52
|
+
const bin = path.join(dir, JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8")).bin.depcruise);
|
|
53
|
+
const configFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-")), "cruise.json");
|
|
54
|
+
fs.writeFileSync(configFile, JSON.stringify(cruiseConfig(root)));
|
|
55
|
+
const run = spawnSync(process.execPath, [bin, ...dirs, "--config", configFile, "--output-type", "json"], {
|
|
56
|
+
cwd: root,
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
maxBuffer: OUTPUT_LIMIT,
|
|
59
|
+
});
|
|
60
|
+
fs.rmSync(path.dirname(configFile), { recursive: true, force: true });
|
|
61
|
+
if (run.status !== 0) throw new Error(`dependency-cruiser failed:\n${run.stderr || run.stdout}`);
|
|
62
|
+
const edges = [];
|
|
63
|
+
for (const file of JSON.parse(run.stdout).modules) {
|
|
64
|
+
if (file.source.includes("node_modules/")) continue;
|
|
65
|
+
let erased = null;
|
|
66
|
+
for (const dependency of file.dependencies) {
|
|
67
|
+
const from = toPosix(file.source);
|
|
68
|
+
if (dependency.coreModule) {
|
|
69
|
+
edges.push({ from, external: dependency.module.replace(/^node:/, ""), std: true });
|
|
70
|
+
} else if (dependency.couldNotResolve || dependency.resolved.includes("node_modules/")) {
|
|
71
|
+
if (!NOT_A_PACKAGE.test(dependency.module)) edges.push({ from, external: packageNameOf(dependency.module), std: false });
|
|
72
|
+
} else {
|
|
73
|
+
const typeOnly = (erased ??= erasedSpecifiers(root, from)).has(dependency.module);
|
|
74
|
+
edges.push({ from, to: toPosix(dependency.resolved), ...(typeOnly && { typeOnly }) });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return edges;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The syntax each scan in the plan looks for. Each message names one scan, so a sighting maps back to it. */
|
|
82
|
+
const SYNTAX = {
|
|
83
|
+
wait: [
|
|
84
|
+
"FunctionDeclaration[async=true]",
|
|
85
|
+
"FunctionExpression[async=true]",
|
|
86
|
+
"ArrowFunctionExpression[async=true]",
|
|
87
|
+
"AwaitExpression",
|
|
88
|
+
"ForOfStatement[await=true]",
|
|
89
|
+
].map((selector) => ({ selector, message: "waits" })),
|
|
90
|
+
// Globals that reach the outside without an import, so the import check cannot see them.
|
|
91
|
+
io: [
|
|
92
|
+
...["fetch", "WebSocket", "XMLHttpRequest", "EventSource"].map((name) => ({
|
|
93
|
+
selector: `:matches(CallExpression, NewExpression)[callee.type='Identifier'][callee.name='${name}']`,
|
|
94
|
+
message: `uses ${name}`,
|
|
95
|
+
})),
|
|
96
|
+
...["localStorage", "sessionStorage", "indexedDB"].map((name) => ({
|
|
97
|
+
selector: `MemberExpression[object.type='Identifier'][object.name='${name}']`,
|
|
98
|
+
message: `uses ${name}`,
|
|
99
|
+
})),
|
|
100
|
+
],
|
|
101
|
+
env: [{ selector: "MemberExpression[object.type='MetaProperty'][property.name='env']", message: "reads import.meta.env" }],
|
|
102
|
+
// JSX needs no import of the UI framework under the automatic runtime, so the import check cannot see it.
|
|
103
|
+
ui: [{ selector: ":matches(JSXElement, JSXFragment)", message: "draws JSX outside view/" }],
|
|
104
|
+
// Calls that patch what a module imports or holds, instead of passing a fake in: vitest, jest and node:test.
|
|
105
|
+
patch: [
|
|
106
|
+
...["vi", "jest"].flatMap((runner) =>
|
|
107
|
+
["mock", "doMock", "unstable_mockModule", "spyOn", "stubGlobal"].map((call) => ({
|
|
108
|
+
selector: `CallExpression[callee.object.name='${runner}'][callee.property.name='${call}']`,
|
|
109
|
+
message: `patches with ${runner}.${call}`,
|
|
110
|
+
})),
|
|
111
|
+
),
|
|
112
|
+
...["method", "module", "getter", "setter", "property"].flatMap((call) => [
|
|
113
|
+
{ selector: `CallExpression[callee.object.name='mock'][callee.property.name='${call}']`, message: `patches with mock.${call}` },
|
|
114
|
+
{ selector: `CallExpression[callee.object.property.name='mock'][callee.property.name='${call}']`, message: `patches with mock.${call}` },
|
|
115
|
+
]),
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const SCAN_OF_MESSAGE = new Map(Object.entries(SYNTAX).flatMap(([scan, selectors]) => selectors.map(({ message }) => [message, scan])));
|
|
120
|
+
|
|
121
|
+
const GLOB_SPECIAL = /[[\]{}()*?!+@]/g;
|
|
122
|
+
const asGlob = (file) => file.replace(GLOB_SPECIAL, "\\$&");
|
|
123
|
+
|
|
124
|
+
/** One no-restricted-syntax entry per set of scans, since a later entry for the same rule replaces an earlier one's options. */
|
|
125
|
+
function syntaxEntries(plan) {
|
|
126
|
+
const scansOf = new Map();
|
|
127
|
+
for (const scan of Object.keys(SYNTAX)) {
|
|
128
|
+
for (const file of plan[scan]) scansOf.set(file, [...(scansOf.get(file) ?? []), scan]);
|
|
129
|
+
}
|
|
130
|
+
return [...Map.groupBy(scansOf, ([, scans]) => scans.join(" "))].map(([scans, entries]) => ({
|
|
131
|
+
files: entries.map(([file]) => asGlob(file)),
|
|
132
|
+
rules: { "no-restricted-syntax": ["error", ...scans.split(" ").flatMap((scan) => SYNTAX[scan])] },
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** What ESLint sees for each scan in the plan. */
|
|
137
|
+
async function lintSightings(root, plan) {
|
|
138
|
+
const all = [...new Set([...Object.keys(SYNTAX).flatMap((scan) => plan[scan]), ...plan.complexity])];
|
|
139
|
+
if (all.length === 0) return [];
|
|
140
|
+
const { ESLint } = await import("eslint");
|
|
141
|
+
const parser = await import("@typescript-eslint/parser");
|
|
142
|
+
const configs = [
|
|
143
|
+
{ files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], languageOptions: { parser } },
|
|
144
|
+
...syntaxEntries(plan),
|
|
145
|
+
{ files: plan.complexity.map(asGlob), rules: { complexity: ["error", { max: plan.maxComplexity }] } },
|
|
146
|
+
{
|
|
147
|
+
files: plan.env.map(asGlob),
|
|
148
|
+
rules: { "no-restricted-properties": ["error", { object: "process", property: "env", message: "reads process.env" }] },
|
|
149
|
+
},
|
|
150
|
+
].filter((entry) => entry.files.length > 0);
|
|
151
|
+
const linter = new ESLint({ cwd: root, overrideConfigFile: true, overrideConfig: configs, ignore: false });
|
|
152
|
+
const results = await linter.lintFiles(all);
|
|
153
|
+
const unreadable = results.filter((result) => result.messages.some((message) => message.fatal));
|
|
154
|
+
if (unreadable.length > 0) {
|
|
155
|
+
throw new Error(`ESLint could not parse ${unreadable.map((result) => path.relative(root, result.filePath)).join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
const seen = {
|
|
158
|
+
"no-restricted-syntax": (message) => ({ scan: SCAN_OF_MESSAGE.get(message.message), detail: message.message }),
|
|
159
|
+
complexity: (message) => ({ scan: "complexity", detail: message.message }),
|
|
160
|
+
"no-restricted-properties": () => ({ scan: "env", detail: "reads process.env" }),
|
|
161
|
+
};
|
|
162
|
+
return results.flatMap((result) => {
|
|
163
|
+
const file = toPosix(path.relative(root, result.filePath));
|
|
164
|
+
return result.messages
|
|
165
|
+
.filter((message) => seen[message.ruleId])
|
|
166
|
+
.map((message) => ({ ...seen[message.ruleId](message), file, line: message.line }));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function compilerOptions(ts, root) {
|
|
171
|
+
const tsconfig = path.join(root, "tsconfig.json");
|
|
172
|
+
if (fs.existsSync(tsconfig)) {
|
|
173
|
+
const read = ts.readConfigFile(tsconfig, ts.sys.readFile);
|
|
174
|
+
return ts.parseJsonConfigFileContent(read.config, ts.sys, root).options;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
allowImportingTsExtensions: true,
|
|
178
|
+
noEmit: true,
|
|
179
|
+
module: ts.ModuleKind.NodeNext,
|
|
180
|
+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
|
181
|
+
target: ts.ScriptTarget.ESNext,
|
|
182
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
183
|
+
strict: true,
|
|
184
|
+
skipLibCheck: true,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function exportLine(ts, checker, sourceFile, exported) {
|
|
189
|
+
const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
|
|
190
|
+
if (!(symbol.flags & ts.SymbolFlags.Value)) return `type ${exported.name}`;
|
|
191
|
+
const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0] ?? sourceFile;
|
|
192
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
|
|
193
|
+
return `${exported.name}: ${checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Each door's exports with their types, one line each, read by the TypeScript compiler. */
|
|
197
|
+
async function doorExports(root, modules) {
|
|
198
|
+
const { default: ts } = await import("typescript");
|
|
199
|
+
const doorOf = (module) => path.join(root, module.dir, module.language.door);
|
|
200
|
+
const program = ts.createProgram(modules.map(doorOf), compilerOptions(ts, root));
|
|
201
|
+
const checker = program.getTypeChecker();
|
|
202
|
+
const api = new Map();
|
|
203
|
+
for (const module of modules) {
|
|
204
|
+
const sourceFile = program.getSourceFile(doorOf(module));
|
|
205
|
+
const symbol = sourceFile && checker.getSymbolAtLocation(sourceFile);
|
|
206
|
+
const lines = symbol ? checker.getExportsOfModule(symbol).map((exported) => exportLine(ts, checker, sourceFile, exported)) : [];
|
|
207
|
+
api.set(module.name, lines.sort());
|
|
208
|
+
}
|
|
209
|
+
return api;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @type {import("../index.js").Language["analyze"]} */
|
|
213
|
+
export async function analyze({ root, config, modules, plan }) {
|
|
214
|
+
return {
|
|
215
|
+
edges: readEdges(root, config.roots),
|
|
216
|
+
sightings: await lintSightings(root, plan),
|
|
217
|
+
findings: [],
|
|
218
|
+
api: await doorExports(root, modules),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readIfExists } from "../../files.js";
|
|
5
|
+
import { copyTemplates, fill, writeNew } from "../../templating.js";
|
|
6
|
+
import { dependenciesOf, testRunnerOf } from "./project.js";
|
|
7
|
+
|
|
8
|
+
const TEMPLATES = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates");
|
|
9
|
+
|
|
10
|
+
function testTemplate(root, kind) {
|
|
11
|
+
return fs.readFileSync(path.join(TEMPLATES, `${kind}-test.${testRunnerOf(root)}.ts`), "utf8");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A sibling module's package.json, when the modules beside this one are workspace packages. */
|
|
15
|
+
function siblingPackage(root, dir) {
|
|
16
|
+
const parent = path.join(root, path.dirname(dir));
|
|
17
|
+
if (!fs.existsSync(parent)) return null;
|
|
18
|
+
for (const entry of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
19
|
+
const text = entry.isDirectory() && readIfExists(path.join(parent, entry.name, "package.json"));
|
|
20
|
+
if (text) return JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A package.json for the new module, when the modules beside it are workspace packages. */
|
|
26
|
+
function writeWorkspacePackage(root, dir, values, written) {
|
|
27
|
+
const sibling = siblingPackage(root, dir);
|
|
28
|
+
if (!sibling) return;
|
|
29
|
+
const scope = sibling.name?.startsWith("@") ? `${sibling.name.split("/")[0]}/` : "";
|
|
30
|
+
const packageJson = { name: `${scope}${values.name}`, private: true, type: "module", exports: { ".": "./index.ts" } };
|
|
31
|
+
writeNew(root, path.join(dir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, written);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function newModule(root, dir, values) {
|
|
35
|
+
const written = [];
|
|
36
|
+
copyTemplates(root, path.join(TEMPLATES, "module"), dir, values, written);
|
|
37
|
+
writeNew(root, path.join(dir, "__tests__", `${values.name}.test.ts`), fill(testTemplate(root, "module"), values), written);
|
|
38
|
+
writeWorkspacePackage(root, dir, values, written);
|
|
39
|
+
return written;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A module with a React view/ and a vitest test, in the shape of spec/frontend.md. */
|
|
43
|
+
export function newViewModule(root, dir, values) {
|
|
44
|
+
const dependencies = dependenciesOf(root);
|
|
45
|
+
const missing = ["react", "react-dom", "vitest"].filter((name) => !dependencies[name]);
|
|
46
|
+
if (missing.length > 0) {
|
|
47
|
+
throw new Error(`new-module --view writes a React view with a vitest test: add ${missing.join(", ")} to package.json first`);
|
|
48
|
+
}
|
|
49
|
+
const written = [];
|
|
50
|
+
copyTemplates(root, path.join(TEMPLATES, "view-module"), dir, values, written);
|
|
51
|
+
writeWorkspacePackage(root, dir, values, written);
|
|
52
|
+
return written;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function newPort(root, dir, values) {
|
|
56
|
+
const written = [];
|
|
57
|
+
copyTemplates(root, path.join(TEMPLATES, "port"), dir, values, written);
|
|
58
|
+
writeNew(root, path.join(dir, "__tests__", `${values.Port}.contract.test.ts`), fill(testTemplate(root, "port"), values), written);
|
|
59
|
+
return written;
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export interface {{Port}} {}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe } from "node:test";
|
|
2
|
+
import { inMemory{{Port}} } from "../adapters/inMemory{{Port}}.ts";
|
|
3
|
+
import type { {{Port}} } from "../ports/{{Port}}.ts";
|
|
4
|
+
|
|
5
|
+
/** One test per promise {{Port}} makes. Every adapter runs this same suite, so the fake cannot drift from the real one. */
|
|
6
|
+
export function {{portCamel}}Contract(make: () => {{Port}}): void {
|
|
7
|
+
void make;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("{{Port}} contract: in-memory fake", () => {
|
|
11
|
+
{{portCamel}}Contract(inMemory{{Port}});
|
|
12
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe } from "vitest";
|
|
2
|
+
import { inMemory{{Port}} } from "../adapters/inMemory{{Port}}.ts";
|
|
3
|
+
import type { {{Port}} } from "../ports/{{Port}}.ts";
|
|
4
|
+
|
|
5
|
+
/** One test per promise {{Port}} makes. Every adapter runs this same suite, so the fake cannot drift from the real one. */
|
|
6
|
+
export function {{portCamel}}Contract(make: () => {{Port}}): void {
|
|
7
|
+
void make;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("{{Port}} contract: in-memory fake", () => {
|
|
11
|
+
{{portCamel}}Contract(inMemory{{Port}});
|
|
12
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
import { create{{Name}}Model, {{Name}}Page } from "../index.ts";
|
|
4
|
+
|
|
5
|
+
test("{{name}} shows it is ready once started", () => {
|
|
6
|
+
const model = create{{Name}}Model({});
|
|
7
|
+
model.send({ type: "started" });
|
|
8
|
+
expect(renderToStaticMarkup(<{{Name}}Page model={model} />)).toContain("Ready");
|
|
9
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** What {{name}} shows. The view draws it, and only update() changes it. */
|
|
2
|
+
export interface {{Name}}State {
|
|
3
|
+
readonly ready: boolean;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** What can happen on the screen. */
|
|
7
|
+
export type {{Name}}Event = { type: "started" };
|
|
8
|
+
|
|
9
|
+
/** What the view reads and sends events to. The shell builds it, and the view knows only this type. */
|
|
10
|
+
export interface {{Name}}Model {
|
|
11
|
+
state(): {{Name}}State;
|
|
12
|
+
subscribe(listener: () => void): () => void;
|
|
13
|
+
send(event: {{Name}}Event): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const initial{{Name}}State: {{Name}}State = { ready: false };
|
|
17
|
+
|
|
18
|
+
export function update(state: {{Name}}State, event: {{Name}}Event): {{Name}}State {
|
|
19
|
+
switch (event.type) {
|
|
20
|
+
case "started":
|
|
21
|
+
return { ...state, ready: true };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const statusOf = (state: {{Name}}State): string => (state.ready ? "Ready" : "Not started");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type {{Name}}Model, type {{Name}}State, initial{{Name}}State, update } from "../core/model.ts";
|
|
2
|
+
|
|
3
|
+
export type {{Name}}Deps = Record<string, never>;
|
|
4
|
+
|
|
5
|
+
export function create{{Name}}Model(deps: {{Name}}Deps): {{Name}}Model {
|
|
6
|
+
void deps;
|
|
7
|
+
let state: {{Name}}State = initial{{Name}}State;
|
|
8
|
+
const listeners = new Set<() => void>();
|
|
9
|
+
return {
|
|
10
|
+
state: () => state,
|
|
11
|
+
subscribe(listener) {
|
|
12
|
+
listeners.add(listener);
|
|
13
|
+
return () => void listeners.delete(listener);
|
|
14
|
+
},
|
|
15
|
+
send(event) {
|
|
16
|
+
state = update(state, event);
|
|
17
|
+
for (const listener of listeners) listener();
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { type {{Name}}Model, statusOf } from "../core/model.ts";
|
|
3
|
+
|
|
4
|
+
export function {{Name}}Page({ model }: { model: {{Name}}Model }) {
|
|
5
|
+
const state = useSyncExternalStore(model.subscribe, model.state, model.state);
|
|
6
|
+
return (
|
|
7
|
+
<section>
|
|
8
|
+
<p>{statusOf(state)}</p>
|
|
9
|
+
<button type="button" onClick={() => model.send({ type: "started" })}>
|
|
10
|
+
Start
|
|
11
|
+
</button>
|
|
12
|
+
</section>
|
|
13
|
+
);
|
|
14
|
+
}
|
package/lib/modules.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { readIfExists, walkFiles } from "./files.js";
|
|
6
|
+
import { doorNames, languageOfDir } from "./languages/index.js";
|
|
7
|
+
import { PARTS } from "./place.js";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const Ajv2020 = require("ajv/dist/2020.js");
|
|
11
|
+
const SCHEMA = require("../manifest.schema.json");
|
|
12
|
+
|
|
13
|
+
export const MANIFEST_FILE = "module.yaml";
|
|
14
|
+
|
|
15
|
+
const validateManifest = new Ajv2020({ allErrors: true }).compile(SCHEMA);
|
|
16
|
+
|
|
17
|
+
function describeSchemaError(error, data) {
|
|
18
|
+
if (error.instancePath !== "/owns") return `${error.instancePath || "module.yaml"} ${error.message}`;
|
|
19
|
+
if (error.keyword === "not") return `owns "${data.owns}" names an activity, not a decision`;
|
|
20
|
+
if (error.keyword === "pattern") return "owns spans more than one line";
|
|
21
|
+
return `owns ${error.message}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function schemaProblems(manifestPath, data) {
|
|
25
|
+
if (validateManifest(data)) return [];
|
|
26
|
+
return validateManifest.errors.map((error) => ({
|
|
27
|
+
id: error.instancePath === "/owns" ? "C2" : "C1",
|
|
28
|
+
file: manifestPath,
|
|
29
|
+
detail: describeSchemaError(error, data),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readModule(root, manifestPath, problems) {
|
|
34
|
+
const dir = path.posix.dirname(manifestPath);
|
|
35
|
+
let data;
|
|
36
|
+
try {
|
|
37
|
+
data = YAML.parse(fs.readFileSync(path.join(root, manifestPath), "utf8")) ?? {};
|
|
38
|
+
} catch (error) {
|
|
39
|
+
problems.push({ id: "C1", file: manifestPath, detail: `is not valid YAML: ${error.message.split("\n")[0]}` });
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const found = schemaProblems(manifestPath, data);
|
|
43
|
+
problems.push(...found);
|
|
44
|
+
if (found.some((problem) => problem.id === "C1")) return null;
|
|
45
|
+
|
|
46
|
+
const language = languageOfDir(path.join(root, dir));
|
|
47
|
+
if (!language) {
|
|
48
|
+
problems.push({ id: "C1", file: manifestPath, detail: `has no door: add ${doorNames()}` });
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const packageText = readIfExists(path.join(root, dir, "package.json"));
|
|
52
|
+
const packageJson = packageText ? JSON.parse(packageText) : null;
|
|
53
|
+
if (packageJson && data.depends_on) {
|
|
54
|
+
problems.push({
|
|
55
|
+
id: "C5",
|
|
56
|
+
file: manifestPath,
|
|
57
|
+
detail: "lists depends_on, but package.json already lists this module's dependencies: keep them only in package.json",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const files = walkFiles(root, dir).filter((file) => language.isSource(file));
|
|
61
|
+
return {
|
|
62
|
+
name: packageJson?.name ?? path.posix.basename(dir),
|
|
63
|
+
dir,
|
|
64
|
+
role: data.role,
|
|
65
|
+
owns: data.owns,
|
|
66
|
+
language,
|
|
67
|
+
manifestPath,
|
|
68
|
+
depsFile: packageJson ? `${dir}/package.json` : manifestPath,
|
|
69
|
+
declared: packageJson
|
|
70
|
+
? Object.keys({ ...packageJson.dependencies, ...packageJson.devDependencies, ...packageJson.peerDependencies })
|
|
71
|
+
: (data.depends_on ?? []),
|
|
72
|
+
ownsTables: data.owns_tables ?? [],
|
|
73
|
+
parts: new Set(PARTS.filter((part) => fs.existsSync(path.join(root, dir, language.partsDir, part)))),
|
|
74
|
+
files,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every module under the configured roots, and every problem found reading their manifests. */
|
|
79
|
+
export function discoverModules(root, config) {
|
|
80
|
+
const problems = [];
|
|
81
|
+
const manifests = [...new Set(config.roots.flatMap((dir) => walkFiles(root, dir)))]
|
|
82
|
+
.filter((file) => path.posix.basename(file) === MANIFEST_FILE)
|
|
83
|
+
.sort();
|
|
84
|
+
const read = manifests.map((manifestPath) => readModule(root, manifestPath, problems)).filter(Boolean);
|
|
85
|
+
|
|
86
|
+
const byName = new Map();
|
|
87
|
+
for (const module of read) {
|
|
88
|
+
const twin = byName.get(module.name);
|
|
89
|
+
if (twin) problems.push({ id: "C1", file: module.manifestPath, detail: `has the same name as ${twin.manifestPath}: ${module.name}` });
|
|
90
|
+
else byName.set(module.name, module);
|
|
91
|
+
}
|
|
92
|
+
const modules = [...byName.values()];
|
|
93
|
+
for (const module of modules) {
|
|
94
|
+
const outer = modules.find((other) => other !== module && module.dir.startsWith(`${other.dir}/`));
|
|
95
|
+
if (outer) problems.push({ id: "C1", file: module.manifestPath, detail: `sits inside module ${outer.name}: modules do not nest` });
|
|
96
|
+
module.dependsOn = module.declared.filter((name) => byName.has(name));
|
|
97
|
+
if (module.depsFile === module.manifestPath) {
|
|
98
|
+
for (const name of module.declared.filter((declared) => !byName.has(declared))) {
|
|
99
|
+
problems.push({ id: "C5", file: module.manifestPath, detail: `depends_on lists ${name}, which is no module` });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { modules, problems };
|
|
104
|
+
}
|
package/lib/mutation.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { affectedModules, changedFiles } from "./affected.js";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { readIfExists } from "./files.js";
|
|
6
|
+
import { discoverModules } from "./modules.js";
|
|
7
|
+
import { placeOf } from "./place.js";
|
|
8
|
+
|
|
9
|
+
export const MUTATION_REPORT = "mutation-report.txt";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The parts whose code is mutated, beside the door. Never ports, which hold only types, adapters, which contract
|
|
13
|
+
* tests prove against the real service, which may not run here, a view, which holds no rules (F3), or a file in
|
|
14
|
+
* no part, such as an app's composition root or a tool's config.
|
|
15
|
+
*/
|
|
16
|
+
const MUTATED = new Set(["core", "shell"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The files mutation testing changes in a module, and the module's own tests, which must notice. An adapter's test
|
|
20
|
+
* proves the adapter against the outside and is slow, while the code it reaches is proven through the door, so it
|
|
21
|
+
* does not count.
|
|
22
|
+
*/
|
|
23
|
+
export function mutationTargets(module) {
|
|
24
|
+
const files = [];
|
|
25
|
+
const tests = [];
|
|
26
|
+
for (const file of module.files) {
|
|
27
|
+
const place = placeOf(module, file);
|
|
28
|
+
if (place.test) {
|
|
29
|
+
if (place.part !== "adapters" && !place.contractTest) tests.push(file);
|
|
30
|
+
}
|
|
31
|
+
else if (place.door || MUTATED.has(place.part)) files.push(file);
|
|
32
|
+
}
|
|
33
|
+
return { files, tests };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const lineOf = (survivor) => `${survivor.file}:${survivor.line} ${survivor.detail}`;
|
|
37
|
+
/** A survivor's identity, without its line, so code that moves keeps its entry. */
|
|
38
|
+
const keyOf = (survivor) => `${survivor.file} ${survivor.detail}`;
|
|
39
|
+
const SURVIVOR_LINE = /^(\S+):(\d+) (.*)$/;
|
|
40
|
+
|
|
41
|
+
/** The live mutants a module's report accepts, none when it has no report. */
|
|
42
|
+
function acceptedSurvivors(root, module) {
|
|
43
|
+
const text = readIfExists(path.join(root, module.dir, MUTATION_REPORT));
|
|
44
|
+
return (text ?? "")
|
|
45
|
+
.split("\n")
|
|
46
|
+
.map((line) => SURVIVOR_LINE.exec(line))
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map(([, file, line, detail]) => ({ file, line: Number(line), detail }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function reportText(module, accepted) {
|
|
52
|
+
return [
|
|
53
|
+
`# ${module.name}: live mutants accepted because no test can tell them apart from the code. Review each one.`,
|
|
54
|
+
"# `module-spec mutate --accept` rewrites this file.",
|
|
55
|
+
"",
|
|
56
|
+
...accepted.map(lineOf),
|
|
57
|
+
"",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a run stands: every live mutant must be one the report accepts, unless this run accepts them all.
|
|
63
|
+
* `fresh` names the live mutants the report does not accept, each accepted entry matching one mutant.
|
|
64
|
+
*/
|
|
65
|
+
export function judge(accepted, survivors, accept) {
|
|
66
|
+
if (accept) return { failed: false, fresh: [] };
|
|
67
|
+
const left = new Map();
|
|
68
|
+
for (const entry of accepted) left.set(keyOf(entry), (left.get(keyOf(entry)) ?? 0) + 1);
|
|
69
|
+
const fresh = survivors.filter((survivor) => {
|
|
70
|
+
const count = left.get(keyOf(survivor)) ?? 0;
|
|
71
|
+
left.set(keyOf(survivor), count - 1);
|
|
72
|
+
return count === 0;
|
|
73
|
+
});
|
|
74
|
+
return { failed: fresh.length > 0, fresh };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Rewrites the report to the mutants that live now, all of them accepted, or removes it when none live. */
|
|
78
|
+
function writeReport(root, module, survivors) {
|
|
79
|
+
const report = path.join(root, module.dir, MUTATION_REPORT);
|
|
80
|
+
const before = readIfExists(report);
|
|
81
|
+
if (survivors.length === 0) {
|
|
82
|
+
if (before !== null) fs.rmSync(report);
|
|
83
|
+
return before !== null;
|
|
84
|
+
}
|
|
85
|
+
const text = reportText(module, survivors);
|
|
86
|
+
if (before === text) return false;
|
|
87
|
+
fs.writeFileSync(report, text);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function mutateOne(root, config, module, accept) {
|
|
92
|
+
const { files, tests } = mutationTargets(module);
|
|
93
|
+
if (files.length === 0) return { module, reason: "nothing to mutate" };
|
|
94
|
+
if (tests.length === 0) return { module, failed: true, reason: "has code to mutate but no tests" };
|
|
95
|
+
const run = await module.language.mutate({ root, config, module, files, tests });
|
|
96
|
+
const inModule = (file) => file.slice(module.dir.length + 1);
|
|
97
|
+
const survivors = run.survivors
|
|
98
|
+
.map((survivor) => ({ ...survivor, file: inModule(survivor.file) }))
|
|
99
|
+
.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.detail.localeCompare(b.detail));
|
|
100
|
+
const verdict = judge(acceptedSurvivors(root, module), survivors, accept);
|
|
101
|
+
const wrote = !verdict.failed && writeReport(root, module, survivors);
|
|
102
|
+
return { module, killed: run.killed, survivors, wrote, ...verdict };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function moduleAt(byDir, dir) {
|
|
106
|
+
const module = byDir.get(dir.replace(/\/+$/, ""));
|
|
107
|
+
if (!module) throw new Error(`${dir} is not a module folder`);
|
|
108
|
+
return module;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Mutation testing, one module at a time with only its own tests: the modules in `dirs`, or else the modules
|
|
113
|
+
* a change since `since` touched. Not their dependents: a dependent's code and tests are unchanged, so its
|
|
114
|
+
* live mutants are too. A module passes with no live mutant its mutation-report.txt does not accept.
|
|
115
|
+
*/
|
|
116
|
+
export async function mutateModules(root, { dirs = [], since, accept = false }) {
|
|
117
|
+
const config = loadConfig(root);
|
|
118
|
+
const { modules } = discoverModules(root, config);
|
|
119
|
+
const byDir = new Map(modules.map((module) => [module.dir, module]));
|
|
120
|
+
const chosen = dirs.length > 0 ? dirs.map((dir) => moduleAt(byDir, dir)) : affectedModules(modules, changedFiles(root, since)).changed;
|
|
121
|
+
const results = [];
|
|
122
|
+
for (const module of chosen) results.push(await mutateOne(root, config, module, accept));
|
|
123
|
+
return results;
|
|
124
|
+
}
|