@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 +9 -0
- package/README.md +6 -42
- package/index.mjs +0 -4
- package/lib/slots.mjs +28 -5
- package/package.json +4 -1
- package/schema-authority.mjs +39 -15
- package/cdc.mjs +0 -150
- package/event-delivery.mjs +0 -93
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
|
-
**
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
28
|
+
const SIBLING_HFS = join(HERE, "..", "..", "..", "hfs")
|
|
25
29
|
|
|
26
|
-
const
|
|
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(
|
|
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.
|
|
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
|
},
|
package/schema-authority.mjs
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
|
80
|
-
export const
|
|
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(
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
}
|
package/event-delivery.mjs
DELETED
|
@@ -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
|
-
}
|