@starci/eslint-canon-be 1.1.0 → 1.2.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.
package/data-access.mjs CHANGED
@@ -1,14 +1,27 @@
1
1
  /**
2
2
  * The rules that hold `data-access.md`.
3
3
  *
4
- * Three rules, all reading a constructor parameter or a decorator, all exact - there is no
5
- * heuristic here and no judgement to get wrong, which is why all three can be switched on without
6
- * a burn-down in a codebase that already follows them.
4
+ * The first three read a constructor parameter or a decorator, all exact - there is no heuristic
5
+ * here and no judgement to get wrong, which is why all three can be switched on without a burn-down
6
+ * in a codebase that already follows them.
7
7
  *
8
- * The two rules canon does NOT try to enforce are worth naming so nobody adds them badly. Whether a
9
- * helper was handed the caller's transactional manager (DATA-4) needs the call graph, and whether a
10
- * relation should have been asked for at the call site (DATA-5) needs to know what the answer is
11
- * for. Both are read by a person; a rule that guessed would fire on correct code.
8
+ * The next two hold a narrower, exact slice of DATA-4 (Rule 5) and DATA-5 (Rule 6) - the slice of
9
+ * each that a single file settles on its own, with no call graph and no knowledge of intent needed.
10
+ *
11
+ * `no-outer-manager-in-transaction` catches the one shape of DATA-4 a parser can see: a
12
+ * `this.<field>.transaction(async (tx) => ...)` callback whose body reaches for `this.<field>`
13
+ * again instead of `tx`. That field held the manager from BEFORE the transaction opened, so a call
14
+ * through it runs and commits on its own - the exact "half of it was written" bug DATA-4 names. What
15
+ * it does NOT see, and cannot: whether a HELPER defined in another file was handed `tx` by ITS
16
+ * caller. That still needs the call graph and stays undecidable, same as the law says.
17
+ *
18
+ * `no-eager-relation` catches the one shape of DATA-5 a parser can see: `eager: true` written on a
19
+ * relation decorator. What it does NOT see: whether a call site correctly asked for a relation it
20
+ * needed. That still needs to know what the answer is for and stays undecidable, same as the law
21
+ * says.
22
+ *
23
+ * Both remaining gaps are read by a person; a rule that guessed at either would fire on correct
24
+ * code, which is how a correct rule gets disabled.
12
25
  */
13
26
 
14
27
  /** Forward-slash form of a filename, so Windows paths compare like every other path. */
@@ -149,24 +162,145 @@ export const requireEntityTableName = {
149
162
  },
150
163
  }
151
164
 
