@starci/eslint-canon-be 1.2.1 → 1.7.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 ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ ## 1.7.0 - 2026-09-30
4
+
5
+ - 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.
6
+ - Changed: `no-injected-repository` now refuses `@InjectRepository(...)` wherever it is written (a property or a method parameter, not only a constructor parameter), and its message states the same one pattern.
7
+
8
+ ## 1.6.1 - 2026-09-30
9
+
10
+ - Fixed: `inbox-dedupe-required` (R80 `BE_INBOX_DEDUPE_MISSING`) no longer relies only on `<event>.consumer.ts` under `transport/message/` to recognize a consumer. A method decorated `@EventPattern`, `@MessagePattern`, `@OnEvent` or `@Process`, or a class whose name ends `Consumer` or `OutboxConsumer`, is now a consumer wherever the file sits. The producer half of an outbox (typically `*-outbox.service.ts` / `*OutboxService`, which reads pending rows and publishes) carries none of those shapes and stays unflagged unless it also consumes.
11
+
12
+ ## 1.6.0 - 2026-09-30
13
+
14
+ Round 3 back-end rules (catalog R78 to R82), each with RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`.
15
+
16
+ - New: `user-copy-through-catalog` (R78 `BE_USER_COPY_LITERAL`, new law `user-copy`): a literal exception message, a `subject`/`title`/`body`/`text`/`message` property of a `notify`/`send`/`publish` call, or a `message`/`description`/`title` property of a `transport/` response comes from the per-capability messages catalog through the typed `MessageCatalog` port; a short code-shaped literal (`UPPER_SNAKE`, a bare word) and a template literal are left alone. `no-ambient-clock` (R79 `BE_AMBIENT_CLOCK`, new law `temporal`): `Date.now()`, a bare `new Date()` and `performance.now()` are read only inside `platform/clock`; `new Date(value)` built from a value the caller already holds is exempt. `inbox-dedupe-required` (R80 `BE_INBOX_DEDUPE_MISSING`, new law `idempotency`): a `@Public()` webhook controller and an outbox/queue consumer (`<event>.consumer.ts`) show a reference to an inbox or dedupe port before they act. `no-hand-rolled-retry` (R81 `BE_HAND_ROLLED_RETRY`, in `resilience`): a `for`, `while` or `do...while` loop that both catches an error and calls something shaped like a delay (`sleep`, `delay`, `wait`, `backoff`, `setTimeout`) goes through `platform/retry`; a polling loop with no `catch` is exempt, same as `no-query-in-loop` exempts a polling `while`. `no-external-call-in-transaction` (R82 `BE_TRANSACTION_EXTERNAL_CALL`, in `data-access`): `fetch`, an HTTP client call or any method of a `*Client`/`*Sdk` receiver does not run inside `.transaction(async (tx) => ...)`.
17
+ - Slot manifest (`knowledge/hfs/slots.yaml`, no version bump, additions stamped `since: 1.0.0`): `be.domain.messages` (`src/modules/{domain,integrations}/<capability>/messages/`) and `be.feature.messages` (`src/features/<feature>/messages/`), each an optional per-capability or per-feature vi/en catalog; `be.platform`'s required instances gain `clock` (the injected `Clock` port) and `i18n` (the `MessageCatalog` port); `be.domain` and `be.feature` allow a `messages/` folder.
18
+ - Deferred (owner ruling, no rule): floating promises; core `no-await-in-loop` is not adopted.
19
+
20
+ ## 1.5.0 - 2026-09-30
21
+
22
+ - New: `no-query-in-loop` (R77 `BE_QUERY_IN_LOOP`): a repository, entity-manager or query-builder read (`find`, `findOne`, `count`, `getOne`, a raw `SELECT`) inside a `for`, `for...of`, `for...in` or an array `map`, `forEach`, `flatMap`, `filter`, `reduce`, `some` or `every` callback is one round trip per element (N+1). A polling `while` loop and a write per element are not reported.
23
+
24
+ ## 1.4.0 - 2026-09-30
25
+
26
+ Round 2 back-end rules (catalog R42, R68 to R76). Every rule below has RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`; each was measured against nivo-backend, starci-next and mia-mia-backend before it shipped and is on at `error`.
27
+
28
+ - New: `no-interpolated-sql` (R68: SQL text carries no runtime substitution; a constant column list, a `${n}` placeholder, `quoteIdent(...)`, `sqlSetClause(...)` and a ternary of fixed strings are the only substitutions), `query-needs-limit` (R69: `getMany`, `getRawMany`, `find`, `findBy` and a raw `SELECT` state a bound; a lookup by `id` and an aggregate are exempt), `http-needs-timeout` (R70: `fetch` carries a `signal`, axios and HttpService calls carry `timeout` or `signal`, `axios.create` a `timeout`), `no-secret-in-log` (R71: a logger call names no credential and no personal identifier), `no-never-cast` and `no-non-null-assertion` (R72), `async-needs-await` (R73: an `async` function that never awaits; a class that extends or implements, a decorated method and a generator are exempt), `migration-down-reversible` (R74: `down()` exists, is not empty and is not a bare throw), `explicit-handler-return-type` (R75), `json-parse-needs-guard` (R76), and `dto-needs-validator` (R42: every property of an input class carries a `class-validator` decorator).
29
+ - New laws: `query-safety`, `resilience`, `log-safety`, `async-discipline`, `input-bounds`; `type-safety` and `schema-authority` gain rules.
30
+
31
+ ## 1.3.0 - 2026-09-29
32
+
33
+ HFS back-end rules (catalog R18, R20, R34, R36-R38, R40-R45, R48). Every rule below has RuleTester specs and a why code in `modules/kernel/failure-codes.yaml`.
34
+
35
+ - New: `catch-must-account` (R40), `error-home` (R38), `no-runtime-schema` (R34), `sql-only-in-repository` (R36), `no-entity-in-contract` (R37), `no-untyped-body` and `public-needs-reason` (R41), `no-direct-env-read` (R43), `no-secret-default` (R44), `secret-compare-timing-safe` (R41), `global-module-allowlist`, `typed-module-definition`, `static-module-register`, `no-new-injectable`, `no-module-let`, `one-module-per-file` (R45), `no-inline-suppression` (R18), `file-size-growth` (R20 ratchet; the soft budget is a hfs check report item, not a lint warning), `spec-no-source-read` and `spec-typed-doubles` (R48).
36
+ - Fixed: `must-deep-module-import` now holds the HFS owner surface. An aliased import names the owner and stops (`@modules/domain/plan`, `@features/plan`, or the explicit `/index`); a path into a file of another owner is refused, and a tier-only or root alias still is. The retired `lib` tier no longer counts as a tier. `no-folder-reexport` accepts a named `index.ts` surface (`export { X } from "./x"`, `export type { T } from "./contracts"`), and refuses `export *`, a `*_STORE` token, a whole `types` folder and an entry wider than the manifest's `indexExports` (60). Both are on again in `starciBeConfig`.
37
+ - Removed: no rule is off. The retired-rule list (`RETIRED`) and its export are gone, and so are the rules it switched off: `exception-name-ends-in-exception`, `exception-code-matches-class-name`, `exception-metadata-type-named-for-class`, `exception-extends-abstract`, `exception-in-errors-folder`, `require-exception-object-arg`, `throw-abstract-exception` (the Academy `AbstractException` family, replaced by `error-home`), `no-handler-encoded-failure` (expected refusals are typed unions), `handler-has-twin-spec`, `no-self-global-module` (replaced by `global-module-allowlist`), `no-line-suppression` and `require-vn-ok-reason` (replaced by `no-inline-suppression`), and the `vn-ok` exemption of `no-non-ascii-source`.
38
+ - `starciBeConfig` refuses a recommendation that carries an `off`.
39
+ - Slot parameters (the `@Global()` allowlist, the file line budget, the `index.ts` width) are read through `lib/slots.mjs` from `knowledge/hfs/slots.yaml` (`ruleParams.be.globalModules`, `ruleParams.be.fileLines.{soft,hardGrowth}`, and the `indexExports` budget of a `be.*` slot), with the rule catalog's values as the fallback.
40
+
41
+ ## 1.2.3 — 2026-09-29
42
+
43
+ - `starciBeConfig({ sources, plugin, recommended })`, `linterOptions` and `RETIRED` are exported. The factory returns the one flat-config block a repository needs: the canon owns the retired-rule list (checked against the code-pattern manifest) and the warn-to-error lift, so a repository no longer hand-copies `replacedByChecks`.
44
+ - `harness-calls-provider-directly` applies only to model harnesses, identified by an LLM provider SDK import, a house model helper or gateway symbol, or a `@harness-kind model` comment - never by the `src/tests/e2e/live/` folder. A live e2e for an identity provider (Keycloak) or any other third party is no longer reported as a model harness with no LLM SDK.
45
+
46
+ ## 1.2.2 — 2026-09-29
47
+
48
+ - Align module aliases and explicit public `index.ts` exports with HFS capability boundaries. Keep self-aliases, tier-only aliases, folder re-exports, and export-star public entries invalid.
49
+ - Accept concrete exceptions in their owning module or feature `errors/` directory, and accept the shared `AbstractException` declaration at its capability root.
50
+ - Classify a `.spec.ts` beside its subject in an HFS `src/tests` category as a colocated structural spec. Ordinary unit specs without an adjacent subject remain invalid in test buckets; model-quality harness checks remain in force.
package/README.md CHANGED
@@ -1,69 +1,145 @@
1
- # @starci/eslint-canon-be
2
-
3
- **37 ESLint rules, from 15 laws, that hold a NestJS-shaped back end to one way of being written.**
4
-
5
- Not a style pack. These rules enforce *architecture*: which layer may import which, whether a
6
- failure carries its own identity, where a query is allowed to be built, what an end-to-end test is
7
- permitted to assume. Prettier decides how code looks; this decides what it is allowed to be.
8
-
9
- ```bash
10
- npm i -D @starci/eslint-canon-be
11
- ```
12
-
13
- ## Use it
14
-
15
- ```js
16
- import starciBe, { recommended } from "@starci/eslint-canon-be"
17
-
18
- export default [
19
- // …your own ignores, language options and other plugins…
20
- {
21
- files: ["src/**/*.ts"],
22
- plugins: { "starci-be": starciBe },
23
- rules: recommended,
24
- },
25
- ]
26
- ```
27
-
28
- Which globs the law applies to is your repository's fact. What the law says is not — so there is no
29
- option to switch a rule off or lower it to a warning.
30
-
31
- Also exported: `rules`, `ruleOwners`, `lawOwners`.
32
-
33
- ## What it actually catches
34
-
35
- A sample, not the list:
36
-
37
- | Area | What the rules hold |
38
- |---|---|
39
- | **Exceptions** | Every failure throws a domain exception carrying its own identity and metadata — never a bare `Error`, never a framework built-in, never an exception declared outside the exceptions folder |
40
- | **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
41
- | **Module layering** | An import that climbs a layer it was not given is a build failure, not a review comment |
42
- | **Data access** | Queries stay where the layer says they may be built |
43
- | **Transport** | The wire shape is declared, not inferred from whatever a handler happened to return |
44
- | **Observability** | A failure is logged as a typed exception, so a log line can be traced to the law that names it |
45
- | **End-to-end flows** | One file, one flow, named steps, and **never sleep** — poll until the state settles, with a deadline |
46
- | **CDC · event delivery** | Projections and events follow the declared delivery contract |
47
- | **Comments · naming · type safety** | Comments say why; no double cast through `unknown` |
48
-
49
- Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
50
- line leads straight to the document that explains why.
51
-
52
- ## Companion
53
-
54
- - **[@starci/eslint-canon-fe](https://www.npmjs.com/package/@starci/eslint-canon-fe)** — the
55
- front-end half: 58 rules from 16 laws covering component tiers, structure contracts, the
56
- fetch/draw split, vendor boundaries and the design-token scale.
57
-
58
- ## Where the laws live
59
-
60
- Each rule is the enforceable half of a written law. The prose — why the rule exists, what it
61
- refuses, which cases sit just outside it — is published openly at
62
- [starci183/starci-claude-skills](https://github.com/starci183/starci-claude-skills).
63
-
64
- A rule that cannot be pointed at in real code is a proposal, not a law. Everything here is
65
- pointed at.
66
-
67
- ## Requirements
68
-
69
- ESLint 9+ (flat config), Node 20.9+.
1
+ # @starci/eslint-canon-be
2
+
3
+ **78 ESLint rules, from 26 laws, that hold a NestJS-shaped back end to one way of being written.**
4
+
5
+ Not a style pack. These rules enforce *architecture*: which layer may import which, whether a
6
+ failure carries its own identity, where a query is allowed to be built, what an end-to-end test is
7
+ permitted to assume. Prettier decides how code looks; this decides what it is allowed to be.
8
+
9
+ ```bash
10
+ npm i -D @starci/eslint-canon-be
11
+ ```
12
+
13
+ ## Use it
14
+
15
+ ```js
16
+ import starciBe, { recommended } from "@starci/eslint-canon-be"
17
+
18
+ export default [
19
+ // …your own ignores, language options and other plugins…
20
+ {
21
+ files: ["src/**/*.ts"],
22
+ plugins: { "starci-be": starciBe },
23
+ rules: recommended,
24
+ },
25
+ ]
26
+ ```
27
+
28
+ Which globs the law applies to is your repository's fact. What the law says is not — so there is no
29
+ option to switch a rule off or lower it to a warning.
30
+
31
+ Prefer the factory, which owns the levels for you:
32
+
33
+ ```js
34
+ import starciBe, { recommended, starciBeConfig } from "@starci/eslint-canon-be"
35
+
36
+ export default [
37
+ // …your own ignores, language options and other plugins…
38
+ starciBeConfig({ sources: ["apps/**/*.ts", "src/**/*.ts"], plugin: starciBe, recommended }),
39
+ ]
40
+ ```
41
+
42
+ It states `warn` as `error` for a zero-warning gate, applies
43
+ `noInlineConfig` and unused-disable reporting, and refuses a recommendation that carries a rule switched `off`. HFS
44
+ keeps no retired-rule list: a rule the standard no longer holds is deleted from the plugin, and every rule that ships is on.
45
+ The parameters a rule takes from the slot manifest (the `@Global()` allowlist, the file line budget, the width of an
46
+ `index.ts`) come through `lib/slots.mjs`, which reads `knowledge/hfs/slots.yaml` and falls back to the rule catalog's values.
47
+
48
+ Also exported: `rules`, `ruleOwners`, `lawOwners`, `starciBeConfig`, `linterOptions`.
49
+
50
+ ## What it actually catches
51
+
52
+ A sample, not the list:
53
+
54
+ | Area | What the rules hold |
55
+ |---|---|
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`) |
58
+ | **Config and secrets** | `process.env` only in `platform/config`; no literal default for a secret, password, token, key or URL; secrets compare with `timingSafeEqual` (`no-direct-env-read`, `no-secret-default`, `secret-compare-timing-safe`) |
59
+ | **Default deny** | Typed bodies and arguments, no `GraphQLJSON` tunnel, no `switch (operation)`, and `@Public` carries a reason (`no-untyped-body`, `public-needs-reason`) |
60
+ | **Module shape** | `@Global()` only on the manifest allowlist; typed `ConfigurableModuleBuilder`; static `register`; one module per file; no `new` of a provider; no module-level `let` |
61
+ | **Suppression** | No `eslint-disable`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck` or `vn-ok` comment, anywhere (`no-inline-suppression`) |
62
+ | **Size** | A file over its line budget is not new and does not grow past its size at the parent commit (`file-size-growth`) |
63
+ | **Query safety** | SQL text carries no runtime substitution; a read states `take`, `limit` or `LIMIT` (`no-interpolated-sql`, `query-needs-limit`, `no-query-in-loop`) |
64
+ | **Resilience** | An outbound HTTP call states a timeout or signal; `JSON.parse` of outside text sits in a `try`; a loop that catches and waits goes through `platform/retry` (`http-needs-timeout`, `json-parse-needs-guard`, `no-hand-rolled-retry`) |
65
+ | **Log safety** | A logger call names no credential and no personal identifier (`no-secret-in-log`) |
66
+ | **Async and types** | An `async` function awaits; no `as never`, no `x!`; handlers and public methods declare a return type (`async-needs-await`, `no-never-cast`, `no-non-null-assertion`, `explicit-handler-return-type`) |
67
+ | **Migrations and input** | A migration's `down()` reverses its `up()`; every property of an input class has a `class-validator` decorator (`migration-down-reversible`, `dto-needs-validator`) |
68
+ | **Spec quality** | A spec does not read repository source with `fs`, and doubles are `mock<T>()`, not `as never` (`spec-no-source-read`, `spec-typed-doubles`) |
69
+ | **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
70
+ | **Module layering** | Another owner is imported through its public `index.ts` (`@modules/domain/plan`, `@features/plan`), never through a path into it; an `index.ts` lists a bounded set of named exports, with no `export *`, storage token or types folder; self-aliases and tier-only aliases are refused. |
71
+ | **Data access** | Queries stay where the layer says they may be built; no transaction spans an external call (`no-external-call-in-transaction`); the database is reached through the named shared `EntityManager` only (`named-entity-manager-only`) |
72
+ | **Transport** | The wire shape is declared, not inferred from whatever a handler happened to return |
73
+ | **Observability** | A failure is logged through the logger port with a typed identity, so a log line can be traced to the law that names it |
74
+ | **Testing** | Unit specs sit beside their subjects, including structural specs in HFS `src/tests` categories; a model-quality harness (one that imports an LLM SDK, reaches a house model helper or declares `@harness-kind model`) calls its provider directly; other live e2e specs, such as an identity provider, are not judged as model harnesses. |
75
+ | **End-to-end flows** | One file, one flow, named steps, and **never sleep** — poll until the state settles, with a deadline |
76
+ | **CDC · event delivery** | Projections and events follow the declared delivery contract |
77
+ | **Comments · naming · type safety** | Comments say why; no double cast through `unknown` |
78
+ | **User copy** | A literal exception message, notification text or response copy comes from the per-capability messages catalog through the typed `MessageCatalog` port (`user-copy-through-catalog`) |
79
+ | **Temporal** | `Date.now()`, a bare `new Date()` and `performance.now()` are read only inside `platform/clock` (`no-ambient-clock`) |
80
+ | **Idempotency** | A `@Public()` webhook handler and an outbox/queue consumer claim the event through the shared inbox before they act (`inbox-dedupe-required`) |
81
+
82
+ Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
83
+ line leads straight to the document that explains why.
84
+
85
+ ## Companion
86
+
87
+ - **[@starci/eslint-canon-fe](https://www.npmjs.com/package/@starci/eslint-canon-fe)** — the
88
+ front-end half: 58 rules from 16 laws covering component tiers, structure contracts, the
89
+ fetch/draw split, vendor boundaries and the design-token scale.
90
+
91
+ ## Where the laws live
92
+
93
+ Each rule is the enforceable half of a written law. The prose — why the rule exists, what it
94
+ refuses, which cases sit just outside it — is published openly at
95
+ [starci183/starci-claude-skills](https://github.com/starci183/starci-claude-skills).
96
+
97
+ A rule that cannot be pointed at in real code is a proposal, not a law. Everything here is
98
+ pointed at.
99
+
100
+ ## Requirements
101
+
102
+ ESLint 9+ (flat config), Node 20.9+.
103
+
104
+ ## HFS rules and their why codes
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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The rule that keeps `async` honest.
3
+ *
4
+ * `async-needs-await` (R73 `BE_ASYNC_NO_AWAIT`) refuses an `async` function that never awaits. Such a function
5
+ * promises asynchronous work and does none: it turns a thrown error into a rejected promise for no reason,
6
+ * hides that the body is synchronous, and reads as if a suspension point existed. Delete the `async` and return the
7
+ * value (or the promise the body already holds); where a signature must return a `Promise`, return `Promise.resolve(x)`.
8
+ *
9
+ * Three shapes are exempt because the `async` is not the author's choice:
10
+ * - a method of a class that `implements` or `extends` something (the base contract fixes the return type);
11
+ * - a method that carries a decorator (the framework contract fixes it: resolvers, event handlers, lifecycle hooks);
12
+ * - an `async` generator, whose `yield` is its suspension.
13
+ */
14
+ import { walk } from "./lib/ast.mjs"
15
+ import { isDeclarationFile, isTestLane } from "./lib/path.mjs"
16
+
17
+ /** Whether the function body awaits, ignoring nested functions (each is judged alone). */
18
+ const awaits = (fn) => {
19
+ let found = false
20
+ walk(fn.body, (node) => {
21
+ if (found) return
22
+ if (node.type === "AwaitExpression") found = true
23
+ else if (node.type === "ForOfStatement" && node.await) found = true
24
+ }, { intoFunctions: false })
25
+ return found
26
+ }
27
+
28
+ /** Whether the function is a method whose `async` a base class, interface or decorator fixes. */
29
+ const asyncIsContractual = (fn) => {
30
+ const method = fn.parent?.type === "MethodDefinition" ? fn.parent : null
31
+ if (!method) return false
32
+ if (method.decorators?.length > 0) return true
33
+ if (method.override) return true
34
+ const klass = method.parent?.parent
35
+ if (klass && (klass.superClass || (klass.implements && klass.implements.length > 0))) return true
36
+ return false
37
+ }
38
+
39
+ /** An `async` function awaits something. */
40
+ export const asyncNeedsAwait = {
41
+ meta: {
42
+ type: "problem",
43
+ docs: { description: "An `async` function contains an `await`, or it is not `async`." },
44
+ schema: [],
45
+ messages: {
46
+ noAwait:
47
+ "This `async` function never awaits. It promises asynchronous work and does none, so a thrown error becomes a rejected promise for no reason and a reader looks for a suspension that is not there. Remove `async` and return the value or the promise the body already holds; where the signature must return a `Promise`, return `Promise.resolve(value)`.",
48
+ },
49
+ },
50
+ create(context) {
51
+ const filename = context.filename || context.getFilename()
52
+ if (isTestLane(filename) || isDeclarationFile(filename)) return {}
53
+ const check = (node) => {
54
+ if (!node.async || node.generator || !node.body) return
55
+ if (node.body.type !== "BlockStatement" && node.type === "ArrowFunctionExpression") {
56
+ // an expression body has no statements to await inside; `async () => x` is judged by the expression itself
57
+ let has = false
58
+ walk(node.body, (child) => {
59
+ if (child.type === "AwaitExpression") has = true
60
+ }, { intoFunctions: false })
61
+ if (!has) context.report({ node, messageId: "noAwait" })
62
+ return
63
+ }
64
+ if (asyncIsContractual(node)) return
65
+ if (!awaits(node)) context.report({ node, messageId: "noAwait" })
66
+ }
67
+ return {
68
+ FunctionDeclaration: check,
69
+ FunctionExpression: check,
70
+ ArrowFunctionExpression: check,
71
+ }
72
+ },
73
+ }
74
+
75
+ /** The rules this law contributes to the plugin. */
76
+ export const rules = {
77
+ "async-needs-await": asyncNeedsAwait,
78
+ }
79
+
80
+ /** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt. */
81
+ export const recommended = {
82
+ "starci-be/async-needs-await": "error",
83
+ }
package/authorization.mjs CHANGED
@@ -1,106 +1,106 @@
1
- /**
2
- * The rule that holds `authorization.md`.
3
- *
4
- * ONE rule, and the honest reason is worth stating: authorization is decided against a row, and a
5
- * parser does not know which row a handler is reaching for or what owning it means. AUTHZ-3, -4 and
6
- * -5 all turn on that, so a rule aimed at them would fire on shape rather than on meaning - and a
7
- * rule that fires on shape is one authors learn to disable, which leaves the law worse off than when
8
- * nothing enforced it.
9
- *
10
- * AUTHZ-1 was measured and deliberately left alone. A handler checking `if (!user)` reads like
11
- * duplication of the door's guard and is not: the guard belongs to one door and the handler belongs
12
- * to every caller of the bus. A rule refusing it would fire on the majority of correct handlers in
13
- * this tree, which is a gate contradicting canon rather than holding it.
14
- *
15
- * What IS checkable in one file is the door: a resolver method that READS the authenticated user
16
- * while nothing on that method or its class establishes the identity. That failure looks like
17
- * nothing at all - the parameter still says `user`, the handler still receives one - and the only
18
- * missing piece is the line that proved it belonged to the caller.
19
- */
20
-
21
- /** Parameter decorators that read an already-authenticated identity off the request. */
22
- const IDENTITY_PARAM_DECORATORS = new Set([
23
- "KeycloakGraphQLUser",
24
- "KeycloakUser",
25
- "CurrentUser",
26
- ])
27
-
28
- /** The decorator that establishes the identity a door then reads. */
29
- const GUARD_DECORATOR = "UseGuards"
30
-
31
- /** Reads the callee name off a decorator expression, whether or not it is called. */
32
- const decoratorName = (decorator) => {
33
- const expression = decorator.expression
34
- if (!expression) return undefined
35
- if (expression.type === "CallExpression") {
36
- return expression.callee && expression.callee.type === "Identifier"
37
- ? expression.callee.name
38
- : undefined
39
- }
40
- return expression.type === "Identifier" ? expression.name : undefined
41
- }
42
-
43
- /** Whether any decorator in the list is the guard. */
44
- const hasGuard = (decorators) =>
45
- (decorators || []).some((decorator) => decoratorName(decorator) === GUARD_DECORATOR)
46
-
47
- /** The identity parameter on a method, when it has one. */
48
- const identityParameter = (node) => {
49
- for (const parameter of node.value && node.value.params ? node.value.params : []) {
50
- // a decorated parameter carries its decorators on the parameter node itself, and TS-ESTree
51
- // wraps a typed+decorated parameter in TSParameterProperty for constructor promotion
52
- const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
53
- const decorators = parameter.decorators || (carrier && carrier.decorators) || []
54
- for (const decorator of decorators) {
55
- if (IDENTITY_PARAM_DECORATORS.has(decoratorName(decorator))) return parameter
56
- }
57
- }
58
- return undefined
59
- }
60
-
61
- // -- AUTHZ-2 ---------------------------------------------------------------------------------------
62
-
63
- /** A door that reads an identity carries the guard that establishes it. */
64
- export const identityNeedsGuard = {
65
- meta: {
66
- type: "problem",
67
- docs: {
68
- description:
69
- "A resolver or controller method reading the authenticated user must carry a guard on that method or its class.",
70
- },
71
- schema: [],
72
- messages: {
73
- unguarded:
74
- "This door reads the authenticated user through `@{{decorator}}()` while neither the method nor its class carries `@UseGuards(...)`. Nothing established the identity, so what reaches the handler is whatever the pipeline left there - and the failure is silent, because the parameter still says `user` and the handler still receives one. Put the guard on the door that reads the identity.",
75
- },
76
- },
77
- create(context) {
78
- return {
79
- MethodDefinition(node) {
80
- const parameter = identityParameter(node)
81
- if (!parameter) return
82
- if (hasGuard(node.decorators)) return
83
-
84
- // the guard may sit on the class instead, covering every method on it
85
- const classBody = node.parent
86
- const classNode = classBody && classBody.parent
87
- if (classNode && hasGuard(classNode.decorators)) return
88
-
89
- const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
90
- const decorators = parameter.decorators || (carrier && carrier.decorators) || []
91
- const named = decorators.map(decoratorName).find((name) => IDENTITY_PARAM_DECORATORS.has(name))
92
- context.report({ node: parameter, messageId: "unguarded", data: { decorator: named } })
93
- },
94
- }
95
- },
96
- }
97
-
98
- /** Every rule this module ships, by the name a config switches it on under. */
99
- export const rules = {
100
- "identity-needs-guard": identityNeedsGuard,
101
- }
102
-
103
- /** The level a consuming repository switches this on at. */
104
- export const recommended = {
105
- "starci-be/identity-needs-guard": "error",
106
- }
1
+ /**
2
+ * The rule that holds `authorization.md`.
3
+ *
4
+ * ONE rule, and the honest reason is worth stating: authorization is decided against a row, and a
5
+ * parser does not know which row a handler is reaching for or what owning it means. AUTHZ-3, -4 and
6
+ * -5 all turn on that, so a rule aimed at them would fire on shape rather than on meaning - and a
7
+ * rule that fires on shape is one authors learn to disable, which leaves the law worse off than when
8
+ * nothing enforced it.
9
+ *
10
+ * AUTHZ-1 was measured and deliberately left alone. A handler checking `if (!user)` reads like
11
+ * duplication of the door's guard and is not: the guard belongs to one door and the handler belongs
12
+ * to every caller of the bus. A rule refusing it would fire on the majority of correct handlers in
13
+ * this tree, which is a gate contradicting canon rather than holding it.
14
+ *
15
+ * What IS checkable in one file is the door: a resolver method that READS the authenticated user
16
+ * while nothing on that method or its class establishes the identity. That failure looks like
17
+ * nothing at all - the parameter still says `user`, the handler still receives one - and the only
18
+ * missing piece is the line that proved it belonged to the caller.
19
+ */
20
+
21
+ /** Parameter decorators that read an already-authenticated identity off the request. */
22
+ const IDENTITY_PARAM_DECORATORS = new Set([
23
+ "KeycloakGraphQLUser",
24
+ "KeycloakUser",
25
+ "CurrentUser",
26
+ ])
27
+
28
+ /** The decorator that establishes the identity a door then reads. */
29
+ const GUARD_DECORATOR = "UseGuards"
30
+
31
+ /** Reads the callee name off a decorator expression, whether or not it is called. */
32
+ const decoratorName = (decorator) => {
33
+ const expression = decorator.expression
34
+ if (!expression) return undefined
35
+ if (expression.type === "CallExpression") {
36
+ return expression.callee && expression.callee.type === "Identifier"
37
+ ? expression.callee.name
38
+ : undefined
39
+ }
40
+ return expression.type === "Identifier" ? expression.name : undefined
41
+ }
42
+
43
+ /** Whether any decorator in the list is the guard. */
44
+ const hasGuard = (decorators) =>
45
+ (decorators || []).some((decorator) => decoratorName(decorator) === GUARD_DECORATOR)
46
+
47
+ /** The identity parameter on a method, when it has one. */
48
+ const identityParameter = (node) => {
49
+ for (const parameter of node.value && node.value.params ? node.value.params : []) {
50
+ // a decorated parameter carries its decorators on the parameter node itself, and TS-ESTree
51
+ // wraps a typed+decorated parameter in TSParameterProperty for constructor promotion
52
+ const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
53
+ const decorators = parameter.decorators || (carrier && carrier.decorators) || []
54
+ for (const decorator of decorators) {
55
+ if (IDENTITY_PARAM_DECORATORS.has(decoratorName(decorator))) return parameter
56
+ }
57
+ }
58
+ return undefined
59
+ }
60
+
61
+ // -- AUTHZ-2 ---------------------------------------------------------------------------------------
62
+
63
+ /** A door that reads an identity carries the guard that establishes it. */
64
+ export const identityNeedsGuard = {
65
+ meta: {
66
+ type: "problem",
67
+ docs: {
68
+ description:
69
+ "A resolver or controller method reading the authenticated user must carry a guard on that method or its class.",
70
+ },
71
+ schema: [],
72
+ messages: {
73
+ unguarded:
74
+ "This door reads the authenticated user through `@{{decorator}}()` while neither the method nor its class carries `@UseGuards(...)`. Nothing established the identity, so what reaches the handler is whatever the pipeline left there - and the failure is silent, because the parameter still says `user` and the handler still receives one. Put the guard on the door that reads the identity.",
75
+ },
76
+ },
77
+ create(context) {
78
+ return {
79
+ MethodDefinition(node) {
80
+ const parameter = identityParameter(node)
81
+ if (!parameter) return
82
+ if (hasGuard(node.decorators)) return
83
+
84
+ // the guard may sit on the class instead, covering every method on it
85
+ const classBody = node.parent
86
+ const classNode = classBody && classBody.parent
87
+ if (classNode && hasGuard(classNode.decorators)) return
88
+
89
+ const carrier = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
90
+ const decorators = parameter.decorators || (carrier && carrier.decorators) || []
91
+ const named = decorators.map(decoratorName).find((name) => IDENTITY_PARAM_DECORATORS.has(name))
92
+ context.report({ node: parameter, messageId: "unguarded", data: { decorator: named } })
93
+ },
94
+ }
95
+ },
96
+ }
97
+
98
+ /** Every rule this module ships, by the name a config switches it on under. */
99
+ export const rules = {
100
+ "identity-needs-guard": identityNeedsGuard,
101
+ }
102
+
103
+ /** The level a consuming repository switches this on at. */
104
+ export const recommended = {
105
+ "starci-be/identity-needs-guard": "error",
106
+ }