@starci/eslint-canon-be 3.4.40 → 3.4.41

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 (48) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +2 -2
  3. package/boundary-schema.mjs +32 -4
  4. package/idempotency.mjs +3 -0
  5. package/index.mjs +2 -0
  6. package/infra-owner.mjs +4 -3
  7. package/lib/client-idempotency.mjs +403 -0
  8. package/lib/persistence.mjs +1 -1
  9. package/lib/schema-values.mjs +90 -0
  10. package/lib/unit-spec.mjs +6 -0
  11. package/operations.mjs +197 -32
  12. package/package.json +1 -1
  13. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  14. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  15. package/runtime/knowledge/hfs/rules.yaml +168 -47
  16. package/runtime/knowledge/hfs/slots.yaml +110 -52
  17. package/runtime/knowledge/sonar-gate.yaml +1 -1
  18. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  19. package/runtime/modules/models/registry.yaml +3 -1
  20. package/runtime/modules/models/runtimes.yaml +2 -0
  21. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  22. package/runtime/scripts/api/git/show.mjs +6 -0
  23. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  24. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  25. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  26. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  27. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  28. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  29. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  30. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  31. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  32. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  33. package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
  34. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  35. package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  37. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  38. package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
  39. package/runtime/scripts/hfs/slots.mjs +1 -0
  40. package/runtime/scripts/hfs/view.mjs +2 -1
  41. package/runtime/scripts/lib/in-order.mjs +71 -0
  42. package/runtime/scripts/lib/language.mjs +94 -1
  43. package/runtime/scripts/lib/package-at.mjs +62 -0
  44. package/runtime/scripts/lib/same-text.mjs +5 -0
  45. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  46. package/suppression.mjs +1 -1
  47. package/unit-spec.mjs +66 -6
  48. package/uploads.mjs +112 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+ - Changed: `operation-table-schemas` and `operation-contract-decidable` leave alone a stub table a spec keeps to itself, but judge one a spec exports (`export const`, `export { }`, `export default`); `rest-route-declared` never counts a table made in a spec as the app's declared table. The same stub table in a production file stays refused.
5
+ - Changed: R95 requires named zod input/output schema exports for every `defineOperations` entry (`operation-table-schemas`) and a canon `OperationValidationPolicy` constructed with the same table on the route body (`operation-route-driven-by-table`). Schema provenance and route binding are proved in both editions, including strict empty inputs, aliases, mismatched tables and local imitations.
6
+
7
+ ## 3.4.41 - 2026-10-25
8
+ - Changed: the bundled canon pins select @starci/stylelint-canon 2.0.11, @starci/hfs 4.7.33 and @starci/cli 1.7.33.
9
+
3
10
  ## 3.4.40 - 2026-10-25
4
11
  - Changed: the bundled canon pins select @starci/grammar 0.11.0, @starci/hfs 4.7.32 and @starci/cli 1.7.32.
5
12
 
package/README.md CHANGED
@@ -53,14 +53,14 @@ A sample, not the list:
53
53
  | **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`) |
54
54
  | **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`) |
55
55
  | **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`) |
56
- | **Boundary schema** (R304, R305) | `zod` reads only boundary data that never reaches a DTO (a provider response, model output, a queue or outbox payload read back): it is imported only from a `<name>.schema.ts` of a domain, platform or integrations owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports no `class-validator`, `class-transformer` or `@nestjs/graphql` (`schema-place`); every string, array, set, record and map of a schema has an upper bound and `z.any()`, `z.unknown()` and `z.json()` do not exist (`schema-bounded`) |
56
+ | **Boundary schema** (R304, R305) | `zod` reads only boundary data that never reaches a DTO (a provider response, model output, a queue or outbox payload read back): it is imported only from a `<name>.schema.ts` of a domain, platform or integrations owner, never in a feature, a transport door or a `dto/` folder, and a schema file imports no `class-validator`, `class-transformer` or `@nestjs/graphql` (`schema-place`) and holds only zod values and types (`schema-file-values-only`; its spec is exempt from the testing module of R281 only while this holds); every string, array, set, record and map of a schema has an upper bound and `z.any()`, `z.unknown()` and `z.json()` do not exist (`schema-bounded`) |
57
57
  | **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 |
58
58
  | **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) |
59
59
  | **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`) |
60
60
  | **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/api/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`. |
61
61
  | **Data access** | The database is reached through the shared `EntityManager` only, received as a constructor parameter with the named injector of a declared connection - a lookalike name, property injection, an injected `DataSource` or `QueryRunner` outside `platform/database` and the cli (`apps/cli`, `src/features/cli/`), `getRepository` and repository types are refused, specs included (`must-inject-entity-manager`, `named-entity-manager-only`, `no-injected-repository`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner` whatever it is called, and no exported function takes an `EntityManager` first (a statement module); `src/tests` is exempt (`no-repository-class`); a `DataSource` or `QueryRunner` is not written as a type, awaited value or `provide:` token outside those places, and a class that receives a wrapper exposing one is refused (`named-entity-manager-only`); a class outside an application handler, a domain service and a platform persistence capability takes, holds or returns no `EntityManager`, `Repository`, `DataSource` or `QueryRunner`, whatever it is called, and no exported function takes an `EntityManager` first (a statement module); the test tree (`src/tests`) is exempt (`no-repository-class`); inside a transaction callback only the manager it received is used (`no-outer-manager-in-transaction`); no call through an integration, `HttpClient` or `MessagePublisher` typed receiver spans a transaction (`no-external-call-in-transaction`); the manager is injected only in application handlers, domain services and the platform database, inbox, outbox and lease capabilities (`em-injection-slots`) |