165
+ // -- DATA-4 (Rule 5) --------------------------------------------------------------------------------
166
+
167
+ /** Node types that rebind `this` - a `this.<field>` read past one of these belongs to a different call. */
168
+ const REBINDS_THIS = /^(?:FunctionDeclaration|FunctionExpression|ClassDeclaration|ClassExpression)$/
169
+
170
+ /** Walk every descendant of `node` that still runs under the SAME `this`, calling `visit` on each. */
171
+ const walkPreservingThis = (node, visit) => {
172
+ if (!node || typeof node.type !== "string") return
173
+ visit(node)
174
+ for (const key of Object.keys(node)) {
175
+ if (key === "parent") continue
176
+ const value = node[key]
177
+ if (Array.isArray(value)) {
178
+ for (const child of value) {
179
+ if (child && typeof child.type === "string" && !REBINDS_THIS.test(child.type)) walkPreservingThis(child, visit)
180
+ }
181
+ } else if (value && typeof value.type === "string" && !REBINDS_THIS.test(value.type)) {
182
+ walkPreservingThis(value, visit)
183
+ }
184
+ }
185
+ }
186
+
187
+ /** Everything inside a transaction receives the transactional manager, not the outer injected one. */
188
+ export const noOuterManagerInTransaction = {
189
+ meta: {
190
+ type: "problem",
191
+ docs: {
192
+ description:
193
+ "Inside `this.<field>.transaction(async (tx) => ...)`, every call uses `tx` - never `this.<field>` again (data-access.md DATA-4, Rule 5).",
194
+ },
195
+ schema: [],
196
+ messages: {
197
+ outerManager:
198
+ "This is inside `this.{{field}}.transaction(...)`, but it reaches for `this.{{field}}` again instead of `{{param}}`, the manager the callback received. `this.{{field}}` is the manager from BEFORE the transaction opened - a call through it runs and commits on its own, which is how half a write survives a rollback. Use `{{param}}`.",
199
+ },
200
+ },
201
+ create(context) {
202
+ return {
203
+ CallExpression(node) {
204
+ const callee = node.callee
205
+ if (callee.type !== "MemberExpression" || callee.computed) return
206
+ if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return
207
+ const object = callee.object
208
+ if (object.type !== "MemberExpression" || object.computed) return
209
+ if (object.object.type !== "ThisExpression") return
210
+ if (object.property.type !== "Identifier") return
211
+ const field = object.property.name
212
+ // Only the arrow-function callback is covered. A plain `function (tx) {}` callback does not
213
+ // inherit `this` from the enclosing method at all - TypeORM invokes it with no receiver, so
214
+ // `this.<field>` inside one throws rather than silently escaping the transaction, and this
215
+ // rule exists for the silent case.
216
+ const callback = node.arguments[0]
217
+ if (!callback || callback.type !== "ArrowFunctionExpression") return
218
+ const param = callback.params[0]
219
+ const paramName = param && param.type === "Identifier" ? param.name : "the transactional manager"
220
+ walkPreservingThis(callback.body, (descendant) => {
221
+ if (
222
+ descendant.type === "MemberExpression"
223
+ && !descendant.computed
224
+ && descendant.object.type === "ThisExpression"
225
+ && descendant.property.type === "Identifier"
226
+ && descendant.property.name === field
227
+ ) {
228
+ context.report({ node: descendant, messageId: "outerManager", data: { field, param: paramName } })
229
+ }
230
+ })
231
+ },
232
+ }
233
+ },
234
+ }
235
+
236
+ // -- DATA-5 (Rule 6) --------------------------------------------------------------------------------
237
+
238
+ /** Relation decorators whose `eager` option can grant the relation to every caller. */
239
+ const RELATION_DECORATORS = /^(?:ManyToOne|OneToOne|OneToMany|ManyToMany)$/
240
+
241
+ /** A relation is asked for by the call site that needs it; the entity grants no relation eagerly. */
242
+ export const noEagerRelation = {
243
+ meta: {
244
+ type: "problem",
245
+ docs: {
246
+ description: "A relation decorator carries no `eager: true` (data-access.md DATA-5, Rule 6).",
247
+ },
248
+ schema: [],
249
+ messages: {
250
+ eager:
251
+ "`@{{decorator}}(..., { eager: true })` grants this relation to every caller whether it asked for it or not, so a query that wants one column now pays for the whole tree. State the relation in the `relations` the call site writes, and drop `eager` here.",
252
+ },
253
+ },
254
+ create(context) {
255
+ return {
256
+ Decorator(node) {
257
+ const expression = node.expression
258
+ if (expression.type !== "CallExpression") return
259
+ if (expression.callee.type !== "Identifier" || !RELATION_DECORATORS.test(expression.callee.name)) return
260
+ for (const argument of expression.arguments) {
261
+ if (argument.type !== "ObjectExpression") continue
262
+ const eagerProperty = argument.properties.find(
263
+ (property) =>
264
+ property.type === "Property"
265
+ && !property.computed
266
+ && (property.key.name === "eager" || property.key.value === "eager"),
267
+ )
268
+ if (!eagerProperty) continue
269
+ if (eagerProperty.value.type === "Literal" && eagerProperty.value.value === true) {
270
+ context.report({ node, messageId: "eager", data: { decorator: expression.callee.name } })
271
+ }
272
+ }
273
+ },
274
+ }
275
+ },
276
+ }
277
+
152
278
  /** The rules this law contributes to the plugin. */
