@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 +50 -0
- package/README.md +145 -69
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +340 -389
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +168 -303
- package/data-access.mjs +452 -307
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/event-delivery.mjs +93 -93
- package/idempotency.mjs +101 -0
- package/index.mjs +137 -107
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +17 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +408 -432
- package/module-shape.mjs +202 -0
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +479 -452
- package/transport.mjs +168 -168
- package/type-safety.mjs +551 -511
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -265
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
|
-
**
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
+
}
|