@starci/eslint-canon-be 3.2.18 → 3.3.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 (50) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +46 -4
  3. package/app-composition.mjs +176 -0
  4. package/config-owner.mjs +34 -19
  5. package/feature-thin.mjs +59 -17
  6. package/index.mjs +2 -0
  7. package/lib/app-roles.mjs +48 -0
  8. package/lib/webhook-ack.mjs +87 -0
  9. package/lib/webhook-handoff.mjs +138 -0
  10. package/lib/webhook-verifiers.mjs +147 -0
  11. package/module-layering.mjs +2 -2
  12. package/module-shape.mjs +6 -5
  13. package/package.json +1 -1
  14. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  15. package/runtime/knowledge/hfs/rules.yaml +41 -13
  16. package/runtime/knowledge/hfs/slots.yaml +99 -43
  17. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  18. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  19. package/runtime/scripts/api/git/diff.mjs +5 -0
  20. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  21. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  22. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  23. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  24. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  25. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  26. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  27. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  28. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  29. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  30. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  31. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  32. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  33. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  34. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  35. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  36. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  37. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  38. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  39. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  40. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  41. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  42. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  43. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  44. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  47. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  48. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  49. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  50. package/webhooks.mjs +141 -69
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The roles of the files of a back-end app (`apps/<app>/src/`, the `be.app.*` slots), read from the slot manifest: the name of a
3
+ * file is the `requires` or `allows` entry of its app slot that it matches, so no rule keeps a path list of its own.
4
+ *
5
+ * - `main.ts` the entry: builds the environment source once, parses options and boots the app.
6
+ * - `app.module.ts` the composition root: spreads the composition groups and binds the app-wide guards and filter.
7
+ * - `<app>.env.ts` the typed, fail-fast environment parsing only; no module composition or business code.
8
+ * - `<app>.options.ts` the options types and port bindings (provider maps); no environment reads.
9
+ * - `compose/<area>.ts` a composition group: the module list of one area of the app, spread by the root.
10
+ */
11
+ import { normalizePath } from "./path.mjs"
12
+
13
+ /** The composition root of an app. */
14
+ const APP_ROOT_ROLE = "app.module.ts"
15
+ /** The options of an app. */
16
+ export const APP_OPTIONS_ROLE = "<app>.options.ts"
17
+ /** The environment parsing of an app. */
18
+ export const APP_ENV_ROLE = "<app>.env.ts"
19
+ /** A composition group of an app. */
20
+ const COMPOSE_GROUP_ROLE = "compose/<area>.ts"
21
+
22
+ /**
23
+ * The app-slot entry a file matches (`main.ts`, `app.module.ts`, `<app>.env.ts`, `<app>.options.ts`, `compose/<area>.ts`, ...), or null for a
24
+ * file outside the `be.app.*` slots or one its slot does not name.
25
+ *
26
+ * @param {object} hfs - The HFS view of the repository.
27
+ * @param {string} filename - The linted file.
28
+ * @returns {{slot: string, entry: string} | null} The app slot and the entry the file matches.
29
+ */
30
+ export const appRoleOf = (hfs, filename) => {
31
+ const file = normalizePath(filename)
32
+ const slot = hfs.slotOf(file) ?? ""
33
+ if (!slot.startsWith("be.app.") || typeof hfs.allows !== "function") return null
34
+ const verdict = hfs.allows(file)
35
+ return verdict?.allowed && verdict.entry ? { slot, entry: verdict.entry } : null
36
+ }
37
+
38
+ /**
39
+ * Whether a file composes the app: its root `app.module.ts` or a composition group `compose/<area>.ts`.
40
+ *
41
+ * @param {object} hfs - The HFS view of the repository.
42
+ * @param {string} filename - The linted file.
43
+ * @returns {boolean} True for a composition file of a back-end app.
44
+ */
45
+ export const isAppComposition = (hfs, filename) => {
46
+ const role = appRoleOf(hfs, filename)?.entry
47
+ return role === APP_ROOT_ROLE || role === COMPOSE_GROUP_ROLE
48
+ }
@@ -0,0 +1,87 @@
1
+ /** A provider's only POST body is its own exported, immutable literal acknowledgement. */
2
+ import ts from "typescript"
3
+ import { aliasTarget } from "./doors.mjs"
4
+ import { typed } from "./types.mjs"
5
+
6
+ /** Static wrappers carry a type, never permission to compute the acknowledgement. */
7
+ const unwrap = (node) => {
8
+ while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression
9
+ return node
10
+ }
11
+
12
+ /** A scalar literal includes JSON null and a signed numeric literal, but no identifier or template. */
13
+ const scalarLiteral = (node) => {
14
+ if (!node) return false
15
+ if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) return true
16
+ if ([ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(node.kind)) return true
17
+ return ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken].includes(node.operator) && ts.isNumericLiteral(node.operand)
18
+ }
19
+
20
+ /** Literal data recursively excludes spreads, computed keys, methods, accessors and input references. */
21
+ const literalData = (expression) => {
22
+ const node = unwrap(expression)
23
+ if (scalarLiteral(node)) return true
24
+ if (node && ts.isArrayLiteralExpression(node)) return node.elements.every(literalData)
25
+ if (!node || !ts.isObjectLiteralExpression(node)) return false
26
+ return node.properties.every((property) => ts.isPropertyAssignment(property)
27
+ && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) || ts.isNumericLiteral(property.name))
28
+ && literalData(property.initializer))
29
+ }
30
+
31
+ /** The declaration has a const assertion anywhere in its static initializer wrappers. */
32
+ const constAsserted = (expression) => {
33
+ let node = expression
34
+ while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) {
35
+ if (ts.isAsExpression(node) && ts.isTypeReferenceNode(node.type) && ts.isIdentifier(node.type.typeName) && node.type.typeName.text === "const") return true
36
+ node = node.expression
37
+ }
38
+ return false
39
+ }
40
+
41
+ /** A readonly literal annotation cannot widen the provider's fixed body to arbitrary data. */
42
+ const builtinReadonly = (node, services) => {
43
+ if (!ts.isTypeReferenceNode(node) || !ts.isIdentifier(node.typeName) || node.typeName.text !== "Readonly" || node.typeArguments?.length !== 1) return false
44
+ const symbol = services.checker.getSymbolAtLocation(node.typeName)
45
+ return (symbol?.getDeclarations() ?? []).some((declaration) => services.program.isSourceFileDefaultLibrary(declaration.getSourceFile()))
46
+ }
47
+
48
+ /** Readonly's built-in mapped type is trusted; a locally shadowed alias is not. */
49
+ const readonlyLiteralType = (node, services, readonly = false) => {
50
+ if (!node) return false
51
+ if (ts.isLiteralTypeNode(node)) return scalarLiteral(node.literal)
52
+ if (ts.isTypeOperatorNode(node) && node.operator === ts.SyntaxKind.ReadonlyKeyword) return readonlyLiteralType(node.type, services, true)
53
+ if (builtinReadonly(node, services)) return readonlyLiteralType(node.typeArguments[0], services, true)
54
+ if (ts.isTupleTypeNode(node)) return readonly && node.elements.every((entry) => readonlyLiteralType(entry, services))
55
+ if (!ts.isTypeLiteralNode(node) || node.members.length === 0) return false
56
+ return node.members.every((member) => ts.isPropertySignature(member) && !member.questionToken
57
+ && (readonly || member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ReadonlyKeyword))
58
+ && readonlyLiteralType(member.type, services))
59
+ }
60
+
61
+ /** The actual symbol is exported by the source module, including a separate export declaration. */
62
+ const exportedByModule = (checker, declaration, symbol) => {
63
+ const sourceSymbol = checker.getSymbolAtLocation(declaration.getSourceFile())
64
+ if (!sourceSymbol) return false
65
+ return checker.getExportsOfModule(sourceSymbol).some((entry) => {
66
+ const target = entry.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(entry) : entry
67
+ return entry.name === "PROVIDER_ACK" && target === symbol
68
+ })
69
+ }
70
+
71
+ /** A return names the declared PROVIDER_ACK itself, with identity and ownership resolved by TypeScript/HFS. */
72
+ export const isProviderAcknowledgement = (context, hfs, expression) => {
73
+ if (expression?.type !== "Identifier") return false
74
+ const symbol = aliasTarget(context, expression)
75
+ if (!symbol || symbol.name !== "PROVIDER_ACK") return false
76
+ const services = typed(context)
77
+ const { checker } = services
78
+ const owner = hfs.ownerOf(context.filename)
79
+ return (symbol.getDeclarations() ?? []).some((declaration) => {
80
+ if (!ts.isVariableDeclaration(declaration) || !(declaration.parent.flags & ts.NodeFlags.Const)) return false
81
+ if (owner === null || hfs.ownerOf(declaration.getSourceFile().fileName) !== owner) return false
82
+ if (!exportedByModule(checker, declaration, symbol)) return false
83
+ const value = unwrap(declaration.initializer)
84
+ if (!value || !(ts.isObjectLiteralExpression(value) || ts.isStringLiteral(value)) || !literalData(value)) return false
85
+ return constAsserted(declaration.initializer) || readonlyLiteralType(declaration.type, services)
86
+ })
87
+ }
@@ -0,0 +1,138 @@
1
+ /** Typed webhook handoff checks: the HTTP door records a delivery; only a later consumer dispatches commands. */
2
+ import ts from "typescript"
3
+ import { walk } from "./ast.mjs"
4
+ import { injectedMembers } from "./doors.mjs"
5
+ import { moduleReferences } from "./import-source.mjs"
6
+ import { baseName } from "./ports.mjs"
7
+ import { moduleOf, originsOfType, typed } from "./types.mjs"
8
+
9
+ const CQRS = "@nestjs/cqrs"
10
+ const BUSES = new Set(["CommandBus", "QueryBus"])
11
+
12
+ /** A feature handler has a feature owner and the closed handler role, regardless of its imported alias. */
13
+ const isFeatureHandler = (hfs, file) => hfs.tierOf(file) === "feature" && baseName(file).endsWith(".handler.ts")
14
+
15
+ /** The bus's package and declared name, or the feature handler's declaring file, establish the forbidden origin. */
16
+ const forbiddenOrigin = (hfs, origin, buses) =>
17
+ (origin.module === CQRS && buses.has(origin.name)) || isFeatureHandler(hfs, origin.file)
18
+
19
+ /** Include unions, intersections and class inheritance, so an alias or a subclass cannot conceal the bus. */
20
+ const hasForbiddenType = (checker, hfs, type, buses = BUSES, seen = new Set()) => {
21
+ if (!type || seen.has(type)) return false
22
+ seen.add(type)
23
+ if (originsOfType(checker, type).some((origin) => forbiddenOrigin(hfs, origin, buses))) return true
24
+ const parts = type.isUnionOrIntersection() ? type.types : []
25
+ const bases = type.isClassOrInterface() ? checker.getBaseTypes(type) ?? [] : []
26
+ return [...parts, ...bases].some((part) => hasForbiddenType(checker, hfs, part, buses, seen))
27
+ }
28
+
29
+ /** A bus or feature handler receiver is never a webhook's delegating call. */
30
+ export const isWebhookForbiddenReceiver = (context, hfs, node) => {
31
+ const { checker, toTs } = typed(context)
32
+ const reference = toTs(node)
33
+ return reference ? hasForbiddenType(checker, hfs, checker.getTypeAtLocation(reference)) : false
34
+ }
35
+
36
+ /** Resolve an imported/exported alias once before inspecting the actual declaration. */
37
+ const targetOf = (checker, symbol) => symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
38
+
39
+ /** A module export's origin, including a bus subclass or an alias of its type. */
40
+ const forbiddenExport = (checker, hfs, original) => {
41
+ const symbol = targetOf(checker, original)
42
+ if (!symbol) return false
43
+ const declarations = symbol.getDeclarations() ?? []
44
+ if (declarations.some((declaration) => {
45
+ const file = declaration.getSourceFile().fileName.replace(/\\/g, "/")
46
+ return isFeatureHandler(hfs, file) || (moduleOf(declaration, file) === CQRS && BUSES.has(symbol.getName()))
47
+ })) return true
48
+ if (declarations.length === 0) return false
49
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declarations[0])
50
+ if (hasForbiddenType(checker, hfs, type)) return true
51
+ return !!(symbol.flags & (ts.SymbolFlags.Class | ts.SymbolFlags.Interface | ts.SymbolFlags.TypeAlias))
52
+ && hasForbiddenType(checker, hfs, checker.getDeclaredTypeOfSymbol(symbol))
53
+ }
54
+
55
+ /** Inspect selected exports of the referenced module, so barrels and renamed imports keep their real owner. */
56
+ const forbiddenModuleReference = (checker, toTs, hfs, reference) => {
57
+ const { source, value, names } = reference
58
+ if (value === CQRS && names.some((name) => BUSES.has(name) || name === "*" || name === "default")) return true
59
+ const sourceNode = toTs(source)
60
+ const module = sourceNode ? targetOf(checker, checker.getSymbolAtLocation(sourceNode)) : null
61
+ if (!module) return false
62
+ const exports = checker.getExportsOfModule(module)
63
+ const wholeModule = names.includes("*") || (names.includes("default") && !exports.some((symbol) => symbol.getName() === "default"))
64
+ return exports.some((symbol) => (wholeModule || names.includes(symbol.getName())) && forbiddenExport(checker, hfs, symbol))
65
+ }
66
+
67
+ /**
68
+ * Forbidden import sites in a door or its declaring HTTP module. Named, type-only, namespace, dynamic and re-exported
69
+ * imports all resolve to the actual export; an inline import type is judged by its resolved type.
70
+ */
71
+ export const webhookForbiddenReferences = (context, hfs, program) => {
72
+ const { checker, toTs } = typed(context)
73
+ const nodes = new Set()
74
+ const visitors = moduleReferences((reference) => {
75
+ if (forbiddenModuleReference(checker, toTs, hfs, reference)) nodes.add(reference.node)
76
+ })
77
+ walk(program, (node) => {
78
+ visitors[node.type]?.(node)
79
+ if (node.type !== "TSImportType") return
80
+ const typeNode = toTs(node)
81
+ if (typeNode && hasForbiddenType(checker, hfs, checker.getTypeAtLocation(typeNode))) nodes.add(node)
82
+ })
83
+ // An ambiently supplied type still cannot be injected; an import finding already refuses the same dependency.
84
+ if (nodes.size === 0) walk(program, (node) => {
85
+ if (node.type !== "ClassDeclaration") return
86
+ for (const member of injectedMembers(context, node)) {
87
+ if (member.annotation && isWebhookForbiddenReceiver(context, hfs, member.annotation)) nodes.add(member.node)
88
+ }
89
+ })
90
+ return [...nodes]
91
+ }
92
+
93
+ /** The symbol of a called member, including a statically resolved bracket access. */
94
+ const calledSymbol = (checker, expression) => {
95
+ if (ts.isPropertyAccessExpression(expression)) return targetOf(checker, checker.getSymbolAtLocation(expression.name))
96
+ if (ts.isElementAccessExpression(expression)) return targetOf(checker, checker.getSymbolAtLocation(expression.argumentExpression))
97
+ return targetOf(checker, checker.getSymbolAtLocation(expression))
98
+ }
99
+
100
+ /** Overload declarations point back to their implementation; arrow-valued service members also carry a body. */
101
+ const bodyOf = (declaration) => {
102
+ if (declaration?.body) return declaration.body
103
+ const initializer = declaration?.initializer
104
+ return initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) ? initializer.body : null
105
+ }
106
+
107
+ /** Bodies the checker actually resolves at the intake call, never every other method of the service. */
108
+ const calledBodies = (checker, call) => {
109
+ const signature = checker.getResolvedSignature(call)?.declaration
110
+ const declarations = calledSymbol(checker, call.expression)?.getDeclarations() ?? []
111
+ return [...new Set([signature, ...declarations].map(bodyOf).filter(Boolean))]
112
+ }
113
+
114
+ /** A method body dispatches on a CQRS bus or feature handler when the receiver's genuine declared type proves it. */
115
+ const dispatchesCommand = (checker, hfs, body) => {
116
+ let found = false
117
+ const visit = (node) => {
118
+ if (found) return
119
+ if (ts.isCallExpression(node) && (ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression))) {
120
+ const type = checker.getTypeAtLocation(node.expression.expression)
121
+ if (hasForbiddenType(checker, hfs, type)) found = true
122
+ }
123
+ ts.forEachChild(node, visit)
124
+ }
125
+ visit(body)
126
+ return found
127
+ }
128
+
129
+ /**
130
+ * Inspect a resolved domain intake's own body, closures included. Dynamic receivers, erased types, external signatures and
131
+ * calls through further helpers remain semantic review: this check does not prove the full inbox/outbox transaction.
132
+ */
133
+ export const webhookIntakeDispatchesCommand = (context, hfs, call) => {
134
+ const { checker, toTs } = typed(context)
135
+ const reference = toTs(call)
136
+ if (!reference || !ts.isCallExpression(reference)) return false
137
+ return calledBodies(checker, reference).some((body) => dispatchesCommand(checker, hfs, body))
138
+ }
@@ -0,0 +1,147 @@
1
+ /** Typed webhook proofs shared by the webhook and thin-feature laws. */
2
+ import { dirname } from "node:path"
3
+ import ts from "typescript"
4
+ import { walk } from "./ast.mjs"
5
+ import { baseName, isOwnedType } from "./ports.mjs"
6
+ import { originsOfType, typeOrigins, typed } from "./types.mjs"
7
+
8
+ /** The shared platform proof, identified by its declared owner. */
9
+ export const isWebhookSignatureType = (context, node) => isOwnedType(context, node, { name: "WebhookSignatureService", capability: "http-security", tier: "platform" })
10
+
11
+ /** A service declared by a domain owner, rather than a similarly named local or package type. */
12
+ export const isWebhookDomainServiceType = (context, hfs, node) =>
13
+ typeOrigins(context, node).some((origin) => origin.module === null && hfs.tierOf(origin.file) === "domain" && baseName(origin.file).endsWith(".service.ts"))
14
+
15
+ /** The method symbol named by a property access, resolving imported aliases. */
16
+ const methodSymbol = (checker, expression) => {
17
+ if (!expression || !ts.isPropertyAccessExpression(expression)) return null
18
+ const symbol = checker.getSymbolAtLocation(expression.name)
19
+ return symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol ?? null
20
+ }
21
+
22
+ /** Unwrap static TypeScript annotations around a declaration's array literal. */
23
+ const arrayOf = (expression) => {
24
+ while (expression && (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isParenthesizedExpression(expression))) expression = expression.expression
25
+ return expression && ts.isArrayLiteralExpression(expression) ? expression : null
26
+ }
27
+
28
+ /** All possible receiver types must be declared verifier classes; a type alias cannot rename a general service into one. */
29
+ const isDomainVerifierType = (context, hfs, receiver) => {
30
+ const { checker, toTs } = typed(context)
31
+ const type = checker.getTypeAtLocation(toTs(receiver))
32
+ const parts = type.isUnion() ? type.types : [type]
33
+ return parts.every((part) => part.getSymbol()?.getDeclarations()?.some((declaration) => {
34
+ const file = declaration.getSourceFile().fileName.replace(/\\/g, "/")
35
+ return ts.isClassDeclaration(declaration) && declaration.name && /(?:Verifier|verify)$/.test(declaration.name.text)
36
+ && hfs.tierOf(file) === "domain" && baseName(file).endsWith(".service.ts")
37
+ }))
38
+ }
39
+
40
+ /**
41
+ * A provider's adjacent HTTP module may export webhookVerifiers as an array of
42
+ * domain methods, e.g. [BillingService.prototype.receiveCassoWebhook] as const.
43
+ * Compare symbols, so spelling an arbitrary receiver or alias as a verifier grants nothing.
44
+ */
45
+ const declaresMethod = (checker, statement, method) => {
46
+ if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const)) return false
47
+ if (!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) return false
48
+ return statement.declarationList.declarations.some((declaration) => ts.isIdentifier(declaration.name)
49
+ && declaration.name.text === "webhookVerifiers"
50
+ && arrayOf(declaration.initializer)?.elements.some((entry) => methodSymbol(checker, entry) === method))
51
+ }
52
+
53
+ /** The module declaration is adjacent to this exact provider door, not another feature's proof list. */
54
+ const isProviderModule = (context, source) => {
55
+ const file = source.fileName.replace(/\\/g, "/")
56
+ const directory = dirname(context.filename).replace(/\\/g, "/")
57
+ const moduleName = `${baseName(context.filename).slice(0, -".webhook.ts".length)}-http.module.ts`
58
+ return dirname(file).replace(/\\/g, "/") === directory && baseName(file) === moduleName
59
+ }
60
+
61
+ /** A general domain service method is a verifier only when this provider's module declares its resolved symbol. */
62
+ const isModuleDeclaredVerifier = (context, call) => {
63
+ const { program, checker, toTs } = typed(context)
64
+ const method = methodSymbol(checker, toTs(call.callee))
65
+ if (method === null) return false
66
+ return program.getSourceFiles().some((source) => isProviderModule(context, source)
67
+ && source.statements.some((statement) => declaresMethod(checker, statement, method)))
68
+ }
69
+
70
+ /** A direct, unconditional method call to the shared proof or a declared domain verifier. */
71
+ const isWebhookVerificationCall = (context, hfs, call, domainOnly = false) => {
72
+ if (call?.type !== "CallExpression" || call.optional) return false
73
+ const callee = call.callee
74
+ if (callee.type !== "MemberExpression" || callee.computed || callee.optional || callee.property.type !== "Identifier") return false
75
+ const { checker, toTs } = typed(context)
76
+ const receiverType = checker.getTypeAtLocation(toTs(callee.object))
77
+ const parts = receiverType.isUnion() ? receiverType.types : [receiverType]
78
+ if (!domainOnly && callee.property.name === "verify" && parts.every((part) => originsOfType(checker, part).some((origin) => origin.module === null && origin.name === "WebhookSignatureService" && hfs.tierOf(origin.file) === "platform" && hfs.ownerOf(origin.file)?.split("/").at(-1) === "http-security"))) return true
79
+ if (!isWebhookDomainServiceType(context, hfs, callee.object)) return false
80
+ return isDomainVerifierType(context, hfs, callee.object) || isModuleDeclaredVerifier(context, call)
81
+ }
82
+
83
+ /** Expressions that can execute work or skip the proof before its call is entered. */
84
+ const PRE_PROOF_WORK = new Set(["CallExpression", "NewExpression", "ImportExpression", "TaggedTemplateExpression", "AssignmentExpression", "UpdateExpression", "AwaitExpression", "YieldExpression", "ChainExpression", "ConditionalExpression", "LogicalExpression", "SequenceExpression"])
85
+
86
+ /**
87
+ * The first statement must itself be the proof (awaited if it returns a thenable).
88
+ * Its receiver and arguments may pass delivery data, but cannot run another call first.
89
+ */
90
+ const completedVerification = (context, hfs, expression, domainOnly = false) => {
91
+ let call = expression
92
+ const awaited = call?.type === "AwaitExpression"
93
+ if (awaited) call = call.argument
94
+ if (!isWebhookVerificationCall(context, hfs, call, domainOnly)) return null
95
+ let work = false
96
+ walk(call, (child) => {
97
+ if (child !== call && (PRE_PROOF_WORK.has(child.type) || (child.type === "UnaryExpression" && child.operator === "delete"))) work = true
98
+ })
99
+ if (work) return null
100
+ const { checker, toTs } = typed(context)
101
+ const signature = checker.getResolvedSignature(toTs(call))
102
+ if (!signature) return null
103
+ const returned = checker.getReturnTypeOfSignature(signature)
104
+ const parts = returned.isUnion() ? returned.types : [returned]
105
+ if (!awaited && parts.some((part) => checker.getApparentType(part).getProperty("then"))) return null
106
+ return call
107
+ }
108
+
109
+ /** POST's first statement remains the proof itself, before the intake or acknowledgement. */
110
+ export const firstWebhookVerification = (context, hfs, method) => {
111
+ const first = method.value.body?.body[0]
112
+ return completedVerification(context, hfs, first?.type === "ExpressionStatement" ? first.expression : null)
113
+ }
114
+
115
+ /** A challenge verifier must produce an echo value, rather than a void proof. */
116
+ const hasChallengeResult = (context, call) => {
117
+ const { checker, toTs } = typed(context)
118
+ const signature = checker.getResolvedSignature(toTs(call))
119
+ const returned = signature && checker.getReturnTypeOfSignature(signature)
120
+ const value = returned && (checker.getAwaitedType(returned) ?? returned)
121
+ const parts = value?.isUnion() ? value.types : [value]
122
+ return parts.every((part) => part && !(part.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)))
123
+ }
124
+
125
+ /** The optional GET proves once, binding its result with const or returning the verifier call directly. */
126
+ export const firstWebhookChallenge = (context, hfs, method) => {
127
+ const statements = method.value.body?.body ?? []
128
+ const first = statements[0]
129
+ if (first?.type === "ReturnStatement") {
130
+ const call = completedVerification(context, hfs, first.argument, true)
131
+ return call && hasChallengeResult(context, call) ? { call, result: null, echo: first, statements } : null
132
+ }
133
+ if (first?.type !== "VariableDeclaration" || first.kind !== "const" || first.declarations.length !== 1) return null
134
+ const binding = first.declarations[0]
135
+ if (binding.id.type !== "Identifier") return null
136
+ const call = completedVerification(context, hfs, binding.init, true)
137
+ return call && hasChallengeResult(context, call) ? { call, result: binding.id, echo: statements[1], statements } : null
138
+ }
139
+
140
+ /** The GET echoes only the original proof's value, with no intervening work or transformation. */
141
+ export const isWebhookChallengeReturn = (context, node, challenge) => {
142
+ if (!challenge || node !== challenge.echo || node.type !== "ReturnStatement") return false
143
+ if (challenge.result === null) return challenge.statements.length === 1
144
+ if (challenge.statements.length !== 2 || node.argument?.type !== "Identifier") return false
145
+ const { checker, toTs } = typed(context)
146
+ return checker.getSymbolAtLocation(toTs(node.argument)) === checker.getSymbolAtLocation(toTs(challenge.result))
147
+ }
@@ -67,9 +67,9 @@ const onSpecifiers = (check) => ({
67
67
  },
68
68
  })
