@starci/eslint-canon-be 3.3.1 → 3.4.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  26. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  27. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  28. package/runtime/knowledge/hfs/slots.yaml +344 -62
  29. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  30. package/runtime/knowledge/sonar-gate.yaml +1 -1
  31. package/runtime/modules/models/runtimes.yaml +1 -1
  32. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  35. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  36. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  38. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  39. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  40. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  41. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  42. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  43. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  44. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  45. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  46. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  47. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  48. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  49. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  50. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  51. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  52. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  53. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  54. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  55. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  56. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  57. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  58. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  59. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  60. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  61. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  62. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  63. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  64. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  65. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  66. package/runtime/scripts/hfs/slots.mjs +1 -0
  67. package/runtime/scripts/lib/env.mjs +20 -0
  68. package/runtime/scripts/lib/event-contract.mjs +107 -0
  69. package/runtime/scripts/lib/json-clone.mjs +61 -0
  70. package/runtime/scripts/lib/list.mjs +4 -0
  71. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  72. package/runtime/scripts/lib/stack-services.mjs +1 -1
  73. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  74. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  75. package/service-isolation.mjs +3 -0
  76. package/size-budget.mjs +9 -5
  77. package/third-party-owner.mjs +149 -0
  78. package/transport.mjs +11 -29
  79. package/unit-spec.mjs +270 -84
  80. package/webhooks.mjs +169 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Fixed (R263 `BE_WEBHOOK_SCOPE`, a18.1 NIVO requests G and J): a third-party OAuth redirect (a GET route with `PublicReason.AuthHandshake` on a path that is not a webhook or events path) is no longer judged as a webhook door (owner ruling `oauth-redirect-not-webhook`); a call on a domain receipt port (no base class, constructor of platform ports only, one of them the Inbox) inside the receipt transaction is the inbox record and not processing (owner ruling `webhook-receipt-port`); an intake that does process the delivery is now reported with its own `intake` finding instead of the signed-metadata text.
6
+ - Known gaps (planned for alpha.19, Nest lifecycle conformance): no enforcer yet for exactly one validation pipe per app, for interceptors limited to logging, timeout and serialization and registered once, or for the absence of authentication middleware. Templates and examples conform today: one `APP_PIPE`, no interceptor, security-headers middleware only.
7
+ - Added: `no-guard-injects-guard` (R41 `BE_DEFAULT_DENY`, a18 lifecycle conformance): a guard class (implements `CanActivate` or named `...Guard`) has no constructor parameter that is another guard (a `...Guard` or `CanActivate` type, `Type<CanActivate>`, or `@Inject(SomeGuard)`); the app registers `RateLimitGuard`, `OriginGuard`, `CredentialGuard` and `AuthGuard` as four global `APP_GUARD`s and `@Credential({ kind })` selects the credential behavior.
8
+ - Added: R278 `BE_REST_DOOR_EVIDENCE` / `BE_REST_ROUTE_UNDECLARED` accept a credential-guarded route (owner ruling `credential-guarded-rest-door`, 2026-10-16): `@Credential({ kind: "<literal>" })` from the domain identity capability on the route or controller, with the capability declaring a `CredentialGuard implements CanActivate`, resolved by TypeScript symbol. A local decorator, another `@UseGuards` guard and a computed kind still fail.
9
+ - Fixed (R281 `BE_SPEC_FORM_ALL_ROLES`, a18 NIVO request A): `spec-infra-double-from-kit` judges a function subject by the callable subset of the sibling's exports (constants, enums, `export ... from` re-exports and overload signatures no longer disable the check; overloads collapse to the implementation); a value-object subject (an undecorated class whose constructor parameters carry no injection token) may be built with `new` (`spec-no-new-subject`, `spec-builds-with-testing-module`, and `useValue: new Subject(...)` in `spec-infra-double-from-kit`) while a decorated or token-injected class stays mandatory; a member of a kit `mock<T>()` object (`mock<{ open: F }>().open`) counts as a kit double.
10
+
11
+ ## 3.4.0 - 2026-10-15
12
+ - Added: `module-definition-required` (R295 `BE_MODULE_DEFINITION_MISSING`, owner ruling 2026-10-15 `owner:module-definition-everywhere`): every `<x>.module.ts` of a feature, capability or projection has a sibling `<x>.module-definition.ts` and `<x>.options.ts`, its class extends the `ConfigurableModuleClass` of that definition, and a module of the product is listed in an `imports` array only as `X.register(...)`.
13
+ - Changed: `capability-module-shape` judges the definition of every module: the representative module of a capability keeps `.setExtras({ isGlobal: false }, ...)` and the one `register` override; a sub-module, feature, transport module or cli group builds `.setClassMethodName("register").build()` and overrides nothing. A module definition in a feature is no longer refused.
14
+
3
15
  ## 3.3.1 - 2026-10-15
4
16
  - Changed: version-only republish. 3.3.0 was published first and the bundled canon-pins copy then moved with the rest of the alpha.14 package set (hfs 4.3.0, canon-fe 8.3.0, cli 1.3.0); 3.3.0 is superseded and no longer pinned anywhere.
5
17
 
