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.
Files changed (79) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +132 -2
  3. package/bin/module-spec.js +193 -0
  4. package/lib/affected.js +54 -0
  5. package/lib/baseline.js +34 -0
  6. package/lib/branch.js +26 -0
  7. package/lib/check.js +53 -0
  8. package/lib/checks/api.js +36 -0
  9. package/lib/checks/clones.js +33 -0
  10. package/lib/checks/imports.js +101 -0
  11. package/lib/checks/manifests.js +52 -0
  12. package/lib/checks/ports.js +32 -0
  13. package/lib/checks/tables.js +37 -0
  14. package/lib/config.js +56 -0
  15. package/lib/files.js +56 -0
  16. package/lib/languages/go/go.mod +5 -0
  17. package/lib/languages/go/index.js +37 -0
  18. package/lib/languages/go/mutate.js +56 -0
  19. package/lib/languages/go/read.js +40 -0
  20. package/lib/languages/go/read_project.go +433 -0
  21. package/lib/languages/go/scaffold.js +28 -0
  22. package/lib/languages/go/templates/module/internal/shell/create.go +14 -0
  23. package/lib/languages/go/templates/module/{{pkg}}.go +15 -0
  24. package/lib/languages/go/templates/module/{{pkg}}_test.go +13 -0
  25. package/lib/languages/go/templates/port/internal/adapters/in_memory_{{portSnake}}.go +10 -0
  26. package/lib/languages/go/templates/port/internal/ports/{{portSnake}}.go +5 -0
  27. package/lib/languages/go/templates/port/{{portSnake}}_contract_test.go +20 -0
  28. package/lib/languages/index.js +66 -0
  29. package/lib/languages/python/environment.js +15 -0
  30. package/lib/languages/python/index.js +35 -0
  31. package/lib/languages/python/mutate.js +72 -0
  32. package/lib/languages/python/package.js +16 -0
  33. package/lib/languages/python/read.js +81 -0
  34. package/lib/languages/python/read_project.py +145 -0
  35. package/lib/languages/python/scaffold.js +19 -0
  36. package/lib/languages/python/templates/module/__init__.py +3 -0
  37. package/lib/languages/python/templates/module/shell/__init__.py +0 -0
  38. package/lib/languages/python/templates/module/shell/create.py +16 -0
  39. package/lib/languages/python/templates/module/tests/__init__.py +0 -0
  40. package/lib/languages/python/templates/module/tests/test_{{snake}}.py +5 -0
  41. package/lib/languages/python/templates/port/adapters/in_memory_{{portSnake}}.py +9 -0
  42. package/lib/languages/python/templates/port/ports/{{portSnake}}.py +5 -0
  43. package/lib/languages/python/templates/port/tests/test_{{portSnake}}_contract.py +11 -0
  44. package/lib/languages/typescript/index.js +37 -0
  45. package/lib/languages/typescript/mutate.js +83 -0
  46. package/lib/languages/typescript/project.js +24 -0
  47. package/lib/languages/typescript/read.js +220 -0
  48. package/lib/languages/typescript/scaffold.js +60 -0
  49. package/lib/languages/typescript/templates/module/index.ts +2 -0
  50. package/lib/languages/typescript/templates/module/shell/create{{Name}}.ts +8 -0
  51. package/lib/languages/typescript/templates/module-test.node.ts +7 -0
  52. package/lib/languages/typescript/templates/module-test.vitest.ts +6 -0
  53. package/lib/languages/typescript/templates/port/adapters/inMemory{{Port}}.ts +5 -0
  54. package/lib/languages/typescript/templates/port/ports/{{Port}}.ts +1 -0
  55. package/lib/languages/typescript/templates/port-test.node.ts +12 -0
  56. package/lib/languages/typescript/templates/port-test.vitest.ts +12 -0
  57. package/lib/languages/typescript/templates/view-module/__tests__/{{name}}.test.tsx +9 -0
  58. package/lib/languages/typescript/templates/view-module/core/model.ts +25 -0
  59. package/lib/languages/typescript/templates/view-module/index.ts +4 -0
  60. package/lib/languages/typescript/templates/view-module/shell/create{{Name}}Model.ts +20 -0
  61. package/lib/languages/typescript/templates/view-module/view/{{Name}}Page.tsx +14 -0
  62. package/lib/modules.js +104 -0
  63. package/lib/mutation.js +118 -0
  64. package/lib/place.js +25 -0
  65. package/lib/report.js +42 -0
  66. package/lib/rules.js +145 -0
  67. package/lib/scaffold.js +57 -0
  68. package/lib/scans.js +62 -0
  69. package/lib/templating.js +28 -0
  70. package/lib/tools.js +17 -0
  71. package/manifest.schema.json +35 -0
  72. package/package.json +38 -4
  73. package/spec/adopting.md +70 -0
  74. package/spec/backend.md +176 -0
  75. package/spec/common.md +163 -0
  76. package/spec/frontend.md +181 -0
  77. package/spec/roles.md +93 -0
  78. package/spec/tests.md +144 -0
  79. package/spec/workflow.md +50 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,90 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - `mutate` changes only `core/`, `shell/` and the door, as the spec says. Before,
