module-spec 0.0.0-stage โ 0.1.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 +90 -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 +83 -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 +118 -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 +144 -0
- package/spec/workflow.md +50 -0
package/lib/place.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const PARTS = ["core", "shell", "view", "adapters", "ports"];
|
|
2
|
+
|
|
3
|
+
/** The parts that never touch the outside, with the rule each breaks when it does. */
|
|
4
|
+
export const PURE_PARTS = { core: "B1", view: "F2" };
|
|
5
|
+
|
|
6
|
+
/** A function that finds the module a file belongs to, or null. */
|
|
7
|
+
export function moduleLookup(modules) {
|
|
8
|
+
const deepestFirst = [...modules].sort((a, b) => b.dir.length - a.dir.length);
|
|
9
|
+
return (file) => deepestFirst.find((module) => file.startsWith(`${module.dir}/`)) ?? null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Where a file sits inside its module: its part, whether it is a test, a contract test or the door. */
|
|
13
|
+
export function placeOf(module, file) {
|
|
14
|
+
const inner = file.slice(module.dir.length + 1);
|
|
15
|
+
const segments = inner.split("/");
|
|
16
|
+
const { partsDir } = module.language;
|
|
17
|
+
const underParts = !partsDir ? segments : segments[0] === partsDir ? segments.slice(1) : [];
|
|
18
|
+
return {
|
|
19
|
+
inner,
|
|
20
|
+
part: underParts.length > 1 && PARTS.includes(underParts[0]) ? underParts[0] : null,
|
|
21
|
+
test: module.language.isTest(segments),
|
|
22
|
+
contractTest: module.language.isContractTest(segments.at(-1)),
|
|
23
|
+
door: module.language.isDoor(inner),
|
|
24
|
+
};
|
|
25
|
+
}
|
package/lib/report.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { moduleLookup } from "./place.js";
|
|
3
|
+
|
|
4
|
+
const COMMITS_READ = 500;
|
|
5
|
+
const SWEEP = 6;
|
|
6
|
+
const TOP_PAIRS = 5;
|
|
7
|
+
|
|
8
|
+
/** Which modules each recent commit touched, from git history. */
|
|
9
|
+
function commitsByModule(root, modules) {
|
|
10
|
+
const log = spawnSync("git", ["log", `-n${COMMITS_READ}`, "--name-only", "--format=@@%H"], { cwd: root, encoding: "utf8" });
|
|
11
|
+
if (log.status !== 0) return [];
|
|
12
|
+
const moduleOf = moduleLookup(modules);
|
|
13
|
+
return log.stdout
|
|
14
|
+
.split("@@")
|
|
15
|
+
.slice(1)
|
|
16
|
+
.map((commit) => new Set(commit.split("\n").slice(1).filter(Boolean).map(moduleOf).filter(Boolean).map((module) => module.name)))
|
|
17
|
+
.filter((touched) => touched.size > 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A map for people, never enforced: how often a commit stays in one module, which modules change together, and fan-in. */
|
|
21
|
+
export function moduleMap(root, modules) {
|
|
22
|
+
const commits = commitsByModule(root, modules);
|
|
23
|
+
const pairs = new Map();
|
|
24
|
+
for (const touched of commits.filter((names) => names.size > 1 && names.size < SWEEP)) {
|
|
25
|
+
const names = [...touched].sort();
|
|
26
|
+
for (let first = 0; first < names.length; first += 1) {
|
|
27
|
+
for (let second = first + 1; second < names.length; second += 1) {
|
|
28
|
+
const pair = `${names[first]} + ${names[second]}`;
|
|
29
|
+
pairs.set(pair, (pairs.get(pair) ?? 0) + 1);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const fanIn = new Map(modules.map((module) => [module.name, 0]));
|
|
34
|
+
for (const module of modules) for (const name of module.dependsOn) fanIn.set(name, fanIn.get(name) + 1);
|
|
35
|
+
return {
|
|
36
|
+
commits: commits.length,
|
|
37
|
+
singleModule: commits.filter((touched) => touched.size === 1).length,
|
|
38
|
+
sweeps: commits.filter((touched) => touched.size >= SWEEP).length,
|
|
39
|
+
topPairs: [...pairs].sort((a, b) => b[1] - a[1]).slice(0, TOP_PAIRS),
|
|
40
|
+
fanIn: [...fanIn].filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1]),
|
|
41
|
+
};
|
|
42
|
+
}
|
package/lib/rules.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export const SPEC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "spec");
|
|
5
|
+
|
|
6
|
+
/** Every rule a check can report. The heading named by `anchor` in `spec/<doc>` holds the why. */
|
|
7
|
+
export const RULES = {
|
|
8
|
+
C1: {
|
|
9
|
+
doc: "common.md",
|
|
10
|
+
anchor: "c1-every-module-has-a-manifest",
|
|
11
|
+
title: "Every module has a valid manifest and a door",
|
|
12
|
+
fix: "Make module.yaml match manifest.schema.json, and give the module the door its language expects.",
|
|
13
|
+
},
|
|
14
|
+
C2: {
|
|
15
|
+
doc: "common.md",
|
|
16
|
+
anchor: "c2-owns-names-a-decision-not-an-activity",
|
|
17
|
+
title: "`owns` names a decision, not an activity",
|
|
18
|
+
fix: "Rewrite owns as the one decision only this module makes, in one line, such as 'how an engagement's fee is computed'.",
|
|
19
|
+
},
|
|
20
|
+
C3: {
|
|
21
|
+
doc: "common.md",
|
|
22
|
+
anchor: "c3-one-door",
|
|
23
|
+
title: "Other code reaches a module only through its door",
|
|
24
|
+
fix: "Import the module's door, and export what you need from there.",
|
|
25
|
+
},
|
|
26
|
+
C4: {
|
|
27
|
+
doc: "common.md",
|
|
28
|
+
anchor: "c4-the-door-stays-small",
|
|
29
|
+
title: "The door's exports match api-report.txt",
|
|
30
|
+
fix: "Run `module-spec api`, then review the diff of api-report.txt: export only what callers need.",
|
|
31
|
+
},
|
|
32
|
+
C5: {
|
|
33
|
+
doc: "common.md",
|
|
34
|
+
anchor: "c5-dependencies-are-declared",
|
|
35
|
+
title: "A module imports exactly the modules it declares",
|
|
36
|
+
fix: "Declare the module (depends_on, or package.json dependencies when the module has a package.json), or remove the import. Remove declared modules nothing imports.",
|
|
37
|
+
},
|
|
38
|
+
C6: {
|
|
39
|
+
doc: "common.md",
|
|
40
|
+
anchor: "c6-no-cycles",
|
|
41
|
+
title: "No cycles between modules",
|
|
42
|
+
fix: "Move what both sides need into a shared module, or let the app pass one side in through createX(deps).",
|
|
43
|
+
},
|
|
44
|
+
C8: {
|
|
45
|
+
doc: "common.md",
|
|
46
|
+
anchor: "c8-one-composition-root-wires-everything",
|
|
47
|
+
title: "Only an app reads the environment",
|
|
48
|
+
fix: "Read the value in the app's composition root and pass it in through createX(deps).",
|
|
49
|
+
},
|
|
50
|
+
C10: {
|
|
51
|
+
doc: "common.md",
|
|
52
|
+
anchor: "c10-only-the-owner-writes-its-tables",
|
|
53
|
+
title: "Only the owning module writes a table",
|
|
54
|
+
fix: "Write the table through its owner's door. If this module owns it, list it in owns_tables, and in no other module's.",
|
|
55
|
+
},
|
|
56
|
+
C11: {
|
|
57
|
+
doc: "common.md",
|
|
58
|
+
anchor: "c11-cross-module-clones-only-go-down",
|
|
59
|
+
title: "Cross-module clones only go down",
|
|
60
|
+
fix: "Give the copied code one owner: move it into a shared module, or let the app pass one module's door to the other.",
|
|
61
|
+
},
|
|
62
|
+
R1: {
|
|
63
|
+
doc: "roles.md",
|
|
64
|
+
anchor: "r1-a-feature-imports-only-shared-modules",
|
|
65
|
+
title: "A feature imports only shared modules",
|
|
66
|
+
fix: "Declare the port you need as a type in this feature's core, and let the app pass the other feature's door in through createX(deps).",
|
|
67
|
+
},
|
|
68
|
+
R2: {
|
|
69
|
+
doc: "roles.md",
|
|
70
|
+
anchor: "r2-a-shared-module-imports-only-shared-modules",
|
|
71
|
+
title: "A shared module imports only shared modules",
|
|
72
|
+
fix: "Move the code this module needs into a shared module, or let the caller pass it in as an argument.",
|
|
73
|
+
},
|
|
74
|
+
R3: {
|
|
75
|
+
doc: "roles.md",
|
|
76
|
+
anchor: "r3-an-app-imports-no-other-app",
|
|
77
|
+
title: "An app imports no other app",
|
|
78
|
+
fix: "Move the code both apps need into a shared module and import it from both.",
|
|
79
|
+
},
|
|
80
|
+
B1: {
|
|
81
|
+
doc: "backend.md",
|
|
82
|
+
anchor: "b1-core-is-pure",
|
|
83
|
+
title: "Core is pure",
|
|
84
|
+
fix: "Move the input/output into adapters/ and pass the data into the core. If the core needs data partway through a decision, run `module-spec new-port`. A third-party package that touches nothing outside goes under pure in module-spec.json.",
|
|
85
|
+
},
|
|
86
|
+
B2: {
|
|
87
|
+
doc: "backend.md",
|
|
88
|
+
anchor: "b2-shell-is-thin",
|
|
89
|
+
title: "Shell is thin",
|
|
90
|
+
fix: "Move the decisions into core/ as a pure function. If the core needs data partway through a decision, run `module-spec new-port`.",
|
|
91
|
+
},
|
|
92
|
+
B3: {
|
|
93
|
+
doc: "backend.md",
|
|
94
|
+
anchor: "b3-only-adapters-touch-the-outside",
|
|
95
|
+
title: "Only adapters touch the outside",
|
|
96
|
+
fix: "Move the code that touches the database, network, files or framework into adapters/. A third-party package that touches nothing outside goes under pure in module-spec.json.",
|
|
97
|
+
},
|
|
98
|
+
F1: {
|
|
99
|
+
doc: "frontend.md",
|
|
100
|
+
anchor: "f1-only-view-knows-the-ui-framework",
|
|
101
|
+
title: "Only view/ knows the UI framework",
|
|
102
|
+
fix: "Move the code that draws into view/. Keep core/ and shell/ plain code, so a new framework or a mobile app rewrites only view/.",
|
|
103
|
+
},
|
|
104
|
+
F2: {
|
|
105
|
+
doc: "frontend.md",
|
|
106
|
+
anchor: "f2-a-view-only-renders",
|
|
107
|
+
title: "A view only renders",
|
|
108
|
+
fix: "Give the view its state and a send function as props, from a model the shell builds. Move loading, saving and fetching into the shell and its adapters. A package that draws goes under ui in module-spec.json, one that touches nothing outside under pure.",
|
|
109
|
+
},
|
|
110
|
+
F3: {
|
|
111
|
+
doc: "frontend.md",
|
|
112
|
+
anchor: "f3-a-view-holds-no-rules",
|
|
113
|
+
title: "A view holds no rules",
|
|
114
|
+
fix: "Move the decision into core/ as a pure function, such as a selector the view calls, and test it through the door.",
|
|
115
|
+
},
|
|
116
|
+
W1: {
|
|
117
|
+
doc: "workflow.md",
|
|
118
|
+
anchor: "w1-one-module-per-chat",
|
|
119
|
+
title: "A branch changes one module",
|
|
120
|
+
fix: "Split the branch: land one module's change, then the next, each against a door that already works (W2). An app only wires, so it may change beside one other module.",
|
|
121
|
+
},
|
|
122
|
+
T1: {
|
|
123
|
+
doc: "tests.md",
|
|
124
|
+
anchor: "t1-tests-reach-a-module-only-through-its-door",
|
|
125
|
+
title: "Tests reach a module only through its door",
|
|
126
|
+
fix: "Test through the module's door, with fakes passed in. Only a port's contract test may import its ports/ and adapters/.",
|
|
127
|
+
},
|
|
128
|
+
T2: {
|
|
129
|
+
doc: "tests.md",
|
|
130
|
+
anchor: "t2-tests-pass-fakes-in-never-patch",
|
|
131
|
+
title: "Tests pass fakes in, never patch",
|
|
132
|
+
fix: "Pass a fake in through createX(deps). If there is no way to pass it in, the module needs a port: run `module-spec new-port`.",
|
|
133
|
+
},
|
|
134
|
+
T3: {
|
|
135
|
+
doc: "tests.md",
|
|
136
|
+
anchor: "t3-every-port-has-one-contract-test",
|
|
137
|
+
title: "Every port has one contract test",
|
|
138
|
+
fix: "Add the contract test the message names: one suite that runs against the fake and the real adapter. `module-spec new-port` writes it.",
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export function specLink(id) {
|
|
143
|
+
const rule = RULES[id];
|
|
144
|
+
return `${path.join(SPEC_DIR, rule.doc)}#${rule.anchor}`;
|
|
145
|
+
}
|
package/lib/scaffold.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { runChecks } from "./check.js";
|
|
5
|
+
import { writeApiReports } from "./checks/api.js";
|
|
6
|
+
import { CONFIG_FILE, defaultConfig, listPackages } from "./config.js";
|
|
7
|
+
import { LANGUAGES, languageNamed, languageOfDir } from "./languages/index.js";
|
|
8
|
+
import { MANIFEST_FILE } from "./modules.js";
|
|
9
|
+
import { writeNew } from "./templating.js";
|
|
10
|
+
|
|
11
|
+
const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
|
|
12
|
+
const pascal = (name) => words(name).map((word) => word[0].toUpperCase() + word.slice(1)).join("");
|
|
13
|
+
const camel = (name) => pascal(name).replace(/^./, (first) => first.toLowerCase());
|
|
14
|
+
const snake = (name) => words(name).map((word) => word.toLowerCase()).join("_");
|
|
15
|
+
|
|
16
|
+
/** The language asked for, else the one the project's files show. Never a guess. */
|
|
17
|
+
function languageFor(root, requested) {
|
|
18
|
+
if (requested) return languageNamed(requested);
|
|
19
|
+
const found = LANGUAGES.find((language) => language.detect(root));
|
|
20
|
+
if (!found) throw new Error(`cannot tell this project's language: pass --lang ${LANGUAGES.flatMap((language) => language.aliases).join("|")}`);
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Writes the api-report.txt of the module at dir, so what a scaffold wrote passes C4 with no extra step. */
|
|
25
|
+
async function reportOn(root, dir, written) {
|
|
26
|
+
const { modules, api } = await runChecks(root);
|
|
27
|
+
const at = path.relative(root, path.resolve(root, dir)).split(path.sep).join("/");
|
|
28
|
+
return [...written, ...writeApiReports(root, modules.filter((module) => module.dir === at), api)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Writes a module: the manifest here, since it is the same in every language, and the code from the language's templates. */
|
|
32
|
+
export async function newModule(root, { dir, role, owns, lang, view = false }) {
|
|
33
|
+
if (!dir || !role || !owns) throw new Error('usage: module-spec new-module <dir> --role app|feature|shared --owns "the decision it owns" [--view]');
|
|
34
|
+
if (fs.existsSync(path.join(root, dir, MANIFEST_FILE))) throw new Error(`${dir} already has a ${MANIFEST_FILE}`);
|
|
35
|
+
const language = languageFor(root, lang);
|
|
36
|
+
const write = view ? language.newViewModule : language.newModule;
|
|
37
|
+
if (!write) throw new Error(`${language.name} has no view template yet: write view/ by hand, as spec/frontend.md shows`);
|
|
38
|
+
const name = path.basename(dir);
|
|
39
|
+
const written = write(root, dir, { role, owns, name, Name: pascal(name), snake: snake(name) });
|
|
40
|
+
writeNew(root, path.join(dir, MANIFEST_FILE), YAML.stringify({ role, owns }), written);
|
|
41
|
+
if (view && listPackages(root, "ui", language.name, language.viewUi)) written.push(CONFIG_FILE);
|
|
42
|
+
return reportOn(root, dir, written);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function newPort(root, { dir, port }) {
|
|
46
|
+
if (!dir || !port) throw new Error("usage: module-spec new-port <module dir> <PortName>");
|
|
47
|
+
if (!fs.existsSync(path.join(root, dir, MANIFEST_FILE))) throw new Error(`${dir} has no ${MANIFEST_FILE}: run module-spec new-module first`);
|
|
48
|
+
const language = languageOfDir(path.join(root, dir));
|
|
49
|
+
if (!language) throw new Error(`${dir} has no door, so its language is unknown`);
|
|
50
|
+
return reportOn(root, dir, language.newPort(root, dir, { Port: pascal(port), portCamel: camel(port), portSnake: snake(port) }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function initProject(root) {
|
|
54
|
+
const written = [];
|
|
55
|
+
writeNew(root, CONFIG_FILE, `${JSON.stringify(defaultConfig(), null, 2)}\n`, written);
|
|
56
|
+
return written;
|
|
57
|
+
}
|
package/lib/scans.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { moduleLookup, PURE_PARTS, placeOf } from "./place.js";
|
|
2
|
+
|
|
3
|
+
/** The detail of a wait in a core, the same in every language so a baseline entry survives a port to another. */
|
|
4
|
+
const WAITS = "async code: a core with no ports never waits";
|
|
5
|
+
|
|
6
|
+
/** The parts whose functions have a complexity cap, with the rule each breaks over it. */
|
|
7
|
+
const CAPPED_PARTS = { shell: "B2", view: "F3" };
|
|
8
|
+
|
|
9
|
+
/** The rule a sighting breaks, from the scan that saw it and the part its file sits in. */
|
|
10
|
+
const RULE_OF = {
|
|
11
|
+
wait: () => "B1",
|
|
12
|
+
io: (part) => PURE_PARTS[part],
|
|
13
|
+
complexity: (part) => CAPPED_PARTS[part],
|
|
14
|
+
env: () => "C8",
|
|
15
|
+
ui: () => "F1",
|
|
16
|
+
patch: () => "T2",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Which files each scan searches. The kit decides it here, once, so a language only runs its own tools
|
|
21
|
+
* over these lists and reports what it saw. A language skips a scan its tools have nothing for.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} ScanPlan
|
|
24
|
+
* @property {string[]} wait search for waiting (async, await): the core of a module with no ports/
|
|
25
|
+
* @property {string[]} io search for input/output globals (fetch, open): every core/ and view/
|
|
26
|
+
* @property {string[]} complexity search for functions over maxComplexity: every shell/ and view/
|
|
27
|
+
* @property {number} maxComplexity
|
|
28
|
+
* @property {string[]} env search for environment reads: every module but an app
|
|
29
|
+
* @property {string[]} ui search for UI syntax (JSX): outside view/, in every module but an app
|
|
30
|
+
* @property {string[]} patch search for patching: every test
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} Sighting what one scan saw: { scan: a ScanPlan key, file, line, detail }
|
|
33
|
+
*/
|
|
34
|
+
export function scanPlan(modules, config) {
|
|
35
|
+
const plan = { wait: [], io: [], complexity: [], maxComplexity: config.maxComplexity, env: [], ui: [], patch: [] };
|
|
36
|
+
for (const module of modules) {
|
|
37
|
+
for (const file of module.files) {
|
|
38
|
+
const { part, test } = placeOf(module, file);
|
|
39
|
+
if (test) {
|
|
40
|
+
plan.patch.push(file);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (part === "core" && !module.parts.has("ports")) plan.wait.push(file);
|
|
44
|
+
if (part in PURE_PARTS) plan.io.push(file);
|
|
45
|
+
if (part in CAPPED_PARTS) plan.complexity.push(file);
|
|
46
|
+
if (module.role !== "app") plan.env.push(file);
|
|
47
|
+
if (module.role !== "app" && part !== "view") plan.ui.push(file);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return plan;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Each sighting as a violation of the rule it breaks. */
|
|
54
|
+
export function findingsOf(sightings, modules) {
|
|
55
|
+
const moduleOf = moduleLookup(modules);
|
|
56
|
+
return sightings.map(({ scan, file, line, detail }) => ({
|
|
57
|
+
id: RULE_OF[scan](placeOf(moduleOf(file), file).part),
|
|
58
|
+
file,
|
|
59
|
+
line: line ?? null,
|
|
60
|
+
detail: scan === "wait" ? WAITS : detail,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { toPosix, walkFiles } from "./files.js";
|
|
4
|
+
|
|
5
|
+
/** `text` with each {{key}} replaced by values[key]. A key with no value is an error, never a blank. */
|
|
6
|
+
export function fill(text, values) {
|
|
7
|
+
return text.replace(/\{\{(\w+)\}\}/g, (placeholder, key) => {
|
|
8
|
+
if (!(key in values)) throw new Error(`template placeholder ${placeholder} has no value`);
|
|
9
|
+
return values[key];
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Writes `file` under root unless it exists, and records it in `written`. Never overwrites. */
|
|
14
|
+
export function writeNew(root, file, text, written) {
|
|
15
|
+
const target = path.join(root, file);
|
|
16
|
+
if (fs.existsSync(target)) return;
|
|
17
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
18
|
+
fs.writeFileSync(target, text);
|
|
19
|
+
written.push(toPosix(file));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Copies every file in templateDir into targetDir, filling placeholders in names and text. */
|
|
23
|
+
export function copyTemplates(root, templateDir, targetDir, values, written) {
|
|
24
|
+
for (const file of walkFiles(templateDir)) {
|
|
25
|
+
const text = fs.readFileSync(path.join(templateDir, file), "utf8");
|
|
26
|
+
writeNew(root, path.join(targetDir, fill(file, values)), fill(text, values), written);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const KIT_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The folder of one of this kit's own dependencies. The kit pins its TypeScript tools rather than borrowing
|
|
9
|
+
* the project's: a project on TypeScript 7 has no compiler API, and one pinned set behaves the same everywhere.
|
|
10
|
+
*/
|
|
11
|
+
export function kitPackageDir(name) {
|
|
12
|
+
for (let dir = KIT_ROOT; ; dir = path.dirname(dir)) {
|
|
13
|
+
const candidate = path.join(dir, "node_modules", name);
|
|
14
|
+
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
|
15
|
+
if (dir === path.dirname(dir)) throw new Error(`module-spec cannot find its dependency ${name}: reinstall module-spec`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "module-spec/manifest.schema.json",
|
|
4
|
+
"title": "module.yaml",
|
|
5
|
+
"description": "The manifest every module carries. See spec/common.md#c1-every-module-has-a-manifest.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["role", "owns"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"role": {
|
|
11
|
+
"enum": ["app", "feature", "shared"],
|
|
12
|
+
"description": "Which modules this one may import. See spec/roles.md."
|
|
13
|
+
},
|
|
14
|
+
"owns": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "The one decision only this module makes, in one line. Names a decision, not an activity. See spec/common.md#c2-owns-names-a-decision-not-an-activity.",
|
|
17
|
+
"pattern": "^[^\\n]+$",
|
|
18
|
+
"not": {
|
|
19
|
+
"pattern": "\\b([Hh]andles?|[Mm]anages?|[Uu]tils?|[Hh]elpers?|[Mm]isc|TODO)\\b"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"depends_on": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"description": "The modules this module may import. Leave it out when the module has a package.json: its dependencies say the same. See spec/common.md#c5-dependencies-are-declared.",
|
|
25
|
+
"items": { "type": "string", "minLength": 1 },
|
|
26
|
+
"uniqueItems": true
|
|
27
|
+
},
|
|
28
|
+
"owns_tables": {
|
|
29
|
+
"type": "array",
|
|
30
|
+
"description": "Tables only this module writes. Leave it out where there is no SQL. See spec/common.md#c10-only-the-owner-writes-its-tables.",
|
|
31
|
+
"items": { "type": "string", "minLength": 1 },
|
|
32
|
+
"uniqueItems": true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "module-spec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "How every project splits its code into modules, with the checks that enforce it. The why behind each rule: spec/.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@12.10.1",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"module-spec": "bin/module-spec.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"lib",
|
|
16
|
+
"spec",
|
|
17
|
+
"manifest.schema.json",
|
|
18
|
+
"README.md",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@stryker-mutator/core": "^10.1.1",
|
|
26
|
+
"@stryker-mutator/vitest-runner": "^10.1.1",
|
|
27
|
+
"@typescript-eslint/parser": "^8.71.1",
|
|
28
|
+
"ajv": "^8.20.0",
|
|
29
|
+
"dependency-cruiser": "^18.7.0",
|
|
30
|
+
"eslint": "^10.12.0",
|
|
31
|
+
"jscpd": "^4.3.0",
|
|
32
|
+
"typescript": "~6.0.3",
|
|
33
|
+
"yaml": "^2.9.1"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"react": "^19.3.1",
|
|
37
|
+
"react-dom": "^19.3.1",
|
|
38
|
+
"vitest": "^5.0.5"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/spec/adopting.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Bringing existing code under the spec
|
|
2
|
+
|
|
3
|
+
Existing code adopts the spec door first. Code that is not a module yet starts
|
|
4
|
+
at the first step. A module that does not follow the spec starts at the door.
|
|
5
|
+
|
|
6
|
+
```mermaid
|
|
7
|
+
---
|
|
8
|
+
config:
|
|
9
|
+
look: handDrawn
|
|
10
|
+
---
|
|
11
|
+
flowchart LR
|
|
12
|
+
find["๐ find the cluster<br/>co-change, name owns"]:::loose --> door["๐ช add the door<br/>re-export what callers use"]:::step
|
|
13
|
+
door --> callers["โก๏ธ point callers<br/>at the door"]:::step
|
|
14
|
+
callers --> gate["๐ฆ turn on checks<br/>baseline today's violations"]:::gate
|
|
15
|
+
gate --> pin["๐งช characterization tests<br/>through the door"]:::step
|
|
16
|
+
pin --> inside["๐ง refactor inside<br/>core / shell, one ticket each"]:::step
|
|
17
|
+
inside -- "baseline shrinks" --> inside
|
|
18
|
+
mod["๐ฆ existing module<br/>starts here"]:::start -.-> door
|
|
19
|
+
classDef loose fill:#5c3d0e,stroke:#f59e0b,color:#fef3c7
|
|
20
|
+
classDef step fill:#1b4332,stroke:#52b788,color:#d8f3dc
|
|
21
|
+
classDef gate fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
|
|
22
|
+
classDef start fill:#2d2d2d,stroke:#9ca3af,color:#e5e7eb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Find the cluster
|
|
26
|
+
|
|
27
|
+
Files that change in the same commits belong together (change coupling, Adam
|
|
28
|
+
Tornhill, *Your Code as a Crime Scene*, 2015). Name the decision they share.
|
|
29
|
+
That line becomes `owns`. `module-spec report` lists the modules that change
|
|
30
|
+
together most, and the share of commits that stay in one module.
|
|
31
|
+
|
|
32
|
+
## Add the door
|
|
33
|
+
|
|
34
|
+
`module-spec new-module <dir> --role <role> --owns "<decision>"` writes the
|
|
35
|
+
manifest, the door, a `createX(deps)` shell and a test through the door. For
|
|
36
|
+
existing code, write `module.yaml` by hand and turn the file callers import
|
|
37
|
+
into the door.
|
|
38
|
+
|
|
39
|
+
## Why the door comes first
|
|
40
|
+
|
|
41
|
+
Once every caller uses the door, the inside can be rebuilt without touching
|
|
42
|
+
them. Every later step depends on that.
|
|
43
|
+
|
|
44
|
+
## The baseline
|
|
45
|
+
|
|
46
|
+
`module-spec.baseline.json` lists every violation that exists on the day the
|
|
47
|
+
checks turn on, and the count of cross-module clones. It is committed.
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
module-spec baseline --accept-new # once, the day the checks turn on
|
|
51
|
+
module-spec check # in CI and before every push
|
|
52
|
+
module-spec baseline # after a fix, to drop its line
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`check` fails on a violation that is not in the baseline, and on a baseline
|
|
56
|
+
line that no longer happens, so a fix also deletes its line and the file only
|
|
57
|
+
shrinks. A branch that changes a module also clears every one of that
|
|
58
|
+
module's lines: `check` fails on any violation in it, baselined or not. So the
|
|
59
|
+
baseline empties module by module, as work reaches each one. The one
|
|
60
|
+
exception is the branch that first adds `module.yaml` files: every module is
|
|
61
|
+
new there, so that branch skips the branch rules and its baseline holds.
|
|
62
|
+
`baseline` refuses to add a new violation unless you pass
|
|
63
|
+
`--accept-new`, so growing the baseline is a choice that shows in the diff.
|
|
64
|
+
An entry has no line number, so moving code does not churn the file.
|
|
65
|
+
|
|
66
|
+
## Characterization tests
|
|
67
|
+
|
|
68
|
+
Before an agent refactors the inside, tests pin what the code does today, good
|
|
69
|
+
or bad (Michael Feathers, *Working Effectively with Legacy Code*, 2004). A
|
|
70
|
+
refactor that changes behaviour then fails a test instead of shipping.
|