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,33 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { toPosix } from "../files.js";
|
|
4
|
+
import { moduleLookup, placeOf } from "../place.js";
|
|
5
|
+
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
/** C11: clones whose two copies sit in different modules, found by jscpd over every module's source, tests left out. */
|
|
9
|
+
export async function findCrossModuleClones(root, modules, config) {
|
|
10
|
+
if (modules.length < 2) return [];
|
|
11
|
+
const counted = modules.flatMap((module) => module.files.filter((file) => !placeOf(module, file).test));
|
|
12
|
+
if (counted.length === 0) return [];
|
|
13
|
+
const { detectClones } = require("jscpd");
|
|
14
|
+
const clones = await detectClones({
|
|
15
|
+
path: counted.map((file) => path.join(root, file)),
|
|
16
|
+
minLines: config.clones.minLines,
|
|
17
|
+
silent: true,
|
|
18
|
+
reporters: [],
|
|
19
|
+
});
|
|
20
|
+
const moduleOf = moduleLookup(modules);
|
|
21
|
+
const where = (duplication) => toPosix(path.relative(root, duplication.sourceId));
|
|
22
|
+
return clones
|
|
23
|
+
.map((clone) => ({
|
|
24
|
+
a: { file: where(clone.duplicationA), line: clone.duplicationA.start.line },
|
|
25
|
+
b: { file: where(clone.duplicationB), line: clone.duplicationB.start.line },
|
|
26
|
+
lines: clone.duplicationA.end.line - clone.duplicationA.start.line + 1,
|
|
27
|
+
}))
|
|
28
|
+
.filter((clone) => {
|
|
29
|
+
const first = moduleOf(clone.a.file);
|
|
30
|
+
const second = moduleOf(clone.b.file);
|
|
31
|
+
return first && second && first !== second;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CONFIG_FILE } from "../config.js";
|
|
2
|
+
import { moduleLookup, PURE_PARTS, placeOf } from "../place.js";
|
|
3
|
+
import { roleRuleBroken } from "./manifests.js";
|
|
4
|
+
|
|
5
|
+
/** Whether `name` is on `list`, where an entry ending in "/" matches a whole scope. */
|
|
6
|
+
function matches(list, name) {
|
|
7
|
+
return list.some((entry) => (entry.endsWith("/") ? name.startsWith(entry) : name === entry));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* "ui", "io", "pure" or "unlisted". The standard library is a fixed set, so each language names the part of
|
|
12
|
+
* it that touches the outside or draws. Third-party packages are an open set, so the project lists the ones
|
|
13
|
+
* it trusts, and any other counts as input/output: a new driver nobody listed still fails.
|
|
14
|
+
*/
|
|
15
|
+
function kindOf(edge, language, config) {
|
|
16
|
+
if (edge.std) {
|
|
17
|
+
if (matches(language.stdlib.ui, edge.external)) return "ui";
|
|
18
|
+
return matches(language.stdlib.io, edge.external) ? "io" : "pure";
|
|
19
|
+
}
|
|
20
|
+
if (matches(config.ui[language.name] ?? [], edge.external)) return "ui";
|
|
21
|
+
return matches(config.pure[language.name] ?? [], edge.external) ? "pure" : "unlisted";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function checkExternal(edge, source, place, config) {
|
|
25
|
+
if (!source || place.test || source.role === "app") return null;
|
|
26
|
+
const kind = kindOf(edge, source.language, config);
|
|
27
|
+
if (kind === "ui") return place.part === "view" ? null : { id: "F1", detail: `imports ${edge.external}` };
|
|
28
|
+
if (kind === "pure" || place.part === "adapters") return null;
|
|
29
|
+
const lists = place.part === "view" ? "pure or ui" : "pure";
|
|
30
|
+
const why = kind === "unlisted" ? `, which ${CONFIG_FILE} does not list under ${lists}` : "";
|
|
31
|
+
return { id: PURE_PARTS[place.part] ?? "B3", detail: `imports ${edge.external}${why}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What each pure part may not import from its own module. */
|
|
35
|
+
const BEHIND_PURE_PART = { core: ["shell", "adapters"], view: ["shell", "ports", "adapters"] };
|
|
36
|
+
|
|
37
|
+
function checkInside(module, place, targetPlace) {
|
|
38
|
+
if (place.test) {
|
|
39
|
+
const allowed = targetPlace.door || targetPlace.test || (place.contractTest && ["ports", "adapters"].includes(targetPlace.part));
|
|
40
|
+
return allowed ? null : { id: "T1", detail: `imports ${targetPlace.inner}, behind ${module.name}'s door` };
|
|
41
|
+
}
|
|
42
|
+
if (BEHIND_PURE_PART[place.part]?.includes(targetPlace.part)) {
|
|
43
|
+
return { id: PURE_PARTS[place.part], detail: `imports ${targetPlace.inner}` };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkAcross(edge, source, place, target, targetPlace) {
|
|
49
|
+
const found = [];
|
|
50
|
+
if (!targetPlace.door) found.push({ id: "C3", detail: `imports ${edge.to}, behind ${target.name}'s door` });
|
|
51
|
+
if (!source) return found;
|
|
52
|
+
const roleRule = roleRuleBroken(source, target);
|
|
53
|
+
if (roleRule) found.push({ id: roleRule, detail: `imports ${target.name}, a ${target.role} module` });
|
|
54
|
+
else if (!source.dependsOn.includes(target.name)) {
|
|
55
|
+
found.push({ id: "C5", detail: `imports ${target.name}, which ${source.name} does not declare` });
|
|
56
|
+
}
|
|
57
|
+
// An import erased before the code runs names the module's types but loads none of its adapters.
|
|
58
|
+
if (PURE_PARTS[place.part] && !place.test && !edge.typeOnly && target.parts.has("adapters")) {
|
|
59
|
+
found.push({ id: PURE_PARTS[place.part], detail: `imports ${target.name}, which carries its adapters` });
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* C3, C5, R1–R3, B1, B3, F1, F2 and T1 over the import edges the language tools read.
|
|
66
|
+
* An edge is { from, line, to, typeOnly } for a project file, or { from, line, external, std } for a package.
|
|
67
|
+
*/
|
|
68
|
+
export function checkImports({ modules, edges, config, analyzedLanguages }) {
|
|
69
|
+
const moduleOf = moduleLookup(modules);
|
|
70
|
+
const used = new Map(modules.map((module) => [module.name, new Set()]));
|
|
71
|
+
const problems = [];
|
|
72
|
+
for (const edge of edges) {
|
|
73
|
+
const source = moduleOf(edge.from);
|
|
74
|
+
const place = source ? placeOf(source, edge.from) : null;
|
|
75
|
+
const at = (found) => ({ ...found, file: edge.from, line: edge.line ?? null });
|
|
76
|
+
if (edge.external) {
|
|
77
|
+
const found = checkExternal(edge, source, place, config);
|
|
78
|
+
if (found) problems.push(at(found));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const target = moduleOf(edge.to);
|
|
82
|
+
if (!target) {
|
|
83
|
+
if (source && !place.test) problems.push(at({ id: "C5", detail: `imports ${edge.to}, which belongs to no module` }));
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const targetPlace = placeOf(target, edge.to);
|
|
87
|
+
if (source === target) {
|
|
88
|
+
const found = checkInside(source, place, targetPlace);
|
|
89
|
+
if (found) problems.push(at(found));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (source) used.get(source.name).add(target.name);
|
|
93
|
+
problems.push(...checkAcross(edge, source, place, target, targetPlace).map(at));
|
|
94
|
+
}
|
|
95
|
+
for (const module of modules.filter((candidate) => analyzedLanguages.has(candidate.language))) {
|
|
96
|
+
for (const name of module.dependsOn.filter((declared) => !used.get(module.name).has(declared))) {
|
|
97
|
+
problems.push({ id: "C5", file: module.depsFile, line: null, detail: `declares ${name}, which nothing in ${module.name} imports` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return problems;
|
|
101
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const MAY_IMPORT = { app: ["feature", "shared"], feature: ["shared"], shared: ["shared"] };
|
|
2
|
+
const ROLE_RULE = { app: "R3", feature: "R1", shared: "R2" };
|
|
3
|
+
|
|
4
|
+
/** The role rule `from` breaks by depending on `to`, or null. */
|
|
5
|
+
export function roleRuleBroken(from, to) {
|
|
6
|
+
return MAY_IMPORT[from.role].includes(to.role) ? null : ROLE_RULE[from.role];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function declaredCycles(modules, byName) {
|
|
10
|
+
const cycles = [];
|
|
11
|
+
const state = new Map();
|
|
12
|
+
const stack = [];
|
|
13
|
+
const visit = (module) => {
|
|
14
|
+
state.set(module.name, "open");
|
|
15
|
+
stack.push(module.name);
|
|
16
|
+
for (const name of module.dependsOn) {
|
|
17
|
+
if (state.get(name) === "open") cycles.push([...stack.slice(stack.indexOf(name)), name]);
|
|
18
|
+
else if (!state.has(name)) visit(byName.get(name));
|
|
19
|
+
}
|
|
20
|
+
stack.pop();
|
|
21
|
+
state.set(module.name, "done");
|
|
22
|
+
};
|
|
23
|
+
for (const module of modules) if (!state.has(module.name)) visit(module);
|
|
24
|
+
return cycles;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** R1–R3, C6 and C10 on what the manifests declare, before any import is read. */
|
|
28
|
+
export function checkManifests(modules) {
|
|
29
|
+
const byName = new Map(modules.map((module) => [module.name, module]));
|
|
30
|
+
const problems = [];
|
|
31
|
+
for (const module of modules) {
|
|
32
|
+
for (const name of module.dependsOn) {
|
|
33
|
+
const rule = roleRuleBroken(module, byName.get(name));
|
|
34
|
+
if (rule) problems.push({ id: rule, file: module.depsFile, detail: `declares ${name}, a ${byName.get(name).role} module` });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (const cycle of declaredCycles(modules, byName)) {
|
|
38
|
+
problems.push({ id: "C6", file: byName.get(cycle[0]).depsFile, detail: `declares a cycle: ${cycle.join(" -> ")}` });
|
|
39
|
+
}
|
|
40
|
+
const owners = new Map();
|
|
41
|
+
for (const module of modules) {
|
|
42
|
+
for (const table of module.ownsTables) owners.set(table, [...(owners.get(table) ?? []), module]);
|
|
43
|
+
}
|
|
44
|
+
for (const [table, tableOwners] of owners) {
|
|
45
|
+
if (tableOwners.length < 2) continue;
|
|
46
|
+
for (const owner of tableOwners) {
|
|
47
|
+
const others = tableOwners.filter((other) => other !== owner).map((other) => other.name);
|
|
48
|
+
problems.push({ id: "C10", file: owner.manifestPath, detail: `owns table ${table}, which ${others.join(", ")} also owns` });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return problems;
|
|
52
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { placeOf } from "../place.js";
|
|
3
|
+
|
|
4
|
+
/** T3: every port in ports/ has a contract test, and that test runs every adapter that implements the port. */
|
|
5
|
+
export function checkPorts(modules, edges) {
|
|
6
|
+
const importsOf = new Map();
|
|
7
|
+
for (const edge of edges.filter((candidate) => candidate.to)) {
|
|
8
|
+
importsOf.set(edge.from, (importsOf.get(edge.from) ?? new Set()).add(edge.to));
|
|
9
|
+
}
|
|
10
|
+
const imports = (from, to) => importsOf.get(from)?.has(to) ?? false;
|
|
11
|
+
const problems = [];
|
|
12
|
+
for (const module of modules.filter((candidate) => candidate.parts.has("ports"))) {
|
|
13
|
+
const places = module.files.map((file) => ({ file, place: placeOf(module, file) }));
|
|
14
|
+
const adapters = places.filter(({ place }) => place.part === "adapters" && !place.test).map(({ file }) => file);
|
|
15
|
+
for (const { file, place } of places) {
|
|
16
|
+
if (place.part !== "ports" || place.test) continue;
|
|
17
|
+
const port = path.posix.basename(file);
|
|
18
|
+
const expected = module.language.contractTestFor(port);
|
|
19
|
+
if (!expected) continue;
|
|
20
|
+
const contractTest = places.find((candidate) => candidate.place.contractTest && path.posix.basename(candidate.file) === expected)?.file;
|
|
21
|
+
if (!contractTest) {
|
|
22
|
+
problems.push({ id: "T3", file, line: null, detail: `port ${port} has no contract test ${expected}` });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// An adapter implements a port by importing it. The contract test must then run that adapter too.
|
|
26
|
+
for (const adapter of adapters.filter((candidate) => imports(candidate, file) && !imports(contractTest, candidate))) {
|
|
27
|
+
problems.push({ id: "T3", file: adapter, line: null, detail: `implements ${port}, but ${expected} never runs it` });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return problems;
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { placeOf } from "../place.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Upper-case SQL that writes a table. Upper case only, so an `update()` call or the word in prose never
|
|
7
|
+
* matches. An upsert's `DO UPDATE SET` and `ON DUPLICATE KEY UPDATE` name columns, not tables.
|
|
8
|
+
*/
|
|
9
|
+
const SQL_WRITE =
|
|
10
|
+
/\b(?:INSERT\s+(?:OR\s+[A-Z]+\s+)?INTO|(?<!KEY\s)(?<!DO\s)UPDATE|DELETE\s+FROM|REPLACE\s+INTO|MERGE\s+INTO)\s+(?!SET\b)[`"[]?([A-Za-z_][\w.]*)/g;
|
|
11
|
+
|
|
12
|
+
function lineAt(text, index) {
|
|
13
|
+
return text.slice(0, index).split("\n").length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** C10: every table a module writes is one it owns. */
|
|
17
|
+
export function checkTables(root, modules) {
|
|
18
|
+
const ownerOf = new Map(modules.flatMap((module) => module.ownsTables.map((table) => [table, module])));
|
|
19
|
+
const problems = [];
|
|
20
|
+
for (const module of modules) {
|
|
21
|
+
for (const file of module.files.filter((candidate) => !placeOf(module, candidate).test)) {
|
|
22
|
+
const text = fs.readFileSync(path.join(root, file), "utf8");
|
|
23
|
+
for (const match of text.matchAll(SQL_WRITE)) {
|
|
24
|
+
const table = match[1].split(".").at(-1);
|
|
25
|
+
const owner = ownerOf.get(table);
|
|
26
|
+
if (owner === module) continue;
|
|
27
|
+
problems.push({
|
|
28
|
+
id: "C10",
|
|
29
|
+
file,
|
|
30
|
+
line: lineAt(text, match.index),
|
|
31
|
+
detail: owner ? `writes table ${table}, which ${owner.name} owns` : `writes table ${table}, which no module owns`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return problems;
|
|
37
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { LANGUAGES } from "./languages/index.js";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE = "module-spec.json";
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
roots: ["."],
|
|
9
|
+
maxComplexity: 5,
|
|
10
|
+
pure: Object.fromEntries(LANGUAGES.map((language) => [language.name, []])),
|
|
11
|
+
ui: Object.fromEntries(LANGUAGES.map((language) => [language.name, []])),
|
|
12
|
+
clones: { minLines: 6 },
|
|
13
|
+
...Object.fromEntries(
|
|
14
|
+
LANGUAGES.filter((language) => Object.keys(language.configDefaults).length > 0).map((language) => [language.name, language.configDefaults]),
|
|
15
|
+
),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Keys whose value merges key by key with the default, instead of replacing it. */
|
|
19
|
+
const MERGED = ["pure", "ui", "clones", ...LANGUAGES.map((language) => language.name)];
|
|
20
|
+
|
|
21
|
+
/** Keys a release retired, so an old module-spec.json fails loudly instead of being ignored. */
|
|
22
|
+
const RETIRED = {
|
|
23
|
+
shellComplexity: "shellComplexity is now maxComplexity, and it caps view/ functions too. Rename the key.",
|
|
24
|
+
impure: "impure is gone. Every package not listed under pure counts as input/output, so delete the key.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function ownConfig(root) {
|
|
28
|
+
const file = path.join(root, CONFIG_FILE);
|
|
29
|
+
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The project's module-spec.json merged over the defaults. Every key is optional. */
|
|
33
|
+
export function loadConfig(root) {
|
|
34
|
+
const own = ownConfig(root);
|
|
35
|
+
for (const [key, message] of Object.entries(RETIRED)) {
|
|
36
|
+
if (key in own) throw new Error(`${CONFIG_FILE}: ${message}`);
|
|
37
|
+
}
|
|
38
|
+
const merged = { ...DEFAULTS, ...own };
|
|
39
|
+
for (const key of MERGED) merged[key] = { ...DEFAULTS[key], ...own[key] };
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Adds `names` to the project's `key` list for `language` ("pure" or "ui"), creating the file when needed. Returns whether it wrote. */
|
|
44
|
+
export function listPackages(root, key, language, names) {
|
|
45
|
+
const own = ownConfig(root);
|
|
46
|
+
const listed = own[key]?.[language] ?? [];
|
|
47
|
+
const missing = names.filter((name) => !listed.includes(name));
|
|
48
|
+
if (missing.length === 0) return false;
|
|
49
|
+
own[key] = { ...own[key], [language]: [...listed, ...missing] };
|
|
50
|
+
fs.writeFileSync(path.join(root, CONFIG_FILE), `${JSON.stringify(own, null, 2)}\n`);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function defaultConfig() {
|
|
55
|
+
return structuredClone(DEFAULTS);
|
|
56
|
+
}
|
package/lib/files.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
const NEVER_WALKED = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
".git",
|
|
8
|
+
"dist",
|
|
9
|
+
"build",
|
|
10
|
+
"coverage",
|
|
11
|
+
"vendor",
|
|
12
|
+
".venv",
|
|
13
|
+
"venv",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
".mypy_cache",
|
|
16
|
+
".ruff_cache",
|
|
17
|
+
".pytest_cache",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function toPosix(filePath) {
|
|
21
|
+
return filePath.split(path.sep).join("/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every file under `dir`, as a posix path relative to `root`, skipping dependency and build folders. */
|
|
25
|
+
export function walkFiles(root, dir = ".") {
|
|
26
|
+
const found = [];
|
|
27
|
+
const visit = (relativeDir) => {
|
|
28
|
+
for (const entry of fs.readdirSync(path.join(root, relativeDir), { withFileTypes: true })) {
|
|
29
|
+
const relativePath = relativeDir === "." ? entry.name : `${relativeDir}/${entry.name}`;
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
if (!NEVER_WALKED.has(entry.name)) visit(relativePath);
|
|
32
|
+
} else if (entry.isFile()) {
|
|
33
|
+
found.push(relativePath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
visit(toPosix(dir));
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readIfExists(filePath) {
|
|
42
|
+
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A throwaway copy of the project, for a tool that writes beside the code it reads. Only the files `keep`
|
|
47
|
+
* accepts are copied, and dependency and build folders never are. The caller removes it.
|
|
48
|
+
*/
|
|
49
|
+
export function scratchCopy(root, keep = () => true) {
|
|
50
|
+
const copy = fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-scratch-"));
|
|
51
|
+
for (const file of walkFiles(root).filter(keep)) {
|
|
52
|
+
fs.mkdirSync(path.dirname(path.join(copy, file)), { recursive: true });
|
|
53
|
+
fs.copyFileSync(path.join(root, file), path.join(copy, file));
|
|
54
|
+
}
|
|
55
|
+
return copy;
|
|
56
|
+
}
|
|
@@ -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 } from "./scaffold.js";
|
|
6
|
+
|
|
7
|
+
const isGo = (name) => name.endsWith(".go");
|
|
8
|
+
const isGoTest = (name) => name.endsWith("_test.go");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Go's door is the package at the module root, and its parts sit under internal/, so the compiler
|
|
12
|
+
* refuses a deep import as well as the kit. Tests sit at the module root in the external _test package.
|
|
13
|
+
*
|
|
14
|
+
* @type {import("../index.js").Language}
|
|
15
|
+
*/
|
|
16
|
+
export default {
|
|
17
|
+
name: "go",
|
|
18
|
+
aliases: ["go"],
|
|
19
|
+
door: "a .go file at the module root",
|
|
20
|
+
isDoor: (inner) => !inner.includes("/") && isGo(inner) && !isGoTest(inner),
|
|
21
|
+
partsDir: "internal",
|
|
22
|
+
isSource: isGo,
|
|
23
|
+
isTest: (segments) => isGoTest(segments.at(-1)),
|
|
24
|
+
isContractTest: (name) => name.endsWith("_contract_test.go"),
|
|
25
|
+
contractTestFor: (file) => (file === "doc.go" ? null : `${file.replace(/\.go$/, "")}_contract_test.go`),
|
|
26
|
+
stdlib: {
|
|
27
|
+
io: ["os", "os/", "net", "net/", "syscall", "database/sql", "io/ioutil", "log/syslog", "plugin"],
|
|
28
|
+
ui: [],
|
|
29
|
+
},
|
|
30
|
+
viewUi: [],
|
|
31
|
+
configDefaults: {},
|
|
32
|
+
detect: (root) => fs.existsSync(path.join(root, "go.mod")),
|
|
33
|
+
analyze,
|
|
34
|
+
mutate,
|
|
35
|
+
newModule,
|
|
36
|
+
newPort,
|
|
37
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { scratchCopy } from "../../files.js";
|
|
6
|
+
|
|
7
|
+
/** Gremlins, pinned, run through `go run` so the go toolchain is the only install. */
|
|
8
|
+
const GREMLINS = "github.com/go-gremlins/gremlins/cmd/gremlins@v0.6.0";
|
|
9
|
+
const OUTPUT_LIMIT = 512 * 1024 * 1024;
|
|
10
|
+
/** Gremlins' statuses: a timeout counts as caught, and a mutant no test reaches counts as live. */
|
|
11
|
+
const CAUGHT = new Set(["KILLED", "TIMED OUT"]);
|
|
12
|
+
const LIVE = new Set(["LIVED", "NOT COVERED"]);
|
|
13
|
+
|
|
14
|
+
const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Mutation testing by gremlins, in a scratch copy that keeps only this module's tests. Gremlins runs
|
|
18
|
+
* `go test ./...` for each mutant (its integration mode), because a module's tests sit at its door, not
|
|
19
|
+
* beside the internal package a mutant changes. With other tests gone, only this module's tests judge it.
|
|
20
|
+
*/
|
|
21
|
+
export async function mutate({ root, module, files, tests }) {
|
|
22
|
+
const own = new Set(tests);
|
|
23
|
+
const copy = scratchCopy(root, (file) => !file.endsWith("_test.go") || own.has(file));
|
|
24
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "module-spec-gremlins-"));
|
|
25
|
+
try {
|
|
26
|
+
const targets = new Set(files);
|
|
27
|
+
const excluded = module.files.filter((file) => !targets.has(file) && !own.has(file));
|
|
28
|
+
const report = path.join(work, "report.json");
|
|
29
|
+
const run = spawnSync(
|
|
30
|
+
"go",
|
|
31
|
+
[
|
|
32
|
+
"run", GREMLINS, "unleash", "--integration", "--invert-logical", "--silent",
|
|
33
|
+
"--coverpkg", `./${module.dir}/...`, "--output", report,
|
|
34
|
+
...excluded.flatMap((file) => ["--exclude-files", `(^|/)${escaped(file.slice(module.dir.length + 1))}$`]),
|
|
35
|
+
`./${module.dir}`,
|
|
36
|
+
],
|
|
37
|
+
{ cwd: copy, encoding: "utf8", maxBuffer: OUTPUT_LIMIT },
|
|
38
|
+
);
|
|
39
|
+
if (run.error || run.status !== 0 || !fs.existsSync(report)) {
|
|
40
|
+
throw new Error(`gremlins failed; Go modules need the go toolchain on the PATH:\n${run.stderr || run.stdout || run.error}`);
|
|
41
|
+
}
|
|
42
|
+
const mutations = JSON.parse(fs.readFileSync(report, "utf8")).files.flatMap((file) =>
|
|
43
|
+
file.mutations.map((mutation) => ({ ...mutation, file: `${module.dir}/${file.file_name}` })),
|
|
44
|
+
);
|
|
45
|
+
const sourceLine = (file, line) => fs.readFileSync(path.join(root, file), "utf8").split("\n")[line - 1].trim();
|
|
46
|
+
return {
|
|
47
|
+
killed: mutations.filter((mutation) => CAUGHT.has(mutation.status)).length,
|
|
48
|
+
survivors: mutations
|
|
49
|
+
.filter((mutation) => LIVE.has(mutation.status))
|
|
50
|
+
.map((mutation) => ({ file: mutation.file, line: mutation.line, detail: `${mutation.type}: ${sourceLine(mutation.file, mutation.line)}` })),
|
|
51
|
+
};
|
|
52
|
+
} finally {
|
|
53
|
+
fs.rmSync(copy, { recursive: true, force: true });
|
|
54
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const READ_PROJECT = path.join(path.dirname(fileURLToPath(import.meta.url)), "read_project.go");
|
|
7
|
+
const OUTPUT_LIMIT = 512 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** Libraries that patch functions or variables in place: Go's form of a mock that patches (T2). */
|
|
10
|
+
const PATCHERS = ["github.com/agiledragon/gomonkey", "github.com/agiledragon/gomonkey/", "bou.ke/monkey", "github.com/undefinedlabs/go-mpatch", "github.com/prashantv/gostub"];
|
|
11
|
+
const patches = (name) => PATCHERS.some((entry) => (entry.endsWith("/") ? name.startsWith(entry) : name === entry));
|
|
12
|
+
|
|
13
|
+
/** @type {import("../index.js").Language["analyze"]} */
|
|
14
|
+
export async function analyze({ modules, root, plan }) {
|
|
15
|
+
const request = {
|
|
16
|
+
projectRoot: root,
|
|
17
|
+
files: modules.flatMap((module) => module.files),
|
|
18
|
+
testFiles: plan.patch,
|
|
19
|
+
doorDirs: modules.map((module) => module.dir),
|
|
20
|
+
plan,
|
|
21
|
+
};
|
|
22
|
+
// Run outside the project, so its go.mod and toolchain line never apply to the kit's own reader.
|
|
23
|
+
const run = spawnSync("go", ["run", READ_PROJECT], {
|
|
24
|
+
cwd: os.tmpdir(),
|
|
25
|
+
input: JSON.stringify(request),
|
|
26
|
+
encoding: "utf8",
|
|
27
|
+
maxBuffer: OUTPUT_LIMIT,
|
|
28
|
+
});
|
|
29
|
+
if (run.error || run.status !== 0) throw new Error(`reading the Go modules failed; Go modules need the go toolchain on the PATH:\n${run.stderr ?? run.error}`);
|
|
30
|
+
const read = JSON.parse(run.stdout);
|
|
31
|
+
const tests = new Set(plan.patch);
|
|
32
|
+
const sightings = [
|
|
33
|
+
...read.sightings,
|
|
34
|
+
...read.edges
|
|
35
|
+
.filter((edge) => edge.external && tests.has(edge.from) && patches(edge.external))
|
|
36
|
+
.map((edge) => ({ scan: "patch", file: edge.from, line: edge.line, detail: `patches with ${edge.external}` })),
|
|
37
|
+
];
|
|
38
|
+
const api = new Map(modules.map((module) => [module.name, read.exports[module.dir] ?? []]));
|
|
39
|
+
return { edges: read.edges, sightings, findings: read.whiteBox.map((found) => ({ ...found, id: "T1" })), api };
|
|
40
|
+
}
|