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
@@ -0,0 +1,176 @@
1
+ # The inside of a backend module
2
+
3
+ The shape is hexagonal (Ports and Adapters, Alistair Cockburn, 2005) with a
4
+ functional core (Gary Bernhardt, "Boundaries", 2012): rules in the middle,
5
+ input and output at the edge, one door in.
6
+
7
+ ```mermaid
8
+ ---
9
+ config:
10
+ look: handDrawn
11
+ ---
12
+ flowchart LR
13
+ caller["📞 caller<br/>an app or a test"]
14
+ subgraph pkg["📦 billing"]
15
+ door["🚪 door<br/>index.ts"]:::always
16
+ shell["🔁 shell<br/>load, call core, save"]:::always
17
+ core["🧠 core<br/>the rules, no input or output"]:::always
18
+ adapters["🔧 adapters<br/>Postgres, HTTP routes"]:::io
19
+ port["🔌 port<br/>InvoiceStore"]:::rare
20
+ end
21
+ outside["🌍 outside<br/>database, network, files"]
22
+ caller --> door --> shell
23
+ shell --> core
24
+ shell --> adapters
25
+ adapters --> outside
26
+ core -. "asks halfway" .-> port
27
+ adapters -. "implements" .-> port
28
+ classDef always fill:#1b4332,stroke:#52b788,color:#d8f3dc
29
+ classDef io fill:#5c3d0e,stroke:#f59e0b,color:#fef3c7
30
+ classDef rare fill:#3b1f5c,stroke:#c084fc,color:#f3e8ff
31
+ ```
32
+
33
+ Green parts every module with rules has. Amber appears when the module touches
34
+ the outside. Purple only when the core needs data halfway through a decision,
35
+ see [B4](#b4-ports-only-when-needed). `InvoiceStore` is an example.
36
+
37
+ ## Layout
38
+
39
+ Each folder exists so one check can find it.
40
+
41
+ ```
42
+ billing/
43
+ module.yaml manifest C1
44
+ index.ts the door: re-exports only C3, C4
45
+ api-report.txt what the door exports C4
46
+ shell/ door functions: load, call core, save B2
47
+ core/ the rules, with no input or output B1
48
+ ports/ driven ports, only when needed B4, T3
49
+ adapters/ database, HTTP routes, files B3
50
+ __tests__/ tests through the door T1, T2
51
+ ```
52
+
53
+ In Python the door is `__init__.py` and the tests folder is `tests/`. In Go the
54
+ door is the package at the module root, the parts sit under `internal/` so the
55
+ compiler refuses a deep import too, and tests sit at the module root in the
56
+ `_test` package. The rest is the same.
57
+
58
+ `ports/` and `adapters/` exist only when needed. A pure calculation module may
59
+ be only `module.yaml`, the door, `core/` and the tests. A module that only moves
60
+ data between HTTP and a table has no rules, so it has no `core/` and no ports.
61
+ Tests may also sit in a `__tests__/` folder inside a part. They still reach the
62
+ module only through its door ([T1](tests.md#t1-tests-reach-a-module-only-through-its-door)).
63
+
64
+ ## B1. Core is pure
65
+
66
+ `core/` takes data and returns data. It reaches the outside only through its
67
+ own module's ports, so it uses:
68
+
69
+ - no input/output code: none of the standard library's input/output modules
70
+ (files, network, processes, such as `fs`, `os` or `socket`), no global that
71
+ does input/output (`fetch`, `open`), and no third-party package the project
72
+ has not listed as pure. The rule is the same in every language;
73
+ - nothing from its own `shell/` or `adapters/`;
74
+ - no other module that carries adapters. Ask for it through a port instead.
75
+ An import the language erases before the code runs may name that module's
76
+ types, because it loads nothing: TypeScript's `import type`, or Python's
77
+ `if TYPE_CHECKING:`. TypeScript's `import { type T }` does not count, since
78
+ Node still loads the module. Go has no such import.
79
+
80
+ A core with no `ports/` folder also never waits: no `async`, no `await`. A
81
+ core that has to wait on something is calling out, so it needs a port. Once
82
+ the module has `ports/`, its core may await them.
83
+
84
+ **Why:** a pure core is tested in one line with no mocks. "Imports nothing
85
+ impure" is a check a machine can run. "Is this the right port" is not.
86
+ **Check:** the import checker, plus a scan of each core for input/output
87
+ globals and, when it has no ports, for `async`. A third-party package counts
88
+ as input/output until the project lists it under `pure` in `module-spec.json`,
89
+ so a new database driver fails though nobody named it (fail-safe defaults,
90
+ Jerome Saltzer and Michael Schroeder, "The Protection of Information in
91
+ Computer Systems", 1975). The kit names no third-party package. Each language
92
+ names only the input/output modules of its own standard library, a fixed set.
93
+
94
+ ## B2. Shell is thin
95
+
96
+ A door function loads what the core needs, calls the core, and saves what the
97
+ core returns.
98
+
99
+ ```ts
100
+ // shell
101
+ const invoice = await db.loadInvoice(id)
102
+ await db.saveInvoice(settle(invoice, payment)) // settle() is the core
103
+ ```
104
+
105
+ **Why:** rules that leak into the shell escape B1.
106
+ **Check:** a complexity cap on `shell/`. Leaked rules add branches and trip
107
+ it. The cap is a proxy: an agent can split a function to slip under it, so the
108
+ clone ratchet and review cover the rest.
109
+
110
+ ## B3. Only adapters touch the outside
111
+
112
+ Only `adapters/` imports database drivers, HTTP frameworks, and file or
113
+ network libraries. HTTP routes live in `adapters/`, because a route changes
114
+ when its feature changes. An app module is exempt: its composition root opens
115
+ the connections.
116
+
117
+ **Check:** the import checker, with the same lists as
118
+ [B1](#b1-core-is-pure): outside `adapters/`, a third-party package must be
119
+ listed under `pure`.
120
+
121
+ ## B4. Ports only when needed
122
+
123
+ Start every module as a functional core with no driven ports. Add a driven
124
+ port only when the core cannot know what data it needs until it has decided
125
+ part of the way, for example "if overdue, fetch the client's payment plan".
126
+
127
+ A port is defined in `ports/`, named after the core's need (`InvoiceStore`),
128
+ not the technology (`PostgresInvoiceRepo`). Adapters implement it (dependency
129
+ inversion, Robert C. Martin).
130
+
131
+ `new-port` writes the port, an in-memory fake and the contract test in one
132
+ step, so the right fix is cheaper than any workaround.
133
+
134
+ **Why:** when a needed port is missing, an agent either imports input/output
135
+ code into the core (B1 fails) or moves rules into the shell (B2 fails). Both
136
+ errors point to `new-port`. That leaves the port as the only path.
137
+
138
+ ## B6. Two kinds of error
139
+
140
+ - **Expected outcomes** are return values. The door's types list them.
141
+ - **Bugs and outages** are thrown and caught once, at the top of the app.
142
+
143
+ ```ts
144
+ type PayResult =
145
+ | { ok: true; invoice: Invoice }
146
+ | { ok: false; error: 'already-paid' | 'amount-mismatch' }
147
+ ```
148
+
149
+ Adapters translate driver errors into the module's own errors. A Postgres
150
+ error code that reaches a caller leaks the decision "billing uses Postgres".
151
+ Where possible, design errors out of existence: deleting a missing item can
152
+ simply do nothing (Ousterhout, *A Philosophy of Software Design*).
153
+
154
+ **Check:** the API report ([C4](common.md#c4-the-door-stays-small)) lists
155
+ every error type at the door, so a leaked driver error shows in the diff.
156
+
157
+ ## B7. Parse inputs at the edge
158
+
159
+ Adapters turn raw input into the module's own types: HTTP bodies, and database
160
+ rows too. The door and the core accept only those types, so a raw `string`
161
+ cannot pass as an `InvoiceId` (Alexis King, "Parse, don't validate", 2019).
162
+ What a value is goes in a type. What to do with it goes in a test.
163
+
164
+ **Check:** the project's own type checker, not this kit. A distinct type per
165
+ kind of value turns the mix-up into a type error.
166
+
167
+ ## Languages
168
+
169
+ Each rule is written once, for every language. The kit reads each language
170
+ with that language's own tools, then applies every rule the same way, so a
171
+ rule has one wording and one error message everywhere. Supported today:
172
+ TypeScript, Python and Go.
173
+
174
+ Where a language's compiler can enforce a rule, prefer it, as Go's `internal/`
175
+ does for the door. In Rust, the core would be its own crate with no database
176
+ or async runtime in its `Cargo.toml`.
package/spec/common.md ADDED
@@ -0,0 +1,163 @@
1
+ # Rules for every module
2
+
3
+ These rules hold for every module, backend or frontend, whatever its role.
4
+ Each rule has an ID, and `module-spec check` prints it with a link to the
5
+ rule's heading here.
6
+
7
+ A module is a folder with one door. Everything behind the door is private. The
8
+ goal is a deep module: a small door over a lot of hidden work (John Ousterhout,
9
+ *A Philosophy of Software Design*). Each module hides one decision, so a change
10
+ to that decision touches one module (David Parnas, "On the Criteria To Be Used
11
+ in Decomposing Systems into Modules", 1972).
12
+
13
+ A project with one module needs none of this. Turn the checks on at the second
14
+ module.
15
+
16
+ ## C1. Every module has a manifest
17
+
18
+ `module.yaml` sits at the module root. Its shape is
19
+ [manifest.schema.json](../manifest.schema.json).
20
+
21
+ ```yaml
22
+ role: feature
23
+ owns: how an engagement's fee is computed
24
+ depends_on: [db, money]
25
+ owns_tables: [invoice, invoice_line]
26
+ ```
27
+
28
+ `role` is one of [the roles](roles.md). A module that has its own
29
+ `package.json` lists its dependencies there, not in `depends_on`, so the two
30
+ never disagree. Modules do not nest.
31
+
32
+ **Why:** the checks read the manifest. Without it, "who may import whom" is a
33
+ guess.
34
+ **Check:** schema validation, a door, a unique name, no module inside another.
35
+
36
+ ## C2. `owns` names a decision, not an activity
37
+
38
+ `owns` says in one line what this module alone decides. "How fees are computed"
39
+ passes. "Handles invoices" fails.
40
+
41
+ **Why:** a module split by activity (validate, save, notify) makes one change
42
+ touch every module. A module split by decision keeps the change in one place.
43
+ This is the single responsibility principle read as "one reason to change"
44
+ (Robert C. Martin, *Clean Architecture*, 2017), not "does one thing".
45
+ **Check:** the schema rejects the activity words "handles", "manages",
46
+ "utils", "helpers" and "misc", the word "TODO", and more than one line.
47
+
48
+ ## C3. One door
49
+
50
+ Other code reaches a module only through its door, and a module has exactly
51
+ one. The door's place is fixed per language, so the manifest has no field for
52
+ it: `index.ts` in TypeScript, `__init__.py` in Python, the package at the module
53
+ root in Go. In a workspace package,
54
+ `package.json` `exports` maps only `.` to the door.
55
+
56
+ **Why:** once callers use only the door, everything behind it can be rebuilt
57
+ without touching them. A second door is a second surface for agents to widen.
58
+ **Check:** the import checker.
59
+
60
+ ## C4. The door stays small
61
+
62
+ Export only what callers need. `module-spec api` writes `api-report.txt` in the
63
+ module: every export of every door, with its type (TypeScript) or its
64
+ `__all__` list (Python). The report is committed, so any change to the door
65
+ shows in the diff.
66
+
67
+ **Why:** agents tend to export whatever they touch. A wide door is a shallow
68
+ module.
69
+ **Check:** `api-report.txt` matches the door, and a Python door declares
70
+ `__all__`.
71
+
72
+ ## C5. Dependencies are declared
73
+
74
+ A module imports exactly the modules it declares, in `depends_on` or in its
75
+ `package.json`. Every declared module is imported somewhere in it.
76
+
77
+ **Why:** an undeclared dependency is an edge nobody decided on. A declared one
78
+ nothing uses is a lie about the design.
79
+ **Check:** the import checker compares imports with the declaration.
80
+
81
+ ## C6. No cycles
82
+
83
+ No module depends on itself through other modules.
84
+
85
+ **Check:** the declared dependencies form no cycle, and C5 keeps the imports
86
+ inside the declarations.
87
+
88
+ ## C8. One composition root wires everything
89
+
90
+ Each app module holds one composition root, such as `app/main.ts` (Mark
91
+ Seemann, *Dependency Injection in .NET*, 2011). It is the only place that
92
+ reads the environment or opens connections. Each module's door exports one
93
+ `createX(deps)` function. The module builds its own adapters from the handles
94
+ in `deps`, so its adapters stay private.
95
+
96
+ ```ts
97
+ // app/main.ts
98
+ const db = openPool(env.DATABASE_URL)
99
+ const billing = createBilling({ db, clock: systemClock })
100
+ ```
101
+
102
+ **Why:** one file shows how the whole app is built. A module never builds
103
+ itself in secret, so a test can pass in fakes.
104
+ **Check:** only app modules read the environment.
105
+
106
+ ## C9. Modules talk through the app
107
+
108
+ ```ts
109
+ // billing declares nothing about reports, and reports imports nothing from billing.
110
+ const reports = createReports({ fees: billing }) // reports asks; billing fits its Fees port
111
+ billing.onInvoicePaid(notifications.sendReceipt) // billing tells, unaware of who listens
112
+ ```
113
+
114
+ - **To ask**, a feature declares the port it needs as a type in its own core,
115
+ such as `Fees`. The app passes the other feature's door, which fits the port.
116
+ The two features never import each other ([R1](roles.md#r1-a-feature-imports-only-shared-modules)).
117
+ - **To tell**, a module exposes an `onX` hook, and the app attaches the
118
+ listeners. The module never imports its listeners.
119
+ - **No global event bus.** It hides who listens, and agents cannot see what
120
+ they cannot search for.
121
+
122
+ **Why:** every wire is in one file, and features stay independent.
123
+ **Check:** R1 and C5.
124
+
125
+ ## C10. Only the owner writes its tables
126
+
127
+ Where there is SQL, each table has one owning module, listed in `owns_tables`.
128
+ Only the owner writes it. Others read through the owner's door, or read the
129
+ table but never write it. The services form of this rule is "database per
130
+ service" (Chris Richardson, *Microservices Patterns*, 2018).
131
+
132
+ **Why:** a table's notion of a valid row is a decision. Two writers means two
133
+ versions of that decision.
134
+ **Check:** upper-case SQL that writes a table (`INSERT INTO`, `UPDATE`,
135
+ `DELETE FROM`, `REPLACE INTO`, `MERGE INTO`) appears only in its owner, and no
136
+ two manifests own one table.
137
+
138
+ ## C11. Cross-module clones only go down
139
+
140
+ **Why:** zero imports between two modules does not mean zero coupling. A rule
141
+ copied into two modules is coupling that no import checker sees, and the
142
+ copies drift apart.
143
+ **Check:** a clone detector counts clones of at least six lines whose copies
144
+ sit in different modules, tests left out. The count is a ratchet, kept in the
145
+ [baseline](adopting.md#the-baseline): it may stay or fall, never rise. A
146
+ module a branch changes may sit in no clone at all.
147
+
148
+ ## When to split or merge
149
+
150
+ These are a map, not checks. They raise a flag. A person decides where the
151
+ edge goes.
152
+ `module-spec report` shows them.
153
+
154
+ - **Split** when a module's rules form two clusters that never import each
155
+ other and never change in the same commit. That is two decisions in one
156
+ folder.
157
+ - **Split** when `owns` no longer fits in one line.
158
+ - **Merge** when two modules always change in the same commits. The split was
159
+ shallow.
160
+
161
+ Start a new project with a few large modules, one per business capability (a
162
+ bounded context, Eric Evans, *Domain-Driven Design*, 2003). An early wrong
163
+ split costs more than a late right one.
@@ -0,0 +1,181 @@
1
+ # The inside of a frontend module
2
+
3
+ A frontend module has the backend's shape plus one part, `view/`. State and the
4
+ rules that change it sit in the core, the shell runs effects, and the view
5
+ draws. This is Model-View-Update, also called The Elm Architecture (Evan
6
+ Czaplicki). The view is a Humble Object (Gerard Meszaros, *xUnit Test
7
+ Patterns*, 2007): code so simple it needs no tests of its own.
8
+
9
+ ```mermaid
10
+ ---
11
+ config:
12
+ look: handDrawn
13
+ ---
14
+ flowchart LR
15
+ user["👤 user"]:::outside
16
+ app["🚀 web app<br/>router, wiring"]:::part
17
+ subgraph checkout["📦 checkout (feature)"]
18
+ door["🚪 index.ts"]:::part
19
+ view["🖼️ view/<br/>state in, events out"]:::new
20
+ shell["🔁 shell/<br/>holds state, runs effects"]:::part
21
+ core["🧠 core/<br/>update(state, event)<br/>selectors"]:::part
22
+ port["🔌 ports/CheckoutApi"]:::part
23
+ adapter["🔧 adapters/httpCheckoutApi"]:::part
24
+ end
25
+ api["🌐 server API"]:::outside
26
+ app --> door
27
+ user -- click --> view
28
+ view -- "send(event)" --> shell
29
+ shell --> core
30
+ shell -- "new state" --> view
31
+ shell --> port
32
+ adapter -. implements .-> port
33
+ adapter --> api
34
+ classDef new fill:#1b4332,stroke:#52b788,color:#d8f3dc
35
+ classDef part fill:#2b2b2b,stroke:#9ca3af,color:#e5e7eb
36
+ classDef outside fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
37
+ ```
38
+
39
+ Green: the one part a frontend adds. Grey: the same parts as a backend module.
40
+ Blue: outside the module.
41
+
42
+ ## Layout
43
+
44
+ ```
45
+ checkout/
46
+ module.yaml manifest C1
47
+ index.ts the door: re-exports only C3, C4
48
+ core/ state, update(state, event), selectors B1
49
+ shell/ the model: holds state, runs effects B2
50
+ view/ components: state in, events out F1, F2, F3
51
+ ports/ CheckoutApi, only when needed B4, T3
52
+ adapters/ the fetch client B3
53
+ __tests__/ tests through the door T1, T2
54
+ ```
55
+
56
+ Every rule in [common.md](common.md), [roles.md](roles.md) and
57
+ [tests.md](tests.md) holds here too, and B1 to B4 in
58
+ [backend.md](backend.md) hold for the parts they name.
59
+
60
+ The door exports two things: the shell's `createCheckoutModel(deps)` and the
61
+ view's `CheckoutPage`. The model's type lives in the core, so the view and the
62
+ shell never import each other. The app joins them:
63
+
64
+ ```ts
65
+ // core/checkout.ts
66
+ export interface CheckoutModel {
67
+ state(): CheckoutState
68
+ subscribe(listener: () => void): () => void
69
+ send(event: CheckoutEvent): void
70
+ }
71
+
72
+ // view/CheckoutPage.tsx
73
+ export function CheckoutPage({ model }: { model: CheckoutModel }) {
74
+ const state = useSyncExternalStore(model.subscribe, model.state, model.state)
75
+ return <button onClick={() => model.send({ type: "paid" })}>{payLabel(state)}</button>
76
+ }
77
+
78
+ // the web app's composition root
79
+ const model = createCheckoutModel({ api: httpCheckoutApi(env.API_URL) })
80
+ root.render(<CheckoutPage model={model} />)
81
+ ```
82
+
83
+ ## F1. Only view/ knows the UI framework
84
+
85
+ Only `view/` imports the UI framework: React, Vue, Svelte, a GUI or a terminal
86
+ UI toolkit. The core, shell, ports and adapters are plain code. An app module
87
+ is exempt, because its composition root starts the framework. Tests are exempt,
88
+ because they render the view.
89
+
90
+ **Why:** the UI framework is the decision most likely to change, and a mobile
91
+ app needs other views over the same model. With the framework in one part,
92
+ either change rewrites `view/` and nothing else (information hiding, David
93
+ Parnas, 1972).
94
+ **Check:** the import checker. The project lists its UI packages, such as
95
+ `react` or a component library, under `ui` in `module-spec.json`, and
96
+ `new-module --view` lists the ones its template uses. A UI module in the
97
+ standard library, such as Python's tkinter, needs no entry. In TypeScript, JSX
98
+ outside `view/` counts too, because JSX needs no import of the framework.
99
+
100
+ ## F2. A view only renders
101
+
102
+ A view gets its state and a `send` function from the model, and draws. It
103
+ imports only the core, other files in `view/`, the UI framework, and other
104
+ modules that carry no adapters. It never imports the shell, ports or adapters,
105
+ and never touches the outside: no `fetch`, no storage, no file or network
106
+ module.
107
+
108
+ A view may still use the framework's own tools for the screen, such as an
109
+ effect that focuses an input. With no way to reach the outside, that is all
110
+ such an effect can do.
111
+
112
+ **Why:** data moves one way: event, shell, core, new state, view. A view that
113
+ fetches breaks that loop, and its test needs a network mock, which
114
+ [T2](tests.md#t2-tests-pass-fakes-in-never-patch) bans.
115
+ **Check:** the import checker, plus the same input/output scan as a core
116
+ ([B1](backend.md#b1-core-is-pure)).
117
+
118
+ ## F3. A view holds no rules
119
+
120
+ A view decides only how things look. A business decision, such as "is shipping
121
+ free for this cart", is a selector in the core that the view calls.
122
+
123
+ **Why:** a rule in a view escapes B1, and it can be tested only by drawing the
124
+ page.
125
+ **Check:** the same complexity cap as the shell
126
+ ([B2](backend.md#b2-shell-is-thin)), `maxComplexity` in `module-spec.json`.
127
+ Rules add branches and trip it. Like B2's cap, it is a proxy, so the clone
128
+ ratchet and review cover the rest.
129
+
130
+ ## Tests
131
+
132
+ A test builds the model through the door with a fake passed in, draws the
133
+ page, acts like the user and reads the screen. "The more your tests resemble
134
+ the way your software is used, the more confidence they can give you" (Kent C.
135
+ Dodds, Testing Library's guiding principle).
136
+
137
+ ```tsx
138
+ test("paying shows the receipt", async () => {
139
+ const model = createCheckoutModel({ api: inMemoryCheckoutApi({ due: 300 }) })
140
+ render(<CheckoutPage model={model} />)
141
+ await user.click(screen.getByRole("button", { name: "Pay $3.00" }))
142
+ await screen.findByText("Paid")
143
+ })
144
+ ```
145
+
146
+ [T1](tests.md#t1-tests-reach-a-module-only-through-its-door) and
147
+ [T2](tests.md#t2-tests-pass-fakes-in-never-patch) hold as they are: the test
148
+ imports only the door, and it never mocks `fetch`, because the fake API comes
149
+ in through `createCheckoutModel`.
150
+
151
+ ## Across features
152
+
153
+ - **Routing lives in the app.** A feature never imports another feature's
154
+ page ([R1](roles.md#r1-a-feature-imports-only-shared-modules)). To link to
155
+ one, it takes a URL or a `navigate` function from the app through its deps.
156
+ - **No global store.** State that several features read, such as the
157
+ signed-in user, is a shared module the app creates once and passes in
158
+ ([C9](common.md#c9-modules-talk-through-the-app)).
159
+ - **A design system is a shared module.** Its components live in its own
160
+ `view/`.
161
+
162
+ ## Why not Feature-Sliced Design
163
+
164
+ Feature-Sliced Design is the best-known frontend folder standard. It has six
165
+ layers (app, pages, widgets, features, entities, shared) against this spec's
166
+ three roles, so a project would have two vocabularies. Its `model` segment
167
+ mixes rules with data fetching, and it has no ports, so its tests mock the
168
+ `api` segment, which T2 bans. This shape is the backend's, plus `view/`, so
169
+ every check already in place keeps working.
170
+
171
+ ## Languages
172
+
173
+ TypeScript reads `.ts` and `.tsx`, so React, Preact, Solid and any JSX
174
+ framework work. Vue and Svelte single-file components (`.vue`, `.svelte`) are
175
+ not read yet. Python and Go apply the same rules to any GUI or terminal UI
176
+ toolkit the project lists under `ui`, such as Qt or Bubble Tea, and Python to
177
+ tkinter with no entry.
178
+
179
+ `module-spec new-module <dir> --view` writes a React module in this shape,
180
+ with a test. It needs `react` and `vitest` in the project, and is TypeScript
181
+ only for now.
package/spec/roles.md ADDED
@@ -0,0 +1,93 @@
1
+ # Roles: app, feature, shared
2
+
3
+ Every module has a `role` in its manifest. The role decides which modules it may
4
+ import, on the backend and the frontend alike. The inside layout is a separate
5
+ matter, see [backend.md](backend.md) and [frontend.md](frontend.md).
6
+
7
+ ```mermaid
8
+ ---
9
+ config:
10
+ look: handDrawn
11
+ ---
12
+ flowchart TB
13
+ subgraph apps["🚀 apps: one per thing you run"]
14
+ direction LR
15
+ server["🖥️ server"]:::app
16
+ web["🌐 web"]:::app
17
+ mobile["📱 mobile"]:::app
18
+ cli["⌨️ cli"]:::app
19
+ end
20
+ subgraph features["🏢 features: what the project does"]
21
+ direction LR
22
+ billing["billing"]:::feature
23
+ reports["reports"]:::feature
24
+ end
25
+ subgraph shared["🔧 shared: what features build on"]
26
+ direction LR
27
+ money["money<br/>how amounts round"]:::shared
28
+ db["db<br/>how we talk to Postgres"]:::shared
29
+ end
30
+ apps -- "may import" --> features
31
+ features -- "may import" --> shared
32
+ apps -- "may import" --> shared
33
+ server -. "❌ never" .-> web
34
+ billing -. "❌ never" .-> reports
35
+ classDef app fill:#1b4332,stroke:#52b788,color:#d8f3dc
36
+ classDef feature fill:#3b1f5c,stroke:#c084fc,color:#f3e8ff
37
+ classDef shared fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
38
+ ```
39
+
40
+ The module names are examples. Imports point only downward.
41
+
42
+ | Role | What it is | May import |
43
+ |-----------|----------------------------------------------------------------|-----------------------------|
44
+ | `app` | one thing you run: a server, a web app, a CLI. Holds the composition root | features and shared modules |
45
+ | `feature` | something the project does: a page, a job, a command | shared modules |
46
+ | `shared` | what two or more features build on | shared modules |
47
+
48
+ The roles follow Ports and Adapters (Alistair Cockburn, 2005), the bounded
49
+ context and shared kernel (Eric Evans, *Domain-Driven Design*, 2003) and the
50
+ acyclic dependencies principle (Robert C. Martin).
51
+
52
+ ## R1. A feature imports only shared modules
53
+
54
+ A feature never imports another feature, not even through its door. When one
55
+ feature needs another, it declares the port it needs as a type in its own core,
56
+ and the app passes the other feature's door in through `createX(deps)`. See
57
+ [C9](common.md#c9-modules-talk-through-the-app).
58
+
59
+ **Why:** features that import each other are one tangle with folders drawn on
60
+ it. A change inside one breaks the other, and neither can be tested or removed
61
+ alone. A door does not fix this: it grows to re-export whatever the next
62
+ caller needs.
63
+ **Check:** the import checker, and the manifest check on what each module
64
+ declares.
65
+
66
+ ## R2. A shared module imports only shared modules
67
+
68
+ **Why:** dependencies point toward what changes least (the stable dependencies
69
+ principle, Robert C. Martin, *Agile Software Development*, 2002). A shared
70
+ module that imports a feature changes whenever that feature does, and so does
71
+ every feature built on it.
72
+ **Check:** the import checker.
73
+
74
+ ## R3. An app imports no other app
75
+
76
+ **Why:** two apps that share code through each other deploy as one.
77
+ **Check:** the import checker.
78
+
79
+ ## When code moves between roles
80
+
81
+ Code a second feature needs moves to a shared module when that second user
82
+ arrives, and back into its feature when only one user is left.
83
+
84
+ ## What a shared module holds
85
+
86
+ Either rules that several features use, such as how money rounds, or plumbing
87
+ any business could use, such as a database pool, a clock or a logger. For
88
+ plumbing, `owns` names the technical decision: "how we talk to Postgres".
89
+
90
+ Many modules build on a shared module, so a change to its door ripples
91
+ everywhere. `module-spec report` lists each module's fan-in, so the most
92
+ depended-on doors are easy to watch. Plumbing often has no rules, so it may be
93
+ only a door and adapters, with no `core/`.