@starci/eslint-canon-be 3.3.1 → 3.4.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 +12 -0
- package/README.md +2 -2
- package/app-composition.mjs +348 -3
- package/authorization.mjs +68 -2
- package/builders.mjs +2 -2
- package/config-owner.mjs +44 -15
- package/default-deny.mjs +80 -9
- package/edge-hardening.mjs +139 -0
- package/feature-thin.mjs +78 -2
- package/function-budget.mjs +49 -0
- package/health-ready.mjs +539 -0
- package/index.mjs +37 -0
- package/infra-owner.mjs +3 -0
- package/jwt-alg.mjs +45 -0
- package/lib/subject-values.mjs +55 -0
- package/lib/unit-spec.mjs +0 -3
- package/lib/webhook-handoff-inbox.mjs +260 -0
- package/lib/webhook-handoff-intake.mjs +140 -0
- package/lib/why.mjs +13 -0
- package/module-shape.mjs +147 -51
- package/operations.mjs +165 -1
- package/package.json +1 -1
- package/realtime.mjs +59 -22
- package/resilience.mjs +45 -14
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +3401 -152
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/service-isolation.mjs +3 -0
- package/size-budget.mjs +9 -5
- package/third-party-owner.mjs +149 -0
- package/transport.mjs +11 -29
- package/unit-spec.mjs +270 -84
- package/webhooks.mjs +169 -0
package/default-deny.mjs
CHANGED
|
@@ -9,17 +9,61 @@
|
|
|
9
9
|
* - `public-needs-reason` refuses `@Public()` without `{ reason: PublicReason.X }`, where `PublicReason` is the enum
|
|
10
10
|
* `domain/identity` exports (judged by the enum's declaring owner, type-aware). An open door states why it is open
|
|
11
11
|
* from a closed list, so the open doors can be read and reviewed; a string or another enum is refused.
|
|
12
|
+
* - `public-reason-closed` holds `PublicReason` to Health, AuthHandshake, SignedWebhook and CatalogRead.
|
|
13
|
+
* API-key and tool-token doors authenticate through their own guard instead of extending this enum.
|
|
12
14
|
*
|
|
13
15
|
* The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
|
|
14
16
|
* one file cannot see which module the app registered.
|
|
15
17
|
*/
|
|
16
18
|
import { isTransportSlot } from "./lib/transport-slots.mjs"
|
|
17
19
|
import { decoratorName, keyName } from "./lib/ast.mjs"
|
|
18
|
-
import { isOwnedEnumMember } from "./lib/declared.mjs"
|
|
20
|
+
import { isOwnedBy, isOwnedEnumMember, isOwnedType, partsOf, typeOf } from "./lib/declared.mjs"
|
|
19
21
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
20
22
|
import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
|
|
23
|
+
import { typed } from "./lib/types.mjs"
|
|
21
24
|
|
|
22
25
|
const BODY_DECORATORS = new Set(["Body", "Args"])
|
|
26
|
+
const PUBLIC_REASONS = new Set(["Health", "AuthHandshake", "SignedWebhook", "CatalogRead"])
|
|
27
|
+
const REASON_WRAPPERS = new Set(["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression"])
|
|
28
|
+
|
|
29
|
+
/** The reason expression supplied to a public decorator, or null when it supplies none. */
|
|
30
|
+
const publicReasonOf = (node) => {
|
|
31
|
+
const expression = node.expression
|
|
32
|
+
const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
|
|
33
|
+
if (argument?.type !== "ObjectExpression") return null
|
|
34
|
+
const property = argument.properties.find((item) => item.type === "Property" && !item.computed && keyName(item.key) === "reason")
|
|
35
|
+
return property ? property.value : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A static member name; a computed identifier is an expression rather than the member's name. */
|
|
39
|
+
const staticMemberName = (node) => {
|
|
40
|
+
if (node.computed && node.property.type !== "Literal") return null
|
|
41
|
+
return keyName(node.property)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** All declared names of the enum, including TypeScript's merged declarations. */
|
|
45
|
+
const publicReasonNames = (context, node) => {
|
|
46
|
+
const { checker, toTs } = typed(context)
|
|
47
|
+
const declaration = toTs(node)
|
|
48
|
+
const symbol = checker.getSymbolAtLocation(declaration.name)
|
|
49
|
+
const members = (symbol?.declarations ?? []).flatMap((part) => part.members ?? [])
|
|
50
|
+
return new Set(members.map((member) => member.name.text))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Reject added enum members even when the expression is an alias or a union of reasons. */
|
|
54
|
+
const hasAddedPublicReason = (context, reason) => {
|
|
55
|
+
let value = reason
|
|
56
|
+
while (REASON_WRAPPERS.has(value.type)) value = value.expression
|
|
57
|
+
// A stale or not-yet-declared member has an error type, but its enum receiver still has an owner.
|
|
58
|
+
if (value.type === "MemberExpression" && isOwnedType(context, value.object, "PublicReason", "domain", "identity")) {
|
|
59
|
+
const name = staticMemberName(value)
|
|
60
|
+
if (name !== null && !PUBLIC_REASONS.has(name)) return true
|
|
61
|
+
}
|
|
62
|
+
if (isOwnedEnumMember(context, reason, "PublicReason", "domain", "identity")) {
|
|
63
|
+
return partsOf(typeOf(context, reason)).some((part) => !PUBLIC_REASONS.has(part.getSymbol()?.name))
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
23
67
|
|
|
24
68
|
const isUntyped = (annotation) => {
|
|
25
69
|
if (!annotation) return true
|
|
@@ -95,27 +139,54 @@ export const publicNeedsReason = {
|
|
|
95
139
|
return {
|
|
96
140
|
Decorator(node) {
|
|
97
141
|
if (decoratorName(node) !== "Public") return
|
|
98
|
-
const
|
|
99
|
-
const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
|
|
100
|
-
let reason = null
|
|
101
|
-
if (argument?.type === "ObjectExpression") {
|
|
102
|
-
const property = argument.properties.find((item) => item.type === "Property" && !item.computed && keyName(item.key) === "reason")
|
|
103
|
-
reason = property ? property.value : null
|
|
104
|
-
}
|
|
142
|
+
const reason = publicReasonOf(node)
|
|
105
143
|
if (reason === null || !isOwnedEnumMember(context, reason, "PublicReason", "domain", "identity")) context.report({ node, messageId: "reason" })
|
|
106
144
|
},
|
|
107
145
|
}
|
|
108
146
|
},
|
|
109
147
|
}
|
|
110
148
|
|
|
149
|
+
/** The identity enum and each public door use exactly the canon's four reasons. */
|
|
150
|
+
export const publicReasonClosed = {
|
|
151
|
+
meta: {
|
|
152
|
+
type: "problem",
|
|
153
|
+
docs: { description: "`PublicReason` is closed to Health, AuthHandshake, SignedWebhook and CatalogRead in both editions." },
|
|
154
|
+
schema: [],
|
|
155
|
+
messages: {
|
|
156
|
+
closed: "BE_PUBLIC_REASON_CLOSED: `PublicReason` has exactly Health, AuthHandshake, SignedWebhook and CatalogRead. An API-key or tool-token route authenticates with its own guard; do not add a public reason.",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
create(context) {
|
|
160
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
161
|
+
if (isDeclarationFile(filename)) return {}
|
|
162
|
+
const inIdentity = isOwnedBy(hfsOf(context), filename, "domain", "identity")
|
|
163
|
+
return {
|
|
164
|
+
TSEnumDeclaration(node) {
|
|
165
|
+
if (!inIdentity || node.id.name !== "PublicReason") return
|
|
166
|
+
const names = publicReasonNames(context, node)
|
|
167
|
+
if (names.size !== PUBLIC_REASONS.size || [...PUBLIC_REASONS].some((name) => !names.has(name))) {
|
|
168
|
+
context.report({ node, messageId: "closed" })
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
Decorator(node) {
|
|
172
|
+
if (decoratorName(node) !== "Public") return
|
|
173
|
+
const reason = publicReasonOf(node)
|
|
174
|
+
if (reason !== null && hasAddedPublicReason(context, reason)) context.report({ node, messageId: "closed" })
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
111
180
|
/** The rules this law contributes to the plugin. */
|
|
112
181
|
export const rules = {
|
|
113
182
|
"no-untyped-body": noUntypedBody,
|
|
114
183
|
"public-needs-reason": publicNeedsReason,
|
|
184
|
+
"public-reason-closed": publicReasonClosed,
|
|
115
185
|
}
|
|
116
186
|
|
|
117
|
-
/**
|
|
187
|
+
/** Every rule starts at error: public doors and typed inputs hold the default-deny boundary. */
|
|
118
188
|
export const recommended = {
|
|
119
189
|
"starci-be/no-untyped-body": "error",
|
|
120
190
|
"starci-be/public-needs-reason": "error",
|
|
191
|
+
"starci-be/public-reason-closed": "error",
|
|
121
192
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** R261: every API entrypoint declares its browser and proxy boundary. */
|
|
2
|
+
import { keyName, staticText, walk } from "./lib/ast.mjs"
|
|
3
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
4
|
+
|
|
5
|
+
const unwrap = (node) => {
|
|
6
|
+
while (node && ["TSAsExpression", "TSTypeAssertion", "TSSatisfiesExpression", "TSNonNullExpression", "AwaitExpression", "ChainExpression"].includes(node.type)) node = node.expression ?? node.argument
|
|
7
|
+
return node
|
|
8
|
+
}
|
|
9
|
+
const property = (node, name, constants = new Map(), seen = new Set()) => {
|
|
10
|
+
if (node?.type !== "ObjectExpression") return undefined
|
|
11
|
+
for (const entry of [...node.properties].reverse()) {
|
|
12
|
+
if (entry.type === "SpreadElement") {
|
|
13
|
+
const spread = resolve(entry.argument, constants, seen)
|
|
14
|
+
if (spread?.type !== "ObjectExpression") return undefined
|
|
15
|
+
const value = property(spread, name, constants, seen)
|
|
16
|
+
if (value !== undefined) return value
|
|
17
|
+
} else {
|
|
18
|
+
const key = entry.computed ? staticText(entry.key) : keyName(entry.key)
|
|
19
|
+
if (key === null) return undefined
|
|
20
|
+
if (key === name) return entry.value
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
const method = (node) => node?.type === "MemberExpression" ? (node.computed ? staticText(node.property) : keyName(node.property)) : null
|
|
26
|
+
|
|
27
|
+
/** Only declared API entrypoints are judged; workers have no browser edge. */
|
|
28
|
+
const isApiMain = (context) => {
|
|
29
|
+
const hfs = hfsOf(context)
|
|
30
|
+
const file = hfs.relative(context.filename ?? context.getFilename())
|
|
31
|
+
return hfs.slotOf(context.filename ?? context.getFilename()) === "be.app.api"
|
|
32
|
+
&& hfs.apps.some((app) => app.kind === "api" && file === `apps/${app.name}/src/main.ts`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolve local constants without making an identifier's spelling a security proof. */
|
|
36
|
+
const resolve = (node, constants, seen = new Set()) => {
|
|
37
|
+
node = unwrap(node)
|
|
38
|
+
if (node?.type === "Identifier" && constants.has(node.name) && !seen.has(node.name)) return resolve(constants.get(node.name), constants, new Set([...seen, node.name]))
|
|
39
|
+
if (node?.type === "MemberExpression" && method(node) !== null) {
|
|
40
|
+
const value = property(resolve(node.object, constants, seen), method(node), constants, seen)
|
|
41
|
+
if (value !== undefined) return resolve(value, constants, seen)
|
|
42
|
+
}
|
|
43
|
+
return node
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allowedOrigin = (node) => {
|
|
47
|
+
const value = staticText(node)
|
|
48
|
+
if (value === null || value.includes("*") || value.endsWith("/")) return false
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(value)
|
|
51
|
+
return value === url.origin && (url.protocol === "https:" || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))
|
|
52
|
+
} catch { return false }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Config owns a dynamic allowlist; literal lists must contain exact origins. */
|
|
56
|
+
const allowlist = (node, constants) => {
|
|
57
|
+
node = resolve(node, constants)
|
|
58
|
+
if (node?.type === "MemberExpression") return method(node) === "allowedOrigins"
|
|
59
|
+
if (node?.type !== "ArrayExpression" || node.elements.length === 0) return false
|
|
60
|
+
return node.elements.every((entry) => entry?.type === "SpreadElement"
|
|
61
|
+
? allowlist(entry.argument, constants)
|
|
62
|
+
: allowedOrigin(resolve(entry, constants)))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hopCount = (node, constants) => {
|
|
66
|
+
node = resolve(node, constants)
|
|
67
|
+
return node?.type === "Literal" && Number.isSafeInteger(node.value) && node.value >= 0
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const audit = (program) => {
|
|
71
|
+
const constants = new Map()
|
|
72
|
+
const helpers = new Set()
|
|
73
|
+
const middleware = new Set()
|
|
74
|
+
const factories = new Set()
|
|
75
|
+
const applications = new Set()
|
|
76
|
+
const calls = []
|
|
77
|
+
walk(program, (node) => {
|
|
78
|
+
if (node.type === "ImportDeclaration") {
|
|
79
|
+
if (node.source.value === "@nestjs/core") {
|
|
80
|
+
for (const specifier of node.specifiers) if (specifier.imported?.name === "NestFactory") factories.add(specifier.local.name)
|
|
81
|
+
}
|
|
82
|
+
if (/^@modules\/platform\/http-security(?:\/|$)/.test(node.source.value)) {
|
|
83
|
+
for (const specifier of node.specifiers) {
|
|
84
|
+
if (["applyHttpSecurity", "configureHttpSecurity"].includes(specifier.imported?.name)) helpers.add(specifier.local.name)
|
|
85
|
+
if (["securityHeaders", "createSecurityHeadersMiddleware"].includes(specifier.imported?.name)) middleware.add(specifier.local.name)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init) constants.set(node.id.name, node.init)
|
|
90
|
+
if (node.type === "CallExpression") calls.push(node)
|
|
91
|
+
})
|
|
92
|
+
for (const [name, initializer] of constants) {
|
|
93
|
+
const node = resolve(initializer, constants)
|
|
94
|
+
if (node?.type === "CallExpression" && method(node.callee) === "create" && factories.has(node.callee.object.name)) applications.add(name)
|
|
95
|
+
}
|
|
96
|
+
const isApplication = (node) => {
|
|
97
|
+
if (node?.type === "Identifier" && applications.has(node.name)) return true
|
|
98
|
+
node = resolve(node, constants)
|
|
99
|
+
if (node?.type !== "CallExpression") return false
|
|
100
|
+
if (method(node.callee) === "create" && factories.has(node.callee.object.name)) return true
|
|
101
|
+
return ["getInstance", "getHttpAdapter"].includes(method(node.callee)) && isApplication(node.callee.object)
|
|
102
|
+
}
|
|
103
|
+
const direct = calls.filter((call) => call.callee.type === "MemberExpression" && isApplication(call.callee.object))
|
|
104
|
+
const applied = calls.filter((call) => call.callee.type === "Identifier" && helpers.has(call.callee.name) && isApplication(call.arguments[0]))
|
|
105
|
+
const helperCors = applied.some((call) => {
|
|
106
|
+
const config = resolve(call.arguments[1], constants)
|
|
107
|
+
return config?.type === "MemberExpression" || allowlist(property(config, "allowedOrigins", constants), constants)
|
|
108
|
+
})
|
|
109
|
+
const corsCalls = direct.filter((call) => method(call.callee) === "enableCors")
|
|
110
|
+
const cors = (helperCors || corsCalls.length > 0) && corsCalls.every((call) => allowlist(property(resolve(call.arguments[0], constants), "origin", constants), constants))
|
|
111
|
+
const headers = applied.length > 0 || direct.some((call) => method(call.callee) === "use" && call.arguments.some((argument) => {
|
|
112
|
+
const candidate = resolve(argument, constants)
|
|
113
|
+
return (candidate?.type === "Identifier" && middleware.has(candidate.name))
|
|
114
|
+
|| (candidate?.type === "CallExpression" && candidate.callee.type === "Identifier" && middleware.has(candidate.callee.name))
|
|
115
|
+
}))
|
|
116
|
+
const poweredCalls = direct.filter((call) => ["set", "enable", "disable"].includes(method(call.callee)) && staticText(call.arguments[0])?.toLowerCase() === "x-powered-by")
|
|
117
|
+
const poweredOff = (applied.length > 0 || poweredCalls.some((call) => method(call.callee) === "disable" || (method(call.callee) === "set" && call.arguments[1]?.value === false)))
|
|
118
|
+
&& poweredCalls.every((call) => method(call.callee) === "disable" || (method(call.callee) === "set" && call.arguments[1]?.value === false))
|
|
119
|
+
const proxyCalls = direct.filter((call) => method(call.callee) === "set" && staticText(call.arguments[0]) === "trust proxy")
|
|
120
|
+
const proxy = (proxyCalls.length > 0 || applied.length > 0)
|
|
121
|
+
&& proxyCalls.every((call) => hopCount(call.arguments[1], constants))
|
|
122
|
+
&& applied.every((call) => hopCount(property(resolve(call.arguments[2], constants), "trustProxy", constants), constants))
|
|
123
|
+
return { cors, headers: headers && poweredOff, proxy }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const edgeRule = (field, code, description) => ({
|
|
127
|
+
meta: { type: "problem", docs: { description }, schema: [], messages: { boundary: `${code}: ${description}` } },
|
|
128
|
+
create(context) {
|
|
129
|
+
if (!isApiMain(context)) return {}
|
|
130
|
+
return { "Program:exit"(node) { if (!audit(node)[field]) context.report({ node, messageId: "boundary" }) } }
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export const corsAllowlist = edgeRule("cors", "BE_CORS_ALLOWLIST", "Configure CORS with an explicit origin allowlist from http-security config; wildcard, open and missing origins are refused.")
|
|
135
|
+
export const securityHeaders = edgeRule("headers", "BE_SECURITY_HEADERS", "Apply the platform/http-security response middleware and disable x-powered-by.")
|
|
136
|
+
export const trustProxy = edgeRule("proxy", "BE_TRUST_PROXY", "Declare trust proxy as a nonnegative integer hop count; blanket trust and undeclared proxy trust are refused.")
|
|
137
|
+
|
|
138
|
+
export const rules = { "cors-allowlist": corsAllowlist, "security-headers": securityHeaders, "trust-proxy": trustProxy }
|
|
139
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-be/${name}`, "error"]))
|
package/feature-thin.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The rules that hold the thin-feature law (R203 `BE_FEATURE_THIN`) and close the support-role escape
|
|
3
|
+
* (R264 `HFS_FEATURE_LOGIC_ROLE`).
|
|
3
4
|
*
|
|
4
5
|
* A file of the feature tier whose role is one of `ruleParams.be.thinRoles` of the slot manifest
|
|
5
6
|
* (`<name>.handler.ts`, `<name>.resolver.ts`, `<name>.consumer.ts`, `<name>.processor.ts`, `<name>.step.ts`,
|
|
@@ -22,7 +23,7 @@
|
|
|
22
23
|
import { keyName, walk } from "./lib/ast.mjs"
|
|
23
24
|
import { aliasTarget, callsOf, decoratorsFrom, injectedMembers } from "./lib/doors.mjs"
|
|
24
25
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
25
|
-
import { importOf } from "./lib/import-source.mjs"
|
|
26
|
+
import { importOf, isImportedFrom } from "./lib/import-source.mjs"
|
|
26
27
|
import { baseName, isLoggerType, isOwnedType } from "./lib/ports.mjs"
|
|
27
28
|
import { firstWebhookVerification, isWebhookDomainServiceType } from "./lib/webhook-verifiers.mjs"
|
|
28
29
|
import { isWebhookForbiddenReceiver } from "./lib/webhook-handoff.mjs"
|
|
@@ -268,12 +269,87 @@ export const featureThin = {
|
|
|
268
269
|
},
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
/** A declaration's signed numeric literal is still data, including an enum member or a constant. */
|
|
273
|
+
const isSignedNumericLiteral = (node) => node.type === "UnaryExpression" && ["+", "-"].includes(node.operator)
|
|
274
|
+
&& node.argument.type === "Literal" && typeof node.argument.value === "number"
|
|
275
|
+
|
|
276
|
+
/** A DTO's decorator application declares metadata; calls inside its arguments remain ordinary executable calls. */
|
|
277
|
+
const isDecoratorApplication = (node) => node.parent?.type === "Decorator" && node.parent.expression === node
|
|
278
|
+
|
|
279
|
+
/** A Nest command/query constructor initializes its data base through the required, argument-free super call. */
|
|
280
|
+
const isCqrsDataConstructor = (context, node) => {
|
|
281
|
+
if (node.callee.type !== "Super" || node.arguments.length !== 0) return false
|
|
282
|
+
for (let parent = node.parent; parent; parent = parent.parent) {
|
|
283
|
+
if (parent.type === "MethodDefinition") {
|
|
284
|
+
const declaration = parent.parent?.parent
|
|
285
|
+
return parent.kind === "constructor" && isImportedFrom(context, declaration?.superClass, "@nestjs/cqrs", ["Command", "Query"])
|
|
286
|
+
}
|
|
287
|
+
if (["FunctionDeclaration", "ArrowFunctionExpression"].includes(parent.type)) return false
|
|
288
|
+
}
|
|
289
|
+
return false
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* `registerEnumType(...)` of `@nestjs/graphql` at module top level of a `<name>.type.ts` file of the GraphQL transport slot
|
|
294
|
+
* (`be.transport.graphql`). It declares a schema enum: `starci app emit` loads only the resolver, scalar and `include` module files, so the
|
|
295
|
+
* registration must sit in a file the resolver imports, and the `.type.ts` is that file (owner ruling 2026-10-16 "Theo y tro", docs/microservices.md#r264).
|
|
296
|
+
* The same call in any other role or slot, or nested in a function, stays executable logic.
|
|
297
|
+
*/
|
|
298
|
+
const isEnumRegistration = (context, hfs, node, role) => role === "type"
|
|
299
|
+
&& hfs.slotOf(context.filename) === "be.transport.graphql"
|
|
300
|
+
&& node.parent?.type === "ExpressionStatement" && node.parent.parent?.type === "Program"
|
|
301
|
+
&& isImportedFrom(context, node.callee, "@nestjs/graphql", ["registerEnumType"])
|
|
302
|
+
|
|
303
|
+
/** Non-door feature roles declare data; renaming a policy or a guard must never hide business logic from R203. */
|
|
304
|
+
export const featureLogicRole = {
|
|
305
|
+
meta: {
|
|
306
|
+
type: "problem",
|
|
307
|
+
docs: { description: "Non-door roles in the feature tier may declare data, types and DTO metadata, but cannot hold decisions, computation or executable calls." },
|
|
308
|
+
schema: [],
|
|
309
|
+
messages: {
|
|
310
|
+
logic: "[HFS_FEATURE_LOGIC_ROLE] {{what}} puts executable logic in a feature `<name>.{{role}}.ts` support or data file. Keep its declarations and data here; put business logic in the tested modules and delegate through a declared thin feature role.",
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
create(context) {
|
|
314
|
+
const hfs = hfsOf(context)
|
|
315
|
+
const name = baseName(context.filename)
|
|
316
|
+
if (name.endsWith(".spec.ts") || name.endsWith(".d.ts")) return {}
|
|
317
|
+
const role = roleOf(name)
|
|
318
|
+
if (role === null || (hfs.ruleParams.thinRoles ?? []).includes(role)) return {}
|
|
319
|
+
if (hfs.tierOf(context.filename) !== "feature") return {}
|
|
320
|
+
// The feature's Nest composition is governed by the module laws. A module suffix in support/ is still support.
|
|
321
|
+
if (["module", "module-definition"].includes(role) && hfs.slotOf(context.filename) !== "be.feature.application.support") return {}
|
|
322
|
+
|
|
323
|
+
const report = (node, what) => context.report({ node, messageId: "logic", data: { what, role } })
|
|
324
|
+
const visitors = {}
|
|
325
|
+
for (const type of BRANCH_NODES) {
|
|
326
|
+
visitors[type] = (node) => report(node, type === "LogicalExpression" ? `a \`${node.operator}\`` : BRANCH_WHAT[type])
|
|
327
|
+
}
|
|
328
|
+
for (const type of COMPUTE_NODES) {
|
|
329
|
+
visitors[type] = (node) => {
|
|
330
|
+
if (!isSignedNumericLiteral(node)) report(node, `an \`${node.operator}\``)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
visitors.TemplateLiteral = (node) => {
|
|
334
|
+
if (node.expressions.length > 0) report(node, "a template literal with an expression")
|
|
335
|
+
}
|
|
336
|
+
visitors.CallExpression = (node) => {
|
|
337
|
+
if (!isDecoratorApplication(node) && !isCqrsDataConstructor(context, node) && !isEnumRegistration(context, hfs, node, role)) report(node, `the call \`${context.sourceCode.getText(node.callee)}\``)
|
|
338
|
+
}
|
|
339
|
+
visitors.TaggedTemplateExpression = (node) => report(node, `the tagged call \`${context.sourceCode.getText(node.tag)}\``)
|
|
340
|
+
visitors.NewExpression = (node) => report(node, `the construction \`${context.sourceCode.getText(node.callee)}\``)
|
|
341
|
+
return visitors
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
|
|
271
345
|
/** The rules this law contributes to the plugin. */
|
|
272
346
|
export const rules = {
|
|
273
347
|
"feature-thin": featureThin,
|
|
348
|
+
"feature-logic-role": featureLogicRole,
|
|
274
349
|
}
|
|
275
350
|
|
|
276
351
|
/** Every rule of this law at `error`. */
|
|
277
352
|
export const recommended = {
|
|
278
353
|
"starci-be/feature-thin": "error",
|
|
354
|
+
"starci-be/feature-logic-role": "error",
|
|
279
355
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R291 HFS_FUNCTION_BUDGET: every function body stays within the common line budget.
|
|
3
|
+
*
|
|
4
|
+
* Count the physical span of the body, including braces, comments, blank lines and nested functions.
|
|
5
|
+
* Signatures and parameters are outside the body. An expression arrow has an expression body and
|
|
6
|
+
* counts the expression and its wrapping parentheses, including comments and blanks inside them.
|
|
7
|
+
*/
|
|
8
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
9
|
+
|
|
10
|
+
/** Functions, methods, constructors, accessors and callbacks all receive the same body budget. */
|
|
11
|
+
export const functionLineBudget = {
|
|
12
|
+
meta: {
|
|
13
|
+
type: "problem",
|
|
14
|
+
docs: { description: "A function body stays within ruleParams.functionLines, counting comments and blank lines." },
|
|
15
|
+
schema: [],
|
|
16
|
+
messages: {
|
|
17
|
+
lines: "HFS_FUNCTION_BUDGET: this function body has {{lines}} lines; the budget is {{max}}. Split the work into functions with one responsibility each.",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
// C08 adds this common parameter to the shipped manifest; keep the owner-decided cap while it lands.
|
|
22
|
+
const max = hfsOf(context).ruleParams.functionLines ?? 80
|
|
23
|
+
if (!Number.isInteger(max) || max < 1) throw new RangeError("ruleParams.functionLines must be a positive integer")
|
|
24
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
25
|
+
const check = (node) => {
|
|
26
|
+
if (!node.body) return
|
|
27
|
+
let start = node.body.loc.start.line
|
|
28
|
+
let end = node.body.loc.end.line
|
|
29
|
+
if (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") {
|
|
30
|
+
const arrow = source.getTokenBefore(node.body, { filter: (token) => token.value === "=>" })
|
|
31
|
+
start = source.getTokenAfter(arrow, { includeComments: true }).loc.start.line
|
|
32
|
+
end = node.loc.end.line
|
|
33
|
+
}
|
|
34
|
+
const lines = end - start + 1
|
|
35
|
+
if (lines > max) context.report({ node: node.body, messageId: "lines", data: { lines, max } })
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
FunctionDeclaration: check,
|
|
39
|
+
FunctionExpression: check,
|
|
40
|
+
ArrowFunctionExpression: check,
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The rule this law contributes to the plugin. */
|
|
46
|
+
export const rules = { "function-line-budget": functionLineBudget }
|
|
47
|
+
|
|
48
|
+
/** The function budget is an error in both editions. */
|
|
49
|
+
export const recommended = { "starci-be/function-line-budget": "error" }
|