@starci/eslint-canon-be 1.2.0 → 1.2.2
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 +7 -0
- package/README.md +70 -69
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +388 -389
- package/cqrs.mjs +302 -303
- package/data-access.mjs +306 -307
- package/e2e-flow.mjs +222 -222
- package/event-delivery.mjs +93 -93
- package/exception-identity.mjs +200 -200
- package/exceptions.mjs +262 -265
- package/index.mjs +107 -107
- package/lib/path.mjs +2 -0
- package/module-layering.mjs +426 -432
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/testing.mjs +500 -454
- package/transport.mjs +168 -168
- package/type-safety.mjs +510 -380
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.2.2 — 2026-09-29
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Accept concrete exceptions in their owning module or feature `errors/` directory, and accept the shared `AbstractException` declaration at its capability root.
|
|
7
|
+
- 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,70 @@
|
|
|
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
|
|
40
|
-
| **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
|
|
41
|
-
| **Module layering** |
|
|
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
|
-
| **
|
|
46
|
-
| **
|
|
47
|
-
| **
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
pointed at.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 carries its own identity and metadata. Concrete exceptions live in their owning feature or module capability's `errors/` folder; the shared abstract base may live at the capability root. |
|
|
40
|
+
| **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
|
|
41
|
+
| **Module layering** | Cross-capability imports may use an explicit public `index.ts`; self-aliases, tier-only aliases, folder re-exports, and export-star public entries are refused. |
|
|
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
|
+
| **Testing** | Unit specs sit beside their subjects, including structural specs in HFS `src/tests` categories; model-quality harness specs still call providers directly. |
|
|
46
|
+
| **End-to-end flows** | One file, one flow, named steps, and **never sleep** — poll until the state settles, with a deadline |
|
|
47
|
+
| **CDC · event delivery** | Projections and events follow the declared delivery contract |
|
|
48
|
+
| **Comments · naming · type safety** | Comments say why; no double cast through `unknown` |
|
|
49
|
+
|
|
50
|
+
Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
|
|
51
|
+
line leads straight to the document that explains why.
|
|
52
|
+
|
|
53
|
+
## Companion
|
|
54
|
+
|
|
55
|
+
- **[@starci/eslint-canon-fe](https://www.npmjs.com/package/@starci/eslint-canon-fe)** — the
|
|
56
|
+
front-end half: 58 rules from 16 laws covering component tiers, structure contracts, the
|
|
57
|
+
fetch/draw split, vendor boundaries and the design-token scale.
|
|
58
|
+
|
|
59
|
+
## Where the laws live
|
|
60
|
+
|
|
61
|
+
Each rule is the enforceable half of a written law. The prose — why the rule exists, what it
|
|
62
|
+
refuses, which cases sit just outside it — is published openly at
|
|
63
|
+
[starci183/starci-claude-skills](https://github.com/starci183/starci-claude-skills).
|
|
64
|
+
|
|
65
|
+
A rule that cannot be pointed at in real code is a proposal, not a law. Everything here is
|
|
66
|
+
pointed at.
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
ESLint 9+ (flat config), Node 20.9+.
|
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
|
+
}
|