@starci/eslint-canon-be 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
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.
7
+
8
+ ## 1.7.1 - 2026-09-30
9
+
10
+ - Fixed: eslint no longer fails to start in a product repository with `HFS slot manifest not found at <repo>/knowledge/hfs/slots.yaml`. `lib/slots.mjs` used to look for the manifest at a path above the package (the product repository, or a linked runtime copy that no longer exists). The package now depends on `@starci/hfs` and resolves the manifest and the YAML reader as `@starci/hfs/runtime/knowledge/hfs/slots.yaml` and `@starci/hfs/runtime/engine/yaml.mjs`, so what the canon reads travels with the installed packages. `STARCI_HFS_SLOTS` still overrides the manifest file.
11
+
3
12
  ## 1.7.0 - 2026-09-30
4
13
 
5
14
  - New: `named-entity-manager-only` (R83 `BE_UNNAMED_DATA_ACCESS`, in `data-access`): a bare `@InjectEntityManager()` with no connection argument, any `.getRepository(...)` call (on a DataSource, an EntityManager or a transaction manager) and an injected `DataSource` (a constructor parameter typed `DataSource`, `@InjectDataSource()`, `@InjectConnection()`) are refused. A `DataSource` is allowed only under `platform/database/` or `platform/databases/` (also `platform/db`, `platform/datasource`, `platform/typeorm`) and `apps/migrate/`; spec files are skipped. The message says to inject the shared EntityManager with the named injector and call it directly.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @starci/eslint-canon-be
2
2
 
3
- **78 ESLint rules, from 26 laws, that hold a NestJS-shaped back end to one way of being written.**
3
+ **ESLint rules, grouped by law (the count is `Object.keys(rules)`; knowledge/hfs/rules.yaml names the HFS rule each one enforces), that hold a NestJS-shaped back end to one way of being written.**
4
4
 
5
5
  Not a style pack. These rules enforce *architecture*: which layer may import which, whether a
6
6
  failure carries its own identity, where a query is allowed to be built, what an end-to-end test is
@@ -54,7 +54,7 @@ A sample, not the list:
54
54
  | Area | What the rules hold |
55
55
  |---|---|