153
279
  export const rules = {
154
280
  "must-inject-entity-manager": mustInjectEntityManager,
155
281
  "no-injected-repository": noInjectedRepository,
156
282
  "require-entity-table-name": requireEntityTableName,
283
+ "no-outer-manager-in-transaction": noOuterManagerInTransaction,
284
+ "no-eager-relation": noEagerRelation,
157
285
  }
158
286
 
159
287
  /**
160
288
  * The level this law asks for, as the plugin's own opinion.
161
289
  *
162
- * All three measured at zero debt in the reference repository, so all three ship at `error`. A
163
- * repository adopting them into an existing tree measures first and lands anything above zero at
164
- * `warn` with the count beside it.
290
+ * All five measured against the reference repository (`src/**`) before shipping. The original three
291
+ * measured at zero debt and ship at `error`. `no-outer-manager-in-transaction` also measured zero -
292
+ * every `.transaction(async (tx) => ...)` callback in the reference repository already uses only the
293
+ * parameter it was handed - and ships at `error`. `no-eager-relation` also measured zero - no entity
294
+ * in the reference repository declares `eager: true` - and ships at `error` as well; a repository
295
+ * adopting these into a tree that is not already clean measures first and lands anything above zero
296
+ * at `warn` with the count beside it.
165
297
  */
166
298
  export const recommended = {
167
299
  "starci-be/must-inject-entity-manager": "error",
168
300
  "starci-be/no-injected-repository": "error",
169
301
  "starci-be/require-entity-table-name": "error",
302
+ "starci-be/no-outer-manager-in-transaction": "error",
303
+ "starci-be/no-eager-relation": "error",
170
304
  }
171
305
 
172
306
  /** Path helper shared with the other backend law modules. */
package/e2e-flow.mjs CHANGED
@@ -14,15 +14,23 @@
14
14
  * - E2E-2 (named steps, not one long case). Counting `it` blocks would refuse a flow that is
15
15
  * genuinely one step, and a rule whose first false positive is legitimate teaches authors that
16
16
  * the rule is wrong rather than that they are.
17
- * - E2E-5, E2E-6 and E2E-9 turn on WHAT is asserted or WHO is acting, which is meaning.
18
- * - E2E-8 (one place stands the world up) is a fact about a repository's fixtures, not about a
19
- * file, so it belongs to a gate that can see the tree rather than to a rule that sees one file.
17
+ * - E2E-5, E2E-6 and E2E-9 turn on WHAT is asserted or WHO is acting, which is meaning. E2E-6 in
18
+ * particular has no shape to deny: absence is proved through a stored row, a socket silence
19
+ * window, or a rejected promise depending on the flow, and a rule narrow enough to accept all
20
+ * three accepts everything. E2E-9's ban on a shared actor is a fact about TWO files at once,
21
+ * which one file's AST cannot see.
22
+ * - E2E-8 (one place stands the world up) is STILL a fact about a repository's fixtures for its
23
+ * first half, not about one file, so THAT half belongs to a gate that can see the tree. Its
24
+ * second half - "a spec file contains no wiring of its own" - is a per-file shape after all, the
25
+ * same kind of shape `e2e-uses-production-transport` already refuses for buses and workers, and
26
+ * is added below.
20
27
  * - E2E-10 (a flow logs nothing) is already held: `no-console` and `no-framework-logger` in the
21
28
  * observability law cover every call site, and a second rule saying the same thing in this lane
22
- * would double every report.
29
+ * would double every report. It stays undeclared here on purpose, not by oversight.
23
30
  *
24
- * The rules below enforce only exact syntax: transport bypass, absence of any persisted-state read,
25
- * direct provider imports, sleeps and branches. They do not pretend to judge business meaning.
31
+ * The rules below enforce only exact syntax: transport bypass, wiring built inline instead of
32
+ * through the shared world, absence of any persisted-state read, direct provider imports, sleeps and
33
+ * branches. They do not pretend to judge business meaning.
26
34
  */
27
35
 
