@starci/eslint-canon-be 1.8.0 → 2.0.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 (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/CHANGELOG.md CHANGED
@@ -1,9 +1,99 @@
1
1
  # Changelog
2
2
 
3
- ## 1.8.0 - 2026-09-30
4
-
5
- - Breaking (hotfix ahead of 2.0.0, owner rule "no repositories"): `sql-only-in-repository` is removed. `sql-text-only` (R36) takes its place: raw SQL text is written only in a `<name>.sql.ts` (the capability's `persistence/<name>.sql.ts`, or a test builder's `src/tests/fixtures/builders/<area>.sql.ts`); everywhere else `.query(...)` takes an imported text constant, never a string literal or template; `createQueryBuilder` is refused everywhere. Migrations keep their inline SQL.
6
- - New: `no-repository-file` (R36): a `*.repository.ts` file is a finding. Move its SQL text into `<name>.sql.ts` and run it from the capability service on the shared EntityManager.
3
+ ## 2.0.0 - 2026-09-30
4
+
5
+ - Changed (lane PORT): `ruleParams.be.infraOwners` names `@nestjs/cache-manager` (the `CACHE_MANAGER` token) beside `cache-manager`: it is reached only by `integrations/cache` and `integrations/redis`, which is the retired repository rule `must-use-cache-service`; `infra-import-owner` (R90) covers it with a passing and a violating case.
6
+ - Changed (lane UT): `inbox-dedupe-required` (R80) moves from the door to the service. It judges the public methods of a `*.service.ts` whose first parameter TYPE has an `eventId` property: the first awaited expression must be `claim(source, eventId, ...)` on the `Inbox` port, with an early return on `false`. Consumers and `SignedWebhook` controllers are no longer judged by it, and `transport-is-thin` no longer allows an `Inbox` injection or an inbox guard in a door.
7
+ - Changed: `transport-is-thin` allows `unwrapOutcome` of `platform/primitives` (resolved by its symbol) beside the bus call and pure mapper functions.
8
+ - Fixed: `infra-needs-injector` (R85) no longer judges the constructor of a CQRS message (`extends Command<R>` / `Query<R>` of `@nestjs/cqrs`): its `params` is data built with `new`.
9
+ - Changed (lane UT, unit-test standard 2026-09-30): `unit-test-colocated` (R47) no longer keeps twin specs for handlers, guards, mappers, policies, clients and row mappers. Only a service is unit-tested: a `<name>.service.ts` has its `<name>.service.spec.ts` beside it (message `missing`), a unit spec (a `*.spec.ts` outside `src/tests/{e2e,integration,contract,world}` and `apps/migrate`) must be a `<name>.service.spec.ts` beside its service (`notService`, `orphan`), and a composition spec is no longer a unit kind.
10
+ - New `unit-spec` rules (R48 `BE_SPEC_QUALITY`): `spec-builds-with-testing-module`, `spec-no-new-subject`, `spec-module-definition-only-providers`, `spec-no-module-mock` (own modules and third-party libraries), `no-return-only-generic` (cast laundering in a spec or a test fixture) and `spec-infra-double-from-kit`, which reads the token-to-double table from `ruleParams.be.specDoubles` of the slot manifest (`knowledge/hfs/slots.yaml`).
11
+ - Changed: `spec-typed-entity-manager` (R48) requires the EntityManager double to be `mockEntityManager()` or `fakeTransaction()` imported from `@starci/jest-preset`; `src/tests/fixtures/database.ts` is no longer a source, and `mock<EntityManager>()` is refused.
12
+ - New `builders` law (R48 `BE_SPEC_QUALITY`, knowledge BE-TEST-16, lane BLD): `spec-no-raw-insert` (a spec, unit service spec, integration or e2e, never runs a raw INSERT/UPDATE/DELETE and never calls `save`/`insert`/`upsert`/`update`/`delete`/`remove` on a receiver typed `EntityManager`, `DataSource` or `QueryRunner`; reads stay allowed) and `builder-arranges-only` (a `*.builder.ts` has no `expect`/jest global, no `Date.now()`/argless `new Date()`/`Math.random()`/`randomUUID()`/`crypto.random*`, and no SQL that switches constraints off). The builder file name and folder are a BE_SOURCE_FORM finding of the slot manifest (`be.tests.fixtures.builders`).
13
+ - New (lane BLD, BE-TEST-16): `persisting-builder-in-builders-slot` (R98), `spec-no-repeated-row-literal` (R99) and `tests-keep-constraints` (R100). `spec-no-raw-insert` (R99) also reads a tagged-template statement; `builder-arranges-only` (R101) judges the builders slot through the HFS view and no longer judges constraints (R100 does, for every file of `be.tests.*`, plus `dropForeignKey(s)`, `dropCheckConstraint(s)` and `dropUniqueConstraint(s)` on a QueryRunner). `spec-no-raw-insert` moved from R48 to R99 and `builder-arranges-only` from R48 to R101; the catalog has one rule per builder obligation.
14
+ - New `cqrs` rule `handler-is-thin` (R87): a handler injects only `*Service` classes and the Logger, and `process` is one `return this.<service>.<method>(...)`.
15
+ - Changed: `transport-dispatch-only` is replaced by `transport-is-thin` (R88), which keeps its injection and one-dispatch checks and adds: no branch, loop or throw, no call other than the bus, the inbox claim and pure `*.mapper.ts` functions (the R80 inbox guard `if (!(await this.inbox.claim(...))) return` is the one allowed `if`), and a route method returns the dispatch result. There is no alias for the old name.
16
+ - Changed: `spec-infra-double-from-kit` also accepts a value whose declared type is a kit type (`MockEntityManager`, `FakeCache`, `FakeLock`, `RecordingOutbox`, `FakeClock`, `FakeIds`, or a `ReturnType` of the kit function): a spec helper parameter, `tx.em` of a `fakeTransaction(...)`, inline `fakeTransaction(...).em`.
17
+ - Fixed: `no-ambient-clock` (R79) exempts the whole test world, `world/kit` included, not only its root slot.
18
+ - Added: the Operations law (R95 `BE_OPERATION_CONTRACT`, `operations.mjs`): `operation-contract-decidable` refuses an `OperationContract` whose input or output is `any`, `unknown`, `Record<string, unknown>`, an unbound generic, a function or a standard-library class, or whose refusal codes are not a closed union of string literals; `operation-route-driven-by-table` requires a route that takes `OperationRequest<Table>` or answers `OperationReply<Table>` to do both, of one table. The canon types are recognised by where they are declared (the platform capability `operations`).
19
+ - Changed: `no-non-ascii-source` (R89) folds each line to NFC before it looks for a Vietnamese letter, so a decomposed spelling is caught like the precomposed one, and it exempts the i18n fixtures slot `be.tests.fixtures.i18n` (`src/tests/fixtures/i18n/`) beside the message-catalog slots; a test title, an identifier and a string in any other spec or fixture are still refused. The detection is `scripts/lib/language.mjs`, shipped in `runtime/`.
20
+
21
+ - Changed: the messages and header of `tests-no-override` and `tests-infra-only-in-world` (`test-world.mjs`) follow the owner refinement 2026-09-30 of R47: a service of the repository's own stack runs real and is failed on purpose through `world.infra.<service>`, only an external SaaS is a network fake (`world.fake.<provider>`). Detection is unchanged.
22
+ - Breaking: one configuration, `export default starciBeConfig({ hfs: loadHfs(import.meta.url) })`. The factory is async
23
+ (ESLint awaits it), turns typed linting on for every `.ts/.mts/.cts/.js/.mjs/.cjs` (`parserOptions.projectService`),
24
+ ignores only `dist/`, `coverage/`, `node_modules/`, borrows `@typescript-eslint/{consistent-type-assertions (never),
25
+ no-explicit-any, no-non-null-assertion, no-floating-promises, no-misused-promises, switch-exhaustiveness-check}` and
26
+ puts the repository's slot view in `settings.starci.hfs` (`lib/hfs.mjs`: `slotOf`, `tierOf`, `ownerOf`, `ruleParams`,
27
+ `connections`). It takes no globs, ignores or overrides. `sources`/`plugin`/`recommended` parameters are gone.
28
+ - Breaking: every rule ships at `error` in `recommended` (the six `warn` levels are gone); the factory refuses any other level.
29
+ - Breaking: `lib/slots.mjs` (which reached into the runtime checkout) is deleted; the manifest ships in `runtime/`
30
+ (byte copy kept by `packages/hfs/scripts/sync-runtime.mjs`). New `lib/types.mjs` answers what a node's TYPE is
31
+ (`typeOrigins`, `isPackageType`) for the type-aware rules.
32
+ - Removed: the SAB-only `cdc` and `event-delivery` laws (`projection-listener-contract`, `no-dynamic-projection-group-id`,
33
+ `projection-recompute-must-upsert`, `nats-bridge-delivery-contract`, `no-call-site-transport-override`).
34
+ - Breaking (slot sweep, RE1): `type-safety`, `spec-quality`, `testing`, `e2e-flow`, `suppression` and `size-budget` ask the slot view (`hfsOf(context)`) and the file's basename role instead of a path pattern. No rule of these laws exempts a spec or a test lane.
35
+ - Removed: `no-never-cast`, `no-non-null-assertion`, `no-double-cast`, `no-unguarded-unknown-cast` (R72) and `spec-typed-doubles` (R48): the borrowed `consistent-type-assertions` (`never`) and `no-non-null-assertion` the factory turns on state exactly the same obligation for every file, specs included. One obligation, one rule.
36
+ - New: `no-function-or-eval` (R72: the `Function` type, `eval` and `new Function`, the type escapes the borrowed rules do not cover).
37
+ - Removed: `harness-calls-provider-directly` (R48; a `harness` spec kind no longer exists, BE-CONVENTION 1.16).
38
+ - Changed: `unit-test-colocated` (R47) requires a twin `.spec.ts` beside a handler, domain service, consumer, job, guard, mapper, policy, client and `*.rows.ts` (by slot and basename role), a subject beside every spec, and refuses `.test.ts`, `.int-spec.ts` and `.harness-spec.ts`.
39
+ - Changed: `e2e-asserts-persisted-state` recognises a state read by the TYPE of the receiver (a typeorm `EntityManager`, `DataSource` or `QueryRunner`), not by a variable name; `e2e-uses-production-transport` recognises a bus by its `@nestjs/cqrs` type and an actor by the file (`*.handler.ts`, `*.consumer.ts`, `*.job.ts`) that declares its type; `no-sleep-in-flow` recognises a wait by its shape (a timer, or a `Promise<void>` call given one number); `no-wiring-in-flow-spec` recognises `Test` by its `@nestjs/testing` import; `no-model-call-in-e2e` and `no-marker-model-stub` ask the slot (`be.tests.e2e-live`, `be.tests.fixtures`, `be.tests.e2e-setup`).
40
+ - Changed: `no-inline-suppression` (R18) also refuses `eslint-env`, `/* global */`, `istanbul`/`c8`/`v8 ignore`, `NOSONAR`, `sonar-disable` and `prettier-ignore`, and finds a `@ts-` directive anywhere in a comment.
41
+ - Changed: `file-size-growth` (R20) takes no option: the `max` and `recorded` options are gone, the budget is `ruleParams.be.fileLines`, the baseline is git. A spec, a fixture and an e2e file are governed like any source file; a migration and a declaration file are not.
42
+ - New `cqrs` rules (R87 `BE_CQRS_SHAPE`): `message-typed-result` (a class of `<action>.command.ts`/`.query.ts` extends `Command<R>`/`Query<R>` imported from `@nestjs/cqrs`, with one `readonly params` parameter property), `execute-params-shape` (`params` is `ExecuteParams<X>`/`PublicExecuteParams<X>` declared by `platform/cqrs`; no `@Entity` class reachable from `X`), `no-use-case` (no `*.use-case.ts`, no `*UseCase` class in the application slots, no forwarder `<op>.service.ts` whose every method is `return <bus>.execute(...)` on a `CommandBus`/`QueryBus`), `no-event-bus` (no `EventBus`, `EventsHandler`, `IEventHandler` from `@nestjs/cqrs`, nothing from `@nestjs/event-emitter`).
43
+ - Changed: `handler-overrides-process` and `message-carries-params-only` read their scope from the slot (`be.feature.application`), find a handler by the resolved `CommandHandler`/`QueryHandler` import and its template by type (the `ICQRSHandler` of `platform/cqrs`); a handler that does not extend the template is refused, an intermediate base that implements `process` is followed through the type. A message may declare no property and its constructor body is `super()`. The CLI-command exemption (decorated classes) is gone: CLI commands are `.cli.ts` in their own slot.
44
+ - New `transport` rules (R88 `BE_TRANSPORT_SHAPE`): `transport-is-thin` (a transport class injects only `CommandBus`/`QueryBus`, plus `RequestLocale` from `platform/i18n` in `transport/http` and `Inbox` from `platform/inbox` in `transport/http` and `transport/message`; every handler method calls `<bus>.execute(` exactly once), `no-response-envelope` (the envelope classes and decorator of the convention, a repository interceptor whose `intercept` maps the response with rxjs `map`, an object literal with `success` and `data` returned from a method), `no-graphql-json` (the JSON scalars of `graphql-type-json`/`graphql-scalars` by import and by re-export, `@Body()` typed `unknown` or a bag of unknowns, `switch (x.operation)`).
45
+ - Changed: `door-lives-in-features` (a `@Controller` outside slot `be.transport.http`), `no-capability-imports-features` (an import resolved by the TypeScript program into tier `feature` from a domain, platform or integrations file) and `rest-door-needs-a-reason` (a public reason `Health`/`AuthHandshake`/`SignedWebhook`, a `StreamableFile`, `Redirect`, upload interceptor or `createReadStream` in the class, judged by resolved imports) read the slot view and types instead of path patterns and route names. The `probe`, `machine` and `operator` route heuristics are gone.
46
+ - New `naming` rule `no-default-export` (R89): no `export default`, `export { x as default }`, `export { default } from` or `export =` in TypeScript source, except a Jest `*.global-setup.ts` in slot `be.tests.e2e-setup`.
47
+ - Changed: `no-non-ascii-source` exempts only message catalogs (slots `be.domain.messages`, `be.feature.messages`); the test-lane exemption for strings is removed, specs and fixtures get the same law.
48
+ - New law `infra-owner` with `infra-import-owner` (R90 `BE_INFRA_OWNER`): an import (static, dynamic, `require`, `export ... from`, subpaths included) or a global reference (`fetch`, `setTimeout`, `globalThis.setInterval`) of a library in `ruleParams.be.infraOwners` outside its owning capability is refused; `[]` means nowhere. Specs are included.
49
+ - Slot manifest: `ruleParams.be` gains `infraOwners`, `suffixes` and `bannedSuffixes` (validated by `scripts/lib/hfs-slots.mjs` and `modules/schemas/hfs-slots.schema.yaml`); `be.feature.application` no longer allows `use-case`, `be.feature.application.support` allows `<name>.<role>.ts` only, the CLI slot allows `<name>.cli.ts`. `hfs check` reports `BE_SOURCE_FORM` for a tracked `src/`/`apps/` `.ts` outside the closed suffix vocabulary; the architecture machine's `BE_SOURCE_NAME_INVALID` is renamed `BE_SOURCE_FORM`.
50
+ - New helpers `lib/import-source.mjs` (resolved import of an identifier, `moduleReferences`) and `lib/transport-slots.mjs`; the typed fixture gains `src/` capabilities (`platform/{cqrs,i18n,inbox,graphql}`, an entity, a feature), stubs for `rxjs`, `graphql-type-json`, `graphql-scalars` and `typeorm`'s `Entity`, and `tester.mjs` raises the default-project file limit so a law can test many virtual files.
51
+ - New: `error-home` (rewritten, R38: a class deriving from `DomainError` only in `errors/<capability>.error.ts` of its owner, a class deriving from the built-in `Error` without `DomainError` refused, both by type), `throw-domain-error` (R38: the thrown value's type derives from `DomainError`; `throw new Error`, the Nest `HttpException` family and thrown non-class values are findings; a domain owner throwing a platform error is one too) and `error-family-shape` (R38: one `<C>ErrorCode` enum with `<CAPABILITY>_<WHAT>` values, one `<C>_ERROR_KINDS: Record<<C>ErrorCode, ErrorKind>` table, one empty `<C>Error extends DomainError<<C>ErrorCode>`).
52
+ - Changed: `catch-must-account` (R40) accepts a call of a method on a receiver typed `Logger` from `platform/logging`; the receiver-name list is gone.
53
+ - Changed: `public-needs-reason` (R41) takes `{ reason: PublicReason.X }` with the enum of `domain/identity` (type-aware); a string or another enum fails. `no-untyped-body` reads the transport slot instead of a path.
54
+ - New: `no-auth-use-guards` (R41: any `@UseGuards(...)`; authentication is the global `APP_GUARD` chain). Removed: `identity-needs-guard` (superseded by it and by the machine's `default-deny-app-guard`).
55
+ - New: `input-bounded` (R42: every property of an input class in a transport `dto/` folder carries a `class-validator` decorator, plus the bound its type calls for). Removed: `dto-needs-validator` (merged into it). New: `no-offset-pagination` (R42: `skip`/`offset` in `find*` options on an `EntityManager`, `OFFSET` in an `sql` template, `page`/`pageNumber`/`offset` on a transport DTO).
56
+ - Changed: `no-direct-env-read` (R43) allows the process environment only in the file that declares the `EnvSource` class in `platform/config`, and refuses every form (`process.env`, destructuring, `Reflect.get`, an `env` import of `node:process`), `@nestjs/config`, `dotenv`, `envConfig()` and a `process.cwd()` path joined into `src` or `.starcistacks`; specs are no longer exempt.
57
+ - Changed: `no-secret-default` (R44) is rewritten on types: a default argument to an `EnvSource` reader of `Secret`/`Url`, a `??`/`||`/`??=` fallback on a `Secret` or `Url` value, and a `""`, `localhost`, `127.0.0.1`, `0.0.0.0` or `http(s)://` literal given to any reader returning one. No key-name matching remains.
58
+ - New: `no-global-decorator` (R45: any `@Global()`, specs included), `is-global-only-in-app` (R45: `isGlobal: true` only in `apps/<app>/src/app.module.ts`), `no-cross-owner-module-import` (R45: a `@Module({ imports })` entry declared by another owner). Removed: `global-module-allowlist`, with `ruleParams.be.globalModules` in the slot manifest. Changed: `static-module-register` allows only `register` (no `forRoot`, `forFeature`, `registerAsync`); `no-new-injectable` decides by the class declaration (a provider decorator), not by a name suffix; `no-module-let` and `one-module-per-file` no longer exempt specs.
59
+ - New: `capability-module-shape` (R45: the representative `<c>.module.ts` of a domain, platform or integrations capability extends the `ConfigurableModuleClass` imported from its own `./<c>.module-definition` and may override only `static register(options: typeof OPTIONS_TYPE): DynamicModule`; the module definition builds `new ConfigurableModuleBuilder<XOptions>().setExtras({ isGlobal: false }, ...).build()`; every sub-module and every feature module is a plain `@Module` class with no base, no `register` and no module definition).
60
+ - Dependencies: `@typescript-eslint/eslint-plugin` and `@typescript-eslint/parser` ^8.70; peer `typescript` >=5.9; node >=22.13.
61
+ The 1.7.1 dependency on `@starci/hfs` (and its sibling-workspace fallback) is gone: the package carries its own
62
+ manifest copy in `runtime/`, one resolution path, no fallback.
63
+ - New law `injection` (R85 `BE_RAW_INJECT`, type-aware: a callee is identified by where its function is declared, never by a variable name; specs are not exempt):
64
+ `injector-only` (`Inject(`, `InjectEntityManager(`, `InjectDataSource(`, `InjectQueue(`, `InjectRepository(` and any package function named `Inject*` are called only in a `*.decorators.ts` at an owner root; a raw `@Inject(...)` decorator, property injection and an injector on a non-constructor parameter are findings),
65
+ `injector-shape` (zero-parameter, `TypedParameterDecorator<T>` from `platform/composition`, body `injector<T>(token)`, PascalCase name, JSDoc naming `T`; exported tokens are `unique symbol = Symbol("<owner>.<thing>")`; `provide:` is a class or a `unique symbol`),
66
+ `injector-type-match` (the `T` of the injector equals the parameter annotation), `infra-needs-injector` (a constructor parameter typed by a platform/integrations declaration or a package carries an injector; only domain services and same-owner types are class-injected),
67
+ `no-module-ref`, `no-forward-ref`, `no-string-token` (a string or template literal, or a string-typed value, as `Inject(...)`/`injector(...)`/`provide:` token; a string literal argument of `getEntityManagerToken`/`getDataSourceToken` outside `<conn>.connection.ts`).
68
+
69
+ ### Persistence rules (lane C0 RA)
70
+
71
+ - New law `connections`: `one-connection-per-database` (R84 `BE_CONNECTION_DUPLICATE`) and `em-injection-slots` (R88 `BE_TRANSPORT_SHAPE`).
72
+ - New: `sql-text-only` and `no-query-builder` (R36 `BE_SQL_OUTSIDE_PERSISTENCE`). Removed: `sql-only-in-repository` (a `*.repository.ts` no longer exists; slot `be.persistence` allows `<name>.sql.ts` and `<name>.rows.ts` instead).
73
+ - Changed: `no-interpolated-sql` (R68) judges the `sql` tag's substitutions by type (`SqlIdent`), drops the UPPER_SNAKE, `quoteIdent` and `sqlSetClause` allowlists, and refuses a `.query()` string built by template or `+`.
74
+ - Changed: `query-needs-limit` (R69) and `no-query-in-loop` (R77) identify the receiver by its `EntityManager` type, not its name; query-builder and raw-SQL reads are gone from both, spec files are no longer exempt, and `.query` text is judged by the machine (R86).
75
+ - Changed: `must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`, `no-outer-manager-in-transaction` and `no-external-call-in-transaction` (R82, R83) read types (`typeorm`, integrations, `HttpClient`, `MessagePublisher`) and slots instead of names and path patterns: the injector must be `Inject<Pascal(connection)>EntityManager` of a declared connection, property injection and a `DataSource`/`QueryRunner` outside `platform/database` and the migrate app are refused, `TypeOrmModule.forFeature` is refused, and specs get the same law.
76
+ - Changed: `lib/types.mjs` origins include a type's alias declaration and its own symbol (new `originsOfType`), so an alias over a generic type hides nothing; new `lib/persistence.mjs` holds the connection and injection-site helpers.
77
+
78
+ - Breaking: `must-deep-module-import` is renamed `import-owner-entry` (BE-CONVENTION 2.4); the old name is deleted. A cross-owner import targets the owner's `index.ts`; a same-owner import is relative and never goes through the own `index.ts`. Owners are asked of the HFS slots and aliases of the program's `compilerOptions.paths`, so `no-self-module-alias` and `no-relative-capability-escape` no longer read a path pattern.
79
+ - Breaking: `no-folder-reexport` refuses a nested `index.ts` (an index that is not at its owner's root) and an index that holds anything but named `export { } from` lines; its `maxExports` option and the `*_STORE` and types-folder name checks are gone (the budget is the slot's `indexExports`).
80
+ - Breaking: `no-ambient-clock` (R79) refuses every reference, not only calls, to `Date.now`, a zero-argument `new Date()`, `Date()`, `performance.now`, `process.hrtime` and `Temporal.Now` (also through `globalThis`, a destructured alias or a `node:perf_hooks` / `node:process` import), and specs are no longer exempt; only the `platform/clock` owner may read them.
81
+ - Breaking: `inbox-dedupe-required` (R80) is rewritten type-aware. In a `*.consumer.ts` of slot `be.transport.message` (every public method) and under `@Public({ reason: PublicReason.SignedWebhook })` (read by the enum member's type) the first awaited expression must be `claim(source, eventId)` on a receiver typed as the `Inbox` port of `platform/inbox`, followed by an early return when it answers `false`. The keyword, decorator-name and class-name matching is deleted.
82
+ - Breaking: `no-hand-rolled-retry` (R81) keeps the loop + catch (or `.catch`) + wait shape but judges the wait by owner and type: `setTimeout`, a `node:timers` import, or a call resolving to a function declared outside `platform/retry` whose body waits; the names `sleep/delay/wait/backoff` no longer decide anything. `for...of` and `for...in` loops are covered and specs are not exempt.
83
+ - Breaking: `no-secret-in-log`, `no-interpolated-log-message`, `no-error-wording-as-log-identity` and `no-framework-logger` (R40/R71) identify the logger by the `Logger` port type of `platform/logging`, not by receiver or method names. A log call's first argument must be a member of an enum declared in an owner's `<owner>.log-events.ts`; `no-secret-in-log` refuses a `Secret` (`platform/config`) or `Pii` (`identity`) value, or a value read out of one, and keeps the key-name list as its second half; `no-framework-logger` also refuses `ConsoleLogger`, a class extending the Nest logger and a namespace-imported `new common.Logger()`. The `standaloneProgramGlobs` export (an exemption list) is deleted.
84
+ - Breaking: `no-runtime-schema` (R34) requires the literal `synchronize: false` in every DataSource or TypeORM options object (found by its contextual `typeorm` type), refuses `new DataSource(<not an object literal>)`, bans `migrationsRun` in any form and detects DDL (`CREATE|ALTER|DROP` of a table, index, type, schema, view, ...; `TRUNCATE TABLE`; `ADD|DROP COLUMN`) in any string outside the `migrations/` folder of slot `be.persistence`.
85
+ - Breaking: `no-entity-in-contract` (R37) is type-aware: any type whose declaration carries `typeorm`'s `@Entity`, reached directly or through type arguments and properties, from a transport signature, a handler `execute`, a `*.contracts.ts`, `*.command.ts` or `*.query.ts`; and entity files importing `@nestjs/graphql` or `class-validator`. The `*Entity` name test is deleted.
86
+ - Changed: `user-copy-through-catalog` (R78) judges error classes by the constructed type extending `Error` (not a `*Error` name) and outbound ports by the receiver's type (declared by an `integrations` owner or `platform/messaging`, not the method names `notify/send/publish`); transport slots come from the slot view.
87
+ - Changed: `http-needs-timeout` receivers are identified by type (`AxiosInstance`, `AxiosStatic`, `HttpService`), not by name. `async-needs-await`, `json-parse-needs-guard`, `sql-only-in-repository` and `migration-down-reversible` ask the slot view instead of a path pattern, and none of the R70/R73/R76/R78/R79/R81 rules exempts specs any more.
88
+
89
+ ## 1.8.1 - 2026-09-30 (hotfix off 1.8.0, branch release/canon-be-1.8)
90
+
91
+ - New: `no-entity-manager-wrapper` (R36): an EntityManager wrapper class outside a service or handler, and an exported function taking an EntityManager first, are findings (the renamed-repository loophole). 2.0.0 carries the structural form as `no-repository-class` (R83).
92
+
93
+ ## 1.8.0 - 2026-09-30 (hotfix off 1.7.1, branch release/canon-be-1.8)
94
+
95
+ - Breaking: `sql-only-in-repository` removed; `sql-text-only` (R36) allows raw SQL text only in a `<name>.sql.ts` (including `src/tests/fixtures/builders/<area>.sql.ts`); `.query(...)` elsewhere takes an imported constant; `createQueryBuilder` refused; migrations exempt. 2.0.0 keeps the id with the typed `SqlText` check.
96
+ - New: `no-repository-file`: a `*.repository.ts` file is a finding (2.0.0 refuses the suffix through R89 as well).
7
97
 
8
98
  ## 1.7.1 - 2026-09-30
9
99
 
@@ -42,7 +132,7 @@ Round 2 back-end rules (catalog R42, R68 to R76). Every rule below has RuleTeste
42
132
  HFS back-end rules (catalog R18, R20, R34, R36-R38, R40-R45, R48). Every rule below has RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`.
43
133
 
44
134
  - New: `catch-must-account` (R40), `error-home` (R38), `no-runtime-schema` (R34), `sql-only-in-repository` (R36), `no-entity-in-contract` (R37), `no-untyped-body` and `public-needs-reason` (R41), `no-direct-env-read` (R43), `no-secret-default` (R44), `secret-compare-timing-safe` (R41), `global-module-allowlist`, `typed-module-definition`, `static-module-register`, `no-new-injectable`, `no-module-let`, `one-module-per-file` (R45), `no-inline-suppression` (R18), `file-size-growth` (R20 ratchet; the soft budget is a hfs check report item, not a lint warning), `spec-no-source-read` and `spec-typed-doubles` (R48).
45
- - Fixed: `must-deep-module-import` now holds the HFS owner surface. An aliased import names the owner and stops (`@modules/domain/plan`, `@features/plan`, or the explicit `/index`); a path into a file of another owner is refused, and a tier-only or root alias still is. The retired `lib` tier no longer counts as a tier. `no-folder-reexport` accepts a named `index.ts` surface (`export { X } from "./x"`, `export type { T } from "./contracts"`), and refuses `export *`, a `*_STORE` token, a whole `types` folder and an entry wider than the manifest's `indexExports` (60). Both are on again in `starciBeConfig`.
135
+ - Fixed: `import-owner-entry` (then under an older name) now holds the HFS owner surface. An aliased import names the owner and stops (`@modules/domain/plan`, `@features/plan`, or the explicit `/index`); a path into a file of another owner is refused, and a tier-only or root alias still is. The retired `lib` tier no longer counts as a tier. `no-folder-reexport` accepts a named `index.ts` surface (`export { X } from "./x"`, `export type { T } from "./contracts"`), and refuses `export *`, a `*_STORE` token, a whole `types` folder and an entry wider than the manifest's `indexExports` (60). Both are on again in `starciBeConfig`.
46
136
  - Removed: no rule is off. The retired-rule list (`RETIRED`) and its export are gone, and so are the rules it switched off: `exception-name-ends-in-exception`, `exception-code-matches-class-name`, `exception-metadata-type-named-for-class`, `exception-extends-abstract`, `exception-in-errors-folder`, `require-exception-object-arg`, `throw-abstract-exception` (the Academy `AbstractException` family, replaced by `error-home`), `no-handler-encoded-failure` (expected refusals are typed unions), `handler-has-twin-spec`, `no-self-global-module` (replaced by `global-module-allowlist`), `no-line-suppression` and `require-vn-ok-reason` (replaced by `no-inline-suppression`), and the `vn-ok` exemption of `no-non-ascii-source`.
47
137
  - `starciBeConfig` refuses a recommendation that carries an `off`.
48
138
  - Slot parameters (the `@Global()` allowlist, the file line budget, the `index.ts` width) are read through `lib/slots.mjs` from `knowledge/hfs/slots.yaml` (`ruleParams.be.globalModules`, `ruleParams.be.fileLines.{soft,hardGrowth}`, and the `indexExports` budget of a `be.*` slot), with the rule catalog's values as the fallback.
package/README.md CHANGED
@@ -12,40 +12,24 @@ npm i -D @starci/eslint-canon-be
12
12
 
13
13
  ## Use it
14
14
 
15
- ```js
16
- import starciBe, { recommended } from "@starci/eslint-canon-be"
17
-
18
- export default [
19
- // …your own ignores, language options and other plugins…
20
- {
21
- files: ["src/**/*.ts"],
22
- plugins: { "starci-be": starciBe },
23
- rules: recommended,
24
- },
25
- ]
26
- ```
27
-
28
- Which globs the law applies to is your repository's fact. What the law says is not — so there is no
29
- option to switch a rule off or lower it to a warning.
30
-
31
- Prefer the factory, which owns the levels for you:
15
+ `eslint.config.mjs` of a back end is a managed file (`hfs sync` renders it; `hfs check` refuses any other content):
32
16
 
33
17
  ```js
34
- import starciBe, { recommended, starciBeConfig } from "@starci/eslint-canon-be"
18
+ import { loadHfs, starciBeConfig } from "@starci/eslint-canon-be"
35
19
 
36
- export default [
37
- // …your own ignores, language options and other plugins…
38
- starciBeConfig({ sources: ["apps/**/*.ts", "src/**/*.ts"], plugin: starciBe, recommended }),
39
- ]
20
+ export default starciBeConfig({ hfs: loadHfs(import.meta.url) })
40
21
  ```
41
22
 
42
- It states `warn` as `error` for a zero-warning gate, applies
43
- `noInlineConfig` and unused-disable reporting, and refuses a recommendation that carries a rule switched `off`. HFS
44
- keeps no retired-rule list: a rule the standard no longer holds is deleted from the plugin, and every rule that ships is on.
45
- The parameters a rule takes from the slot manifest (the `@Global()` allowlist, the file line budget, the width of an
46
- `index.ts`) come through `lib/slots.mjs`, which reads `knowledge/hfs/slots.yaml` and falls back to the rule catalog's values.
23
+ That is the whole configuration. `loadHfs` reads the repository's `hfs.json` beside the config file and the slot
24
+ manifest this package ships in `runtime/` (a byte copy of `knowledge/hfs/slots.yaml`, refreshed by
25
+ `packages/hfs/scripts/sync-runtime.mjs`). `starciBeConfig` returns the flat config: typed linting
26
+ (`parserOptions.projectService`) over every `.ts/.mts/.cts/.js/.mjs/.cjs`, ignores `dist/`, `coverage/` and
27
+ `node_modules/` only, `noInlineConfig` with unused-disable reporting, the borrowed typescript-eslint rules, every canon
28
+ rule at `error`, and `settings.starci.hfs` - the slot view each path-scoped rule asks (`lib/hfs.mjs`). The factory takes
29
+ no globs, ignores or rule overrides, and refuses a recommendation with any rule not at `error`. A rule the standard no
30
+ longer holds is deleted from the plugin; every rule that ships is on.
47
31
 
48
- Also exported: `rules`, `ruleOwners`, `lawOwners`, `starciBeConfig`, `linterOptions`.
32
+ Also exported: `rules`, `ruleOwners`, `lawOwners`, `ruleDeclarations`, `recommended`, `linterOptions`.
49
33
 
50
34
  ## What it actually catches
51
35
 
@@ -53,31 +37,36 @@ A sample, not the list:
53
37
 
54
38
  | Area | What the rules hold |
55
39
  |---|---|
56
- | **Errors** | A `catch` rethrows, logs through the logger port or returns an outcome carrying its cause; an error lives in its capability's `errors/`, extends `DomainError`, and no bare `Error` or framework exception escapes (`catch-must-account`, `error-home`) |
57
- | **Schema authority** | Migrations only: no `synchronize`, no `migrationsRun`, no runtime DDL, no entity glob; raw SQL text only in a `<name>.sql.ts`, no `*.repository.ts` file; no ORM entity in a contract (`no-runtime-schema`, `sql-text-only`, `no-repository-file`, `no-entity-in-contract`) |
58
- | **Config and secrets** | `process.env` only in `platform/config`; no literal default for a secret, password, token, key or URL; secrets compare with `timingSafeEqual` (`no-direct-env-read`, `no-secret-default`, `secret-compare-timing-safe`) |
59
- | **Default deny** | Typed bodies and arguments, no `GraphQLJSON` tunnel, no `switch (operation)`, and `@Public` carries a reason (`no-untyped-body`, `public-needs-reason`) |
60
- | **Module shape** | `@Global()` only on the manifest allowlist; typed `ConfigurableModuleBuilder`; static `register`; one module per file; no `new` of a provider; no module-level `let` |
61
- | **Suppression** | No `eslint-disable`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck` or `vn-ok` comment, anywhere (`no-inline-suppression`) |
62
- | **Size** | A file over its line budget is not new and does not grow past its size at the parent commit (`file-size-growth`) |
63
- | **Query safety** | SQL text carries no runtime substitution; a read states `take`, `limit` or `LIMIT` (`no-interpolated-sql`, `query-needs-limit`, `no-query-in-loop`) |
64
- | **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` (`http-needs-timeout`, `json-parse-needs-guard`, `no-hand-rolled-retry`) |
65
- | **Log safety** | A logger call names no credential and no personal identifier (`no-secret-in-log`) |
66
- | **Async and types** | An `async` function awaits; no `as never`, no `x!`; handlers and public methods declare a return type (`async-needs-await`, `no-never-cast`, `no-non-null-assertion`, `explicit-handler-return-type`) |
67
- | **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has a `class-validator` decorator (`migration-down-reversible`, `dto-needs-validator`) |
68
- | **Spec quality** | A spec does not read repository source with `fs`, and doubles are `mock<T>()`, not `as never` (`spec-no-source-read`, `spec-typed-doubles`) |
69
- | **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
70
- | **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/plan`), never through a path into it; an `index.ts` lists a bounded set of named exports, with no `export *`, storage token or types folder; self-aliases and tier-only aliases are refused. |
71
- | **Data access** | Queries stay where the layer says they may be built; no transaction spans an external call (`no-external-call-in-transaction`); the database is reached through the named shared `EntityManager` only (`named-entity-manager-only`) |
72
- | **Transport** | The wire shape is declared, not inferred from whatever a handler happened to return |
73
- | **Observability** | A failure is logged through the logger port with a typed identity, so a log line can be traced to the law that names it |
74
- | **Testing** | Unit specs sit beside their subjects, including structural specs in HFS `src/tests` categories; a model-quality harness (one that imports an LLM SDK, reaches a house model helper or declares `@harness-kind model`) calls its provider directly; other live e2e specs, such as an identity provider, are not judged as model harnesses. |
75
- | **End-to-end flows** | One file, one flow, named steps, and **never sleep** — poll until the state settles, with a deadline |
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
+ | **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`) |
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
+ | **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
+ | **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
+ | **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
+ | **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
+ | **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`) |
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`) |
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`); 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
+ | **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`) |
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`) |
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
+ | **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
+ | **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`) |
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
+ | **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
+ | **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`) |
76
64
  | **CDC · event delivery** | Projections and events follow the declared delivery contract |