62
62
  | **Connections** | One database is one connection and one `Inject<Conn>EntityManager()` injector: `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in `platform/database/<conn>.decorators.ts`, the cli and the test world; an exported EntityManager injector is exactly the declared connection's; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and the cli; the connection name is written once, in its `<conn>.connection.ts`; a `<conn>.config.ts` reads only keys of its `envPrefix` (`one-connection-per-database`) |
63
- | **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>`, binds that body to `new OperationValidationService(RequestClass)` of `platform/http-security` and answers `Promise<OperationReply<Table>>` of one table (`operation-contract-decidable`, `operation-route-driven-by-table`) |
63
+ | **Operations** | A versioned operation is declared once in the app's typed operation table (`query({ input, output, refusal })` / `mutation(...)` registered by `defineOperations`, declared by the platform capability `operations`): bounded input and output schemas come from an owner's `*.schema.ts` file, their inferred types are closed, and refusal codes form a closed union of string literals. A route takes `OperationRequest<Table>`, validates it with the canon `OperationValidationPolicy` constructed from that same table, and answers `Promise<OperationReply<Table>>` (`operation-contract-decidable`, `operation-table-schemas`, `operation-route-driven-by-table`) |
64
64
  | **Webhooks and realtime** | A provider webhook door injects only the `WebhookSignatureService` and domain services, verifies first (awaited when async), and hands the POST delivery to exactly one domain intake that records the inbox receipt and outbox event; a domain method may combine proof and intake. The POST returns nothing or the own-feature exported literal `PROVIDER_ACK`. One optional GET challenge handler returns only its declared domain verifier's result. The door and HTTP module import or inject no `CommandBus`, `QueryBus` or feature command handler, and its resolved domain intake dispatches no command/query bus or feature handler; a reactor or job dispatches after commit (`webhook-shape`, `webhook-verify-first`, `feature-thin`). A realtime door is one gateway or subscription that injects only the `RealtimeHub` and subscribes to a topic built from its `@CurrentPrincipal()` (`realtime-shape`, `realtime-read-only`, `realtime-topic-scope`) |
