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