@starci/eslint-canon-be 1.2.2 → 1.7.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/cqrs.mjs CHANGED
@@ -22,10 +22,6 @@
22
22
  * have reported on more correct files than wrong ones, which is precisely the failure mode this law
23
23
  * warns against - it would have taught everybody to disable it. The law's own "documented" tier for
24
24
  * CQRS-1 is the honest answer, not a gap to close.
25
- *
26
- * Rule 5 (CQRS-5) is the opposite case: a handler returning `{ success: false }` or `{ error }`
27
- * instead of throwing is a shape a parser CAN see, narrowly, without guessing at intent - and this
28
- * reference repository has two real handlers doing exactly that today.
29
25
  */
30
26
 
31
27
  import { normalizePath } from "./lib/path.mjs"
@@ -150,153 +146,23 @@ export const messageCarriesParamsOnly = {
150
146
  },
151
147
  }
152
148
 
153
- // -- CQRS-7 ----------------------------------------------------------------------------------------
154
-
155
- /**
156
- * A handler has its twin spec beside it.
157
- *
158
- * The check is the FILENAME, not the disk: a rule that stats the filesystem reports differently
159
- * depending on what else is checked out, and a rule whose answer depends on the working tree is one
160
- * nobody can reproduce. This rule fires on the handler and names the file that should exist; a gate
161
- * that walks the tree is what counts them.
162
- */
163
- export const handlerHasTwinSpec = {
164
- meta: {
165
- type: "problem",
166
- docs: { description: "A CQRS handler declares its twin spec filename in the same folder." },
167
- schema: [
168
- {
169
- type: "object",
170
- properties: { specs: { type: "array", items: { type: "string" } } },
171
- additionalProperties: false,
172
- },
173
- ],
174
- messages: {
175
- missing:
176
- "`{{operation}}.handler.ts` has no `{{operation}}.handler.spec.ts` beside it. The decisions live in the handler, so an untested handler is an untested decision - and a spec in the same folder is found by whoever edits the handler rather than by whoever goes looking in a test tree.",
177
- },
178
- },
179
- create(context) {
180
- const filename = context.filename || context.getFilename()
181
- const operation = handlerFile(filename)
182
- if (!operation) return {}
183
- const options = context.options && context.options[0]
184
- const known = options && Array.isArray(options.specs) ? options.specs : null
185
- if (!known) return {}
186
- if (known.includes(`${operation}.handler.spec.ts`)) return {}
187
- return {
188
- "Program:exit"(node) {
189
- context.report({ node, messageId: "missing", data: { operation } })
190
- },
191
- }
192
- },
193
- }
194
-
195
- // -- CQRS-5 ----------------------------------------------------------------------------------------
196
-
197
- /** The property name that turns a returned object into an encoded failure, or null. */
198
- const encodedFailureKey = (node) => {
199
- if (!node || node.type !== "ObjectExpression") return null
200
- for (const prop of node.properties || []) {
201
- if (prop.type !== "Property") continue
202
- const key = prop.key
203
- const name = key.type === "Identifier" ? key.name : key.type === "Literal" && typeof key.value === "string" ? key.value : null
204
- if (!name) continue
205
- // `error` is the encode regardless of what carries it - a caught exception, a message string,
206
- // an upstream response. Its presence on a RETURNED object is the tell: the branch that found
207
- // the failure chose to hand it back instead of throwing it.
208
- if (name === "error") return name
209
- // `success`/`ok` only encode failure at `false` - `success: true` on the happy path is not
210
- // this pattern, and flagging it would report the correct half of every handler that uses this
211
- // shape at all.
212
- if ((name === "success" || name === "ok") && prop.value && prop.value.type === "Literal" && prop.value.value === false) {
213
- return name
214
- }
215
- }
216
- return null
217
- }
218
-
219
- /** The nearest enclosing method named `process`, or null. */
220
- const enclosingProcessMethod = (node) => {
221
- for (let current = node.parent; current; current = current.parent) {
222
- if (current.type === "MethodDefinition" && current.key && current.key.name === "process") return current
223
- }
224
- return null
225
- }
226
-
227
- /** The nearest enclosing class declaration, or null. */
228
- const enclosingClass = (node) => {
229
- for (let current = node.parent; current; current = current.parent) {
230
- if (current.type === "ClassDeclaration") return current
231
- }
232
- return null
233
- }
234
-
235
- /** A handler that cannot do its work throws the domain exception naming why - it does not return one. */
236
- export const noHandlerEncodedFailure = {
237
- meta: {
238
- type: "problem",
239
- docs: {
240
- description:
241
- "A CQRS handler throws the domain exception that names a failure; it does not return a success/error shape (Law 5, CQRS-5).",
242
- },
243
- schema: [],
244
- messages: {
245
- encoded:
246
- "`{{name}}`'s `process` returns an object carrying `{{key}}` instead of throwing. CQRS-5: a handler that cannot do its work throws the domain exception naming why; an encoded `{{key}}` field asks every caller to decode this shape its own way, and the REASON for the refusal never arrives - only the fact that there was one. Throw the exception that names why, in the branch that discovers it, and let the success path return the value itself.",
247
- },
248
- },
249
- create(context) {
250
- const filename = context.filename || context.getFilename()
251
- const operation = handlerFile(filename)
252
- if (!operation) return {}
253
- return {
254
- ReturnStatement(node) {
255
- const key = encodedFailureKey(node.argument)
256
- if (!key) return
257
- // Scoped to `process` on a decorated handler, same as CQRS-3 - a private mapper in the
258
- // same file returning an unrelated object shaped like `{ error: ... }` is not this
259
- // handler's refusal path, and a rule that fired anywhere in the file would not be able to
260
- // tell the two apart.
261
- if (!enclosingProcessMethod(node)) return
262
- const klass = enclosingClass(node)
263
- if (!klass || !decoratorNames(klass).some((name) => HANDLER_DECORATORS.test(name))) return
264
- context.report({ node: node.argument, messageId: "encoded", data: { name: operation, key } })
265
- },
266
- }
267
- },
268
- }
269
-
270
149
  /** The rules this law contributes to the plugin. */
