@starci/eslint-canon-be 1.2.2 → 1.7.1

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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The rules that hold the shape of a Nest module in HFS (catalog R45 `BE_MODULE_SHAPE`).
3
+ *
4
+ * - `global-module-allowlist` lets `@Global()` appear only on the platform capabilities the slot manifest
5
+ * names (`ruleParams.be.globalModules`; read through `lib/slots.mjs`).
6
+ * A global module hides who depends on whom, so it is reserved for the three every capability needs.
7
+ * - `typed-module-definition` requires `ConfigurableModuleBuilder<Options>` to state its options type.
8
+ * - `static-module-register` requires `register`, `registerAsync`, `forRoot` and `forFeature` to be `static`.
9
+ * - `no-new-injectable` refuses `new SomeService()` (and other injectable-shaped names) outside specs: the
10
+ * container builds providers, so construction by hand bypasses its scope and its overrides.
11
+ * - `no-module-let` refuses mutable module-level state (`let` and `var` at the top of a file).
12
+ * - `one-module-per-file` allows one `@Module` class in a file, so one transport or application module has
13
+ * one home.
14
+ *
15
+ * `no-self-global-module` of the earlier canon is gone: it refused `@Global()` everywhere, which also refused
16
+ * the three platform capabilities HFS requires to be global.
17
+ */
18
+ import { decoratorName } from "./lib/ast.mjs"
19
+ import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
20
+ import { hfsParams } from "./lib/slots.mjs"
21
+
22
+ /** `platform/<capability>` of a file inside `src/modules/platform`, else null. */
23
+ const platformCapability = (filename) => {
24
+ const match = /\/src\/modules\/(platform\/[^/]+)\//.exec(filename)
25
+ return match ? match[1] : null
26
+ }
27
+
28
+ /** `@Global()` only on an allowlisted platform capability. */
29
+ export const globalModuleAllowlist = {
30
+ meta: {
31
+ type: "problem",
32
+ docs: { description: "`@Global()` only for the manifest's platform allowlist." },
33
+ schema: [
34
+ {
35
+ type: "object",
36
+ properties: { allowed: { type: "array", items: { type: "string" } } },
37
+ additionalProperties: false,
38
+ },
39
+ ],
40
+ messages: {
41
+ global: "`@Global()` is allowed only on {{allowed}}. A global module hides its dependants; import the module where it is used, or pass options through `register`.",
42
+ },
43
+ },
44
+ create(context) {
45
+ const filename = normalizePath(context.filename || context.getFilename())
46
+ if (isDeclarationFile(filename) || isTestLane(filename)) return {}
47
+ const allowed = context.options[0]?.allowed ?? hfsParams.globalModules
48
+ const capability = platformCapability(filename)
49
+ if (capability !== null && allowed.includes(capability)) return {}
50
+ return {
51
+ Decorator(node) {
52
+ if (decoratorName(node) === "Global") context.report({ node, messageId: "global", data: { allowed: allowed.map((name) => `\`${name}\``).join(", ") } })
53
+ },
54
+ }
55
+ },
56
+ }
57
+
58
+ /** A configurable module builder names its options type. */
59
+ export const typedModuleDefinition = {
60
+ meta: {
61
+ type: "problem",
62
+ docs: { description: "`new ConfigurableModuleBuilder<Options>()` states its options type." },
63
+ schema: [],
64
+ messages: {
65
+ untyped: "`ConfigurableModuleBuilder` has no options type. Write `new ConfigurableModuleBuilder<{{example}}Options>()` so `register` is checked.",
66
+ },
67
+ },
68
+ create(context) {
69
+ if (isDeclarationFile(context.filename || context.getFilename())) return {}
70
+ return {
71
+ NewExpression(node) {
72
+ if (node.callee.type !== "Identifier" || node.callee.name !== "ConfigurableModuleBuilder") return
73
+ const typed = (node.typeArguments ?? node.typeParameters)?.params?.length > 0
74
+ if (!typed) context.report({ node, messageId: "untyped", data: { example: "Capability" } })
75
+ },
76
+ }
77
+ },
78
+ }
79
+
80
+ const REGISTER_NAMES = new Set(["register", "registerAsync", "forRoot", "forRootAsync", "forFeature"])
81
+
82
+ /** The dynamic-module factory methods are `static`. */
83
+ export const staticModuleRegister = {
84
+ meta: {
85
+ type: "problem",
86
+ docs: { description: "`register`, `registerAsync`, `forRoot` and `forFeature` are static." },
87
+ schema: [],
88
+ messages: {
89
+ notStatic: "`{{name}}` builds a dynamic module and must be `static`; an instance method cannot be called from `imports`.",
90
+ },
91
+ },
92
+ create(context) {
93
+ if (isDeclarationFile(context.filename || context.getFilename())) return {}
94
+ return {
95
+ ClassDeclaration(node) {
96
+ if (!node.decorators?.some((decorator) => decoratorName(decorator) === "Module")) return
97
+ for (const member of node.body.body) {
98
+ if (member.type !== "MethodDefinition" || member.static || member.computed) continue
99
+ if (member.key.type === "Identifier" && REGISTER_NAMES.has(member.key.name)) {
100
+ context.report({ node: member, messageId: "notStatic", data: { name: member.key.name } })
101
+ }
102
+ }
103
+ },
104
+ }
105
+ },
106
+ }
107
+
108
+ const INJECTABLE_SUFFIX = /(?:Service|Repository|Guard|Interceptor|Resolver|Consumer|UseCase)$/
109
+
110
+ /** Providers are built by the container, not by `new`. */
111
+ export const noNewInjectable = {
112
+ meta: {
113
+ type: "problem",
114
+ docs: { description: "No `new SomeService()` for an injectable-shaped class outside a spec." },
115
+ schema: [],
116
+ messages: {
117
+ construct: "`new {{name}}()` builds a provider by hand, bypassing the container's scope and overrides. Inject it, or register it as a provider.",
118
+ },
119
+ },
120
+ create(context) {
121
+ const filename = normalizePath(context.filename || context.getFilename())
122
+ if (isDeclarationFile(filename) || isTestLane(filename)) return {}
123
+ return {
124
+ NewExpression(node) {
125
+ if (node.callee.type === "Identifier" && INJECTABLE_SUFFIX.test(node.callee.name)) {
126
+ context.report({ node, messageId: "construct", data: { name: node.callee.name } })
127
+ }
128
+ },
129
+ }
130
+ },
131
+ }
132
+
133
+ /** No mutable state at module scope. */
134
+ export const noModuleLet = {
135
+ meta: {
136
+ type: "problem",
137
+ docs: { description: "No `let` or `var` at module scope." },
138
+ schema: [],
139
+ messages: {
140
+ mutable: "`{{kind}}` at module scope is process-wide mutable state shared by every request and test. Keep the state in an injected provider.",
141
+ },
142
+ },
143
+ create(context) {
144
+ const filename = normalizePath(context.filename || context.getFilename())
145
+ if (isDeclarationFile(filename) || isTestLane(filename)) return {}
146
+ const check = (node) => {
147
+ if (node.type === "VariableDeclaration" && node.kind !== "const") context.report({ node, messageId: "mutable", data: { kind: node.kind } })
148
+ }
149
+ return {
150
+ Program(node) {
151
+ for (const statement of node.body) {
152
+ check(statement)
153
+ if (statement.type === "ExportNamedDeclaration" && statement.declaration) check(statement.declaration)
154
+ }
155
+ },
156
+ }
157
+ },
158
+ }
159
+
160
+ /** One `@Module` class per file. */
161
+ export const oneModulePerFile = {
162
+ meta: {
163
+ type: "problem",
164
+ docs: { description: "A file declares at most one `@Module` class." },
165
+ schema: [],
166
+ messages: {
167
+ many: "This file declares a second `@Module` class. One transport or application module has one file; split this one out.",
168
+ },
169
+ },
170
+ create(context) {
171
+ const filename = normalizePath(context.filename || context.getFilename())
172
+ if (isDeclarationFile(filename) || isTestLane(filename)) return {}
173
+ let seen = 0
174
+ return {
175
+ ClassDeclaration(node) {
176
+ if (!node.decorators?.some((decorator) => decoratorName(decorator) === "Module")) return
177
+ seen += 1
178
+ if (seen > 1) context.report({ node, messageId: "many" })
179
+ },
180
+ }
181
+ },
182
+ }
183
+
184
+ /** The rules this law contributes to the plugin. */
185
+ export const rules = {
186
+ "global-module-allowlist": globalModuleAllowlist,
187
+ "typed-module-definition": typedModuleDefinition,
188
+ "static-module-register": staticModuleRegister,
189
+ "no-new-injectable": noNewInjectable,
190
+ "no-module-let": noModuleLet,
191
+ "one-module-per-file": oneModulePerFile,
192
+ }
193
+
194
+ /** All start at error: HFS has one module shape and no baseline. */
195
+ export const recommended = {
196
+ "starci-be/global-module-allowlist": "error",
197
+ "starci-be/typed-module-definition": "error",
198
+ "starci-be/static-module-register": "error",
199
+ "starci-be/no-new-injectable": "error",
200
+ "starci-be/no-module-let": "error",
201
+ "starci-be/one-module-per-file": "error",
202
+ }