77
- | **Comments · naming · type safety** | Comments say why; no double cast through `unknown` |
78
- | **User copy** | A literal exception message, notification text or response copy comes from the per-capability messages catalog through the typed `MessageCatalog` port (`user-copy-through-catalog`) |
79
- | **Temporal** | `Date.now()`, a bare `new Date()` and `performance.now()` are read only inside `platform/clock` (`no-ambient-clock`) |
80
- | **Idempotency** | A `@Public()` webhook handler and an outbox/queue consumer claim the event through the shared inbox before they act (`inbox-dedupe-required`) |
65
+ | **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
+ | **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
+ | **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
+ | **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`) |
81
70
 
82
71
  Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
83
72
  line leads straight to the document that explains why.
@@ -12,7 +12,7 @@
12
12
  * - an `async` generator, whose `yield` is its suspension.
13
13
  */
14
14
  import { walk } from "./lib/ast.mjs"
15
- import { isDeclarationFile, isTestLane } from "./lib/path.mjs"
15
+ import { isDeclarationFile } from "./lib/path.mjs"
16
16
 
17
17
  /** Whether the function body awaits, ignoring nested functions (each is judged alone). */
18
18
  const awaits = (fn) => {
@@ -49,7 +49,7 @@ export const asyncNeedsAwait = {
49
49
  },
50
50
  create(context) {
51
51
  const filename = context.filename || context.getFilename()
52
- if (isTestLane(filename) || isDeclarationFile(filename)) return {}
52
+ if (isDeclarationFile(filename)) return {}
53
53
  const check = (node) => {
54
54
  if (!node.async || node.generator || !node.body) return
55
55
  if (node.body.type !== "BlockStatement" && node.type === "ArrowFunctionExpression") {
package/authorization.mjs CHANGED
@@ -1,106 +1,56 @@
1
1
  /**
2
- * The rule that holds `authorization.md`.
2
+ * The rule that holds the guard half of HFS default-deny (catalog R41 `BE_DEFAULT_DENY`, law `authorization`).
3
3
  *
4
- * ONE rule, and the honest reason is worth stating: authorization is decided against a row, and a
5
- * parser does not know which row a handler is reaching for or what owning it means. AUTHZ-3, -4 and
6
- * -5 all turn on that, so a rule aimed at them would fire on shape rather than on meaning - and a
7
- * rule that fires on shape is one authors learn to disable, which leaves the law worse off than when
8
- * nothing enforced it.
4
+ * Authentication and authorization are the global `APP_GUARD` chain of the app (throttler, CSRF origin guard, then the
5
+ * `AuthGuard` of `domain/identity`), registered once and read by the architecture machine. A route therefore never picks
6
+ * its own guard: `@UseGuards(...)` has no legitimate use in product code, and a door that names a guard is a door that
7
+ * can also forget one. `@Public({ reason: PublicReason.X })` is the only way to open a door.
9
8
  *
10
- * AUTHZ-1 was measured and deliberately left alone. A handler checking `if (!user)` reads like
11
- * duplication of the door's guard and is not: the guard belongs to one door and the handler belongs
12
- * to every caller of the bus. A rule refusing it would fire on the majority of correct handlers in
13
- * this tree, which is a gate contradicting canon rather than holding it.
14
- *
15
- * What IS checkable in one file is the door: a resolver method that READS the authenticated user
16
- * while nothing on that method or its class establishes the identity. That failure looks like
17
- * nothing at all - the parameter still says `user`, the handler still receives one - and the only
18
- * missing piece is the line that proved it belonged to the caller.
9
+ * This rule replaces `identity-needs-guard`, which asked a door that read the caller's identity to carry a guard: with
10
+ * default-deny the guard is global, so there is nothing left for the door to carry.
19
11
  */
20
-
21
- /** Parameter decorators that read an already-authenticated identity off the request. */
22
- const IDENTITY_PARAM_DECORATORS = new Set([
23
- "KeycloakGraphQLUser",
24
- "KeycloakUser",
25
- "CurrentUser",
26
- ])
27
-
28
- /** The decorator that establishes the identity a door then reads. */
29
- const GUARD_DECORATOR = "UseGuards"
30
-
31
- /** Reads the callee name off a decorator expression, whether or not it is called. */
32
- const decoratorName = (decorator) => {
33
- const expression = decorator.expression
34
- if (!expression) return undefined
35
- if (expression.type === "CallExpression") {
36
- return expression.callee && expression.callee.type === "Identifier"
37
- ? expression.callee.name
38
- : undefined
39
- }
40
- return expression.type === "Identifier" ? expression.name : undefined
41
- }
42
-
43
- /** Whether any decorator in the list is the guard. */
44
- const hasGuard = (decorators) =>
45
- (decorators || []).some((decorator) => decoratorName(decorator) === GUARD_DECORATOR)
46
-
47
- /** The identity parameter on a method, when it has one. */
48
- const identityParameter = (node) => {
49
- for (const parameter of node.value && node.value.params ? node.value.params : []) {
50
- // a decorated parameter carries its decorators on the parameter node itself, and TS-ESTree
51
- // wraps a typed+decorated parameter in TSParameterProperty for constructor promotion
52
- const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
53
- const decorators = parameter.decorators || (carrier && carrier.decorators) || []
54
- for (const decorator of decorators) {
55
- if (IDENTITY_PARAM_DECORATORS.has(decoratorName(decorator))) return parameter
12
+ import { importsFrom } from "./lib/declared.mjs"
13
+
14
+ /** True when the decorator expression names `UseGuards`, however it was imported. */
15
+ const isUseGuards = (decorator, aliases) => {
16
+ const expression = decorator.expression.type === "CallExpression" ? decorator.expression.callee : decorator.expression
17
+ if (expression.type === "Identifier") return expression.name === "UseGuards" || aliases.get(expression.name) === "UseGuards"
18
+ if (expression.type === "MemberExpression" && !expression.computed && expression.property.type === "Identifier") {
19
+ return expression.property.name === "UseGuards" && expression.object.type === "Identifier" && aliases.get(expression.object.name) === "*"
56
20
  }
57
- }
58
- return undefined
21
+ return false
59
22
  }
60
23
 
61
- // -- AUTHZ-2 ---------------------------------------------------------------------------------------
62
-
63
- /** A door that reads an identity carries the guard that establishes it. */
64
- export const identityNeedsGuard = {
65
- meta: {
66
- type: "problem",
67
- docs: {
68
- description:
69
- "A resolver or controller method reading the authenticated user must carry a guard on that method or its class.",
24
+ /** No route picks its own guard: the global APP_GUARD chain decides. */
25
+ export const noAuthUseGuards = {
26
+ meta: {
27
+ type: "problem",
28
+ docs: { description: "`@UseGuards(...)` is not used: authentication and authorization are the global APP_GUARD chain." },
29
+ schema: [],
30
+ messages: {
31
+ useGuards:
32
+ "`@UseGuards(...)` picks a guard per door, so a door can forget it. Authentication and authorization are the global `APP_GUARD` chain of the app; delete this decorator, and open a door only with `@Public({ reason: PublicReason.X })`.",
33
+ },
70
34
  },
71
- schema: [],
72
- messages: {
73
- unguarded:
74
- "This door reads the authenticated user through `@{{decorator}}()` while neither the method nor its class carries `@UseGuards(...)`. Nothing established the identity, so what reaches the handler is whatever the pipeline left there - and the failure is silent, because the parameter still says `user` and the handler still receives one. Put the guard on the door that reads the identity.",
35
+ create(context) {
36
+ let aliases = new Map()
37
+ return {
38
+ Program(node) {
39
+ aliases = importsFrom(node, () => true)
40
+ },
41
+ Decorator(node) {
42
+ if (isUseGuards(node, aliases)) context.report({ node, messageId: "useGuards" })
43
+ },
44
+ }
75
45
  },
76
- },
77
- create(context) {
78
- return {
79
- MethodDefinition(node) {
80
- const parameter = identityParameter(node)
81
- if (!parameter) return
82
- if (hasGuard(node.decorators)) return
83
-
84
- // the guard may sit on the class instead, covering every method on it
85
- const classBody = node.parent
86
- const classNode = classBody && classBody.parent
87
- if (classNode && hasGuard(classNode.decorators)) return
88
-
89
- const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
90
- const decorators = parameter.decorators || (carrier && carrier.decorators) || []
91
- const named = decorators.map(decoratorName).find((name) => IDENTITY_PARAM_DECORATORS.has(name))
92
- context.report({ node: parameter, messageId: "unguarded", data: { decorator: named } })
93
- },
94
- }
95
- },
96
46
  }
97
47
 
98
48
  /** Every rule this module ships, by the name a config switches it on under. */
99
49
  export const rules = {
100
- "identity-needs-guard": identityNeedsGuard,
50
+ "no-auth-use-guards": noAuthUseGuards,
101
51
  }
102
52
 
103
53
  /** The level a consuming repository switches this on at. */
104
54
  export const recommended = {
105
- "starci-be/identity-needs-guard": "error",
55
+ "starci-be/no-auth-use-guards": "error",
106
56
  }