271
150
  export const rules = {
272
151
  "handler-overrides-process": handlerOverridesProcess,
273
152
  "message-carries-params-only": messageCarriesParamsOnly,
274
- "handler-has-twin-spec": handlerHasTwinSpec,
275
- "no-handler-encoded-failure": noHandlerEncodedFailure,
276
153
  }
277
154
 
278
155
  /**
279
156
  * The level this law asks for, as the plugin's own opinion.
280
157
  *
281
- * MEASURED AGAINST THE REFERENCE REPOSITORY, not estimated: 3 of 141 handlers override `execute`,
282
- * 2 of 138 messages carry something other than a single `params`, and 2 of 152 handlers return an
283
- * encoded failure (`sync-flashcard-quiz-session-progress.handler.ts` and
284
- * `sync-mock-interview-session-turns.handler.ts` both `return { success: false }` where a late sync
285
- * finds its session already closed). Debt above zero means the rule lands at `warn` with the count
286
- * beside it, gets burned down, and flips to `error` at zero - shipping at `error` with debt
287
- * outstanding blocks every commit that touches an offender.
288
- *
289
- * COUNT ONLY THIS RULE'S REPORTS when measuring. Inline `eslint-disable` comments in the source
290
- * refer to rules a minimal measuring config never loads, and eslint reports each of those as a
291
- * problem of its own - which inflated the first measurement of every rule here by the same seven
292
- * files, and this rule's by two.
158
+ * Both measured at zero debt in the reference repository (3 of 141 handlers overrode `execute` and
159
+ * 2 of 138 messages carried more than `params`; both were burned down), so both are `error`.
293
160
  *
294
- * `handler-has-twin-spec` is off by default because it needs the folder listing passed as an
295
- * option; a repository that wires it supplies the listing from its own gate.
161
+ * The twin-spec count rule and the encoded-failure rule are gone. Test selection follows behavior and
162
+ * risk, not a filename count; and HFS returns an expected refusal as a typed outcome union, so a
163
+ * handler that returns `{ success: false }` is no longer a defect this canon can call by shape.
296
164
  */