6
+ it also changed files in no part, such as an app's `main.ts` or a
7
+ `vitest.config.ts`.
8
+ - `mutate` runs a vitest project through Stryker's vitest runner, limited to the
9
+ module's own tests by `testFiles`, instead of starting vitest once per mutant.
10
+ It also no longer crashes on TypeScript 7, which has no JS API.
11
+ - A module has exactly one door. The optional pure door `core/index.ts` is gone.
12
+ - B1 is one rule for every language: a core uses no input/output module and no
13
+ input/output global (`fetch`, `open`). A TypeScript core may now use pure
14
+ Node builtins.
15
+ - Fewer terms: "platform module" folds into the shared role.
16
+ - The spec no longer lists tools per language. The kit picks them.
17
+ - Each language is one folder, `lib/languages/<name>/`, and the rules never
18
+ name a language. A shared conformance suite proves every language finds the
19
+ same violations in the same project.
20
+ - `new-module` without `--lang` fails on a project whose language is unclear,
21
+ instead of guessing Python.
22
+ - Test rules have their own page, spec/tests.md. C7 is now T1, and B5 is now
23
+ T3. T1 drops the exception for tests inside `adapters/`: only a port's
24
+ contract test reaches behind the door. New T2: tests pass fakes in and never
25
+ patch.
26
+ - T3 also fails when an adapter that imports a port is missing from that
27
+ port's contract test, so the real adapter cannot be left out.
28
+ - Go support: the door is the package at the module root, parts sit under
29
+ `internal/`, and tests use the `_test` package. A Go program built on
30
+ `go/parser` reads the code, so no Go tool beyond the toolchain is needed.
31
+ - `api-report.txt` drops its door heading: a module has one door.
32
+ - Frontend modules, spec/frontend.md: the backend's shape plus `view/`. New
33
+ F1: only `view/` imports the UI framework. New F2: a view only renders, with
34
+ no input/output and no import of its own shell, ports or adapters. New F3:
35
+ a view holds no rules, under the same complexity cap as the shell. Each
36
+ language lists its UI packages, and a project adds its own through `ui`.
37
+ - `new-module --view` writes a React module in that shape, with a vitest test.
38
+ - **Breaking:** `shellComplexity` in `module-spec.json` is now `maxComplexity`,
39
+ since it caps `view/` too. The old key fails with a message naming the new one.
40
+ - C8 also catches `import.meta.env` in TypeScript.
41
+ - B1 and F2 also catch `EventSource`, `localStorage`, `sessionStorage` and
42
+ `indexedDB` in TypeScript.
43
+ - The kit decides once, in `lib/scans.js`, which files each scan reads and
44
+ which rule a finding breaks. Each language only runs its tools over the list.
45
+ - `new-module` writes `module.yaml` itself, quoted safely, instead of one
46
+ template per language.
47
+ - `module-spec affected` prints the modules a change since `main` can break:
48
+ the changed ones, and every module that depends on them.
49
+ - `module-spec mutate` mutation-tests each module a change touched with only
50
+ its own tests, by each language's own tool. It fails on any live mutant.
51
+ `--accept` lists mutants no test can tell apart from the code in the
52
+ module's `mutation-report.txt`.
53
+ - Zero where you work: `check` fails on any violation or cross-module clone in
54
+ a module the branch changes, even one in the baseline. Untouched modules
55
+ keep their baseline, which may only shrink.
56
+ - New W1 check: a branch changes one module besides apps.
57
+ - The branch that first adds `module.yaml` files skips W1 and zero where you
58
+ work, so adopting the spec on a branch passes with its baseline.
59
+ - Trends are now called a map. They never fail CI.
60
+ - **Breaking:** the kit names no third-party package. Any package a project
61
+ has not listed under `pure` in `module-spec.json` counts as input/output, so
62
+ a new driver nobody named still fails B1, B3 or F2. UI packages go under
63
+ `ui`, and `new-module --view` adds its own. Each language names only its
64
+ standard library's input/output and UI modules, and its toolchain says what
65
+ is standard. The `impure` key fails with a message.
66
+ - An agent skill, `skills/module-spec/`, installed as a Claude Code plugin from
67
+ this repo. A test keeps its commands and flags in step with the CLI.
68
+ - B1 and F2 let a core or view name the types of a module that carries
69
+ adapters through an import erased before the code runs: TypeScript's
70
+ `import type`, or Python's `if TYPE_CHECKING:`. `import { type T }` still
71
+ counts, because Node loads the module.
72
+ - W2 says how a door change that breaks callers lands one module per branch:
73
+ expand, migrate, contract.
74
+ - pnpm replaces npm: the repo pins it in `packageManager`, and the skill, its
75
+ CI template and the README install with it.
76
+ - Invoked with no words, the skill sets up what a project lacks: the kit, its
77
+ config, adopted modules and a CI step, read from `scripts/state.sh`.
78
+ - `new-module` and `new-port` also write the module's `api-report.txt`, so a
79
+ fresh module passes `check` with no extra step.
80
+ - The scaffold tests now see a failing TypeScript test. Run inside
81
+ `node --test`, a nested `node --test` always exited 0.
82
+
83
+ ## 0.1.0
84
+
85
+ First version.
86
+
87
+ - The spec: common rules C1–C11, roles R1–R3, the backend inside B1–B7,
88
+ adopting existing code, and working on modules in parallel.
89
+ - `module-spec check`, `baseline`, `api`, `new-module`, `new-port`, `report`
90
+ and `init`, for TypeScript and Python modules.
package/README.md CHANGED
@@ -1,3 +1,133 @@
1
- # Temporary Holding Version
1
+ # module-spec
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ How every project splits its code into modules, so many agents can work on it
4
+ at once without the code rotting, and the checks that hold it there.
5
+
6
+ ## Why
7
+
8
+ Agents copy whatever the code around them makes easy, and they lose context
9
+ between chats. A rule written only in prose is read once and then forgotten.
10
+ So every rule here ends in a check, and every check's error names the rule,
11
+ the fix and the heading that explains why. The spec says why. The checks
12
+ enforce. The templates make the right shape the cheapest one to write.
13
+
14
+ ## Read in this order
15
+
16
+ 1. [spec/common.md](spec/common.md): rules for every module.
17
+ 2. [spec/roles.md](spec/roles.md): app, feature and shared modules.
18
+ 3. [spec/backend.md](spec/backend.md): the inside of a backend module.
19
+ 4. [spec/tests.md](spec/tests.md): how a module is tested, and why good design deletes tests.
20
+ 5. [spec/frontend.md](spec/frontend.md): the inside of a frontend module.
21
+ 6. [spec/adopting.md](spec/adopting.md): bringing existing code under the spec.
22
+ 7. [spec/workflow.md](spec/workflow.md): agents working on modules in parallel.
23
+
24
+ ## Install
25
+
26
+ Node 24 or later and pnpm. In the project:
27
+
28
+ ```sh
29
+ pnpm add -D "git+ssh://gitea/aleburrascano/module-spec.git"
30
+ ```
31
+
32
+ A Go project needs the `go` toolchain on the PATH and `go.mod` at the project
33
+ root. Its first `mutate` downloads the mutation tool through `go run`. A Python
34
+ project also installs `import-linter`, `ruff` and, for `mutate`, `mutmut` in its
35
+ own environment. The kit runs `.venv/bin/python` when it exists, or the
36
+ interpreter named in `module-spec.json`.
37
+
38
+ ## Commands
39
+
40
+ | Command | What it does |
41
+ |---|---|
42
+ | `module-spec check [--since <ref>]` | Runs every check. Fails on a violation that is not in the baseline, a baseline entry that no longer happens, any violation or clone in a module the branch changes, and a branch that changes two modules besides apps. |
43
+ | `module-spec baseline [--accept-new]` | Records today's violations in `module-spec.baseline.json`. Refuses to add new ones unless `--accept-new`. |
44
+ | `module-spec api` | Rewrites each module's `api-report.txt` from what its door exports. |
45
+ | `module-spec new-module <dir> --role <role> --owns "<decision>" [--lang ts\|py\|go] [--view]` | Writes a module that already passes every check. Without `--lang`, the project's files decide, and an unclear project is an error. `--view` writes a frontend module with a React `view/` and a vitest test (TypeScript only). |
46
+ | `module-spec new-port <module dir> <PortName>` | Writes a port, an in-memory fake and its contract test. |
47
+ | `module-spec affected [--since <ref>]` | Prints the modules a change since `<ref>` (default: `main`) can break: the changed ones and every module that depends on them. One folder per line. |
48
+ | `module-spec mutate [<module dir>...] [--since <ref>] [--accept]` | Mutation-tests each module with only its own tests, by default the modules changed since `<ref>`. Fails on any live mutant its `mutation-report.txt` does not accept. `--accept` records mutants no test can tell apart from the code. |
49
+ | `module-spec report` | A map, never enforced: commits that stay in one module, modules that change together, fan-in. |
50
+ | `module-spec init` | Writes a default `module-spec.json`. |
51
+
52
+ Every command takes `--root <dir>`. An error looks like this:
53
+
54
+ ```
55
+ B1 billing/core/settle.ts imports fs
56
+ Core is pure. Fix: Move the input/output into adapters/ and pass the data into the core. ...
57
+ See .../module-spec/spec/backend.md#b1-core-is-pure
58
+ ```
59
+
60
+ ## How a rule is enforced
61
+
62
+ | Kind | Meaning | Examples |
63
+ |---|---|---|
64
+ | Rule | stays at zero, CI fails | cycles, imports past a door, live mutants no one accepted, a branch that changes two modules |
65
+ | Zero where you work | a module a branch changes holds none, baselined or not | violations, cross-module clones |
66
+ | Ratchet | may only go down, in modules no branch has touched yet | violations left in the baseline, cross-module clones |
67
+ | Map | read by people, never fails CI | fan-in, modules that changed together |
68
+
69
+ Agents copy the code they read, and behind one door per module that is the
70
+ inside of the module they change. So a branch clears every violation in that
71
+ module, even one in the baseline: debt there stops the work until it is gone
72
+ (stop the line, Taiichi Ohno, *Toyota Production System*). Debt in a module no
73
+ branch touches waits for the next branch that does, so one module's debt never
74
+ blocks every chat. A map has no right value: a shared module many features use
75
+ is the design working, and failing on fan-in would push agents to copy code
76
+ instead of importing it. Each language's own tools read the code, and the kit applies
77
+ each rule once for every language; see [backend.md](spec/backend.md#languages).
78
+
79
+ ## Agent skill
80
+
81
+ `skills/module-spec/` tells an agent which command to run at each step of a
82
+ change. The repo is its own Claude Code plugin marketplace. On the machine with
83
+ a clone of this repo, from the clone:
84
+
85
+ ```sh
86
+ claude plugin marketplace add "$PWD"
87
+ claude plugin install module-spec@module-spec
88
+ ```
89
+
90
+ The plugin reads the clone in place, so a pull updates the skill.
91
+ `__tests__/skill.test.js` fails when the skill runs a command or flag the CLI
92
+ does not have.
93
+
94
+ ## module-spec.json
95
+
96
+ Every key is optional.
97
+
98
+ | Key | Default | Meaning |
99
+ |---|---|---|
100
+ | `roots` | `["."]` | Folders searched for `module.yaml`. |
101
+ | `maxComplexity` | `5` | The highest complexity a `shell/` or `view/` function may have (B2, F3). Was `shellComplexity`. |
102
+ | `pure.<language>` | `[]` | Third-party packages that touch nothing outside, such as `"pure": { "typescript": ["zod"] }`. Any other third-party package counts as input/output, so only `adapters/` and apps may import it (B1, B3, F2). An entry ending in `/` matches a scope. Replaces `impure`, which now fails. |
103
+ | `ui.<language>` | `[]` | Packages that draw the user interface, which only `view/` may import (F1), such as `react` or a component library. `new-module --view` adds the ones its template uses. `/` works as in `pure`. |
104
+ | `python.executable` | `.venv/bin/python`, else `python3` | The interpreter that has import-linter and ruff. |
105
+ | `clones.minLines` | `6` | The shortest clone C11 counts. |
106
+
107
+ ## Layout
108
+
109
+ ```
110
+ spec/ the why, one rule per heading, each with an ID
111
+ manifest.schema.json the shape of module.yaml
112
+ bin/module-spec.js the CLI
113
+ skills/module-spec/ the agent skill: which command to run when
114
+ .claude-plugin/ the plugin and marketplace that install the skill
115
+ lib/ the rules, which never name a language
116
+ lib/languages/<name>/ one folder per language: its door, its tools, its templates
117
+ __tests__/conformance/ the projects every language must pass
118
+ ```
119
+
120
+ `pnpm test` runs the suite. The Python tests need `.venv` with import-linter,
121
+ ruff and pytest: `python3 -m venv .venv && .venv/bin/pip install import-linter ruff pytest mutmut`.
122
+
123
+ ## Adding a language
124
+
125
+ 1. Add `lib/languages/<name>/`. Its `index.js` exports the `Language` shape
126
+ described in [lib/languages/index.js](lib/languages/index.js): the door, what
127
+ counts as a test, which standard library modules touch the outside or draw,
128
+ how its own tools read the code, and its templates. Add it to `LANGUAGES` there.
129
+ 2. Add `__tests__/conformance/<name>.js`. It writes that language's code for
130
+ the modules in [cases.js](__tests__/conformance/cases.js), whose `code`
131
+ field says what each module holds.
132
+ 3. The language is supported when `pnpm test` passes: the clean project finds
133
+ nothing, and the broken one finds exactly what cases.js lists.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ import path from "node:path";
3
+ import { affectedModules, changedFiles } from "../lib/affected.js";
4
+ import { BASELINE_FILE, compareWithBaseline, keyOf, loadBaseline, writeBaseline } from "../lib/baseline.js";
5
+ import { checkBranch } from "../lib/branch.js";
6
+ import { runChecks } from "../lib/check.js";
7
+ import { writeApiReports } from "../lib/checks/api.js";
8
+ import { loadConfig } from "../lib/config.js";
9
+ import { LANGUAGES } from "../lib/languages/index.js";
10
+ import { discoverModules } from "../lib/modules.js";
11
+ import { MUTATION_REPORT, mutateModules } from "../lib/mutation.js";
12
+ import { moduleMap } from "../lib/report.js";
13
+ import { RULES, specLink } from "../lib/rules.js";
14
+ import { initProject, newModule, newPort } from "../lib/scaffold.js";
15
+
16
+ const USAGE = `usage: module-spec <command> [--root <dir>]
17
+
18
+ check [--since <ref>] run every check; fails on a violation not in ${BASELINE_FILE}, a stale entry there,
19
+ any violation in a module this branch changes, or a branch that changes two modules
20
+ baseline [--accept-new] record today's violations; refuses to add new ones unless --accept-new
21
+ api rewrite each module's api-report.txt from what its door exports
22
+ new-module <dir> --role app|feature|shared --owns "<decision>" [--lang ${LANGUAGES.flatMap((language) => language.aliases).join("|")}] [--view]
23
+ new-port <module dir> <PortName>
24
+ affected [--since <ref>] the modules a change since <ref> (default: main) can break, one folder per line
25
+ mutate [<module dir>...] [--since <ref>] [--accept]
26
+ mutation-test each module with its own tests (default: the modules changed since <ref>); fails on any live mutant not accepted
27
+ report a map, never enforced: commits that stay in one module, pairs that change together, fan-in
28
+ init write a default module-spec.json`;
29
+
30
+ /** Flags that take no value. */
31
+ const SWITCHES = { "--accept-new": "acceptNew", "--view": "view", "--accept": "accept" };
32
+
33
+ function parseArgs(argv) {
34
+ const positional = [];
35
+ const flags = {};
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+ if (!arg.startsWith("--")) positional.push(arg);
39
+ else if (SWITCHES[arg]) flags[SWITCHES[arg]] = true;
40
+ else flags[arg.slice(2)] = argv[(index += 1)];
41
+ }
42
+ return { positional, flags };
43
+ }
44
+
45
+ function describe(violation) {
46
+ const rule = RULES[violation.id];
47
+ const where = violation.line ? `${violation.file}:${violation.line}` : violation.file;
48
+ return `${violation.id} ${where} ${violation.detail}\n ${rule.title}. Fix: ${rule.fix}\n See ${specLink(violation.id)}`;
49
+ }
50
+
51
+ function describeClones(clones) {
52
+ return clones.map((clone) => ` ${clone.lines} lines: ${clone.a.file}:${clone.a.line} = ${clone.b.file}:${clone.b.line}`).join("\n");
53
+ }
54
+
55
+ async function check(root, since) {
56
+ const { modules, violations, clones } = await runChecks(root);
57
+ const diff = compareWithBaseline(loadBaseline(root), violations, clones.length);
58
+ const branch = checkBranch(root, { modules, violations, clones, since });
59
+ const fresh = new Set(diff.fresh.map(keyOf));
60
+ const baselinedHere = (branch?.violations ?? []).filter((violation) => !fresh.has(keyOf(violation)));
61
+ for (const violation of diff.fresh) console.log(describe(violation));
62
+ for (const violation of baselinedHere) console.log(`${describe(violation)}\n In the baseline, but this branch changes its module, so it goes now.`);
63
+ for (const violation of branch?.spans ?? []) console.log(describe(violation));
64
+ if (branch?.clones.length > 0) {
65
+ console.log(`C11 ${branch.clones.length} cross-module clones reach a module this branch changes\n ${RULES.C11.title}. Fix: ${RULES.C11.fix}\n See ${specLink("C11")}`);
66
+ console.log(describeClones(branch.clones));
67
+ }
68
+ for (const key of diff.stale) console.log(`stale ${key}\n Fixed, so remove it: run module-spec baseline.`);
69
+ if (diff.clonesNow > diff.clonesBefore) {
70
+ console.log(`C11 ${diff.clonesNow} cross-module clones, up from ${diff.clonesBefore}\n ${RULES.C11.title}. Fix: ${RULES.C11.fix}\n See ${specLink("C11")}`);
71
+ console.log(describeClones(clones));
72
+ } else if (diff.clonesNow < diff.clonesBefore) {
73
+ console.log(`C11 cross-module clones fell to ${diff.clonesNow} from ${diff.clonesBefore}: run module-spec baseline to lock it in.`);
74
+ }
75
+ const branchFailed = baselinedHere.length > 0 || (branch?.spans.length ?? 0) > 0 || (branch?.clones.length ?? 0) > 0;
76
+ const failed = branchFailed || diff.fresh.length > 0 || diff.stale.length > 0 || diff.clonesNow !== diff.clonesBefore;
77
+ const changed = branch ? `, branch changes ${branch.changed.map((module) => module.name).join(", ") || "no module"}` : "";
78
+ console.log(
79
+ `module-spec: ${modules.length} modules, ${diff.fresh.length} new, ${diff.stale.length} stale, ${diff.known} known, ${diff.clonesNow} cross-module clones${changed}`,
80
+ );
81
+ return failed ? 1 : 0;
82
+ }
83
+
84
+ async function baseline(root, acceptNew) {
85
+ const { violations, clones } = await runChecks(root);
86
+ const before = loadBaseline(root);
87
+ const added = violations.filter((violation) => !before.violations.has(keyOf(violation)));
88
+ const clonesUp = clones.length > before.clones;
89
+ if ((added.length > 0 || clonesUp) && !acceptNew) {
90
+ for (const violation of added) console.log(describe(violation));
91
+ if (clonesUp) console.log(`C11 cross-module clones rose to ${clones.length} from ${before.clones}`);
92
+ console.log("module-spec: refusing to add new violations to the baseline. Fix them, or rerun with --accept-new.");
93
+ return 1;
94
+ }
95
+ writeBaseline(root, violations, clones.length);
96
+ console.log(`module-spec: wrote ${BASELINE_FILE}: ${violations.length} violations, ${clones.length} cross-module clones`);
97
+ return 0;
98
+ }
99
+
100
+ async function api(root) {
101
+ const { modules, api: exportsByModule } = await runChecks(root);
102
+ const written = writeApiReports(root, modules, exportsByModule);
103
+ console.log(written.length > 0 ? `module-spec: rewrote ${written.join(", ")}` : "module-spec: every api-report.txt is current");
104
+ return 0;
105
+ }
106
+
107
+ function report(root) {
108
+ const { modules } = discoverModules(root, loadConfig(root));
109
+ const found = moduleMap(root, modules);
110
+ const share = found.commits === 0 ? 0 : Math.round((100 * found.singleModule) / found.commits);
111
+ console.log(`commits that touch modules: ${found.commits}, ${share}% stay in one module, ${found.sweeps} sweep 6 or more`);
112
+ console.log("modules that change together most:");
113
+ for (const [pair, count] of found.topPairs) console.log(` ${count} ${pair}`);
114
+ console.log("fan-in (modules that declare each one):");
115
+ for (const [name, count] of found.fanIn) console.log(` ${count} ${name}`);
116
+ return 0;
117
+ }
118
+
119
+ function affected(root, since) {
120
+ const { modules } = discoverModules(root, loadConfig(root));
121
+ const found = affectedModules(modules, changedFiles(root, since));
122
+ for (const module of found.modules) console.log(module.dir);
123
+ if (found.outside.length > 0) {
124
+ console.error(`module-spec: ${found.outside.length} changed files belong to no module, so only the full suite covers them: ${found.outside.join(", ")}`);
125
+ }
126
+ return 0;
127
+ }
128
+
129
+ function describeMutation(result) {
130
+ const { module } = result;
131
+ if (result.reason) return `${module.dir}: ${result.reason}`;
132
+ const head = `${module.dir}: killed ${result.killed} of ${result.killed + result.survivors.length}, ${result.survivors.length} live`;
133
+ if (!result.failed) {
134
+ const accepted = result.survivors.length > 0 ? `, all accepted in ${MUTATION_REPORT}` : "";
135
+ return `${head}${accepted}${result.wrote ? `. Rewrote ${module.dir}/${MUTATION_REPORT}` : ""}`;
136
+ }
137
+ return [
138
+ `${head}. Live mutants no test catches:`,
139
+ ...result.fresh.map((survivor) => ` ${module.dir}/${survivor.file}:${survivor.line} ${survivor.detail}`),
140
+ " Add a test through the door that fails for each one. Only if no test can tell one apart from the real code, rerun with --accept.",
141
+ ].join("\n");
142
+ }
143
+
144
+ async function mutate(root, dirs, flags) {
145
+ const results = await mutateModules(root, { dirs, since: flags.since, accept: flags.accept });
146
+ if (results.length === 0) console.log("module-spec: no module changed, so nothing to mutate");
147
+ for (const result of results) console.log(describeMutation(result));
148
+ return results.some((result) => result.failed) ? 1 : 0;
149
+ }
150
+
151
+ function printWritten(written) {
152
+ console.log(written.length > 0 ? `module-spec: wrote ${written.join(", ")}` : "module-spec: nothing to write");
153
+ return 0;
154
+ }
155
+
156
+ async function main(argv) {
157
+ const { positional, flags } = parseArgs(argv);
158
+ const root = path.resolve(flags.root ?? ".");
159
+ const [command, ...rest] = positional;
160
+ switch (command) {
161
+ case "check":
162
+ return check(root, flags.since);
163
+ case "baseline":
164
+ return baseline(root, flags.acceptNew);
165
+ case "api":
166
+ return api(root);
167
+ case "new-module":
168
+ return printWritten(await newModule(root, { dir: rest[0], role: flags.role, owns: flags.owns, lang: flags.lang, view: flags.view }));
169
+ case "new-port":
170
+ return printWritten(await newPort(root, { dir: rest[0], port: rest[1] }));
171
+ case "affected":
172
+ return affected(root, flags.since);
173
+ case "mutate":
174
+ return mutate(root, rest, flags);
175
+ case "report":
176
+ return report(root);
177
+ case "init":
178
+ return printWritten(initProject(root));
179
+ default:
180
+ console.log(USAGE);
181
+ return command ? 1 : 0;
182
+ }
183
+ }
184
+
185
+ main(process.argv.slice(2)).then(
186
+ (code) => {
187
+ process.exitCode = code;
188
+ },
189
+ (error) => {
190
+ console.error(`module-spec: ${error.message}`);
191
+ process.exitCode = 2;
192
+ },
193
+ );
@@ -0,0 +1,54 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import { MANIFEST_FILE } from "./modules.js";
4
+ import { moduleLookup } from "./place.js";
5
+
6
+ /** Branches a change is measured from when no --since names one, in the order tried: the remote's default first. */
7
+ const MAIN_BRANCHES = ["origin/HEAD", "main", "master"];
8
+
9
+ function git(root, args) {
10
+ const run = spawnSync("git", args, { cwd: root, encoding: "utf8" });
11
+ if (run.error || run.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${(run.stderr ?? String(run.error)).trim()}`);
12
+ return run.stdout.split("\n").filter(Boolean);
13
+ }
14
+
15
+ /** The commit a change starts from: where HEAD left `since`, or the remote's default branch, main or master. */
16
+ function baseOf(root, since) {
17
+ const ref = since ?? MAIN_BRANCHES.find((branch) => spawnSync("git", ["rev-parse", "--verify", "--quiet", branch], { cwd: root }).status === 0);
18
+ if (!ref) throw new Error(`no ${MAIN_BRANCHES.join(" or ")} branch to compare with: pass --since <ref>`);
19
+ return git(root, ["merge-base", "HEAD", ref])[0];
20
+ }
21
+
22
+ /** Every file changed since `since`, committed or not, new files included, as paths relative to root. */
23
+ export function changedFiles(root, since) {
24
+ const base = baseOf(root, since);
25
+ const changed = [...git(root, ["diff", "--name-only", "--relative", base]), ...git(root, ["ls-files", "--others", "--exclude-standard"])];
26
+ return [...new Set(changed)].sort();
27
+ }
28
+
29
+ /**
30
+ * The modules a change can break: `changed` holds a changed file, and `modules` adds every module that
31
+ * depends on one of them, directly or through others. A dependent counts even when no door changed, because
32
+ * behaviour can change behind an unchanged door. `outside` lists the changed files that belong to no module.
33
+ */
34
+ export function affectedModules(modules, files) {
35
+ const moduleOf = moduleLookup(modules);
36
+ const dependents = new Map(modules.map((module) => [module.name, []]));
37
+ for (const module of modules) for (const name of module.dependsOn) dependents.get(name).push(module);
38
+ const changed = new Set(files.map(moduleOf).filter(Boolean));
39
+ const affected = new Set(changed);
40
+ // A Set visits what is added while it is walked, so this reaches dependents of dependents.
41
+ for (const module of affected) for (const dependent of dependents.get(module.name)) affected.add(dependent);
42
+ const byDir = (a, b) => a.dir.localeCompare(b.dir);
43
+ return { changed: [...changed].sort(byDir), modules: [...affected].sort(byDir), outside: files.filter((file) => !moduleOf(file)) };
44
+ }
45
+
46
+ /** Whether the commit a change starts from holds a module.yaml. It does not on the branch that first adopts the spec. */
47
+ export function adoptedAtBase(root, since) {
48
+ return git(root, ["ls-tree", "-r", "--name-only", baseOf(root, since)]).some((file) => path.posix.basename(file) === MANIFEST_FILE);
49
+ }
50
+
51
+ /** Whether root sits in a git work tree with a first commit, so a change can be measured at all. */
52
+ export function hasHistory(root) {
53
+ return spawnSync("git", ["rev-parse", "--verify", "--quiet", "HEAD"], { cwd: root }).status === 0;
54
+ }
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export const BASELINE_FILE = "module-spec.baseline.json";
5
+
6
+ /** A violation's identity in the baseline. No line number, so moving code does not churn the file. */
7
+ export function keyOf(violation) {
8
+ return `${violation.id} ${violation.file} ${violation.detail}`;
9
+ }
10
+
11
+ export function loadBaseline(root) {
12
+ const file = path.join(root, BASELINE_FILE);
13
+ if (!fs.existsSync(file)) return { violations: new Set(), clones: 0 };
14
+ const saved = JSON.parse(fs.readFileSync(file, "utf8"));
15
+ return { violations: new Set(saved.violations), clones: saved.clones };
16
+ }
17
+
18
+ export function writeBaseline(root, violations, clones) {
19
+ const saved = { violations: [...new Set(violations.map(keyOf))].sort(), clones };
20
+ fs.writeFileSync(path.join(root, BASELINE_FILE), `${JSON.stringify(saved, null, 2)}\n`);
21
+ }
22
+
23
+ /** What changed since the baseline. Both new and stale entries fail, so the baseline only shrinks. */
24
+ export function compareWithBaseline(baseline, violations, clones) {
25
+ const current = new Set(violations.map(keyOf));
26
+ const fresh = violations.filter((violation) => !baseline.violations.has(keyOf(violation)));
27
+ return {
28
+ fresh,
29
+ stale: [...baseline.violations].filter((key) => !current.has(key)),
30
+ known: violations.length - fresh.length,
31
+ clonesBefore: baseline.clones,
32
+ clonesNow: clones,
33
+ };
34
+ }
package/lib/branch.js ADDED
@@ -0,0 +1,26 @@
1
+ import { adoptedAtBase, affectedModules, changedFiles, hasHistory } from "./affected.js";
2
+ import { moduleLookup } from "./place.js";
3
+
4
+ /**
5
+ * What a branch must leave at zero. Agents copy the code they read, and behind one door per module the code a
6
+ * branch reads is the inside of the module it changes. So that module carries no violation, baselined or not,
7
+ * and no cross-module clone. A branch changes one module besides apps, which only wire (W1).
8
+ * Before a repository's first commit there is no branch, so nothing is returned. Nor on the branch that first adopts the
9
+ * spec: every module is new there, and the baseline it writes holds today's debt.
10
+ */
11
+ export function checkBranch(root, { modules, violations, clones, since }) {
12
+ if (!hasHistory(root) || !adoptedAtBase(root, since)) return null;
13
+ const { changed } = affectedModules(modules, changedFiles(root, since));
14
+ const touched = new Set(changed);
15
+ const moduleOf = moduleLookup(modules);
16
+ const changedWork = changed.filter((module) => module.role !== "app");
17
+ return {
18
+ changed,
19
+ violations: violations.filter((violation) => touched.has(moduleOf(violation.file))),
20
+ clones: clones.filter((clone) => touched.has(moduleOf(clone.a.file)) || touched.has(moduleOf(clone.b.file))),
21
+ spans:
22
+ changedWork.length > 1
23
+ ? [{ id: "W1", file: changedWork[1].manifestPath, line: null, detail: `changes ${changedWork.map((module) => module.name).join(", ")}: one module per branch` }]
24
+ : [],
25
+ };
26
+ }
package/lib/check.js ADDED
@@ -0,0 +1,53 @@
1
+ import { keyOf } from "./baseline.js";
2
+ import { loadConfig } from "./config.js";
3
+ import { checkApiReports } from "./checks/api.js";
4
+ import { findCrossModuleClones } from "./checks/clones.js";
5
+ import { checkImports } from "./checks/imports.js";
6
+ import { checkManifests } from "./checks/manifests.js";
7
+ import { checkPorts } from "./checks/ports.js";
8
+ import { checkTables } from "./checks/tables.js";
9
+ import { LANGUAGES } from "./languages/index.js";
10
+ import { discoverModules } from "./modules.js";
11
+ import { findingsOf, scanPlan } from "./scans.js";
12
+
13
+ /** Runs each language's own tools over that language's modules and merges what they read. */
14
+ async function analyzeLanguages(root, config, modules) {
15
+ const merged = { edges: [], findings: [], api: new Map(), languages: new Set() };
16
+ for (const language of LANGUAGES) {
17
+ const own = modules.filter((module) => module.language === language);
18
+ if (own.length === 0) continue;
19
+ const read = await language.analyze({ root, config, modules: own, plan: scanPlan(own, config) });
20
+ merged.edges.push(...read.edges);
21
+ merged.findings.push(...read.findings, ...findingsOf(read.sightings, own));
22
+ for (const [name, lines] of read.api) merged.api.set(name, lines);
23
+ merged.languages.add(language);
24
+ }
25
+ return merged;
26
+ }
27
+
28
+ function unique(violations) {
29
+ const seen = new Map();
30
+ for (const violation of violations) {
31
+ const normalized = { line: null, ...violation };
32
+ if (!seen.has(keyOf(normalized))) seen.set(keyOf(normalized), normalized);
33
+ }
34
+ return [...seen.values()].sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
35
+ }
36
+
37
+ /** Every rule over the project at `root`: its violations, its cross-module clones and what the doors export. */
38
+ export async function runChecks(root) {
39
+ const config = loadConfig(root);
40
+ const { modules, problems } = discoverModules(root, config);
41
+ const read = await analyzeLanguages(root, config, modules);
42
+ const violations = unique([
43
+ ...problems,
44
+ ...checkManifests(modules),
45
+ ...read.findings,
46
+ ...checkImports({ modules, edges: read.edges, config, analyzedLanguages: read.languages }),
47
+ ...checkTables(root, modules),
48
+ ...checkPorts(modules, read.edges),
49
+ ...checkApiReports(root, modules, read.api),
50
+ ]);
51
+ const clones = await findCrossModuleClones(root, modules, config);
52
+ return { config, modules, violations, clones, api: read.api };
53
+ }
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readIfExists } from "../files.js";
4
+
5
+ export const API_REPORT = "api-report.txt";
6
+
7
+ /** The api-report.txt a module's door exports should produce. */
8
+ function apiReportOf(module, lines) {
9
+ return [`# ${module.name}: what its door exports. Rewrite with \`module-spec api\`.`, "", ...lines].join("\n") + "\n";
10
+ }
11
+
12
+ /** C4: each module's committed api-report.txt matches what its door exports. */
13
+ export function checkApiReports(root, modules, api) {
14
+ const problems = [];
15
+ for (const module of modules.filter((candidate) => api.has(candidate.name))) {
16
+ const file = `${module.dir}/${API_REPORT}`;
17
+ const expected = apiReportOf(module, api.get(module.name));
18
+ const committed = readIfExists(path.join(root, file));
19
+ if (committed === null) problems.push({ id: "C4", file, line: null, detail: "is missing: run module-spec api" });
20
+ else if (committed !== expected) problems.push({ id: "C4", file, line: null, detail: "differs from what the door exports: run module-spec api and review the diff" });
21
+ }
22
+ return problems;
23
+ }
24
+
25
+ export function writeApiReports(root, modules, api) {
26
+ const written = [];
27
+ for (const module of modules.filter((candidate) => api.has(candidate.name))) {
28
+ const expected = apiReportOf(module, api.get(module.name));
29
+ const file = path.join(root, module.dir, API_REPORT);
30
+ if (readIfExists(file) !== expected) {
31
+ fs.writeFileSync(file, expected);
32
+ written.push(`${module.dir}/${API_REPORT}`);
33
+ }
34
+ }
35
+ return written;
36
+ }
@@ -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
+ }