@starci/eslint-canon-be 1.2.0 → 1.2.2
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 +7 -0
- package/README.md +70 -69
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +388 -389
- package/cqrs.mjs +302 -303
- package/data-access.mjs +306 -307
- package/e2e-flow.mjs +222 -222
- package/event-delivery.mjs +93 -93
- package/exception-identity.mjs +200 -200
- package/exceptions.mjs +262 -265
- package/index.mjs +107 -107
- package/lib/path.mjs +2 -0
- package/module-layering.mjs +426 -432
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/testing.mjs +500 -454
- package/transport.mjs +168 -168
- package/type-safety.mjs +510 -380
package/module-layering.mjs
CHANGED
|
@@ -1,432 +1,426 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
|
|
3
|
-
*
|
|
4
|
-
* Five rules, all reading import specifiers, export specifiers, decorators or a filename against
|
|
5
|
-
* the path of the file being linted. They are cheap and exact -- none of them touches disk -- which
|
|
6
|
-
* is the whole reason they can be `error`:
|
|
7
|
-
* - `must-deep-module-import` / `no-self-module-alias` hold LAYERING-1 and LAYERING-2.
|
|
8
|
-
* - `no-folder-reexport` holds LAYERING-5 / Law 7 (no file re-exports a folder).
|
|
9
|
-
* - `no-self-global-module` holds the decidable slice of LAYERING-4 / Law 6 (a capability may not
|
|
10
|
-
* declare itself `@Global()`; see that rule's own doc comment for what is deliberately still
|
|
11
|
-
* left undecided).
|
|
12
|
-
* - `no-relative-capability-escape` holds Law 8 (a relative import may not walk out of its own
|
|
13
|
-
* capability -- crossing a boundary always goes through the declared public alias).
|
|
14
|
-
*
|
|
15
|
-
* LAYERING-3 (a capability module importing a sibling capability) is NOT here, and the omission is
|
|
16
|
-
* deliberate rather than an oversight: deciding whether an imported module is a sibling capability
|
|
17
|
-
* or a nested child needs the module graph, and a rule reading one file at a time cannot see it. In
|
|
18
|
-
* the reference repository that rule exists and is scoped by path glob; a repository adopting this
|
|
19
|
-
* law should port it as a gate that walks the tree, not as a per-file rule that guesses. The same
|
|
20
|
-
* reasoning is why `no-self-global-module` stops at the `@Global()` decorator and does not also
|
|
21
|
-
* chase a hardcoded `isGlobal: true` passed into a `.register(...)` call -- this repository passes
|
|
22
|
-
* that option through at every level of legitimate nesting, and only the module graph can tell a
|
|
23
|
-
* nested child from a foreign capability.
|
|
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
|
-
prefix: "@tests/",
|
|
56
|
-
root: "/src/tests/",
|
|
57
|
-
metaAware: false,
|
|
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
|
-
const
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
const
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
if (part === "
|
|
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
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
check(node.source, node.source.value)
|
|
396
|
-
},
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
"starci-be/must-deep-module-import": "error",
|
|
428
|
-
"starci-be/no-self-module-alias": "error",
|
|
429
|
-
"starci-be/no-folder-reexport": "error",
|
|
430
|
-
"starci-be/no-self-global-module": "error",
|
|
431
|
-
"starci-be/no-relative-capability-escape": "error",
|
|
432
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
|
|
3
|
+
*
|
|
4
|
+
* Five rules, all reading import specifiers, export specifiers, decorators or a filename against
|
|
5
|
+
* the path of the file being linted. They are cheap and exact -- none of them touches disk -- which
|
|
6
|
+
* is the whole reason they can be `error`:
|
|
7
|
+
* - `must-deep-module-import` / `no-self-module-alias` hold LAYERING-1 and LAYERING-2.
|
|
8
|
+
* - `no-folder-reexport` holds LAYERING-5 / Law 7 (no file re-exports a folder).
|
|
9
|
+
* - `no-self-global-module` holds the decidable slice of LAYERING-4 / Law 6 (a capability may not
|
|
10
|
+
* declare itself `@Global()`; see that rule's own doc comment for what is deliberately still
|
|
11
|
+
* left undecided).
|
|
12
|
+
* - `no-relative-capability-escape` holds Law 8 (a relative import may not walk out of its own
|
|
13
|
+
* capability -- crossing a boundary always goes through the declared public alias).
|
|
14
|
+
*
|
|
15
|
+
* LAYERING-3 (a capability module importing a sibling capability) is NOT here, and the omission is
|
|
16
|
+
* deliberate rather than an oversight: deciding whether an imported module is a sibling capability
|
|
17
|
+
* or a nested child needs the module graph, and a rule reading one file at a time cannot see it. In
|
|
18
|
+
* the reference repository that rule exists and is scoped by path glob; a repository adopting this
|
|
19
|
+
* law should port it as a gate that walks the tree, not as a per-file rule that guesses. The same
|
|
20
|
+
* reasoning is why `no-self-global-module` stops at the `@Global()` decorator and does not also
|
|
21
|
+
* chase a hardcoded `isGlobal: true` passed into a `.register(...)` call -- this repository passes
|
|
22
|
+
* that option through at every level of legitimate nesting, and only the module graph can tell a
|
|
23
|
+
* nested child from a foreign capability.
|
|
24
|
+
*
|
|
25
|
+
* Module tiers (`domain/`, `platform/`, `integrations/`) contain capabilities. Their name is the
|
|
26
|
+
* second segment; an alias ending there names its explicit index.ts public entry. The first segment
|
|
27
|
+
* alone names a tier, not a capability.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { normalizePath } from "./lib/path.mjs"
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Category folders that hold capabilities rather than being one.
|
|
34
|
+
*
|
|
35
|
+
* Under these the capability name is the second segment. `lib` remains recognized while older
|
|
36
|
+
* sources transition to the HFS tiers; the architecture check rejects it as a new tier.
|
|
37
|
+
*/
|
|
38
|
+
const META_ROOTS = new Set(["domain", "platform", "integrations", "lib"])
|
|
39
|
+
|
|
40
|
+
/** The aliases a capability is reachable through. */
|
|
41
|
+
const ALIASES = [
|
|
42
|
+
{
|
|
43
|
+
prefix: "@modules/",
|
|
44
|
+
root: "/src/modules/",
|
|
45
|
+
metaAware: true,
|
|
46
|
+
publicEntry: true,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
prefix: "@features/",
|
|
50
|
+
root: "/src/features/",
|
|
51
|
+
metaAware: false,
|
|
52
|
+
publicEntry: true,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
prefix: "@tests/",
|
|
56
|
+
root: "/src/tests/",
|
|
57
|
+
metaAware: false,
|
|
58
|
+
publicEntry: false,
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
// -- LAYERING-1 ------------------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** An import names a capability's explicit public entry or a file, never a root or tier. */
|
|
65
|
+
export const mustDeepModuleImport = {
|
|
66
|
+
meta: {
|
|
67
|
+
type: "problem",
|
|
68
|
+
docs: { description: "Import a capability public entry or a file, never an alias root or tier." },
|
|
69
|
+
schema: [],
|
|
70
|
+
messages: {
|
|
71
|
+
barrel:
|
|
72
|
+
"`{{specifier}}` stops before a capability public entry or file. Import an explicit capability index.ts API or the declaring file.",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
create(context) {
|
|
76
|
+
const check = (node, specifier) => {
|
|
77
|
+
if (typeof specifier !== "string") return
|
|
78
|
+
const alias = ALIASES.find((candidate) => specifier.startsWith(candidate.prefix))
|
|
79
|
+
if (!alias) return
|
|
80
|
+
|
|
81
|
+
const rest = specifier.slice(alias.prefix.length)
|
|
82
|
+
if (!rest) {
|
|
83
|
+
context.report({ node, messageId: "barrel", data: { specifier } })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const parts = rest.split("/")
|
|
87
|
+
const barrelDepth = alias.metaAware && META_ROOTS.has(parts[0])
|
|
88
|
+
? 2
|
|
89
|
+
: 1
|
|
90
|
+
if (parts.length < barrelDepth || (!alias.publicEntry && parts.length === barrelDepth)) {
|
|
91
|
+
context.report({ node, messageId: "barrel", data: { specifier } })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ImportDeclaration(node) {
|
|
96
|
+
check(node.source, node.source.value)
|
|
97
|
+
},
|
|
98
|
+
ExportNamedDeclaration(node) {
|
|
99
|
+
if (node.source) check(node.source, node.source.value)
|
|
100
|
+
},
|
|
101
|
+
ExportAllDeclaration(node) {
|
|
102
|
+
if (node.source) check(node.source, node.source.value)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// -- LAYERING-2 ------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/** The aliases a file's OWN capability is reachable through, or null when it is in none. */
|
|
111
|
+
const selfAliases = (filename) => {
|
|
112
|
+
const file = normalizePath(filename)
|
|
113
|
+
for (const alias of ALIASES) {
|
|
114
|
+
const at = file.lastIndexOf(alias.root)
|
|
115
|
+
if (at === -1) continue
|
|
116
|
+
const parts = file.slice(at + alias.root.length).split("/")
|
|
117
|
+
if (!parts[0]) continue
|
|
118
|
+
if (alias.metaAware && META_ROOTS.has(parts[0]) && parts.length >= 2) {
|
|
119
|
+
// reachable long (`domain/task`) and short (`task`), so both are self
|
|
120
|
+
return {
|
|
121
|
+
prefix: alias.prefix,
|
|
122
|
+
keys: [`${parts[0]}/${parts[1]}`, parts[1]],
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
prefix: alias.prefix,
|
|
127
|
+
keys: [parts[0]],
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Inside a capability, imports are relative -- never the capability's own public alias. */
|
|
134
|
+
export const noSelfModuleAlias = {
|
|
135
|
+
meta: {
|
|
136
|
+
type: "problem",
|
|
137
|
+
docs: { description: "Inside a capability, import relatively rather than through its own alias." },
|
|
138
|
+
schema: [],
|
|
139
|
+
messages: {
|
|
140
|
+
self:
|
|
141
|
+
"`{{specifier}}` reaches this capability through its own public alias. That is the capability talking to itself through its front door: a cycle magnet, and a lie about the boundary - the alias exists to say \"this comes from elsewhere\", so using it for something that does not is exactly the signal that stops meaning anything. Use a relative import.",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
create(context) {
|
|
145
|
+
const self = selfAliases(context.filename || context.getFilename())
|
|
146
|
+
if (!self) return {}
|
|
147
|
+
|
|
148
|
+
const check = (node, specifier) => {
|
|
149
|
+
if (typeof specifier !== "string" || !specifier.startsWith(self.prefix)) return
|
|
150
|
+
const rest = specifier.slice(self.prefix.length)
|
|
151
|
+
const hit = self.keys.some((key) => rest === key || rest.startsWith(`${key}/`))
|
|
152
|
+
if (hit) context.report({ node, messageId: "self", data: { specifier } })
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ImportDeclaration(node) {
|
|
156
|
+
check(node.source, node.source.value)
|
|
157
|
+
},
|
|
158
|
+
ExportNamedDeclaration(node) {
|
|
159
|
+
if (node.source) check(node.source, node.source.value)
|
|
160
|
+
},
|
|
161
|
+
ExportAllDeclaration(node) {
|
|
162
|
+
if (node.source) check(node.source, node.source.value)
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// -- shared path arithmetic --------------------------------------------------------------------
|
|
169
|
+
//
|
|
170
|
+
// No fs anywhere below: every check in this file, old and new, reads a specifier string against a
|
|
171
|
+
// filename string. Resolving a relative specifier therefore has to be done the same way -- pure
|
|
172
|
+
// segment arithmetic against the importer's own path, never a disk lookup.
|
|
173
|
+
|
|
174
|
+
/** POSIX-style dirname of an already-normalized (forward-slash) path. */
|
|
175
|
+
const dirnameOf = (file) => {
|
|
176
|
+
const at = file.lastIndexOf("/")
|
|
177
|
+
return at === -1 ? "" : file.slice(0, at)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolves a relative specifier against the file that imports it, without touching disk.
|
|
182
|
+
*
|
|
183
|
+
* `..` pops a segment, `.` is a no-op, everything else pushes. Whether the importer's directory
|
|
184
|
+
* was rooted (a leading `/`) is preserved, so a POSIX-style absolute path stays POSIX-style after
|
|
185
|
+
* resolution; a Windows drive-letter path (`D:/repo/...`) already carries its root as its first
|
|
186
|
+
* segment and needs nothing extra.
|
|
187
|
+
*/
|
|
188
|
+
const resolveRelativeSpecifier = (filename, specifier) => {
|
|
189
|
+
const dir = dirnameOf(normalizePath(filename))
|
|
190
|
+
const rooted = dir.startsWith("/")
|
|
191
|
+
const stack = dir.split("/").filter(Boolean)
|
|
192
|
+
for (const part of specifier.split("/")) {
|
|
193
|
+
if (part === "" || part === ".") continue
|
|
194
|
+
if (part === "..") {
|
|
195
|
+
stack.pop()
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
stack.push(part)
|
|
199
|
+
}
|
|
200
|
+
return (rooted ? "/" : "") + stack.join("/")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// -- LAYERING-5 / Law 7 -------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/** This repository's own convention, and the law's Anchor: an `index.*` file at any depth. */
|
|
206
|
+
const INDEX_FILE_RE = /(^|\/)index\.(ts|tsx|js|jsx|mjs|cjs)$/
|
|
207
|
+
|
|
208
|
+
/** A specifier so bare it can only ever name a directory: `.`, `./`, `..`, `../`, or trailing `/`. */
|
|
209
|
+
const isBareFolderSpecifier = (specifier) =>
|
|
210
|
+
specifier === "." || specifier === "./" || specifier === ".." || specifier === "../" || specifier.endsWith("/")
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* LAYERING-5 / module-layering.md Law 7: no file re-exports a folder.
|
|
214
|
+
*
|
|
215
|
+
* Two certain signs, neither needing a disk lookup:
|
|
216
|
+
* - a bare-dot or trailing-slash specifier names a directory by construction -- no file path ever
|
|
217
|
+
* looks like that, aliased or relative, so there is nothing to guess;
|
|
218
|
+
* - an export-star in an `index.*` public entry hides the capability's API rather than listing
|
|
219
|
+
* its exports explicitly, even when other named exports appear beside it.
|
|
220
|
+
*
|
|
221
|
+
* What this deliberately does NOT flag: a single bridging re-export naming a real file
|
|
222
|
+
* (`export { AiInvokeService } from '@modules/ai/ai-invoke.service'`), which LAYERING-1's own
|
|
223
|
+
* common-business-situations list keeps legitimate, and a relative barrel that names a real
|
|
224
|
+
* multi-segment path with no file extension (`export * from './services'`) -- telling that apart
|
|
225
|
+
* from a real file with an implicit extension needs the filesystem, which no rule in this canon
|
|
226
|
+
* touches; see the module doc comment for why that gap is left to LAYERING-1's alias-only reach
|
|
227
|
+
* plus this filename check rather than guessed at.
|
|
228
|
+
*/
|
|
229
|
+
export const noFolderReexport = {
|
|
230
|
+
meta: {
|
|
231
|
+
type: "problem",
|
|
232
|
+
docs: {
|
|
233
|
+
description:
|
|
234
|
+
"LAYERING-5 / HFS: no file re-exports a folder, and public index files list named exports.",
|
|
235
|
+
},
|
|
236
|
+
schema: [],
|
|
237
|
+
messages: {
|
|
238
|
+
bareSpecifier:
|
|
239
|
+
"`{{specifier}}` names a directory, not a file. LAYERING-5: a capability's public surface is the files call sites actually import -- re-exporting a whole folder makes that surface a list nobody reads instead. Export the specific file.",
|
|
240
|
+
indexBarrel:
|
|
241
|
+
"`{{name}}` is a public entry and must list explicit named exports. Replace export-star with the symbols this capability exposes.",
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
create(context) {
|
|
245
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
246
|
+
|
|
247
|
+
const checkSpecifier = (node, specifier) => {
|
|
248
|
+
if (typeof specifier !== "string") return
|
|
249
|
+
if (isBareFolderSpecifier(specifier)) {
|
|
250
|
+
context.report({ node, messageId: "bareSpecifier", data: { specifier } })
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
ImportDeclaration(node) {
|
|
256
|
+
checkSpecifier(node.source, node.source.value)
|
|
257
|
+
},
|
|
258
|
+
ExportNamedDeclaration(node) {
|
|
259
|
+
if (node.source) checkSpecifier(node.source, node.source.value)
|
|
260
|
+
},
|
|
261
|
+
ExportAllDeclaration(node) {
|
|
262
|
+
if (node.source) checkSpecifier(node.source, node.source.value)
|
|
263
|
+
if (INDEX_FILE_RE.test(filename) && !isBareFolderSpecifier(node.source?.value)) {
|
|
264
|
+
context.report({
|
|
265
|
+
node,
|
|
266
|
+
messageId: "indexBarrel",
|
|
267
|
+
data: { name: filename.slice(filename.lastIndexOf("/") + 1) },
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// -- LAYERING-4 / Law 6 (partial) ----------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
/** Files that build their own throwaway test graph rather than wiring a production capability. */
|
|
278
|
+
const TEST_FILE_RE = /\.(spec|test)\.(ts|tsx|js|jsx|mjs|cjs)$/
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* LAYERING-4 / module-layering.md Law 6, the one slice of it decidable without whole-application
|
|
282
|
+
* knowledge: a capability declaring `@Global()` on itself.
|
|
283
|
+
*
|
|
284
|
+
* The rest of Law 6 -- which capabilities exist at all, and what starts before what -- has no rule
|
|
285
|
+
* here, and the omission is deliberate, not an oversight. Both need knowledge a single file does
|
|
286
|
+
* not have: "which capabilities exist" is a fact about the whole application graph, and "what
|
|
287
|
+
* starts first" is recorded today as an English comment (`apps/core/src/main.ts`'s side-effect
|
|
288
|
+
* import), which a rule can only pattern-match on wording and would be guessing at meaning, not
|
|
289
|
+
* reading syntax.
|
|
290
|
+
*
|
|
291
|
+
* `isGlobal` passed as a plain, caller-supplied OPTION (`.register({ isGlobal: false })`,
|
|
292
|
+
* `.setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal
|
|
293
|
+
* }))`) is deliberately OUT of scope too, and measuring the reference repository is why: it passes
|
|
294
|
+
* that literal option at every level of nesting, including a capability's own top module wiring its
|
|
295
|
+
* own nested children (`CoreModule.register({ isGlobal: true })` inside `features/api/api.module.ts`,
|
|
296
|
+
* which is nesting, not the sideways declaration Law 6 refuses). Telling "a nested child" from "a
|
|
297
|
+
* foreign capability" needs the module graph -- the exact reason LAYERING-3 stays a documented code
|
|
298
|
+
* rather than an enforced one, and the same reason applies here. `@Global()` carries none of that
|
|
299
|
+
* ambiguity: it declares the module global unconditionally, for every caller, which is precisely
|
|
300
|
+
* the fact Law 6 says only a composition root gets to state.
|
|
301
|
+
*/
|
|
302
|
+
export const noSelfGlobalModule = {
|
|
303
|
+
meta: {
|
|
304
|
+
type: "problem",
|
|
305
|
+
docs: {
|
|
306
|
+
description:
|
|
307
|
+
"LAYERING-4 / module-layering.md Law 6: which capabilities are global is stated only at the composition root -- a capability's own file may not declare itself `@Global()`.",
|
|
308
|
+
},
|
|
309
|
+
schema: [],
|
|
310
|
+
messages: {
|
|
311
|
+
global:
|
|
312
|
+
"`@Global()` declares this module global from inside its own capability. LAYERING-4: whether a capability is global is a fact about the APPLICATION, decided per-application at the composition root (`AiModule.register({ isGlobal: true })` in `apps/*/src/app.module.ts`), not a fact a capability gets to state about itself. Remove the decorator and pass `isGlobal` through as a caller-supplied option instead.",
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
create(context) {
|
|
316
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
317
|
+
if (TEST_FILE_RE.test(filename)) return {}
|
|
318
|
+
const self = selfAliases(filename)
|
|
319
|
+
if (!self || self.prefix === "@tests/") return {}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
Decorator(node) {
|
|
323
|
+
const expr = node.expression
|
|
324
|
+
const name =
|
|
325
|
+
expr.type === "CallExpression"
|
|
326
|
+
? expr.callee.type === "Identifier"
|
|
327
|
+
? expr.callee.name
|
|
328
|
+
: null
|
|
329
|
+
: expr.type === "Identifier"
|
|
330
|
+
? expr.name
|
|
331
|
+
: null
|
|
332
|
+
if (name === "Global") {
|
|
333
|
+
context.report({ node, messageId: "global" })
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// -- Law 8 ("moved to another repository") -------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Law 8 / the law's own moved-capability test, read as a rule: a relative specifier may never leave
|
|
344
|
+
* the capability it is written in. `LAYERING-2` already says the path must be relative INSIDE a
|
|
345
|
+
* capability; this is the fact `LAYERING-2` does not check -- that a relative path can just as
|
|
346
|
+
* easily walk OUT (`../../other-capability/thing`), reaching a sibling capability without ever
|
|
347
|
+
* naming it through the declared public alias `LAYERING-1` requires. A file doing that could not be
|
|
348
|
+
* moved to another repository with its capability and still resolve: the relative path assumes the
|
|
349
|
+
* sibling is sitting right there on disk.
|
|
350
|
+
*
|
|
351
|
+
* Certain because it never touches disk either -- both sides of the comparison (the importer's own
|
|
352
|
+
* path, and the specifier resolved against it) run through the exact same `selfAliases` lookup this
|
|
353
|
+
* file already uses for `LAYERING-2`, so "different capability" is read off two path strings, not
|
|
354
|
+
* guessed from specifier text.
|
|
355
|
+
*/
|
|
356
|
+
export const noRelativeCapabilityEscape = {
|
|
357
|
+
meta: {
|
|
358
|
+
type: "problem",
|
|
359
|
+
docs: {
|
|
360
|
+
description:
|
|
361
|
+
"Law 8 (module-layering.md): a capability moved to another repository still resolves every import it declares -- a relative specifier may not walk out of its own capability.",
|
|
362
|
+
},
|
|
363
|
+
schema: [],
|
|
364
|
+
messages: {
|
|
365
|
+
escape:
|
|
366
|
+
"`{{specifier}}` is a relative import that leaves the `{{from}}` capability and reaches `{{to}}`. Law 8: this capability could not be moved to another repository and still resolve that path -- cross a capability boundary through its declared public alias (LAYERING-1), never by walking out with `../`.",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
create(context) {
|
|
370
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
371
|
+
const self = selfAliases(filename)
|
|
372
|
+
if (!self) return {}
|
|
373
|
+
|
|
374
|
+
const check = (node, specifier) => {
|
|
375
|
+
if (typeof specifier !== "string") return
|
|
376
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return
|
|
377
|
+
const resolved = resolveRelativeSpecifier(filename, specifier)
|
|
378
|
+
const target = selfAliases(resolved)
|
|
379
|
+
if (!target) return
|
|
380
|
+
if (target.prefix === self.prefix && target.keys[0] === self.keys[0]) return
|
|
381
|
+
context.report({
|
|
382
|
+
node,
|
|
383
|
+
messageId: "escape",
|
|
384
|
+
data: { specifier, from: self.keys[0], to: target.keys[0] },
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
ImportDeclaration(node) {
|
|
389
|
+
check(node.source, node.source.value)
|
|
390
|
+
},
|
|
391
|
+
ExportNamedDeclaration(node) {
|
|
392
|
+
if (node.source) check(node.source, node.source.value)
|
|
393
|
+
},
|
|
394
|
+
ExportAllDeclaration(node) {
|
|
395
|
+
if (node.source) check(node.source, node.source.value)
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The rules this law contributes to the plugin. */
|
|
402
|
+
export const rules = {
|
|
403
|
+
"must-deep-module-import": mustDeepModuleImport,
|
|
404
|
+
"no-self-module-alias": noSelfModuleAlias,
|
|
405
|
+
"no-folder-reexport": noFolderReexport,
|
|
406
|
+
"no-self-global-module": noSelfGlobalModule,
|
|
407
|
+
"no-relative-capability-escape": noRelativeCapabilityEscape,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
412
|
+
*
|
|
413
|
+
* All five measured at zero debt in the reference repository -- the three added alongside
|
|
414
|
+
* `no-folder-reexport`, `no-self-global-module` and `no-relative-capability-escape` fired zero
|
|
415
|
+
* times across the repository's ~4700 `src/**` TypeScript files, verified against a harness that
|
|
416
|
+
* DOES fire on synthetic violations of each (so the zero is measured, not a broken rule going
|
|
417
|
+
* quiet). A repository adopting any of these five into an existing tree will not be at zero: land
|
|
418
|
+
* above zero at `warn` with the count, exactly as the burn-down procedure says.
|
|
419
|
+
*/
|
|
420
|
+
export const recommended = {
|
|
421
|
+
"starci-be/must-deep-module-import": "error",
|
|
422
|
+
"starci-be/no-self-module-alias": "error",
|
|
423
|
+
"starci-be/no-folder-reexport": "error",
|
|
424
|
+
"starci-be/no-self-global-module": "error",
|
|
425
|
+
"starci-be/no-relative-capability-escape": "error",
|
|
426
|
+
}
|