28
36
  /** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
@@ -156,17 +164,59 @@ export const noBranchInFlowStep = {
156
164
  },
157
165
  }
158
166
 
167
+ // -- E2E-8 (the per-file half: a spec builds no wiring of its own) --------------------------------
168
+
169
+ /** A flow boots through the shared world; it does not build its own copy of the app. */
170
+ export const noWiringInFlowSpec = {
171
+ meta: {
172
+ type: "problem",
173
+ docs: {
174
+ description:
175
+ "An e2e spec boots through the shared world helper and builds no testing module of its own (E2E-8).",
176
+ },
177
+ schema: [],
178
+ messages: {
179
+ wiring:
180
+ "`Test.createTestingModule(...)` builds this flow's own copy of the app instead of booting through the shared world helper. E2E-8: the world is stood up in one place, and a spec file carries no wiring of its own - when the wiring changes, every flow that inlined its own module has to change with it, and two flows standing the world up slightly differently is how nobody learns where they differ. Boot through the shared helper and override only the one token this flow needs.",
181
+ },
182
+ },
183
+ create(context) {
184
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
185
+ return {
186
+ CallExpression(node) {
187
+ const callee = node.callee
188
+ if (!callee || callee.type !== "MemberExpression" || callee.computed) return
189
+ if (callee.object.type !== "Identifier" || callee.object.name !== "Test") return
190
+ if (callee.property.name !== "createTestingModule") return
191
+ context.report({ node: callee, messageId: "wiring" })
192
+ },
193
+ }
194
+ },
195
+ }
196
+
159
197
  /** The rules this law contributes to the plugin. */
160
198
  export const rules = {
161
199
  "e2e-uses-production-transport": e2eUsesProductionTransport,
162
200
  "no-sleep-in-flow": noSleepInFlow,
163
201
  "no-branch-in-flow-step": noBranchInFlowStep,
202
+ "no-wiring-in-flow-spec": noWiringInFlowSpec,
164
203
  }
165
204
 
166
205
  /**
167
206
  * The level this law asks for, as the plugin's own opinion.
168
207
  *
169
- * Both are exact and both fire on a syntactic shape rather than on a judgement, so neither carries
170
- * the false-positive risk that would justify adopting it at `warn`.
208
+ * The first three are exact and fire on a syntactic shape rather than on a judgement, so none of
209
+ * them carries the false-positive risk that would justify adopting it at `warn`.
210
+ *
211
+ * `no-wiring-in-flow-spec` is exact in the SAME sense - `Test.createTestingModule(...)` is a fixed
212
+ * shape, not a guess - but it was measured at 60 of 77 e2e files in the reference repository
213
+ * building their own testing module rather than calling `bootFlowWorld`. That is real, pre-existing
214
+ * debt this task does not fix, so the rule lands at `warn` with the count rather than `error`,
215
+ * exactly like a rule that has not yet been burned down.
171
216
  */
