@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -9
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +270 -155
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. 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
- * redelivers a message it never saw acknowledged. The standard is one inbox table keyed by
6
- * `(source, event id)` in front of every door delivery can repeat through, so a repeat is a no-op
7
- * instead of a second charge, a second email or a second state transition.
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` refuses a `@Public()` webhook controller method, or an outbox/queue
10
- * consumer, whose file shows no reference to an inbox or dedupe port. A consumer is recognized
11
- * by more than its filename: `<event>.consumer.ts` under `transport/message/` (the
12
- * `be.transport.message` slot) still counts, and so does a method decorated `@EventPattern`,
13
- * `@MessagePattern`, `@OnEvent` or `@Process`, or a class whose name ends `Consumer` or
14
- * `OutboxConsumer`, wherever that file sits. A class that only PUBLISHES - the producer half of
15
- * an outbox, typically `*-outbox.service.ts` / `*OutboxService` - carries none of those shapes
16
- * and is not flagged: it has no repeat delivery to dedupe unless it also consumes.
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
- * This reads one file's text for the shape of a claim (`inbox`, `dedupe`, `idempoten*` naming a
19
- * port, a field or a call) - it cannot see whether the call actually runs before the side effect,
20
- * which needs the call graph and stays a review question, same as `no-outer-manager-in-transaction`
21
- * says for its own undecidable half.
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 { decoratorName, keyName, staticText } from "./lib/ast.mjs"
24
- import { isTestLane, normalizePath } from "./lib/path.mjs"
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
- const INBOX_REFERENCE = /inbox|dedupe|idempoten/i
27
- const CONSUMER_FILE = /\/transport\/message\/[^/]+\.consumer\.ts$/
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
- /** The route or reason text a `@Public()` decorator's argument carries, lower-cased. */
35
- const publicReason = (node) => {
36
- const expression = node.expression
37
- const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
38
- if (argument?.type !== "ObjectExpression") return ""
39
- const property = argument.properties.find((item) => item.type === "Property" && keyName(item.key) === "reason")
40
- return (property ? staticText(property.value) : null)?.toLowerCase() ?? ""
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
- /** A webhook door: the file is named for one, the open door names its reason, or the route itself does. */
44
- const isWebhookController = (filename, sourceText, publicDecorators) =>
45
- WEBHOOK_FILE.test(filename)
46
- || publicDecorators.some((node) => /webhook/.test(publicReason(node)))
47
- || /@Controller\(\s*["'`][^"'`]*webhook/i.test(sourceText)
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
- /** Delivery this instance can receive more than once shows a claim against the inbox before it acts. */
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 `@Public()` webhook handler and an outbox/queue consumer claim the event through the shared inbox before they act." },
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
- webhook: "This webhook controller has no reference to an inbox or dedupe port. A sender that gets no timely reply resends the same event; claim `(source, event id)` in the shared inbox before acting, so a repeat delivery is a no-op.",
57
- consumer: "This consumer has no reference to an inbox or dedupe port. A queue redelivers a message it never saw acknowledged; claim `(source, event id)` in the shared inbox before acting, so a redelivery is a no-op.",
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 filename = normalizePath(context.filename || context.getFilename())
62
- if (isTestLane(filename)) return {}
63
- let isConsumer = CONSUMER_FILE.test(filename)
64
- const publicDecorators = []
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
- Decorator(node) {
67
- const name = decoratorName(node)
68
- if (name === "Public") publicDecorators.push(node)
69
- else if (CONSUMER_DECORATORS.has(name)) isConsumer = true
70
- },
71
- ClassDeclaration(node) {
72
- if (node.id && CONSUMER_CLASS_NAME.test(node.id.name)) isConsumer = true
73
- },
74
- ClassExpression(node) {
75
- if (node.id && CONSUMER_CLASS_NAME.test(node.id.name)) isConsumer = true
76
- },
77
- "Program:exit"(node) {
78
- const sourceCode = context.sourceCode || context.getSourceCode()
79
- const text = sourceCode.getText()
80
- if (INBOX_REFERENCE.test(text)) return
81
- if (isConsumer) {
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, as the plugin's own opinion.
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
- export default {
140
+ const plugin = {
129
141
  meta: { name: "eslint-plugin-starci-be" },
130
142
  rules,
131
143
  }
132
144
 
133
- export { starciBeConfig, linterOptions } from "./lib/config.mjs"
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"
@@ -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
+ }