@starci/eslint-canon-be 3.0.7 → 3.0.9

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 (171) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/hfs.mjs +3 -3
  28. package/lib/path.mjs +0 -6
  29. package/lib/persistence.mjs +11 -18
  30. package/lib/ports.mjs +5 -2
  31. package/lib/specifier.mjs +27 -0
  32. package/lib/transactions.mjs +37 -0
  33. package/lib/transport-slots.mjs +8 -3
  34. package/lib/unit-spec.mjs +63 -2
  35. package/module-layering.mjs +3 -24
  36. package/module-shape.mjs +2 -2
  37. package/naming.mjs +2 -2
  38. package/package.json +5 -3
  39. package/project-graph.mjs +8 -5
  40. package/projections.mjs +117 -0
  41. package/query-safety.mjs +1 -1
  42. package/realtime.mjs +181 -0
  43. package/runtime/engine/config.mjs +494 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/runtime-root.mjs +5 -0
  47. package/runtime/engine/yaml.mjs +3 -3
  48. package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
  49. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  50. package/runtime/knowledge/hfs/slots.yaml +915 -123
  51. package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
  52. package/runtime/knowledge/sonar-gate.yaml +53 -19
  53. package/runtime/modules/models/registry.yaml +591 -0
  54. package/runtime/modules/models/runtimes.yaml +701 -0
  55. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  56. package/runtime/scripts/api/git/config-get.mjs +10 -0
  57. package/runtime/scripts/api/git/lib.mjs +17 -27
  58. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  59. package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
  60. package/runtime/scripts/api/git/remote.mjs +5 -0
  61. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  62. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  63. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  64. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
  65. package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
  66. package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
  67. package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
  68. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
  69. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
  70. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  71. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  72. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  73. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  74. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  75. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
  76. package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
  77. package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
  78. package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
  79. package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
  80. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
  81. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
  82. package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
  83. package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
  84. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  85. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
  86. package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
  87. package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
  88. package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
  89. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  90. package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
  91. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  92. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
  93. package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
  94. package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
  95. package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
  96. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
  97. package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +17 -114
  98. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  99. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  100. package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +27 -147
  101. package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +59 -18
  102. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  103. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  104. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  107. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +10 -4
  108. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
  109. package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
  110. package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
  111. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
  112. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  113. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  114. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  115. package/runtime/scripts/hfs/manifest-shape.mjs +247 -0
  116. package/runtime/scripts/hfs/param-names.mjs +18 -0
  117. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
  118. package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
  119. package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
  120. package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
  121. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  122. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
  123. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
  124. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
  125. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
  126. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
  127. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  128. package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
  129. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
  130. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  131. package/runtime/scripts/lib/git.mjs +36 -0
  132. package/runtime/scripts/lib/i18n.mjs +86 -0
  133. package/runtime/scripts/lib/json.mjs +13 -3
  134. package/runtime/scripts/lib/language.mjs +26 -7
  135. package/runtime/scripts/lib/line-count.mjs +9 -0
  136. package/runtime/scripts/lib/list.mjs +20 -0
  137. package/runtime/scripts/lib/path-key.mjs +59 -2
  138. package/runtime/scripts/lib/read-text.mjs +34 -0
  139. package/runtime/scripts/lib/regex.mjs +4 -0
  140. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  141. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  142. package/runtime/scripts/lib/stack-services.mjs +7 -31
  143. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  144. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
  145. package/schema-authority.mjs +14 -12
  146. package/service-deps.mjs +7 -13
  147. package/service-isolation.mjs +72 -0
  148. package/size-budget.mjs +3 -2
  149. package/test-world.mjs +5 -4
  150. package/testing.mjs +27 -24
  151. package/transport.mjs +12 -9
  152. package/unit-spec.mjs +1 -1
  153. package/user-copy.mjs +5 -4
  154. package/webhooks.mjs +173 -0
  155. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  156. package/runtime/scripts/lib/recorded-lines.mjs +0 -27
  157. package/runtime/scripts/lib/test-secrets.mjs +0 -116
  158. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  159. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  160. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  161. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  162. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  163. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  164. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  165. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  166. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  167. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  168. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  169. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  170. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  171. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The rule that holds the thin-feature law (R203 `BE_FEATURE_THIN`).
