@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.
- package/CHANGELOG.md +13 -1
- package/README.md +13 -7
- package/ambient-id.mjs +90 -0
- package/cli.mjs +181 -0
- package/comments.mjs +18 -21
- package/config-owner.mjs +20 -4
- package/connections.mjs +12 -12
- package/cookies.mjs +106 -0
- package/cqrs.mjs +22 -26
- package/data-access.mjs +9 -28
- package/default-deny.mjs +2 -1
- package/e2e-flow.mjs +4 -4
- package/event-bus.mjs +237 -0
- package/feature-thin.mjs +237 -0
- package/idempotency.mjs +3 -1
- package/index.mjs +22 -1
- package/infra-owner.mjs +1 -1
- package/injection.mjs +67 -22
- package/input-bounds.mjs +2 -1
- package/jobs.mjs +214 -0
- package/lib/ast.mjs +7 -0
- package/lib/config.mjs +1 -1
- package/lib/db-writes.mjs +1 -1
- package/lib/doors.mjs +81 -0
- package/lib/edition.mjs +45 -0
- package/lib/entity-writes.mjs +35 -0
- package/lib/hfs.mjs +3 -3
- package/lib/path.mjs +0 -6
- package/lib/persistence.mjs +11 -18
- package/lib/ports.mjs +5 -2
- package/lib/specifier.mjs +27 -0
- package/lib/transactions.mjs +37 -0
- package/lib/transport-slots.mjs +8 -3
- package/lib/unit-spec.mjs +63 -2
- package/module-layering.mjs +3 -24
- package/module-shape.mjs +2 -2
- package/naming.mjs +2 -2
- package/package.json +5 -3
- package/project-graph.mjs +8 -5
- package/projections.mjs +117 -0
- package/query-safety.mjs +1 -1
- package/realtime.mjs +181 -0
- package/runtime/engine/config.mjs +494 -0
- package/runtime/engine/invalid-config.mjs +15 -0
- package/runtime/engine/orca-config.mjs +27 -0
- package/runtime/engine/runtime-root.mjs +5 -0
- package/runtime/engine/yaml.mjs +3 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
- package/runtime/knowledge/hfs/rules.yaml +2059 -0
- package/runtime/knowledge/hfs/slots.yaml +915 -123
- package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
- package/runtime/knowledge/sonar-gate.yaml +53 -19
- package/runtime/modules/models/registry.yaml +591 -0
- package/runtime/modules/models/runtimes.yaml +701 -0
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
- package/runtime/scripts/api/git/config-get.mjs +10 -0
- package/runtime/scripts/api/git/lib.mjs +17 -27
- package/runtime/scripts/api/git/ls-files.mjs +6 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
- package/runtime/scripts/api/git/remote.mjs +5 -0
- package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
- package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
- package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
- package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
- package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
- package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
- package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
- package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
- package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
- package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
- package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
- package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
- package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
- package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
- package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
- package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
- package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
- package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
- package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
- package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
- package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
- package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
- package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
- package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
- package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
- package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +17 -114
- package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
- package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
- package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +27 -147
- package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +59 -18
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
- package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
- package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +10 -4
- package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
- package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
- package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
- package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
- package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
- package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
- package/runtime/scripts/hfs/edition-slots.mjs +66 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +247 -0
- package/runtime/scripts/hfs/param-names.mjs +18 -0
- package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
- package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
- package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
- package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
- package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
- package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
- package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
- package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
- package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
- package/runtime/scripts/lib/dockerfile.mjs +124 -0
- package/runtime/scripts/lib/git.mjs +36 -0
- package/runtime/scripts/lib/i18n.mjs +86 -0
- package/runtime/scripts/lib/json.mjs +13 -3
- package/runtime/scripts/lib/language.mjs +26 -7
- package/runtime/scripts/lib/line-count.mjs +9 -0
- package/runtime/scripts/lib/list.mjs +20 -0
- package/runtime/scripts/lib/path-key.mjs +59 -2
- package/runtime/scripts/lib/read-text.mjs +34 -0
- package/runtime/scripts/lib/regex.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +4 -3
- package/runtime/scripts/lib/sops-envelope.mjs +58 -0
- package/runtime/scripts/lib/stack-services.mjs +7 -31
- package/runtime/scripts/lib/ts-ast.mjs +35 -0
- package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
- package/schema-authority.mjs +14 -12
- package/service-deps.mjs +7 -13
- package/service-isolation.mjs +72 -0
- package/size-budget.mjs +3 -2
- package/test-world.mjs +5 -4
- package/testing.mjs +27 -24
- package/transport.mjs +12 -9
- package/unit-spec.mjs +1 -1
- package/user-copy.mjs +5 -4
- package/webhooks.mjs +173 -0
- package/runtime/scripts/api/sops/decrypt.mjs +0 -16
- package/runtime/scripts/lib/recorded-lines.mjs +0 -27
- package/runtime/scripts/lib/test-secrets.mjs +0 -116
- /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
- /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
- /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 `
|
|
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
|
-
|
|
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))
|
package/lib/edition.mjs
ADDED
|
@@ -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/
|
|
8
|
-
* `
|
|
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/
|
|
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
|
|
package/lib/persistence.mjs
CHANGED
|
@@ -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
|
|
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
|
|
74
|
+
* @returns {boolean} True for the files of the cli app and the cli feature root.
|
|
73
75
|
*/
|
|
74
|
-
export const
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/transport-slots.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
19
|
+
export const isTransportSlot = (slot) => typeof slot === "string" && (slot.startsWith("be.transport.") || KIND_DOOR_SLOTS.includes(slot))
|