@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
package/jobs.mjs ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The rules that hold the fenced-job pattern (catalog R157 `BE_JOB_WRITE_OUTSIDE_OWNER`, R158 `BE_JOB_FENCE_REQUIRED`,
3
+ * R159 `BE_JOB_RUN_KEY`, R160 `BE_JOB_SHAPE`).
4
+ *
5
+ * A background job is a row whose `fencingToken` the claim bumps. A worker that lost its claim (a zombie) holds a stale token, so every
6
+ * write to the row is guarded by `expectedFencingToken` and an external effect carries an idempotency key that includes the token.
7
+ * `platform/jobs` is the ONE owner of the row: it declares the entity, the claim and the guarded writes, and nothing else writes it.
8
+ *
9
+ * Everything is found by TYPE ORIGIN (the `JobClaims`, `JobStep`, `RunKey` and `FencedProcessor` declared by `platform/jobs`, the entity
10
+ * class declared by that owner, typeorm receivers and query builders) and by the slot view (`be.feature.jobs.steps`, `be.feature.jobs`); the
11
+ * only names read are the contract's own members `runKey`, `jobId` and `expectedFencingToken`.
12
+ */
13
+ import ts from "typescript"
14
+ import { walk } from "./lib/ast.mjs"
15
+ import { writtenEntityOrigins } from "./lib/entity-writes.mjs"
16
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
17
+ import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
18
+ import { typeOrigins, typed } from "./lib/types.mjs"
19
+
20
+ const isJobClaimsType = (context, node) => isOwnedType(context, node, { name: "JobClaims", capability: "jobs", tier: "platform" })
21
+ const isJobStepType = (context, node) => isOwnedType(context, node, { name: "JobStep", capability: "jobs", tier: "platform" })
22
+ const isFencedProcessorType = (context, node) => isOwnedType(context, node, { name: "FencedProcessor", capability: "jobs", tier: "platform" })
23
+ const isRunKeyType = (context, node) => isOwnedType(context, node, { name: "RunKey", capability: "jobs", tier: "platform" })
24
+
25
+ /** True for a file of the owner itself, and for the test composition. */
26
+ const isOwnerOrTest = (hfs, file) => {
27
+ const tier = hfs.tierOf(file)
28
+ if (tier === "e2e" || tier === "fixtures" || inTestWorld(hfs, file)) return true
29
+ return tier === "platform" && ownerNameOf(hfs, file) === "jobs"
30
+ }
31
+
32
+ /** A write to an entity that `platform/jobs` declares (the job row) is made only by `platform/jobs`. */
33
+ export const jobWriteOwner = {
34
+ meta: {
35
+ type: "problem",
36
+ docs: { description: "The job entity is written only by `platform/jobs`: no `update`, `increment`, `save`, `insert`, `delete` or query-builder write on it elsewhere." },
37
+ schema: [],
38
+ messages: {
39
+ foreign: "`{{call}}` writes the job row outside `platform/jobs`. The fencing token and the status of a job change only through `JobClaims` (`claim`, `advance`, `complete`, `fail`), each of which requires `expectedFencingToken`; a direct write lets a zombie worker overwrite the job a newer worker owns.",
40
+ },
41
+ },
42
+ create(context) {
43
+ const hfs = hfsOf(context)
44
+ if (isOwnerOrTest(hfs, context.filename)) return {}
45
+ return {
46
+ CallExpression(node) {
47
+ const origins = writtenEntityOrigins(context, node)
48
+ if (!origins.some((origin) => hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === "jobs" && hfs.slotOf(origin.file) === "be.persistence")) return
49
+ context.report({ node, messageId: "foreign", data: { call: context.sourceCode.getText(node.callee) } })
50
+ },
51
+ }
52
+ },
53
+ }
54
+
55
+ /** The names the guarded-write contract fixes: the target job and the token the writer holds. */
56
+ const TARGET = "jobId"
57
+ const TOKEN = "expectedFencingToken"
58
+ /** The property of a claimed job that holds the token it carries. */
59
+ const CARRIED = "fencingToken"
60
+
61
+ /** A property of a TypeScript type by name, or undefined. */
62
+ const propertyOf = (type, name) => type.getProperty(name)
63
+
64
+ /** True when the property is a required `number`. */
65
+ const isRequiredNumber = (checker, property, location) => {
66
+ if (!property || (property.flags & ts.SymbolFlags.Optional) !== 0) return false
67
+ const type = checker.getTypeOfSymbolAtLocation(property, location)
68
+ return (type.flags & ts.TypeFlags.Number) !== 0
69
+ }
70
+
71
+ /** Calls on the `JobClaims` port, and what a guarded write is: required token, no cast to get around it, and `JobFencedOut` is not swallowed. */
72
+ export const jobFenceRequired = {
73
+ meta: {
74
+ type: "problem",
75
+ docs: { description: "Every `JobClaims` method that writes an existing job declares a required `expectedFencingToken: number`, a call never casts around it, and a `catch` around a guarded write rethrows." },
76
+ schema: [],
77
+ messages: {
78
+ optionalToken: "`{{method}}` takes a job (`{{target}}`) but its parameter type does not require `{{token}}: number`. Every write to an existing job is guarded by the fencing token the caller holds: declare it required (non-optional, non-nullable) in the type.",
79
+ cast: "This argument of `{{call}}` uses a cast or a non-null assertion. The token a guarded write carries is the one the claim returned: pass it as typed, never `as`, `!` or `any` around it.",
80
+ swallowed: "This `catch` hides a failed guarded write: a `JobFencedOut` means a newer worker owns the job and this one must stop with no further effect. Do not catch around `JobClaims` writes in a step or processor; the `FencedProcessor` base class owns the failure path. Rethrow if you must catch.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const hfs = hfsOf(context)
85
+ const owner = isOwnerOrTest(hfs, context.filename)
86
+ const { checker, toTs } = typed(context)
87
+ const claimsCall = (node) => node.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && isJobClaimsType(context, node.callee.object)
88
+ return {
89
+ // The declaration side: in the owner, each member of the port that takes a job requires the token.
90
+ TSMethodSignature(node) {
91
+ if (!owner || hfs.tierOf(context.filename) !== "platform" || !isJobClaimsType(context, node.parent.parent?.id ?? node.parent)) return
92
+ const first = node.params?.[0]
93
+ if (!first) return
94
+ const tsNode = toTs(first)
95
+ const type = tsNode ? checker.getTypeAtLocation(tsNode) : undefined
96
+ // A parameter that already carries the token (the claimed job itself) is a derivation, not a write.
97
+ if (!type || !propertyOf(type, TARGET) || propertyOf(type, CARRIED)) return
98
+ if (!isRequiredNumber(checker, propertyOf(type, TOKEN), tsNode)) {
99
+ context.report({ node: first, messageId: "optionalToken", data: { method: node.key?.name ?? "this method", target: TARGET, token: TOKEN } })
100
+ }
101
+ },
102
+ CallExpression(node) {
103
+ if (owner || !claimsCall(node)) return
104
+ for (const argument of node.arguments) {
105
+ walk(argument, (child) => {
106
+ if (child.type === "TSAsExpression" || child.type === "TSTypeAssertion" || child.type === "TSNonNullExpression") context.report({ node: child, messageId: "cast", data: { call: context.sourceCode.getText(node.callee) } })
107
+ })
108
+ }
109
+ },
110
+ TryStatement(node) {
111
+ if (owner || !node.handler) return
112
+ let writes = false
113
+ walk(node.block, (child) => { if (claimsCall(child)) writes = true })
114
+ if (!writes) return
115
+ let rethrows = false
116
+ walk(node.handler.body, (child) => { if (child.type === "ThrowStatement") rethrows = true })
117
+ if (!rethrows) context.report({ node: node.handler, messageId: "swallowed" })
118
+ },
119
+ }
120
+ },
121
+ }
122
+
123
+ /** The class of an external effect of a step passes the key `JobClaims.runKey` made. */
124
+ export const jobRunKey = {
125
+ meta: {
126
+ type: "problem",
127
+ docs: { description: "In a job step every call on an integration port passes a `RunKey` (made by `JobClaims.runKey`, which includes the fencing token), never a cast or a literal." },
128
+ schema: [],
129
+ messages: {
130
+ missing: "`{{call}}` is an external call from a job step without a `RunKey` argument. An effect that a zombie worker may repeat must be idempotent at the provider: pass `this.claims.runKey(job, \"<step>\")` (it includes the fencing token) as the idempotency key. A read belongs in the application layer, not in a step.",
131
+ forged: "This `RunKey` is built with a cast. Only `JobClaims.runKey(job, step)` makes one, so the key always carries the fencing token of the claim.",
132
+ },
133
+ },
134
+ create(context) {
135
+ const hfs = hfsOf(context)
136
+ if (hfs.slotOf(context.filename) !== "be.feature.jobs.steps") return {}
137
+ return {
138
+ CallExpression(node) {
139
+ const callee = node.callee
140
+ if (callee.type !== "MemberExpression" || callee.computed) return
141
+ const origins = typeOrigins(context, callee.object)
142
+ if (!origins.some((origin) => origin.module === null && hfs.tierOf(origin.file) === "integrations")) return
143
+ const keyArguments = node.arguments.filter((argument) => isRunKeyType(context, argument))
144
+ if (keyArguments.length === 0) {
145
+ context.report({ node, messageId: "missing", data: { call: context.sourceCode.getText(callee) } })
146
+ return
147
+ }
148
+ for (const argument of keyArguments) {
149
+ walk(argument, (child) => { if (child.type === "TSAsExpression" || child.type === "TSTypeAssertion") context.report({ node: child, messageId: "forged" }) })
150
+ }
151
+ },
152
+ }
153
+ },
154
+ }
155
+
156
+ /** A processor extends the fenced base and is named after its job; a step implements `JobStep` in `steps/`. */
157
+ export const jobShape = {
158
+ meta: {
159
+ type: "problem",
160
+ docs: { description: "`<job>.processor.ts` of `features/jobs/<job>/transport/queue/` declares a class extending `FencedProcessor`; `steps/<step>.step.ts` declares a class implementing `JobStep`; neither role exists elsewhere." },
161
+ schema: [],
162
+ messages: {
163
+ processorPlacement: "`{{name}}` is in a `.processor.ts` file outside `features/jobs/<job>/transport/queue/`. A job processor lives only in the queue transport of its job, so the jobs kind stays one place.",
164
+ processorStem: "The processor of job `{{job}}` is `{{job}}.processor.ts`, but this file is `{{stem}}.processor.ts`.",
165
+ notFenced: "`{{name}}` is a job processor that does not extend `FencedProcessor` of `platform/jobs`. The base class claims the job with a bumped fencing token and settles it through the guarded writes; a processor without it has no fence.",
166
+ stepPlacement: "`{{name}}` implements `JobStep` outside `features/jobs/<job>/steps/<step>.step.ts`.",
167
+ notStep: "`{{name}}` is in a step file but does not implement `JobStep` of `platform/jobs`.",
168
+ },
169
+ },
170
+ create(context) {
171
+ const hfs = hfsOf(context)
172
+ const file = context.filename
173
+ const found = hfs.classify(file)
174
+ const base = baseName(file)
175
+ const isProcessorFile = base.endsWith(".processor.ts")
176
+ const isStepFile = base.endsWith(".step.ts")
177
+ const inJob = found.slot === "be.feature.jobs.queue"
178
+ const inSteps = found.slot === "be.feature.jobs.steps"
179
+ const inPlatform = hfs.tierOf(file) === "platform" && ownerNameOf(hfs, file) === "jobs"
180
+ const check = (node) => {
181
+ const name = node.id?.name ?? "this class"
182
+ const extendsFenced = node.superClass ? isFencedProcessorType(context, node.superClass) : false
183
+ const implementsStep = (node.implements ?? []).some((entry) => isJobStepType(context, entry))
184
+ if (isProcessorFile && !inPlatform) {
185
+ if (!inJob) context.report({ node: node.id ?? node, messageId: "processorPlacement", data: { name } })
186
+ else {
187
+ const stem = base.slice(0, -".processor.ts".length)
188
+ if (found.bindings?.job !== undefined && found.bindings.job !== stem) context.report({ node: node.id ?? node, messageId: "processorStem", data: { job: found.bindings.job, stem } })
189
+ if (!extendsFenced) context.report({ node: node.id ?? node, messageId: "notFenced", data: { name } })
190
+ }
191
+ }
192
+ if (isStepFile) {
193
+ if (!inSteps) context.report({ node: node.id ?? node, messageId: "stepPlacement", data: { name } })
194
+ else if (!implementsStep) context.report({ node: node.id ?? node, messageId: "notStep", data: { name } })
195
+ } else if (implementsStep && !inPlatform) context.report({ node: node.id ?? node, messageId: "stepPlacement", data: { name } })
196
+ }
197
+ return { ClassDeclaration: check, ClassExpression: check }
198
+ },
199
+ }
200
+
201
+ export const rules = {
202
+ "job-write-owner": jobWriteOwner,
203
+ "job-fence-required": jobFenceRequired,
204
+ "job-run-key": jobRunKey,
205
+ "job-shape": jobShape,
206
+ }
207
+
208
+ /** The level these laws ask for: `error`, switched off nowhere. */
209
+ export const recommended = {
210
+ "starci-be/job-write-owner": "error",
211
+ "starci-be/job-fence-required": "error",
212
+ "starci-be/job-run-key": "error",
213
+ "starci-be/job-shape": "error",
214
+ }
package/lib/ast.mjs CHANGED
@@ -54,6 +54,13 @@ export const keyName = (node) => {
54
54
  return null
55
55
  }
56
56
 
57
+ /** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
58
+ export const paramParts = (param) => {
59
+ const inner = param.type === "TSParameterProperty" ? param.parameter : param
60
+ const target = inner.type === "AssignmentPattern" ? inner.left : inner
61
+ return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
62
+ }
63
+
57
64
  /** The name of a decorator (`@Foo` or `@Foo(...)`), else null. */
58
65
  export const decoratorName = (decorator) => {
59
66
  const expression = decorator?.expression
package/lib/config.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The one flat config of a StarCi back end.
3
3
  *
4
- * A repository's `eslint.config.mjs` is a managed file (R05/R16), rendered by `hfs sync` as exactly:
4
+ * A repository's `eslint.config.mjs` is a managed file (R05/R16), rendered by `starci app sync` as exactly:
5
5
  *
6
6
  * import { loadHfs, starciBeConfig } from "@starci/eslint-canon-be"
7
7
  * export default starciBeConfig({ hfs: loadHfs(import.meta.url) })
package/lib/db-writes.mjs CHANGED
@@ -6,7 +6,7 @@ import { staticText } from "./ast.mjs"
6
6
  import { isPackageType } from "./types.mjs"
7
7
 
8
8
  /** The `typeorm` types a call can write through. */
9
- export const RECEIVER_TYPES = Object.freeze(["EntityManager", "DataSource", "QueryRunner"])
9
+ const RECEIVER_TYPES = Object.freeze(["EntityManager", "DataSource", "QueryRunner"])
10
10
 
11
11
  /** Statement text that changes rows. */
12
12
  export const WRITING_SQL = /(?:^|[\s;(])(?:insert\s+into|update\s+["`\w.]+\s+set|delete\s+from|truncate\b)/i
package/lib/doors.mjs ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What the door rules of the feature kinds (webhooks, realtime) read from a class: what is injected into it, which methods
3
+ * the framework enters, and which expression a call is made on. Every answer comes from a decorator's import, a value's
4
+ * declared type or a declaration's origin, never from a spelling.
5
+ */
6
+ import ts from "typescript"
7
+ import { walk } from "./ast.mjs"
8
+ import { decoratorCallee, importOf, isImportedFrom } from "./import-source.mjs"
9
+ import { typed } from "./types.mjs"
10
+
11
+ /** True when the class carries a decorator that is `name` exported by `source`. */
12
+ export const hasClassDecorator = (context, node, source, name) =>
13
+ (node.decorators ?? []).some((decorator) => isImportedFrom(context, decoratorCallee(decorator), source, name))
14
+
15
+ /** The decorators of a method or class that are one of `names` exported by `source`. */
16
+ export const decoratorsFrom = (context, node, source, names) =>
17
+ (node.decorators ?? []).filter((decorator) => isImportedFrom(context, decoratorCallee(decorator), source, names))
18
+
19
+ /**
20
+ * Every value injected into a class: a constructor parameter (plain or a parameter property) and a property that
21
+ * carries a decorator which injects (`Inject` of Nest, a `@nestjs/typeorm` injector or a function declared in a
22
+ * `*.decorators.ts` file).
23
+ *
24
+ * @param {object} context - The ESLint rule context.
25
+ * @param {object} classNode - A `ClassDeclaration`.
26
+ * @returns {Array<{ node: object, annotation: object | undefined }>} The injected members, each with its declared type annotation.
27
+ */
28
+ export const injectedMembers = (context, classNode) => {
29
+ const members = []
30
+ const constructor = classNode.body.body.find((member) => member.type === "MethodDefinition" && member.kind === "constructor")
31
+ for (const original of constructor?.value.params ?? []) {
32
+ const param = original.type === "TSParameterProperty" ? original.parameter : original
33
+ members.push({ node: original, annotation: param.typeAnnotation?.typeAnnotation })
34
+ }
35
+ for (const member of classNode.body.body) {
36
+ if (member.type !== "PropertyDefinition" || !(member.decorators ?? []).some((decorator) => isInjectorDecorator(context, decorator))) continue
37
+ members.push({ node: member, annotation: member.typeAnnotation?.typeAnnotation })
38
+ }
39
+ return members
40
+ }
41
+
42
+ /** True for a decorator that injects: `Inject` of Nest, a `@nestjs/typeorm` injector, or a function declared in a `*.decorators.ts` file. */
43
+ const isInjectorDecorator = (context, decorator) => {
44
+ const callee = decoratorCallee(decorator)
45
+ if (callee?.type !== "Identifier") return false
46
+ const found = importOf(context, callee)
47
+ if (found?.source === "@nestjs/common" && found.imported === "Inject") return true
48
+ if (found?.source === "@nestjs/typeorm") return true
49
+ const symbol = aliasTarget(context, callee)
50
+ return (symbol?.getDeclarations() ?? []).some((declaration) => declaration.getSourceFile().fileName.split("\\").join("/").endsWith(".decorators.ts"))
51
+ }
52
+
53
+ /** The symbol an identifier names, with an import alias resolved to its target. */
54
+ export const aliasTarget = (context, identifier) => {
55
+ const { checker, toTs } = typed(context)
56
+ const tsNode = toTs(identifier)
57
+ const symbol = tsNode && checker.getSymbolAtLocation(tsNode)
58
+ return symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0 ? checker.getAliasedSymbol(symbol) : symbol
59
+ }
60
+
61
+ /** The methods of a class (instance, non-constructor) that carry one of the decorators `names` of `source`. */
62
+ export const methodsDecoratedBy = (context, classNode, source, names) =>
63
+ classNode.body.body.filter((member) => member.type === "MethodDefinition" && member.kind === "method" && !member.static && decoratorsFrom(context, member, source, names).length > 0)
64
+
65
+ /**
66
+ * Every call made in a method body, closures included: `<receiver>.<method>(...)` as `{ call, receiver, method }`, and any
67
+ * other call as `{ call, receiver: null, method: null }`.
68
+ */
69
+ export const callsOf = (method) => {
70
+ const calls = []
71
+ walk(method.value.body, (node) => {
72
+ if (node.type !== "CallExpression") return
73
+ const callee = node.callee
74
+ const member = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
75
+ calls.push({ call: node, receiver: member?.object ?? null, method: member?.property.name ?? null })
76
+ })
77
+ return calls
78
+ }
79
+
80
+ /** The parameters of a handler method, with the pattern a default value or a destructuring wraps unwrapped. */
81
+ export const parametersOf = (method) => method.value.params.map((param) => (param.type === "TSParameterProperty" ? param.parameter : param))
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The edition gate of the back-end plugin.
3
+ *
4
+ * Edition applicability belongs to the HFS rule catalog, beside the law and its enforcer. A rule
5
+ * that the catalog does not judge in this app's edition still ships in the plugin, but its `create`
6
+ * returns no listeners. The plugin wiring therefore has one generic gate instead of edition checks
7
+ * scattered through rule bodies.
8
+ */
9
+ import { dirname, join } from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { loadRuleCatalog } from "../runtime/scripts/hfs/slots.mjs"
12
+
13
+ const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
14
+ let catalogCache
15
+
16
+ /** The bundled rule catalog, loaded once per lint process. */
17
+ const bundledCatalog = () => {
18
+ catalogCache ??= loadRuleCatalog({ root: RUNTIME })
19
+ return catalogCache
20
+ }
21
+
22
+ /** A rule which intentionally registers no listeners in this edition. */
23
+ const inactive = (rule) => Object.freeze({ ...rule, create: () => ({}) })
24
+
25
+ /**
26
+ * The plugin view for one HFS edition.
27
+ *
28
+ * Full is the default edition and the catalog's superset, so it needs no runtime catalog read. This
29
+ * keeps the source checkout testable before `sync-runtime` refreshes the generated bundle; lite
30
+ * loads the package's bundled catalog in a published installation. Tests may inject a catalog to
31
+ * prove the generic filtering independently of generated copies.
32
+ */
33
+ export const pluginForEdition = ({ plugin, hfs, catalog }) => {
34
+ const edition = hfs?.edition === undefined ? "full" : hfs.edition
35
+ if (edition !== "full" && edition !== "lite") {
36
+ throw new RangeError(`Unknown HFS edition ${JSON.stringify(edition)}; expected "full" or "lite"`)
37
+ }
38
+ if (edition === "full" && catalog === undefined) return plugin
39
+ const loaded = catalog ?? bundledCatalog()
40
+ const rules = Object.fromEntries(Object.entries(plugin.rules).map(([id, rule]) => [
41
+ id,
42
+ loaded.enforcerJudgedIn("eslint-be", id, edition) ? rule : inactive(rule),
43
+ ]))
44
+ return Object.freeze({ ...plugin, rules: Object.freeze(rules) })
45
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Which entities a database write call targets, found by TYPE: a write method called on a typeorm database receiver (`EntityManager`,
3
+ * `DataSource`, `QueryRunner`) or on a typeorm query builder, whose arguments (or, for a query builder, the arguments of any call
4
+ * of the same chain) are the entity class or an instance of it. No variable, class or table name is read.
5
+ */
6
+ import { isDatabaseReceiver } from "./db-writes.mjs"
7
+ import { typeOrigins } from "./types.mjs"
8
+
9
+ /** The methods of a manager or query builder that change rows. */
10
+ export const WRITE_METHODS = Object.freeze(["update", "save", "insert", "upsert", "delete", "remove", "increment", "decrement", "softDelete", "softRemove", "recover", "restore", "clear"])
11
+
12
+ /** True when the node's type is a query builder of `typeorm` (`SelectQueryBuilder`, `UpdateQueryBuilder` ...). */
13
+ const isQueryBuilder = (context, node) => typeOrigins(context, node).some((origin) => origin.module === "typeorm" && origin.name.endsWith("QueryBuilder"))
14
+
15
+ /** The declarations (class origins) of the entities a database write call targets, or `[]` when the node is not such a call. */
16
+ export const writtenEntityOrigins = (context, node) => {
17
+ const callee = node.callee
18
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || !WRITE_METHODS.includes(callee.property.name)) return []
19
+ if (!isDatabaseReceiver(context, callee.object) && !isQueryBuilder(context, callee.object)) return []
20
+ const candidates = [...node.arguments]
21
+ for (let link = callee.object; link; ) {
22
+ if (link.type === "CallExpression") {
23
+ candidates.push(...link.arguments)
24
+ link = link.callee.type === "MemberExpression" ? link.callee.object : null
25
+ } else if (link.type === "MemberExpression") link = link.object
26
+ else if (link.type === "TSNonNullExpression" || link.type === "AwaitExpression") link = link.expression ?? link.argument
27
+ else link = null
28
+ }
29
+ // The entity of a builder write may come later in the same chain (`.delete().from(Entity)`, `.insert().into(Entity)`).
30
+ for (let outer = node; outer.parent?.type === "MemberExpression" && outer.parent.object === outer && outer.parent.parent?.type === "CallExpression" && outer.parent.parent.callee === outer.parent; ) {
31
+ outer = outer.parent.parent
32
+ candidates.push(...outer.arguments)
33
+ }
34
+ return candidates.flatMap((candidate) => typeOrigins(context, candidate)).filter((origin) => origin.module === null)
35
+ }
package/lib/hfs.mjs CHANGED
@@ -4,8 +4,8 @@
4
4
  * A consuming `eslint.config.mjs` is the one line `export default starciBeConfig({ hfs: loadHfs(import.meta.url) })`.
5
5
  * The config lives in the be/ side folder of an app; `loadHfs` finds the app-root `hfs.json` one level up and gives every file
6
6
  * linted under that folder the view of the be side (paths relative to be/), with the slot manifest this package ships in
7
- * `runtime/` (a byte copy of the runtime's `knowledge/hfs/slots.yaml`, `scripts/lib/hfs-slots.mjs` and `scripts/lib/hfs-view.mjs`, refreshed by
8
- * `packages/hfs/scripts/sync-runtime.mjs`), and returns an object the factory puts in `settings.starci.hfs`.
7
+ * `runtime/` (a byte copy of the runtime's `knowledge/hfs/slots.yaml`, `scripts/hfs/slots.mjs` and `scripts/hfs/view.mjs`, refreshed by
8
+ * `scripts/hfs/sync-runtime.mjs`), and returns an object the factory puts in `settings.starci.hfs`.
9
9
  *
10
10
  * Every path-scoped rule asks `slotOf` here instead of testing a path with a regular expression: a rule never assumes
11
11
  * `/src/modules/` or `/src/tests/`. A rule that needs the manifest and finds no `settings.starci.hfs` stops the lint run
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { dirname, join } from "node:path"
15
15
  import { fileURLToPath } from "node:url"
16
- import { declaredHfsView, openHfsView } from "../runtime/scripts/lib/hfs-view.mjs"
16
+ import { declaredHfsView, openHfsView } from "../runtime/scripts/hfs/view.mjs"
17
17
 
18
18
  const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
19
19
 
package/lib/path.mjs CHANGED
@@ -4,12 +4,6 @@ export const normalizePath = (filename) => String(filename || "").replace(/\\/g,
4
4
  /** A spec file: the unit lane, where a fixture may build a deliberately wrong value. */
5
5
  export const isSpecFile = (filename) => /\.(?:spec|test)\.[cm]?[jt]sx?$|-spec\.[cm]?[jt]sx?$/.test(normalizePath(filename))
6
6
 
7
- /** Any file of the test lanes: a spec, or anything under `src/tests/`. */
8
- export const isTestLane = (filename) => {
9
- const file = normalizePath(filename)
10
- return isSpecFile(file) || file.includes("/src/tests/")
11
- }
12
-
13
7
  /** A migration: the one place schema-changing SQL is allowed to be written. */
14
8
  export const isMigrationFile = (filename) => /\/persistence\/migrations\/[^/]+\.[cm]?[jt]s$/.test(normalizePath(filename))
15
9
 
@@ -6,6 +6,7 @@
6
6
  * `typeorm`'s; a file is the database capability's because the HFS slot manifest says so.
7
7
  */
8
8
  import { normalizePath } from "./path.mjs"
9
+ import { ownerNameOf } from "./ports.mjs"
9
10
  import { typeOrigins } from "./types.mjs"
10
11
 
11
12
  /** The `typeorm` types a class must never receive except through the platform database capability. */
@@ -65,24 +66,14 @@ export const inDatabaseCapability = (hfs, file) => {
65
66
  }
66
67
 
67
68
  /**
68
- * Whether a file belongs to the migrate app.
69
+ * Whether a file is the command line of the back end: the cli app (slot `be.app.cli`) or the cli feature root (slot `be.cli`,
70
+ * `src/features/cli/`), where every one-off action runs, the migrate and seed commands among them.
69
71
  *
70
72
  * @param {object} hfs - The HFS view of the repository.
71
73
  * @param {string} file - A file path.
72
- * @returns {boolean} True for `be.app.migrate` files.
74
+ * @returns {boolean} True for the files of the cli app and the cli feature root.
73
75
  */
74
- export const inMigrateApp = (hfs, file) => hfs.slotOf(file) === "be.app.migrate"
75
-
76
- /**
77
- * Whether a file is the test world: slot `be.tests.world` (`src/tests/world/**`), the one test location that owns
78
- * the shared infrastructure (containers, apps/migrate bootstrap, teardown) and so may build and hold a `DataSource`.
79
- * Specs (unit and e2e) take `world.db.<connection>` instead (owner ruling on R83/R47, 2026-09-30).
80
- *
81
- * @param {object} hfs - The HFS view of the repository.
82
- * @param {string} file - A file path.
83
- * @returns {boolean} True for `be.tests.world` files.
84
- */
85
- export const inTestBootstrap = (hfs, file) => hfs.slotOf(file) === "be.tests.world"
76
+ export const inCli = (hfs, file) => hfs.slotOf(file) === "be.app.cli" || hfs.slotOf(file) === "be.cli"
86
77
 
87
78
  /**
88
79
  * Whether a file is a migration: a `be.persistence` file below its `migrations/` folder.
@@ -113,11 +104,11 @@ export const connectionFileOf = (hfs, file, role) => {
113
104
  return connectionsOf(hfs).find((connection) => connection.name === name) ?? null
114
105
  }
115
106
 
116
- /** The platform capabilities that own a `persistence/` and so may hold an `EntityManager`. */
117
- const PERSISTENCE_CAPABILITIES = ["database", "inbox", "outbox", "lease"]
107
+ /** The platform capabilities that own a `persistence/` and so may hold an `EntityManager` (a pattern capability is named by its folder). */
108
+ const PERSISTENCE_CAPABILITIES = ["database", "inbox", "event-bus", "queue", "jobs", "saga", "outbox"]
118
109
 
119
110
  /**
120
- * Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a platform persistence capability.
111
+ * Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a projection, a typed queue producer (it takes the transaction of its caller), a platform persistence capability.
121
112
  *
122
113
  * @param {object} hfs - The HFS view of the repository.
123
114
  * @param {string} file - A file path.
@@ -128,7 +119,9 @@ export const mayHoldEntityManager = (hfs, file) => {
128
119
  const base = baseNameOf(file)
129
120
  if (found.slot === "be.feature.application") return base.endsWith(".handler.ts")
130
121
  if (found.slot === "be.domain") return base.endsWith(".service.ts")
131
- return found.slot === "be.platform" && PERSISTENCE_CAPABILITIES.includes(found.bindings?.capability)
122
+ if (found.slot === "be.projections") return base.endsWith(".projection.ts")
123
+ if (found.slot === "be.queues") return base.endsWith(".queue.ts")
124
+ return typeof found.slot === "string" && found.slot.startsWith("be.platform") && PERSISTENCE_CAPABILITIES.includes(ownerNameOf(hfs, file))
132
125
  }
133
126
 
134
127
  /**
package/lib/ports.mjs CHANGED
@@ -23,7 +23,10 @@ export const baseName = (file) => String(file).replace(/\\/g, "/").split("/").po
23
23
  */
24
24
  export const ownerNameOf = (hfs, file) => {
25
25
  const bindings = hfs.classify(file).bindings ?? {}
26
- return bindings.capability ?? bindings.feature ?? null
26
+ if (bindings.capability ?? bindings.feature) return bindings.capability ?? bindings.feature
27
+ // A pattern slot names its capability literally (`src/modules/platform/event-bus/`), so no variable binds it: the owner folder is its name.
28
+ const root = hfs.ownerOf(file)
29
+ return root === null || root === undefined ? null : root.split("/").pop()
27
30
  }
28
31
 
29
32
  /**
@@ -33,7 +36,7 @@ export const ownerNameOf = (hfs, file) => {
33
36
  * @param {string} file - A file path.
34
37
  * @returns {string | null} The slot-relative path.
35
38
  */
36
- export const pathInSlot = (hfs, file) => {
39
+ const pathInSlot = (hfs, file) => {
37
40
  const found = hfs.classify(file)
38
41
  if (!found.slot) return null
39
42
  const rel = hfs.relative(file)
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Where an import specifier points. A `compilerOptions.paths` alias resolves through the program's own options, so a
3
+ * rule asks the same question TypeScript asks instead of maintaining a second alias table.
4
+ */
5
+ import { posix } from "node:path"
6
+ import { typed } from "./types.mjs"
7
+
8
+ /**
9
+ * The absolute path an alias specifier maps to under the program's `compilerOptions.paths`, or null when no pattern matches.
10
+ *
11
+ * @param {object} context - The ESLint rule context.
12
+ * @param {string} specifier - An import specifier.
13
+ * @returns {{ target: string, rest: string } | null} The absolute target without extension and the part the wildcard matched.
14
+ */
15
+ export const resolveAlias = (context, specifier) => {
16
+ const options = typed(context).program.getCompilerOptions()
17
+ const base = options.baseUrl ?? options.pathsBasePath
18
+ if (!options.paths || typeof base !== "string") return null
19
+ for (const [pattern, targets] of Object.entries(options.paths)) {
20
+ if (!pattern.endsWith("/*") || !targets[0]) continue
21
+ const prefix = pattern.slice(0, -1)
22
+ if (!specifier.startsWith(prefix)) continue
23
+ const rest = specifier.slice(prefix.length)
24
+ return { target: posix.normalize(`${base.replace(/\\/g, "/")}/${targets[0].replace("*", rest)}`), rest }
25
+ }
26
+ return null
27
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Questions a rule asks about database transactions: the callback of a `.transaction(...)` call and the variable an identifier
3
+ * resolves to. The receiver of `.transaction` is judged by its TYPE (typeorm's `EntityManager` or `DataSource`), never by its name.
4
+ */
5
+ import { infraTypeOf } from "./persistence.mjs"
6
+
7
+ /**
8
+ * The callback of a `.transaction(...)` call on a manager or data source, or null.
9
+ *
10
+ * @param {object} context - The ESLint rule context.
11
+ * @param {object} node - A CallExpression.
12
+ * @returns {object | null} The arrow or function expression that receives the transactional manager.
13
+ */
14
+ export const transactionCallback = (context, node) => {
15
+ const callee = node.callee
16
+ if (callee.type !== "MemberExpression" || callee.computed) return null
17
+ if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return null
18
+ const receiver = infraTypeOf(context, callee.object)
19
+ if (receiver !== "EntityManager" && receiver !== "DataSource") return null
20
+ const callback = node.arguments[node.arguments.length - 1]
21
+ return callback && (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") ? callback : null
22
+ }
23
+
24
+ /**
25
+ * The variable an identifier resolves to, looking outward from the identifier's own scope.
26
+ *
27
+ * @param {object} sourceCode - The ESLint source code.
28
+ * @param {object} node - An Identifier.
29
+ * @returns {object | null} The scope variable.
30
+ */
31
+ export const resolveVariable = (sourceCode, node) => {
32
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
33
+ const variable = scope.set.get(node.name)
34
+ if (variable) return variable
35
+ }
36
+ return null
37
+ }
@@ -2,13 +2,18 @@
2
2
  * The transport slots of the HFS manifest, in one place.
3
3
  *
4
4
  * A slot id is manifest vocabulary, not a path: every rule that judges "a transport file" asks this instead of spelling
5
- * the ids itself. The command-line slot carries its feature prefix in the manifest (`be.feature.transport.cli`).
5
+ * the ids itself. A cli command is no transport: it is an action runner of the cli feature root (slot `be.cli`). The
6
+ * webhook and realtime kinds hold their doors in slots of their own (`be.feature.webhooks.http`, `be.feature.realtime.graphql`, `be.feature.realtime.websocket`): a provider webhook and a
7
+ * push channel are doors like any other and answer to the door laws (default deny, bounded input, no envelope).
6
8
  */
7
9
 
10
+ /** The slots of the feature kinds that are doors but are not an api's `transport/<protocol>/` folders. */
11
+ export const KIND_DOOR_SLOTS = Object.freeze(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket"])
12
+
8
13
  /**
9
- * True for a slot that holds transport code (`be.transport.<protocol>` and the command-line slot).
14
+ * True for a slot that holds transport code (`be.transport.<protocol>` and the door slots of the webhook and realtime kinds).
10
15
  *
11
16
  * @param {string | null} slot - A slot id from `hfs.slotOf`.
12
17
  * @returns {boolean} Whether files of that slot are transport files.
13
18
  */
14
- export const isTransportSlot = (slot) => typeof slot === "string" && (slot.startsWith("be.transport.") || slot === "be.feature.transport.cli")
19
+ export const isTransportSlot = (slot) => typeof slot === "string" && (slot.startsWith("be.transport.") || KIND_DOOR_SLOTS.includes(slot))