297
165
  export const recommended = {
298
- "starci-be/handler-overrides-process": "error", // no=0 of 141 - burned down from 3
299
- "starci-be/message-carries-params-only": "error", // no=0 of 138 - burned down from 2
300
- "starci-be/handler-has-twin-spec": "off", // needs the folder listing as an option; a repo that wires it turns this on
301
- "starci-be/no-handler-encoded-failure": "warn", // no=2 of 152 handlers - debt outstanding, burn down before flipping to error
166
+ "starci-be/handler-overrides-process": "error",
167
+ "starci-be/message-carries-params-only": "error",
302
168
  }
package/data-access.mjs CHANGED
@@ -22,8 +22,20 @@
22
22
  *
23
23
  * Both remaining gaps are read by a person; a rule that guessed at either would fire on correct
24
24
  * code, which is how a correct rule gets disabled.
25
+ *
26
+ * `no-external-call-in-transaction` (R82 `BE_TRANSACTION_EXTERNAL_CALL`) refuses `fetch`, an HTTP
27
+ * client call or any method of a `*Client`/`*Sdk` receiver written inside `.transaction(async (tx) =>
28
+ * ...)`. Holding the transaction open for as long as the external call takes means a failure after
29
+ * partial commit, or a success the rollback then reverses with no way back; the fix is to commit
30
+ * first and call out after, or to write an outbox message inside the transaction instead.
31
+ *
32
+ * `named-entity-manager-only` (R83 `BE_UNNAMED_DATA_ACCESS`) holds the one way a use case reaches the
33
+ * database: the shared `EntityManager` injected through a named injector and called directly. It refuses a bare
34
+ * `@InjectEntityManager()`, any `.getRepository(...)` call, and an injected `DataSource` outside the platform
35
+ * database module and `apps/migrate`. `no-injected-repository` refuses `@InjectRepository` and `Repository<T>` in the same spirit.
25
36
  */
26
37
 
38
+ import { walk } from "./lib/ast.mjs"
27
39
  import { normalizePath } from "./lib/path.mjs"
28
40
 
