-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path.dependency-cruiser.cjs
More file actions
224 lines (222 loc) · 10.8 KB
/
Copy path.dependency-cruiser.cjs
File metadata and controls
224 lines (222 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
// SPDX-FileCopyrightText: The Plinky Authors
// SPDX-License-Identifier: AGPL-3.0-or-later
// The architecture contract, enforced. The app is built as a stack of layers whose
// dependencies point strictly downward:
//
// routes → components/features → components/ui → core
// │ │
// ▼ ▼
// hooks → stores → ports ← adapters (core has no outward edges;
// └────────┴───────┴────────┘ dev/ and the build config
// depend down on core too)
//
// core/ is pure domain — no React, no OSMD, no I/O. Side effects live behind ports
// (interfaces) implemented by adapters, so the units that use them stay testable
// with fakes. These rules catch a dependency that would flow the wrong way; a rule
// whose folder does not exist yet lies dormant until the folder lands, at which
// point it starts guarding. Browser globals (localStorage & friends) are not
// imports, so dependency-cruiser cannot see them — dev/check-globals.mjs confines
// those to their adapters.
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
{
name: "no-circular",
comment:
"A dependency cycle. There are none today; keep it that way — cycles defeat the " +
"downward-only layering and make modules impossible to reason about in isolation.",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "core-stays-pure",
comment:
"core/ is the pure domain layer: it may depend on nothing but itself. No React, no " +
"OpenSheetMusicDisplay, and no reaching up into app/. Extract the pure half instead. " +
"Tests are exempt — a browser test may render a pure module through OSMD to verify it.",
severity: "error",
from: { path: "^core/", pathNot: "\\.(test|stories)\\.[jt]sx?$" },
to: {
path: [
"^app/",
"node_modules/react/",
"node_modules/react-dom/",
"node_modules/react-router/",
"node_modules/opensheetmusicdisplay/",
],
},
},
{
name: "ports-are-interfaces",
comment:
"app/ports/ holds interfaces only — it may import core types but nothing that " +
"implements or consumes a port (adapters, stores, hooks, components, routes, react).",
severity: "error",
from: { path: "^app/ports/", pathNot: "\\.(test|stories)\\.[jt]sx?$" },
to: {
path: [
"^app/adapters/",
"^app/stores/",
"^app/hooks/",
"^app/components/",
"^app/routes/",
"^app/lib/",
"node_modules/react/",
],
},
},
{
name: "adapters-point-down",
comment:
"app/adapters/ implements ports over core; it must not reach up into stores, hooks, " +
"components or routes.",
severity: "error",
from: { path: "^app/adapters/", pathNot: "\\.(test|stories)\\.[jt]sx?$" },
to: {
path: ["^app/stores/", "^app/hooks/", "^app/components/", "^app/routes/"],
},
},
{
name: "stores-point-down",
comment:
"app/stores/ is the single-source-of-truth state layer over core + ports; it must not " +
"import React glue (hooks), components, routes, or the transitional lib/ helpers — " +
"what a store needs from lib is a sign that piece belongs in core or a store.",
severity: "error",
from: { path: "^app/stores/", pathNot: "\\.(test|stories)\\.[jt]sx?$" },
to: { path: ["^app/hooks/", "^app/components/", "^app/routes/", "^app/lib/"] },
},
{
name: "ui-is-pure",
comment:
"app/components/ui/ are pure presentational primitives: props in, elements out. They " +
"may compose core + other ui + react, but never stores, adapters, contexts, effectful " +
"hooks or the legacy lib/.",
severity: "error",
from: { path: "^app/components/ui/", pathNot: "\\.(test|stories)\\.[jt]sx?$" },
to: {
path: [
"^app/stores/",
"^app/adapters/",
"^app/hooks/",
"^app/contexts/",
"^app/routes/",
"^app/lib/",
],
},
},
{
name: "adapters-only-at-the-composition-root",
comment:
"Concrete adapters are wired at the composition roots only: the services context " +
"(which injects them), the app root (which hands the storage-health signal to the " +
"banner and reads the theme before the provider exists), and the play route's " +
"static meta(). Everything else receives its capabilities through the provider, " +
"so a test can swap them for fakes.",
severity: "error",
from: {
pathNot: [
"\\.(test|stories)\\.[jt]sx?$",
"^app/adapters/",
"^app/contexts/services\\.tsx$",
// The MIDI provider composes the key-lights encoder onto its OWN send
// path — the connection and the preference it closes over are state
// only it holds, so injecting the encoder would move the wiring
// without moving the coupling. The seam the rule exists to protect is
// already injected here: a test swaps the MIDI access itself for
// fakeMidi, and the encoder has its own unit test.
"^app/contexts/midi\\.tsx$",
"^app/root\\.tsx$",
"^app/testing/",
// meta() runs outside the React tree, so the play route wires
// the real adapter for its prerender/title resolution directly.
"^app/routes/play\\.tsx$",
// The promo renderer and the piano demo are composition roots of their
// own: dev entry points that wire the real exporter and the real
// offline audio render in a browser.
"^dev/promo/",
"^dev/piano/",
],
},
to: { path: "^app/adapters/" },
},
{
name: "dev-depends-on-core",
comment:
"Build/import scripts under dev/ may only reach down into core/ (pure, shared music " +
"tooling), never sideways into the app UI layers — the app is the consumer of the " +
"catalogue dev builds, not a dependency of it. dev/promo/ and dev/piano/ are the " +
"exceptions: both run IN a browser (the video export needs WebCodecs and an audio " +
"context), and the whole purpose of each is to drive the app's own machinery — the " +
"painter and exporter for a promo clip, the offline audio render for the sampled " +
"piano — so that what they produce is what the app produces rather than a second " +
"renderer free to drift from it. They are composition roots, not app code.",
severity: "error",
from: { path: "^dev/", pathNot: "^dev/(promo|piano)/" },
to: { path: ["^app/"] },
},
{
name: "osmd-stays-at-the-surface",
comment:
"OpenSheetMusicDisplay is the concrete score renderer — a stateful engine the " +
"rendering surface (components, hooks, the scoreColor painting utilities, and a " +
"future renderer adapter) drives directly. The domain and state layers never " +
"touch it: pure score logic works on parsed documents and step models instead.",
severity: "error",
from: {
path: "^(core/|app/(stores|ports|contexts)/)",
pathNot: "\\.(test|stories)\\.[jt]sx?$",
},
to: { path: "node_modules/opensheetmusicdisplay/" },
},
{
name: "no-orphans",
comment:
"A module nothing imports. knip is the blocking dead-code gate (it understands the " +
"react-router entry graph); this warns as an architecture smell. Routes, entries and " +
"generated/config files are excluded because the framework wires them without an import.",
severity: "warn",
from: {
orphan: true,
pathNot: [
"\\.(test|stories)\\.[jt]sx?$",
"\\.d\\.ts$",
"(^|/)\\.[^/]+\\.(c|m)?[jt]s$", // dotfiles like this config
"^app/routes/", // react-router loads these by path string, not import
"^app/root\\.tsx$",
"^app/routes\\.ts$",
"^app/entry\\.server\\.tsx$",
"^app/test-setup",
"^app/paraglide/",
"^react-router\\.config\\.ts$",
],
},
to: {},
},
],
options: {
// Resolve TypeScript path/rootDirs the way Vite does, so the generated +types
// route modules and bundler resolution line up with the real build.
tsConfig: { fileName: "tsconfig.json" },
tsPreCompilationDeps: true,
enhancedResolveOptions: {
extensions: [".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs", ".json"],
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
// Third-party code, generated output, and non-code assets are not ours to lint.
doNotFollow: {
path: ["node_modules", "\\.react-router", "^app/paraglide/"],
},
exclude: {
// Vite query-suffixed and generated modules are unresolvable to a static
// analyzer; the .woff2?url font import and the paraglide output are not edges
// the architecture cares about.
path: ["\\?(url|raw|worker)$", "\\.react-router/", "^app/paraglide/"],
},
reporterOptions: {
dot: { collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)" },
},
},
};