@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +46 -4
  3. package/app-composition.mjs +176 -0
  4. package/config-owner.mjs +34 -19
  5. package/feature-thin.mjs +59 -17
  6. package/index.mjs +2 -0
  7. package/lib/app-roles.mjs +48 -0
  8. package/lib/webhook-ack.mjs +87 -0
  9. package/lib/webhook-handoff.mjs +138 -0
  10. package/lib/webhook-verifiers.mjs +147 -0
  11. package/module-layering.mjs +2 -2
  12. package/module-shape.mjs +6 -5
  13. package/package.json +1 -1
  14. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  15. package/runtime/knowledge/hfs/rules.yaml +41 -13
  16. package/runtime/knowledge/hfs/slots.yaml +99 -43
  17. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  18. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  19. package/runtime/scripts/api/git/diff.mjs +5 -0
  20. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  21. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  22. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  23. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  24. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  25. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  26. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  27. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  28. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  29. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  30. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  31. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  32. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  33. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  36. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  37. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  38. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  39. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  40. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  41. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  42. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  43. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  44. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  47. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  48. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  49. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  50. 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), `@nestjs/config`, `dotenv` only in the file of `platform/config` that declares `EnvSource` (and the test world, slot `be.tests.world`, for `process.env`); a function exported by a `<capability>.config.ts` is called only from `apps/<app>/src/main.ts` and `<app>.options.ts`, its own config file or spec, the test world and a provider contract spec (`config-parsed-in-main`); 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`) |
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, starts with the unconditional `verify` call, makes exactly one domain call and returns nothing (`webhook-shape`, `webhook-verify-first`); 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
+ | **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 `verify`; orchestration (step order, compensation, claim, fencing, retry) belongs to the platform (`feature-thin`) |
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 from `<capability>.config.ts`. `process.env` in any form
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>.options.ts`), the config file itself, its own spec, the test world and a provider contract spec. The callee is found through the type
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 file that declares `EnvSource` (and the test world) reads the process environment. */
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 the `EnvSource` class of `platform/config`." },
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 the `EnvSource` of `platform/config`. Add the key to the capability's `<capability>.config.ts` (`parse<C>Config(env: EnvSource)`) and receive the value through its `<capability>.options.ts`.",
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`; nothing else calls a config parser or getter. */
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: "A function of a `<capability>.config.ts` is called only from the app entry, its own config file or spec, and the test world." },
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)`; read it here with `Inject<C>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
- // the composition roots: the app entry and its `<app>.options.ts` (which `main.ts` calls to build `AppOptions`), the test world,
186
- // and a provider contract spec (it registers one module with options parsed from the declared sandbox keys, as the world does)
187
- const isEntry = slot.startsWith("be.app.") && (baseOf(filename) === "main.ts" || baseOf(filename).endsWith(OPTIONS_SUFFIX))
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 declaration = functionDeclarationOf(signature?.declaration)
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`: those gate calls
18
- * are free, the one effect each makes is the delegating call. `.spec.ts` and `.d.ts` are never judged, and neither is a file whose tier is not
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}}; everything else - parsing, mapping by hand, a helper, a second dependency - is logic that belongs in the modules.",
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.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
161
- if (mapperFile && member !== null && MAPPER_SHAPE_METHODS.has(member.property.name) && injectedMemberOf(member.object, call) === null) return "free"
162
- // a cli group command shows its help without a sub-command: the `help` of the command nest-commander gives it
163
- if (role === "cli" && member !== null && member.property.name === "help" && member.object.type === "MemberExpression" && member.object.object.type === "ThisExpression" && member.object.property.type === "Identifier" && member.object.property.name === "command") return "free"
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) => context.report({ node, messageId: "compute", data: { what: `an \`${node.operator}\``, role } })
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 kind = classify(node)
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 },