@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
@@ -0,0 +1,539 @@
1
+ /** R262: API health routes are wired and readiness checks a required dependency under a failing, bounded timeout. */
2
+ import fs from "node:fs"
3
+ import path from "node:path"
4
+ import tsParser from "@typescript-eslint/parser"
5
+ import { appRoleOf } from "./lib/app-roles.mjs"
6
+ import { hfsOf } from "./lib/hfs.mjs"
7
+ import { decoratorName, keyName, paramParts, staticText, walk } from "./lib/ast.mjs"
8
+
9
+ const unwrap = (node) => {
10
+ while (["TSAsExpression", "TSNonNullExpression", "ChainExpression", "TSSatisfiesExpression"].includes(node?.type)) node = node.expression
11
+ return node
12
+ }
13
+ const memberName = (node) => node?.computed ? staticText(node.property) : keyName(node?.property)
14
+ const typeName = (node) => node?.typeAnnotation?.typeAnnotation?.typeName?.name ?? null
15
+ const methodsOf = (node) => node.body.body.filter((entry) => entry.type === "MethodDefinition")
16
+ const classesOf = (program) => {
17
+ const classes = new Map()
18
+ walk(program, (node) => { if (node.type === "ClassDeclaration" && node.id) classes.set(node.id.name, node) })
19
+ return classes
20
+ }
21
+ const bindingsOf = (program) => {
22
+ const bindings = new Map()
23
+ walk(program, (node) => { if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init) bindings.set(node.id.name, node.init) })
24
+ return bindings
25
+ }
26
+
27
+ /** Expand local constants when proving a race: a timeout promise or duration may be held in a const. */
28
+ function expandedNodes(node, bindings, seen = new Set()) {
29
+ const nodes = []
30
+ walk(node, (child) => {
31
+ nodes.push(child)
32
+ if (child.type !== "Identifier" || seen.has(child.name) || !bindings.has(child.name)) return
33
+ const next = new Set(seen).add(child.name)
34
+ nodes.push(...expandedNodes(bindings.get(child.name), bindings, next))
35
+ })
36
+ return nodes
37
+ }
38
+
39
+ function sourcePath(source, filename, root) {
40
+ let base = null
41
+ if (source.startsWith(".")) base = path.resolve(path.dirname(filename), source)
42
+ if (source.startsWith("@modules/")) base = path.join(root, "src/modules", source.slice(9))
43
+ if (source.startsWith("@features/")) base = path.join(root, "src/features", source.slice(10))
44
+ if (!base || path.relative(root, base).startsWith("..")) return null
45
+ return [base, `${base}.ts`, path.join(base, "index.ts")].find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) ?? null
46
+ }
47
+
48
+ function readProgram(filename) {
49
+ try { return tsParser.parse(fs.readFileSync(filename, "utf8"), { sourceType: "module", ecmaVersion: "latest" }) } catch { return null }
50
+ }
51
+
52
+ const routesOf = (controller) => {
53
+ const decorator = controller.decorators?.find((entry) => decoratorName(entry) === "Controller")
54
+ if (!decorator) return []
55
+ const prefix = staticText(decorator.expression.arguments?.[0]) ?? ""
56
+ return methodsOf(controller).flatMap((method) => (method.decorators ?? []).filter((entry) => decoratorName(entry) === "Get").flatMap((entry) => {
57
+ const arg = entry.expression.arguments?.[0]
58
+ const paths = arg?.type === "ArrayExpression" ? arg.elements.map(staticText) : [staticText(arg) ?? ""]
59
+ return paths.filter((route) => route !== null).map((route) => ({ route: `/${prefix}/${route}`.replace(/\/+/g, "/").replace(/\/$/, ""), method }))
60
+ }))
61
+ }
62
+
63
+ function injectedOf(controller) {
64
+ const constructor = methodsOf(controller).find((entry) => entry.kind === "constructor")
65
+ return new Map((constructor?.value.params ?? []).map((param) => {
66
+ const { target } = paramParts(param)
67
+ return [target.name, typeName(target)]
68
+ }).filter(([name]) => name))
69
+ }
70
+
71
+ const injectedCall = (node, injected) => {
72
+ if (node.type !== "CallExpression" || unwrap(node.callee)?.type !== "MemberExpression") return null
73
+ let owner = unwrap(node.callee.object)
74
+ while (owner?.type === "CallExpression" && unwrap(owner.callee)?.type === "MemberExpression") owner = unwrap(owner.callee.object)
75
+ while (owner?.type === "MemberExpression" && owner.object?.type !== "ThisExpression") owner = unwrap(owner.object)
76
+ if (owner?.type !== "MemberExpression" || owner.object?.type !== "ThisExpression") return null
77
+ const name = memberName(owner)
78
+ if (!injected.has(name) || /(?:queryBus|commandBus|logger|liveness|liveProbe)/i.test(`${name} ${injected.get(name)}`)) return null
79
+ return { name, method: memberName(unwrap(node.callee)), type: injected.get(name) }
80
+ }
81
+
82
+ function positiveDuration(node, bindings, seen = new Set()) {
83
+ node = unwrap(node)
84
+ if (node?.type === "Identifier" && bindings.has(node.name) && !seen.has(node.name)) return positiveDuration(bindings.get(node.name), bindings, new Set(seen).add(node.name))
85
+ return node?.type === "Literal" && typeof node.value === "number" && Number.isFinite(node.value) && node.value > 0 && node.value <= 60000
86
+ }
87
+
88
+ function resolvedType(node, source, seen = new Set()) {
89
+ if (node?.type !== "TSTypeReference") return node
90
+ const target = delegatedClass(node.typeName?.name, source.program, source.filename, source.root)
91
+ if (target?.controller.type !== "TSTypeAliasDeclaration") return null
92
+ const identity = `${target.filename}:${target.controller.id.name}`
93
+ if (seen.has(identity)) return null
94
+ return resolvedType(target.controller.typeAnnotation, { ...target, root: source.root }, new Set(seen).add(identity))
95
+ }
96
+
97
+ function timerMethod(call, injected, source) {
98
+ const dependency = injectedCall(call, injected)
99
+ if (!dependency) return false
100
+ const target = delegatedClass(dependency.type, source.program, source.filename, source.root)
101
+ const members = target?.controller.body?.body ?? []
102
+ const method = members.find((entry) => keyName(entry.key) === dependency.method)
103
+ const params = method?.params ?? method?.value?.params ?? []
104
+ const location = { ...target, root: source.root }
105
+ const callback = resolvedType(params[0]?.typeAnnotation?.typeAnnotation, location)
106
+ const returns = resolvedType(method?.returnType?.typeAnnotation ?? method?.value?.returnType?.typeAnnotation, location)
107
+ const delay = resolvedType(params[1]?.typeAnnotation?.typeAnnotation, location)
108
+ return callback?.type === "TSFunctionType" && callback.params.length === 0
109
+ && callback.returnType?.typeAnnotation?.type === "TSVoidKeyword"
110
+ && delay?.type === "TSNumberKeyword"
111
+ && returns?.type === "TSFunctionType" && returns.params.length === 0
112
+ && returns.returnType?.typeAnnotation?.type === "TSVoidKeyword"
113
+ && timerBinding(dependency, target, source)
114
+ }
115
+
116
+ function timerToken(name, source) {
117
+ const constructor = methodsOf(source.controller).find((entry) => entry.kind === "constructor")
118
+ const parameter = constructor?.value.params.map(paramParts).find((entry) => entry.target.name === name)
119
+ for (const decorator of parameter?.decorators ?? []) {
120
+ const target = delegatedClass(decoratorName(decorator), source.program, source.filename, source.root)
121
+ const body = target?.controller.init?.body ?? target?.controller.body
122
+ const token = body?.type === "CallExpression" && body.arguments[0]?.name
123
+ const resolved = token && delegatedClass(token, target.program, target.filename, source.root)
124
+ if (resolved) return resolved
125
+ }
126
+ return null
127
+ }
128
+
129
+ function timerImplementation(target, methodName, contract, root) {
130
+ const matches = target.controller.implements?.some((entry) => {
131
+ const implemented = delegatedClass(entry.expression?.name, target.program, target.filename, root)
132
+ return implemented && sameClass(implemented, contract)
133
+ })
134
+ if (!matches) return false
135
+ const method = methodsOf(target.controller).find((entry) => keyName(entry.key) === methodName)
136
+ if (!method?.value.body) return false
137
+ const [callback, delay] = method.value.params.map((entry) => paramParts(entry).target.name)
138
+ const statements = method.value.body.body
139
+ const declarations = statements.filter((entry) => entry.type === "VariableDeclaration").flatMap((entry) => entry.declarations)
140
+ const scheduled = declarations.find((entry) => entry.init?.type === "CallExpression" && entry.init.callee.name === "setTimeout"
141
+ && entry.init.arguments[0]?.name === callback && entry.init.arguments[1]?.name === delay)
142
+ if (!scheduled) return false
143
+ return statements.some((entry) => entry.type === "ReturnStatement" && entry.argument?.type === "ArrowFunctionExpression"
144
+ && entry.argument.body?.type === "CallExpression" && entry.argument.body.callee.name === "clearTimeout"
145
+ && entry.argument.body.arguments[0]?.name === scheduled.id.name)
146
+ }
147
+
148
+ function providedTimer(module, token, dependency, contract, source) {
149
+ for (const object of moduleObjects(module)) {
150
+ const providers = object.properties.find((entry) => keyName(entry.key) === "providers")?.value
151
+ for (const node of expandedNodes(providers, bindingsOf(source.program)).filter((entry) => entry.type === "ObjectExpression")) {
152
+ const name = node.properties.find((entry) => keyName(entry.key) === "provide")?.value?.name
153
+ const provided = name && delegatedClass(name, source.program, source.filename, source.root)
154
+ if (!provided || !sameClass(provided, token)) continue
155
+ const implementation = node.properties.find((entry) => keyName(entry.key) === "useClass")?.value?.name
156
+ const target = implementation && delegatedClass(implementation, source.program, source.filename, source.root)
157
+ if (target?.controller.type === "ClassDeclaration" && timerImplementation(target, dependency.method, contract, source.root)) return true
158
+ }
159
+ }
160
+ return false
161
+ }
162
+
163
+ function timerBinding(dependency, contract, source) {
164
+ const token = timerToken(dependency.name, source)
165
+ if (!token) return false
166
+ const directory = path.dirname(contract.filename)
167
+ for (const entry of fs.readdirSync(directory).filter((name) => name.endsWith(".module.ts"))) {
168
+ const filename = path.join(directory, entry)
169
+ const program = readProgram(filename)
170
+ if (!program) continue
171
+ const location = { program, filename, root: source.root }
172
+ if ([...classesOf(program).values()].some((module) => providedTimer(module, token, dependency, contract, location))) return true
173
+ }
174
+ return false
175
+ }
176
+
177
+ function rejectingTimeout(nodes, bindings, injected, source) {
178
+ return nodes.some((node) => {
179
+ if (node.type !== "CallExpression" || !positiveDuration(node.arguments[1], bindings)) return false
180
+ const native = node.callee.type === "Identifier" && node.callee.name === "setTimeout"
181
+ if (!native && !timerMethod(node, injected, source)) return false
182
+ return expandedNodes(node.arguments[0], bindings).some((entry) => entry.type === "CallExpression" && entry.callee.type === "Identifier" && entry.callee.name === "reject")
183
+ })
184
+ }
185
+
186
+ function parentsOf(body) {
187
+ const parents = new Map()
188
+ walk(body, (node) => {
189
+ for (const [key, value] of Object.entries(node)) {
190
+ if (["parent", "loc", "range", "tokens", "comments"].includes(key)) continue
191
+ for (const child of Array.isArray(value) ? value : [value]) if (typeof child?.type === "string") parents.set(child, node)
192
+ }
193
+ })
194
+ return parents
195
+ }
196
+
197
+ function consumedCall(node, body, bindings) {
198
+ const parents = parentsOf(body)
199
+ const nodes = []
200
+ walk(body, (entry) => nodes.push(entry))
201
+ return nodes.some((entry) => ["AwaitExpression", "ReturnStatement"].includes(entry.type)
202
+ && expandedNodes(entry.argument, bindings).includes(node)
203
+ && !expandedNodes(entry.argument, bindings).some((child) => child.type === "UnaryExpression" && child.operator === "void")
204
+ && !["ArrowFunctionExpression", "FunctionExpression"].includes(parents.get(entry)?.type))
205
+ }
206
+
207
+ /** A required dependency failure must still make the readiness promise fail. */
208
+ function propagatesFailures(body) {
209
+ const catches = []
210
+ const promiseCatches = []
211
+ walk(body, (node) => { if (node.type === "CatchClause") catches.push(node) })
212
+ walk(body, (node) => { if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && memberName(node.callee) === "catch") promiseCatches.push(node.arguments[0]) })
213
+ const rejected = (node) => node?.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object.name === "Promise" && memberName(node.callee) === "reject"
214
+ const fails = (block) => block?.body?.some((statement) => statement.type === "ThrowStatement" || statement.type === "ReturnStatement" && rejected(statement.argument))
215
+ return catches.every((node) => node.body.body.some((statement) => statement.type === "ThrowStatement"
216
+ || statement.type === "ReturnStatement" && statement.argument?.type === "CallExpression" && statement.argument.callee?.type === "MemberExpression"
217
+ && statement.argument.callee.object.name === "Promise" && memberName(statement.argument.callee) === "reject"))
218
+ && promiseCatches.every((callback) => callback && (rejected(callback.body) || fails(callback.body)))
219
+ }
220
+
221
+ function boundedDependency(method, injected, bindings, source) {
222
+ if (!propagatesFailures(method.value.body)) return false
223
+ const nodes = expandedNodes(method.value.body, bindings)
224
+ return nodes.some((node) => {
225
+ if (node.type !== "CallExpression" || !consumedCall(node, method.value.body, bindings)) return false
226
+ const callee = unwrap(node.callee)
227
+ if (callee?.type !== "MemberExpression") return false
228
+ if (injectedCall(node, injected)) return requiredCall(node, injected, source) && expandedNodes(node, bindings).some((entry) => entry.type === "CallExpression"
229
+ && entry.callee.type === "MemberExpression" && entry.callee.object.name === "AbortSignal" && memberName(entry.callee) === "timeout" && positiveDuration(entry.arguments[0], bindings))
230
+ if (callee.object.name !== "Promise" || memberName(callee) !== "race") return false
231
+ const racers = expandedNodes(node.arguments[0], bindings)
232
+ return racers.some((entry) => requiredCall(entry, injected, source)) && rejectingTimeout(racers, bindings, injected, source)
233
+ })
234
+ }
235
+
236
+ function classInSource(name, program, filename, root, seen = new Set()) {
237
+ const identity = `${filename}:${name}`
238
+ if (seen.has(identity)) return null
239
+ const next = new Set(seen).add(identity)
240
+ let local = classesOf(program).get(name)
241
+ walk(program, (node) => { if (["TSInterfaceDeclaration", "TSTypeAliasDeclaration", "VariableDeclarator", "FunctionDeclaration"].includes(node.type) && node.id?.name === name) local = node })
242
+ if (local) return { controller: local, program, filename }
243
+ for (const entry of program.body.filter((node) => ["ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) && node.source)) {
244
+ const specifier = entry.specifiers?.find((node) => keyName(node.exported) === name)
245
+ if (entry.type !== "ExportAllDeclaration" && !specifier) continue
246
+ const target = sourcePath(entry.source.value, filename, root)
247
+ const imported = target && readProgram(target)
248
+ const controller = imported && classInSource(keyName(specifier?.local) ?? name, imported, target, root, next)
249
+ if (controller) return controller
250
+ }
251
+ return null
252
+ }
253
+
254
+ function delegatedClass(name, program, filename, root) {
255
+ const local = classInSource(name, program, filename, root)
256
+ if (local) return local
257
+ for (const entry of program.body.filter((node) => node.type === "ImportDeclaration")) {
258
+ const specifier = entry.specifiers.find((node) => node.local.name === name)
259
+ if (!specifier) continue
260
+ const target = sourcePath(entry.source.value, filename, root)
261
+ const imported = target && readProgram(target)
262
+ const controller = imported && classInSource(specifier.imported?.name ?? name, imported, target, root)
263
+ if (controller) return controller
264
+ }
265
+ return null
266
+ }
267
+
268
+ function consumedDependencies(method, injected, source) {
269
+ const bindings = bindingsOf(source.program)
270
+ return expandedNodes(method.value.body, bindings).filter((node) => consumedCall(node, method.value.body, bindings))
271
+ .map((node) => ({ node, call: injectedCall(node, injected) })).filter((entry) => entry.call)
272
+ }
273
+
274
+ function requiredCall(node, injected, source, visited = new Set()) {
275
+ const call = injectedCall(node, injected)
276
+ if (!call || timerMethod(node, injected, source)) return false
277
+ const target = delegatedClass(call.type, source.program, source.filename, source.root)
278
+ if (!target) return true
279
+ if (target.controller.type !== "ClassDeclaration") return false
280
+ const method = methodsOf(target.controller).find((entry) => keyName(entry.key) === call.method)
281
+ const identity = `${target.filename}:${call.type}:${call.method}`
282
+ if (!method || visited.has(identity) || !propagatesFailures(method.value.body)) return false
283
+ const next = new Set(visited).add(identity)
284
+ const location = { ...target, root: source.root }
285
+ const dependencies = injectedOf(target.controller)
286
+ return consumedDependencies(method, dependencies, location).some((entry) => requiredCall(entry.node, dependencies, location, next))
287
+ }
288
+
289
+ function importedAs(program, name, imported, module) {
290
+ return program.body.some((entry) => entry.type === "ImportDeclaration" && entry.source.value === module
291
+ && entry.specifiers.some((specifier) => specifier.local.name === name && keyName(specifier.imported) === imported))
292
+ }
293
+
294
+ function moduleObjects(controller) {
295
+ const objects = []
296
+ const decorator = controller.decorators?.find((entry) => decoratorName(entry) === "Module")
297
+ const declared = decorator?.expression.arguments?.[0]
298
+ if (declared?.type === "ObjectExpression") objects.push(declared)
299
+ for (const method of methodsOf(controller).filter((entry) => entry.static)) walk(method.value.body, (node) => {
300
+ if (node.type === "ReturnStatement" && node.argument?.type === "ObjectExpression") objects.push(node.argument)
301
+ })
302
+ return objects
303
+ }
304
+
305
+ function moduleReferences(controller, field, source) {
306
+ const names = new Set()
307
+ for (const object of moduleObjects(controller)) {
308
+ const value = object.properties.find((entry) => keyName(entry.key) === field)?.value
309
+ for (const node of expandedNodes(value, bindingsOf(source.program))) if (node.type === "Identifier") names.add(node.name)
310
+ }
311
+ return [...names].map((name) => delegatedClass(name, source.program, source.filename, source.root))
312
+ .filter((entry) => entry?.controller.type === "ClassDeclaration")
313
+ }
314
+
315
+ const sameClass = (left, right) => left.filename === right.filename && left.controller.id.name === right.controller.id.name
316
+
317
+ function routeModules(controller, source) {
318
+ const roots = []
319
+ let directory = path.dirname(source.filename)
320
+ while (!path.relative(source.root, directory).startsWith("..")) {
321
+ for (const entry of fs.readdirSync(directory).filter((name) => name.endsWith(".module.ts"))) {
322
+ const filename = path.join(directory, entry)
323
+ const program = readProgram(filename)
324
+ if (!program) continue
325
+ const location = { program, filename, root: source.root }
326
+ for (const module of classesOf(program).values()) {
327
+ const target = { controller, filename: source.filename }
328
+ if (moduleReferences(module, "controllers", location).some((candidate) => sameClass(candidate, target))) roots.push({ ...location, controller: module })
329
+ }
330
+ }
331
+ if (directory === source.root) break
332
+ directory = path.dirname(directory)
333
+ }
334
+ return roots
335
+ }
336
+
337
+ function registeredProviders(controller, source) {
338
+ const pending = routeModules(controller, source)
339
+ const visited = new Set()
340
+ const providers = []
341
+ while (pending.length) {
342
+ const module = pending.pop()
343
+ const identity = `${module.filename}:${module.controller.id.name}`
344
+ if (visited.has(identity)) continue
345
+ visited.add(identity)
346
+ providers.push(...moduleReferences(module.controller, "providers", { ...module, root: source.root }))
347
+ pending.push(...moduleReferences(module.controller, "imports", { ...module, root: source.root }))
348
+ }
349
+ return providers
350
+ }
351
+
352
+ function thisCall(node, name) {
353
+ return node.type === "CallExpression" && node.callee.type === "MemberExpression"
354
+ && node.callee.object.type === "ThisExpression" && memberName(node.callee) === name
355
+ }
356
+
357
+ function handlerEntry(target, root) {
358
+ const own = methodsOf(target.controller)
359
+ const execute = own.find((entry) => keyName(entry.key) === "execute")
360
+ if (execute) return execute
361
+ const base = target.controller.superClass
362
+ const parent = base?.type === "Identifier" && delegatedClass(base.name, target.program, target.filename, root)
363
+ const inherited = parent?.controller.type === "ClassDeclaration" && methodsOf(parent.controller).find((entry) => keyName(entry.key) === "execute")
364
+ if (!inherited || !propagatesFailures(inherited.value.body)) return null
365
+ const bindings = bindingsOf(parent.program)
366
+ const consumesProcess = expandedNodes(inherited.value.body, bindings).some((node) => thisCall(node, "process") && consumedCall(node, inherited.value.body, bindings))
367
+ return consumesProcess && own.find((entry) => keyName(entry.key) === "process")
368
+ }
369
+
370
+ function handlerOf(target, query, root) {
371
+ return target.controller.decorators?.some((entry) => {
372
+ const expression = entry.expression
373
+ if (!importedAs(target.program, expression.callee?.name, "QueryHandler", "@nestjs/cqrs")) return false
374
+ const name = expression.arguments?.[0]?.name
375
+ const handled = name && delegatedClass(name, target.program, target.filename, root)
376
+ return handled && sameClass(handled, query)
377
+ })
378
+ }
379
+
380
+ function dispatchedQueries(controller, method, source) {
381
+ const injected = injectedOf(controller)
382
+ const bindings = bindingsOf(source.program)
383
+ return expandedNodes(method.value.body, bindings).flatMap((node) => {
384
+ const callee = unwrap(node.callee)
385
+ if (node.type !== "CallExpression" || callee?.type !== "MemberExpression" || memberName(callee) !== "execute") return []
386
+ const owner = callee.object
387
+ if (owner.type !== "MemberExpression" || owner.object.type !== "ThisExpression") return []
388
+ if (!importedAs(source.program, injected.get(memberName(owner)), "QueryBus", "@nestjs/cqrs")) return []
389
+ if (!consumedCall(node, method.value.body, bindings)) return []
390
+ const message = unwrap(node.arguments[0])
391
+ const target = message?.type === "NewExpression" && delegatedClass(message.callee.name, source.program, source.filename, source.root)
392
+ return target ? [target] : []
393
+ })
394
+ }
395
+
396
+ function busReadiness(controller, method, source, visited) {
397
+ const queries = dispatchedQueries(controller, method, source)
398
+ if (!queries.length) return false
399
+ return registeredProviders(controller, source).some((target) => {
400
+ if (!queries.some((query) => handlerOf(target, query, source.root))) return false
401
+ const entry = handlerEntry(target, source.root)
402
+ return entry && realReadiness(target.controller, entry, target.program, target.filename, source.root, visited)
403
+ })
404
+ }
405
+
406
+ function realReadiness(controller, method, program, filename, root, visited = new Set()) {
407
+ const identity = `${filename}:${controller.id.name}:${keyName(method.key)}`
408
+ if (visited.has(identity)) return false
409
+ const next = new Set(visited).add(identity)
410
+ const injected = injectedOf(controller)
411
+ const source = { program, filename, root, controller }
412
+ if (boundedDependency(method, injected, bindingsOf(program), source)) return true
413
+ if (!propagatesFailures(method.value.body)) return false
414
+ if (busReadiness(controller, method, source, next)) return true
415
+ return consumedDependencies(method, injected, source).some(({ call }) => {
416
+ const target = call.type && delegatedClass(call.type, program, filename, root)
417
+ const delegated = target?.controller.type === "ClassDeclaration" && methodsOf(target.controller).find((entry) => keyName(entry.key) === call.method)
418
+ return delegated && realReadiness(target.controller, delegated, target.program, target.filename, root, next)
419
+ })
420
+ }
421
+
422
+ /** Readiness delegation is proved through the injected service; the HTTP controller remains a thin door. */
423
+ export const readyReal = {
424
+ meta: {
425
+ type: "problem",
426
+ docs: { description: "BE_READY_REAL: readiness probes an injected required dependency and fails under a bounded timeout." },
427
+ schema: [],
428
+ messages: { real: "BE_READY_REAL: this readiness route must probe an injected required dependency with a failing, bounded timeout; a liveness query or an unconditional success is not readiness." },
429
+ },
430
+ create(context) {
431
+ const hfs = hfsOf(context)
432
+ const filename = context.filename || context.getFilename()
433
+ if (hfs.slotOf(filename) !== "be.transport.http") return {}
434
+ return {
435
+ "Program:exit"(program) {
436
+ for (const controller of classesOf(program).values()) for (const { route, method } of routesOf(controller)) {
437
+ if (!/(?:\/health\/ready|\/readyz)$/.test(route)) continue
438
+ if (!realReadiness(controller, method, program, filename, hfs.repoRoot)) context.report({ node: method, messageId: "real" })
439
+ }
440
+ },
441
+ }
442
+ },
443
+ }
444
+
445
+ /** Follow the app's local import closure; unused imports do not install a health transport. */
446
+ function wiringNames(program, wanted) {
447
+ const used = new Set()
448
+ const bindings = bindingsOf(program)
449
+ const collect = (node) => { for (const entry of expandedNodes(node, bindings)) if (entry.type === "Identifier") used.add(entry.name) }
450
+ const moduleObject = (node) => {
451
+ if (node?.type !== "ObjectExpression") return
452
+ for (const property of node.properties) if (["imports", "controllers"].includes(keyName(property.key))) collect(property.value)
453
+ }
454
+ for (const controller of classesOf(program).values()) {
455
+ if (wanted?.includes(controller.id.name) === false) continue
456
+ const module = controller.decorators?.find((entry) => decoratorName(entry) === "Module")
457
+ moduleObject(module?.expression.arguments?.[0])
458
+ for (const method of methodsOf(controller).filter((entry) => entry.static)) walk(method.value.body, (node) => {
459
+ if (node.type === "ReturnStatement" && node.argument?.type === "ObjectExpression" && node.argument.properties.some((entry) => keyName(entry.key) === "module")) moduleObject(node.argument)
460
+ })
461
+ }
462
+ walk(program, (node) => {
463
+ if (node.type === "VariableDeclarator" && wanted?.includes(node.id.name)) collect(node.init)
464
+ if (node.type === "FunctionDeclaration" && wanted?.includes(node.id?.name)) collect(node.body)
465
+ if (node.type === "ExportNamedDeclaration" && !node.source) for (const specifier of node.specifiers) if (!wanted || wanted.includes(keyName(specifier.exported))) used.add(keyName(specifier.local))
466
+ })
467
+ return used
468
+ }
469
+
470
+ function importedTargets(program, filename, root, used) {
471
+ const targets = []
472
+ for (const entry of program.body.filter((node) => node.type === "ImportDeclaration")) {
473
+ if (entry.importKind === "type" || !entry.specifiers.some((specifier) => used.has(specifier.local.name))) continue
474
+ const target = sourcePath(entry.source.value, filename, root)
475
+ const imported = entry.specifiers.filter((specifier) => used.has(specifier.local.name)).map((specifier) => keyName(specifier.imported) ?? specifier.local.name)
476
+ if (target) targets.push({ filename: target, wanted: imported })
477
+ }
478
+ return targets
479
+ }
480
+
481
+ function exportedTargets(program, filename, root, wanted) {
482
+ const targets = []
483
+ for (const entry of program.body.filter((node) => ["ExportNamedDeclaration", "ExportAllDeclaration"].includes(node.type) && node.source && node.exportKind !== "type")) {
484
+ const target = sourcePath(entry.source.value, filename, root)
485
+ const exported = entry.type === "ExportAllDeclaration" ? wanted : entry.specifiers.filter((specifier) => !wanted || wanted.includes(keyName(specifier.exported))).map((specifier) => keyName(specifier.local))
486
+ if (target && (!exported || exported.length)) targets.push({ filename: target, wanted: exported })
487
+ }
488
+ return targets
489
+ }
490
+
491
+ function usedRoutes(program, wanted, used) {
492
+ return [...classesOf(program).values()].filter((controller) => !wanted || wanted.includes(controller.id.name) || used.has(controller.id.name))
493
+ .flatMap((controller) => routesOf(controller).map((entry) => entry.route))
494
+ }
495
+
496
+ function appRoutes(filename, root) {
497
+ const routes = new Set()
498
+ const pending = [{ filename, wanted: ["AppModule"] }]
499
+ const visited = new Set()
500
+ while (pending.length) {
501
+ const { filename: current, wanted } = pending.pop()
502
+ const identity = `${current}:${wanted?.join(",") ?? "*"}`
503
+ if (visited.has(identity)) continue
504
+ visited.add(identity)
505
+ const program = readProgram(current)
506
+ if (!program) continue
507
+ const used = wiringNames(program, wanted)
508
+ for (const route of usedRoutes(program, wanted, used)) routes.add(route)
509
+ pending.push(...importedTargets(program, current, root, used), ...exportedTargets(program, current, root, wanted))
510
+ }
511
+ return routes
512
+ }
513
+
514
+ /** Each declared API composition exposes both process-local liveness and required-dependency readiness. */
515
+ export const healthSignals = {
516
+ meta: {
517
+ type: "problem",
518
+ docs: { description: "BE_HEALTH_SIGNALS: every API app exposes /health/live and /health/ready." },
519
+ schema: [],
520
+ messages: { signals: "BE_HEALTH_SIGNALS: API {{app}} must register both /health/live and /health/ready in its module composition (missing {{missing}})." },
521
+ },
522
+ create(context) {
523
+ const hfs = hfsOf(context)
524
+ const filename = context.filename || context.getFilename()
525
+ if (appRoleOf(hfs, filename)?.entry !== "main.ts") return {}
526
+ const app = hfs.apps.find((entry) => entry.name === hfs.classify(filename).bindings?.app)
527
+ if (app?.kind !== "api") return {}
528
+ return {
529
+ "Program:exit"(node) {
530
+ const routes = appRoutes(path.join(path.dirname(filename), "app.module.ts"), hfs.repoRoot)
531
+ const missing = ["/health/live", "/health/ready"].filter((route) => !routes.has(route))
532
+ if (missing.length) context.report({ node, messageId: "signals", data: { app: app.name, missing: missing.join(", ") } })
533
+ },
534
+ }
535
+ },
536
+ }
537
+
538
+ export const rules = { "ready-real": readyReal, "health-signals": healthSignals }
539
+ export const recommended = { "starci-be/ready-real": "error", "starci-be/health-signals": "error" }
package/index.mjs CHANGED
@@ -183,3 +183,40 @@ export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin: pluginFo
183
183
 
184
184
  export { linterOptions } from "./lib/config.mjs"
185
185
  export { loadHfs } from "./lib/hfs.mjs"
186
+
187
+ // A12 registration is appended so the shared registry can be union-merged.
188
+ import { recommended as thirdPartyOwnerRecommended, rules as thirdPartyOwnerRules } from "./third-party-owner.mjs"
189
+ Object.assign(rules, thirdPartyOwnerRules)
190
+ Object.assign(recommended, thirdPartyOwnerRecommended)
191
+ lawOwners.push("third-party-owner")
192
+ for (const name of Object.keys(thirdPartyOwnerRules)) {
193
+ ruleOwners[name] = "third-party-owner"
194
+ // infra-owner also exposes these rules so the catalog's static contribution reader sees them.
195
+ const declaration = ruleDeclarations.find((entry) => entry.name === name)
196
+ if (declaration) declaration.law = "third-party-owner"
197
+ else ruleDeclarations.push({ law: "third-party-owner", name })
198
+ }
199
+ // A21 append-only registration: keep the already exported plugin and recommendation live.
200
+ import { recommended as functionBudgetRecommended, rules as functionBudgetRules } from "./function-budget.mjs"
201
+ CONTRIBUTIONS.push({ law: "function-budget", rules: functionBudgetRules, recommended: functionBudgetRecommended })
202
+ lawOwners.push("function-budget")
203
+ ruleDeclarations.push(...Object.keys(functionBudgetRules).map((name) => ({ law: "function-budget", name })))
204
+ Object.assign(ruleOwners, Object.fromEntries(Object.keys(functionBudgetRules).map((name) => [name, "function-budget"])))
205
+ Object.assign(rules, functionBudgetRules)
206
+ Object.assign(recommended, functionBudgetRecommended)
207
+ // A06 registrations are appended: the coordinator union-merges this shared registry.
208
+ import { recommended as edgeHardeningRecommended, rules as edgeHardeningRules } from "./edge-hardening.mjs"
209
+ import { recommended as healthReadyRecommended, rules as healthReadyRules } from "./health-ready.mjs"
210
+ import { recommended as jwtAlgRecommended, rules as jwtAlgRules } from "./jwt-alg.mjs"
211
+
212
+ for (const entry of [
213
+ { law: "edge-hardening", rules: edgeHardeningRules, recommended: edgeHardeningRecommended },
214
+ { law: "health-ready", rules: healthReadyRules, recommended: healthReadyRecommended },
215
+ { law: "jwt-alg", rules: jwtAlgRules, recommended: jwtAlgRecommended },
216
+ ]) {
217
+ lawOwners.push(entry.law)
218
+ ruleDeclarations.push(...Object.keys(entry.rules).map((name) => ({ law: entry.law, name })))
219
+ Object.assign(ruleOwners, Object.fromEntries(Object.keys(entry.rules).map((name) => [name, entry.law])))
220
+ Object.assign(rules, entry.rules)
221
+ Object.assign(recommended, entry.recommended)
222
+ }
package/infra-owner.mjs CHANGED
@@ -26,6 +26,7 @@
26
26
  import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
27
27
  import { moduleReferences } from "./lib/import-source.mjs"
28
28
  import { isOwnedType } from "./lib/ports.mjs"
29
+ import { recommended as thirdPartyRecommended, rules as thirdPartyRules } from "./third-party-owner.mjs"
29
30
 
30
31
  /** The owner id of a file: the last two segments of its owner root (`platform/http`), or null when no owner holds it. */
31
32
  const ownerIdOf = (hfs, filename) => {
@@ -126,10 +127,12 @@ export const httpClientOnlyInIntegrations = {
126
127
  export const rules = {
127
128
  "infra-import-owner": infraImportOwner,
128
129
  "http-client-only-in-integrations": httpClientOnlyInIntegrations,
130
+ ...thirdPartyRules,
129
131
  }
130
132
 
131
133
  /** Every rule of this law at `error`. */
132
134
  export const recommended = {
133
135
  "starci-be/infra-import-owner": "error",
134
136
  "starci-be/http-client-only-in-integrations": "error",
137
+ ...thirdPartyRecommended,
135
138
  }
package/jwt-alg.mjs ADDED
@@ -0,0 +1,45 @@
1
+ /** R260 BE_JWT_ALG_PINNED: remote JWT verification pins algorithms, configuration and principal role. */
2
+ import ts from "typescript"
3
+ import { typed } from "./lib/types.mjs"
4
+ import { isDeclarationFile } from "./lib/path.mjs"
5
+ import { jwtPolicyProblems } from "./runtime/scripts/hfs/architecture/supabase-ast.mjs"
6
+
7
+ /** Recognize jose imports and aliases through their declarations, including namespace imports. */
8
+ const joseVerifier = (checker, expression, seen = new Set()) => {
9
+ if (!expression || seen.has(expression)) return false
10
+ seen.add(expression)
11
+ if (ts.isPropertyAccessExpression(expression)) {
12
+ if (expression.name.text !== "jwtVerify") return false
13
+ return checker.getSymbolAtLocation(expression.expression)?.declarations?.some(declaration =>
14
+ ts.isNamespaceImport(declaration) && declaration.parent.parent.moduleSpecifier.text === "jose") ?? false
15
+ }
16
+ return checker.getSymbolAtLocation(expression)?.declarations?.some(declaration => {
17
+ if (ts.isImportSpecifier(declaration)) return (declaration.propertyName ?? declaration.name).text === "jwtVerify"
18
+ && declaration.parent.parent.parent.moduleSpecifier.text === "jose"
19
+ return ts.isVariableDeclaration(declaration) && declaration.initializer && joseVerifier(checker, declaration.initializer, seen)
20
+ }) ?? false
21
+ }
22
+
23
+ export const jwtAlgPinned = {
24
+ meta: {
25
+ type: "problem",
26
+ docs: { description: "R260 BE_JWT_ALG_PINNED: literal secure algorithms, config issuer and authenticated principal role." },
27
+ schema: [],
28
+ messages: { pinned: "{{detail}}" },
29
+ },
30
+ create(context) {
31
+ if (isDeclarationFile(context.filename || context.getFilename())) return {}
32
+ const { checker, toTs } = typed(context)
33
+ return {
34
+ CallExpression(node) {
35
+ const call = toTs(node)
36
+ if (!call || !joseVerifier(checker, call.expression)) return
37
+ const problems = jwtPolicyProblems(ts, checker, call)
38
+ if (problems.length) context.report({ node, messageId: "pinned", data: { detail: problems.join(" ") } })
39
+ },
40
+ }
41
+ },
42
+ }
43
+
44
+ export const rules = { "jwt-alg-pinned": jwtAlgPinned }
45
+ export const recommended = { "starci-be/jwt-alg-pinned": "error" }