69
69
 
70
- /** The public-surface width: the `budget.indexExports` of the slot that owns the file, else of the first back-end owner slot that states one. */
70
+ /** The public-surface width: the file's slot budget, else its tier's owner-slot budget, else a back-end owner default. */
71
71
  const indexExportBudget = (hfs, filename) =>
72
- [hfs.slotOf(filename), "be.domain", "be.feature"].map((id) => (id ? hfs.slot(id)?.budget?.indexExports : undefined)).find(Number.isInteger)
72
+ [hfs.slotOf(filename), `be.${hfs.tierOf(filename)}`, "be.domain", "be.feature"].map((id) => (id ? hfs.slot(id)?.budget?.indexExports : undefined)).find(Number.isInteger)
73
73
 
74
74
  // -- R30: import-owner-entry -----------------------------------------------------------------------
75
75
 
package/module-shape.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * - `no-global-decorator` refuses `@Global()` anywhere, specs included.
8
8
  * - `is-global-only-in-app` refuses `isGlobal: true` (and a dynamic module literal `global: true`) outside
9
- * `apps/<app>/src/app.module.ts`.
9
+ * `apps/<app>/src/app.module.ts` and the composition groups `apps/<app>/src/compose/<area>.ts` it spreads.
10
10
  * - `no-cross-owner-module-import` refuses a `@Module({ imports })` entry whose module class is declared by another
