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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- `mutate` counts only the module's core, shell and door tests. An adapter's test and a
|
|
6
|
+
contract test prove the adapter against the outside and are slow, so a mutant that only
|
|
7
|
+
they catch now lives until a test through the door catches it. On factory's chat module
|
|
8
|
+
those tests were 39 of 40 seconds of test time, rerun for every core mutant.
|
|
9
|
+
- Under vitest, `mutate` keeps each module's verdicts in
|
|
10
|
+
`node_modules/.cache/module-spec/mutate/`, and a rerun tests again only the mutants whose
|
|
11
|
+
code or covering tests changed.
|
|
12
|
+
- `mutate` changes only `core/`, `shell/` and the door, as the spec says. Before,
|
|
13
|
+
it also changed files in no part, such as an app's `main.ts` or a
|
|
14
|
+
`vitest.config.ts`.
|
|
15
|
+
- `mutate` runs a vitest project through Stryker's vitest runner, limited to the
|
|
16
|
+
module's own tests by `testFiles`, instead of starting vitest once per mutant.
|
|
17
|
+
It also no longer crashes on TypeScript 7, which has no JS API.
|
|
18
|
+
- A module has exactly one door. The optional pure door `core/index.ts` is gone.
|
|
19
|
+
- B1 is one rule for every language: a core uses no input/output module and no
|
|
20
|
+
input/output global (`fetch`, `open`). A TypeScript core may now use pure
|
|
21
|
+
Node builtins.
|
|
22
|
+
- Fewer terms: "platform module" folds into the shared role.
|
|
23
|
+
- The spec no longer lists tools per language. The kit picks them.
|
|
24
|
+
- Each language is one folder, `lib/languages/<name>/`, and the rules never
|
|
25
|
+
name a language. A shared conformance suite proves every language finds the
|
|
26
|
+
same violations in the same project.
|
|
27
|
+
- `new-module` without `--lang` fails on a project whose language is unclear,
|
|
28
|
+
instead of guessing Python.
|
|
29
|
+
- Test rules have their own page, spec/tests.md. C7 is now T1, and B5 is now
|
|
30
|
+
T3. T1 drops the exception for tests inside `adapters/`: only a port's
|
|
31
|
+
contract test reaches behind the door. New T2: tests pass fakes in and never
|
|
32
|
+
patch.
|
|
33
|
+
- T3 also fails when an adapter that imports a port is missing from that
|
|
34
|
+
port's contract test, so the real adapter cannot be left out.
|
|
35
|
+
- Go support: the door is the package at the module root, parts sit under
|
|
36
|
+
`internal/`, and tests use the `_test` package. A Go program built on
|
|
37
|
+
`go/parser` reads the code, so no Go tool beyond the toolchain is needed.
|
|
38
|
+
- `api-report.txt` drops its door heading: a module has one door.
|
|
39
|
+
- Frontend modules, spec/frontend.md: the backend's shape plus `view/`. New
|
|
40
|
+
F1: only `view/` imports the UI framework. New F2: a view only renders, with
|
|
41
|
+
no input/output and no import of its own shell, ports or adapters. New F3:
|
|
42
|
+
a view holds no rules, under the same complexity cap as the shell. Each
|
|
43
|
+
language lists its UI packages, and a project adds its own through `ui`.
|
|
44
|
+
- `new-module --view` writes a React module in that shape, with a vitest test.
|
|
45
|
+
- **Breaking:** `shellComplexity` in `module-spec.json` is now `maxComplexity`,
|
|
46
|
+
since it caps `view/` too. The old key fails with a message naming the new one.
|
|
47
|
+
- C8 also catches `import.meta.env` in TypeScript.
|
|
48
|
+
- B1 and F2 also catch `EventSource`, `localStorage`, `sessionStorage` and
|
|
49
|
+
`indexedDB` in TypeScript.
|
|
50
|
+
- The kit decides once, in `lib/scans.js`, which files each scan reads and
|
|
51
|
+
which rule a finding breaks. Each language only runs its tools over the list.
|
|
52
|
+
- `new-module` writes `module.yaml` itself, quoted safely, instead of one
|
|
53
|
+
template per language.
|
|
54
|
+
- `module-spec affected` prints the modules a change since `main` can break:
|
|
55
|
+
the changed ones, and every module that depends on them.
|
|
56
|
+
- `module-spec mutate` mutation-tests each module a change touched with only
|
|
57
|
+
its own tests, by each language's own tool. It fails on any live mutant.
|
|
58
|
+
`--accept` lists mutants no test can tell apart from the code in the
|
|
59
|
+
module's `mutation-report.txt`.
|
|
60
|
+
- Zero where you work: `check` fails on any violation or cross-module clone in
|
|
61
|
+
a module the branch changes, even one in the baseline. Untouched modules
|
|
62
|
+
keep their baseline, which may only shrink.
|
|
63
|
+
- New W1 check: a branch changes one module besides apps.
|
|
64
|
+
- The branch that first adds `module.yaml` files skips W1 and zero where you
|
|
65
|
+
work, so adopting the spec on a branch passes with its baseline.
|
|
66
|
+
- Trends are now called a map. They never fail CI.
|
|
67
|
+
- **Breaking:** the kit names no third-party package. Any package a project
|
|
68
|
+
has not listed under `pure` in `module-spec.json` counts as input/output, so
|
|
69
|
+
a new driver nobody named still fails B1, B3 or F2. UI packages go under
|
|
70
|
+
`ui`, and `new-module --view` adds its own. Each language names only its
|
|
71
|
+
standard library's input/output and UI modules, and its toolchain says what
|
|
72
|
+
is standard. The `impure` key fails with a message.
|
|
73
|
+
- An agent skill, `skills/module-spec/`, installed as a Claude Code plugin from
|
|
74
|
+
this repo. A test keeps its commands and flags in step with the CLI.
|
|
75
|
+
- B1 and F2 let a core or view name the types of a module that carries
|
|
76
|
+
adapters through an import erased before the code runs: TypeScript's
|
|
77
|
+
`import type`, or Python's `if TYPE_CHECKING:`. `import { type T }` still
|
|
78
|
+
counts, because Node loads the module.
|
|
79
|
+
- W2 says how a door change that breaks callers lands one module per branch:
|
|
80
|
+
expand, migrate, contract.
|
|
81
|
+
- pnpm replaces npm: the repo pins it in `packageManager`, and the skill, its
|
|
82
|
+
CI template and the README install with it.
|
|
83
|
+
- Invoked with no words, the skill sets up what a project lacks: the kit, its
|
|
84
|
+
config, adopted modules and a CI step, read from `scripts/state.sh`.
|
|
85
|
+
- `new-module` and `new-port` also write the module's `api-report.txt`, so a
|
|
86
|
+
fresh module passes `check` with no extra step.
|
|
87
|
+
- The scaffold tests now see a failing TypeScript test. Run inside
|
|
88
|
+
`node --test`, a nested `node --test` always exited 0.
|
|
89
|
+
|
|
90
|
+
## 0.1.0
|
|
91
|
+
|
|
92
|
+
First version.
|
|
93
|
+
|
|
94
|
+
- The spec: common rules C1–C11, roles R1–R3, the backend inside B1–B7,
|
|
95
|
+
adopting existing code, and working on modules in parallel.
|
|
96
|
+
- `module-spec check`, `baseline`, `api`, `new-module`, `new-port`, `report`
|
|
97
|
+
and `init`, for TypeScript and Python modules.
|
package/README.md
CHANGED
|
@@ -1,3 +1,133 @@
|
|
|
1
|
-
#
|
|
1
|
+
# module-spec
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
);
|
package/lib/affected.js
ADDED
|
@@ -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
|
+
}
|
package/lib/baseline.js
ADDED
|
@@ -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
|
+
}
|