29
41
  /** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
@@ -97,21 +109,28 @@ export const mustInjectEntityManager = {
97
109
  export const noInjectedRepository = {
98
110
  meta: {
99
111
  type: "problem",
100
- docs: { description: "Persistence goes through `EntityManager`, never an injected repository." },
112
+ docs: { description: "Persistence goes through the shared `EntityManager`, never an injected repository." },
101
113
  schema: [],
102
114
  messages: {
103
115
  repo:
104
- "Inject `EntityManager` instead of a repository. A repository is bound to ONE entity, so it cannot carry a transaction into a second table - and a use case that grows a second write then has to be rewritten rather than extended.",
116
+ "Inject the shared EntityManager with the named injector and call it directly. A repository is bound to ONE entity, so it cannot carry a transaction into a second table - and a use case that grows a second write then has to be rewritten rather than extended.",
105
117
  },
106
118
  },
107
119
  create(context) {
108
120
  return {
121
+ // `@InjectRepository(...)` is refused wherever it is written: a constructor parameter, a property, a method parameter.
122
+ Decorator(node) {
123
+ const expression = node.expression
124
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "InjectRepository") {
125
+ context.report({ node, messageId: "repo" })
126
+ }
127
+ },
109
128
  MethodDefinition(node) {
110
129
  for (const original of constructorParams(node)) {
111
130
  const param = unwrapParam(original)
112
- const byDecorator = paramDecorators(original).includes("InjectRepository")
113
- const byType = REPOSITORY_TYPES.test(paramTypeName(param) || "")
114
- if (byDecorator || byType) context.report({ node: param, messageId: "repo" })
131
+ // A decorated parameter is reported once, by the Decorator visitor.
132
+ if (paramDecorators(original).includes("InjectRepository")) continue
133
+ if (REPOSITORY_TYPES.test(paramTypeName(param) || "")) context.report({ node: param, messageId: "repo" })
115
134
  }
116
135
  },
117
136
  }
@@ -274,6 +293,129 @@ export const noEagerRelation = {
274
293
  },
275
294
  }
276
295
 
296
+ // -- DATA-6 (R82) ------------------------------------------------------------------------------------
297
+
298
+ const HTTP_RECEIVER = /^(?:axios|http|httpService|httpClient|axiosInstance)$/
299
+ const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "request"])
300
+ /** A receiver named for an integration client or SDK: `stripeClient`, `this.paymentSdk`. */
301
+ const EXTERNAL_RECEIVER = /(?:Client|Sdk)$/
302
+
303
+ const calleeMethodName = (callee) =>
304
+ callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee.property.name : null
305
+
306
+ const calleeReceiverName = (callee) => {
307
+ if (callee.type !== "MemberExpression") return null
308
+ const object = callee.object
309
+ if (object.type === "Identifier") return object.name
310
+ if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
311
+ return null
312
+ }
313
+
314
+ /** Whether `node` is a call this law treats as reaching an external system: `fetch`, an HTTP client, or any method of a `*Client`/`*Sdk`. */
315
+ const isExternalCall = (node) => {
316
+ if (node.type !== "CallExpression") return false
317
+ const { callee } = node
318
+ if (callee.type === "Identifier" && callee.name === "fetch") return true
319
+ const method = calleeMethodName(callee)
320
+ const receiver = calleeReceiverName(callee)
321
+ if (!method || !receiver) return false
322
+ if (HTTP_RECEIVER.test(receiver) && HTTP_METHODS.has(method)) return true
323
+ return EXTERNAL_RECEIVER.test(receiver)
324
+ }
325
+
326
+ /** A short label for a reported call: `stripeClient.charge`, `fetch`. */
327
+ const describeCall = (node) => {
328
+ const { callee } = node
329
+ if (callee.type === "Identifier") return callee.name
330
+ const method = calleeMethodName(callee) ?? "?"
331
+ const receiver = calleeReceiverName(callee) ?? "?"
332
+ return `${receiver}.${method}`
333
+ }
334
+
335
+ /** No transaction spans an external call: commit first, then call out (or write an outbox message inside the transaction). */
336
+ export const noExternalCallInTransaction = {
337
+ meta: {
338
+ type: "problem",
339
+ docs: {
340
+ description: "An HTTP or SDK call does not run inside `.transaction(async (tx) => ...)`.",
341
+ },
342
+ schema: [],
343
+ messages: {
344
+ external:
345
+ "`{{call}}` runs inside `.transaction(...)`. A database transaction held open for as long as an external call takes means: the call fails after the transaction already did its work and now has to be undone by hand, or the call succeeds and the transaction then rolls back, and the two systems disagree with no way back. Commit the transaction, then make the call - or write an outbox message inside the transaction and let a worker deliver it after commit.",
346
+ },
347
+ },
348
+ create(context) {
349
+ return {
350
+ CallExpression(node) {
351
+ const callee = node.callee
352
+ if (callee.type !== "MemberExpression" || callee.computed) return
353
+ if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return
354
+ const callback = node.arguments[0]
355
+ if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
356
+ walk(callback.body, (child) => {
357
+ if (isExternalCall(child)) context.report({ node: child, messageId: "external", data: { call: describeCall(child) } })
358
+ })
359
+ },
360
+ }
361
+ },
362
+ }
363
+
364
+ // -- DATA-7 (R83) ------------------------------------------------------------------------------------
365
+
366
+ /** Files that build the connections: the platform database module and the migrate app. */
367
+ const CONNECTION_BUILDER = /(?:^|\/)(?:platform\/(?:databases?|db|datasource|typeorm)|apps\/migrate)\//
368
+
369
+ /** The one way a use case reaches the database: the shared `EntityManager`, named, then called directly. */
370
+ export const namedEntityManagerOnly = {
371
+ meta: {
372
+ type: "problem",
373
+ docs: {
374
+ description:
375
+ "The shared EntityManager is injected through a named injector and called directly; no bare `@InjectEntityManager()`, no `getRepository`, no injected `DataSource` (data-access.md DATA-7, R83).",
376
+ },
377
+ schema: [],
378
+ messages: {
379
+ unnamed:
380
+ "`@InjectEntityManager()` names no connection, and this application registers more than one - so it points at whichever is default. Inject the shared EntityManager with the named injector (`InjectPrimaryEntityManager()`, `InjectAgentOsEntityManager()`, `InjectExpertAcademyEntityManager()`, or `@InjectEntityManager(<CONNECTION_TOKEN>)`) and call it directly.",
381
+ getRepository:
382
+ "`.getRepository(...)` binds a handle to ONE entity and hides which connection it came from. Inject the shared EntityManager with the named injector and call it directly: `manager.find(Entity, ...)`, `manager.save(Entity, ...)`, `tx.insert(Entity, ...)`.",
383
+ dataSource:
384
+ "`DataSource` is injected here to reach the database. Inject the shared EntityManager with the named injector and call it directly; a `DataSource` is built only by the platform database module.",
385
+ },
386
+ },
387
+ create(context) {
388
+ const filename = normalizePath(context.filename || context.getFilename())
389
+ if (/\.d\.[cm]?ts$/.test(filename) || /\.(?:spec|test)\.[cm]?[jt]sx?$/.test(filename)) return {}
390
+ const mayBuildConnections = CONNECTION_BUILDER.test(filename)
391
+ return {
392
+ Decorator(node) {
393
+ const expression = node.expression
394
+ if (expression.type !== "CallExpression" || expression.callee.type !== "Identifier") return
395
+ if (expression.callee.name === "InjectEntityManager" && expression.arguments.length === 0) {
396
+ context.report({ node, messageId: "unnamed" })
397
+ }
398
+ },
399
+ CallExpression(node) {
400
+ const callee = node.callee
401
+ if (callee.type !== "MemberExpression" || callee.computed) return
402
+ if (callee.property.type === "Identifier" && callee.property.name === "getRepository") {
403
+ context.report({ node, messageId: "getRepository" })
404
+ }
405
+ },
406
+ MethodDefinition(node) {
407
+ if (mayBuildConnections) return
408
+ for (const original of constructorParams(node)) {
409
+ const param = unwrapParam(original)
410
+ const byType = paramTypeName(param) === "DataSource"
411
+ const byDecorator = paramDecorators(original).some((name) => name === "InjectDataSource" || name === "InjectConnection")
412
+ if (byType || byDecorator) context.report({ node: param, messageId: "dataSource" })
413
+ }
414
+ },
415
+ }
416
+ },
417
+ }
418
+
277
419
  /** The rules this law contributes to the plugin. */