11
11
  * owner of the repository (the declaring owner is found by resolving the class, not by reading its name): a
12
12
  * capability's representative module is reached through its `Inject*()` decorators, never by import. One owner may
@@ -28,6 +28,7 @@
28
28
  import ts from "typescript"
29
29
  import { decoratorName, keyName, walk } from "./lib/ast.mjs"
30
30
  import { importsFrom } from "./lib/declared.mjs"
31
+ import { isAppComposition } from "./lib/app-roles.mjs"
31
32
  import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
32
33
  import { isDeclarationFile, isSpecFile, normalizePath } from "./lib/path.mjs"
33
34
  import { typed } from "./lib/types.mjs"
@@ -61,8 +62,8 @@ export const noGlobalDecorator = {
61
62
  },
62
63
  }
63
64
 
64
- /** The app composition file: `apps/<app>/src/app.module.ts`, judged by the app slot and the file's role name. */
65
- const isAppModule = (hfs, filename) => (hfs.slotOf(filename) ?? "").startsWith("be.app.") && filename.split("/").at(-1) === "app.module.ts"
65
+ /** The app composition files: `apps/<app>/src/app.module.ts` and its groups `compose/<area>.ts`, judged by the app slot's entries. */
66
+ const isAppModule = isAppComposition
66
67
 
