@starci/eslint-canon-be 3.3.0 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  26. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  27. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  28. package/runtime/knowledge/hfs/slots.yaml +344 -62
  29. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  30. package/runtime/knowledge/sonar-gate.yaml +1 -1
  31. package/runtime/modules/models/runtimes.yaml +1 -1
  32. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  35. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  36. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  38. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  39. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  40. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  41. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  42. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  43. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  44. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  45. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  46. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  47. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  48. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  49. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  50. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  51. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  52. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  53. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  54. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  55. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  56. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  57. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  58. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  59. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  60. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  61. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  62. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  63. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  64. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  65. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  66. package/runtime/scripts/hfs/slots.mjs +1 -0
  67. package/runtime/scripts/lib/env.mjs +20 -0
  68. package/runtime/scripts/lib/event-contract.mjs +107 -0
  69. package/runtime/scripts/lib/json-clone.mjs +61 -0
  70. package/runtime/scripts/lib/list.mjs +4 -0
  71. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  72. package/runtime/scripts/lib/stack-services.mjs +1 -1
  73. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  74. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  75. package/service-isolation.mjs +3 -0
  76. package/size-budget.mjs +9 -5
  77. package/third-party-owner.mjs +149 -0
  78. package/transport.mjs +11 -29
  79. package/unit-spec.mjs +270 -84
  80. 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 expression = node.expression
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
- /** Both start at error: an untyped door and an unexplained open door are the failures this law exists for. */
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 rule that holds the thin-feature law (R203 `BE_FEATURE_THIN`).
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" }