@starci/eslint-canon-be 1.2.1 → 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.
package/index.mjs CHANGED
@@ -1,107 +1,137 @@
1
- /**
2
- * The back-end plugin: every law's rules, gathered once.
3
- *
4
- * A consuming repository imports THIS and nothing below it. Registering rules one by one in the
5
- * target's own plugin file is how the two lists drift: a law gains a rule here, nobody adds it there,
6
- * and the rule ships as a document. That is not hypothetical for this axis - the back end kept a
7
- * hand-written plugin whose names had already diverged from canon's, so `no-nest-logger` and
8
- * `no-framework-logger` were one law wearing two names and no build could say which had fired.
9
- *
10
- * The target's `eslint.config.mjs` stays the authority on what is switched on, at what level and
11
- * over which globs. That is a different question from what EXISTS, and this file answers only the
12
- * second.
13
- *
14
- * WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
15
- * law that declares it, because that name is what appears in a build log, in a disable comment and in
16
- * every conversation about the failure. Aliasing one here to match a target's older spelling would
17
- * leave two names for one rule and no way to tell which a message came from.
18
- */
19
- import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
20
- import { recommended as cdcRecommended, rules as cdcRules } from "./cdc.mjs"
21
- import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
22
- import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
23
- import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
24
- import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
25
- 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"
28
- import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
29
- import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
30
- import { recommended as observabilityRecommended, rules as observabilityRules } from "./observability.mjs"
31
- import { recommended as testingRecommended, rules as testingRules } from "./testing.mjs"
32
- import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
33
- import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
34
-
35
- /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
36
- const CONTRIBUTIONS = [
37
- { law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
38
- { law: "cdc", rules: cdcRules, recommended: cdcRecommended },
39
- { law: "comments", rules: commentsRules, recommended: commentsRecommended },
40
- { law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
41
- { law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
42
- { law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
43
- { law: "event-delivery", rules: eventDeliveryRules, recommended: eventDeliveryRecommended },
44
- { law: "exception-identity", rules: exceptionIdentityRules, recommended: exceptionIdentityRecommended },
45
- { law: "exceptions", rules: exceptionsRules, recommended: exceptionsRecommended },
46
- { law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
47
- { law: "naming", rules: namingRules, recommended: namingRecommended },
48
- { law: "observability", rules: observabilityRules, recommended: observabilityRecommended },
49
- { law: "testing", rules: testingRules, recommended: testingRecommended },
50
- { law: "transport", rules: transportRules, recommended: transportRecommended },
51
- { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
52
- ]
53
-
54
- /** Every gathered law. */
55
- export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
56
-
57
- /**
58
- * Every (law, rule name) pair as DECLARED, before any collapsing.
59
- *
60
- * This exists because `ruleOwners` and `rules` below are both built with `Object.fromEntries`, which
61
- * silently keeps the last writer when two laws declare one name. A test that walks either of them
62
- * therefore cannot see a collision: the duplicate is already gone by the time it looks, so every
63
- * name appears exactly once BY CONSTRUCTION and the check passes forever while being blind.
64
- *
65
- * That is exactly what happened - three back-end rules were declared by two laws each, one copy was
66
- * discarded on import, and the guard reported green the whole time. A gate that cannot fail is
67
- * indistinguishable from no gate, so the raw declarations are published here and the guard walks
68
- * these instead.
69
- */
70
- export const ruleDeclarations = CONTRIBUTIONS.flatMap((entry) =>
71
- Object.keys(entry.rules).map((name) => ({ law: entry.law, name })),
72
- )
73
-
74
- /**
75
- * Which law declares each rule.
76
- *
77
- * Exported because a failing rule is a question about a LAW, and the shortest path from a build log
78
- * to the document that explains it is this map. It answers "who owns this name" for a name that
79
- * SHIPPED; proving no two laws claim one name is `ruleDeclarations`' job, not this map's.
80
- */
81
- export const ruleOwners = Object.fromEntries(
82
- CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
83
- )
84
-
85
- /** Every rule this canon publishes, keyed by its published name. */
86
- export const rules = Object.fromEntries(
87
- CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.rules)),
88
- )
89
-
90
- /**
91
- * The levels this canon asks for, as the plugin's own opinion.
92
- *
93
- * Unlike the front end, this axis allows `warn`, and the allowance is the burn-down playbook rather
94
- * than a softer standard: a rule measured with debt arrives at `warn` with a ledger entry naming the
95
- * offenders, and flips to `error` the day that entry closes. Switching a rule on over existing
96
- * offenders is what teaches an author to scroll past it, and a reader who has learned to scroll past
97
- * one rule reads none of them.
98
- */
99
- export const recommended = Object.fromEntries(
100
- CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
101
- )
102
-
103
- /** The plugin object, shaped the way a flat config expects it. */
104
- export default {
105
- meta: { name: "eslint-plugin-starci-be" },
106
- rules,
107
- }
1
+ /**
2
+ * The back-end plugin: every law's rules, gathered once.
3
+ *
4
+ * A consuming repository imports THIS and nothing below it. Registering rules one by one in the
5
+ * target's own plugin file is how the two lists drift: a law gains a rule here, nobody adds it there,
6
+ * and the rule ships as a document. That is not hypothetical for this axis - the back end kept a
7
+ * hand-written plugin whose names had already diverged from canon's, so `no-nest-logger` and
8
+ * `no-framework-logger` were one law wearing two names and no build could say which had fired.
9
+ *
10
+ * The target's `eslint.config.mjs` stays the authority on what is switched on, at what level and
11
+ * over which globs. That is a different question from what EXISTS, and this file answers only the
12
+ * second.
13
+ *
14
+ * WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
15
+ * law that declares it, because that name is what appears in a build log, in a disable comment and in
16
+ * every conversation about the failure. Aliasing one here to match a target's older spelling would
17
+ * leave two names for one rule and no way to tell which a message came from.
18
+ */
19
+ import { recommended as asyncDisciplineRecommended, rules as asyncDisciplineRules } from "./async-discipline.mjs"
20
+ import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
21
+ import { recommended as cdcRecommended, rules as cdcRules } from "./cdc.mjs"
22
+ import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
23
+ import { recommended as configOwnerRecommended, rules as configOwnerRules } from "./config-owner.mjs"
24
+ import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
25
+ import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
26
+ import { recommended as defaultDenyRecommended, rules as defaultDenyRules } from "./default-deny.mjs"
27
+ import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
28
+ import { recommended as errorHandlingRecommended, rules as errorHandlingRules } from "./error-handling.mjs"
29
+ import { recommended as eventDeliveryRecommended, rules as eventDeliveryRules } from "./event-delivery.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"
33
+ import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
34
+ import { recommended as moduleShapeRecommended, rules as moduleShapeRules } from "./module-shape.mjs"
35
+ import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
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"
44
+ import { recommended as testingRecommended, rules as testingRules } from "./testing.mjs"
45
+ import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
46
+ import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
47
+ import { recommended as userCopyRecommended, rules as userCopyRules } from "./user-copy.mjs"
48
+
49
+ /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
50
+ const CONTRIBUTIONS = [
51
+ { law: "async-discipline", rules: asyncDisciplineRules, recommended: asyncDisciplineRecommended },
52
+ { law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
53
+ { law: "cdc", rules: cdcRules, recommended: cdcRecommended },
54
+ { law: "comments", rules: commentsRules, recommended: commentsRecommended },
55
+ { law: "config-owner", rules: configOwnerRules, recommended: configOwnerRecommended },
56
+ { law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
57
+ { law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
58
+ { law: "default-deny", rules: defaultDenyRules, recommended: defaultDenyRecommended },
59
+ { law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
60
+ { law: "error-handling", rules: errorHandlingRules, recommended: errorHandlingRecommended },
61
+ { law: "event-delivery", rules: eventDeliveryRules, recommended: eventDeliveryRecommended },
62
+ { law: "idempotency", rules: idempotencyRules, recommended: idempotencyRecommended },
63
+ { law: "input-bounds", rules: inputBoundsRules, recommended: inputBoundsRecommended },
64
+ { law: "log-safety", rules: logSafetyRules, recommended: logSafetyRecommended },
65
+ { law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
66
+ { law: "module-shape", rules: moduleShapeRules, recommended: moduleShapeRecommended },
67
+ { law: "naming", rules: namingRules, recommended: namingRecommended },
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 },
76
+ { law: "testing", rules: testingRules, recommended: testingRecommended },
77
+ { law: "transport", rules: transportRules, recommended: transportRecommended },
78
+ { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
79
+ { law: "user-copy", rules: userCopyRules, recommended: userCopyRecommended },
80
+ ]
81
+
82
+ /** Every gathered law. */
83
+ export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
84
+
85
+ /**
86
+ * Every (law, rule name) pair as DECLARED, before any collapsing.
87
+ *
88
+ * This exists because `ruleOwners` and `rules` below are both built with `Object.fromEntries`, which
89
+ * silently keeps the last writer when two laws declare one name. A test that walks either of them
90
+ * therefore cannot see a collision: the duplicate is already gone by the time it looks, so every
91
+ * name appears exactly once BY CONSTRUCTION and the check passes forever while being blind.
92
+ *
93
+ * That is exactly what happened - three back-end rules were declared by two laws each, one copy was
94
+ * discarded on import, and the guard reported green the whole time. A gate that cannot fail is
95
+ * indistinguishable from no gate, so the raw declarations are published here and the guard walks
96
+ * these instead.
97
+ */
98
+ export const ruleDeclarations = CONTRIBUTIONS.flatMap((entry) =>
99
+ Object.keys(entry.rules).map((name) => ({ law: entry.law, name })),
100
+ )
101
+
102
+ /**
103
+ * Which law declares each rule.
104
+ *
105
+ * Exported because a failing rule is a question about a LAW, and the shortest path from a build log
106
+ * to the document that explains it is this map. It answers "who owns this name" for a name that
107
+ * SHIPPED; proving no two laws claim one name is `ruleDeclarations`' job, not this map's.
108
+ */
109
+ export const ruleOwners = Object.fromEntries(
110
+ CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
111
+ )
112
+
113
+ /** Every rule this canon publishes, keyed by its published name. */
114
+ export const rules = Object.fromEntries(
115
+ CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.rules)),
116
+ )
117
+
118
+ /**
119
+ * The levels this canon asks for, as the plugin's own opinion.
120
+ *
121
+ * Unlike the front end, this axis allows `warn`, and the allowance is the burn-down playbook rather
122
+ * than a softer standard: a rule measured with debt arrives at `warn` with a ledger entry naming the
123
+ * offenders, and flips to `error` the day that entry closes. Switching a rule on over existing
124
+ * offenders is what teaches an author to scroll past it, and a reader who has learned to scroll past
125
+ * one rule reads none of them.
126
+ */
127
+ export const recommended = Object.fromEntries(
128
+ CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
129
+ )
130
+
131
+ /** The plugin object, shaped the way a flat config expects it. */
132
+ export default {
133
+ meta: { name: "eslint-plugin-starci-be" },
134
+ rules,
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 ADDED
@@ -0,0 +1,17 @@
1
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
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))
package/lib/slots.mjs ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The adapter between the HFS slot manifest (`knowledge/hfs/slots.yaml`) and the lint rules that need a
3
+ * number or a list the manifest owns: which platform capabilities may be `@Global()`, how long a source
4
+ * file may be, how many names a public `index.ts` may export.
5
+ *
6
+ * The manifest states these in `ruleParams.be` (the same block `ruleParams(manifest, "be")` of
7
+ * `scripts/lib/hfs-slots.mjs` returns) and in the `budget.indexExports` of a `be.*` slot. This file reads only those
8
+ * keys, and there is no other source: a manifest that does not state them, or that cannot be read, stops the lint
9
+ * run with an error naming the file, because the slot check owns the finding and a rule must not guess a number.
10
+ *
11
+ * - `ruleParams.be.globalModules` directories `src/modules/platform/<capability>/` allowed to declare `@Global()`;
12
+ * the rule compares the capability `platform/<capability>`
13
+ * - `ruleParams.be.fileLines` `{ soft, hardGrowth }`: `soft` is the line budget; with `hardGrowth: true` a file
14
+ * over it may not be born or grow (`file-size-growth`)
15
+ * - the `budget.indexExports` of the first `be.*` slot that states one, the public-surface width
16
+ *
17
+ * The manifest is found through `STARCI_HFS_SLOTS`, else at `<runtime>/knowledge/hfs/slots.yaml` above this package.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs"
20
+ import { dirname, join } from "node:path"
21
+ import { fileURLToPath } from "node:url"
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+ const RUNTIME_ROOT = join(HERE, "..", "..", "..", "..")
25
+
26
+ const manifestFile = () => process.env.STARCI_HFS_SLOTS || join(RUNTIME_ROOT, "knowledge", "hfs", "slots.yaml")
27
+
28
+ const toCapability = (dir) => String(dir).replace(/^src\/modules\//, "").replace(/\/+$/, "")
29
+
30
+ /**
31
+ * Reads the rule parameters out of a parsed manifest.
32
+ *
33
+ * @param {object} manifest - The parsed `slots.yaml`.
34
+ * @returns {{ globalModules: readonly string[], fileLines: { soft: number, hardGrowth: number }, indexExports: number }} The parameters; `fileLines.hardGrowth` is the line count above which a file may not grow.
35
+ */
36
+ export const paramsFromManifest = (manifest) => {
37
+ const be = manifest?.ruleParams?.be
38
+ if (!Array.isArray(be?.globalModules) || !be.globalModules.every((name) => typeof name === "string")) throw new Error("slots.yaml states no ruleParams.be.globalModules")
39
+ if (!Number.isInteger(be.fileLines?.soft) || typeof be.fileLines?.hardGrowth !== "boolean") throw new Error("slots.yaml states no ruleParams.be.fileLines {soft, hardGrowth}")
40
+ const indexSlot = Array.isArray(manifest.slots)
41
+ ? manifest.slots.find((slot) => String(slot?.id ?? "").startsWith("be.") && Number.isInteger(slot?.budget?.indexExports))
42
+ : undefined
43
+ if (indexSlot === undefined) throw new Error("slots.yaml states no budget.indexExports on a be.* slot")
44
+ const { soft, hardGrowth } = be.fileLines
45
+ return {
46
+ globalModules: be.globalModules.map(toCapability),
47
+ fileLines: { soft, hardGrowth: hardGrowth ? soft : Number.POSITIVE_INFINITY },
48
+ indexExports: indexSlot.budget.indexExports,
49
+ }
50
+ }
51
+
52
+ const load = async () => {
53
+ const file = manifestFile()
54
+ if (!existsSync(file)) throw new Error(`HFS slot manifest not found at ${file}`)
55
+ const { parseYaml } = await import(new URL("../../../../engine/yaml.mjs", import.meta.url))
56
+ return paramsFromManifest(parseYaml(readFileSync(file, "utf8")))
57
+ }
58
+
59
+ /** The parameters, resolved once per process. */
60
+ export const hfsParams = await load()