172
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-be/${name}`, "error"]))
217
+ export const recommended = {
218
+ "starci-be/e2e-uses-production-transport": "error",
219
+ "starci-be/no-sleep-in-flow": "error",
220
+ "starci-be/no-branch-in-flow-step": "error",
221
+ "starci-be/no-wiring-in-flow-spec": "warn", // no=60 of 77 - pre-existing, not burned down here
222
+ }
@@ -1,40 +1,93 @@
1
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
2
-
3
- /** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
4
- export const natsBridgeDeliveryContract = {
5
- meta: {
6
- type: "problem",
7
- docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
8
- schema: [],
9
- messages: {
10
- origin: "Drop envelopes whose producer id matches this instance before local emit.",
11
- digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
12
- },
13
- },
14
- create(context) {
15
- if (!normalizePath(context.filename || context.getFilename()).endsWith(
16
- "/event/nats/nats-bridge.service.ts",
17
- )) return {}
18
- return {
19
- "Program:exit"(node) {
20
- const source = context.sourceCode || context.getSourceCode()
21
- const text = source.getText()
22
- const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
23
- const digestIndex = text.indexOf("parsed.digest")
24
- const emitIndex = text.indexOf("this.eventEmitter.emit")
25
- if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
26
- context.report({ node, messageId: "origin" })
27
- }
28
- if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
29
- context.report({ node, messageId: "digest" })
30
- }
31
- },
32
- }
33
- },
34
- }
35
-
36
- export const rules = { "nats-bridge-delivery-contract": natsBridgeDeliveryContract }
37
-
38
- export const recommended = {
39
- "starci-be/nats-bridge-delivery-contract": "error",
40
- }
1
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
2
+
3
+ /** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
4
+ export const natsBridgeDeliveryContract = {
5
+ meta: {
6
+ type: "problem",
7
+ docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
8
+ schema: [],
9
+ messages: {
10
+ origin: "Drop envelopes whose producer id matches this instance before local emit.",
11
+ digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
12
+ },
13
+ },
14
+ create(context) {
15
+ if (!normalizePath(context.filename || context.getFilename()).endsWith(
16
+ "/event/nats/nats-bridge.service.ts",
17
+ )) return {}
18
+ return {
19
+ "Program:exit"(node) {
20
+ const source = context.sourceCode || context.getSourceCode()
21
+ const text = source.getText()
22
+ const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
23
+ const digestIndex = text.indexOf("parsed.digest")
24
+ const emitIndex = text.indexOf("this.eventEmitter.emit")
25
+ if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
26
+ context.report({ node, messageId: "origin" })
27
+ }
28
+ if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
29
+ context.report({ node, messageId: "digest" })
30
+ }
31
+ },
32
+ }
33
+ },
34
+ }
35
+
36
+ const isEmitCallOnEventEmitterService = (node) =>
37
+ node.callee.type === "MemberExpression" &&
38
+ !node.callee.computed &&
39
+ node.callee.property.type === "Identifier" &&
40
+ node.callee.property.name === "emit" &&
41
+ node.callee.object.type === "MemberExpression" &&
42
+ !node.callee.object.computed &&
43
+ node.callee.object.property.type === "Identifier" &&
44
+ node.callee.object.property.name === "eventEmitterService"
45
+
46
+ const findObjectProperty = (objectExpression, name) => {
47
+ if (!objectExpression || objectExpression.type !== "ObjectExpression") return null
48
+ return objectExpression.properties.find(
49
+ (property) => property.type === "Property" &&
50
+ !property.computed &&
51
+ (property.key.name === name || property.key.value === name),
52
+ ) || null
53
+ }
54
+
55
+ /** DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in one config, never at the call site. */
56
+ export const noCallSiteTransportOverride = {
57
+ meta: {
58
+ type: "problem",
59
+ docs: {
60
+ description: "DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in the central config; it is never chosen at the call site.",
61
+ },
62
+ schema: [],
63
+ messages: {
64
+ callsite: "DELIVERY Rule 2 (DELIVERY-2): do not pass `options.useLocal`/`options.useNats` to `eventEmitterService.emit(...)`. Transport is part of the event's definition in the central config, not a per-call decision -- a call site choosing it is a promise the config never made and a reader has to guess at.",
65
+ },
66
+ },
67
+ create(context) {
68
+ return {
69
+ CallExpression(node) {
70
+ if (!isEmitCallOnEventEmitterService(node)) return
71
+ const args = node.arguments[0]
72
+ const optionsProperty = findObjectProperty(args, "options")
73
+ if (!optionsProperty) return
74
+ const optionsValue = optionsProperty.value
75
+ const hasUseLocal = findObjectProperty(optionsValue, "useLocal")
76
+ const hasUseNats = findObjectProperty(optionsValue, "useNats")
77
+ if (hasUseLocal || hasUseNats) {
78
+ context.report({ node: optionsProperty, messageId: "callsite" })
79
+ }
80
+ },
81
+ }
82
+ },
83
+ }
84
+
85
+ export const rules = {
86
+ "nats-bridge-delivery-contract": natsBridgeDeliveryContract,
87
+ "no-call-site-transport-override": noCallSiteTransportOverride,
88
+ }
89
+
90
+ export const recommended = {
91
+ "starci-be/nats-bridge-delivery-contract": "error",
92
+ "starci-be/no-call-site-transport-override": "error",
93
+ }