@starci/eslint-canon-be 2.1.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +8 -7
  3. package/comments.mjs +104 -1
  4. package/config-owner.mjs +113 -13
  5. package/connections.mjs +1 -12
  6. package/cqrs.mjs +68 -0
  7. package/data-access.mjs +100 -4
  8. package/error-handling.mjs +107 -1
  9. package/index.mjs +2 -0
  10. package/infra-owner.mjs +39 -0
  11. package/input-bounds.mjs +64 -2
  12. package/lib/config.mjs +1 -1
  13. package/lib/hfs.mjs +7 -6
  14. package/lib/persistence.mjs +29 -0
  15. package/log-safety.mjs +89 -2
  16. package/package.json +1 -1
  17. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  18. package/runtime/knowledge/hfs/slots.yaml +193 -128
  19. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  20. package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
  21. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  22. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  23. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  24. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  25. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  26. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  27. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  28. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  29. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  30. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  31. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  32. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  33. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  34. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  35. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  36. package/runtime/scripts/lib/language.mjs +11 -1
  37. package/service-deps.mjs +249 -0
  38. package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
  39. package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.1 - 2026-10-01
4
+
5
+ - Changed: the bundled runtime copy of knowledge/hfs/canon-pins.yaml pins @starci/hfs 4.0.1 (the scaffold resolves a real lockfile) and this canon at 3.0.1. No rule changed.
6
+
7
+ ## 3.0.0 - 2026-10-01
8
+
9
+ - Fixed (lane SHAPE): `source-suffix` (R89 `BE_SOURCE_FORM`) accepts `src/tests/world/fakes/<provider>/server.ts`: an allows entry of the owning slot whose last segment is the literal file name is that file's role (be.tests.world allows `fakes/<provider>/server.ts`). `fakes/<provider>/<name>.server.ts` is still refused (`server` is no role suffix).
10
+ - Breaking: the app monorepo standard (hfs 4.0.0). `loadHfs(import.meta.url)` in `be/eslint.config.mjs` finds the app-root `hfs.json` (kind `app`) one level up and gives every linted file the view of the be side (paths relative to `be/`); a standalone back-end `hfs.json` (`profile: be`) is refused. The project graph (`project-graph` law) is built per side from the side folder. The config stays the managed one-liner.
11
+ - New (lane c0-orphan): `replacement-throw-carries-cause` (R108 `BE_ERROR_CAUSE_DROPPED`, law error-handling). A `throw` that escapes a `catch` or a promise `.catch` handler rethrows the caught value or carries it (as `cause` of the capability error); a `catch` with no binding throws nothing new. A value the rule cannot see through (a parameter, a call handed the caught value, a variable built from it) counts as carrying. Replaces the unrun foreign-cause script adapter of the retired code-pattern folder.
12
+ - New (lane c0-orphan): `require-public-member-jsdoc` (R109 `BE_MEMBER_DOC_MISSING`, law comments). Every public member of an exported class, interface or object type alias opens with JSDoc; private, protected and `#` members, the constructor, index signatures and a property set to a literal or a named constant are not judged, overloads share one doc, and the test tiers (`e2e`, `fixtures`) are out of scope. Replaces the unrun member-documentation script adapter of the retired code-pattern folder.
13
+ - New `service-deps` law (lane SVC, restored by lane RESTORE after merge `9958cce38` dropped it): `provider-param-token` (R85: a constructor parameter of a class carrying a `@nestjs/*` class decorator is typed by a class or carries an `Inject<Thing>()`), `no-repository-class` (R83: a class, or an exported function taking an EntityManager first, outside an application handler, a domain service, a platform persistence capability, the migrate app, `src/tests` and a migration takes, holds or returns an `EntityManager`, `Repository`, `DataSource` or `QueryRunner`) and `no-test-double-in-source` (R89: no `Mock`, `Fake` or `Stub` word in the name of a class, function or exported constant outside specs and `src/tests`).
14
+ - Changed: `named-entity-manager-only` (R83) also refuses a `DataSource`/`QueryRunner` written as a type, an awaited value or a `provide:` `useValue`/`useFactory` outside the platform database capability, the migrate app, the test world and migrations, and a constructor parameter typed by a class that exposes one.
15
+ - Changed: `no-event-bus` (R87) also refuses `new EventEmitter()`/`extends EventEmitter`, `new Subject/BehaviorSubject/ReplaySubject/AsyncSubject` and a class that stores callbacks in a Set/Array/Map property and registers a callback parameter into it.
16
+ - New (lane AUTHCHK): `auth-door-strict-rate-tier` (R42). A door opened for `PublicReason.AuthHandshake` or `PublicReason.SignedWebhook` (the reason's TYPE is the member of `PublicReason` of `domain/identity`) carries a decorator whose argument is `RateTier.Strict` of `platform/http-security`, on the method or on its class. BE-CONVENTION 1.7 made this a MUST, and nothing checked it until now.
17
+ - New (lane AUTHCHK): `http-client-only-in-integrations` (R90). A call on the `HttpClient` port of `platform/http` (judged by the receiver's type) is made only in an `integrations` owner, in `platform/http` and in the test world. A domain service that calls an identity provider's token or admin endpoint, or a mailer API, directly is refused (BE-INFRA-3).
18
+ - New (lane AUTHCHK): `no-secret-in-error` (R71). A `new X(...)` whose type derives from `Error` carries no value typed by the `Secret` brand, nor one read out of it. It also carries no text value whose own name, or whose key, says it is a credential. The name test is the one `secret-compare-timing-safe` uses, now exported as `isCredentialName`. Personal identifiers are not judged.
19
+ - Changed (lane AUTHCHK): `secret-compare-timing-safe` (R41) also refuses an equality on a value typed by the `Secret` brand of `platform/config`, or read out of one (`secret.reveal() === x`), whatever it is called. Its credential words gain `authorization`.
20
+ - Fixed (lane AUTHCHK): `config-parsed-in-main` (R43) is back. The merge `9958cce38` (merge main into lane/ut-int, round 2) resolved `config-owner.mjs` and its test to the blob from before the rule existed. That dropped the rule, the test world's `process.env` allowance and the removal of the name-based `envConfig()` check, together with its catalog enforcer, README row, failure-code text, code-patterns id and contract change `hfs-be-config-parsed-in-main`. The rule and its fixture files are restored exactly as `d5f11c337` left them.
21
+
3
22
  ## 2.1.2 - 2026-10-01
4
23
 
5
24
  - Fixed: a local export list (`export { x }`, no module specifier) crashed the backend machine behind the project-graph rules (`Cannot read properties of undefined (reading 'kind')` in every file of a repository that has one). The packed-load spec covers it.
package/README.md CHANGED
@@ -39,33 +39,34 @@ 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` and `envConfig()` only in the file of `platform/config` that declares `EnvSource`; no default of any kind for a value typed `Secret` or `Url`; secrets compare with `timingSafeEqual` (`no-direct-env-read`, `no-secret-default`, `secret-compare-timing-safe`) |
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`) |
43
43
  | **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`) |
44
44
  | **Module shape** | No `@Global()`; `isGlobal: true` only in an app's `app.module.ts`; no import of another owner's module; typed `ConfigurableModuleBuilder`; `static register` is the only factory; a capability's representative module extends the `ConfigurableModuleClass` of its own module definition and every sub-module or feature module is plain (`capability-module-shape`); one module per file; no `new` of a provider; no module-level `let` (`no-global-decorator`, `is-global-only-in-app`, `no-cross-owner-module-import`, `static-module-register`) |
45
45
  | **Suppression** | No `eslint-disable`, `eslint-env`, `/* global */`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `istanbul`, `c8` or `v8 ignore`, `NOSONAR`, `sonar-disable`, `prettier-ignore` or `vn-ok` comment, anywhere (`no-inline-suppression`) |
46
46
  | **Size** | A source file, a spec included, over the manifest's line budget is not new and does not grow past its size at the parent commit; the rule takes no option (`file-size-growth`) |
47
47
  | **Query safety** | `.query(...)` takes only a `SqlText` built with the `sql` tag in a persistence `<name>.sql.ts`, and no query builder exists (`sql-text-only`, `no-query-builder`); the tag's only substitution is a `SqlIdent` and a `.query` string is never built by template or `+` (`no-interpolated-sql`); `find`, `findBy` and `findAndCount` on an `EntityManager` state `take` and no `EntityManager` read runs per loop element (`query-needs-limit`, `no-query-in-loop`) |
48
48
  | **Resilience** | An outbound HTTP call states a timeout or signal; `JSON.parse` of outside text sits in a `try`; a loop that catches and waits goes through `platform/retry` - the wait is `setTimeout`, `node:timers/promises` or a function outside `platform/retry` whose body waits, whatever it is called (`http-needs-timeout`, `json-parse-needs-guard`, `no-hand-rolled-retry`) |
49
- | **Log safety** | A call on the `Logger` port (found by the receiver's type) passes no `Secret` or `Pii` value and no value named like a credential or a personal identifier (`no-secret-in-log`) |
49
+ | **Log safety** | A call on the `Logger` port (found by the receiver's type) passes no `Secret` or `Pii` value and no value named like a credential or a personal identifier (`no-secret-in-log`); a constructed error (its type derives from `Error`) carries no `Secret` value and no text value named like a credential (`no-secret-in-error`) |
50
50
  | **Async and types** | An `async` function awaits; handlers and public methods declare a return type; no `Function` type, no `eval`; an inline object type takes a name; no `const enum`. Casts, `x!` and `any` are refused in every file, specs included, by the typescript-eslint rules the factory turns on (`async-needs-await`, `explicit-handler-return-type`, `no-function-or-eval`, `no-inline-param-type`, `no-inline-object-type`, `no-const-enum`) |
51
- | **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has the `class-validator` decorators its type calls for (string `@MaxLength`, enum `@IsEnum`, array `@ArrayMaxSize`, nested `@ValidateNested()` and `@Type`); reads page by cursor, never by `skip`, `offset` or `OFFSET` (`migration-down-reversible`, `input-bounded`, `no-offset-pagination`) |
51
+ | **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has the `class-validator` decorators its type calls for (string `@MaxLength`, enum `@IsEnum`, array `@ArrayMaxSize`, nested `@ValidateNested()` and `@Type`); reads page by cursor, never by `skip`, `offset` or `OFFSET`; a door opened for `PublicReason.AuthHandshake` or `PublicReason.SignedWebhook` carries `RateTier.Strict` of `platform/http-security` on the method or its class (`migration-down-reversible`, `input-bounded`, `no-offset-pagination`, `auth-door-strict-rate-tier`) |
52
52
  | **Spec quality** | A spec does not read repository source with `fs` (`spec-no-source-read`); the borrowed `consistent-type-assertions` (`never`) and `no-non-null-assertion` leave no `as` and no `x!` in a spec, and `no-ambient-clock` and `no-direct-env-read` cover specs, so no `Date.now()`, argless `new Date()` or `process.env`. A unit spec is `<name>.service.spec.ts`: it builds its subject with `Test.createTestingModule({ providers })` from `@nestjs/testing`, `.compile()` and `moduleRef.get(...)` (`spec-builds-with-testing-module`), never `new` on the service (`spec-no-new-subject`), no `imports` key and no `override*` (`spec-module-definition-only-providers`), no `jest.mock`/`doMock`/`unstable_mockModule`/`requireMock` of an own module or a library (`spec-no-module-mock`), no cast-laundering helper in a spec or fixture: a return-only generic, an `unknown` handed back as another type, `JSON.parse(JSON.stringify(x))`, `Object.assign(new X(), y)` returned as another type (`no-return-only-generic`); a provider `{ provide: TOKEN, useValue: X }` takes X from the matching `@starci/jest-preset` double, the token table being `ruleParams.be.specDoubles` of the slot manifest (`spec-infra-double-from-kit`); a unit spec asserts exact values, never `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)` (`expect` being the jest global or the `@jest/globals` import): ids come from `fakeIds()` and dates from `FakeClock` (`spec-exact-values`); the EntityManager double is the kit's `mockEntityManager()`/`fakeTransaction()`, never ad hoc (`spec-typed-entity-manager`); test data goes through builders, see the **Test data builders** row |
53
53
  | **Test data builders** (R98 to R101) | Builders live only in `src/tests/fixtures/builders/<area>.builder.ts`: a module of the test tree that exports a function creating persisted rows through an EntityManager, DataSource or QueryRunner is a builder wherever it sits (`persisting-builder-in-builders-slot`, R98); a spec never runs a raw INSERT/UPDATE/DELETE or a direct write (`save`, `insert`, `upsert`, `update`, `delete`, `remove`) on a typeorm receiver and never builds the same `@Entity` class as an object literal twice (`spec-no-raw-insert`, `spec-no-repeated-row-literal`, R99); a builder never asserts and has deterministic defaults (`builder-arranges-only`, R101); nothing under `src/tests` disables or drops a constraint (`tests-keep-constraints`, R100) |
54
- | **CQRS** | A message extends `Command<R>` / `Query<R>` from `@nestjs/cqrs`, carries one `readonly params: ExecuteParams<X>` (no entity reachable from `X`), a handler extends `ICQRSHandler` and overrides `process`; a handler is thin: it injects only `*Service` classes and the Logger, and `process` is one `return this.<service>.<method>(...)` with no branch, loop, EntityManager or second call (`handler-is-thin`); no `*.use-case.ts`, no forwarder service, no `EventBus` or `@nestjs/event-emitter` (`handler-overrides-process`, `message-carries-params-only`, `message-typed-result`, `execute-params-shape`, `no-use-case`, `no-event-bus`) |
54
+ | **CQRS** | A message extends `Command<R>` / `Query<R>` from `@nestjs/cqrs`, carries one `readonly params: ExecuteParams<X>` (no entity reachable from `X`), a handler extends `ICQRSHandler` and overrides `process`; a handler is thin: it injects only `*Service` classes and the Logger, and `process` is one `return this.<service>.<method>(...)` with no branch, loop, EntityManager or second call (`handler-is-thin`); no `*.use-case.ts`, no forwarder service, no `EventBus`, `@nestjs/event-emitter`, `EventEmitter` or RxJS `Subject` constructed or extended, and no class that stores callbacks other code registers (`handler-overrides-process`, `message-carries-params-only`, `message-typed-result`, `execute-params-shape`, `no-use-case`, `no-event-bus`) |
55
55
  | **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/plan`), never through a path into it, and a same-owner import is relative and never through the own index (`import-owner-entry`); an owner has one `index.ts` (a nested one is refused) that holds only named `export { }` lines, with no `export *`, and a bounded number of names (`no-folder-reexport`); self-aliases and relative escapes are refused (`no-self-module-alias`, `no-relative-capability-escape`). Owners come from the HFS slots, aliases from the program's `compilerOptions.paths`. |
