@starci/eslint-canon-be 3.3.1 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +2 -2
- package/app-composition.mjs +348 -3
- package/authorization.mjs +68 -2
- package/builders.mjs +2 -2
- package/config-owner.mjs +44 -15
- package/default-deny.mjs +80 -9
- package/edge-hardening.mjs +139 -0
- package/feature-thin.mjs +78 -2
- package/function-budget.mjs +49 -0
- package/health-ready.mjs +539 -0
- package/index.mjs +37 -0
- package/infra-owner.mjs +3 -0
- package/jwt-alg.mjs +45 -0
- package/lib/subject-values.mjs +55 -0
- package/lib/unit-spec.mjs +0 -3
- package/lib/webhook-handoff-inbox.mjs +260 -0
- package/lib/webhook-handoff-intake.mjs +140 -0
- package/lib/why.mjs +13 -0
- package/module-shape.mjs +147 -51
- package/operations.mjs +165 -1
- package/package.json +1 -1
- package/realtime.mjs +59 -22
- package/resilience.mjs +45 -14
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +2855 -163
- package/runtime/knowledge/hfs/slots.yaml +333 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +5 -7
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +4 -1
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +4 -4
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +69 -9
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +158 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +3 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/service-isolation.mjs +3 -0
- package/size-budget.mjs +9 -5
- package/third-party-owner.mjs +149 -0
- package/transport.mjs +11 -29
- package/unit-spec.mjs +270 -84
- package/webhooks.mjs +169 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 3.4.1 - 2026-10-17
|
|
6
|
+
|
|
7
|
+
- Changed: the bundled runtime copies follow the runtime of 1.0.0-alpha.19 (owner ruling no-runtime-specs, 2026-10-17): the failure codes, the slot manifest (`exampleNa`) and the rule catalog (`specNa`, `exampleNa`) carry the removal of the runtime's own specs; package behavior is unchanged.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
## 3.4.0 - 2026-10-15
|
|
15
|
+
- 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(...)`.
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
3
18
|
## 3.3.1 - 2026-10-15
|
|
4
19
|
- 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
20
|
|
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`,
|
|
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;
|
|
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`) |
|
package/app-composition.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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 (
|
|
5
|
-
* `AuthGuard` of `domain/identity
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
67
|
+
if (variable) return variable
|
|
77
68
|
}
|
|
78
|
-
return
|
|
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
|