65
65
  | **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`) |
66
66
  | **Feature thin** (R203 BE_FEATURE_THIN) | A feature-tier file of a thin role (`<name>.handler.ts`, `.resolver.ts`, `.controller.ts`, `.consumer.ts`, `.processor.ts`, `.step.ts`, `.saga.ts`, `.saga-step.ts`, `.compensation.ts`, `.webhook.ts`, `.gateway.ts`, `.subscription.ts`, `.cli.ts`, `.mapper.ts`, `.service.ts`) maps its parameters and makes exactly one delegating call into the modules - an injected member, a bus `execute` or a modules function in statement position - with no branch, loop, `try`, `throw` or computation; free calls are pure `*.mapper.ts` functions, the `Logger` port and a webhook's first verification call when followed by one separate intake (a combined domain verification and intake call counts as the delegation). A webhook POST may return its declared literal `PROVIDER_ACK`; its optional GET challenge has one domain verifier call and echoes only that result. Webhook doors and their HTTP modules reach no command/query bus or feature command handler; orchestration (step order, compensation, claim, fencing, retry) belongs to the platform (`feature-thin`) |
@@ -17,16 +17,17 @@
17
17
  import { hfsOf } from "./lib/hfs.mjs"
18
18
  import { normalizePath } from "./lib/path.mjs"
19
19
  import { isTransportSlot } from "./lib/transport-slots.mjs"
20
+ import { SCHEMA_TIERS, schemaFileProblems } from "./lib/schema-values.mjs"
20
21
 
21
22
  const ZOD = /^zod(?:\/|$)/
22
23
  /** The imports of the other validation layer, which never share a file with a schema. */
23
24
  const MIXED = new Set(["class-validator", "class-transformer", "@nestjs/graphql"])
24
25
  const SCHEMA_FILE = /\.schema(?:\.spec)?\.ts$/
25
- /** Tiers a schema file may live in: the owners of boundary data. */
26
- const SCHEMA_TIERS = new Set(["domain", "platform", "integrations"])
27
26
 
28
27
  /** Constructors that build a string-valued schema and owe a `.max(n)` or `.length(n)`. */
29
- const STRINGY = new Set(["string", "email", "url", "httpUrl", "hostname", "base64", "base64url", "jwt", "emoji", "hex"])
28
+ const STRINGY = new Set(["string", "email", "url", "httpUrl", "hostname", "base64", "base64url", "jwt", "emoji", "hex", "iso.datetime", "iso.date", "iso.time", "iso.duration"])
29
+ /** The ISO namespace constructs strings; `z.date()` instead constructs a Date-valued schema. */
30
+ const ISO_STRINGY = new Set(["datetime", "date", "time", "duration"])
30
31
  /** Constructors that build a counted container: bounded by `.max(n)`, `.length(n)` or `.size(n)`. */
31
32
  const COUNTED = new Set(["array", "set"])
32
33
  /** Constructors whose entry count only a refinement can bound. */
@@ -66,11 +67,16 @@ const chainOf = (node) => {
66
67
  return calls
67
68
  }
68
69
 
69
- /** Resolves a callee to the zod constructor it names, or null: `z.string`, `z.coerce.string`, or a named import `string`. */
70
+ /** Resolves a zod constructor, including `z.iso.datetime()` and a renamed named import of `iso`. */
70
71
  const constructorOf = (callee, namespaces, named) => {
71
72
  if (callee.type === "Identifier") return named.get(callee.name) ?? null
72
73
  if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return null
73
74
  let object = callee.object
75
+ if (ISO_STRINGY.has(callee.property.name)) {
76
+ const importedIso = object.type === "Identifier" && named.get(object.name) === "iso"
77
+ const namespaceIso = object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier" && object.property.name === "iso" && object.object.type === "Identifier" && namespaces.has(object.object.name)
78
+ if (importedIso || namespaceIso) return `iso.${callee.property.name}`
79
+ }
74
80
  while (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier" && PASS_THROUGH.has(object.property.name)) object = object.object
75
81
  return object.type === "Identifier" && namespaces.has(object.name) ? callee.property.name : null
76
82
  }
@@ -184,14 +190,36 @@ export const schemaPlace = {
184
190
  },
185
191
  }
186
192
 
193
+ /** What a schema file holds: zod values and types, nothing that runs. */
194
+ export const schemaFileValuesOnly = {
195
+ meta: {
196
+ type: "problem",
197
+ docs: { description: "A schema file imports zod and declares only zod schemas and their types: no class, provider, function or other logic." },
198
+ schema: [],
199
+ messages: {
200
+ logic: "[BE_SCHEMA_PLACE] This schema file is not a boundary schema: {{problem}}. A schema file imports zod and declares only zod values and their inferred types; logic belongs in a service, policy or mapper under its own role suffix.",
201
+ },
202
+ },
203
+ create(context) {
204
+ if (!SCHEMA_FILE.test(normalizePath(context.filename)) || context.filename.endsWith(".spec.ts")) return {}
205
+ return {
206
+ "Program:exit"(node) {
207
+ for (const problem of schemaFileProblems(context.sourceCode.text)) context.report({ node, messageId: "logic", data: { problem } })
208
+ },
209
+ }
210
+ },
211
+ }
212
+
187
213
  /** The rules this law contributes to the plugin. */
188
214
  export const rules = {
189
215
  "schema-bounded": schemaBounded,
190
216
  "schema-place": schemaPlace,
217
+ "schema-file-values-only": schemaFileValuesOnly,
191
218
  }
192
219
 
193
220
  /** Both start at error: no baseline exists. */
194
221
  export const recommended = {
195
222
  "starci-be/schema-bounded": "error",
196
223
  "starci-be/schema-place": "error",
224
+ "starci-be/schema-file-values-only": "error",
197
225
  }
package/idempotency.mjs CHANGED
@@ -29,6 +29,7 @@ import { walk } from "./lib/ast.mjs"
29
29
  import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
30
30
  import { resolveVariable } from "./lib/transactions.mjs"
31
31
  import { isPackageType, typed } from "./lib/types.mjs"
32
+ import { mutationIdempotent } from "./lib/client-idempotency.mjs"
32
33
 
33
34
  /** The `Inbox` port of `platform/inbox`. */
34
35
  const isInboxType = (context, node) => isOwnedType(context, node, { name: "Inbox", capability: "inbox", tier: "platform" })
@@ -175,9 +176,11 @@ export const inboxDedupeRequired = {
175
176
  /** The rule this law contributes to the plugin. */
176
177
  export const rules = {
177
178
  "inbox-dedupe-required": inboxDedupeRequired,
179
+ "mutation-idempotent": mutationIdempotent,
178
180
  }
179
181
 
180
182
  /** Starts at error: a duplicate charge or a duplicate side effect is never a warning. */
181
183
  export const recommended = {
182
184
  "starci-be/inbox-dedupe-required": "error",
185
+ "starci-be/mutation-idempotent": "error",
183
186
  }
package/index.mjs CHANGED
@@ -62,6 +62,7 @@ import { recommended as testWorldRecommended, rules as testWorldRules } from "./
62
62
  import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
63
63
  import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
64
64
  import { recommended as unitSpecRecommended, rules as unitSpecRules } from "./unit-spec.mjs"
65
+ import { recommended as uploadsRecommended, rules as uploadsRules } from "./uploads.mjs"
65
66
  import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
66
67
  import { recommended as webhooksRecommended, rules as webhooksRules } from "./webhooks.mjs"
67
68
  import { recommended as realtimeRecommended, rules as realtimeRules } from "./realtime.mjs"
@@ -116,6 +117,7 @@ const CONTRIBUTIONS = [
116
117
  { law: "transport", rules: transportRules, recommended: transportRecommended },
117
118
  { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
118
119
  { law: "unit-spec", rules: unitSpecRules, recommended: unitSpecRecommended },
120
+ { law: "uploads", rules: uploadsRules, recommended: uploadsRecommended },
119
121
  { law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
120
122
  { law: "webhooks", rules: webhooksRules, recommended: webhooksRecommended },
121
123
  { law: "realtime", rules: realtimeRules, recommended: realtimeRecommended },
package/infra-owner.mjs CHANGED
@@ -13,8 +13,9 @@
13
13
  *
14
14
  * The test world (slot `be.tests.world`, `src/tests/world`) is an infrastructure owner like the platform capabilities: it is
15
15
  * the TEST COMPOSITION ROOT that starts docker, polls readiness, serves the network-edge fakes over `node:http` and drives the
16
- * apps over HTTP with a raw client, and it has no Nest container to inject a port from. It may use every library the
17
- * table gives an owner; a library the table owns by nobody (`[]`, `nowhere`) is still refused there. A spec does not
16
+ * apps over HTTP with a raw client, and it has no Nest container to inject a port from. It may use libraries the
17
+ * table gives an owner except logging infrastructure, whose ambient state stays inside its sole owner. A library the
18
+ * table owns by nobody (`[]`, `nowhere`) is still refused there. A spec does not
18
19
  * compose: specs stay refused and reach infrastructure through `useTestWorld(...)`.
19
20
  *
20
21
  * `no-framework-logger` (R40) and `no-ambient-clock` (R79) judge Nest `Logger` and `Date`; the table holds neither.
@@ -64,7 +65,7 @@ export const infraImportOwner = {
64
65
  const isWorld = inTestWorld(hfs, context.filename)
65
66
  const check = (node, name, key) => {
66
67
  const owners = table[key]
67
- if (owners.includes(here) || (isWorld && owners.length > 0)) return
68
+ if (owners.includes(here) || (isWorld && owners.length > 0 && !owners.includes("platform/logging"))) return
68
69
  if (owners.length === 0) context.report({ node, messageId: "nowhere", data: { name } })
69
70
  else context.report({ node, messageId: "foreign", data: { name, owners: owners.map((owner) => `\`${owner}\``).join(", ") } })
