@starci/eslint-canon-be 1.2.2 → 1.7.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.
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The rules that hold HFS errors and their handling (catalog R38 `BE_ERROR_HOME`, R40 `BE_LOGGER_REQUIRED`).
3
+ *
4
+ * TWO RULES, ONE HABIT: a failure is either named where its owner lives, or it is visible.
5
+ *
6
+ * - `catch-must-account` refuses a `catch` (and a promise `.catch`) that swallows. A handler passes when it
7
+ * rethrows, when it logs through a logger port, or when it returns an outcome that carries the caught
8
+ * error as its `cause`. An empty handler, and one that returns `null` or `false` and moves on, turns a
9
+ * real failure into "nothing happened" - the payment error that became `unavailable` with no trace.
10
+ * - `error-home` keeps every error where its semantic owner is. A class deriving from `DomainError` lives in
11
+ * `<capability>/errors/`; the retired `exceptions/` capability, the retired `AbstractException` base, a bare
12
+ * `throw new Error(...)`, a framework `HttpException`, and a domain that throws a platform error are all
13
+ * refused. Expected business results are typed unions, not exceptions, and are outside this rule.
14
+ *
15
+ * This file replaces the Academy `AbstractException` laws: those rules pinned a central exception family that
16
+ * HFS no longer has, and a rule that survives its standard as "off" is a rule nobody can trust.
17
+ */
18
+ import { some, walk } from "./lib/ast.mjs"
19
+ import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
20
+
21
+ const LOG_METHODS = new Set(["error", "warn", "info", "debug", "log", "fatal", "verbose", "trace"])
22
+
23
+ /** The object a member call is made on, as the last identifier of its chain (`this.logger.error` gives `logger`). */
24
+ const receiverName = (callee) => {
25
+ const object = callee.object
26
+ if (object.type === "Identifier") return object.name
27
+ if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
28
+ return null
29
+ }
30
+
31
+ /** `logger.error(...)`, `this.log.warn(...)`: a call on a logger port. `console` is never one. */
32
+ const isLoggerCall = (node) => {
33
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || node.callee.computed) return false
34
+ if (node.callee.property.type !== "Identifier" || !LOG_METHODS.has(node.callee.property.name)) return false
35
+ const receiver = receiverName(node.callee)
36
+ return receiver !== null && receiver !== "console" && /log/i.test(receiver)
37
+ }
38
+
39
+ /** True when `name` is used as a value in `node` (not just as the object of `name.message`). */
40
+ const carriesCaught = (node, name) => {
41
+ if (!name) return false
42
+ let carried = false
43
+ walk(node, (child) => {
44
+ if (child.type !== "Identifier" || child.name !== name) return
45
+ const parent = child.parent
46
+ if (parent?.type === "MemberExpression" && parent.object === child) return
47
+ carried = true
48
+ })
49
+ return carried
50
+ }
51
+
52
+ /**
53
+ * Whether a handler block accounts for the failure.
54
+ *
55
+ * @param {object} body - The block of the handler.
56
+ * @param {string | null} param - The caught error's name, when the handler names it.
57
+ * @returns {boolean} True when it rethrows, logs, or returns an outcome carrying the error.
58
+ */
59
+ const accounts = (body, param) =>
60
+ some(
61
+ body,
62
+ (node) =>
63
+ node.type === "ThrowStatement" ||
64
+ isLoggerCall(node) ||
65
+ (node.type === "ReturnStatement" && node.argument !== null && carriesCaught(node.argument, param)),
66
+ { intoFunctions: false },
67
+ )
68
+
69
+ const paramName = (fn) => (fn.params?.[0]?.type === "Identifier" ? fn.params[0].name : null)
70
+
71
+ /** Every failure is rethrown, logged, or returned as an outcome carrying its cause. */
72
+ export const catchMustAccount = {
73
+ meta: {
74
+ type: "problem",
75
+ docs: { description: "A catch rethrows, logs through a logger port, or returns an outcome that carries the cause." },
76
+ schema: [],
77
+ messages: {
78
+ empty: "This `catch` is empty, so the failure vanishes. Rethrow it, log it through the logger port, or return a typed outcome carrying it as `cause`.",
79
+ swallowed:
80
+ "This `catch` neither rethrows, logs, nor returns an outcome carrying the caught error, so the failure leaves no trace. Rethrow it, log it through the logger port, or return a typed outcome with `cause`.",
81
+ },
82
+ },
83
+ create(context) {
84
+ const checkBlock = (reportNode, body, param) => {
85
+ if (body.body.length === 0) context.report({ node: reportNode, messageId: "empty" })
86
+ else if (!accounts(body, param)) context.report({ node: reportNode, messageId: "swallowed" })
87
+ }
88
+ return {
89
+ CatchClause(node) {
90
+ checkBlock(node, node.body, node.param?.type === "Identifier" ? node.param.name : null)
91
+ },
92
+ CallExpression(node) {
93
+ if (node.callee.type !== "MemberExpression" || node.callee.computed) return
94
+ if (node.callee.property.type !== "Identifier" || node.callee.property.name !== "catch") return
95
+ const handler = node.arguments[0]
96
+ if (!handler || (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")) return
97
+ if (handler.body.type === "BlockStatement") {
98
+ checkBlock(handler, handler.body, paramName(handler))
99
+ } else if (!isLoggerCall(handler.body) && !carriesCaught(handler.body, paramName(handler))) {
100
+ context.report({ node: handler, messageId: "swallowed" })
101
+ }
102
+ },
103
+ }
104
+ },
105
+ }
106
+
107
+ /** The framework exceptions that carry an HTTP status and no identity. */
108
+ const FRAMEWORK_EXCEPTIONS = new Set([
109
+ "BadRequestException",
110
+ "NotFoundException",
111
+ "UnauthorizedException",
112
+ "ForbiddenException",
113
+ "InternalServerErrorException",
114
+ "HttpException",
115
+ "ConflictException",
116
+ "NotAcceptableException",
117
+ "RequestTimeoutException",
118
+ "GoneException",
119
+ "PayloadTooLargeException",
120
+ "UnsupportedMediaTypeException",
121
+ "UnprocessableEntityException",
122
+ "NotImplementedException",
123
+ "BadGatewayException",
124
+ "ServiceUnavailableException",
125
+ "GatewayTimeoutException",
126
+ ])
127
+
128
+ const BUILTIN_ERRORS = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "ReferenceError", "EvalError", "URIError"])
129
+
130
+ /** `<capability>/errors/` under a module tier or a feature, or the platform `errors` capability itself. */
131
+ const ERRORS_HOME =
132
+ /\/src\/(?:modules\/(?:domain|platform|integrations)\/[^/]+|features\/[^/]+)\/(?:[^/]+\/)*errors\/|\/src\/modules\/platform\/errors\//
133
+ const PLATFORM_ERRORS = /\/src\/modules\/platform\/errors\//
134
+ const RETIRED_HOME = /\/src\/modules\/(?:domain|platform|integrations)\/exceptions\//
135
+ const DOMAIN_TIER = /\/src\/modules\/domain\//
136
+ const HEALTH_PROBE = /\/health(?:z)?\.controller\.ts$|\/health\//
137
+
138
+ /** An error class is declared at its owner, extends `DomainError`, and is never a bare or framework throw. */
139
+ export const errorHome = {
140
+ meta: {
141
+ type: "problem",
142
+ docs: { description: "Errors live in `<capability>/errors/`, extend DomainError, and no bare or framework error escapes." },
143
+ schema: [],
144
+ messages: {
145
+ place: "`{{name}}` is an error of this capability but is declared outside its `errors/` folder. Move it to the `errors/` folder of the capability that owns its meaning.",
146
+ retiredHome: "This file sits in a retired `exceptions/` capability. Errors belong to the capability that owns their meaning, in its `errors/` folder.",
147
+ retiredBase: "`{{name}}` extends `AbstractException`, the retired central base. Extend `DomainError` from `platform/errors` and keep the class in its capability's `errors/`.",
148
+ mustExtendDomainError: "`{{name}}` extends the built-in `{{parent}}`. Extend `DomainError` so the error carries a stable `code` and a `cause`.",
149
+ bareError: "`throw new Error(...)` carries a sentence and no code, so nothing downstream can group or map it. Throw a `DomainError` subclass of the owning capability.",
150
+ framework: "`throw new {{name}}(...)` carries an HTTP status and no identity. Throw a `DomainError` subclass and let the transport map its code to a status.",
151
+ platformThrown: "A domain capability throws `{{name}}`, a platform error. Translate it into an error of this capability so platform details do not cross the boundary.",
152
+ },
153
+ },
154
+ create(context) {
155
+ const filename = normalizePath(context.filename || context.getFilename())
156
+ if (isDeclarationFile(filename)) return {}
157
+ const listeners = {}
158
+ if (RETIRED_HOME.test(filename)) {
159
+ listeners.Program = (node) => context.report({ node, messageId: "retiredHome" })
160
+ }
161
+ if (isTestLane(filename)) return listeners
162
+ const inHome = ERRORS_HOME.test(filename)
163
+ const platformImports = new Set()
164
+ listeners.ImportDeclaration = (node) => {
165
+ if (typeof node.source.value !== "string" || !/(?:^|\/)platform\//.test(node.source.value)) return
166
+ for (const specifier of node.specifiers) platformImports.add(specifier.local.name)
167
+ }
168
+ listeners.ClassDeclaration = (node) => {
169
+ const parent = node.superClass
170
+ if (!node.id || !parent || parent.type !== "Identifier") return
171
+ if (parent.name === "AbstractException") {
172
+ context.report({ node: parent, messageId: "retiredBase", data: { name: node.id.name } })
173
+ } else if (BUILTIN_ERRORS.has(parent.name)) {
174
+ if (!PLATFORM_ERRORS.test(filename)) {
175
+ context.report({ node: parent, messageId: "mustExtendDomainError", data: { name: node.id.name, parent: parent.name } })
176
+ }
177
+ } else if (/Error$/.test(parent.name) && !inHome) {
178
+ context.report({ node: node.id, messageId: "place", data: { name: node.id.name } })
179
+ }
180
+ }
181
+ listeners.ThrowStatement = (node) => {
182
+ const thrown = node.argument
183
+ if (!thrown || thrown.type !== "NewExpression" || thrown.callee.type !== "Identifier") return
184
+ const name = thrown.callee.name
185
+ if (name === "Error") {
186
+ context.report({ node: thrown, messageId: "bareError" })
187
+ } else if (FRAMEWORK_EXCEPTIONS.has(name) && !HEALTH_PROBE.test(filename)) {
188
+ context.report({ node: thrown, messageId: "framework", data: { name } })
189
+ } else if (DOMAIN_TIER.test(filename) && name !== "DomainError" && /Error$/.test(name) && platformImports.has(name)) {
190
+ context.report({ node: thrown, messageId: "platformThrown", data: { name } })
191
+ }
192
+ }
193
+ return listeners
194
+ },
195
+ }
196
+
197
+ /** The rules this law contributes to the plugin. */
198
+ export const rules = {
199
+ "catch-must-account": catchMustAccount,
200
+ "error-home": errorHome,
201
+ }
202
+
203
+ /** Both start at error: HFS has no baseline, and the migration lanes clear the debt before a repository adopts them. */
204
+ export const recommended = {
205
+ "starci-be/catch-must-account": "error",
206
+ "starci-be/error-home": "error",
207
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The rule that holds inbox dedupe (catalog R80 `BE_INBOX_DEDUPE_MISSING`).
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.
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.
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.
22
+ */
23
+ import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
24
+ import { isTestLane, normalizePath } from "./lib/path.mjs"
25
+
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"])
33
+
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() ?? ""
41
+ }
42
+
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)
48
+
49
+ /** Delivery this instance can receive more than once shows a claim against the inbox before it acts. */
50
+ export const inboxDedupeRequired = {
51
+ meta: {
52
+ type: "problem",
53
+ docs: { description: "A `@Public()` webhook handler and an outbox/queue consumer claim the event through the shared inbox before they act." },
54
+ schema: [],
55
+ 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.",
58
+ },
59
+ },
60
+ 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 = []
65
+ 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
+ }
88
+ },
89
+ }
90
+ },
91
+ }
92
+
93
+ /** The rule this law contributes to the plugin. */
94
+ export const rules = {
95
+ "inbox-dedupe-required": inboxDedupeRequired,
96
+ }
97
+
98
+ /** Starts at error: a duplicate charge or a duplicate side effect is never a warning. */
99
+ export const recommended = {
100
+ "starci-be/inbox-dedupe-required": "error",
101
+ }
package/index.mjs CHANGED
@@ -16,39 +16,67 @@
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 { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
19
20
  import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
20
21
  import { recommended as cdcRecommended, rules as cdcRules } from "./cdc.mjs"
21
22
  import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
23
+ import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
22
24
  import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
23
25
  import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
26
+ import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
24
27
  import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
28
+ import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
25
29
  import { recommended as eventDeliveryRecommended, rules as eventDeliveryRules } from "./event-delivery.mjs"
26
- import { recommended as exceptionIdentityRecommended, rules as exceptionIdentityRules } from "./exception-identity.mjs"
27
- import { recommended as exceptionsRecommended, rules as exceptionsRules } from "./exceptions.mjs"
30
+ import { recommended as idempotencyRecommended, rules as idempotencyRules } from "./idempotency.mjs"
31
+ import { recommended as inputBoundsRecommended, rules as inputBoundsRules } from "./input-bounds.mjs"
32
+ import { recommended as logSafetyRecommended, rules as logSafetyRules } from "./log-safety.mjs"
28
33
  import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
34
+ import { recommended as moduleShapeRecommended, rules as moduleShapeRules } from "./module-shape.mjs"
29
35
  import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
30
36
  import { recommended as observabilityRecommended, rules as observabilityRules } from "./observability.mjs"
37
+ import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
38
+ import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
39
+ import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
40
+ import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
41
+ import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
42
+ import { recommended as suppressionRecommended, rules as suppressionRules } from "./suppression.mjs"
43
+ import { recommended as temporalRecommended, rules as temporalRules } from "./temporal.mjs"
31
44
  import { recommended as testingRecommended, rules as testingRules } from "./testing.mjs"
32
45
  import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
33
46
  import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
47
+ import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
34
48
 
35
49
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
36
50
  const CONTRIBUTIONS = [
51
+ { law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
37
52
  { law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
38
53
  { law: "cdc", rules: cdcRules, recommended: cdcRecommended },
39
54
  { law: "comments", rules: commentsRules, recommended: commentsRecommended },
55
+ { law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
40
56
  { law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
41
57
  { law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
58
+ { law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
42
59
  { law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
60
+ { law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
43
61
  { law: "event-delivery", rules: eventDeliveryRules, recommended: eventDeliveryRecommended },
44
- { law: "exception-identity", rules: exceptionIdentityRules, recommended: exceptionIdentityRecommended },
45
- { law: "exceptions", rules: exceptionsRules, recommended: exceptionsRecommended },
62
+ { law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
63
+ { law: "input-bounds", rules: inputBoundsRules, recommended: inputBoundsRecommended },
64
+ { law: "log-safety", rules: logSafetyRules, recommended: logSafetyRecommended },
46
65
  { law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
66
+ { law: "module-shape", rules: moduleShapeRules, recommended: moduleShapeRecommended },
47
67
  { law: "naming", rules: namingRules, recommended: namingRecommended },
48
68
  { law: "observability", rules: observabilityRules, recommended: observabilityRecommended },
69
+ { law: "query-safety", rules: querySafetyRules, recommended: querySafetyRecommended },
70
+ { law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
71
+ { law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
72
+ { law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
73
+ { law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
74
+ { law: "suppression", rules: suppressionRules, recommended: suppressionRecommended },
75
+ { law: "temporal", rules: temporalRules, recommended: temporalRecommended },
49
76
  { law: "testing", rules: testingRules, recommended: testingRecommended },
50
77
  { law: "transport", rules: transportRules, recommended: transportRecommended },
51
78
  { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
79
+ { law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
52
80
  ]
53
81
 
54
82
  /** Every gathered law. */
@@ -105,3 +133,5 @@ export default {
105
133
  meta: { name: "eslint-plugin-starci-be" },
106
134
  rules,
107
135
  }
136
+
137
+ export { starciBeConfig, linterOptions } from "./lib/config.mjs"
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The rules that keep input bounded (R42 `BE_INPUT_BOUNDED`).
3
+ *
4
+ * `dto-needs-validator` refuses an input class whose property carries no `class-validator` decorator. A property with
5
+ * no validator accepts any value the wire delivers: a string of any length, a number of any size, an array of any
6
+ * count. The transport layer states what it accepts; the use case never re-checks shape.
7
+ *
8
+ * An input class is one decorated `@InputType` or `@ArgsType`, or declared in a file named `*.dto.ts`, `*.input.ts`,
9
+ * `*.args.ts` or `*.request.ts`. A class decorated `@ObjectType` is a response and is left alone. A decorator counts as
10
+ * a validator only when it is imported from `class-validator`, so a `@Field` or a `@Type` does not satisfy the rule.
11
+ */
12
+ import { decoratorName } from "./lib/ast.mjs"
13
+ import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
14
+
15
+ const INPUT_FILE = /\.(?:dto|input|args|request)\.ts$/
16
+
17
+ const decoratorsOf = (node) => node.decorators ?? []
18
+
19
+ /** Names imported from `class-validator`, by local name. */
20
+ const validatorImports = (program) => {
21
+ const names = new Set()
22
+ for (const statement of program.body) {
23
+ if (statement.type !== "ImportDeclaration" || statement.source.value !== "class-validator") continue
24
+ for (const specifier of statement.specifiers) names.add(specifier.local.name)
25
+ }
26
+ return names
27
+ }
28
+
29
+ /** Every property of an input class has a validator. */
30
+ export const dtoNeedsValidator = {
31
+ meta: {
32
+ type: "problem",
33
+ docs: { description: "A property of an input class carries a `class-validator` decorator." },
34
+ schema: [],
35
+ messages: {
36
+ missing:
37
+ "`{{name}}` has no `class-validator` decorator, so the wire may deliver any value here: a string of any length, a number of any size, an array of any count. Add the validators the field needs (`@IsString() @MaxLength(n)`, `@IsInt() @Min(0) @Max(n)`, `@IsArray() @ArrayMaxSize(n)`, `@IsOptional()` for a nullable field).",
38
+ },
39
+ },
40
+ create(context) {
41
+ const filename = normalizePath(context.filename || context.getFilename())
42
+ if (isTestLane(filename) || isDeclarationFile(filename)) return {}
43
+ const sourceCode = context.sourceCode || context.getSourceCode()
44
+ let validators = null
45
+ return {
46
+ Program(node) {
47
+ validators = validatorImports(node)
48
+ },
49
+ ClassDeclaration(node) {
50
+ checkClass(node)
51
+ },
52
+ ClassExpression(node) {
53
+ checkClass(node)
54
+ },
55
+ }
56
+ function checkClass(node) {
57
+ const decorators = decoratorsOf(node).map(decoratorName)
58
+ const target = node.parent?.type === "ExportNamedDeclaration" || node.parent?.type === "ExportDefaultDeclaration" ? node.parent : node
59
+ const classDecorators = [...decorators, ...decoratorsOf(target).map(decoratorName)]
60
+ if (classDecorators.includes("ObjectType")) return
61
+ const isInput = classDecorators.includes("InputType") || classDecorators.includes("ArgsType") || INPUT_FILE.test(filename)
62
+ if (!isInput) return
63
+ for (const member of node.body.body) {
64
+ if (member.type !== "PropertyDefinition" || member.static || member.computed) continue
65
+ if (member.key.type !== "Identifier") continue
66
+ const validated = decoratorsOf(member).some((decorator) => {
67
+ const name = decoratorName(decorator)
68
+ return name !== null && validators.has(name)
69
+ })
70
+ if (!validated) context.report({ node: member.key, messageId: "missing", data: { name: sourceCode.getText(member.key) } })
71
+ }
72
+ }
73
+ },
74
+ }
75
+
76
+ /** The rules this law contributes to the plugin. */
77
+ export const rules = {
78
+ "dto-needs-validator": dtoNeedsValidator,
79
+ }
80
+
81
+ /** Starts at error: no baseline exists, and the repositories' fix lanes clear the debt. */
82
+ export const recommended = {
83
+ "starci-be/dto-needs-validator": "error",
84
+ }
package/lib/ast.mjs ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Small AST helpers the HFS laws share, so each rule states its question instead of its traversal.
3
+ *
4
+ * Nothing here touches disk or the type checker: a rule reads one file's syntax and its path.
5
+ */
6
+
7
+ const SKIP_KEYS = new Set(["parent", "loc", "range", "tokens", "comments"])
8
+
9
+ const isNode = (value) => value !== null && typeof value === "object" && typeof value.type === "string"
10
+
11
+ const FUNCTION_TYPES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"])
12
+
13
+ /**
14
+ * Visits `node` and every descendant, depth first.
15
+ *
16
+ * @param {object} node - The subtree root.
17
+ * @param {(child: object) => void} visit - Called with each node.
18
+ * @param {{ intoFunctions?: boolean }} [options] - `intoFunctions: false` stops at nested function boundaries.
19
+ * @returns {void}
20
+ */
21
+ export const walk = (node, visit, { intoFunctions = true } = {}) => {
22
+ if (!isNode(node)) return
23
+ visit(node)
24
+ for (const [key, value] of Object.entries(node)) {
25
+ if (SKIP_KEYS.has(key)) continue
26
+ if (Array.isArray(value)) {
27
+ for (const child of value) {
28
+ if (!isNode(child)) continue
29
+ if (!intoFunctions && FUNCTION_TYPES.has(child.type)) continue
30
+ walk(child, visit, { intoFunctions })
31
+ }
32
+ } else if (isNode(value)) {
33
+ if (!intoFunctions && FUNCTION_TYPES.has(value.type)) continue
34
+ walk(value, visit, { intoFunctions })
35
+ }
36
+ }
37
+ }
38
+
39
+ /** True when any node of the subtree satisfies `predicate`. */
40
+ export const some = (node, predicate, options) => {
41
+ let found = false
42
+ walk(node, (child) => {
43
+ if (!found && predicate(child)) found = true
44
+ }, options)
45
+ return found
46
+ }
47
+
48
+ /** The name a property key, identifier or string literal spells, else null. */
49
+ export const keyName = (node) => {
50
+ if (!node) return null
51
+ if (node.type === "Identifier") return node.name
52
+ if (node.type === "Literal" && typeof node.value === "string") return node.value
53
+ if (node.type === "PrivateIdentifier") return node.name
54
+ return null
55
+ }
56
+
57
+ /** The name of a decorator (`@Foo` or `@Foo(...)`), else null. */
58
+ export const decoratorName = (decorator) => {
59
+ const expression = decorator?.expression
60
+ if (!expression) return null
61
+ if (expression.type === "Identifier") return expression.name
62
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
63
+ return null
64
+ }
65
+
66
+ /** True for a string literal or a template without substitutions, giving its text via `staticText`. */
67
+ export const staticText = (node) => {
68
+ if (!node) return null
69
+ if (node.type === "Literal" && typeof node.value === "string") return node.value
70
+ if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis.map((q) => q.value.cooked ?? "").join("")
71
+ return null
72
+ }
73
+
74
+ /** Splits `webhookSecret`, `WEBHOOK_SECRET` and `webhook-secret` into lower-case words. */
75
+ export const wordsOf = (name) =>
76
+ String(name)
77
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
78
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
79
+ .split(/[^A-Za-z0-9]+/)
80
+ .filter(Boolean)
81
+ .map((word) => word.toLowerCase())
package/lib/config.mjs ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The one flat-config block that attaches the back-end canon to a repository.
3
+ *
4
+ * A consuming `eslint.config.mjs` used to hand-copy three things: the list of rules it switches off, the
5
+ * helper that lifts `warn` to `error`, and the source globs. Copies drift - two repositories held
6
+ * byte-identical files, a third had grown its own variant - and a rule that a copy switched off with the words
7
+ * "replaced by checks" was enforced by nothing when the replacing check ran in no gate. This factory owns the
8
+ * levels, so a repository states only its source globs.
9
+ *
10
+ * NO RULE IS OFF. HFS has no retired-rule list: a rule the standard no longer holds is deleted from the
11
+ * plugin, and a rule that stays is on. The factory refuses a recommendation that carries an `off`, so a rule
12
+ * cannot be published switched off and still look adopted.
13
+ */
14
+
15
+ /** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
16
+ export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" })
17
+
18
+ /** The zero-warning gate reads a warning as a failure, so the canon's `warn` level is stated as `error`. */
19
+ const asError = (setting) =>
20
+ setting === "warn" ? "error" : Array.isArray(setting) && setting[0] === "warn" ? ["error", ...setting.slice(1)] : setting
21
+
22
+ const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting)
23
+
24
+ /**
25
+ * @param {object} input - Attachment options.
26
+ * @param {string[]} input.sources - The globs of the repository's production and spec source.
27
+ * @param {object} input.plugin - The plugin object, imported from this canon.
28
+ * @param {Record<string, unknown>} input.recommended - The recommendation, imported from this canon.
29
+ * @returns {object} One flat-config block.
30
+ */
31
+ export const starciBeConfig = ({ sources, plugin, recommended }) => {
32
+ if (!Array.isArray(sources) || sources.length === 0) {
33
+ throw new Error("starciBeConfig needs the repository's source globs - a block that matches no file governs nothing")
34
+ }
35
+ const rules = recommended?.rules ?? recommended
36
+ if (!rules || Object.keys(rules).length === 0) {
37
+ throw new Error("starciBeConfig received an empty recommendation - a config with no rules is not adoption")
38
+ }
39
+ const off = Object.entries(rules)
40
+ .filter(([, setting]) => levelOf(setting) === "off")
41
+ .map(([name]) => name)
42
+ if (off.length > 0) {
43
+ throw new Error(`starciBeConfig refuses a recommendation with rules switched off: ${off.join(", ")}`)
44
+ }
45
+ const configured = Object.fromEntries(
46
+ Object.entries(rules).map(([name, setting]) => [name, asError(setting)]),
47
+ )
48
+ return { files: sources, linterOptions: { ...linterOptions }, plugins: { "starci-be": plugin }, rules: configured }
49
+ }
package/lib/path.mjs CHANGED
@@ -1,2 +1,17 @@
1
1
  /** Forward-slash form of a filename, so Windows paths compare like every other path. */
2
2
  export const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
3
+
4
+ /** A spec file: the unit lane, where a fixture may build a deliberately wrong value. */
5
+ export const isSpecFile = (filename) => /\.(?:spec|test)\.[cm]?[jt]sx?$|-spec\.[cm]?[jt]sx?$/.test(normalizePath(filename))
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
+ /** A migration: the one place schema-changing SQL is allowed to be written. */
14
+ export const isMigrationFile = (filename) => /\/persistence\/migrations\/[^/]+\.[cm]?[jt]s$/.test(normalizePath(filename))
15
+
16
+ /** A declaration file carries types only and no runtime behaviour to police. */
17
+ export const isDeclarationFile = (filename) => /\.d\.[cm]?ts$/.test(normalizePath(filename))