@starci/eslint-canon-be 3.2.18 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +46 -4
- package/app-composition.mjs +176 -0
- package/config-owner.mjs +34 -19
- package/feature-thin.mjs +59 -17
- package/index.mjs +2 -0
- package/lib/app-roles.mjs +48 -0
- package/lib/webhook-ack.mjs +87 -0
- package/lib/webhook-handoff.mjs +138 -0
- package/lib/webhook-verifiers.mjs +147 -0
- package/module-layering.mjs +2 -2
- package/module-shape.mjs +6 -5
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +41 -13
- package/runtime/knowledge/hfs/slots.yaml +99 -43
- package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
- package/runtime/scripts/api/git/cat-file.mjs +5 -0
- package/runtime/scripts/api/git/diff.mjs +5 -0
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
- package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
- package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
- package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
- package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
- package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
- package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
- package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
- package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
- package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
- package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
- package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
- package/runtime/scripts/hfs/path-findings.mjs +32 -2
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +3 -1
- package/runtime/scripts/hfs/slot-required.mjs +13 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
- package/runtime/scripts/lib/ts-analysis.mjs +20 -0
- package/webhooks.mjs +141 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.3.0 - 2026-10-14
|
|
4
|
+
- Added: the app file role `<app>.env.ts` (150-line budget, `app-file-budget`): `config-owner` lets `main.ts` and the app's env file parse the environment; `app-options-composition-free` also refuses a module list or `register`/`forRoot` call in it, and it imports no feature.
|
|
5
|
+
- Changed: `background-unowned` decides by context (processor, scheduler, saga, event handler and what they call; a resolver or controller is never background) instead of by method name.
|
|
6
|
+
- Changed: `webhook-verify-first` and `webhook-shape` accept a first call to a declared domain verifier (`...Verifier` service or a verifier the webhook module declares) beside `WebhookSignatureService.verify`; `feature-thin` treats that proof as a gate call.
|
|
7
|
+
- Changed: the bundled architecture machine carries `HFS_DUPLICATE_CODE` canon-shape exclusions, staged dead-export consumers and the `<app>.env.ts` role.
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
- Added: law `app-composition` (R245 and R246, owner ruling 2026-10-14). `app-options-composition-free` (`BE_APP_OPTIONS_COMPOSITION`) keeps `apps/<app>/src/<app>.options.ts` to env-derived options and port bindings (provider maps such as `{ approvalGate: X }`): an array that lists a `*Module` class, a `X.register(...)`/`forRoot(...)`/`registerQueue(...)` call, a `*SchedulerOf(...)`/`*Queue(...)` entry or a spread of such a list, and an `imports`, `modules`, `schedulers` or `processors` array are refused, pointing to `compose/<area>.ts`. `app-file-budget` (`BE_APP_COMPOSITION_BUDGET`) holds `main.ts` (80), `<app>.options.ts` (200), `compose/<area>.ts` (120) and `app.module.ts` (250) to the `budget` of their app slot; the budget is absolute. The role of an app file is the slot entry it matches (`lib/app-roles.mjs`), never a path list.
|
|
11
|
+
- Changed: `is-global-only-in-app` accepts `isGlobal: true` in the composition groups `apps/<app>/src/compose/<area>.ts` (they are part of the app root), and the bundled architecture machine reads an app's root and its groups as one composition (register-once, module-per-transport, the connection and context maps).
|
|
12
|
+
- Changed: carry the runtime mirror of HFS manifest 3.1.0 (rule catalog 3.1.0) and the canon pins of eslint-canon-fe 8.2.17, HFS 4.3.0 and CLI 1.2.16.
|
|
13
|
+
|
|
14
|
+
## 3.2.19 - 2026-10-14
|
|
15
|
+
|
|
16
|
+
- Changed: carry the canon pins of eslint-canon-fe 8.2.18, HFS 4.2.17 and CLI 1.2.17 in the runtime mirror. ESLint rule implementations are unchanged.
|
|
17
|
+
|
|
3
18
|
## 3.2.18 - 2026-10-14
|
|
4
19
|
|
|
5
20
|
- Changed: carry the runtime mirror with the dead-exports fix (an import by a sibling declared entry is a use) and the canon pins of eslint-canon-fe 8.2.17, HFS 4.2.16 and CLI 1.2.16. ESLint rule implementations are unchanged.
|
package/README.md
CHANGED
|
@@ -39,8 +39,9 @@ A sample, not the list:
|
|
|
39
39
|
|---|---|
|
|
40
40
|
| **Errors** | A `catch` rethrows, calls a method on a `Logger` receiver (typed, from `platform/logging`) or returns an outcome carrying its cause; a `DomainError` subclass lives only in `errors/<capability>.error.ts` of its owner, and that file is one code enum, one `Record<Code, ErrorKind>` table and one empty class; a `throw` throws a `DomainError`, never a bare `Error`, a framework exception or a platform error out of a domain (`catch-must-account`, `error-home`, `throw-domain-error`, `error-family-shape`) |
|
|
41
41
|
| **Schema authority** | Migrations only: every DataSource or TypeORM options object states the literal `synchronize: false` (found by the contextual type), no `migrationsRun`, no DDL string outside a migration, no entity glob; no `@Entity` type reachable from a transport signature, a handler `execute`, a contract or a message, and no GraphQL or validator import in an entity file (`no-runtime-schema`, `no-entity-in-contract`) |
|
|
42
|
-
| **Config and secrets** | `process.env` (any form)
|
|
42
|
+
| **Config and secrets** | `process.env` (any form) is allowed in the `platform/config` file declaring `EnvSource`, the test world (slot `be.tests.world`) and the app bootstrap roles `apps/<app>/src/main.ts` and its matching `<app>.env.ts`; only those two app roles may read `EnvSource` or capability parse helpers. Compose groups and `<app>.options.ts` never read `process.env` or `EnvSource`. `@nestjs/config` and `dotenv` are forbidden config paths. A function exported by a `<capability>.config.ts` keeps its existing name and is called only from these bootstrap roles, its own config file or spec, the test world and a provider contract spec (`config-parsed-in-main`). Secrets use `EnvSource.secret()`: `<NAME>_FILE` takes priority for `.starcistacks` custody, and plain `<NAME>` is read only when the file key is absent; a declared file supplies exact contents and an empty or unreadable path fails startup without fallback. No default of any kind for a value typed `Secret` or `Url`; secrets compare with `timingSafeEqual` (`no-direct-env-read`, `config-parsed-in-main`, `no-secret-default`, `secret-compare-timing-safe`) |
|
|
43
43
|
| **Ambient ids** (R142) | `randomUUID` of Node's crypto (named, namespace or global), the `v1`, `v4`, `v6` and `v7` generators of `uuid`, `nanoid` and `ulid` are used only inside `platform/ids`, specs included: code asks the injected `Ids` port and a spec provides `fakeIds()`; the generators are found by the module they are imported from, never by a local name (`no-ambient-id`) |
|
|
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 |
|
|
44
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 |
|
|
45
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`) |
|
|
46
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`) |
|
|
@@ -59,9 +60,9 @@ A sample, not the list:
|
|
|
59
60
|
| **Data access** | The database is reached through the shared `EntityManager` only, received as a constructor parameter with the named injector of a declared connection - a lookalike name, property injection, an injected `DataSource` or `QueryRunner` outside `platform/database` and the cli (`apps/cli`, `src/features/cli/`), `getRepository` and repository types are refused, specs included (`must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner` whatever it is called, and no exported function takes an `EntityManager` first (a statement module); `src/tests` is exempt (`no-repository-class`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner`, whatever it is called, and no exported function takes an `EntityManager` first (a statement module); the test tree (`src/tests`) is exempt (`no-repository-class`); inside a transaction callback only the manager it received is used (`no-outer-manager-in-transaction`); no call through an integration, `HttpClient` or `MessagePublisher` typed receiver spans a transaction (`no-external-call-in-transaction`); the manager is injected only in application handlers, domain services and the platform database, inbox, outbox and lease capabilities (`em-injection-slots`) |
|
|
60
61
|
| **Connections** | One database is one connection and one `Inject<Conn>EntityManager()` injector: `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in `platform/database/<conn>.decorators.ts`, the cli and the test world; an exported EntityManager injector is exactly the declared connection's; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and the cli; the connection name is written once, in its `<conn>.connection.ts`; a `<conn>.config.ts` reads only keys of its `envPrefix` (`one-connection-per-database`) |
|
|
61
62
|
| **Operations** | A versioned operation is declared once in the app's typed operation table (`query<Input, Output, RefusalCode>()` / `mutation<...>()` registered by `defineOperations`, declared by the platform capability `operations`): its input and output are closed types (no `any`, `unknown`, `Record<string, unknown>`, unbound generic, function or `Date`) and its refusal codes a closed union of string literals; a route that takes or answers the table's types takes `OperationRequest<Table>` and answers `Promise<OperationReply<Table>>` of one table (`operation-contract-decidable`, `operation-route-driven-by-table`) |
|
|
62
|
-
| **Webhooks and realtime** | A provider webhook door injects only the `WebhookSignatureService` and domain services,
|
|
63
|
+
| **Webhooks and realtime** | A provider webhook door injects only the `WebhookSignatureService` and domain services, verifies first (awaited when async), and hands the POST delivery to exactly one domain intake that records the inbox receipt and outbox event; a domain method may combine proof and intake. The POST returns nothing or the own-feature exported literal `PROVIDER_ACK`. One optional GET challenge handler returns only its declared domain verifier's result. The door and HTTP module import or inject no `CommandBus`, `QueryBus` or feature command handler, and its resolved domain intake dispatches no command/query bus or feature handler; a reactor or job dispatches after commit (`webhook-shape`, `webhook-verify-first`, `feature-thin`). A realtime door is one gateway or subscription that injects only the `RealtimeHub` and subscribes to a topic built from its `@CurrentPrincipal()` (`realtime-shape`, `realtime-read-only`, `realtime-topic-scope`) |
|
|
63
64
|
| **Transport** | A door injects only `CommandBus` / `QueryBus` (and `RequestLocale` in REST) and dispatches exactly one message per handler, holding no branch, loop, EntityManager or call other than the bus, and pure `*.mapper.ts` functions and `unwrapOutcome` of `platform/primitives` (`transport-is-thin`); no response envelope, no `GraphQLJSON`, no `unknown` body, no `switch (input.operation)`; a REST door lives in `transport/http` and shows a public reason, a byte stream or a redirect; a capability never imports a feature (`transport-is-thin`, `no-response-envelope`, `no-graphql-json`, `door-lives-in-features`, `rest-door-needs-a-reason`, `no-capability-imports-features`) |
|
|
64
|
-
| **Feature thin** (R203 BE_FEATURE_THIN) | A feature-tier file of a thin role (`<name>.handler.ts`, `.resolver.ts`, `.controller.ts`, `.consumer.ts`, `.processor.ts`, `.step.ts`, `.saga.ts`, `.saga-step.ts`, `.compensation.ts`, `.webhook.ts`, `.gateway.ts`, `.subscription.ts`, `.cli.ts`, `.mapper.ts`, `.service.ts`) maps its parameters and makes exactly one delegating call into the modules - an injected member, a bus `execute` or a modules function in statement position - with no branch, loop, `try`, `throw` or computation; free calls are pure `*.mapper.ts` functions, the `Logger` port and a webhook's `
|
|
65
|
+
| **Feature thin** (R203 BE_FEATURE_THIN) | A feature-tier file of a thin role (`<name>.handler.ts`, `.resolver.ts`, `.controller.ts`, `.consumer.ts`, `.processor.ts`, `.step.ts`, `.saga.ts`, `.saga-step.ts`, `.compensation.ts`, `.webhook.ts`, `.gateway.ts`, `.subscription.ts`, `.cli.ts`, `.mapper.ts`, `.service.ts`) maps its parameters and makes exactly one delegating call into the modules - an injected member, a bus `execute` or a modules function in statement position - with no branch, loop, `try`, `throw` or computation; free calls are pure `*.mapper.ts` functions, the `Logger` port and a webhook's first verification call when followed by one separate intake (a combined domain verification and intake call counts as the delegation). A webhook POST may return its declared literal `PROVIDER_ACK`; its optional GET challenge has one domain verifier call and echoes only that result. Webhook doors and their HTTP modules reach no command/query bus or feature command handler; orchestration (step order, compensation, claim, fencing, retry) belongs to the platform (`feature-thin`) |
|
|
65
66
|
| **Infrastructure owners** | Each raw library (HTTP, cache, queue, scheduler, logger, date, config, events) is imported or reached as a global only inside the capability the manifest names for it (`ruleParams.be.infraOwners`); `@nestjs/config`, `dotenv` and `@nestjs/event-emitter` nowhere; the `HttpClient` port of `platform/http` (found by the receiver's type) is called only in an `integrations` owner, `platform/http` and the test world (`infra-import-owner`, `http-client-only-in-integrations`) |
|
|
66
67
|
| **Observability** | Logs leave through the `Logger` port of `platform/logging`, never the framework's `Logger`; a log call's first argument is a member of an owner's `<owner>.log-events.ts` enum; a failure log carries the exception's identity, not only its wording (`no-framework-logger`, `no-interpolated-log-message`, `no-error-wording-as-log-identity`) |
|
|
67
68
|
| **Testing** | A unit spec is `<name>.<role>.spec.ts` beside its subject: REQUIRED beside every file of a spec-required role of `ruleParams.be.unitRoles` (every `<name>.service.ts` its `<name>.service.spec.ts`, every `<name>.cli.ts` of `src/features/cli/` its `<name>.cli.spec.ts`) and beside a webhook or realtime door that has no service of its own; OPTIONAL beside any other file of a `ruleParams.be.logicRoles` role inside a slot whose `coverage` is `required` (the file owes 100 per file, not a spec of its own); any other `*.spec.ts` outside `src/tests/{e2e,integration,contract,world}` (handler, resolver, controller, consumer, entity, module, composition) is a finding; there is no `.test.ts`, `int-spec` or `harness-spec`; a spec asserts a result or a state, not only a call; an e2e reads persisted state back through a typeorm `EntityManager` and reaches no model provider outside `src/tests/e2e/live/` (`unit-test-colocated`, `no-call-only-spec`, `e2e-asserts-persisted-state`, `no-model-call-in-e2e`, `no-api-shaped-e2e-filename`, `no-marker-model-stub`) |
|
|
@@ -79,6 +80,48 @@ A sample, not the list:
|
|
|
79
80
|
Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
|
|
80
81
|
line leads straight to the document that explains why.
|
|
81
82
|
|
|
83
|
+
## Domain webhook verification
|
|
84
|
+
|
|
85
|
+
The first call may be a method on a domain service whose declared class name ends in `Verifier` or `verify`.
|
|
86
|
+
The service must resolve to a domain `<name>.service.ts`; a local alias or a lookalike class from another tier does not grant proof.
|
|
87
|
+
For an existing service with another name, declare the exact verification method in the adjacent
|
|
88
|
+
`<provider>-http.module.ts`:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
export const webhookVerifiers = [BillingService.prototype.receiveCassoWebhook] as const
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The declaration names TypeScript method symbols, never strings. Its first unconditional call must finish proof before
|
|
95
|
+
processing the delivery and must be awaited when async. A method may combine verification and intake as the handler's only
|
|
96
|
+
call, or a dedicated verification call may precede exactly one separate intake call. No data read, nested call or side effect
|
|
97
|
+
may run in the door before proof; the domain verifier owns connection-secret resolution. The verifier's service spec proves
|
|
98
|
+
that invalid proof refuses the delivery before intake, because lint establishes the call's declaration and order, not its
|
|
99
|
+
cryptographic behavior.
|
|
100
|
+
|
|
101
|
+
The POST intake records the delivery through the shared inbox and publishes its receipt event in the same transaction.
|
|
102
|
+
The existing `Inbox` port supplies `claim`; `inbox.record` describes this domain recording method, such as
|
|
103
|
+
`recordProviderDelivery`, rather than a second inbox API. A reactor or job consumes the durable receipt and dispatches the
|
|
104
|
+
processing command. The webhook door and its HTTP module may not import or inject `CommandBus`, `QueryBus` or a feature
|
|
105
|
+
command handler. Lint also refuses typed command/query-bus and feature-handler receivers in the resolved called intake method; calls hidden behind
|
|
106
|
+
helpers, dynamic receivers or unavailable implementations need semantic review.
|
|
107
|
+
|
|
108
|
+
For a provider that requires a fixed response, declare the constant in the door or its own HTTP module:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
export const PROVIDER_ACK = { success: true } as const
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
After proof and its one intake call, the POST may `return PROVIDER_ACK`. A readonly literal object type or literal string
|
|
115
|
+
is also supported. Only literal contents qualify: an inline returned object, computed value, call, spread, interpolated
|
|
116
|
+
template, input-derived constant or another feature's constant remains a `returns` finding. The constant never replaces
|
|
117
|
+
the first-statement proof.
|
|
118
|
+
|
|
119
|
+
A subscription provider may have one GET handler beside the one POST. Its first statement calls a declared domain verifier
|
|
120
|
+
and it echoes exactly that result, for example `const challenge = this.verifier.challenge(query); return challenge`.
|
|
121
|
+
The awaited form is required for an async verifier. GET has no intake and no other computed response. Both handlers carry
|
|
122
|
+
the signed-webhook public reason and strict rate tier. See `knowledge/patterns/be/webhooks.yaml` for the full Meta-style
|
|
123
|
+
door, receipt and reactor file tree.
|
|
124
|
+
|
|
82
125
|
## Companion
|
|
83
126
|
|
|
84
127
|
- **[@starci/eslint-canon-fe](https://www.npmjs.com/package/@starci/eslint-canon-fe)** — the
|
|
@@ -103,4 +146,3 @@ ESLint 9+ (flat config), Node 20.9+.
|
|
|
103
146
|
Every rule of this plugin is an enforcer of one HFS rule. The relation (rule id -> HFS rule -> catalogued Vietnamese why code)
|
|
104
147
|
lives in exactly one place, `knowledge/hfs/rules.yaml` (`enforcers`), and `scripts/checks/check-hfs-rules.mjs` refuses a
|
|
105
148
|
rule of this plugin that no catalog entry names. There is no second table here.
|
|
106
|
-
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the composition files of a back-end app (catalog R245 `BE_APP_OPTIONS_COMPOSITION` and R246
|
|
3
|
+
* `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
|
+
* `main.ts`, `app.module.ts`, `<app>.env.ts`, `<app>.options.ts` and the composition groups `compose/<area>.ts`.
|
|
5
|
+
*
|
|
6
|
+
* - `app-options-composition-free` (`BE_APP_OPTIONS_COMPOSITION`) keeps `<app>.env.ts` to typed, fail-fast environment
|
|
7
|
+
* parsing only and `<app>.options.ts` to options types and port bindings (provider maps such as `{ approvalGate: X }`): an array that lists Nest modules (a
|
|
8
|
+
* `*Module` class, a `X.register(...)`/`forRoot(...)`/`registerQueue(...)` call), queue registrations or scheduler entries
|
|
9
|
+
* (a `*SchedulerOf(...)`, `*Scheduler(...)` or `*Queue(...)` call), a spread of such a list, and an `imports`, `modules`,
|
|
10
|
+
* `schedulers` or `processors` array are refused. Those lists are composition: they live in `compose/<area>.ts`, one
|
|
11
|
+
* group per area, which `app.module.ts` spreads. The env role also refuses registration and scheduler calls outside arrays.
|
|
12
|
+
* `config-owner.mjs` keeps process environment access and owned `EnvSource` reads in `main.ts` or the matching env role;
|
|
13
|
+
* options files and composition groups receive the typed result of `read<App>Env(env: EnvSource)`.
|
|
14
|
+
* - `app-file-budget` (`BE_APP_COMPOSITION_BUDGET`) holds each composition file to the line budget its app slot names
|
|
15
|
+
* (`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
|
+
* `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
|
+
* Both rules ask the slot view which entry a file matches; neither keeps a path of its own.
|
|
19
|
+
*/
|
|
20
|
+
import { lineCount } from "./runtime/scripts/lib/line-count.mjs"
|
|
21
|
+
import { APP_ENV_ROLE, APP_OPTIONS_ROLE, appRoleOf } from "./lib/app-roles.mjs"
|
|
22
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
23
|
+
import { keyName, staticText } from "./lib/ast.mjs"
|
|
24
|
+
import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
|
|
25
|
+
|
|
26
|
+
/** Static factories that register a Nest module (a dynamic module), a queue or a flow producer. */
|
|
27
|
+
const REGISTRATION_CALLS = new Set([
|
|
28
|
+
"register",
|
|
29
|
+
"registerAsync",
|
|
30
|
+
"forRoot",
|
|
31
|
+
"forRootAsync",
|
|
32
|
+
"forFeature",
|
|
33
|
+
"forFeatureAsync",
|
|
34
|
+
"registerQueue",
|
|
35
|
+
"registerQueueAsync",
|
|
36
|
+
"registerFlowProducer",
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
/** Keys whose array value is a composition list whatever it holds. */
|
|
40
|
+
const COMPOSITION_KEYS = new Set(["imports", "modules", "schedulers", "processors"])
|
|
41
|
+
|
|
42
|
+
/** A name that is a Nest module class. */
|
|
43
|
+
const MODULE_NAME = /Module$/
|
|
44
|
+
/** A name that is a list of modules, queues, schedulers or processors (the operand of a spread). */
|
|
45
|
+
const LIST_NAME = /(?:Modules|Queues|Schedulers|Processors)$/
|
|
46
|
+
/** A call that builds a scheduler entry or a queue registration: `orderExpirySchedulerOf(...)`, `ordersQueue(...)`. */
|
|
47
|
+
const ENTRY_CALL = /(?:Scheduler|Queue)(?:Of)?$/
|
|
48
|
+
|
|
49
|
+
/** Type-only wrappers do not change whether an expression composes modules. */
|
|
50
|
+
const WRAPPERS = new Set(["TSAsExpression", "TSTypeAssertion", "TSSatisfiesExpression", "TSNonNullExpression", "TSInstantiationExpression", "ChainExpression"])
|
|
51
|
+
const unwrap = (node) => {
|
|
52
|
+
while (node && WRAPPERS.has(node.type)) node = node.expression
|
|
53
|
+
return node
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The known name of a member, including a static computed string. */
|
|
57
|
+
const memberName = (node) => node.computed ? staticText(node.property) : keyName(node.property)
|
|
58
|
+
|
|
59
|
+
/** The last name of an identifier or a statically named member expression, else null. */
|
|
60
|
+
const lastName = (node) => {
|
|
61
|
+
node = unwrap(node)
|
|
62
|
+
if (node?.type === "Identifier") return node.name
|
|
63
|
+
if (node?.type === "MemberExpression") return memberName(node)
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What an array element composes (`module`, `registration`, `scheduler`, `list`), or null when it is plain options data. */
|
|
68
|
+
const compositionOf = (element) => {
|
|
69
|
+
element = unwrap(element)
|
|
70
|
+
if (!element) return null
|
|
71
|
+
if (element.type === "SpreadElement") {
|
|
72
|
+
const argument = unwrap(element.argument)
|
|
73
|
+
const spread = argument?.type === "CallExpression" ? lastName(argument.callee) : lastName(argument)
|
|
74
|
+
return spread !== null && (LIST_NAME.test(spread) || MODULE_NAME.test(spread)) ? "list" : null
|
|
75
|
+
}
|
|
76
|
+
if (element.type === "CallExpression") {
|
|
77
|
+
const callee = unwrap(element.callee)
|
|
78
|
+
if (callee.type === "MemberExpression" && REGISTRATION_CALLS.has(memberName(callee))) return "registration"
|
|
79
|
+
const name = lastName(callee)
|
|
80
|
+
return name !== null && ENTRY_CALL.test(name) ? "scheduler" : null
|
|
81
|
+
}
|
|
82
|
+
const name = lastName(element)
|
|
83
|
+
return name !== null && MODULE_NAME.test(name) ? "module" : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Env parsing and options bindings hold no module, queue or scheduler composition. */
|
|
87
|
+
export const appOptionsCompositionFree = {
|
|
88
|
+
meta: {
|
|
89
|
+
type: "problem",
|
|
90
|
+
docs: { description: "`<app>.env.ts` holds environment parsing only and `<app>.options.ts` holds options types and port bindings; module, queue and scheduler composition lives in `compose/<area>.ts`." },
|
|
91
|
+
schema: [],
|
|
92
|
+
messages: {
|
|
93
|
+
list: "`{{key}}` is a composition list in {{role}}. `<app>.env.ts` holds typed, fail-fast environment parsing only and `<app>.options.ts` holds options types and port bindings; move this list into `apps/<app>/src/compose/<area>.ts` (one group per area) and spread the group in `app.module.ts`.",
|
|
94
|
+
element: "This array lists a {{what}} in {{role}}. `<app>.env.ts` holds environment parsing only and `<app>.options.ts` holds options types and port bindings; module lists, queue registrations and scheduler entries live in `apps/<app>/src/compose/<area>.ts`, which `app.module.ts` spreads.",
|
|
95
|
+
call: "This {{what}} composes modules, queues or schedulers in `<app>.env.ts`. Keep typed, fail-fast environment parsing here and move composition into `apps/<app>/src/compose/<area>.ts`, which `app.module.ts` spreads.",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
create(context) {
|
|
99
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
100
|
+
if (isDeclarationFile(filename)) return {}
|
|
101
|
+
const role = appRoleOf(hfsOf(context), filename)?.entry
|
|
102
|
+
if (role !== APP_OPTIONS_ROLE && role !== APP_ENV_ROLE) return {}
|
|
103
|
+
const judged = new WeakSet()
|
|
104
|
+
return {
|
|
105
|
+
CallExpression(node) {
|
|
106
|
+
if (role !== APP_ENV_ROLE) return
|
|
107
|
+
let element = node
|
|
108
|
+
while (WRAPPERS.has(element.parent?.type)) element = element.parent
|
|
109
|
+
if (element.parent?.type === "ArrayExpression") return
|
|
110
|
+
const what = compositionOf(node)
|
|
111
|
+
if (what !== "registration" && what !== "scheduler") return
|
|
112
|
+
context.report({ node, messageId: "call", data: { what: what === "registration" ? "registration call" : "scheduler or queue call" } })
|
|
113
|
+
},
|
|
114
|
+
Property(node) {
|
|
115
|
+
const value = unwrap(node.value)
|
|
116
|
+
if (value?.type !== "ArrayExpression") return
|
|
117
|
+
const key = node.computed ? staticText(node.key) : keyName(node.key)
|
|
118
|
+
if (key === null || !COMPOSITION_KEYS.has(key)) return
|
|
119
|
+
judged.add(value)
|
|
120
|
+
context.report({ node, messageId: "list", data: { key, role } })
|
|
121
|
+
},
|
|
122
|
+
"ArrayExpression:exit"(node) {
|
|
123
|
+
if (judged.has(node)) return
|
|
124
|
+
const what = node.elements.map(compositionOf).find((kind) => kind !== null)
|
|
125
|
+
if (what === undefined) return
|
|
126
|
+
const label = { module: "Nest module", registration: "module or queue registration", scheduler: "scheduler or queue entry", list: "spread of a module, queue or scheduler list" }[what]
|
|
127
|
+
context.report({ node, messageId: "element", data: { what: label, role } })
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Each composition file of an app stays within the line budget its app slot names. */
|
|
134
|
+
export const appFileBudget = {
|
|
135
|
+
meta: {
|
|
136
|
+
type: "problem",
|
|
137
|
+
docs: { description: "`main.ts`, `app.module.ts`, `<app>.env.ts`, `<app>.options.ts` and `compose/<area>.ts` stay within the line budget of their app slot." },
|
|
138
|
+
schema: [],
|
|
139
|
+
messages: {
|
|
140
|
+
over: "This {{role}} has {{lines}} lines, over its budget of {{max}} (the `budget` of slot {{slot}}). {{fix}}",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
create(context) {
|
|
144
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
145
|
+
if (isDeclarationFile(filename)) return {}
|
|
146
|
+
const hfs = hfsOf(context)
|
|
147
|
+
const role = appRoleOf(hfs, filename)
|
|
148
|
+
const max = role ? hfs.slot(role.slot)?.budget?.[role.entry] : undefined
|
|
149
|
+
if (!Number.isInteger(max)) return {}
|
|
150
|
+
const fix = role.entry === APP_ENV_ROLE
|
|
151
|
+
? "Keep typed, fail-fast environment parsing only here; move module composition into `compose/<area>.ts` and business code into its owning module."
|
|
152
|
+
: role.entry === APP_OPTIONS_ROLE
|
|
153
|
+
? "Keep options types and port bindings here; move environment parsing into `<app>.env.ts` and module, queue or scheduler lists into `compose/<area>.ts`."
|
|
154
|
+
: role.entry === "main.ts"
|
|
155
|
+
? "Move environment parsing into `<app>.env.ts` and module composition into `compose/<area>.ts`; keep the bootstrap here."
|
|
156
|
+
: "Move one area of the composition into its own group `apps/<app>/src/compose/<area>.ts` and spread it in `app.module.ts`."
|
|
157
|
+
return {
|
|
158
|
+
"Program:exit"(node) {
|
|
159
|
+
const lines = lineCount((context.sourceCode || context.getSourceCode()).text)
|
|
160
|
+
if (lines > max) context.report({ node, loc: { line: 1, column: 0 }, messageId: "over", data: { role: role.entry, lines, max, slot: role.slot, fix } })
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The rules this law contributes to the plugin. */
|
|
167
|
+
export const rules = {
|
|
168
|
+
"app-options-composition-free": appOptionsCompositionFree,
|
|
169
|
+
"app-file-budget": appFileBudget,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Both start at error: every rule is an error, and an app already over a budget migrates its lists into compose/. */
|
|
173
|
+
export const recommended = {
|
|
174
|
+
"starci-be/app-options-composition-free": "error",
|
|
175
|
+
"starci-be/app-file-budget": "error",
|
|
176
|
+
}
|
package/config-owner.mjs
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
* and the secret-comparison half of R41 `BE_DEFAULT_DENY`).
|
|
4
4
|
*
|
|
5
5
|
* - `no-direct-env-read` keeps the process environment inside the one file that declares the `EnvSource` class of
|
|
6
|
-
* `platform/config`. Everything else receives typed options
|
|
6
|
+
* `platform/config`, `main.ts` or the matching `<app>.env.ts`. Everything else receives typed options. `process.env` in any form
|
|
7
7
|
* (member access, destructuring, `Reflect.get(process, "env")`, an `env` import of `node:process`), an import of
|
|
8
8
|
* `@nestjs/config` or `dotenv`, and a `process.cwd()` path joined into `src` or
|
|
9
9
|
* `.starcistacks` are refused everywhere else. The test world (slot `be.tests.world`) may read and write `process.env`:
|
|
10
10
|
* it hands the run state file path to the jest workers. A config getter that reads `process.env` itself is caught here at its
|
|
11
11
|
* definition.
|
|
12
|
-
* - `config-parsed-in-main` keeps the parsing of configuration in `main.ts`: a call of a function exported by a
|
|
12
|
+
* - `config-parsed-in-main` keeps the parsing of configuration in `main.ts` or `<app>.env.ts`: a call of a function exported by a
|
|
13
13
|
* `<capability>.config.ts` (or of a function returning a `<capability>.options.ts` type, at module scope) is refused
|
|
14
|
-
* outside the app entry (`main.ts` and `<app>.
|
|
14
|
+
* outside the app entry (`main.ts` and the matching `<app>.env.ts`), the config file itself, its own spec, the test world and a provider contract spec.
|
|
15
|
+
* Other app files also cannot call the app's env parser or the typed `EnvSource` readers. The callee is found through the type
|
|
15
16
|
* checker, so no function name is matched.
|
|
16
17
|
* - `no-secret-default` refuses a default for a value typed `Secret` or `Url` by `platform/config`: a default argument
|
|
17
18
|
* to an `EnvSource` reader, a `??`/`||` fallback on such a value, and a string literal default (`""`, `"localhost"`,
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
* No test lane is exempt: a spec builds its `EnvSource` from a literal record and never touches the process environment.
|
|
25
26
|
*/
|
|
26
27
|
import ts from "typescript"
|
|
28
|
+
import { APP_ENV_ROLE, appRoleOf } from "./lib/app-roles.mjs"
|
|
27
29
|
import { keyName, staticText, walk, wordsOf } from "./lib/ast.mjs"
|
|
28
30
|
import { isOwnedBy, isOwnedType, originsOf } from "./lib/declared.mjs"
|
|
29
31
|
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
@@ -49,15 +51,15 @@ const isGlobalProcess = (node) =>
|
|
|
49
51
|
/** True when the property of a member expression spells `env`. */
|
|
50
52
|
const spellsEnv = (node) => (node.computed ? staticText(node.property) === "env" : keyName(node.property) === "env")
|
|
51
53
|
|
|
52
|
-
/** Only the
|
|
54
|
+
/** Only EnvSource, the app bootstrap roles and the test world read the process environment. */
|
|
53
55
|
export const noDirectEnvRead = {
|
|
54
56
|
meta: {
|
|
55
57
|
type: "problem",
|
|
56
|
-
docs: { description: "The process environment is read only by
|
|
58
|
+
docs: { description: "The process environment is read only by `EnvSource` of `platform/config`, app `main.ts`, the matching `<app>.env.ts` and the test world." },
|
|
57
59
|
schema: [],
|
|
58
60
|
messages: {
|
|
59
|
-
env: "The process environment is read outside
|
|
60
|
-
package: "`{{source}}` is a second config path. Configuration is parsed once in `main.ts` by `platform/config` typed readers and reaches a module through `register(options)`.",
|
|
61
|
+
env: "The process environment is read outside `EnvSource` of `platform/config`, app `main.ts`, the matching `<app>.env.ts` or the test world. Read app keys through `read<App>Env(env: EnvSource)` and capability keys through `<capability>.config.ts`, then receive typed options here.",
|
|
62
|
+
package: "`{{source}}` is a second config path. Configuration is parsed once in `main.ts` or `<app>.env.ts` by `platform/config` typed readers and reaches a module through `register(options)`.",
|
|
61
63
|
cwdPath: "A path built from `process.cwd()` and `{{segment}}` depends on where the process was started. Resolve it from a configured root.",
|
|
62
64
|
},
|
|
63
65
|
},
|
|
@@ -78,8 +80,10 @@ export const noDirectEnvRead = {
|
|
|
78
80
|
const isProcess = (node) => (node.type === "Identifier" && processNames.has(node.name) && !isLocal(node)) || isGlobalProcess(node)
|
|
79
81
|
const isEnvSourceFile = () => inConfigOwner && declaresEnvSource
|
|
80
82
|
const inWorld = isTestWorld(hfs, filename)
|
|
83
|
+
const appRole = appRoleOf(hfs, filename)?.entry
|
|
84
|
+
const inAppEntry = appRole === "main.ts" || appRole === APP_ENV_ROLE
|
|
81
85
|
const reportEnv = (node) => {
|
|
82
|
-
if (!isEnvSourceFile() && !inWorld) context.report({ node, messageId: "env" })
|
|
86
|
+
if (!isEnvSourceFile() && !inWorld && !inAppEntry) context.report({ node, messageId: "env" })
|
|
83
87
|
}
|
|
84
88
|
const reportSource = (node, source) => {
|
|
85
89
|
if (isConfigPackage(source)) context.report({ node, messageId: "package", data: { source } })
|
|
@@ -97,7 +101,7 @@ export const noDirectEnvRead = {
|
|
|
97
101
|
if (!PROCESS_MODULES.has(source)) return
|
|
98
102
|
for (const specifier of node.specifiers) {
|
|
99
103
|
if (specifier.type === "ImportSpecifier" && (specifier.imported.name ?? specifier.imported.value) === "env") reportEnv(specifier)
|
|
100
|
-
else if (specifier.type !== "ImportSpecifier") processNames.add(specifier.local.name)
|
|
104
|
+
else if (specifier.type !== "ImportSpecifier" || (specifier.imported.name ?? specifier.imported.value) === "default") processNames.add(specifier.local.name)
|
|
101
105
|
}
|
|
102
106
|
},
|
|
103
107
|
ImportExpression(node) {
|
|
@@ -166,15 +170,15 @@ const isModuleScope = (node) => {
|
|
|
166
170
|
return true
|
|
167
171
|
}
|
|
168
172
|
|
|
169
|
-
/** Config is parsed once, in `main.ts
|
|
173
|
+
/** Config is parsed once, in `main.ts` or its app's env file; other app roles do not read the environment. */
|
|
170
174
|
export const configParsedInMain = {
|
|
171
175
|
meta: {
|
|
172
176
|
type: "problem",
|
|
173
|
-
docs: { description: "
|
|
177
|
+
docs: { description: "Configuration is parsed in `main.ts` or its matching `<app>.env.ts`; other app files do not call parsers or EnvSource readers." },
|
|
174
178
|
schema: [],
|
|
175
179
|
messages: {
|
|
176
|
-
parsed: "`{{name}}` is declared by `{{file}}` and parses configuration. Configuration is parsed once in `apps/<app>/src/main.ts` and reaches a module through `register(options)`;
|
|
177
|
-
moduleScope: "`{{name}}` returns configuration options and is called when the module loads. Options are built once in `main.ts` and injected with `Inject<C>Options()`.",
|
|
180
|
+
parsed: "`{{name}}` is declared by `{{file}}` and parses configuration. Configuration is parsed once in `apps/<app>/src/main.ts` or its matching `<app>.env.ts` and reaches a module through `register(options)`; receive typed options here.",
|
|
181
|
+
moduleScope: "`{{name}}` returns configuration options and is called when the module loads. Options are built once in `main.ts` or its matching `<app>.env.ts` and injected with `Inject<C>Options()`.",
|
|
178
182
|
},
|
|
179
183
|
},
|
|
180
184
|
create(context) {
|
|
@@ -182,11 +186,13 @@ export const configParsedInMain = {
|
|
|
182
186
|
if (isDeclarationFile(filename)) return {}
|
|
183
187
|
const hfs = hfsOf(context)
|
|
184
188
|
const slot = hfs.slotOf(filename) ?? ""
|
|
185
|
-
//
|
|
186
|
-
// and
|
|
187
|
-
const
|
|
189
|
+
// The app entry and its matching env file parse options; options files and composition groups only consume them.
|
|
190
|
+
// The test world and provider contract specs parse the declared sandbox keys to register their modules.
|
|
191
|
+
const inApp = slot.startsWith("be.app.")
|
|
192
|
+
const role = appRoleOf(hfs, filename)?.entry
|
|
193
|
+
const isEntry = role === "main.ts" || role === APP_ENV_ROLE
|
|
188
194
|
const isComposingTest = isTestWorld(hfs, filename) || slot === "be.tests.contract"
|
|
189
|
-
const isOwnSpec = baseOf(filename).endsWith(CONFIG_SPEC_SUFFIX)
|
|
195
|
+
const isOwnSpec = !inApp && baseOf(filename).endsWith(CONFIG_SPEC_SUFFIX)
|
|
190
196
|
if (isEntry || isComposingTest || isOwnSpec) return {}
|
|
191
197
|
const { checker, toTs } = typed(context)
|
|
192
198
|
return {
|
|
@@ -194,11 +200,20 @@ export const configParsedInMain = {
|
|
|
194
200
|
const tsCall = toTs(node)
|
|
195
201
|
if (!tsCall) return
|
|
196
202
|
const signature = checker.getResolvedSignature(tsCall)
|
|
197
|
-
const
|
|
203
|
+
const resolvedDeclaration = signature?.declaration
|
|
204
|
+
if (inApp && resolvedDeclaration && ts.isMethodDeclaration(resolvedDeclaration)
|
|
205
|
+
&& ts.isClassDeclaration(resolvedDeclaration.parent) && resolvedDeclaration.parent.name?.text === "EnvSource") {
|
|
206
|
+
const declaredIn = normalizePath(resolvedDeclaration.getSourceFile().fileName)
|
|
207
|
+
if (isOwnedBy(hfs, declaredIn, "platform", "config")) {
|
|
208
|
+
context.report({ node, messageId: "parsed", data: { name: context.sourceCode.getText(node.callee), file: baseOf(declaredIn) } })
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const declaration = functionDeclarationOf(resolvedDeclaration)
|
|
198
213
|
if (!declaration) return
|
|
199
214
|
const declaredIn = normalizePath(declaration.getSourceFile().fileName)
|
|
200
215
|
const name = context.sourceCode.getText(node.callee)
|
|
201
|
-
if (isConfigFile(hfs, declaredIn)) {
|
|
216
|
+
if (isConfigFile(hfs, declaredIn) || appRoleOf(hfs, declaredIn)?.entry === APP_ENV_ROLE) {
|
|
202
217
|
if (declaredIn !== filename) context.report({ node, messageId: "parsed", data: { name, file: baseOf(declaredIn) } })
|
|
203
218
|
return
|
|
204
219
|
}
|
package/feature-thin.mjs
CHANGED
|
@@ -14,15 +14,19 @@
|
|
|
14
14
|
* the file-name suffix the closed role vocabulary defines - never a path regex. What a receiver is comes
|
|
15
15
|
* from its TYPE (`Logger` of `platform/logging`, `WebhookSignatureService` of `platform/http-security`) and
|
|
16
16
|
* from whether the member is injected (`injectedMembers`); what a free function is comes from the file its
|
|
17
|
-
* import resolves to. A job `step` records its fence through `JobClaims` (`advance`, `runKey`) and a webhook proves its signature through `verify
|
|
18
|
-
*
|
|
17
|
+
* import resolves to. A job `step` records its fence through `JobClaims` (`advance`, `runKey`), and a webhook proves its signature through the shared `verify`
|
|
18
|
+
* or a declared domain verifier as its first call: a separate gate call is free beside the one intake delegation.
|
|
19
|
+
* A combined domain proof and intake counts as the delegation. `.spec.ts` and `.d.ts` are never judged, and neither is a file whose tier is not
|
|
19
20
|
* `feature` or whose role is not a thin role. A clean thin file is never flagged: that is the bar.
|
|
20
21
|
*/
|
|
21
22
|
import { keyName, walk } from "./lib/ast.mjs"
|
|
22
|
-
import { aliasTarget, injectedMembers } from "./lib/doors.mjs"
|
|
23
|
+
import { aliasTarget, callsOf, decoratorsFrom, injectedMembers } from "./lib/doors.mjs"
|
|
23
24
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
24
25
|
import { importOf } from "./lib/import-source.mjs"
|
|
25
26
|
import { baseName, isLoggerType, isOwnedType } from "./lib/ports.mjs"
|
|
27
|
+
import { firstWebhookVerification, isWebhookDomainServiceType } from "./lib/webhook-verifiers.mjs"
|
|
28
|
+
import { isWebhookForbiddenReceiver } from "./lib/webhook-handoff.mjs"
|
|
29
|
+
import { isProviderAcknowledgement } from "./lib/webhook-ack.mjs"
|
|
26
30
|
|
|
27
31
|
/** The statements and expressions that are a decision, a repetition or an exit: none lives in a thin file. */
|
|
28
32
|
const BRANCH_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement", "LogicalExpression"])
|
|
@@ -65,6 +69,27 @@ const isClaimsReceiver = (context, node) => isOwnedType(context, node, { name: "
|
|
|
65
69
|
/** The `Logger` port of `platform/logging` - the one call that is always free. */
|
|
66
70
|
const isLoggerReceiver = isLoggerType
|
|
67
71
|
|
|
72
|
+
/** A direct, non-computed member call; other callee shapes keep their ordinary call classification. */
|
|
73
|
+
const memberOf = (callee) => callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
|
|
74
|
+
|
|
75
|
+
/** Nest-commander's group help is transport bookkeeping in a CLI door. */
|
|
76
|
+
const isCliHelp = (role, member) => role === "cli" && member?.property.name === "help" && member.object.type === "MemberExpression"
|
|
77
|
+
&& member.object.object.type === "ThisExpression" && member.object.property.type === "Identifier" && member.object.property.name === "command"
|
|
78
|
+
|
|
79
|
+
/** A signed number inside the validated immutable provider literal is data, rather than door computation. */
|
|
80
|
+
const isLiteralAcknowledgementInitializer = (context, hfs, node) => {
|
|
81
|
+
let child = node
|
|
82
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
83
|
+
if (parent.type === "VariableDeclarator") return parent.init === child && isProviderAcknowledgement(context, hfs, parent.id)
|
|
84
|
+
child = parent
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A signed numeric literal in a TypeScript type annotation executes no expression. */
|
|
90
|
+
const isSignedNumericLiteralType = (node) => node.type === "UnaryExpression" && node.parent?.type === "TSLiteralType"
|
|
91
|
+
&& ["+", "-"].includes(node.operator) && node.argument.type === "Literal" && typeof node.argument.value === "number"
|
|
92
|
+
|
|
68
93
|
/** The name of a thin file's law is `feature-thin` (R203). */
|
|
69
94
|
export const featureThin = {
|
|
70
95
|
meta: {
|
|
@@ -74,7 +99,7 @@ export const featureThin = {
|
|
|
74
99
|
messages: {
|
|
75
100
|
branch: "{{what}} is a decision or a repetition in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and makes one delegating call into the modules; a branch, a loop, a `try` or a `throw` is business logic that belongs in a service of the modules (where a unit spec covers it) - and orchestration (step order, compensation, claim, fencing, retry) belongs to the platform.",
|
|
76
101
|
compute: "{{what}} computes a value in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and hands them over; an expression that computes is logic that belongs in the modules. Map the value there and read it back.",
|
|
77
|
-
call: "`{{what}}` is a call a thin file does not make. It calls exactly one injected member (a service, a bus `execute`, an event bus, a queue producer) or one function of the modules, and pure `*.mapper.ts` functions and the `Logger` port are free{{webhook}};
|
|
102
|
+
call: "`{{what}}` is a call a thin file does not make. It calls exactly one injected member (a service, a bus `execute`, an event bus, a queue producer) or one function of the modules, and pure `*.mapper.ts` functions and the `Logger` port are free{{webhook}}; a webhook hands off through domain intake and the inbox/outbox, never a command/query bus or a feature handler. Everything else - parsing, mapping by hand, a helper, a second dependency - is logic that belongs in the modules.",
|
|
78
103
|
many: "`{{name}}` makes a second delegating call here. A thin file makes exactly one; a second is orchestration - step order, compensation, claim, fencing, retry - and belongs to a capability of the platform or inside the service the file calls.",
|
|
79
104
|
},
|
|
80
105
|
},
|
|
@@ -87,6 +112,7 @@ export const featureThin = {
|
|
|
87
112
|
if (hfs.tierOf(context.filename) !== "feature") return {}
|
|
88
113
|
const sourceCode = context.sourceCode
|
|
89
114
|
const mapperFile = role === "mapper"
|
|
115
|
+
const webhookDoor = role === "webhook" && hfs.slotOf(context.filename) === "be.feature.webhooks.http"
|
|
90
116
|
|
|
91
117
|
/** The injected member names of a class: constructor parameter properties and properties carrying an injector decorator. */
|
|
92
118
|
const memberNames = new Map()
|
|
@@ -154,20 +180,28 @@ export const featureThin = {
|
|
|
154
180
|
* `free` (a mapper, a logger method, a webhook `verify`, a job step's `JobClaims` fence call, a modules function in a mapping position)
|
|
155
181
|
* or `call` (a call a thin file does not make). A `mapper` file holds only mapper calls and `new`.
|
|
156
182
|
*/
|
|
183
|
+
const classifyMapperOrCli = (member, call) => {
|
|
184
|
+
if (mapperFile && member !== null && MAPPER_SHAPE_METHODS.has(member.property.name) && injectedMemberOf(member.object, call) === null) return "free"
|
|
185
|
+
if (isCliHelp(role, member)) return "free"
|
|
186
|
+
return mapperFile ? "call" : null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The free bookkeeping calls beside an injected member's one delegate. */
|
|
190
|
+
const classifyInjected = (member) => {
|
|
191
|
+
if (webhookDoor && isWebhookForbiddenReceiver(context, hfs, member.object)) return "call"
|
|
192
|
+
if (isLoggerReceiver(context, member.object)) return "free"
|
|
193
|
+
if (role === "webhook" && member.property.name === "verify" && isSignatureReceiver(context, member.object)) return "free"
|
|
194
|
+
if (role === "step" && isClaimsReceiver(context, member.object)) return "free"
|
|
195
|
+
return "delegating"
|
|
196
|
+
}
|
|
197
|
+
|
|
157
198
|
const classify = (call) => {
|
|
158
199
|
const callee = call.callee
|
|
159
200
|
if (callee.type === "Identifier" && isMapperCall(callee)) return "free"
|
|
160
|
-
const member = callee
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
if (mapperFile) return "call"
|
|
165
|
-
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && injectedMemberOf(callee.object, call) !== null) {
|
|
166
|
-
if (isLoggerReceiver(context, callee.object)) return "free"
|
|
167
|
-
if (role === "webhook" && callee.property.name === "verify" && isSignatureReceiver(context, callee.object)) return "free"
|
|
168
|
-
if (role === "step" && isClaimsReceiver(context, callee.object)) return "free"
|
|
169
|
-
return "delegating"
|
|
170
|
-
}
|
|
201
|
+
const member = memberOf(callee)
|
|
202
|
+
const special = classifyMapperOrCli(member, call)
|
|
203
|
+
if (special !== null) return special
|
|
204
|
+
if (member !== null && injectedMemberOf(member.object, call) !== null) return classifyInjected(member)
|
|
171
205
|
if (callee.type === "Identifier" && isModulesCall(callee)) return isStatementCall(call) ? "delegating" : "free"
|
|
172
206
|
return "call"
|
|
173
207
|
}
|
|
@@ -201,7 +235,11 @@ export const featureThin = {
|
|
|
201
235
|
visitors[type] = (node) => context.report({ node, messageId: "branch", data: { what: type === "LogicalExpression" ? `a \`${node.operator}\`` : BRANCH_WHAT[type], role } })
|
|
202
236
|
}
|
|
203
237
|
for (const type of COMPUTE_NODES) {
|
|
204
|
-
visitors[type] = (node) =>
|
|
238
|
+
visitors[type] = (node) => {
|
|
239
|
+
if (isSignedNumericLiteralType(node)) return
|
|
240
|
+
if (webhookDoor && isLiteralAcknowledgementInitializer(context, hfs, node)) return
|
|
241
|
+
context.report({ node, messageId: "compute", data: { what: `an \`${node.operator}\``, role } })
|
|
242
|
+
}
|
|
205
243
|
}
|
|
206
244
|
visitors.TemplateLiteral = (node) => {
|
|
207
245
|
if (node.expressions.length > 0) context.report({ node, messageId: "compute", data: { what: "a template literal with an expression", role } })
|
|
@@ -210,10 +248,14 @@ export const featureThin = {
|
|
|
210
248
|
for (const root of rootFunctionsOf(program)) {
|
|
211
249
|
const body = root.type === "MethodDefinition" ? root.value?.body : root.body
|
|
212
250
|
if (!body) continue
|
|
251
|
+
const proof = role === "webhook" && hfs.slotOf(context.filename) === "be.feature.webhooks.http" && root.type === "MethodDefinition" && decoratorsFrom(context, root, "@nestjs/common", "Post").length > 0
|
|
252
|
+
? firstWebhookVerification(context, hfs, root) : null
|
|
253
|
+
const separateDomainProof = proof !== null && callsOf(root).filter(({ receiver }) => receiver !== null && isWebhookDomainServiceType(context, hfs, receiver)).length > 1
|
|
213
254
|
let delegating = 0
|
|
214
255
|
walk(body, (node) => {
|
|
215
256
|
if (node.type !== "CallExpression") return
|
|
216
|
-
const
|
|
257
|
+
const classified = classify(node)
|
|
258
|
+
const kind = separateDomainProof && node === proof && classified === "delegating" ? "free" : classified
|
|
217
259
|
if (kind === "call") context.report({ node, messageId: "call", data: { what: sourceCode.getText(node.callee), webhook: role === "webhook" ? ", plus `verify` of the signature service" : "" } })
|
|
218
260
|
else if (kind === "delegating") {
|
|
219
261
|
delegating += 1
|
package/index.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { buildBeConfig } from "./lib/config.mjs"
|
|
20
20
|
import { pluginForEdition } from "./lib/edition.mjs"
|
|
21
21
|
import { recommended as ambientIdRecommended, rules as ambientIdRules } from "./ambient-id.mjs"
|
|
22
|
+
import { recommended as appCompositionRecommended, rules as appCompositionRules } from "./app-composition.mjs"
|
|
22
23
|
import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
|
|
23
24
|
import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
|
|
24
25
|
import { recommended as buildersRecommended, rules as buildersRules } from "./builders.mjs"
|
|
@@ -71,6 +72,7 @@ import { recommended as sonarParityRecommended, rules as sonarParityRules } from
|
|
|
71
72
|
const CONTRIBUTIONS = [
|
|
72
73
|
{ law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
|
|
73
74
|
{ law: "ambient-id", rules: ambientIdRules, recommended: ambientIdRecommended },
|
|
75
|
+
{ law: "app-composition", rules: appCompositionRules, recommended: appCompositionRecommended },
|
|
74
76
|
{ law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
|
|
75
77
|
{ law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
|
|
76
78
|
{ law: "builders", rules: buildersRules, recommended: buildersRecommended },
|