278
420
  export const rules = {
279
421
  "must-inject-entity-manager": mustInjectEntityManager,
@@ -281,6 +423,8 @@ export const rules = {
281
423
  "require-entity-table-name": requireEntityTableName,
282
424
  "no-outer-manager-in-transaction": noOuterManagerInTransaction,
283
425
  "no-eager-relation": noEagerRelation,
426
+ "no-external-call-in-transaction": noExternalCallInTransaction,
427
+ "named-entity-manager-only": namedEntityManagerOnly,
284
428
  }
285
429
 
286
430
  /**
@@ -300,6 +444,8 @@ export const recommended = {
300
444
  "starci-be/require-entity-table-name": "error",
301
445
  "starci-be/no-outer-manager-in-transaction": "error",
302
446
  "starci-be/no-eager-relation": "error",
447
+ "starci-be/no-external-call-in-transaction": "error",
448
+ "starci-be/named-entity-manager-only": "error",
303
449
  }
304
450
 
305
451
  /** Path helper shared with the other backend law modules. */
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The rules that hold the transport half of HFS default-deny (catalog R41 `BE_DEFAULT_DENY`).
3
+ *
4
+ * Every route needs a signed-in principal unless it says otherwise, and every body has a type:
5
+ *
6
+ * - `no-untyped-body` refuses a `@Body()` (or `@Args()`) parameter with no type, `unknown`, `any`, `object`,
7
+ * `{}` or `Record<string, unknown>`; it refuses `GraphQLJSON` as a tunnel around the schema; and it refuses
8
+ * a `switch (input.operation)` in a transport file, because each operation is its own typed operation.
9
+ * - `public-needs-reason` refuses `@Public()` without `{ reason: "<why>" }`. An open door states why it is
10
+ * open, so the list of open doors can be read and reviewed.
11
+ *
12
+ * The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
13
+ * one file cannot see which module the app registered.
14
+ */
15
+ import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
16
+ import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
17
+
18
+ const BODY_DECORATORS = new Set(["Body", "Args"])
19
+
20
+ const isUntyped = (annotation) => {
21
+ if (!annotation) return true
22
+ const type = annotation.typeAnnotation
23
+ if (!type) return true
24
+ if (["TSUnknownKeyword", "TSAnyKeyword", "TSObjectKeyword"].includes(type.type)) return true
25
+ if (type.type === "TSTypeLiteral" && type.members.length === 0) return true
26
+ if (type.type === "TSTypeReference" && type.typeName.type === "Identifier" && type.typeName.name === "Record") {
27
+ const value = type.typeArguments?.params?.[1] ?? type.typeParameters?.params?.[1]
28
+ return value?.type === "TSUnknownKeyword" || value?.type === "TSAnyKeyword"
29
+ }
30
+ return false
31
+ }
32
+
33
+ /** A body or argument carries a declared type, never `unknown`, `any` or GraphQLJSON. */
34
+ export const noUntypedBody = {
35
+ meta: {
36
+ type: "problem",
37
+ docs: { description: "`@Body()` and `@Args()` parameters are typed; no GraphQLJSON tunnel; no `switch (input.operation)`." },
38
+ schema: [],
39
+ messages: {
40
+ untyped: "This `@{{decorator}}()` parameter has no useful type. Declare a DTO class or contract type so the framework validates the input.",
41
+ json: "`GraphQLJSON` tunnels untyped data through the schema. Declare an input type with the fields the operation needs.",
42
+ operationSwitch: "`switch` on an `operation` field dispatches several operations through one untyped door. Make each operation its own typed resolver or controller method.",
43
+ },
44
+ },
45
+ create(context) {
46
+ const filename = normalizePath(context.filename || context.getFilename())
47
+ if (isDeclarationFile(filename)) return {}
48
+ const inTransport = filename.includes("/transport/")
49
+ return {
50
+ Identifier(node) {
51
+ if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
52
+ context.report({ node, messageId: "json" })
53
+ }
54
+ },
55
+ SwitchStatement(node) {
56
+ const discriminant = node.discriminant
57
+ if (inTransport && discriminant.type === "MemberExpression" && keyName(discriminant.property) === "operation") {
58
+ context.report({ node, messageId: "operationSwitch" })
59
+ }
60
+ },
61
+ Decorator(node) {
62
+ const name = decoratorName(node)
63
+ if (!BODY_DECORATORS.has(name)) return
64
+ const parameter = node.parent
65
+ if (!parameter) return
66
+ const bound = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
67
+ if (bound.type !== "Identifier" && bound.type !== "ObjectPattern" && bound.type !== "AssignmentPattern") return
68
+ const annotated = bound.type === "AssignmentPattern" ? bound.left : bound
69
+ if (isUntyped(annotated.typeAnnotation)) {
70
+ context.report({ node, messageId: "untyped", data: { decorator: name } })
71
+ }
72
+ },
73
+ }
74
+ },
75
+ }
76
+
77
+ /** An open door states why it is open. */
78
+ export const publicNeedsReason = {
79
+ meta: {
80
+ type: "problem",
81
+ docs: { description: "`@Public()` carries `{ reason: \"<why>\" }`." },
82
+ schema: [],
83
+ messages: {
84
+ reason: "`@Public()` has no reason. Write `@Public({ reason: \"<why this operation is open>\" })` so the open doors can be reviewed.",
85
+ },
86
+ },
87
+ create(context) {
88
+ const filename = normalizePath(context.filename || context.getFilename())
89
+ if (isDeclarationFile(filename)) return {}
90
+ return {
91
+ Decorator(node) {
92
+ if (decoratorName(node) !== "Public") return
93
+ const expression = node.expression
94
+ const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
95
+ let reason = null
96
+ if (argument?.type === "ObjectExpression") {
97
+ const property = argument.properties.find((item) => item.type === "Property" && keyName(item.key) === "reason")
98
+ reason = property ? staticText(property.value) : null
99
+ }
100
+ if (reason === null || reason.trim() === "") context.report({ node, messageId: "reason" })
101
+ },
102
+ }
103
+ },
104
+ }
105
+
106
+ /** The rules this law contributes to the plugin. */
107
+ export const rules = {
108
+ "no-untyped-body": noUntypedBody,
109
+ "public-needs-reason": publicNeedsReason,
110
+ }
111
+
112
+ /** Both start at error: an untyped door and an unexplained open door are the failures this law exists for. */
113
+ export const recommended = {
114
+ "starci-be/no-untyped-body": "error",
115
+ "starci-be/public-needs-reason": "error",
116
+ }