package/README.md CHANGED
@@ -44,8 +44,8 @@ A sample, not the list:
44
44
  | **App composition** (R245, R246) | `apps/<app>/src/<app>.env.ts` exports an explicit typed `<App>Env` interface and `read<App>Env(env: EnvSource): <App>Env` (for example `ApiEnv` and `readApiEnv`). The interface declares its own readonly parsed members, such as `readonly server: ReturnType<typeof parseServerConfig>`, structurally compatible with the app's options. The role name stays `<app>.env.ts`, never `env.ts`; capability `<capability>.config.ts` files stay unchanged. `main.ts` creates `EnvSource` once, imports and calls the reader, builds options and boots. The env file holds fail-fast parsing only, with no localhost or default host fallback, logic, module composition, business code or feature imports; `<app>.options.ts` holds env-derived options and port bindings only, with no parsing. Both refuse an array listing `*Module` classes, module or queue registrations (`register`, `forRoot`, `registerQueue`) or scheduler entries, and an `imports`, `modules`, `schedulers` or `processors` list; the env file also refuses standalone module or queue registration calls. Composition belongs in a `compose/<area>.ts` group that `app.module.ts` spreads (`app-options-composition-free`, `BE_APP_OPTIONS_COMPOSITION`). Each app file stays within its slot's line budget: `main.ts` 80, env 150, options 200, group 120, root 250 (`app-file-budget`, `BE_APP_COMPOSITION_BUDGET`). The interface and reader exports are a documented scaffold contract; they need no new lint rule |
45
45
  | **Sonar parity** (R235) | The smells SonarCloud measures that a lint rule can decide: no `await` inside a loop (`no-await-in-loop`, S9382; `for (;;)` and `while (true)` are spared), no `charCodeAt`/`fromCharCode` (`prefer-code-point`), no string that only escapes backslashes (`prefer-string-raw`), no nested conditional (`no-nested-conditional`), `void` only on a call (`no-void-operator`), no unused import (`no-unused-import`), `export ... from` for an import that is only handed on (`prefer-export-from`); the factory also turns on typescript-eslint `no-deprecated`, `no-base-to-string` and `require-await`. The files `*.spec.ts` and `*.e2e-spec.ts` are outside the scan, so outside these rules |
