@starci/eslint-canon-be 1.8.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -9
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +270 -155
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- package/lib/slots.mjs +0 -83
package/idempotency.mjs
CHANGED
|
@@ -1,90 +1,121 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rule that holds inbox dedupe (catalog R80 `BE_INBOX_DEDUPE_MISSING`).
|
|
3
3
|
*
|
|
4
|
-
* A sender that gets no timely reply resends: a payment provider retries a webhook, a queue
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A sender that gets no timely reply resends: a payment provider retries a webhook, a queue redelivers a message it
|
|
5
|
+
* never saw acknowledged. The standard is one inbox table keyed by `(source, event id)` in front of every door
|
|
6
|
+
* delivery can repeat through, so a repeat is a no-op instead of a second charge, a second email or a second state
|
|
7
|
+
* transition.
|
|
8
8
|
*
|
|
9
|
-
* - `inbox-dedupe-required`
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* - `inbox-dedupe-required` judges every DELIVERY method of a service. Doors (consumers, `SignedWebhook` controllers) are
|
|
10
|
+
* thin (R88): they map the delivery and dispatch one message, so the claim lives in the `*.service.ts` the handler calls,
|
|
11
|
+
* inside its transaction. A delivery method is a public method of a `<name>.service.ts` whose FIRST parameter has an
|
|
12
|
+
* `eventId` property, read by the parameter's TYPE. The FIRST awaited expression of such a method must be
|
|
13
|
+
* `claim(source, eventId)` on a receiver whose type is the `Inbox` port of `platform/inbox`, and the method must return
|
|
14
|
+
* early when the claim answers `false`. A lookalike `Inbox` declared by another owner, a renamed receiver and a
|
|
15
|
+
* property injection are all judged by the receiver's type, so only the real port satisfies the rule; a keyword or a
|
|
16
|
+
* class name never does.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* Accepted shapes of "claim, then return on false":
|
|
19
|
+
* if (!(await this.inbox.claim(source, id))) return
|
|
20
|
+
* if ((await this.inbox.claim(source, id)) === false) { return }
|
|
21
|
+
* const fresh = await this.inbox.claim(source, id)
|
|
22
|
+
* if (!fresh) return
|
|
23
|
+
* Whether the handler has side effects BEFORE its first await stays a review question (the call graph is not read).
|
|
22
24
|
*/
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
26
|
+
import { walk } from "./lib/ast.mjs"
|
|
27
|
+
import { baseName, isOwnedType } from "./lib/ports.mjs"
|
|
28
|
+
import { typed } from "./lib/types.mjs"
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const WEBHOOK_FILE = /\/transport\/http\/[^/]*webhook[^/]*\.controller\.ts$/i
|
|
29
|
-
/** A class whose name alone says it consumes a delivery, regardless of where the file sits. */
|
|
30
|
-
const CONSUMER_CLASS_NAME = /(?:Outbox)?Consumer$/
|
|
31
|
-
/** A method decorator that binds a handler to an incoming message or event, not just a filename convention. */
|
|
32
|
-
const CONSUMER_DECORATORS = new Set(["EventPattern", "MessagePattern", "OnEvent", "Process"])
|
|
30
|
+
/** The `Inbox` port of `platform/inbox`. */
|
|
31
|
+
const isInboxType = (context, node) => isOwnedType(context, node, { name: "Inbox", capability: "inbox", tier: "platform" })
|
|
33
32
|
|
|
34
|
-
/**
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
/** A `return` statement, or a block that ends in one. */
|
|
34
|
+
const returns = (statement) =>
|
|
35
|
+
statement?.type === "ReturnStatement"
|
|
36
|
+
|| (statement?.type === "BlockStatement" && statement.body.at(-1)?.type === "ReturnStatement")
|
|
37
|
+
|
|
38
|
+
/** Whether `test` is `!<expression>` or `<expression> === false` around `target` (the awaited claim, or the identifier holding it). */
|
|
39
|
+
const refusesOn = (test, target) => {
|
|
40
|
+
if (test.type === "UnaryExpression" && test.operator === "!") return test.argument === target || (test.argument.type === "Identifier" && target.type === "Identifier" && test.argument.name === target.name)
|
|
41
|
+
if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) {
|
|
42
|
+
const [left, right] = [test.left, test.right]
|
|
43
|
+
const isFalse = (node) => node.type === "Literal" && node.value === false
|
|
44
|
+
return (left === target && isFalse(right)) || (right === target && isFalse(left))
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
/**
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
/** Where the awaited claim sits: the statement that tests it, or the declaration that stores it followed by that test. */
|
|
50
|
+
const returnsOnFalse = (awaited) => {
|
|
51
|
+
const node = awaited
|
|
52
|
+
const parent = node.parent
|
|
53
|
+
if (parent.type === "UnaryExpression" || parent.type === "BinaryExpression") {
|
|
54
|
+
const statement = parent.parent
|
|
55
|
+
return statement.type === "IfStatement" && statement.test === parent && refusesOn(parent, node) && returns(statement.consequent)
|
|
56
|
+
}
|
|
57
|
+
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier") {
|
|
58
|
+
const declaration = parent.parent
|
|
59
|
+
const siblings = declaration.parent?.body
|
|
60
|
+
if (!Array.isArray(siblings)) return false
|
|
61
|
+
const next = siblings[siblings.indexOf(declaration) + 1]
|
|
62
|
+
return next?.type === "IfStatement" && refusesOn(next.test, parent.id) && returns(next.consequent)
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
48
66
|
|
|
49
|
-
/**
|
|
67
|
+
/** The awaited expression that completes first in a body, ignoring nested functions; null when the body awaits nothing. */
|
|
68
|
+
const firstAwait = (body) => {
|
|
69
|
+
let first = null
|
|
70
|
+
walk(body, (node) => {
|
|
71
|
+
if (node.type === "AwaitExpression" && (first === null || node.range[1] < first.range[1])) first = node
|
|
72
|
+
}, { intoFunctions: false })
|
|
73
|
+
return first
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a parameter's type has an `eventId` property: the shape of a delivery. */
|
|
77
|
+
const isDeliveryParam = (context, param) => {
|
|
78
|
+
const target = param.type === "TSParameterProperty" ? param.parameter : param.type === "AssignmentPattern" ? param.left : param
|
|
79
|
+
const { checker, toTs } = typed(context)
|
|
80
|
+
const tsNode = toTs(target)
|
|
81
|
+
if (!tsNode) return false
|
|
82
|
+
const type = checker.getTypeAtLocation(tsNode)
|
|
83
|
+
return (type.isUnion() ? type.types : [type]).some((part) => checker.getPropertyOfType(part, "eventId") !== undefined)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A service method that takes a delivery claims the event through the shared inbox before it acts. */
|
|
50
87
|
export const inboxDedupeRequired = {
|
|
51
88
|
meta: {
|
|
52
89
|
type: "problem",
|
|
53
|
-
docs: { description: "A
|
|
90
|
+
docs: { description: "A public service method whose first parameter carries an `eventId` claims `(source, eventId)` on the `Inbox` port first and returns early when it answers `false`." },
|
|
54
91
|
schema: [],
|
|
55
92
|
messages: {
|
|
56
|
-
|
|
57
|
-
|
|
93
|
+
noClaim: "This delivery method's first awaited expression is not `claim(source, eventId)` on the `Inbox` port of `platform/inbox` (`@InjectInbox() private readonly inbox: Inbox`). A sender that gets no timely reply resends the same event: claim it before anything else so a repeat delivery is a no-op.",
|
|
94
|
+
noEarlyReturn: "This delivery method claims the event but does not return when the claim answers `false`. Write `if (!(await this.inbox.claim(source, eventId))) return` (or store the answer and return on `!fresh` straight after), so a repeat does none of the work again.",
|
|
58
95
|
},
|
|
59
96
|
},
|
|
60
97
|
create(context) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
const hfs = hfsOf(context)
|
|
99
|
+
const filename = context.filename || context.getFilename()
|
|
100
|
+
const slot = hfs.slotOf(filename)
|
|
101
|
+
if (!baseName(filename).endsWith(".service.ts") || !slot || slot.startsWith("be.tests.")) return {}
|
|
65
102
|
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
if (
|
|
82
|
-
context.report({ node, messageId: "consumer" })
|
|
83
|
-
return
|
|
84
|
-
}
|
|
85
|
-
if (publicDecorators.length && isWebhookController(filename, text, publicDecorators)) {
|
|
86
|
-
context.report({ node, messageId: "webhook" })
|
|
87
|
-
}
|
|
103
|
+
MethodDefinition(node) {
|
|
104
|
+
if (node.kind !== "method" || node.static || node.key.type === "PrivateIdentifier" || !node.value.body) return
|
|
105
|
+
if (node.accessibility === "private" || node.accessibility === "protected") return
|
|
106
|
+
const first = node.value.params[0]
|
|
107
|
+
if (!first || !isDeliveryParam(context, first)) return
|
|
108
|
+
const awaited = firstAwait(node.value.body)
|
|
109
|
+
const call = awaited?.argument
|
|
110
|
+
const claims = call?.type === "CallExpression"
|
|
111
|
+
&& call.callee.type === "MemberExpression"
|
|
112
|
+
&& !call.callee.computed
|
|
113
|
+
&& call.callee.property.type === "Identifier"
|
|
114
|
+
&& call.callee.property.name === "claim"
|
|
115
|
+
&& call.arguments.length >= 2
|
|
116
|
+
&& isInboxType(context, call.callee.object)
|
|
117
|
+
if (!claims) context.report({ node: node.key, messageId: "noClaim" })
|
|
118
|
+
else if (!returnsOnFalse(awaited)) context.report({ node: node.key, messageId: "noEarlyReturn" })
|
|
88
119
|
},
|
|
89
120
|
}
|
|
90
121
|
},
|
package/index.mjs
CHANGED
|
@@ -16,62 +16,79 @@
|
|
|
16
16
|
* every conversation about the failure. Aliasing one here to match a target's older spelling would
|
|
17
17
|
* leave two names for one rule and no way to tell which a message came from.
|
|
18
18
|
*/
|
|
19
|
+
import { buildBeConfig } from "./lib/config.mjs"
|
|
19
20
|
import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
|
|
20
21
|
import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
|
|
22
|
+
import { recommended as buildersRecommended, rules as buildersRules } from "./builders.mjs"
|
|
21
23
|
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
22
24
|
import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
|
|
25
|
+
import { recommended as connectionsRecommended, rules as connectionsRules } from "./connections.mjs"
|
|
23
26
|
import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
|
|
24
27
|
import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
|
|
25
28
|
import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
|
|
26
29
|
import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
|
|
27
30
|
import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
|
|
28
31
|
import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
|
|
32
|
+
import { recommended as infraOwnerRecommended, rules as infraOwnerRules } from "./infra-owner.mjs"
|
|
33
|
+
import { recommended as injectionRecommended, rules as injectionRules } from "./injection.mjs"
|
|
29
34
|
import { recommended as inputBoundsRecommended, rules as inputBoundsRules } from "./input-bounds.mjs"
|
|
30
35
|
import { recommended as logSafetyRecommended, rules as logSafetyRules } from "./log-safety.mjs"
|
|
31
36
|
import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
|
|
32
37
|
import { recommended as moduleShapeRecommended, rules as moduleShapeRules } from "./module-shape.mjs"
|
|
33
38
|
import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
|
|
34
39
|
import { recommended as observabilityRecommended, rules as observabilityRules } from "./observability.mjs"
|
|
40
|
+
import { recommended as operationsRecommended, rules as operationsRules } from "./operations.mjs"
|
|
35
41
|
import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
|
|
36
42
|
import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
|
|
37
43
|
import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
|
|
38
44
|
import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
|
|
45
|
+
import { recommended as specNoSkipRecommended, rules as specNoSkipRules } from "./spec-no-skip.mjs"
|
|
39
46
|
import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
|
|
40
47
|
import { recommended as suppressionRecommended, rules as suppressionRules } from "./suppression.mjs"
|
|
41
48
|
import { recommended as temporalRecommended, rules as temporalRules } from "./temporal.mjs"
|
|
42
49
|
import { recommended as testingRecommended, rules as testingRules } from "./testing.mjs"
|
|
50
|
+
import { recommended as testWorldRecommended, rules as testWorldRules } from "./test-world.mjs"
|
|
43
51
|
import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
|
|
44
52
|
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
53
|
+
import { recommended as unitSpecRecommended, rules as unitSpecRules } from "./unit-spec.mjs"
|
|
45
54
|
import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
|
|
46
55
|
|
|
47
56
|
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
48
57
|
const CONTRIBUTIONS = [
|
|
49
58
|
{ law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
|
|
50
59
|
{ law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
|
|
60
|
+
{ law: "builders", rules: buildersRules, recommended: buildersRecommended },
|
|
51
61
|
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
52
62
|
{ law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
|
|
63
|
+
{ law: "connections", rules: connectionsRules, recommended: connectionsRecommended },
|
|
53
64
|
{ law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
|
|
54
65
|
{ law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
|
|
55
66
|
{ law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
|
|
56
67
|
{ law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
|
|
57
68
|
{ law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
|
|
58
69
|
{ law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
|
|
70
|
+
{ law: "infra-owner", rules: infraOwnerRules, recommended: infraOwnerRecommended },
|
|
71
|
+
{ law: "injection", rules: injectionRules, recommended: injectionRecommended },
|
|
59
72
|
{ law: "input-bounds", rules: inputBoundsRules, recommended: inputBoundsRecommended },
|
|
60
73
|
{ law: "log-safety", rules: logSafetyRules, recommended: logSafetyRecommended },
|
|
61
74
|
{ law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
|
|
62
75
|
{ law: "module-shape", rules: moduleShapeRules, recommended: moduleShapeRecommended },
|
|
63
76
|
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
64
77
|
{ law: "observability", rules: observabilityRules, recommended: observabilityRecommended },
|
|
78
|
+
{ law: "operations", rules: operationsRules, recommended: operationsRecommended },
|
|
65
79
|
{ law: "query-safety", rules: querySafetyRules, recommended: querySafetyRecommended },
|
|
66
80
|
{ law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
|
|
67
81
|
{ law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
|
|
68
82
|
{ law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
|
|
83
|
+
{ law: "spec-no-skip", rules: specNoSkipRules, recommended: specNoSkipRecommended },
|
|
69
84
|
{ law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
|
|
70
85
|
{ law: "suppression", rules: suppressionRules, recommended: suppressionRecommended },
|
|
71
86
|
{ law: "temporal", rules: temporalRules, recommended: temporalRecommended },
|
|
72
87
|
{ law: "testing", rules: testingRules, recommended: testingRecommended },
|
|
88
|
+
{ law: "test-world", rules: testWorldRules, recommended: testWorldRecommended },
|
|
73
89
|
{ law: "transport", rules: transportRules, recommended: transportRecommended },
|
|
74
90
|
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
91
|
+
{ law: "unit-spec", rules: unitSpecRules, recommended: unitSpecRecommended },
|
|
75
92
|
{ law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
|
|
76
93
|
]
|
|
77
94
|
|
|
@@ -112,22 +129,28 @@ export const rules = Object.fromEntries(
|
|
|
112
129
|
)
|
|
113
130
|
|
|
114
131
|
/**
|
|
115
|
-
* The levels this canon asks for
|
|
116
|
-
*
|
|
117
|
-
* Unlike the front end, this axis allows `warn`, and the allowance is the burn-down playbook rather
|
|
118
|
-
* than a softer standard: a rule measured with debt arrives at `warn` with a ledger entry naming the
|
|
119
|
-
* offenders, and flips to `error` the day that entry closes. Switching a rule on over existing
|
|
120
|
-
* offenders is what teaches an author to scroll past it, and a reader who has learned to scroll past
|
|
121
|
-
* one rule reads none of them.
|
|
132
|
+
* The levels this canon asks for: every rule at `error`. There is no `warn` and no burn-down level; a rule with
|
|
133
|
+
* debt is fixed in the repositories before it ships, never published weaker.
|
|
122
134
|
*/
|
|
123
135
|
export const recommended = Object.fromEntries(
|
|
124
136
|
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
|
|
125
137
|
)
|
|
126
138
|
|
|
127
139
|
/** The plugin object, shaped the way a flat config expects it. */
|
|
128
|
-
|
|
140
|
+
const plugin = {
|
|
129
141
|
meta: { name: "eslint-plugin-starci-be" },
|
|
130
142
|
rules,
|
|
131
143
|
}
|
|
132
144
|
|
|
133
|
-
export
|
|
145
|
+
export default plugin
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The whole flat config of a back end: `export default starciBeConfig({ hfs: loadHfs(import.meta.url) })`.
|
|
149
|
+
*
|
|
150
|
+
* @param {{ hfs: object }} input - The HFS view of the repository.
|
|
151
|
+
* @returns {Promise<Array<object>>} The flat config.
|
|
152
|
+
*/
|
|
153
|
+
export const starciBeConfig = ({ hfs }) => buildBeConfig({ hfs, plugin, recommended })
|
|
154
|
+
|
|
155
|
+
export { linterOptions } from "./lib/config.mjs"
|
|
156
|
+
export { loadHfs } from "./lib/hfs.mjs"
|
package/infra-owner.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds BE-CONVENTION 1.10-1.14 (R90 `BE_INFRA_OWNER`).
|
|
3
|
+
*
|
|
4
|
+
* Every raw infrastructure library (HTTP, cache, queue, scheduler, logger, date, config, in-process events) has exactly
|
|
5
|
+
* one owning platform or integration capability, and only files of that capability may import it or reach for it as a
|
|
6
|
+
* global. The table lives in the slot manifest (`ruleParams.be.infraOwners`: specifier -> owners, `[]` for "nowhere"),
|
|
7
|
+
* so the rule holds no library name of its own and the messages and the check read one source.
|
|
8
|
+
*
|
|
9
|
+
* "Owning capability" is asked of the slot view (`ownerOf(filename)`), never of a path pattern. A reference is an
|
|
10
|
+
* import (static, dynamic, `require`, `export ... from`) of the specifier or of a subpath of it, or a value reference to
|
|
11
|
+
* an unresolved global (`fetch`, `setTimeout`, `globalThis.setInterval`) whose name is a key of the table. Specs are
|
|
12
|
+
* included: they use the fake clock and the doubles the owning capability provides.
|
|
13
|
+
*
|
|
14
|
+
* The test world (slot `be.tests.world`, `src/tests/world`) is an infrastructure owner like the platform capabilities: it is
|
|
15
|
+
* the TEST COMPOSITION ROOT that starts docker, polls readiness, serves the network-edge fakes over `node:http` and drives the
|
|
16
|
+
* apps over HTTP with a raw client, and it has no Nest container to inject a port from. It may use every library the
|
|
17
|
+
* table gives an owner; a library the table owns by nobody (`[]`, `nowhere`) is still refused there. A spec does not
|
|
18
|
+
* compose: specs stay refused and reach infrastructure through `useTestWorld(...)`.
|
|
19
|
+
*
|
|
20
|
+
* `no-framework-logger` (R40) and `no-ambient-clock` (R79) judge Nest `Logger` and `Date`; the table holds neither.
|
|
21
|
+
*/
|
|
22
|
+
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
23
|
+
import { moduleReferences } from "./lib/import-source.mjs"
|
|
24
|
+
|
|
25
|
+
/** The owner id of a file: the last two segments of its owner root (`platform/http`), or null when no owner holds it. */
|
|
26
|
+
const ownerIdOf = (hfs, filename) => {
|
|
27
|
+
const root = hfs.ownerOf(filename)
|
|
28
|
+
return root === null ? null : root.split("/").slice(-2).join("/")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The table key a module specifier falls under (the specifier itself or a subpath of it, with or without `node:`), or null. */
|
|
32
|
+
const keyOf = (table, specifier) => {
|
|
33
|
+
const variants = [specifier, specifier.startsWith("node:") ? specifier.slice(5) : `node:${specifier}`]
|
|
34
|
+
let best = null
|
|
35
|
+
for (const variant of variants) {
|
|
36
|
+
for (const key of Object.keys(table)) {
|
|
37
|
+
if ((variant === key || variant.startsWith(`${key}/`)) && (best === null || key.length > best.length)) best = key
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return best
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A raw infrastructure library is used only inside the capability that owns it. */
|
|
44
|
+
export const infraImportOwner = {
|
|
45
|
+
meta: {
|
|
46
|
+
type: "problem",
|
|
47
|
+
docs: { description: "A raw infrastructure library (HTTP, cache, queue, scheduler, logger, date, config, events) is imported or referenced only by its owning capability." },
|
|
48
|
+
schema: [],
|
|
49
|
+
messages: {
|
|
50
|
+
foreign: "`{{name}}` is owned by {{owners}}. Use that capability's port (injected with its `Inject*()` decorator) instead of the raw library, or move this code into the owner.",
|
|
51
|
+
nowhere: "`{{name}}` is not used in a back end: its job belongs to a platform port (config through the typed `<c>.config.ts`, events through the outbox, schedules through `platform/scheduling`). Remove it.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
const hfs = hfsOf(context)
|
|
56
|
+
const table = hfs.ruleParams.infraOwners
|
|
57
|
+
const here = ownerIdOf(hfs, context.filename)
|
|
58
|
+
const isWorld = inTestWorld(hfs, context.filename)
|
|
59
|
+
const check = (node, name, key) => {
|
|
60
|
+
const owners = table[key]
|
|
61
|
+
if (owners.includes(here) || (isWorld && owners.length > 0)) return
|
|
62
|
+
if (owners.length === 0) context.report({ node, messageId: "nowhere", data: { name } })
|
|
63
|
+
else context.report({ node, messageId: "foreign", data: { name, owners: owners.map((owner) => `\`${owner}\``).join(", ") } })
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
...moduleReferences(({ source, value }) => {
|
|
67
|
+
const key = keyOf(table, value)
|
|
68
|
+
if (key !== null) check(source, value, key)
|
|
69
|
+
}),
|
|
70
|
+
MemberExpression(node) {
|
|
71
|
+
if (node.computed || node.object.type !== "Identifier" || node.object.name !== "globalThis" || node.property.type !== "Identifier") return
|
|
72
|
+
if (Object.hasOwn(table, node.property.name)) check(node, `globalThis.${node.property.name}`, node.property.name)
|
|
73
|
+
},
|
|
74
|
+
"Program:exit"() {
|
|
75
|
+
const globalScope = context.sourceCode.scopeManager.globalScope
|
|
76
|
+
if (!globalScope) return
|
|
77
|
+
const references = [...globalScope.through, ...globalScope.variables.filter((variable) => variable.defs.length === 0).flatMap((variable) => variable.references)]
|
|
78
|
+
for (const reference of references) {
|
|
79
|
+
if (reference.isValueReference === false) continue
|
|
80
|
+
const name = reference.identifier.name
|
|
81
|
+
if (Object.hasOwn(table, name)) check(reference.identifier, name, name)
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The rules this law contributes to the plugin. */
|
|
89
|
+
export const rules = {
|
|
90
|
+
"infra-import-owner": infraImportOwner,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every rule of this law at `error`. */
|
|
94
|
+
export const recommended = {
|
|
95
|
+
"starci-be/infra-import-owner": "error",
|
|
96
|
+
}
|