56
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 only in a persistence repository; no ORM entity in a contract (`no-runtime-schema`, `sql-only-in-repository`, `no-entity-in-contract`) |
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
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
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
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` |
@@ -103,43 +103,7 @@ ESLint 9+ (flat config), Node 20.9+.
103
103
 
104
104
  ## HFS rules and their why codes
105
105
 
106
- Each rule below reports through the code-pattern gate with a catalogued Vietnamese reason (`modules/kernel/failure-codes.yaml`,
107
- mapped by `scripts/checks/lint-why.mjs`). The catalog id is the rule number in `knowledge/hfs/rules.yaml`.
108
-
109
- | Rule | Catalog | Why code |
110
- |---|---|---|
111
- | `catch-must-account` | R40 | `BE_LOGGER_REQUIRED` |
112
- | `error-home` | R38 | `BE_ERROR_HOME` |
113
- | `no-runtime-schema` | R34 | `BE_SCHEMA_AUTHORITY` |
114
- | `sql-only-in-repository` | R36 | `BE_SQL_OUTSIDE_REPOSITORY` |
115
- | `no-entity-in-contract` | R37 | `BE_ENTITY_IN_CONTRACT` |
116
- | `no-untyped-body`, `public-needs-reason`, `secret-compare-timing-safe` | R41 | `BE_DEFAULT_DENY` |
117
- | `no-direct-env-read` | R43 | `BE_CONFIG_OWNER` |
118
- | `no-secret-default` | R44 | `BE_SECRET_DEFAULT` |
119
- | `global-module-allowlist`, `typed-module-definition`, `static-module-register`, `no-new-injectable`, `no-module-let`, `one-module-per-file` | R45 | `BE_MODULE_SHAPE` |
120
- | `no-inline-suppression` | R18 | `HFS_INLINE_SUPPRESSION` |
121
- | `file-size-growth` | R20 | `HFS_SIZE_GROWTH` |
122
- | `spec-no-source-read`, `spec-typed-doubles` | R48 | `BE_SPEC_QUALITY` |
123
- | `must-deep-module-import`, `no-folder-reexport` | R30 | `BE_PUBLIC_SURFACE` |
124
- | `dto-needs-validator` | R42 | `BE_INPUT_BOUNDED` |
125
- | `no-interpolated-sql` | R68 | `BE_SQL_INTERPOLATED` |
126
- | `query-needs-limit` | R69 | `BE_QUERY_UNBOUNDED` |
127
- | `no-query-in-loop` | R77 | `BE_QUERY_IN_LOOP` |
128
- | `http-needs-timeout` | R70 | `BE_HTTP_TIMEOUT` |
129
- | `no-secret-in-log` | R71 | `BE_LOG_SECRET` |
130
- | `no-never-cast`, `no-non-null-assertion` | R72 | `BE_TYPE_ESCAPE` |
131
- | `async-needs-await` | R73 | `BE_ASYNC_NO_AWAIT` |
132
- | `migration-down-reversible` | R74 | `BE_MIGRATION_REVERSIBLE` |
133
- | `explicit-handler-return-type` | R75 | `BE_RETURN_TYPE` |
134
- | `json-parse-needs-guard` | R76 | `BE_JSON_PARSE_UNGUARDED` |
135
- | `user-copy-through-catalog` | R78 | `BE_USER_COPY_LITERAL` |
136
- | `no-ambient-clock` | R79 | `BE_AMBIENT_CLOCK` |
137
- | `inbox-dedupe-required` | R80 | `BE_INBOX_DEDUPE_MISSING` |
138
- | `no-hand-rolled-retry` | R81 | `BE_HAND_ROLLED_RETRY` |
139
- | `no-external-call-in-transaction` | R82 | `BE_TRANSACTION_EXTERNAL_CALL` |
140
- | `named-entity-manager-only`, `no-injected-repository` | R83 | `BE_UNNAMED_DATA_ACCESS` |
141
-
142
- There is no soft size-limit lint rule: a warning under a zero-warning gate is an exception in disguise. Listing files over the soft budget (`ruleParams.be.fileLines.soft`) is a report item of the hfs check or the architecture machine, as migration backlog, and never blocks.
143
-
144
- The tier direction matrix and the feature-imports-feature ban (R26, R28) are owned by the architecture machine
145
- (`@starci/hfs`), not by a lint rule: they need the whole import graph.
106
+ Every rule of this plugin is an enforcer of one HFS rule. The relation (rule id -> HFS rule -> catalogued Vietnamese why code)
107
+ lives in exactly one place, `knowledge/hfs/rules.yaml` (`enforcers`), and `scripts/checks/check-hfs-rules.mjs` refuses a
108
+ rule of this plugin that no catalog entry names. There is no second table here.
109
+
package/index.mjs CHANGED
@@ -18,7 +18,6 @@
18
18
  */
19
19
  import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
20
20
  import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
21
- import { recommended as cdcRecommended, rules as cdcRules } from "./cdc.mjs"
22
21
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
23
22
  import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
24
23
  import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
@@ -26,7 +25,6 @@ import { recommended as dataAccessRecommended, rules as dataAccessRules } from "
26
25
  import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
27
26
  import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
28
27
  import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
29
- import { recommended as eventDeliveryRecommended, rules as eventDeliveryRules } from "./event-delivery.mjs"
30
28
  import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
31
29
  import { recommended as inputBoundsRecommended, rules as inputBoundsRules } from "./input-bounds.mjs"
32
30
  import { recommended as logSafetyRecommended, rules as logSafetyRules } from "./log-safety.mjs"
@@ -50,7 +48,6 @@ import { recommended as userCopyRecommended, rules as userCopyRules } from "./us
50
48
  const CONTRIBUTIONS = [
51
49
  { law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
52
50
  { law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
53
- { law: "cdc", rules: cdcRules, recommended: cdcRecommended },
54
51
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
55
52
  { law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
56
53
  { law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
@@ -58,7 +55,6 @@ const CONTRIBUTIONS = [
58
55
  { law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
59
56
  { law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
60
57
  { law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
61
- { law: "event-delivery", rules: eventDeliveryRules, recommended: eventDeliveryRecommended },
62
58
  { law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
63
59
  { law: "input-bounds", rules: inputBoundsRules, recommended: inputBoundsRecommended },
64
60
  { law: "log-safety", rules: logSafetyRules, recommended: logSafetyRecommended },
package/lib/slots.mjs CHANGED
@@ -14,16 +14,39 @@
14
14
  * over it may not be born or grow (`file-size-growth`)
15
15
  * - the `budget.indexExports` of the first `be.*` slot that states one, the public-surface width
16
16
  *
17
- * The manifest is found through `STARCI_HFS_SLOTS`, else at `<runtime>/knowledge/hfs/slots.yaml` above this package.
17
+ * The manifest and the YAML reader come from the installed `@starci/hfs` package, which carries a runtime copy
18
+ * (`runtime/knowledge/hfs/slots.yaml`, `runtime/engine/yaml.mjs`) and is a dependency of this package. They are
19
+ * resolved as `@starci/hfs/runtime/...` with `import.meta.resolve`, never from the product repository's filesystem
20
+ * and never from a link path. `STARCI_HFS_SLOTS` overrides the manifest file (tests, experiments). Inside the runtime
21
+ * repository itself, where `@starci/hfs` is not installed, the sibling `packages/hfs` workspace stands in.
18
22
  */
19
23
  import { existsSync, readFileSync } from "node:fs"
20
24
  import { dirname, join } from "node:path"
21
- import { fileURLToPath } from "node:url"
25
+ import { fileURLToPath, pathToFileURL } from "node:url"
22
26
 
23
27
  const HERE = dirname(fileURLToPath(import.meta.url))
24
- const RUNTIME_ROOT = join(HERE, "..", "..", "..", "..")
28
+ const SIBLING_HFS = join(HERE, "..", "..", "..", "hfs")
25
29
 
26
- const manifestFile = () => process.env.STARCI_HFS_SLOTS || join(RUNTIME_ROOT, "knowledge", "hfs", "slots.yaml")
30
+ const HFS_SLOTS = "@starci/hfs/runtime/knowledge/hfs/slots.yaml"
31
+ const HFS_YAML = "@starci/hfs/runtime/engine/yaml.mjs"
32
+
33
+ /**
34
+ * Resolves a file of the installed `@starci/hfs` package.
35
+ *
36
+ * @param {string} specifier - The `@starci/hfs/...` specifier.
37
+ * @returns {string} The `file:` URL of the file.
38
+ */
39
+ const hfsFile = (specifier) => {
40
+ try {
41
+ return import.meta.resolve(specifier)
42
+ } catch (error) {
43
+ const sibling = join(SIBLING_HFS, specifier.slice("@starci/hfs/".length))
44
+ if (existsSync(join(SIBLING_HFS, "package.json")) && existsSync(sibling)) return pathToFileURL(sibling).href
45
+ throw new Error(`@starci/hfs is not installed (${specifier} does not resolve): add it to dependencies and reinstall. ${error.message}`)
46
+ }
47
+ }
48
+
49
+ const manifestFile = () => process.env.STARCI_HFS_SLOTS || fileURLToPath(hfsFile(HFS_SLOTS))
27
50
 
28
51
  const toCapability = (dir) => String(dir).replace(/^src\/modules\//, "").replace(/\/+$/, "")
29
52
 
@@ -52,7 +75,7 @@ export const paramsFromManifest = (manifest) => {
52
75
  const load = async () => {
53
76
  const file = manifestFile()
54
77
  if (!existsSync(file)) throw new Error(`HFS slot manifest not found at ${file}`)
55
- const { parseYaml } = await import(new URL("../../../../engine/yaml.mjs", import.meta.url))
78
+ const { parseYaml } = await import(hfsFile(HFS_YAML))
56
79
  return paramsFromManifest(parseYaml(readFileSync(file, "utf8")))
57
80
  }
58
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -19,6 +19,9 @@
19
19
  "engines": {
20
20
  "node": ">=20.9.0"
21
21
  },
22
+ "dependencies": {
23
+ "@starci/hfs": "1.0.1"
24
+ },
22
25
  "peerDependencies": {
23
26
  "eslint": ">=9"
24
27
  },
@@ -7,8 +7,11 @@
7
7
  * the schema: a `synchronize` that is not the literal `false`, a `dataSource.synchronize()` call, a
8
8
  * `migrationsRun` that is not the literal `false`, a `CREATE|ALTER|DROP TABLE` string outside
9
9
  * `persistence/migrations/`, and an entity or migration glob. Only `apps/migrate` runs migrations.
10
- * - `sql-only-in-repository` (R36 `BE_SQL_OUTSIDE_REPOSITORY`) keeps raw SQL and query builders inside a
11
- * `*.repository.ts` of a `persistence/` folder, so a use case or a service never contains SQL.
10
+ * - `sql-text-only` (R36) keeps raw SQL text inside a `<name>.sql.ts` (the owning capability's `persistence/`,
11
+ * or a test builder's `src/tests/fixtures/builders/<area>.sql.ts`): `.query(...)` elsewhere takes an imported
12
+ * text constant, never a literal or template; `createQueryBuilder` is not used.
13
+ * - `no-repository-file` (R36) refuses a `*.repository.ts` file: repositories are banned (owner rule); SQL text lives
14
+ * in `<name>.sql.ts` and the capability's service runs it on the shared EntityManager.
12
15
  * - `no-entity-in-contract` (R37 `BE_ENTITY_IN_CONTRACT`) keeps ORM entities out of the types that cross a
13
16
  * boundary: `*.contracts.ts`, DTOs, resolver and controller signatures. A contract returns a projection.
14
17
  */
@@ -74,31 +77,33 @@ export const noRuntimeSchema = {
74
77
  },
75
78
  }
76
79
 
77
- const REPOSITORY_FILE = /\/persistence\/(?:[^/]+\/)*[^/]+\.repository\.[cm]?ts$/
80
+ /** A file that holds SQL text: `<name>.sql.ts`. */
81
+ const SQL_FILE = /\.sql\.[cm]?ts$/
82
+ /** A repository file, banned. */
83
+ const REPOSITORY_FILE = /\.repository\.[cm]?ts$/
78
84
 
79
- /** Raw SQL and query builders live in a `*.repository.ts` under `persistence/`, nowhere else. */
80
- export const sqlOnlyInRepository = {
85
+ /** Raw SQL text is written only in a `<name>.sql.ts`; everywhere else `.query(...)` takes an imported constant. */
86
+ export const sqlTextOnly = {
81
87
  meta: {
82
88
  type: "problem",
83
- docs: { description: "`.query(<sql>)` and `createQueryBuilder` only inside a persistence `*.repository.ts`." },
89
+ docs: { description: "Raw SQL text only in a `<name>.sql.ts`; `.query(...)` elsewhere takes an imported text constant; no `createQueryBuilder`." },
84
90
  schema: [],
85
91
  messages: {
86
- sql: "Raw SQL or a query builder outside a persistence repository. Move it into the `*.repository.ts` of the owning capability's `persistence/` and call that from the use case.",
92
+ sql: "Raw SQL text outside a `<name>.sql.ts`. Write the statement as an exported constant in the owning capability's `persistence/<name>.sql.ts` (a test builder's in `src/tests/fixtures/builders/<area>.sql.ts`), import it, and pass values as numbered parameters.",
93
+ builder: "`createQueryBuilder(...)` is banned. Use `manager.find(Entity, { where, take })`, or a SQL text constant from a `<name>.sql.ts` run with `manager.query(...)`.",
87
94
  },
88
95
  },
89
96
  create(context) {
90
97
  const filename = normalizePath(context.filename || context.getFilename())
91
- if (isDeclarationFile(filename) || isMigrationFile(filename) || REPOSITORY_FILE.test(filename)) return {}
98
+ if (isDeclarationFile(filename) || isMigrationFile(filename)) return {}
99
+ const sqlFile = SQL_FILE.test(filename)
92
100
  return {
93
101
  CallExpression(node) {
94
102
  const callee = node.callee
95
103
  if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
96
104
  const name = callee.property.name
97
- if (name === "createQueryBuilder") {
98
- context.report({ node, messageId: "sql" })
99
- } else if (name === "query" && staticText(node.arguments[0]) !== null) {
100
- context.report({ node, messageId: "sql" })
101
- } else if (name === "query" && node.arguments[0]?.type === "TemplateLiteral") {
105
+ if (name === "createQueryBuilder") context.report({ node, messageId: "builder" })
106
+ else if (!sqlFile && name === "query" && (staticText(node.arguments[0]) !== null || node.arguments[0]?.type === "TemplateLiteral")) {
102
107
  context.report({ node, messageId: "sql" })
103
108
  }
104
109
  },
@@ -106,6 +111,23 @@ export const sqlOnlyInRepository = {
106
111
  },
107
112
  }
108
113
 
114
+ /** Repositories are banned: a `*.repository.ts` file is a finding. */
115
+ export const noRepositoryFile = {
116
+ meta: {
117
+ type: "problem",
118
+ docs: { description: "No `*.repository.ts` file." },
119
+ schema: [],
120
+ messages: {
121
+ file: "Repository files are banned. Move the SQL text into the capability's `persistence/<name>.sql.ts` and run it from the capability's `*.service.ts` on the shared EntityManager.",
122
+ },
123
+ },
124
+ create(context) {
125
+ const filename = normalizePath(context.filename || context.getFilename())
126
+ if (isDeclarationFile(filename) || !REPOSITORY_FILE.test(filename)) return {}
127
+ return { Program(node) { context.report({ node, loc: { line: 1, column: 0 }, messageId: "file" }) } }
128
+ },
129
+ }
130
+
109
131
  /** Files whose types cross a boundary: contracts, DTOs, resolvers, controllers. */
110
132
  const BOUNDARY_FILE = /\.contracts\.[cm]?ts$|\/dto\/|\.(?:request|response|input|type|args)\.[cm]?ts$|\.(?:resolver|controller)\.[cm]?ts$/
111
133
 
@@ -173,7 +195,8 @@ export const migrationDownReversible = {
173
195
  /** The rules this law contributes to the plugin. */
174
196
  export const rules = {
175
197
  "no-runtime-schema": noRuntimeSchema,
176
- "sql-only-in-repository": sqlOnlyInRepository,
198
+ "sql-text-only": sqlTextOnly,
199
+ "no-repository-file": noRepositoryFile,
177
200
  "no-entity-in-contract": noEntityInContract,
178
201
  "migration-down-reversible": migrationDownReversible,
179
202
  }
@@ -181,7 +204,8 @@ export const rules = {
181
204
  /** All three start at error: HFS keeps no baseline and the schema migration lanes clear the debt first. */
182
205
  export const recommended = {
183
206
  "starci-be/no-runtime-schema": "error",
184
- "starci-be/sql-only-in-repository": "error",
207
+ "starci-be/sql-text-only": "error",
208
+ "starci-be/no-repository-file": "error",
185
209
  "starci-be/no-entity-in-contract": "error",
186
210
  "starci-be/migration-down-reversible": "error",
187
211
  }
package/cdc.mjs DELETED
@@ -1,150 +0,0 @@
1
- const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
2
- String(filename || "").replace(/\\/g, "/"),
3
- ) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
4
-
5
- const isProjectionService = (filename) => /-projection\.service\.ts$/.test(
6
- String(filename || "").replace(/\\/g, "/"),
7
- )
8
-
9
- const memberName = (member) => member.key && (member.key.name || member.key.value)
10
-
11
- /** A groupId literal is safe: a plain string, or a template with no `${}` substitution. */
12
- const isStaticStringValue = (node) => {
13
- if (!node) return false
14
- if (node.type === "Literal" && typeof node.value === "string") return true
15
- if (node.type === "TemplateLiteral" && node.expressions.length === 0) return true
16
- return false
17
- }
18
-
19
- /** Flatten a function's parameter list to the names it binds, through destructuring/defaults/rest. */
20
- const collectParamNames = (params, names = []) => {
21
- for (const param of params || []) {
22
- if (!param) continue
23
- if (param.type === "Identifier") {
24
- names.push(param.name)
25
- } else if (param.type === "AssignmentPattern") {
26
- collectParamNames([param.left], names)
27
- } else if (param.type === "RestElement") {
28
- collectParamNames([param.argument], names)
29
- } else if (param.type === "ObjectPattern") {
30
- for (const prop of param.properties || []) {
31
- if (prop.type === "Property" && prop.key) {
32
- names.push(prop.key.name || prop.key.value)
33
- } else if (prop.type === "RestElement") {
34
- collectParamNames([prop.argument], names)
35
- }
36
- }
37
- }
38
- }
39
- return names
40
- }
41
-
42
- const DELTA_LIKE_PARAMS = new Set(["amount", "delta", "points", "increment"])
43
-
44
- /** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
45
- export const projectionListenerContract = {
46
- meta: {
47
- type: "problem",
48
- docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
49
- schema: [],
50
- messages: {
51
- base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
52
- member: "A projection CDC listener must declare `{{name}}`.",
53
- lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
54
- },
55
- },
56
- create(context) {
57
- if (!isProjectionListener(context.filename || context.getFilename())) return {}
58
- return {
59
- ClassDeclaration(node) {
60
- if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
61
- context.report({ node: node.id || node, messageId: "base" })
62
- }
63
- const members = node.body.body || []
64
- const names = new Set(members.map(memberName))
65
- for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
66
- if (!names.has(name)) {
67
- context.report({ node: node.id || node, messageId: "member", data: { name } })
68
- }
69
- }
70
- const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
71
- if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
72
- },
73
- }
74
- },
75
- }
76
-
77
- /** CDC Rule 3 (CDC-2): a projection's consumer group id is a constant, not computed at boot. */
78
- export const noDynamicProjectionGroupId = {
79
- meta: {
80
- type: "problem",
81
- docs: {
82
- description: "CDC Rule 3 (CDC-2): `groupId` is a stable string literal, not a value computed at boot.",
83
- },
84
- schema: [],
85
- messages: {
86
- dynamic: "CDC Rule 3 (CDC-2): `groupId` must be a constant string literal. A template substitution, a call (`Date.now()`, `randomUUID()`, `hostname()`), or an env/instance read makes the consumer group unstable across restarts, so each restart replays the whole history in a fresh group instead of resuming from the committed offset.",
87
- },
88
- },
89
- create(context) {
90
- if (!isProjectionListener(context.filename || context.getFilename())) return {}
91
- return {
92
- PropertyDefinition(node) {
93
- if (memberName(node) !== "groupId") return
94
- if (node.value && !isStaticStringValue(node.value)) {
95
- context.report({ node: node.value, messageId: "dynamic" })
96
- }
97
- },
98
- }
99
- },
100
- }
101
-
102
- /** CDC Rules 1 and 6 (CDC-4): recompute rebuilds from source by UPSERT, never by applying a delta. */
103
- export const projectionRecomputeMustUpsert = {
104
- meta: {
105
- type: "problem",
106
- docs: {
107
- description: "CDC Rules 1 and 6 (CDC-4): a projection recomputes from source rows by UPSERT; it is never updated by applying an event's delta.",
108
- },
109
- schema: [],
110
- messages: {
111
- delta: "CDC Rules 1 and 6 (CDC-4): `recompute` must not take a `{{name}}` parameter. Recompute takes only the target's identity and rebuilds the value from authoritative source rows -- a delta/amount/points parameter means a duplicate delivery doubles the projection and a lost delivery never heals.",
112
- upsert: "CDC Rules 1 and 6 (CDC-4): a projection service with a `recompute` method must write through an UPSERT (`ON CONFLICT`). A value assembled without one is not safe to replay, and replay is the one guarantee a broker actually offers.",
113
- },
114
- },
115
- create(context) {
116
- if (!isProjectionService(context.filename || context.getFilename())) return {}
117
- let hasRecompute = false
118
- return {
119
- MethodDefinition(node) {
120
- if (memberName(node) !== "recompute") return
121
- hasRecompute = true
122
- const params = collectParamNames(node.value.params)
123
- for (const name of params) {
124
- if (DELTA_LIKE_PARAMS.has(String(name).toLowerCase())) {
125
- context.report({ node, messageId: "delta", data: { name } })
126
- }
127
- }
128
- },
129
- "Program:exit"(node) {
130
- if (!hasRecompute) return
131
- const source = context.sourceCode || context.getSourceCode()
132
- if (!/on\s*conflict/i.test(source.getText())) {
133
- context.report({ node, messageId: "upsert" })
134
- }
135
- },
136
- }
137
- },
138
- }
139
-
140
- export const rules = {
141
- "projection-listener-contract": projectionListenerContract,
142
- "no-dynamic-projection-group-id": noDynamicProjectionGroupId,
143
- "projection-recompute-must-upsert": projectionRecomputeMustUpsert,
144
- }
145
-
146
- export const recommended = {
147
- "starci-be/projection-listener-contract": "error",
148
- "starci-be/no-dynamic-projection-group-id": "error",
149
- "starci-be/projection-recompute-must-upsert": "error",
150
- }
@@ -1,93 +0,0 @@
1
- import { normalizePath } from "./lib/path.mjs"
2
-
3
- /** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
4
- export const natsBridgeDeliveryContract = {
5
- meta: {
6
- type: "problem",
7
- docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
8
- schema: [],
9
- messages: {
10
- origin: "Drop envelopes whose producer id matches this instance before local emit.",
11
- digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
12
- },
13
- },
14
- create(context) {
15
- if (!normalizePath(context.filename || context.getFilename()).endsWith(
16
- "/event/nats/nats-bridge.service.ts",
17
- )) return {}
18
- return {
19
- "Program:exit"(node) {
20
- const source = context.sourceCode || context.getSourceCode()
21
- const text = source.getText()
22
- const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
23
- const digestIndex = text.indexOf("parsed.digest")
24
- const emitIndex = text.indexOf("this.eventEmitter.emit")
25
- if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
26
- context.report({ node, messageId: "origin" })
27
- }
28
- if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
29
- context.report({ node, messageId: "digest" })
30
- }
31
- },
32
- }
33
- },
34
- }
35
-
36
- const isEmitCallOnEventEmitterService = (node) =>
37
- node.callee.type === "MemberExpression" &&
38
- !node.callee.computed &&
39
- node.callee.property.type === "Identifier" &&
40
- node.callee.property.name === "emit" &&
41
- node.callee.object.type === "MemberExpression" &&
42
- !node.callee.object.computed &&
43
- node.callee.object.property.type === "Identifier" &&
44
- node.callee.object.property.name === "eventEmitterService"
45
-
46
- const findObjectProperty = (objectExpression, name) => {
47
- if (!objectExpression || objectExpression.type !== "ObjectExpression") return null
48
- return objectExpression.properties.find(
49
- (property) => property.type === "Property" &&
50
- !property.computed &&
51
- (property.key.name === name || property.key.value === name),
52
- ) || null
53
- }
54
-
55
- /** DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in one config, never at the call site. */
56
- export const noCallSiteTransportOverride = {
57
- meta: {
58
- type: "problem",
59
- docs: {
60
- description: "DELIVERY Rule 2 (DELIVERY-2): transport is declared per event in the central config; it is never chosen at the call site.",
61
- },
62
- schema: [],
63
- messages: {
64
- callsite: "DELIVERY Rule 2 (DELIVERY-2): do not pass `options.useLocal`/`options.useNats` to `eventEmitterService.emit(...)`. Transport is part of the event's definition in the central config, not a per-call decision -- a call site choosing it is a promise the config never made and a reader has to guess at.",
65
- },
66
- },
67
- create(context) {
68
- return {
69
- CallExpression(node) {
70
- if (!isEmitCallOnEventEmitterService(node)) return
71
- const args = node.arguments[0]
72
- const optionsProperty = findObjectProperty(args, "options")
73
- if (!optionsProperty) return
74
- const optionsValue = optionsProperty.value
75
- const hasUseLocal = findObjectProperty(optionsValue, "useLocal")
76
- const hasUseNats = findObjectProperty(optionsValue, "useNats")
77
- if (hasUseLocal || hasUseNats) {
78
- context.report({ node: optionsProperty, messageId: "callsite" })
79
- }
80
- },
81
- }
82
- },
83
- }
84
-
85
- export const rules = {
86
- "nats-bridge-delivery-contract": natsBridgeDeliveryContract,
87
- "no-call-site-transport-override": noCallSiteTransportOverride,
88
- }
89
-
90
- export const recommended = {
91
- "starci-be/nats-bridge-delivery-contract": "error",
92
- "starci-be/no-call-site-transport-override": "error",
93
- }