67
68
  /**
68
69
  * The test world (slot `be.tests.world`, `src/tests/world`) is the TEST COMPOSITION ROOT: `use-test-world.ts` assembles
@@ -77,10 +78,10 @@ const isTrue = (node) => node?.type === "Literal" && node.value === true
77
78
  export const isGlobalOnlyInApp = {
78
79
  meta: {
79
80
  type: "problem",
80
- docs: { description: "`isGlobal: true` appears only in `apps/<app>/src/app.module.ts`." },
81
+ docs: { description: "`isGlobal: true` appears only in `apps/<app>/src/app.module.ts` and its composition groups `compose/<area>.ts`." },
81
82
  schema: [],
82
83
  messages: {
83
- isGlobal: "`{{key}}: true` outside `apps/<app>/src/app.module.ts` makes a module global from the wrong place. Only the app registers a capability, once, with `isGlobal: true`; here keep the module's own default.",
84
+ isGlobal: "`{{key}}: true` outside `apps/<app>/src/app.module.ts` and its `compose/<area>.ts` groups makes a module global from the wrong place. Only the app registers a capability, once, with `isGlobal: true`; here keep the module's own default.",
84
85
  },
85
86
  },
86
87
  create(context) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "3.2.18",
3
+ "version": "3.3.0",
4
4
  "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.2.18
32
+ version: 3.3.0
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.2.17
39
+ version: 8.3.0
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.2.16
79
+ version: 1.3.0
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.2.16
86
+ version: 4.3.0
87
87
  group: starci
88
88
  install: registry
89
89
  side: both