56
- | **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 migrate app, `getRepository` and repository types are refused, specs included (`must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`); 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`) |
56
+ | **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 migrate app, `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`) |
57
57
  | **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 migrate app and the test database fixture; an exported EntityManager injector is exactly the declared connection's; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and the migrate app; 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`) |
58
58
  | **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`) |
59
59
  | **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`) |
60
- | **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 (`infra-import-owner`) |
60
+ | **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`) |
61
61
  | **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`) |
62
62
  | **Testing** | Only services are unit-tested: every `<name>.service.ts` has its `<name>.service.spec.ts` beside it, a service spec has its service beside it, and any other `*.spec.ts` outside `src/tests/{e2e,integration,contract,world}` and `apps/migrate` (handler, resolver, controller, consumer, mapper, entity, guard, module, policy, helper, 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`) |
63
63
  | **End-to-end flows** | One file, one flow, named steps; the flow enters through transport (a `CommandBus`, `QueryBus` or a handler, consumer or job is never called), boots through `src/tests/e2e/setup` (no `Test.createTestingModule`), **never sleeps** — it polls with `waitFor` until the state settles, with a deadline — and takes no branch inside a step (`e2e-uses-production-transport`, `no-wiring-in-flow-spec`, `no-sleep-in-flow`, `no-branch-in-flow-step`) |
64
64
  | **CDC · event delivery** | Projections and events follow the declared delivery contract |
65
+ | **Service dependencies · source form** | Production source declares no `Mock*`, `Fake*` or `Stub*` class, function or constant: the marker is a whole PascalCase or camelCase word of the identifier, a double lives in a spec or `src/tests`, and an offline driver is an options-driven adapter named for what it does (`no-test-double-in-source`); the provider, repository-class and connection rules of this law are listed under Dependency injection and Data access |
65
66
  | **Comments · naming · type safety** | Comments say why; no double cast through `unknown`; named exports only, the Jest `*.global-setup.ts` being the one default export; Vietnamese only in a message catalog or the `src/tests/fixtures/i18n/` fixtures slot (`be.tests.fixtures.i18n`), never in an identifier, string, comment or test title, specs included, in NFC or NFD spelling (`no-default-export`, `no-non-ascii-source`) |
66
67
  | **User copy** | A literal message of an error class (judged by the constructed type extending `Error`), a text property handed to an outbound port (a receiver typed from `integrations` or `platform/messaging`) or a response copy in a transport slot comes from the messages catalog through the typed `MessageCatalog` port, specs included (`user-copy-through-catalog`) |
67
68
  | **Temporal** | `Date.now`, a bare `new Date()`, `performance.now`, `process.hrtime` and `Temporal.Now` are referenced (not only called) only inside `platform/clock`, specs included (`no-ambient-clock`) |
68
- | **Dependency injection** | Raw `Inject(`, `InjectEntityManager(`, `InjectDataSource(`, `InjectQueue(`, `InjectRepository(` and any third-party `Inject*` are called only in a `<owner>.decorators.ts` at an owner root, and injectors decorate constructor parameters only; an injector is zero-argument, typed `TypedParameterDecorator<T>`, built with `injector<T>(token)`, documented, over a `unique symbol` token; `T` equals the parameter type; a parameter typed by a platform, integrations or package declaration carries an injector (only domain services and the same owner are class-injected); no `ModuleRef`, no `forwardRef`, no string token (`injector-only`, `injector-shape`, `injector-type-match`, `infra-needs-injector`, `no-module-ref`, `no-forward-ref`, `no-string-token`) |
69
+ | **Dependency injection** | Raw `Inject(`, `InjectEntityManager(`, `InjectDataSource(`, `InjectQueue(`, `InjectRepository(` and any third-party `Inject*` are called only in a `<owner>.decorators.ts` at an owner root, and injectors decorate constructor parameters only; an injector is zero-argument, typed `TypedParameterDecorator<T>`, built with `injector<T>(token)`, documented, over a `unique symbol` token; `T` equals the parameter type; a parameter typed by a platform, integrations or package declaration carries an injector (only domain services and the same owner are class-injected); no `ModuleRef`, no `forwardRef`, no string token; a constructor parameter of a Nest provider is typed by a class or carries an `Inject<Thing>()`, never a bare primitive, `Map`, union or interface (`injector-only`, `injector-shape`, `injector-type-match`, `infra-needs-injector`, `provider-param-token`, `no-module-ref`, `no-forward-ref`, `no-string-token`) |
69
70
  | **Idempotency** | The first awaited expression of a public service method whose first parameter has an `eventId` is `claim(source, eventId)` on a receiver typed as the `Inbox` port, followed by an early return on `false`; doors are thin and claim nothing (`inbox-dedupe-required`) |
70
71
 
71
72
  Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
package/comments.mjs CHANGED
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * The rules that hold `comments.md`.
3
3
  *
4
- * Five rules, and each is careful about a different false positive:
4
+ * Six rules, and each is careful about a different false positive:
5
5
  *
6
6
  * - `require-export-jsdoc` skips plain data constants. `export const MAX_ATTEMPTS = 3` is already
7
7
  * fully described by its own name, and demanding a sentence there produces sentences that
8
8
  * restate the name - which COMMENT-3 forbids. Only declarations with a surface get the check.
9
+ * - `require-public-member-jsdoc` (R109) judges only what a caller reaches: a private, protected or `#` member, the
10
+ * constructor and an index signature are not surface, overload signatures of one name share one doc, and a property
11
+ * set to a literal or a named constant is a data constant (COMMENT-1's reason). Product source only: the test tiers
12
+ * (`e2e`, `fixtures` of the slot manifest) document their spec-read shapes at the type.
9
13
  * - `require-enum-member-jsdoc` can check that a doc EXISTS and never that it states a
10
14
  * consequence. That half is read by a person, and the rule says so rather than pretending.
11
15
  * - `no-non-ascii-source` takes no exemption marker: HFS removed `vn-ok`, so text a program depends on
@@ -20,6 +24,7 @@
20
24
  */
21
25
 
22
26
  import { hfsOf } from "./lib/hfs.mjs"
27
+ import { normalizePath } from "./lib/path.mjs"
23
28
  import { hasSecondLanguage } from "./runtime/scripts/lib/language.mjs"
24
29
 
25
30
  /** The slots of the per-owner message catalogs: the only source files that may hold Vietnamese. */
@@ -136,6 +141,102 @@ export const requireExportJsdoc = {
136
141
  },
137
142
  }
138
143
 
144
+ // -- COMMENT-3 (R109) -----------------------------------------------------------------------------
145
+
146
+ /** Class members a caller reaches: everything but the constructor, static blocks and index signatures. */
147
+ const CLASS_MEMBER_KINDS = new Set([
148
+ "MethodDefinition",
149
+ "PropertyDefinition",
150
+ "AccessorProperty",
151
+ "TSAbstractMethodDefinition",
152
+ "TSAbstractPropertyDefinition",
153
+ "TSAbstractAccessorProperty",
154
+ ])
155
+
156
+ /** Interface and type-literal members a caller reaches; call, construct and index signatures have no name to document. */
157
+ const SIGNATURE_MEMBER_KINDS = new Set(["TSPropertySignature", "TSMethodSignature"])
158
+
159
+ /**
160
+ * A property set to a literal or a named constant (`name = "CreatePlans1758"`, `readonly queue = PLAN_QUEUE`) is already
161
+ * described by its own name and value, the same reason COMMENT-1 leaves a data constant alone.
162
+ */
163
+ const isDataProperty = (member) => {
164
+ if (member.type !== "PropertyDefinition" || !member.value) return false
165
+ const value = member.value
166
+ if (value.type === "Literal" || value.type === "Identifier") return true
167
+ if (value.type === "TemplateLiteral") return value.expressions.length === 0
168
+ return value.type === "MemberExpression" && !value.computed && value.object.type === "Identifier"
169
+ }
170
+
171
+ /** The test tiers of the slot manifest: spec-read shapes there are documented at the type, not field by field. */
172
+ const TEST_TIERS = new Set(["e2e", "fixtures"])
173
+
174
+ /** The members of an exported class, interface or object type alias that are its public surface. */
175
+ const publicMembersOf = (declaration) => {
176
+ if (declaration.type === "ClassDeclaration") {
177
+ return declaration.body.body.filter((member) =>
178
+ CLASS_MEMBER_KINDS.has(member.type)
179
+ && member.kind !== "constructor"
180
+ && !isDataProperty(member)
181
+ && member.key?.type !== "PrivateIdentifier"
182
+ && member.accessibility !== "private"
183
+ && member.accessibility !== "protected")
184
+ }
185
+ if (declaration.type === "TSInterfaceDeclaration") return declaration.body.body.filter((member) => SIGNATURE_MEMBER_KINDS.has(member.type))
186
+ if (declaration.type === "TSTypeAliasDeclaration") {
187
+ const type = declaration.typeAnnotation
188
+ const literals = type.type === "TSTypeLiteral" ? [type]
189
+ : type.type === "TSIntersectionType" ? type.types.filter((part) => part.type === "TSTypeLiteral") : []
190
+ return literals.flatMap((literal) => literal.members.filter((member) => SIGNATURE_MEMBER_KINDS.has(member.type)))
191
+ }
192
+ return []
193
+ }
194
+
195
+ /** The name a member is reached by, with its static side kept apart; null for a computed key the rule cannot name. */
196
+ const memberKey = (member) => {
197
+ if (member.computed) return null
198
+ const key = member.key
199
+ const name = key?.type === "Identifier" ? key.name : key?.type === "Literal" ? String(key.value) : null
200
+ return name === null ? null : `${member.static ? "static " : ""}${name}`
201
+ }
202
+
203
+ /** Every public member of an exported class, interface or object type opens with a doc block (BE-COMMENT-3). */
204
+ export const requirePublicMemberJsdoc = {
205
+ meta: {
206
+ type: "suggestion",
207
+ docs: { description: "Every public member of an exported class, interface or object type alias opens with JSDoc." },
208
+ schema: [],
209
+ messages: {
210
+ jsdoc:
211
+ "`{{owner}}.{{name}}` is public surface with no doc block. A caller reaches the member, not the type's doc: say what it is for, what calling or reading it causes, or what it holds.",
212
+ },
213
+ },
214
+ create(context) {
215
+ const sourceCode = context.sourceCode || context.getSourceCode()
216
+ if (TEST_TIERS.has(hfsOf(context).tierOf(normalizePath(context.filename)))) return {}
217
+ const check = (node) => {
218
+ const declaration = node.declaration
219
+ if (!declaration || !declaration.id) return
220
+ // overload signatures share one name; a doc on any of them documents the member
221
+ const groups = new Map()
222
+ for (const member of publicMembersOf(declaration)) {
223
+ const key = memberKey(member)
224
+ if (key === null) continue
225
+ if (!groups.has(key)) groups.set(key, [])
226
+ groups.get(key).push(member)
227
+ }
228
+ for (const [key, members] of groups) {
229
+ if (members.some((member) => hasJsdocBefore(sourceCode, member))) continue
230
+ context.report({ node: members[0].key, messageId: "jsdoc", data: { owner: declaration.id.name, name: key.replace(/^static /, "") } })
231
+ }
232
+ }
233
+ return {
234
+ ExportNamedDeclaration: check,
235
+ ExportDefaultDeclaration: check,
236
+ }
237
+ },
238
+ }
239
+
139
240
  // -- COMMENT-2 -------------------------------------------------------------------------------------
140
241
 
141
242
  /** Every member of an exported enum carries its own doc. */
@@ -305,6 +406,7 @@ export const noRestatedNameJsdoc = {
305
406
  export const rules = {
306
407
  "require-export-jsdoc": requireExportJsdoc,
307
408
  "require-enum-member-jsdoc": requireEnumMemberJsdoc,
409
+ "require-public-member-jsdoc": requirePublicMemberJsdoc,
308
410
  "no-non-ascii-source": noNonAsciiSource,
309
411
  "no-restated-name-jsdoc": noRestatedNameJsdoc,
310
412
  }
@@ -320,6 +422,7 @@ export const rules = {
320
422
  export const recommended = {
321
423
  "starci-be/require-export-jsdoc": "error",
322
424
  "starci-be/require-enum-member-jsdoc": "error",
425
+ "starci-be/require-public-member-jsdoc": "error",
323
426
  "starci-be/no-non-ascii-source": "error",
324
427
  "starci-be/no-restated-name-jsdoc": "error",
325
428
  }
package/config-owner.mjs CHANGED
@@ -5,21 +5,33 @@
5
5
  * - `no-direct-env-read` keeps the process environment inside the one file that declares the `EnvSource` class of
6
6
  * `platform/config`. Everything else receives typed options from `<capability>.config.ts`. `process.env` in any form
7
7
  * (member access, destructuring, `Reflect.get(process, "env")`, an `env` import of `node:process`), an import of
8
- * `@nestjs/config` or `dotenv`, a call of `envConfig()`, and a `process.cwd()` path joined into `src` or
9
- * `.starcistacks` are refused everywhere else.
8
+ * `@nestjs/config` or `dotenv`, and a `process.cwd()` path joined into `src` or
9
+ * `.starcistacks` are refused everywhere else. The test world (slot `be.tests.world`) may read and write `process.env`:
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
+ * definition.
12
+ * - `config-parsed-in-main` keeps the parsing of configuration in `main.ts`: a call of a function exported by a
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
15
+ * checker, so no function name is matched.
10
16
  * - `no-secret-default` refuses a default for a value typed `Secret` or `Url` by `platform/config`: a default argument
11
17
  * to an `EnvSource` reader, a `??`/`||` fallback on such a value, and a string literal default (`""`, `"localhost"`,
12
18
  * `"127.0.0.1"`, `"0.0.0.0"`, `http(s)://...`) given to any reader that returns one. The TYPE decides; no key name is
13
19
  * matched. A missing value must stop the boot, naming the key.
14
- * - `secret-compare-timing-safe` refuses `===` and `!==` on a secret-named identifier. A secret compares with
20
+ * - `secret-compare-timing-safe` refuses `===` and `!==` on a value typed by the `Secret` brand of `platform/config` (or
21
+ * read out of one, `secret.reveal()`) and on a secret-named identifier. A secret compares with
15
22
  * `timingSafeEqual`, because the length of the matching prefix is a measurable side channel.
16
23
  *
17
24
  * No test lane is exempt: a spec builds its `EnvSource` from a literal record and never touches the process environment.
18
25
  */
26
+ import ts from "typescript"
19
27
  import { keyName, staticText, walk, wordsOf } from "./lib/ast.mjs"
20
- import { isOwnedBy, originsOf } from "./lib/declared.mjs"
28
+ import { isOwnedBy, isOwnedType, originsOf } from "./lib/declared.mjs"
21
29
  import { hfsOf } from "./lib/hfs.mjs"
22
30
  import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
31
+ import { originsOfType, typed } from "./lib/types.mjs"
32
+
33
+ /** The test composition root (slot `be.tests.world`): it composes app options like `main.ts` and hands run state to jest workers through the environment. */
34
+ const isTestWorld = (hfs, file) => hfs.slotOf(file) === "be.tests.world"
23
35
 
24
36
  const PROCESS_MODULES = new Set(["process", "node:process"])
25
37
 
@@ -37,7 +49,7 @@ const isGlobalProcess = (node) =>
37
49
  /** True when the property of a member expression spells `env`. */
38
50
  const spellsEnv = (node) => (node.computed ? staticText(node.property) === "env" : keyName(node.property) === "env")
39
51
 
40
- /** Only the file that declares `EnvSource` reads the process environment. */
52
+ /** Only the file that declares `EnvSource` (and the test world) reads the process environment. */
41
53
  export const noDirectEnvRead = {
42
54
  meta: {
43
55
  type: "problem",
@@ -46,7 +58,6 @@ export const noDirectEnvRead = {
46
58
  messages: {
47
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`.",
48
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)`.",
49
- envConfig: "`envConfig()` re-reads the environment per call site. Receive the value through the capability's options, read with `Inject<C>Options()`.",
50
61
  cwdPath: "A path built from `process.cwd()` and `{{segment}}` depends on where the process was started. Resolve it from a configured root.",
51
62
  },
52
63
  },
@@ -66,8 +77,9 @@ export const noDirectEnvRead = {
66
77
  }
67
78
  const isProcess = (node) => (node.type === "Identifier" && processNames.has(node.name) && !isLocal(node)) || isGlobalProcess(node)
68
79
  const isEnvSourceFile = () => inConfigOwner && declaresEnvSource
80
+ const inWorld = isTestWorld(hfs, filename)
69
81
  const reportEnv = (node) => {
70
- if (!isEnvSourceFile()) context.report({ node, messageId: "env" })
82
+ if (!isEnvSourceFile() && !inWorld) context.report({ node, messageId: "env" })
71
83
  }
72
84
  const reportSource = (node, source) => {
73
85
  if (isConfigPackage(source)) context.report({ node, messageId: "package", data: { source } })
@@ -101,7 +113,6 @@ export const noDirectEnvRead = {
101
113
  },
102
114
  CallExpression(node) {
103
115
  const callee = node.callee
104
- if (callee.type === "Identifier" && callee.name === "envConfig") context.report({ node, messageId: "envConfig" })
105
116
  if (callee.type === "Identifier" && callee.name === "require") {
106
117
  const source = staticText(node.arguments[0])
107
118
  if (source !== null) reportSource(node, source)
@@ -127,6 +138,78 @@ export const noDirectEnvRead = {
127
138
  },
128
139
  }
129
140
 
141
+ const CONFIG_SUFFIX = ".config.ts"
142
+ const OPTIONS_SUFFIX = ".options.ts"
143
+ const CONFIG_SPEC_SUFFIX = ".config.spec.ts"
144
+
145
+ const baseOf = (file) => normalizePath(file).split("/").at(-1) ?? ""
146
+
147
+ /** A file in a slot of the repository whose role is `<c>.config.ts` (a file with the suffix that no slot claims is not one). */
148
+ const isConfigFile = (hfs, file) => baseOf(file).endsWith(CONFIG_SUFFIX) && hfs.slotOf(file) !== null
149
+
150
+ /** A file in a slot of the repository whose role is `<c>.options.ts`. */
151
+ const isOptionsFile = (hfs, file) => baseOf(file).endsWith(OPTIONS_SUFFIX) && hfs.slotOf(file) !== null
152
+
153
+ /** The declaration of a plain function (declaration, or arrow/function expression bound to a variable), else null: a method is not a getter. */
154
+ const functionDeclarationOf = (declaration) => {
155
+ if (!declaration) return null
156
+ if (ts.isFunctionDeclaration(declaration)) return declaration
157
+ if ((ts.isArrowFunction(declaration) || ts.isFunctionExpression(declaration)) && ts.isVariableDeclaration(declaration.parent)) return declaration
158
+ return null
159
+ }
160
+
161
+ /** True when the node is evaluated when the module loads: no function, method or class member body encloses it. */
162
+ const isModuleScope = (node) => {
163
+ for (let parent = node.parent; parent; parent = parent.parent) {
164
+ if (/Function|MethodDefinition|PropertyDefinition|StaticBlock/.test(parent.type)) return false
165
+ }
166
+ return true
167
+ }
168
+
169
+ /** Config is parsed once, in `main.ts`; nothing else calls a config parser or getter. */
170
+ export const configParsedInMain = {
171
+ meta: {
172
+ 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." },
174
+ schema: [],
175
+ 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()`.",
178
+ },
179
+ },
180
+ create(context) {
181
+ const filename = normalizePath(context.filename || context.getFilename())
182
+ if (isDeclarationFile(filename)) return {}
183
+ const hfs = hfsOf(context)
184
+ 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))
188
+ const isComposingTest = isTestWorld(hfs, filename) || slot === "be.tests.contract"
189
+ const isOwnSpec = baseOf(filename).endsWith(CONFIG_SPEC_SUFFIX)
190
+ if (isEntry || isComposingTest || isOwnSpec) return {}
191
+ const { checker, toTs } = typed(context)
192
+ return {
193
+ CallExpression(node) {
194
+ const tsCall = toTs(node)
195
+ if (!tsCall) return
196
+ const signature = checker.getResolvedSignature(tsCall)
197
+ const declaration = functionDeclarationOf(signature?.declaration)
198
+ if (!declaration) return
199
+ const declaredIn = normalizePath(declaration.getSourceFile().fileName)
200
+ const name = context.sourceCode.getText(node.callee)
201
+ if (isConfigFile(hfs, declaredIn)) {
202
+ if (declaredIn !== filename) context.report({ node, messageId: "parsed", data: { name, file: baseOf(declaredIn) } })
203
+ return
204
+ }
205
+ if (!isModuleScope(node)) return
206
+ const returned = originsOfType(checker, checker.getReturnTypeOfSignature(signature))
207
+ if (returned.some((origin) => isOptionsFile(hfs, origin.file))) context.report({ node, messageId: "moduleScope", data: { name } })
208
+ },
209
+ }
210
+ },
211
+ }
212
+
130
213
  /** The brand classes of `platform/config` a default must never be given to. */
131
214
  const BRANDS = new Set(["Secret", "Url"])
132
215
  const SECRET_READERS = new Set(["secret", "url", "host"])
@@ -181,7 +264,7 @@ export const noSecretDefault = {
181
264
  },
182
265
  }
183
266
 
184
- const SECRET_COMPARE_WORDS = new Set(["secret", "password", "passwd", "passphrase", "token", "signature", "hmac", "credential", "credentials", "apikey"])
267
+ const SECRET_COMPARE_WORDS = new Set(["secret", "password", "passwd", "passphrase", "token", "signature", "hmac", "credential", "credentials", "apikey", "authorization"])
185
268
  const KEY_PREFIXES = new Set(["api", "secret", "private", "access", "signing", "encryption", "master", "auth", "jwt", "session"])
186
269
  const NOT_A_VALUE_WORDS = new Set(["type", "kind", "length", "count", "ttl", "name", "prefix", "header", "scheme", "field", "path", "regex", "pattern"])
187
270
 
@@ -195,7 +278,12 @@ const operandName = (node) => {
195
278
  /** Words before the last one that make a token a counter or a cursor, not a credential. */
196
279
  const NOT_A_CREDENTIAL_QUALIFIERS = new Set(["fencing", "idempotency", "cursor", "page", "next", "continuation", "pagination", "sync", "csrf"])
197
280
 
198
- const isSecretName = (name) => {
281
+ /**
282
+ * Whether a value's own name says it IS a credential: its last word is one (`password`, `accessToken`, `clientSecret`,
283
+ * `signingKey`), unless the name is an UPPER_SNAKE constant naming a key or a counter or cursor qualifies it. Shared by the
284
+ * comparison rule here and the error rule of R71.
285
+ */
286
+ export const isCredentialName = (name) => {
199
287
  // an UPPER_SNAKE name is a constant naming a key, not the value it names
200
288
  if (/^[A-Z][A-Z0-9_]*$/.test(name)) return false
201
289
  const words = wordsOf(name)
@@ -210,11 +298,17 @@ const isPresenceOperand = (node) =>
210
298
  (node.type === "Identifier" && node.name === "undefined") ||
211
299
  (node.type === "UnaryExpression" && node.operator === "typeof")
212
300
 
301
+ /** A value typed by the `Secret` brand of `platform/config`, or read out of one (`secret.reveal()`, `secret.value`): the TYPE says it is a secret. */
302
+ const isSecretValue = (context, node) => {
303
+ const holder = node.type === "CallExpression" && node.callee.type === "MemberExpression" ? node.callee.object : node.type === "MemberExpression" ? node.object : null
304
+ return isOwnedType(context, node, "Secret", "platform", "config") || (holder !== null && isOwnedType(context, holder, "Secret", "platform", "config"))
305
+ }
306
+
213
307
  /** A secret is compared with `timingSafeEqual`, never with an equality operator. */
214
308
  export const secretCompareTimingSafe = {
215
309
  meta: {
216
310
  type: "problem",
217
- docs: { description: "A secret-named value is compared with `timingSafeEqual`, not `===` or `!==`." },
311
+ docs: { description: "A `Secret`-typed or secret-named value is compared with `timingSafeEqual`, not `===` or `!==`." },
218
312
  schema: [],
219
313
  messages: {
220
314
  compare: "`{{name}}` is a secret compared with `{{operator}}`. Compare secrets with `crypto.timingSafeEqual` on equal-length buffers so the comparison time does not leak the match.",
@@ -228,8 +322,12 @@ export const secretCompareTimingSafe = {
228
322
  if (!["===", "!==", "==", "!="].includes(node.operator)) return
229
323
  if (isPresenceOperand(node.left) || isPresenceOperand(node.right)) return
230
324
  for (const side of [node.left, node.right]) {
325
+ if (isSecretValue(context, side)) {
326
+ context.report({ node, messageId: "compare", data: { name: context.sourceCode.getText(side), operator: node.operator } })
327
+ return
328
+ }
231
329
  const name = operandName(side)
232
- if (name && isSecretName(name)) {
330
+ if (name && isCredentialName(name)) {
233
331
  context.report({ node, messageId: "compare", data: { name, operator: node.operator } })
234
332
  return
235
333
  }
@@ -242,13 +340,15 @@ export const secretCompareTimingSafe = {
242
340
  /** The rules this law contributes to the plugin. */
243
341
  export const rules = {
244
342
  "no-direct-env-read": noDirectEnvRead,
343
+ "config-parsed-in-main": configParsedInMain,
245
344
  "no-secret-default": noSecretDefault,
246
345
  "secret-compare-timing-safe": secretCompareTimingSafe,
247
346
  }
248
347
 
249
- /** All three start at error: a secret in source or a timing leak is never a warning. */
348
+ /** All four start at error: a secret in source or a timing leak is never a warning. */
250
349
  export const recommended = {
251
350
  "starci-be/no-direct-env-read": "error",
351
+ "starci-be/config-parsed-in-main": "error",
252
352
  "starci-be/no-secret-default": "error",
253
353
  "starci-be/secret-compare-timing-safe": "error",
254
354
  }
package/connections.mjs CHANGED
@@ -28,15 +28,13 @@ import {
28
28
  infraTypeOf,
29
29
  injectionSites,
30
30
  injectorNameOf,
31
+ mayHoldEntityManager,
31
32
  } from "./lib/persistence.mjs"
32
33
  import { isPackageType, originsOfType, typed, typeOrigins } from "./lib/types.mjs"
33
34
 
34
35
  /** The `@nestjs/typeorm` calls that build a second door to a connection. */
35
36
  const RAW_CONNECTION_CALLS = new Set(["InjectEntityManager", "InjectDataSource", "getEntityManagerToken"])
36
37
 
37
- /** The platform capabilities that own a `persistence/` and so may hold an `EntityManager`. */
38
- const PERSISTENCE_CAPABILITIES = ["database", "inbox", "outbox", "lease"]
39
-
40
38
  /** The initializers that can be an injector factory; a plain value is never typed here. */
41
39
  const FUNCTION_LIKE = new Set(["ArrowFunctionExpression", "FunctionExpression", "CallExpression"])
42
40
 
@@ -209,15 +207,6 @@ export const oneConnectionPerDatabase = {
209
207
  },
210
208
  }
211
209
 
212
- /** Whether a class in this file may receive an `EntityManager`. */
213
- const mayHoldEntityManager = (hfs, file) => {
214
- const found = hfs.classify(file)
215
- const base = baseNameOf(file)
216
- if (found.slot === "be.feature.application") return base.endsWith(".handler.ts")
217
- if (found.slot === "be.domain") return base.endsWith(".service.ts")
218
- return found.slot === "be.platform" && PERSISTENCE_CAPABILITIES.includes(found.bindings?.capability)
219
- }
220
-
221
210
  /** An EntityManager is injected in an application handler, a domain service or a platform persistence capability only. */
222
211
  export const emInjectionSlots = {
223
212
  meta: {
package/cqrs.mjs CHANGED
@@ -411,6 +411,29 @@ const EVENT_NAMES = new Set(["EventBus", "EventsHandler", "IEventHandler"])
411
411
  /** The package of the second in-process event system. */
412
412
  const EVENT_EMITTER_PACKAGE = "@nestjs/event-emitter"
413
413
 
414
+ /** The Node and RxJS classes that are an in-process event bus when constructed or extended: package -> class names. */
415
+ const BUS_CLASSES = { events: new Set(["EventEmitter"]), "node:events": new Set(["EventEmitter"]), rxjs: new Set(["Subject", "BehaviorSubject", "ReplaySubject", "AsyncSubject"]) }
416
+
417
+ /** The bus class a constructed or extended expression is declared as (by declaration origin), else null. */
418
+ const busClassOf = (context, node) => {
419
+ const origin = typeOrigins(context, node).find((entry) => entry.module !== null && BUS_CLASSES[entry.module]?.has(entry.name))
420
+ return origin ? origin.name : null
421
+ }
422
+
423
+ /** The methods that register a subscriber on a collection. */
424
+ const REGISTER_METHODS = new Set(["add", "push", "unshift", "set"])
425
+
426
+ /** True for a type that is a function: it has a call signature and no construct signature. */
427
+ const isFunctionType = (type) => type.getCallSignatures().length > 0 && type.getConstructSignatures().length === 0
428
+
429
+ /** True when a collection type (Set, Array, Map) holds functions. */
430
+ const holdsFunctions = (checker, type) => {
431
+ const symbolName = type.getSymbol()?.getName()
432
+ if (!["Set", "Array", "ReadonlyArray", "Map", "WeakSet"].includes(symbolName ?? "")) return false
433
+ const args = checker.getTypeArguments(type)
434
+ return args.length > 0 && isFunctionType(args[args.length - 1])
435
+ }
436
+
414
437
  /** No in-process events: a side effect that must happen anyway is an outbox message. */
415
438
  export const noEventBus = {
416
439
  meta: {
@@ -419,11 +442,56 @@ export const noEventBus = {
419
442
  schema: [],
420
443
  messages: {
421
444
  event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction: write an outbox message in the transaction and consume it in `transport/message/` instead.",
445
+ bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction. Write an outbox message in the transaction and consume it in `transport/message/`; a state a caller waits for is read from the database.",
446
+ registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus. Write an outbox message in the transaction and consume it in `transport/message/` instead.",
422
447
  },
423
448
  },
424
449
  create(context) {
425
450
  const report = (node, name) => context.report({ node, messageId: "event", data: { name } })
451
+ const { checker, toTs } = typed(context)
452
+ /** Collects, per class, the functions-collection properties and the registrations made on them. */
453
+ const checkClass = (classNode) => {
454
+ const collections = new Map()
455
+ for (const member of classNode.body.body) {
456
+ if (member.type !== "PropertyDefinition" || member.computed || member.key.type !== "Identifier") continue
457
+ const tsKey = toTs(member.key)
458
+ if (tsKey && holdsFunctions(checker, checker.getTypeAtLocation(tsKey))) collections.set(member.key.name, member.key)
459
+ }
460
+ if (collections.size === 0) return
461
+ const registered = new Set()
462
+ const visit = (node) => {
463
+ if (!node || typeof node.type !== "string") return
464
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier" && REGISTER_METHODS.has(node.callee.property.name)) {
465
+ const holder = node.callee.object
466
+ if (holder.type === "MemberExpression" && holder.object.type === "ThisExpression" && !holder.computed && holder.property.type === "Identifier" && collections.has(holder.property.name)) {
467
+ const callback = node.arguments[node.arguments.length - 1]
468
+ const tsCallback = callback ? toTs(callback) : null
469
+ if (tsCallback && isFunctionType(checker.getTypeAtLocation(tsCallback)) && callback.type === "Identifier") registered.add(holder.property.name)
470
+ }
471
+ }
472
+ for (const key of Object.keys(node)) {
473
+ if (key === "parent") continue
474
+ const value = node[key]
475
+ if (Array.isArray(value)) for (const child of value) visit(child)
476
+ else if (value && typeof value.type === "string") visit(value)
477
+ }
478
+ }
479
+ visit(classNode.body)
480
+ for (const name of registered) context.report({ node: collections.get(name), messageId: "registry", data: { name } })
481
+ }
426
482
  return {
483
+ ClassDeclaration(node) {
484
+ checkClass(node)
485
+ if (node.superClass) {
486
+ const name = busClassOf(context, node.superClass)
487
+ if (name) context.report({ node: node.superClass, messageId: "bus", data: { name } })
488
+ }
489
+ },
490
+ ClassExpression(node) { checkClass(node) },
491
+ NewExpression(node) {
492
+ const name = busClassOf(context, node.callee)
493
+ if (name) context.report({ node, messageId: "bus", data: { name } })
494
+ },
427
495
  ...moduleReferences(({ node, source, value, names }) => {
428
496
  if (value === EVENT_EMITTER_PACKAGE) report(source, value)
429
497
  else if (value === CQRS_PACKAGE) for (const name of names) if (EVENT_NAMES.has(name)) report(node, name)