@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/CHANGELOG.md +50 -0
- package/README.md +145 -69
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/cdc.mjs +150 -150
- package/comments.mjs +340 -389
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +168 -303
- package/data-access.mjs +452 -307
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/event-delivery.mjs +93 -93
- package/idempotency.mjs +101 -0
- package/index.mjs +137 -107
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +17 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +408 -432
- package/module-shape.mjs +202 -0
- package/naming.mjs +219 -220
- package/observability.mjs +275 -275
- package/package.json +4 -2
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +479 -452
- package/transport.mjs +168 -168
- package/type-safety.mjs +551 -511
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -265
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
|
|
20
|
-
import { recommended as
|
|
21
|
-
import { recommended as
|
|
22
|
-
import { recommended as
|
|
23
|
-
import { recommended as
|
|
24
|
-
import { recommended as
|
|
25
|
-
import { recommended as
|
|
26
|
-
import { recommended as
|
|
27
|
-
import { recommended as
|
|
28
|
-
import { recommended as
|
|
29
|
-
import { recommended as
|
|
30
|
-
import { recommended as
|
|
31
|
-
import { recommended as
|
|
32
|
-
import { recommended as
|
|
33
|
-
import { recommended as
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{ law: "
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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"
|
package/input-bounds.mjs
ADDED
|
@@ -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()
|