3
+ *
4
+ * A file of the feature tier whose role is one of `ruleParams.be.thinRoles` of the slot manifest
5
+ * (`<name>.handler.ts`, `<name>.resolver.ts`, `<name>.consumer.ts`, `<name>.processor.ts`, `<name>.step.ts`,
6
+ * `<name>.webhook.ts`, `<name>.mapper.ts`, ...) is a door: it maps its parameters and makes ONE delegating
7
+ * call into the modules (a service, the command or query bus, the event bus, a queue producer). No branch,
8
+ * loop, `try` or `throw` on business data and no computation lives in it - the logic belongs to the modules,
9
+ * where every file is unit-covered at 100, and orchestration (step order, compensation, claim, fencing,
10
+ * retry) belongs to the platform. A `mapper` file holds only property mapping: `*.mapper.ts` calls, `new`, and the shape conversions `map` and `toISOString`
11
+ * expressions, no delegating call at all.
12
+ *
13
+ * Kind comes from the SLOT and the TIER (`hfs.tierOf(filename)`, `hfs.ruleParams.thinRoles`), the role from
14
+ * the file-name suffix the closed role vocabulary defines - never a path regex. What a receiver is comes
15
+ * from its TYPE (`Logger` of `platform/logging`, `WebhookSignatureService` of `platform/http-security`) and
16
+ * from whether the member is injected (`injectedMembers`); what a free function is comes from the file its
17
+ * import resolves to. A job `step` records its fence through `JobClaims` (`advance`, `runKey`) and a webhook proves its signature through `verify`: those gate calls
18
+ * are free, the one effect each makes is the delegating call. `.spec.ts` and `.d.ts` are never judged, and neither is a file whose tier is not
19
+ * `feature` or whose role is not a thin role. A clean thin file is never flagged: that is the bar.
20
+ */
21
+ import { keyName, walk } from "./lib/ast.mjs"
22
+ import { aliasTarget, injectedMembers } from "./lib/doors.mjs"
23
+ import { hfsOf } from "./lib/hfs.mjs"
24
+ import { importOf } from "./lib/import-source.mjs"
25
+ import { baseName, isLoggerType, isOwnedType } from "./lib/ports.mjs"
26
+
27
+ /** The statements and expressions that are a decision, a repetition or an exit: none lives in a thin file. */
28
+ const BRANCH_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement", "LogicalExpression"])
29
+
30
+ /** The expressions that compute a value (a `TemplateLiteral` with an expression joins them). */
31
+ const COMPUTE_NODES = new Set(["BinaryExpression", "UnaryExpression", "UpdateExpression", "AssignmentExpression"])
32
+
33
+ /** The nodes a function can be: the walk for calls and the nesting test use the same set. */
34
+ const FUNCTION_NODES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "MethodDefinition"])
35
+
36
+ /** What one decision or loop node is called in a message. */
37
+ const BRANCH_WHAT = {
38
+ IfStatement: "an `if`",
39
+ SwitchStatement: "a `switch`",
40
+ ConditionalExpression: "a ternary",
41
+ ForStatement: "a `for`",
42
+ ForInStatement: "a `for-in`",
43
+ ForOfStatement: "a `for-of`",
44
+ WhileStatement: "a `while`",
45
+ DoWhileStatement: "a `do-while`",
46
+ TryStatement: "a `try`",
47
+ ThrowStatement: "a `throw`",
48
+ }
49
+
50
+ /** The wraps a call may sit under while still being the call of its statement. */
51
+ const STATEMENT_WRAPPERS = new Set(["AwaitExpression", "TSAsExpression", "TSSatisfiesExpression", "TSNonNullExpression", "ChainExpression"])
52
+
53
+ /** The closed-vocabulary role a file name carries (`<name>.<role>.ts` gives `<role>`), or null. */
54
+ const roleOf = (filename) => /\.([a-z][a-z0-9-]*)\.ts$/.exec(baseName(filename))?.[1] ?? null
55
+
56
+ /** The `WebhookSignatureService` of `platform/http-security` - the `verify` a webhook door may call. */
57
+ const isSignatureReceiver = (context, node) => isOwnedType(context, node, { name: "WebhookSignatureService", capability: "http-security", tier: "platform" })
58
+
59
+ /** The shape conversions a mapper may make on a value it received: a collection mapped item by item and a date written as text. They decide nothing and compute nothing. */
60
+ const MAPPER_SHAPE_METHODS = new Set(["map", "toISOString"])
61
+
62
+ /** The `JobClaims` port of `platform/jobs` - the fence a job step records itself through (`advance`, `runKey`): bookkeeping the fenced-job pattern requires, free beside the step's one delegating call. */
63
+ const isClaimsReceiver = (context, node) => isOwnedType(context, node, { name: "JobClaims", capability: "jobs", tier: "platform" })
64
+
65
+ /** The `Logger` port of `platform/logging` - the one call that is always free. */
66
+ const isLoggerReceiver = isLoggerType
67
+
68
+ /** The name of a thin file's law is `feature-thin` (R203). */
69
+ export const featureThin = {
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description: "A feature-tier file of a thin role maps its parameters and makes one delegating call into the modules: no branch, loop, `try`, `throw` or computation, and no call other than the one delegate, a modules function in mapping position, a `*.mapper.ts` function or the Logger." },
73
+ schema: [],
74
+ messages: {
75
+ branch: "{{what}} is a decision or a repetition in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and makes one delegating call into the modules; a branch, a loop, a `try` or a `throw` is business logic that belongs in a service of the modules (where a unit spec covers it) - and orchestration (step order, compensation, claim, fencing, retry) belongs to the platform.",
76
+ compute: "{{what}} computes a value in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and hands them over; an expression that computes is logic that belongs in the modules. Map the value there and read it back.",
77
+ call: "`{{what}}` is a call a thin file does not make. It calls exactly one injected member (a service, a bus `execute`, an event bus, a queue producer) or one function of the modules, and pure `*.mapper.ts` functions and the `Logger` port are free{{webhook}}; everything else - parsing, mapping by hand, a helper, a second dependency - is logic that belongs in the modules.",
78
+ many: "`{{name}}` makes a second delegating call here. A thin file makes exactly one; a second is orchestration - step order, compensation, claim, fencing, retry - and belongs to a capability of the platform or inside the service the file calls.",
79
+ },
80
+ },
81
+ create(context) {
82
+ const hfs = hfsOf(context)
83
+ const name = baseName(context.filename)
84
+ if (name.endsWith(".spec.ts") || name.endsWith(".d.ts")) return {}
85
+ const role = roleOf(name)
86
+ if (role === null || !(hfs.ruleParams.thinRoles ?? []).includes(role)) return {}
87
+ if (hfs.tierOf(context.filename) !== "feature") return {}
88
+ const sourceCode = context.sourceCode
89
+ const mapperFile = role === "mapper"
90
+
91
+ /** The injected member names of a class: constructor parameter properties and properties carrying an injector decorator. */
92
+ const memberNames = new Map()
93
+ const injectedNamesOf = (classNode) => {
94
+ if (memberNames.has(classNode)) return memberNames.get(classNode)
95
+ const names = new Set()
96
+ for (const { node } of injectedMembers(context, classNode)) {
97
+ if (node.type === "TSParameterProperty" && node.parameter.type === "Identifier") names.add(node.parameter.name)
98
+ else if (node.type === "PropertyDefinition") {
99
+ const key = keyName(node.key)
100
+ if (key !== null) names.add(key)
101
+ }
102
+ }
103
+ memberNames.set(classNode, names)
104
+ return names
105
+ }
106
+
107
+ /**
108
+ * The class `this` binds at a node's position: the nearest enclosing class, or null when a non-arrow
109
+ * function or an object-literal method rebinds `this` in between (a method's own `FunctionExpression`
110
+ * value is skipped - a method's `this` is the class).
111
+ */
112
+ const thisClassOf = (node) => {
113
+ for (let parent = node.parent; parent; parent = parent.parent) {
114
+ if (parent.type === "ClassDeclaration" || parent.type === "ClassExpression") return parent
115
+ if (parent.type === "FunctionDeclaration") return null
116
+ if (parent.type === "FunctionExpression" && parent.parent?.type !== "MethodDefinition") return null
117
+ }
118
+ return null
119
+ }
120
+
121
+ /** The member name of `this.<member>` the call's receiver names, when it is an injected member of the enclosing class. */
122
+ const injectedMemberOf = (object, call) => {
123
+ if (object.type !== "MemberExpression" || object.computed || object.object.type !== "ThisExpression" || object.property.type !== "Identifier") return null
124
+ const classNode = thisClassOf(call)
125
+ if (classNode === null) return null
126
+ const member = object.property.name
127
+ return injectedNamesOf(classNode).has(member) ? member : null
128
+ }
129
+
130
+ /** True for a call `f(...)` where `f` is imported from a `*.mapper.ts` (the pure mapping functions). */
131
+ const isMapperCall = (callee) => {
132
+ if (callee.type !== "Identifier") return false
133
+ const found = importOf(context, callee)
134
+ return found !== null && /(?:^|\/)[^/]+\.mapper(?:\.[cm]?[jt]s)?$/.test(found.source.replace(/\\/g, "/"))
135
+ }
136
+
137
+ /** True for a call `f(...)` where `f` is imported and its declaration lives outside the feature tier (a function of the modules). */
138
+ const isModulesCall = (callee) => {
139
+ if (callee.type !== "Identifier" || importOf(context, callee) === null) return false
140
+ const symbol = aliasTarget(context, callee)
141
+ return (symbol?.getDeclarations() ?? []).some((declaration) => hfs.tierOf(declaration.getSourceFile().fileName) !== "feature")
142
+ }
143
+
144
+ /** True when the call IS the statement it sits in: `f(...)`, `await f(...)`, `return (await) f(...)`, or the whole body of a concise arrow. */
145
+ const isStatementCall = (call) => {
146
+ let current = call
147
+ while (STATEMENT_WRAPPERS.has(current.parent?.type)) current = current.parent
148
+ const parent = current.parent
149
+ return parent?.type === "ExpressionStatement" || parent?.type === "ReturnStatement" || (parent?.type === "ArrowFunctionExpression" && parent.body === current)
150
+ }
151
+
152
+ /**
153
+ * What one call is to a thin file: `delegating` (the one call into the modules a file may make),
154
+ * `free` (a mapper, a logger method, a webhook `verify`, a job step's `JobClaims` fence call, a modules function in a mapping position)
155
+ * or `call` (a call a thin file does not make). A `mapper` file holds only mapper calls and `new`.
156
+ */
157
+ const classify = (call) => {
158
+ const callee = call.callee
159
+ if (callee.type === "Identifier" && isMapperCall(callee)) return "free"
160
+ const member = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
161
+ if (mapperFile && member !== null && MAPPER_SHAPE_METHODS.has(member.property.name) && injectedMemberOf(member.object, call) === null) return "free"
162
+ // a cli group command shows its help without a sub-command: the `help` of the command nest-commander gives it
163
+ if (role === "cli" && member !== null && member.property.name === "help" && member.object.type === "MemberExpression" && member.object.object.type === "ThisExpression" && member.object.property.type === "Identifier" && member.object.property.name === "command") return "free"
164
+ if (mapperFile) return "call"
165
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && injectedMemberOf(callee.object, call) !== null) {
166
+ if (isLoggerReceiver(context, callee.object)) return "free"
167
+ if (role === "webhook" && callee.property.name === "verify" && isSignatureReceiver(context, callee.object)) return "free"
168
+ if (role === "step" && isClaimsReceiver(context, callee.object)) return "free"
169
+ return "delegating"
170
+ }
171
+ if (callee.type === "Identifier" && isModulesCall(callee)) return isStatementCall(call) ? "delegating" : "free"
172
+ return "call"
173
+ }
174
+
175
+ /** Every root function of the file (a method body, function or arrow not nested in another function and not inside a decorator subtree). */
176
+ const rootFunctionsOf = (program) => {
177
+ const roots = []
178
+ walk(program, (node) => {
179
+ if (!FUNCTION_NODES.has(node.type)) return
180
+ for (let parent = node.parent; parent; parent = parent.parent) {
181
+ if (parent.type === "Decorator" || FUNCTION_NODES.has(parent.type)) return
182
+ }
183
+ if (node.type === "MethodDefinition" && node.kind === "constructor") return
184
+ roots.push(node)
185
+ })
186
+ return roots
187
+ }
188
+
189
+ /** The name a `many` message reports for a function root. */
190
+ const nameOf = (fn) => {
191
+ if (fn.type === "MethodDefinition") return keyName(fn.key) ?? "the method"
192
+ if (fn.type === "FunctionDeclaration" && fn.id?.type === "Identifier") return fn.id.name
193
+ const parent = fn.parent
194
+ if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
195
+ if ((parent?.type === "PropertyDefinition" || parent?.type === "Property") && keyName(parent.key)) return keyName(parent.key)
196
+ return "the function"
197
+ }
198
+
199
+ const visitors = {}
200
+ for (const type of BRANCH_NODES) {
201
+ visitors[type] = (node) => context.report({ node, messageId: "branch", data: { what: type === "LogicalExpression" ? `a \`${node.operator}\`` : BRANCH_WHAT[type], role } })
202
+ }
203
+ for (const type of COMPUTE_NODES) {
204
+ visitors[type] = (node) => context.report({ node, messageId: "compute", data: { what: `an \`${node.operator}\``, role } })
205
+ }
206
+ visitors.TemplateLiteral = (node) => {
207
+ if (node.expressions.length > 0) context.report({ node, messageId: "compute", data: { what: "a template literal with an expression", role } })
208
+ }
209
+ visitors["Program:exit"] = (program) => {
210
+ for (const root of rootFunctionsOf(program)) {
211
+ const body = root.type === "MethodDefinition" ? root.value?.body : root.body
212
+ if (!body) continue
213
+ let delegating = 0
214
+ walk(body, (node) => {
215
+ if (node.type !== "CallExpression") return
216
+ const kind = classify(node)
217
+ if (kind === "call") context.report({ node, messageId: "call", data: { what: sourceCode.getText(node.callee), webhook: role === "webhook" ? ", plus `verify` of the signature service" : "" } })
218
+ else if (kind === "delegating") {
219
+ delegating += 1
220
+ if (delegating === 2) context.report({ node, messageId: "many", data: { name: nameOf(root) } })
221
+ }
222
+ })
223
+ }
224
+ }
225
+ return visitors
226
+ },
227
+ }
228
+
229
+ /** The rules this law contributes to the plugin. */
230
+ export const rules = {
231
+ "feature-thin": featureThin,
232
+ }
233
+
234
+ /** Every rule of this law at `error`. */
235
+ export const recommended = {
236
+ "starci-be/feature-thin": "error",
237
+ }
package/idempotency.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { hfsOf } from "./lib/hfs.mjs"
26
26
  import { walk } from "./lib/ast.mjs"