46
46
  | **Cookies** (R141) | A cookie a door writes through the response of Express or Fastify (`cookie`, `setCookie`) states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict` in options typed with those literals, and `Set-Cookie` is never written as a header; the call is recognised by where its signature is declared (`response-cookie-attributes`). A secret-derived value is compared with `timingSafeEqual`: a `Secret` of `platform/config` and the `.digest()` of a `Hash` or `Hmac` of Node's crypto are recognised by type (`secret-compare-timing-safe`) |
47
- | **Default deny** | Typed bodies and arguments, no `GraphQLJSON` tunnel, no `switch (operation)`, `@Public` carries a `PublicReason` member of `domain/identity`, and no `@UseGuards` (the global `APP_GUARD` chain decides) (`no-untyped-body`, `public-needs-reason`, `no-auth-use-guards`) |
48
- | **Module shape** | No `@Global()`; `isGlobal: true` only in an app's `app.module.ts`; no import of another owner's module; typed `ConfigurableModuleBuilder`; `static register` is the only factory; a capability's representative module extends the `ConfigurableModuleClass` of its own module definition and every sub-module or feature module is plain (`capability-module-shape`); one module per file; no `new` of a provider; no module-level `let` (`no-global-decorator`, `is-global-only-in-app`, `no-cross-owner-module-import`, `static-module-register`) |
47
+ | **Default deny** | Typed bodies and arguments, no `GraphQLJSON` tunnel, no `switch (operation)`, `@Public` carries a `PublicReason` member of `domain/identity`, no `@UseGuards` (the global `APP_GUARD` chain decides) and no guard injected into another guard (`no-untyped-body`, `public-needs-reason`, `no-auth-use-guards`, `no-guard-injects-guard`) |
48
+ | **Module shape** | No `@Global()`; `isGlobal: true` only in an app's `app.module.ts`; no import of another owner's module; typed `ConfigurableModuleBuilder`; `static register` is the only factory; every module (capability, sub-module, feature, transport, cli group) has a sibling `<x>.module-definition.ts` and `<x>.options.ts`, extends its own `ConfigurableModuleClass` and is listed only as `X.register(...)` (`module-definition-required`); only a capability's representative module keeps `.setExtras({ isGlobal: false })` and the `register` override (`capability-module-shape`); one module per file; no `new` of a provider; no module-level `let` (`no-global-decorator`, `is-global-only-in-app`, `no-cross-owner-module-import`, `static-module-register`) |
49
49
  | **Suppression** | No `eslint-disable`, `eslint-env`, `/* global */`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `istanbul`, `c8` or `v8 ignore`, `NOSONAR`, `sonar-disable`, or `prettier-ignore` comment, anywhere (`no-inline-suppression`) |
50
50
  | **Size** | A source file, a spec included, over the manifest's line budget is not new and does not grow past its size at the parent commit; the rule takes no option (`file-size-growth`) |
51
51
  | **Query safety** | `.query(...)` takes only a `SqlText` built with the `sql` tag in a persistence `<name>.sql.ts`, and no query builder exists (`sql-text-only`, `no-query-builder`); the tag's only substitution is a `SqlIdent` and a `.query` string is never built by template or `+` (`no-interpolated-sql`); `find`, `findBy` and `findAndCount` on an `EntityManager` state `take` and no `EntityManager` read runs per loop element (`query-needs-limit`, `no-query-in-loop`) |
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The rules that hold the composition files of a back-end app (catalog R245 `BE_APP_OPTIONS_COMPOSITION` and R246
2
+ * The rules that hold the composition files of a back-end app (catalog R245 `BE_APP_OPTIONS_COMPOSITION`, R265
3
+ * `BE_COMPOSITION_UNTYPED` and R246
3
4
  * `BE_APP_COMPOSITION_BUDGET`, owner ruling 2026-10-14). An app's `src/` holds five roles, named by the entries of its `be.app.*` slot (`lib/app-roles.mjs`):
4
5
  * `main.ts`, `app.module.ts`, `<app>.env.ts`, `<app>.options.ts` and the composition groups `compose/<area>.ts`.
5
6
  *
@@ -15,13 +16,17 @@
15
16
  * (`budget` of the slot, keyed by the same entry: `app.module.ts` 250, `<app>.env.ts` 150, `<app>.options.ts` 200, `compose/<area>.ts` 120,
16
17
  * `main.ts` 80). The budget is absolute, not a ratchet: an app root that outgrows it moves an area into a new group.
17
18
  *
18
- * Both rules ask the slot view which entry a file matches; neither keeps a path of its own.
19
+ * R265 resolves composition from types, including renamed constructors, factories, maps and scheduler tables. All
20
+ * three rules ask the slot view which entry a file matches; none keeps a path of its own.
19
21
  */
20
22
  import { lineCount } from "./runtime/scripts/lib/line-count.mjs"
23
+ import ts from "typescript"
21
24
  import { APP_ENV_ROLE, APP_OPTIONS_ROLE, appRoleOf } from "./lib/app-roles.mjs"
22
25
  import { hfsOf } from "./lib/hfs.mjs"
26
+ import { isOwnedBy } from "./lib/declared.mjs"
23
27
  import { keyName, staticText } from "./lib/ast.mjs"
24
28
  import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
29
+ import { moduleOf, originsOfType, packageOfExport, typed } from "./lib/types.mjs"
25
30
 
26
31
  /** Static factories that register a Nest module (a dynamic module), a queue or a flow producer. */
27
32
  const REGISTRATION_CALLS = new Set([
@@ -130,6 +135,340 @@ export const appOptionsCompositionFree = {
130
135
  },
131
136
  }
132
137
 
138
+ /** Composition is a value's type, independent of the spelling of its class, factory or helper. */
139
+ const compositionTypes = (checker) => {
140
+ const propertyType = (type, name) => {
141
+ const property = type.getProperty(name)
142
+ const declaration = property?.valueDeclaration ?? property?.getDeclarations?.()?.[0]
143
+ return declaration ? checker.getTypeOfSymbolAtLocation(property, declaration) : null
144
+ }
145
+ const constructor = (type) => (type?.getConstructSignatures().length ?? 0) > 0
146
+ const dynamicModule = (type) => constructor(propertyType(type, "module"))
147
+ const moduleConstructor = (type) => {
148
+ if (!constructor(type)) return false
149
+ for (const declaration of type.getSymbol()?.getDeclarations?.() ?? []) {
150
+ if (!ts.isClassDeclaration(declaration) && !ts.isClassExpression(declaration)) continue
151
+ for (const decorator of ts.getDecorators(declaration) ?? []) {
152
+ const expression = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
153
+ const symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(expression) ? expression.name : expression)
154
+ const resolved = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
155
+ if (resolved?.name === "Module" && resolved.getDeclarations()?.some((origin) => moduleOf(origin, normalizePath(origin.getSourceFile().fileName)) === "@nestjs/common")) return true
156
+ }
157
+ }
158
+ // A factory returning DynamicModule identifies a module even if it has no decorator or conventional name.
159
+ return type.getProperties().some((property) => {
160
+ const factory = propertyType(type, property.name)
161
+ return factory?.getCallSignatures().some((signature) => dynamicModule(checker.getReturnTypeOfSignature(signature)))
162
+ })
163
+ }
164
+ const arrayElement = (type) => checker.isArrayType(type) || checker.isTupleType(type)
165
+ ? checker.getIndexTypeOfType(type, ts.IndexKind.Number)
166
+ : type.getSymbol()?.name === "ReadonlyArray" ? checker.getTypeArguments(type)[0] : null
167
+ const MAX_DEPTH = 12
168
+ const contains = (type, constructors = false, seen = new Set(), depth = 0) => {
169
+ // A library type graph (the application, an HTTP adapter) can be deep and cyclic through member types: a bounded walk never overflows the stack.
170
+ if (depth > MAX_DEPTH) return false
171
+ if (!type || seen.has(type) || type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.TypeParameter)) return false
172
+ seen.add(type)
173
+ if (type.isUnionOrIntersection()) return type.types.some((part) => contains(part, constructors, new Set(seen), depth + 1))
174
+ // Async bootstrap returns Promise<void>; walk its result rather than recursively instantiating .then/.catch.
175
+ const awaited = checker.getAwaitedType(type)
176
+ if (awaited && awaited !== type) return contains(awaited, constructors, seen, depth + 1)
177
+ const origins = originsOfType(checker, type)
178
+ if (origins.some((origin) => origin.module === "@nestjs/common" && ["DynamicModule", "Type", "Provider"].includes(origin.name))) return true
179
+ if (constructor(type)) return constructors || moduleConstructor(type)
180
+ if (dynamicModule(type)) return true
181
+ const properties = new Set(type.getProperties().map((property) => property.name))
182
+ if (properties.has("provide") && ["useClass", "useFactory", "useValue", "useExisting"].some((key) => properties.has(key))) return true
183
+ if (properties.has("queue") && ["cron", "everyMs", "pattern", "repeat"].some((key) => properties.has(key))) return true
184
+ // Primitive options values and their library methods never compose an app.
185
+ if (!(type.flags & ts.TypeFlags.Object)) return false
186
+ const element = arrayElement(type)
187
+ if (element) return contains(element, true, seen, depth + 1)
188
+ // Index signatures retain the Type<unknown> contract of a module map, unlike a plain adapter port map.
189
+ for (const index of checker.getIndexInfosOfType(type)) {
190
+ if (contains(index.type, false, new Set(seen), depth + 1)) return true
191
+ }
192
+ for (const signature of type.getCallSignatures()) {
193
+ if (contains(checker.getReturnTypeOfSignature(signature), false, new Set(seen), depth + 1)) return true
194
+ }
195
+ return type.getProperties().some((property) => contains(propertyType(type, property.name), false, new Set(seen), depth + 1))
196
+ }
197
+ const typedList = (type) => {
198
+ const element = arrayElement(type)
199
+ if (!element || element.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
200
+ const parts = element.isUnion() ? element.types : [element]
201
+ return parts.every((part) => contains(part, true))
202
+ }
203
+ return { arrayElement, contains, typedList }
204
+ }
205
+
206
+ /** R265: app values compose only in typed groups, never in env/options/bootstrap lists. */
207
+ export const appCompositionTyped = {
208
+ meta: {
209
+ type: "problem",
210
+ docs: { description: "Module, provider, queue and scheduler composition is judged by its resolved type and lives in typed compose groups." },
211
+ schema: [],
212
+ messages: {
213
+ outside: "[BE_COMPOSITION_UNTYPED] This value composes modules, providers, queues or schedules in `{{role}}`. Move it into a typed `compose/<area>.ts` group and spread the group in `app.module.ts`.",
214
+ group: "[BE_COMPOSITION_UNTYPED] Declare this composition group's array type or return type explicitly (for example `Array<DynamicModule | Type<unknown>>`); every element must be a module, provider, queue or scheduler entry.",
215
+ business: "[BE_APP_BUSINESS_ROLE] Composition groups only wire modules, providers, queues and schedules. Move business functions and branching into their owning feature or module.",
216
+ },
217
+ },
218
+ create(context) {
219
+ const filename = normalizePath(context.filename || context.getFilename())
220
+ if (isDeclarationFile(filename)) return {}
221
+ const role = appRoleOf(hfsOf(context), filename)?.entry
222
+ if (![APP_ENV_ROLE, APP_OPTIONS_ROLE, "main.ts", "compose/<area>.ts"].includes(role)) return {}
223
+ const { checker, toTs } = typed(context)
224
+ const { arrayElement, contains, typedList } = compositionTypes(checker)
225
+ const reported = new WeakSet()
226
+ const hasReport = (node) => {
227
+ for (let ancestor = node; ancestor; ancestor = ancestor.parent) if (reported.has(ancestor)) return true
228
+ return false
229
+ }
230
+ const report = (node, messageId) => {
231
+ if (hasReport(node)) return
232
+ reported.add(node)
233
+ context.report({ node, messageId, data: { role } })
234
+ }
235
+ const typeOf = (node) => checker.getTypeAtLocation(toTs(node))
236
+ const bootstrapArgument = (node) => {
237
+ // The root module's registration belongs here; inline lists and metadata still belong in compose/.
238
+ if (role !== "main.ts" || node.type !== "CallExpression") return false
239
+ let expression = node
240
+ while (WRAPPERS.has(expression.parent?.type)) expression = expression.parent
241
+ const parent = expression.parent
242
+ if (parent?.type !== "CallExpression" || !parent.arguments.includes(expression) || parent.callee.type !== "MemberExpression") return false
243
+ const owner = (callee) => packageOfExport(context, callee) ?? packageOfExport(context, callee.object)
244
+ // An api boots through NestFactory; a cli app boots its root module through nest-commander's CommandFactory.run (R148).
245
+ return (["create", "createApplicationContext", "createMicroservice"].includes(memberName(parent.callee)) && owner(parent.callee) === "@nestjs/core")
246
+ || (memberName(parent.callee) === "run" && parent.arguments[0] === expression && owner(parent.callee) === "nest-commander")
247
+ }
248
+ const checkValue = (node) => {
249
+ if (role === "compose/<area>.ts" || hasReport(node) || bootstrapArgument(node)) return
250
+ if (contains(typeOf(node))) report(node, "outside")
251
+ }
252
+ const checkGroup = (node, value, annotation) => {
253
+ if (role !== "compose/<area>.ts") return
254
+ const type = typeOf(value)
255
+ const signatures = type.getCallSignatures()
256
+ const result = signatures.length ? checker.getReturnTypeOfSignature(signatures[0]) : type
257
+ if (signatures.length && !arrayElement(result) && !contains(result)) {
258
+ report(node, "business")
259
+ return
260
+ }
261
+ if (!annotation || !typedList(result)) report(node, "group")
262
+ }
263
+ const annotationOf = (node) => {
264
+ if (!node) return null
265
+ if (node.type === "TSSatisfiesExpression") return node.typeAnnotation
266
+ if (WRAPPERS.has(node.type)) return annotationOf(node.expression)
267
+ if (node.type === "VariableDeclarator") return node.id.typeAnnotation ?? annotationOf(node.init)
268
+ if (node.returnType) return node.returnType
269
+ if (node.type !== "Identifier") return null
270
+ const symbol = checker.getSymbolAtLocation(toTs(node))
271
+ const resolved = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
272
+ return resolved?.getDeclarations()?.some((declaration) => {
273
+ const initializer = declaration.initializer
274
+ return declaration.type || initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer) || ts.isSatisfiesExpression(initializer)) && initializer.type
275
+ }) ?? false
276
+ }
277
+ return {
278
+ ExportNamedDeclaration(node) {
279
+ if (role !== "compose/<area>.ts" || node.exportKind === "type") return
280
+ const declaration = node.declaration
281
+ if (!declaration) {
282
+ for (const specifier of node.specifiers) {
283
+ if (specifier.exportKind !== "type") checkGroup(specifier, specifier.local, annotationOf(specifier.local))
284
+ }
285
+ return
286
+ }
287
+ if (declaration.type === "FunctionDeclaration" && declaration.body) checkGroup(declaration, declaration, declaration.returnType)
288
+ if (declaration.type !== "VariableDeclaration") return
289
+ for (const binding of declaration.declarations) {
290
+ if (!binding.init) continue
291
+ checkGroup(binding, binding.id, annotationOf(binding))
292
+ }
293
+ },
294
+ ExportDefaultDeclaration(node) {
295
+ if (role === "compose/<area>.ts") checkGroup(node, node.declaration, annotationOf(node.declaration))
296
+ },
297
+ VariableDeclarator(node) {
298
+ if (node.init) checkValue(node.init)
299
+ },
300
+ FunctionDeclaration(node) {
301
+ if (node.body && role !== "compose/<area>.ts" && contains(typeOf(node))) report(node, "outside")
302
+ },
303
+ ArrowFunctionExpression(node) {
304
+ if (role !== "compose/<area>.ts" || hasReport(node)) return
305
+ const signature = typeOf(node).getCallSignatures()[0]
306
+ if (signature && !contains(checker.getReturnTypeOfSignature(signature))) report(node, "business")
307
+ },
308
+ IfStatement(node) { if (role === "compose/<area>.ts") report(node, "business") },
309
+ SwitchStatement(node) { if (role === "compose/<area>.ts") report(node, "business") },
310
+ ConditionalExpression(node) { if (role === "compose/<area>.ts") report(node, "business") },
311
+ ForStatement(node) { if (role === "compose/<area>.ts") report(node, "business") },
312
+ ForOfStatement(node) { if (role === "compose/<area>.ts") report(node, "business") },
313
+ WhileStatement(node) { if (role === "compose/<area>.ts") report(node, "business") },
314
+ ArrayExpression: checkValue,
315
+ ObjectExpression: checkValue,
316
+ CallExpression: checkValue,
317
+ ReturnStatement(node) {
318
+ if (node.argument) checkValue(node.argument)
319
+ },
320
+ }
321
+ },
322
+ }
323
+
324
+ /** Resolve an imported or local binding without relying on its local spelling. */
325
+ const resolvedSymbol = (checker, node) => {
326
+ const symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node)
327
+ return symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
328
+ }
329
+
330
+ /** The app's declared name comes from its matching env filename, not its kind's slot name. */
331
+ const appEnvNames = (filename, role) => {
332
+ const root = filename.slice(0, filename.lastIndexOf("/"))
333
+ const app = role === APP_ENV_ROLE ? filename.split("/").at(-1).slice(0, -".env.ts".length) : root.split("/").at(-2)
334
+ const pascal = app.replace(/(?:^|[-_])([a-z\d])/g, (_, letter) => letter.toUpperCase())
335
+ return { file: `${root}/${app}.env.ts`, reader: `read${pascal}Env` }
336
+ }
337
+
338
+ /** R289: the typed app env reader owns parsing; main acquires one source and delegates once. */
339
+ export const appEnvRequired = {
340
+ meta: {
341
+ type: "problem",
342
+ docs: { description: "Every app exports its typed EnvSource reader; main builds one source and delegates parsing to that reader." },
343
+ schema: [],
344
+ messages: {
345
+ shape: "[BE_APP_ENV_REQUIRED] Export an environment interface and `{{reader}}(env: EnvSource)` returning that interface from the matching app env file.",
346
+ inline: "[BE_APP_ENV_REQUIRED] Bootstrap builds EnvSource once and delegates environment parsing only to the matching app env reader. Move this environment read or parser into `<app>.env.ts`.",
347
+ delegate: "[BE_APP_ENV_REQUIRED] Main must build EnvSource once and call `{{reader}}` from `{{file}}` with that source once.",
348
+ },
349
+ },
350
+ create(context) {
351
+ const filename = normalizePath(context.filename || context.getFilename())
352
+ if (isDeclarationFile(filename)) return {}
353
+ const hfs = hfsOf(context)
354
+ const role = appRoleOf(hfs, filename)?.entry
355
+ if (role !== APP_ENV_ROLE && role !== "main.ts") return {}
356
+ const { checker, toTs } = typed(context)
357
+ const names = appEnvNames(filename, role)
358
+ const ownedEnv = (type) => originsOfType(checker, type).some((origin) => origin.name === "EnvSource" && isOwnedBy(hfs, origin.file, "platform", "config"))
359
+ const envValue = (node) => node && ownedEnv(checker.getTypeAtLocation(toTs(node)))
360
+ const matchingReader = (node) => {
361
+ const symbol = resolvedSymbol(checker, toTs(node))
362
+ return symbol?.name === names.reader && symbol.getDeclarations()?.some((declaration) => normalizePath(declaration.getSourceFile().fileName) === names.file)
363
+ }
364
+ const envContract = (node) => {
365
+ const source = toTs(node)
366
+ if (!source.symbol) return false
367
+ const exported = checker.getExportsOfModule(source.symbol)
368
+ const reader = exported.find((symbol) => symbol.name === names.reader)
369
+ const declaration = reader?.valueDeclaration ?? reader?.getDeclarations()?.[0]
370
+ if (!declaration) return false
371
+ const signatures = checker.getTypeOfSymbolAtLocation(reader, declaration).getCallSignatures()
372
+ return signatures.some((signature) => {
373
+ const parameters = signature.getParameters()
374
+ if (parameters.length !== 1 || !signature.declaration?.type) return false
375
+ const parameter = parameters[0]
376
+ if (!parameter.valueDeclaration?.type || !ownedEnv(checker.getTypeOfSymbolAtLocation(parameter, parameter.valueDeclaration))) return false
377
+ const result = checker.getReturnTypeOfSignature(signature)
378
+ return result.getSymbol()?.getDeclarations()?.some((item) => ts.isInterfaceDeclaration(item)
379
+ && item.getSourceFile() === source && exported.some((symbol) => symbol.name === item.name.text))
380
+ })
381
+ }
382
+ let sources = 0
383
+ let delegates = 0
384
+ let inline = false
385
+ const reported = new WeakSet()
386
+ const reportInline = (node) => {
387
+ for (let parent = node; parent; parent = parent.parent) if (reported.has(parent)) return
388
+ reported.add(node)
389
+ inline = true
390
+ context.report({ node, messageId: "inline" })
391
+ }
392
+ return {
393
+ CallExpression(node) {
394
+ if (role !== "main.ts") return
395
+ const declaration = checker.getResolvedSignature(toTs(node))?.declaration
396
+ if (declaration && ts.isMethodDeclaration(declaration) && declaration.name.getText() === "fromProcess"
397
+ && envValue(node) && isOwnedBy(hfs, normalizePath(declaration.getSourceFile().fileName), "platform", "config")) {
398
+ sources += 1
399
+ if (sources > 1 || node.arguments.length) reportInline(node)
400
+ return
401
+ }
402
+ if (matchingReader(node.callee) && node.arguments.length === 1 && envValue(node.arguments[0])) {
403
+ delegates += 1
404
+ if (delegates > 1) reportInline(node)
405
+ return
406
+ }
407
+ const callee = unwrap(node.callee)
408
+ if (callee.type === "MemberExpression" && envValue(callee.object) || node.arguments.some(envValue)) reportInline(node)
409
+ },
410
+ MemberExpression(node) {
411
+ if (role !== "main.ts") return
412
+ if (memberName(node) === "env" && lastName(node.object) === "process") reportInline(node)
413
+ if (envValue(node.object) && !node.parent?.type.startsWith("CallExpression") && memberName(node) !== "fromProcess") reportInline(node)
414
+ },
415
+ "Program:exit"(node) {
416
+ if (role === APP_ENV_ROLE) {
417
+ if (!envContract(node)) context.report({ node, messageId: "shape", data: names })
418
+ } else if (!inline && (sources !== 1 || delegates !== 1)) context.report({ node, messageId: "delegate", data: names })
419
+ },
420
+ }
421
+ },
422
+ }
423
+
424
+ /** R289: module lists in the app root contain only spreads of its own compose groups. */
425
+ export const appModuleListInline = {
426
+ meta: {
427
+ type: "problem",
428
+ docs: { description: "The app root spreads compose groups; its only inline provider registrations are app-wide guards and filters." },
429
+ schema: [],
430
+ messages: {
431
+ inline: "[BE_APP_MODULE_LIST_INLINE] Move module, provider, queue or schedule entries into typed `compose/<area>.ts` groups and spread those groups here. Only app-wide guards and filters may be inline.",
432
+ },
433
+ },
434
+ create(context) {
435
+ const filename = normalizePath(context.filename || context.getFilename())
436
+ if (isDeclarationFile(filename)) return {}
437
+ const hfs = hfsOf(context)
438
+ if (appRoleOf(hfs, filename)?.entry !== "app.module.ts") return {}
439
+ const { checker, toTs } = typed(context)
440
+ const { contains } = compositionTypes(checker)
441
+ const composeRoot = filename.slice(0, filename.lastIndexOf("/")) + "/compose/"
442
+ const groupSpread = (node) => {
443
+ if (node.type !== "SpreadElement") return false
444
+ const value = unwrap(node.argument)
445
+ const reference = value.type === "CallExpression" ? unwrap(value.callee) : value
446
+ const symbol = resolvedSymbol(checker, toTs(reference))
447
+ return symbol?.getDeclarations()?.some((declaration) => {
448
+ const file = normalizePath(declaration.getSourceFile().fileName)
449
+ return file.startsWith(composeRoot) && appRoleOf(hfs, file)?.entry === "compose/<area>.ts"
450
+ }) ?? false
451
+ }
452
+ const globalProvider = (node) => {
453
+ node = unwrap(node)
454
+ if (node?.type !== "ObjectExpression") return false
455
+ const binding = node.properties.find((property) => property.type === "Property" && keyName(property.key) === "provide")
456
+ const value = binding && unwrap(binding.value)
457
+ if (!value || !["APP_GUARD", "APP_FILTER"].includes(resolvedSymbol(checker, toTs(value))?.name)) return false
458
+ return packageOfExport(context, value) === "@nestjs/core"
459
+ }
460
+ return {
461
+ ArrayExpression(node) {
462
+ const parent = node.parent
463
+ const key = parent?.type === "Property" ? parent.computed ? staticText(parent.key) : keyName(parent.key) : null
464
+ if (![...COMPOSITION_KEYS, "providers"].includes(key) && !contains(checker.getTypeAtLocation(toTs(node)))) return
465
+ const violation = node.elements.find((element) => !element || !groupSpread(element) && !(key === "providers" && globalProvider(element)))
466
+ if (violation || node.elements.includes(null)) context.report({ node: violation ?? node, messageId: "inline" })
467
+ },
468
+ }
469
+ },
470
+ }
471
+
133
472
  /** Each composition file of an app stays within the line budget its app slot names. */
134
473
  export const appFileBudget = {
135
474
  meta: {
@@ -166,11 +505,17 @@ export const appFileBudget = {
166
505
  /** The rules this law contributes to the plugin. */
167
506
  export const rules = {
168
507
  "app-options-composition-free": appOptionsCompositionFree,
508
+ "app-composition-typed": appCompositionTyped,
509
+ "app-env-required": appEnvRequired,
510
+ "app-module-list-inline": appModuleListInline,
169
511
  "app-file-budget": appFileBudget,
170
512
  }
171
513
 
172
- /** Both start at error: every rule is an error, and an app already over a budget migrates its lists into compose/. */
514
+ /** Every rule starts at error; an app already over a budget migrates its lists into compose/. */
173
515
  export const recommended = {
174
516
  "starci-be/app-options-composition-free": "error",
517
+ "starci-be/app-composition-typed": "error",
518
+ "starci-be/app-env-required": "error",
519
+ "starci-be/app-module-list-inline": "error",
175
520
  "starci-be/app-file-budget": "error",
176
521
  }
package/authorization.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The rule that holds the guard half of HFS default-deny (catalog R41 `BE_DEFAULT_DENY`, law `authorization`).
3
3
  *
4
- * Authentication and authorization are the global `APP_GUARD` chain of the app (throttler, CSRF origin guard, then the
5
- * `AuthGuard` of `domain/identity`), registered once and read by the architecture machine. A route therefore never picks
4
+ * Authentication and authorization are the global `APP_GUARD` chain of the app (RateLimitGuard, OriginGuard, the `CredentialGuard` and the
5
+ * `AuthGuard` of `domain/identity`, in that order), registered once and read by the architecture machine. A route therefore never picks
6
6
  * its own guard: `@UseGuards(...)` has no legitimate use in product code, and a door that names a guard is a door that
7
7
  * can also forget one. `@Public({ reason: PublicReason.X })` is the only way to open a door.
8
8
  *
@@ -45,12 +45,78 @@ export const noAuthUseGuards = {
45
45
  },
46
46
  }
47
47
 
48
+ const GUARD_NAME = /Guard$/
49
+
50
+ /** The identifier a type reference or expression ends in, or undefined. */
51
+ const nameOf = (node) => {
52
+ if (node === undefined || node === null) return undefined
53
+ if (node.type === "Identifier") return node.name
54
+ if (node.type === "TSQualifiedName") return node.right.name
55
+ if (node.type === "MemberExpression" && !node.computed) return node.property.name
56
+ return undefined
57
+ }
58
+
59
+ /** True when the class is a guard: it implements CanActivate or is named `...Guard`. */
60
+ const isGuardClass = (node) =>
61
+ GUARD_NAME.test(node.id?.name ?? "") || (node.implements ?? []).some((item) => nameOf(item.expression) === "CanActivate")
62
+
63
+ /** True when the type mentions CanActivate or a `...Guard` class, directly or as `Type<...>` / a union member. */
64
+ const namesGuardType = (type) => {
65
+ if (type === undefined || type === null) return false
66
+ if (type.type === "TSTypeReference") {
67
+ const name = nameOf(type.typeName)
68
+ if (name === "CanActivate" || GUARD_NAME.test(name ?? "")) return true
69
+ return (type.typeArguments ?? type.typeParameters)?.params?.some(namesGuardType) ?? false
70
+ }
71
+ if (type.type === "TSUnionType" || type.type === "TSIntersectionType") return type.types.some(namesGuardType)
72
+ return false
73
+ }
74
+
75
+ const parameterOf = (parameter) => (parameter.type === "TSParameterProperty" ? parameter.parameter : parameter)
76
+
77
+ /** True when an `@Inject(SomeGuard)` style decorator names a guard class. */
78
+ const injectsGuardToken = (parameter) =>
79
+ (parameter.decorators ?? []).some(
80
+ (decorator) =>
81
+ decorator.expression.type === "CallExpression" &&
82
+ decorator.expression.arguments.some((argument) => GUARD_NAME.test(nameOf(argument) ?? "")),
83
+ )
84
+
85
+ /** A guard never calls, extends or receives another guard: the app registers each one as its own APP_GUARD. */
86
+ export const noGuardInjectsGuard = {
87
+ meta: {
88
+ type: "problem",
89
+ docs: { description: "A guard class has no constructor parameter that is another guard (no CanActivate-typed parameter)." },
90
+ schema: [],
91
+ messages: {
92
+ guardInGuard:
93
+ "A guard must not inject another guard (found a `{{name}}` constructor parameter). Each guard is its own global `APP_GUARD` in the fixed order RateLimitGuard, OriginGuard, CredentialGuard, AuthGuard; share logic through a service or a port, and let metadata (`@Credential`, `@Public`) select the behavior.",
94
+ },
95
+ },
96
+ create(context) {
97
+ const check = (node) => {
98
+ if (!isGuardClass(node)) return
99
+ const constructor = node.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
100
+ for (const raw of constructor?.value.params ?? []) {
101
+ const parameter = parameterOf(raw)
102
+ const annotation = parameter.typeAnnotation?.typeAnnotation
103
+ if (namesGuardType(annotation) || injectsGuardToken(raw) || injectsGuardToken(parameter)) {
104
+ context.report({ node: raw, messageId: "guardInGuard", data: { name: parameter.name ?? "guard" } })
105
+ }
106
+ }
107
+ }
108
+ return { ClassDeclaration: check, ClassExpression: check }
109
+ },
110
+ }
111
+
48
112
  /** Every rule this module ships, by the name a config switches it on under. */
49
113
  export const rules = {
50
114
  "no-auth-use-guards": noAuthUseGuards,
115
+ "no-guard-injects-guard": noGuardInjectsGuard,
51
116
  }
52
117
 
53
118
  /** The level a consuming repository switches this on at. */
54
119
  export const recommended = {
55
120
  "starci-be/no-auth-use-guards": "error",
121
+ "starci-be/no-guard-injects-guard": "error",
56
122
  }
package/builders.mjs CHANGED
@@ -72,7 +72,7 @@ const isEntityClass = (checker, declaration) => {
72
72
  const callee = ts.isCallExpression(decorator.expression) ? decorator.expression.expression : decorator.expression
73
73
  const symbol = checker.getSymbolAtLocation(callee)
74
74
  if (!symbol) return false
75
- const target = (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol
75
+ const target = ts.skipAlias(symbol, checker)
76
76
  return (target.getDeclarations?.() ?? []).some((found) => moduleOf(found, String(found.getSourceFile().fileName).replace(/\\/g, "/")) === "typeorm")
77
77
  })
78
78
  }
@@ -212,7 +212,7 @@ export const builderArrangesOnly = {
212
212
  }
213
213
  return {
214
214
  ImportDeclaration(node) {
215
- if (node.source.value === "@jest/globals") context.report({ node, messageId: "assertion", data: { name: "@jest/globals" } })
215
+ if (["expect", "@jest/globals"].includes(node.source.value)) context.report({ node, messageId: "assertion", data: { name: node.source.value } })
216
216
  },
217
217
  Identifier(node) {
218
218
  const parent = node.parent
package/config-owner.mjs CHANGED
@@ -40,14 +40,6 @@ const PROCESS_MODULES = new Set(["process", "node:process"])
40
40
  /** `@nestjs/config`, `dotenv` and their subpaths. */
41
41
  const isConfigPackage = (source) => /^(?:@nestjs\/config|dotenv)(?:\/|$)/.test(source)
42
42
 
43
- /** `globalThis.process` and `global.process`. */
44
- const isGlobalProcess = (node) =>
45
- node.type === "MemberExpression" &&
46
- !node.computed &&
47
- keyName(node.property) === "process" &&
48
- node.object.type === "Identifier" &&
49
- (node.object.name === "globalThis" || node.object.name === "global")
50
-
51
43
  /** True when the property of a member expression spells `env`. */
52
44
  const spellsEnv = (node) => (node.computed ? staticText(node.property) === "env" : keyName(node.property) === "env")
53
45
 
@@ -69,15 +61,49 @@ export const noDirectEnvRead = {
69
61
  const hfs = hfsOf(context)
70
62
  const inConfigOwner = isOwnedBy(hfs, filename, "platform", "config")
71
63
  let declaresEnvSource = false
72
- const processNames = new Set(["process"])
73
- const isLocal = (identifier) => {
64
+ const variableOf = (identifier) => {
74
65
  for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
75
66
  const variable = scope.set.get(identifier.name)
76
- if (variable) return variable.defs.length > 0 && !variable.defs.every((definition) => definition.type === "ImportBinding")
67
+ if (variable) return variable
77
68
  }
78
- return false
69
+ return null
70
+ }
71
+ const isGlobal = (node) => node?.type === "Identifier" && ["globalThis", "global"].includes(node.name) && !variableOf(node)?.defs.length
72
+ // Resolve aliases by their lexical binding: an identically named local or parameter is not Node's process.
73
+ const isProcess = (node, seen = new Set()) => {
74
+ if (!node) return false
75
+ if (["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "ChainExpression"].includes(node.type)) return isProcess(node.expression, seen)
76
+ if (node.type === "MemberExpression") {
77
+ return isGlobal(node.object) && (node.computed ? staticText(node.property) : keyName(node.property)) === "process"
78
+ }
79
+ if (node.type !== "Identifier") return false
80
+ const variable = variableOf(node)
81
+ if (!variable?.defs.length) return node.name === "process"
82
+ if (seen.has(node)) return false
83
+ seen.add(node)
84
+ return variable.defs.some((definition) => {
85
+ if (definition.type === "ImportBinding") {
86
+ const specifier = definition.node
87
+ return PROCESS_MODULES.has(definition.parent.source.value)
88
+ && (specifier.type !== "ImportSpecifier" || keyName(specifier.imported) === "default")
89
+ }
90
+ if (definition.type !== "Variable") return false
91
+ const readScope = context.sourceCode.getScope(node).variableScope
92
+ const write = variable.references.filter((reference) => reference.isWrite()
93
+ && [readScope, variable.scope.variableScope].includes(reference.from.variableScope)
94
+ && reference.writeExpr?.range[1] <= node.range[0])
95
+ .sort((left, right) => right.identifier.range[0] - left.identifier.range[0])[0]
96
+ // A const captured by a function keeps its initializer even when the function is declared first.
97
+ const source = write?.writeExpr ?? (definition.parent.kind === "const" ? definition.node.init : null)
98
+ if (!source) return false
99
+ const identifier = write?.identifier ?? definition.name
100
+ const parent = identifier.parent
101
+ if (parent.type === "Property" && parent.value === identifier && parent.parent.type === "ObjectPattern") {
102
+ return isGlobal(source) && (parent.computed ? staticText(parent.key) : keyName(parent.key)) === "process"
103
+ }
104
+ return isProcess(source, seen)
105
+ })
79
106
  }
80
- const isProcess = (node) => (node.type === "Identifier" && processNames.has(node.name) && !isLocal(node)) || isGlobalProcess(node)
81
107
  const isEnvSourceFile = () => inConfigOwner && declaresEnvSource
82
108
  const inWorld = isTestWorld(hfs, filename)
83
109
  const appRole = appRoleOf(hfs, filename)?.entry
@@ -101,7 +127,6 @@ export const noDirectEnvRead = {
101
127
  if (!PROCESS_MODULES.has(source)) return
102
128
  for (const specifier of node.specifiers) {
103
129
  if (specifier.type === "ImportSpecifier" && (specifier.imported.name ?? specifier.imported.value) === "env") reportEnv(specifier)
104
- else if (specifier.type !== "ImportSpecifier" || (specifier.imported.name ?? specifier.imported.value) === "default") processNames.add(specifier.local.name)
105
130
  }
106
131
  },
107
132
  ImportExpression(node) {
@@ -113,7 +138,11 @@ export const noDirectEnvRead = {
113
138
  },
114
139
  VariableDeclarator(node) {
115
140
  if (!node.init || !isProcess(node.init) || node.id.type !== "ObjectPattern") return
116
- if (node.id.properties.some((property) => property.type === "Property" && keyName(property.key) === "env")) reportEnv(node)
141
+ if (node.id.properties.some((property) => property.type === "Property" && (property.computed ? staticText(property.key) : keyName(property.key)) === "env")) reportEnv(node)
142
+ },
143
+ AssignmentExpression(node) {
144
+ if (node.operator !== "=" || node.left.type !== "ObjectPattern" || !isProcess(node.right)) return
145
+ if (node.left.properties.some((property) => property.type === "Property" && (property.computed ? staticText(property.key) : keyName(property.key)) === "env")) reportEnv(node)
117
146
  },
118
147
  CallExpression(node) {
119
148
  const callee = node.callee