70
71
  }
@@ -0,0 +1,403 @@
1
+ /** BE-IDEMPOTENCY-3: marked client commands preserve their key and execute through the transactional inbox. */
2
+ import ts from "typescript"
3
+ import { hfsOf } from "./hfs.mjs"
4
+ import { baseName, ownerNameOf } from "./ports.mjs"
5
+ import { moduleOf, originsOfType, typed } from "./types.mjs"
6
+ import { isTransportSlot } from "./transport-slots.mjs"
7
+
8
+ /** Imported aliases and namespace access resolve to the declaring value. */
9
+ const valueSymbol = (checker, expression, seen = new Set()) => {
10
+ if (!expression || seen.has(expression)) return undefined
11
+ seen.add(expression)
12
+ const name = ts.isPropertyAccessExpression(expression) ? expression.name : expression
13
+ const symbol = name.parent && ts.isShorthandPropertyAssignment(name.parent) && name.parent.name === name
14
+ ? checker.getShorthandAssignmentValueSymbol(name.parent) : checker.getSymbolAtLocation(name)
15
+ const target = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
16
+ const alias = target?.getDeclarations()?.find((declaration) => ts.isVariableDeclaration(declaration)
17
+ && declaration.parent.flags & ts.NodeFlags.Const && declaration.initializer
18
+ && (ts.isIdentifier(declaration.initializer) || ts.isPropertyAccessExpression(declaration.initializer)))
19
+ return alias ? valueSymbol(checker, alias.initializer, seen) : target
20
+ }
21
+
22
+ /** The genuine decorator belongs to its platform capability, rather than merely sharing a name. */
23
+ const ownedValue = (checker, hfs, expression, name, capability) => {
24
+ const symbol = valueSymbol(checker, expression)
25
+ return symbol?.name === name && symbol.getDeclarations()?.some((declaration) => {
26
+ const file = declaration.getSourceFile().fileName
27
+ return moduleOf(declaration, file) === null && hfs.tierOf(file) === "platform" && ownerNameOf(hfs, file) === capability
28
+ })
29
+ }
30
+
31
+ /** A resolved decorator declaration and its call arguments. */
32
+ const decorators = (checker, node) => (ts.getDecorators(node) ?? []).map((decorator) => {
33
+ const call = decorator.expression
34
+ const expression = ts.isCallExpression(call) ? call.expression : call
35
+ const symbol = valueSymbol(checker, expression)
36
+ return { expression, symbol, args: ts.isCallExpression(call) ? call.arguments : [], called: ts.isCallExpression(call) }
37
+ })
38
+
39
+ /** Whether the class carries the platform inbox's effectful marker. */
40
+ const effectful = (checker, hfs, node) => decorators(checker, node).some((decorator) => decorator.called
41
+ && decorator.args.length === 0 && ownedValue(checker, hfs, decorator.expression, "Effectful", "inbox"))
42
+
43
+ /** A package declaration establishes framework identity even through an import alias. */
44
+ const fromPackage = (checker, expression, name, pkg) => {
45
+ const symbol = valueSymbol(checker, expression)
46
+ return symbol?.name === name && symbol.getDeclarations()?.some((declaration) => moduleOf(declaration, declaration.getSourceFile().fileName) === pkg)
47
+ }
48
+
49
+ /** Class inheritance keeps the Nest Command identity without relying on local spelling. */
50
+ const commandType = (checker, type, seen = new Set()) => {
51
+ if (!type || seen.has(type)) return false
52
+ seen.add(type)
53
+ if (originsOfType(checker, type).some((origin) => origin.name === "Command" && origin.module === "@nestjs/cqrs")) return true
54
+ return type.isClassOrInterface() && (checker.getBaseTypes(type) ?? []).some((base) => commandType(checker, base, seen))
55
+ }
56
+
57
+ /** Required strings exclude optional properties, undefined, null and unconstrained types. */
58
+ const requiredString = (checker, type, property) => {
59
+ const member = checker.getPropertyOfType(type, property)
60
+ if (!member || member.flags & ts.SymbolFlags.Optional) return false
61
+ const declaration = member.valueDeclaration ?? member.declarations?.[0]
62
+ if (!declaration) return false
63
+ const value = checker.getTypeOfSymbolAtLocation(member, declaration)
64
+ return (value.isUnion() ? value.types : [value]).every((part) => !!(part.flags & ts.TypeFlags.StringLike))
65
+ }
66
+
67
+ /** Canonical ExecuteParams carries the request, while a direct client params object carries its own key. */
68
+ const keyContract = (checker, type) => {
69
+ if (requiredString(checker, type, "idempotencyKey")) return true
70
+ const request = checker.getPropertyOfType(type, "request")
71
+ const declaration = request?.valueDeclaration ?? request?.declarations?.[0]
72
+ return !!declaration && !(request.flags & ts.SymbolFlags.Optional)
73
+ && requiredString(checker, checker.getTypeOfSymbolAtLocation(request, declaration), "idempotencyKey")
74
+ }
75
+
76
+ /** A sole returned expression admits an optional await and inline expression-bodied callbacks. */
77
+ const returned = (body) => {
78
+ if (!body) return null
79
+ let expression = ts.isBlock(body) ? body.statements.length === 1 && ts.isReturnStatement(body.statements[0]) ? body.statements[0].expression : null : body
80
+ while (expression && ts.isParenthesizedExpression(expression)) expression = expression.expression
81
+ return expression && ts.isAwaitExpression(expression) ? expression.expression : expression
82
+ }
83
+
84
+ /** Only a literal member name has a statically proved operation. */
85
+ const memberName = (expression) => ts.isPropertyAccessExpression(expression) ? expression.name.text : null
86
+
87
+ /** Computed literal names have ordinary last-write semantics; dynamic names could overwrite any field. */
88
+ const propertyName = (name) => ts.isComputedPropertyName(name)
89
+ ? ts.isStringLiteralLike(name.expression) ? name.expression.text : null
90
+ : ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : null
91
+
92
+ /** Property mappings retain their last assignment, including spreads, as JavaScript does. */
93
+ const propertyValue = (checker, expression, name) => {
94
+ if (!expression) return null
95
+ if (!ts.isObjectLiteralExpression(expression)) return checker.getPropertyOfType(checker.getTypeAtLocation(expression), name) ? { base: expression, name } : null
96
+ let result = null
97
+ for (const property of expression.properties) {
98
+ if (ts.isSpreadAssignment(property)) {
99
+ const type = checker.getTypeAtLocation(property.expression)
100
+ if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown) || checker.getIndexTypeOfType(type, ts.IndexKind.String)) result = null
101
+ else if (checker.getPropertyOfType(type, name)) result = { base: property.expression, name }
102
+ } else if (propertyName(property.name) === null) result = null
103
+ else if (propertyName(property.name) === name) {
104
+ result = ts.isPropertyAssignment(property) ? { expression: property.initializer }
105
+ : ts.isShorthandPropertyAssignment(property) ? { expression: property.name } : null
106
+ }
107
+ }
108
+ return result
109
+ }
110
+
111
+ /** A property path is rooted in the same lexical parameter, so a renamed or shadowed value cannot invent a key. */
112
+ const parameterPath = (checker, expression, parameter) => {
113
+ const path = []
114
+ let current = expression
115
+ while (current && ts.isPropertyAccessExpression(current) && !current.questionDotToken) {
116
+ path.unshift(current.name.text)
117
+ current = current.expression
118
+ }
119
+ return current && ts.isIdentifier(current) && valueSymbol(checker, current) === valueSymbol(checker, parameter.name) ? path : null
120
+ }
121
+
122
+ /** A mapped field has the expected path from the original parameter. */
123
+ const mappedFrom = (checker, mapping, parameter, paths) => {
124
+ if (!mapping) return false
125
+ const path = parameterPath(checker, mapping.expression ?? mapping.base, parameter)
126
+ if (!path) return false
127
+ if (mapping.name) path.push(mapping.name)
128
+ return paths.some((expected) => path.length === expected.length && path.every((part, index) => part === expected[index]))
129
+ }
130
+
131
+ /** Type origins bind the transactional manager and inbox port to their actual owners. */
132
+ const hasOrigin = (checker, hfs, expression, name, pkg, capability) => !!expression && originsOfType(checker, checker.getTypeAtLocation(expression)).some((origin) =>
133
+ origin.name === name && origin.module === pkg && (!capability || hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === capability))
134
+
135
+ /** Rejects visible calls, writes, constructions and explicit getter accesses before the inbox claim. */
136
+ const pureEnvelope = (checker, expression) => {
137
+ let pure = true
138
+ const visit = (node) => {
139
+ if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isAwaitExpression(node)
140
+ || ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
141
+ || ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)
142
+ || ts.isPostfixUnaryExpression(node)) pure = false
143
+ if (ts.isPropertyAccessExpression(node) && valueSymbol(checker, node)?.getDeclarations()?.some(ts.isGetAccessorDeclaration)) pure = false
144
+ ts.forEachChild(node, visit)
145
+ }
146
+ visit(expression)
147
+ return pure
148
+ }
149
+
150
+ /** The service returns its transaction, whose actual callback returns the inbox execution with that callback's own manager. */
151
+ const transactionalClient = (checker, hfs, method) => {
152
+ const transaction = returned(method.body)
153
+ if (!transaction || !ts.isCallExpression(transaction) || transaction.questionDotToken || transaction.expression.questionDotToken || memberName(transaction.expression) !== "transaction"
154
+ || !hasOrigin(checker, hfs, transaction.expression.expression, "EntityManager", "typeorm")) return false
155
+ const first = transaction.arguments[0]
156
+ const callback = transaction.arguments.length === 1 ? first : transaction.arguments.length === 2
157
+ && ts.isStringLiteral(first) && ["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"].includes(first.text) ? transaction.arguments[1] : null
158
+ if (!callback || ![ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.FunctionExpression].includes(callback.kind)
159
+ || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name)) return false
160
+ const execution = returned(callback.body)
161
+ if (!execution || !ts.isCallExpression(execution) || execution.questionDotToken || execution.expression.questionDotToken || memberName(execution.expression) !== "runClient"
162
+ || !hasOrigin(checker, hfs, execution.expression.expression, "Inbox", null, "inbox") || execution.arguments.length !== 3) return false
163
+ const [envelope, manager, effect] = execution.arguments
164
+ const original = method.parameters[0]
165
+ if (!original || !ts.isIdentifier(original.name) || !requiredString(checker, checker.getTypeAtLocation(original), "idempotencyKey")
166
+ || !ts.isIdentifier(manager) || valueSymbol(checker, manager) !== valueSymbol(checker, callback.parameters[0].name)
167
+ || !hasOrigin(checker, hfs, manager, "EntityManager", "typeorm") || !ts.isObjectLiteralExpression(envelope)
168
+ || ![ts.SyntaxKind.ArrowFunction, ts.SyntaxKind.FunctionExpression].includes(effect.kind) || effect.parameters.length !== 0
169
+ || !pureEnvelope(checker, envelope)) return false
170
+ const key = propertyValue(checker, envelope, "idempotencyKey")
171
+ const principal = propertyValue(checker, envelope, "principalId")
172
+ const operation = propertyValue(checker, envelope, "operation")?.expression
173
+ return mappedFrom(checker, key, original, [["idempotencyKey"]]) && !!principal?.expression
174
+ && (parameterPath(checker, principal.expression, original)?.length ?? 0) > 0
175
+ && !!operation && ts.isStringLiteralLike(operation) && operation.text.length > 0
176
+ && !!propertyValue(checker, envelope, "request")
177
+ }
178
+
179
+ /** The CommandHandler decorator resolves its marked command declaration. */
180
+ const handledCommand = (checker, hfs, node) => {
181
+ for (const decorator of decorators(checker, node)) {
182
+ if (!fromPackage(checker, decorator.expression, "CommandHandler", "@nestjs/cqrs") || decorator.args.length !== 1) continue
183
+ const command = valueSymbol(checker, decorator.args[0])?.getDeclarations()?.find(ts.isClassDeclaration)
184
+ if (command && effectful(checker, hfs, command)) return command
185
+ }
186
+ return null
187
+ }
188
+
189
+ /** A marked handler delegates once to a concrete domain service and preserves the request's key. */
190
+ const delegatedMethod = (checker, hfs, handler) => {
191
+ const process = handler.members.find((member) => ts.isMethodDeclaration(member) && member.name.getText() === "process")
192
+ const call = returned(process?.body)
193
+ if (!process || process.parameters.length !== 1 || !ts.isIdentifier(process.parameters[0].name)
194
+ || !call || !ts.isCallExpression(call) || call.questionDotToken || call.expression.questionDotToken || call.arguments.length !== 1) return null
195
+ const method = checker.getResolvedSignature(call)?.declaration
196
+ if (!method || !ts.isMethodDeclaration(method) || !method.body
197
+ || hfs.tierOf(method.getSourceFile().fileName) !== "domain" || !baseName(method.getSourceFile().fileName).endsWith(".service.ts")) return null
198
+ const mapping = propertyValue(checker, call.arguments[0], "idempotencyKey")
199
+ return mappedFrom(checker, mapping, process.parameters[0], [["params", "idempotencyKey"], ["params", "request", "idempotencyKey"]]) ? method : null
200
+ }
201
+
202
+ /** Every reached service method is judged when that file is linted, including changed domain files. */
203
+ const reachedByProgram = new WeakMap()
204
+ const reachedServices = (checker, program, hfs) => {
205
+ const cached = reachedByProgram.get(program)
206
+ if (cached) return cached
207
+ const methods = new Set()
208
+ for (const source of program.getSourceFiles()) {
209
+ if (hfs.slotOf(source.fileName) !== "be.feature.application") continue
210
+ const visit = (node) => {
211
+ if (ts.isClassDeclaration(node) && handledCommand(checker, hfs, node)) {
212
+ const method = delegatedMethod(checker, hfs, node)
213
+ if (method) methods.add(method)
214
+ }
215
+ ts.forEachChild(node, visit)
216
+ }
217
+ visit(source)
218
+ }
219
+ reachedByProgram.set(program, methods)
220
+ return methods
221
+ }
222
+
223
+ /** Genuine validators must reject empty, optional and overlong wire keys. */
224
+ const boundedKey = (checker, declaration) => {
225
+ if (!declaration || !ts.isPropertyDeclaration(declaration) || declaration.questionToken
226
+ || propertyName(declaration.name) === null
227
+ || !requiredString(checker, checker.getTypeAtLocation(declaration.parent), propertyName(declaration.name))) return false
228
+ const validators = decorators(checker, declaration).filter((decorator) => decorator.called && decorator.symbol?.getDeclarations()?.some((item) => moduleOf(item, item.getSourceFile().fileName) === "class-validator"))
229
+ const has = (name) => validators.some((decorator) => decorator.symbol.name === name)
230
+ const bound = (name, accepts) => validators.some((decorator) => decorator.symbol.name === name
231
+ && decorator.args.length > 0 && ts.isNumericLiteral(decorator.args[0]) && accepts(Number(decorator.args[0].text)))
232
+ return !has("IsOptional") && !has("ValidateIf") && (has("IsUUID") || has("IsString")
233
+ && bound("MinLength", (value) => value >= 1) && bound("MaxLength", (value) => value >= 1 && value <= 64))
234
+ }
235
+
236
+ /** Follow object mappings to their original property declarations rather than trusting an annotated return type. */
237
+ const propertySymbol = (checker, expression, path) => {
238
+ if (path.length === 0) return valueSymbol(checker, expression)
239
+ if (ts.isObjectLiteralExpression(expression)) {
240
+ const mapping = propertyValue(checker, expression, path[0])
241
+ if (!mapping) return undefined
242
+ return mapping.expression ? propertySymbol(checker, mapping.expression, path.slice(1))
243
+ : propertySymbol(checker, mapping.base, [mapping.name, ...path.slice(1)])
244
+ }
245
+ let type = checker.getTypeAtLocation(expression)
246
+ let symbol
247
+ for (const name of path) {
248
+ symbol = checker.getPropertyOfType(type, name)
249
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
250
+ if (!declaration) return undefined
251
+ type = checker.getTypeOfSymbolAtLocation(symbol, declaration)
252
+ }
253
+ return symbol
254
+ }
255
+
256
+ /** A key mapped by a pure function must come from its actual argument, never a constant or captured replacement. */
257
+ const mappedKeySymbol = (checker, mapping, bindings = new Map()) => {
258
+ if (!mapping) return undefined
259
+ const path = mapping.name ? [mapping.name] : []
260
+ let expression = mapping.expression ?? mapping.base
261
+ while (ts.isPropertyAccessExpression(expression) && !expression.questionDotToken) {
262
+ path.unshift(expression.name.text)
263
+ expression = expression.expression
264
+ }
265
+ const actual = bindings.get(valueSymbol(checker, expression))
266
+ if (actual) return propertySymbol(checker, actual, path)
267
+ return bindings.size > 0 ? undefined : propertySymbol(checker, expression, path)
268
+ }
269
+
270
+ /** A mapper is proved by its sole pure return expression and original argument bindings, not its function name. */
271
+ const requestKey = (checker, expression) => {
272
+ if (!ts.isCallExpression(expression)) return mappedKeySymbol(checker, propertyValue(checker, expression, "idempotencyKey"))
273
+ const mapper = checker.getResolvedSignature(expression)?.declaration
274
+ const body = returned(mapper?.body)
275
+ if (!mapper || !body || expression.questionDotToken || mapper.parameters.length !== expression.arguments.length
276
+ || mapper.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)
277
+ || !pureEnvelope(checker, body) || expression.arguments.some((argument) => !pureEnvelope(checker, argument))) return undefined
278
+ const bindings = new Map()
279
+ for (const [index, parameter] of mapper.parameters.entries()) {
280
+ if (!ts.isIdentifier(parameter.name) || parameter.initializer || parameter.dotDotDotToken) return undefined
281
+ bindings.set(valueSymbol(checker, parameter.name), expression.arguments[index])
282
+ }
283
+ return mappedKeySymbol(checker, propertyValue(checker, body, "idempotencyKey"), bindings)
284
+ }
285
+
286
+ /** Follow the constructor's key mapping to the original DTO property, including a whole ExecuteParams value. */
287
+ const constructionKey = (checker, construction) => {
288
+ const params = construction.arguments?.[0]
289
+ const request = propertyValue(checker, params, "request")
290
+ let nestedSymbol
291
+ if (request?.base) {
292
+ const property = checker.getPropertyOfType(checker.getTypeAtLocation(request.base), request.name)
293
+ const declaration = property?.valueDeclaration ?? property?.declarations?.[0]
294
+ if (declaration) nestedSymbol = checker.getPropertyOfType(checker.getTypeOfSymbolAtLocation(property, declaration), "idempotencyKey")
295
+ }
296
+ return nestedSymbol ?? (request?.expression ? requestKey(checker, request.expression)
297
+ : mappedKeySymbol(checker, propertyValue(checker, params, "idempotencyKey")))
298
+ }
299
+
300
+ /** Only DTO key declarations that reach an effectful command carry this rule's transport obligation. */
301
+ const keysByProgram = new WeakMap()
302
+ const reachedKeys = (checker, program, hfs) => {
303
+ const cached = keysByProgram.get(program)
304
+ if (cached) return cached
305
+ const keys = new Set()
306
+ for (const source of program.getSourceFiles()) {
307
+ if (!isTransportSlot(hfs.slotOf(source.fileName))) continue
308
+ const visit = (node) => {
309
+ if (ts.isNewExpression(node)) {
310
+ const command = valueSymbol(checker, node.expression)?.getDeclarations()?.find(ts.isClassDeclaration)
311
+ if (command && effectful(checker, hfs, command)) {
312
+ for (const declaration of constructionKey(checker, node)?.getDeclarations() ?? []) keys.add(declaration)
313
+ }
314
+ }
315
+ ts.forEachChild(node, visit)
316
+ }
317
+ visit(source)
318
+ }
319
+ keysByProgram.set(program, keys)
320
+ return keys
321
+ }
322
+
323
+ /** Effectful commands carry a bounded client key to the one atomic domain inbox execution. */
324
+ export const mutationIdempotent = {
325
+ meta: {
326
+ type: "problem",
327
+ docs: { description: "An @Effectful command requires a validated client key and delegates to a domain transaction returning Inbox.runClient with that same key and manager." },
328
+ schema: [],
329
+ messages: {
330
+ marker: "[BE_MUTATION_IDEMPOTENT] Use @Effectful() exported by platform/inbox on a Nest Command; a local lookalike or a marker on a query does not designate an effectful command.",
331
+ declaration: "[BE_MUTATION_IDEMPOTENT] This @Effectful command requires idempotency in hfs.json sides.be.patterns, including its required behavior scenarios in the app's proof lane.",
332
+ key: "[BE_MUTATION_IDEMPOTENT] This @Effectful command requires a non-optional string idempotencyKey in its params request. Preserve the client's key through transport and handler mapping.",
333
+ handler: "[BE_MUTATION_IDEMPOTENT] This effectful command handler must return one domain service call carrying the original params.request.idempotencyKey. The service owns its transaction and the inbox claim.",
334
+ transaction: "[BE_MUTATION_IDEMPOTENT] This effectful domain method must return an EntityManager transaction whose inline callback returns Inbox.runClient({principalId, operation, idempotencyKey, request}, callbackManager, async () => outcome). Use the original key and callback manager, and put every effect in the final callback.",
335
+ validation: "[BE_MUTATION_IDEMPOTENT] The client idempotencyKey must be required and validated by class-validator: @IsUUID(), or @IsString() @MinLength(1) @MaxLength(64). Optional or conditional validation cannot protect an effectful command.",
336
+ },
337
+ },
338
+ create(context) {
339
+ const hfs = hfsOf(context)
340
+ const slot = hfs.slotOf(context.filename)
341
+ if (!slot || slot.startsWith("be.tests.")) return {}
342
+ const { checker, program, toTs } = typed(context)
343
+ let reached
344
+ let keys
345
+ return {
346
+ Identifier(node) {
347
+ if (hfs.tierOf(context.filename) === "platform" && ownerNameOf(hfs, context.filename) === "inbox"
348
+ || !ownedValue(checker, hfs, toTs(node), "Effectful", "inbox")) return
349
+ const parent = node.parent
350
+ const typeOnly = parent?.type?.startsWith("TS") && !["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "TSInstantiationExpression"].includes(parent.type)
351
+ if (["ImportSpecifier", "ExportSpecifier", "Decorator"].includes(parent?.type) || typeOnly) return
352
+ if (parent?.type === "VariableDeclarator" && parent.id === node) return
353
+ if (parent?.type === "Property" && parent.key === node && !parent.computed && parent.value !== node) return
354
+ const reference = parent?.type === "MemberExpression" && parent.property === node ? parent : node
355
+ if (reference.parent?.type === "CallExpression" && reference.parent.callee === reference) return
356
+ if (reference.parent?.type === "VariableDeclarator" && reference.parent.init === reference && reference.parent.parent.kind === "const") return
357
+ context.report({ node, messageId: "marker" })
358
+ },
359
+ CallExpression(node) {
360
+ const call = toTs(node)
361
+ if (ownedValue(checker, hfs, call.expression, "Effectful", "inbox") && node.parent?.type !== "Decorator") context.report({ node, messageId: "marker" })
362
+ },
363
+ ClassDeclaration(node) {
364
+ const declaration = toTs(node)
365
+ const marks = decorators(checker, declaration).filter((decorator) => decorator.symbol?.name === "Effectful")
366
+ if (marks.length > 0) {
367
+ if (!effectful(checker, hfs, declaration) || !commandType(checker, checker.getTypeAtLocation(declaration))) {
368
+ context.report({ node: node.id ?? node, messageId: "marker" })
369
+ } else {
370
+ if (!(hfs.patterns ?? []).includes("idempotency")) context.report({ node: node.id ?? node, messageId: "declaration" })
371
+ const constructor = declaration.members.find(ts.isConstructorDeclaration)
372
+ if (!constructor || constructor.parameters.length !== 1 || !keyContract(checker, checker.getTypeAtLocation(constructor.parameters[0]))) context.report({ node: node.id ?? node, messageId: "key" })
373
+ }
374
+ }
375
+ if (!handledCommand(checker, hfs, declaration)) return
376
+ const method = delegatedMethod(checker, hfs, declaration)
377
+ if (!method) context.report({ node: node.id ?? node, messageId: "handler" })
378
+ else if (!transactionalClient(checker, hfs, method)) context.report({ node: node.id ?? node, messageId: "transaction" })
379
+ },
380
+ MethodDefinition(node) {
381
+ if (hfs.tierOf(context.filename) !== "domain" || !baseName(context.filename).endsWith(".service.ts")) return
382
+ reached ??= reachedServices(checker, program, hfs)
383
+ const method = toTs(node)
384
+ if (reached.has(method) && !transactionalClient(checker, hfs, method)) context.report({ node: node.key, messageId: "transaction" })
385
+ },
386
+ PropertyDefinition(node) {
387
+ if (!isTransportSlot(slot)) return
388
+ keys ??= reachedKeys(checker, program, hfs)
389
+ const declaration = toTs(node)
390
+ if (keys.has(declaration) && !boundedKey(checker, declaration)) context.report({ node: node.key, messageId: "validation" })
391
+ },
392
+ NewExpression(node) {
393
+ if (!isTransportSlot(slot)) return
394
+ const construction = toTs(node)
395
+ const command = valueSymbol(checker, construction.expression)?.getDeclarations()?.find(ts.isClassDeclaration)
396
+ if (!command || !effectful(checker, hfs, command)) return
397
+ const declarations = constructionKey(checker, construction)?.getDeclarations() ?? []
398
+ const localProperty = declarations.some((declaration) => ts.isPropertyDeclaration(declaration) && declaration.getSourceFile() === construction.getSourceFile())
399
+ if (!localProperty && !declarations.some((declaration) => boundedKey(checker, declaration))) context.report({ node, messageId: "validation" })
400
+ },
401
+ }
402
+ },
403
+ }
@@ -105,7 +105,7 @@ export const connectionFileOf = (hfs, file, role) => {
105
105
  }
106
106
 
107
107
  /** The platform capabilities that own a `persistence/` and so may hold an `EntityManager` (a pattern capability is named by its folder). */
108
- const PERSISTENCE_CAPABILITIES = ["database", "inbox", "event-bus", "queue", "jobs", "saga", "outbox"]
108
+ const PERSISTENCE_CAPABILITIES = ["database", "inbox", "event-bus", "queue", "jobs", "saga", "outbox", "realtime"]
109
109
 
110
110
  /**
111
111
  * Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a projection, a typed queue producer (it takes the transaction of its caller), a platform persistence capability.