27
- import { baseName, isOwnedType } from "./lib/ports.mjs"
27
+ import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
28
28
  import { typed } from "./lib/types.mjs"
29
29
 
30
30
  /** The `Inbox` port of `platform/inbox`. */
@@ -99,6 +99,8 @@ export const inboxDedupeRequired = {
99
99
  const filename = context.filename || context.getFilename()
100
100
  const slot = hfs.slotOf(filename)
101
101
  if (!baseName(filename).endsWith(".service.ts") || !slot || slot.startsWith("be.tests.")) return {}
102
+ // The event bus publishes events (their shape carries an `eventId`) and its runtime claims the inbox before a consumer runs: no delivery method here.
103
+ if (slot.startsWith("be.platform") && ownerNameOf(hfs, filename) === "event-bus") return {}
102
104
  return {
103
105
  MethodDefinition(node) {
104
106
  if (node.kind !== "method" || node.static || node.key.type === "PrivateIdentifier" || !node.value.body) return
package/index.mjs CHANGED
@@ -17,15 +17,21 @@
17
17
  * leave two names for one rule and no way to tell which a message came from.
18
18
  */
19
19
  import { buildBeConfig } from "./lib/config.mjs"
20
+ import { pluginForEdition } from "./lib/edition.mjs"
21
+ import { recommended as ambientIdRecommended, rules as ambientIdRules } from "./ambient-id.mjs"
20
22
  import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
21
23
  import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
22
24
  import { recommended as buildersRecommended, rules as buildersRules } from "./builders.mjs"
23
25
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
24
26
  import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
25
27
  import { recommended as connectionsRecommended, rules as connectionsRules } from "./connections.mjs"
28
+ import { recommended as cookiesRecommended, rules as cookiesRules } from "./cookies.mjs"
26
29
  import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
27
30
  import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
28
31
  import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
32
+ import { recommended as eventBusRecommended, rules as eventBusRules } from "./event-bus.mjs"
33
+ import { recommended as jobsRecommended, rules as jobsRules } from "./jobs.mjs"
34
+ import { recommended as projectionsRecommended, rules as projectionsRules } from "./projections.mjs"
29
35
  import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
30
36
  import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
31
37
  import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
@@ -41,7 +47,9 @@ import { recommended as operationsRecommended, rules as operationsRules } from "
41
47
  import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
42
48
  import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
43
49
  import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
50
+ import { recommended as cliRecommended, rules as cliRules } from "./cli.mjs"
44
51
  import { recommended as serviceDepsRecommended, rules as serviceDepsRules } from "./service-deps.mjs"
52
+ import { recommended as serviceIsolationRecommended, rules as serviceIsolationRules } from "./service-isolation.mjs"
45
53
  import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
46
54
  import { recommended as specNoSkipRecommended, rules as specNoSkipRules } from "./spec-no-skip.mjs"
47
55
  import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
@@ -53,20 +61,29 @@ import { recommended as transportRecommended, rules as transportRules } from "./
53
61
  import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
54
62
  import { recommended as unitSpecRecommended, rules as unitSpecRules } from "./unit-spec.mjs"
55
63
  import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
64
+ import { recommended as webhooksRecommended, rules as webhooksRules } from "./webhooks.mjs"
65
+ import { recommended as realtimeRecommended, rules as realtimeRules } from "./realtime.mjs"
66
+ import { recommended as featureThinRecommended, rules as featureThinRules } from "./feature-thin.mjs"
56
67
  import { recommended as projectGraphRecommended, rules as projectGraphRules } from "./project-graph.mjs"
57
68
 
58
69
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
59
70
  const CONTRIBUTIONS = [
60
71
  { law: "project-graph", rules: projectGraphRules, recommended: projectGraphRecommended },
72
+ { law: "ambient-id", rules: ambientIdRules, recommended: ambientIdRecommended },
61
73
  { law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
62
74
  { law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
63
75
  { law: "builders", rules: buildersRules, recommended: buildersRecommended },
76
+ { law: "cli", rules: cliRules, recommended: cliRecommended },
64
77
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
65
78
  { law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
66
79
  { law: "connections", rules: connectionsRules, recommended: connectionsRecommended },
80
+ { law: "cookies", rules: cookiesRules, recommended: cookiesRecommended },
67
81
  { law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
68
82
  { law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
69
83
  { law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
84
+ { law: "event-bus", rules: eventBusRules, recommended: eventBusRecommended },
85
+ { law: "jobs", rules: jobsRules, recommended: jobsRecommended },
86
+ { law: "projections", rules: projectionsRules, recommended: projectionsRecommended },
70
87
  { law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
71
88
  { law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
72
89
  { law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
@@ -83,6 +100,7 @@ const CONTRIBUTIONS = [
83
100
  { law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
84
101
  { law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
85
102
  { law: "service-deps", rules: serviceDepsRules, recommended: serviceDepsRecommended },
103
+ { law: "service-isolation", rules: serviceIsolationRules, recommended: serviceIsolationRecommended },
86
104
  { law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
87
105
  { law: "spec-no-skip", rules: specNoSkipRules, recommended: specNoSkipRecommended },
88
106
  { law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
@@ -94,6 +112,9 @@ const CONTRIBUTIONS = [
94
112
  { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
95
113
  { law: "unit-spec", rules: unitSpecRules, recommended: unitSpecRecommended },
96
114
  { law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
115
+ { law: "webhooks", rules: webhooksRules, recommended: webhooksRecommended },
116
+ { law: "realtime", rules: realtimeRules, recommended: realtimeRecommended },
117
+ { law: "feature-thin", rules: featureThinRules, recommended: featureThinRecommended },
97
118
  ]
98
119
 
99
120
  /** Every gathered law. */
@@ -154,7 +175,7 @@ export default plugin
154
175
  * @param {{ hfs: object }} input - The HFS view of the repository.
155
176
  * @returns {Promise<Array<object>>} The flat config.
156
177
  */
157
- export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin, recommended })
178
+ export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin: pluginForEdition({ plugin, hfs }), recommended })
158
179
 
159
180
  export { linterOptions } from "./lib/config.mjs"
160
181
  export { loadHfs } from "./lib/hfs.mjs"
package/infra-owner.mjs CHANGED
@@ -53,7 +53,7 @@ export const infraImportOwner = {
53
53
  schema: [],
54
54
  messages: {
55
55
  foreign: "`{{name}}` is owned by {{owners}}. Use that capability's port (injected with its `Inject*()` decorator) instead of the raw library, or move this code into the owner.",
56
- nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through the outbox, schedules through `platform/scheduling`). Remove it.",
56
+ nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through `eventBus.publish(event, tx)` of `platform/event-bus`, schedules and background jobs through a queue's BullMQ job scheduler of `platform/queue`). Remove it.",
57
57
  },
58
58
  },
59
59
  create(context) {
package/injection.mjs CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { posix } from "node:path"
27
27
  import ts from "typescript"
28
- import { staticText } from "./lib/ast.mjs"
28
+ import { paramParts, staticText } from "./lib/ast.mjs"
29
29
  import { hfsOf } from "./lib/hfs.mjs"
30
30
  import { isImportedFrom } from "./lib/import-source.mjs"
31
31
  import { normalizePath } from "./lib/path.mjs"
@@ -133,13 +133,6 @@ const inComposition = (hfs, file) => {
133
133
  return owner !== null && posix.basename(owner) === "composition"
134
134
  }
135
135
 
136
- /** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
137
- const decoratorsOfParam = (param) => {
138
- const inner = param.type === "TSParameterProperty" ? param.parameter : param
139
- const target = inner.type === "AssignmentPattern" ? inner.left : inner
140
- return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
141
- }
142
-
143
136
  /** The injected type `T` of a call typed `TypedParameterDecorator<T>`, else null. */
144
137
  const injectedTypeOf = (context, call) => {
145
138
  const { checker, toTs } = typed(context)
@@ -373,7 +366,7 @@ export const injectorTypeMatch = {
373
366
  return {
374
367
  MethodDefinition(node) {
375
368
  for (const param of constructorParams(node)) {
376
- const { target, decorators } = decoratorsOfParam(param)
369
+ const { target, decorators } = paramParts(param)
377
370
  for (const decorator of decorators) {
378
371
  if (decorator.expression.type !== "CallExpression") continue
379
372
  const injected = injectedTypeOf(context, decorator.expression)
@@ -417,7 +410,25 @@ const isContainerBuilt = (context, constructor) => {
417
410
  const declaration = constructor.parent?.parent
418
411
  if (declaration?.type !== "ClassDeclaration" && declaration?.type !== "ClassExpression") return false
419
412
  if ((declaration.decorators ?? []).some((decorator) => packageOfExport(context, decorator.expression)?.startsWith("@nestjs/"))) return true
420
- return constructorParams(constructor).some((param) => decoratorsOfParam(param).decorators.length > 0)
413
+ return constructorParams(constructor).some((param) => paramParts(param).decorators.length > 0)
414
+ }
415
+
416
+ /**
417
+ * The origin that makes an annotated type infrastructure: declared by a package, or by a `platform/*` or `integrations/*` owner other
418
+ * than `own`; null for a domain type, a type of the same owner or a language global.
419
+ */
420
+ const infraOriginOf = (context, hfs, own, annotation) => {
421
+ const origins = typeOrigins(context, annotation)
422
+ // A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
423
+ // (`@types/node`): the augmentation does not make it infrastructure.
424
+ const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
425
+ return origins.find((origin) => {
426
+ if (isLanguageGlobal(origin)) return false
427
+ if (origin.module !== null) return true
428
+ if (own !== null && hfs.ownerOf(origin.file) === own) return false
429
+ const tier = hfs.tierOf(origin.file)
430
+ return tier === "platform" || tier === "integrations"
431
+ }) ?? null
421
432
  }
422
433
 
423
434
  /** A constructor parameter of an infrastructure type carries an injector. */
@@ -441,20 +452,10 @@ export const infraNeedsInjector = {
441
452
  // metadata. A plain class (a CQRS message, a factory-built adapter, a test fake) is never DI-constructed.
442
453
  if (!isContainerBuilt(context, node)) return
443
454
  for (const param of constructorParams(node)) {
444
- const { target, decorators } = decoratorsOfParam(param)
455
+ const { target, decorators } = paramParts(param)
445
456
  const annotation = target.typeAnnotation?.typeAnnotation
446
457
  if (!annotation) continue
447
- const origins = typeOrigins(context, annotation)
448
- // A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
449
- // (`@types/node`): the augmentation does not make it infrastructure.
450
- const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
451
- const infra = origins.find((origin) => {
452
- if (isLanguageGlobal(origin)) return false
453
- if (origin.module !== null) return true
454
- if (own !== null && hfs.ownerOf(origin.file) === own) return false
455
- const tier = hfs.tierOf(origin.file)
456
- return tier === "platform" || tier === "integrations"
457
- })
458
+ const infra = infraOriginOf(context, hfs, own, annotation)
458
459
  if (!infra) continue
459
460
  const carries = decorators.some((decorator) => {
460
461
  const expression = decorator.expression
@@ -470,6 +471,48 @@ export const infraNeedsInjector = {
470
471
  },
471
472
  }
472
473
 
474
+ // -- injected-param-name --------------------------------------------------------------------------
475
+
476
+ /** The `ruleParams.be.paramNames` entry for an origin's declared name, or null. */
477
+ const paramEntryOf = (entries, name) => entries.find((entry) => (entry.type !== undefined ? entry.type === name : name.length > entry.typeSuffix.length && name.endsWith(entry.typeSuffix))) ?? null
478
+
479
+ /** An injected port of the table of `paramNames` is named the way every class names it. */
480
+ export const injectedParamName = {
481
+ meta: {
482
+ type: "problem",
483
+ docs: { description: "A constructor parameter that injects a port named in `ruleParams.be.paramNames` (`Clock`, `Logger`, `Cache`, `CommandBus`, `QueryBus`, `EntityManager`, `<Owner>Options`) carries the name that table gives it." },
484
+ schema: [],
485
+ messages: {
486
+ name: "`{{param}}` injects `{{type}}`, and every class names that port {{expected}}. Rename the parameter so a reader finds the same dependency under the same name in every class.",
487
+ },
488
+ },
489
+ create(context) {
490
+ const hfs = hfsOf(context)
491
+ const entries = hfs.ruleParams.paramNames
492
+ const own = hfs.ownerOf(context.filename || context.getFilename())
493
+ return {
494
+ MethodDefinition(node) {
495
+ if (!isContainerBuilt(context, node)) return
496
+ for (const param of constructorParams(node)) {
497
+ const { target, decorators } = paramParts(param)
498
+ if (target.type !== "Identifier") continue
499
+ const annotation = target.typeAnnotation?.typeAnnotation
500
+ if (!annotation) continue
501
+ const injected = decorators.some((decorator) => decorator.expression.type === "CallExpression" && (INJECT_NAME.test(calleeOf(context, decorator.expression).name ?? "") || injectedTypeOf(context, decorator.expression) !== null))
502
+ if (!injected) continue
503
+ const infra = infraOriginOf(context, hfs, own, annotation)
504
+ if (!infra) continue
505
+ const entry = paramEntryOf(entries, infra.name)
506
+ if (!entry || entry.names.includes(target.name)) continue
507
+ if (entry.nameSuffix !== undefined && target.name.length > entry.nameSuffix.length && target.name.endsWith(entry.nameSuffix)) continue
508
+ const expected = [...entry.names.map((name) => `\`${name}\``), ...(entry.nameSuffix !== undefined ? [`\`<name>${entry.nameSuffix}\``] : [])].join(" or ")
509
+ context.report({ node: target, messageId: "name", data: { param: target.name, type: infra.name, expected } })
510
+ }
511
+ },
512
+ }
513
+ },
514
+ }
515
+
473
516
  // -- no-module-ref ---------------------------------------------------------------------------------
474
517
 
475
518
  const MODULE_REF_CALLS = new Set(["get", "resolve", "create"])
@@ -585,6 +628,7 @@ export const rules = {
585
628
  "injector-shape": injectorShape,
586
629
  "injector-type-match": injectorTypeMatch,
587
630
  "infra-needs-injector": infraNeedsInjector,
631
+ "injected-param-name": injectedParamName,
588
632
  "no-module-ref": noModuleRef,
589
633
  "no-forward-ref": noForwardRef,
590
634
  "no-string-token": noStringToken,
@@ -596,6 +640,7 @@ export const recommended = {
596
640
  "starci-be/injector-shape": "error",
597
641
  "starci-be/injector-type-match": "error",
598
642
  "starci-be/infra-needs-injector": "error",
643
+ "starci-be/injected-param-name": "error",
599
644
  "starci-be/no-module-ref": "error",
600
645
  "starci-be/no-forward-ref": "error",
601
646
  "starci-be/no-string-token": "error",
package/input-bounds.mjs CHANGED
@@ -16,6 +16,7 @@
16
16
  * argument is `RateTier.Strict` of `platform/http-security`, on the method or on its class. A sign-in, sign-up,
17
17
  * password-reset or callback door on the default tier is the brute-force path the strict tier exists for.
18
18
  */
19
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
19
20
  import ts from "typescript"
20
21
  import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
21
22
  import { importsFrom, isOwnedEnumMember, partsOf, presentParts, typeOf } from "./lib/declared.mjs"
@@ -26,7 +27,7 @@ import { isPackageType, typed } from "./lib/types.mjs"
26
27
  /** True when the file sits in a transport slot inside a `dto/` folder, judged by the slot and the owner-relative path. */
27
28
  const isTransportDto = (hfs, filename) => {
28
29
  const slot = hfs.slotOf(filename) ?? ""
29
- if (!slot.startsWith("be.transport.") && !slot.startsWith("be.feature.transport.")) return false
30
+ if (!isTransportSlot(slot) && !slot.startsWith("be.feature.transport.")) return false
30
31
  return hfs.relative(filename).split("/").slice(0, -1).includes("dto")
31
32
  }
32
33