@starci/eslint-canon-fe 3.0.2 → 5.1.2
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 +35 -0
- package/README.md +41 -16
- package/brand-values.mjs +174 -0
- package/class-names.mjs +1 -1
- package/client-boundary.mjs +199 -0
- package/comments.mjs +144 -190
- package/docs/brand-values.md +30 -0
- package/docs/client-boundary.md +99 -0
- package/docs/e2e-shape.md +161 -0
- package/docs/env-owner.md +52 -0
- package/docs/formatting.md +30 -0
- package/docs/hooks-folder.md +29 -0
- package/docs/hygiene.md +111 -0
- package/docs/lint-escape-hatch.md +32 -0
- package/docs/lists.md +74 -0
- package/docs/native-controls.md +73 -0
- package/docs/next-conventions.md +206 -0
- package/docs/size-and-state-budget.md +73 -0
- package/docs/spec-quality.md +139 -0
- package/docs/translation.md +50 -0
- package/docs/transport.md +168 -0
- package/docs/type-safety.md +95 -0
- package/e2e-shape.mjs +348 -0
- package/env-owner.mjs +134 -0
- package/file-layout.mjs +100 -7
- package/formatting.mjs +106 -0
- package/hooks-folder.mjs +119 -0
- package/hygiene.mjs +235 -0
- package/icon.mjs +210 -211
- package/index.mjs +292 -131
- package/lib/ast.mjs +94 -0
- package/lib/path.mjs +2 -0
- package/lib/scope.mjs +42 -0
- package/lib/why.mjs +304 -0
- package/lint-adoption.mjs +15 -52
- package/lint-escape-hatch.mjs +91 -56
- package/lists.mjs +197 -0
- package/loading.mjs +163 -164
- package/naming.mjs +143 -143
- package/native-controls.mjs +112 -0
- package/next-conventions.mjs +346 -0
- package/package.json +7 -3
- package/props-and-slots.mjs +4 -2
- package/served-locale.mjs +160 -160
- package/shape-slot.mjs +339 -0
- package/size-and-state-budget.mjs +196 -0
- package/spec-quality.mjs +239 -0
- package/the-split.mjs +133 -134
- package/tokens.mjs +267 -268
- package/translation.mjs +262 -122
- package/transport.mjs +404 -0
- package/type-safety.mjs +156 -74
- package/typography.mjs +82 -83
package/index.mjs
CHANGED
|
@@ -1,56 +1,80 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The front-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
|
|
6
|
-
* there, and the rule ships as a document.
|
|
7
|
-
* what is switched on and
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
|
|
11
|
-
* law that declares it, because that name is what appears in a build log, in a disable comment and
|
|
12
|
-
* in every conversation about the failure. Aliasing one here to match a target's older spelling
|
|
13
|
-
* would leave two names for one rule and no way to tell which a message came from.
|
|
14
|
-
*/
|
|
15
|
-
import {
|
|
1
|
+
/**
|
|
2
|
+
* The front-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
|
|
6
|
+
* there, and the rule ships as a document. `starciFeConfig` therefore owns BOTH questions - what
|
|
7
|
+
* exists and what is switched on - and every rule is an error: the repository's `eslint.config.mjs`
|
|
8
|
+
* names its layout and nothing else about the law.
|
|
9
|
+
*
|
|
10
|
+
* WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
|
|
11
|
+
* law that declares it, because that name is what appears in a build log, in a disable comment and
|
|
12
|
+
* in every conversation about the failure. Aliasing one here to match a target's older spelling
|
|
13
|
+
* would leave two names for one rule and no way to tell which a message came from.
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from "node:module"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { pathToFileURL } from "node:url"
|
|
18
|
+
import { recommended as brandValuesRecommended, rules as brandValuesRules } from "./brand-values.mjs"
|
|
19
|
+
import { recommended as clientBoundaryRecommended, rules as clientBoundaryRules } from "./client-boundary.mjs"
|
|
20
|
+
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
16
21
|
import { recommended as classNamesRecommended, rules as classNamesRules } from "./class-names.mjs"
|
|
22
|
+
import { recommended as e2eShapeRecommended, rules as e2eShapeRules, scope as e2eShapeScope } from "./e2e-shape.mjs"
|
|
23
|
+
import { recommended as envOwnerRecommended, rules as envOwnerRules } from "./env-owner.mjs"
|
|
17
24
|
import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
|
|
18
25
|
import {
|
|
19
26
|
audits as grammarBoundaryAudits,
|
|
20
27
|
recommended as grammarBoundaryRecommended,
|
|
21
28
|
rules as grammarBoundaryRules,
|
|
22
29
|
} from "./grammar-boundary.mjs"
|
|
23
|
-
import { recommended as
|
|
24
|
-
import { recommended as
|
|
25
|
-
import { recommended as
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import { recommended as
|
|
43
|
-
import { recommended as
|
|
44
|
-
import { recommended as
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
30
|
+
import { recommended as formattingRecommended, rules as formattingRules } from "./formatting.mjs"
|
|
31
|
+
import { recommended as hooksFolderRecommended, rules as hooksFolderRules } from "./hooks-folder.mjs"
|
|
32
|
+
import { recommended as hygieneRecommended, rules as hygieneRules } from "./hygiene.mjs"
|
|
33
|
+
import { recommended as iconRecommended, rules as iconRules } from "./icon.mjs"
|
|
34
|
+
import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
|
|
35
|
+
import { recommended as listsRecommended, rules as listsRules } from "./lists.mjs"
|
|
36
|
+
import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
|
|
37
|
+
import {
|
|
38
|
+
LAYOUT_GLOBS,
|
|
39
|
+
LAYOUTS,
|
|
40
|
+
audits as lintAdoptionAudits,
|
|
41
|
+
recommended as lintAdoptionRecommended,
|
|
42
|
+
rules as lintAdoptionRules,
|
|
43
|
+
} from "./lint-adoption.mjs"
|
|
44
|
+
import {
|
|
45
|
+
linterOptions as strictLinterOptions,
|
|
46
|
+
recommended as lintEscapeRecommended,
|
|
47
|
+
rules as lintEscapeRules,
|
|
48
|
+
} from "./lint-escape-hatch.mjs"
|
|
49
|
+
import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
|
|
50
|
+
import { recommended as nativeControlsRecommended, rules as nativeControlsRules } from "./native-controls.mjs"
|
|
51
|
+
import { recommended as nextConventionsRecommended, rules as nextConventionsRules } from "./next-conventions.mjs"
|
|
52
|
+
import { recommended as propsRecommended, rules as propsRules } from "./props-and-slots.mjs"
|
|
53
|
+
import { recommended as servedLocaleRecommended, rules as servedLocaleRules } from "./served-locale.mjs"
|
|
54
|
+
import { recommended as shapeSlotRecommended, rules as shapeSlotRules } from "./shape-slot.mjs"
|
|
55
|
+
import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-and-state-budget.mjs"
|
|
56
|
+
import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
|
|
57
|
+
import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
|
|
58
|
+
import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
|
|
59
|
+
import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
|
|
60
|
+
import { recommended as translationRecommended, rules as translationRules } from "./translation.mjs"
|
|
61
|
+
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
62
|
+
import { recommended as typographyRecommended, rules as typographyRules } from "./typography.mjs"
|
|
63
|
+
import { recommended as vendorRecommended, rules as vendorRules } from "./vendor-boundary.mjs"
|
|
64
|
+
import { why } from "./lib/why.mjs"
|
|
65
|
+
|
|
66
|
+
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
67
|
+
const CONTRIBUTIONS = [
|
|
68
|
+
{ law: "brand-values", rules: brandValuesRules, recommended: brandValuesRecommended },
|
|
69
|
+
{ law: "client-boundary", rules: clientBoundaryRules, recommended: clientBoundaryRecommended },
|
|
70
|
+
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
49
71
|
{
|
|
50
72
|
law: "class-names",
|
|
51
73
|
rules: classNamesRules,
|
|
52
74
|
recommended: classNamesRecommended,
|
|
53
75
|
},
|
|
76
|
+
{ law: "e2e-shape", rules: e2eShapeRules, recommended: e2eShapeRecommended, scope: e2eShapeScope },
|
|
77
|
+
{ law: "env-owner", rules: envOwnerRules, recommended: envOwnerRecommended },
|
|
54
78
|
{ law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
|
|
55
79
|
{
|
|
56
80
|
law: "grammar-boundary",
|
|
@@ -58,93 +82,230 @@ const CONTRIBUTIONS = [
|
|
|
58
82
|
recommended: grammarBoundaryRecommended,
|
|
59
83
|
audits: grammarBoundaryAudits,
|
|
60
84
|
},
|
|
61
|
-
{ law: "
|
|
62
|
-
{ law: "
|
|
63
|
-
{ law: "
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
{ law: "
|
|
75
|
-
{ law: "
|
|
76
|
-
{ law: "
|
|
77
|
-
{ law: "
|
|
78
|
-
{ law: "
|
|
79
|
-
{ law: "
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
export const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
*/
|
|
136
|
-
export const
|
|
137
|
-
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
85
|
+
{ law: "formatting", rules: formattingRules, recommended: formattingRecommended },
|
|
86
|
+
{ law: "hooks-folder", rules: hooksFolderRules, recommended: hooksFolderRecommended },
|
|
87
|
+
{ law: "hygiene", rules: hygieneRules, recommended: hygieneRecommended },
|
|
88
|
+
{ law: "icon", rules: iconRules, recommended: iconRecommended },
|
|
89
|
+
{ law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
|
|
90
|
+
{ law: "lists", rules: listsRules, recommended: listsRecommended },
|
|
91
|
+
{ law: "loading", rules: loadingRules, recommended: loadingRecommended },
|
|
92
|
+
{
|
|
93
|
+
law: "lint-adoption",
|
|
94
|
+
rules: lintAdoptionRules,
|
|
95
|
+
recommended: lintAdoptionRecommended,
|
|
96
|
+
audits: lintAdoptionAudits,
|
|
97
|
+
},
|
|
98
|
+
{ law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
|
|
99
|
+
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
100
|
+
{ law: "native-controls", rules: nativeControlsRules, recommended: nativeControlsRecommended },
|
|
101
|
+
{ law: "next-conventions", rules: nextConventionsRules, recommended: nextConventionsRecommended },
|
|
102
|
+
{ law: "props-and-slots", rules: propsRules, recommended: propsRecommended },
|
|
103
|
+
{ law: "served-locale", rules: servedLocaleRules, recommended: servedLocaleRecommended },
|
|
104
|
+
{ law: "shape-slot", rules: shapeSlotRules, recommended: shapeSlotRecommended },
|
|
105
|
+
{ law: "size-and-state-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
|
|
106
|
+
{ law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
|
|
107
|
+
{ law: "the-split", rules: splitRules, recommended: splitRecommended },
|
|
108
|
+
{ law: "tokens", rules: tokensRules, recommended: tokensRecommended },
|
|
109
|
+
{ law: "translation", rules: translationRules, recommended: translationRecommended },
|
|
110
|
+
{ law: "transport", rules: transportRules, recommended: transportRecommended },
|
|
111
|
+
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
112
|
+
{ law: "typography", rules: typographyRules, recommended: typographyRecommended },
|
|
113
|
+
{ law: "vendor-boundary", rules: vendorRules, recommended: vendorRecommended },
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
/** Every gathered law, including repository audits that publish no AST rule. */
|
|
117
|
+
export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
|
|
118
|
+
|
|
119
|
+
/** Which law declares each repository-level audit. */
|
|
120
|
+
export const auditOwners = Object.fromEntries(
|
|
121
|
+
CONTRIBUTIONS.flatMap((entry) =>
|
|
122
|
+
Object.keys(entry.audits ?? {}).map((name) => [name, entry.law]),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
/** Repository-level audits gathered beside the plugin. */
|
|
127
|
+
export const audits = Object.fromEntries(
|
|
128
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.audits ?? {})),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Which law declares each rule.
|
|
133
|
+
*
|
|
134
|
+
* Exported because a failing rule is a question about a LAW, and the shortest path from a build log
|
|
135
|
+
* to the document that explains it is this map. It is also what the twin test walks to prove no two
|
|
136
|
+
* laws claim one name.
|
|
137
|
+
*/
|
|
138
|
+
export const ruleOwners = Object.fromEntries(
|
|
139
|
+
CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every (law, rule name) pair as DECLARED, before any collapsing.
|
|
144
|
+
*
|
|
145
|
+
* `ruleOwners` above and `rules` below are both built with `Object.fromEntries`, which silently
|
|
146
|
+
* keeps the last writer when two laws declare one name. A guard that walks either of them cannot
|
|
147
|
+
* see a collision - the duplicate is gone before it looks, so every name appears exactly once BY
|
|
148
|
+
* CONSTRUCTION and the check passes forever while being blind.
|
|
149
|
+
*
|
|
150
|
+
* That is not hypothetical. The back-end twin had this exact shape, three rules were declared by two
|
|
151
|
+
* laws each, one copy was discarded on import, and its guard reported green throughout. This axis
|
|
152
|
+
* has no collision today; publishing the raw declarations is what keeps that a FACT rather than an
|
|
153
|
+
* assumption nobody can test.
|
|
154
|
+
*/
|
|
155
|
+
export const ruleDeclarations = CONTRIBUTIONS.flatMap((entry) =>
|
|
156
|
+
Object.keys(entry.rules).map((name) => ({ law: entry.law, name })),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
/** Every rule this canon publishes, keyed by its published name. */
|
|
160
|
+
export const rules = Object.fromEntries(
|
|
161
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.rules)),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The levels this canon asks for, as the plugin's own opinion.
|
|
166
|
+
*
|
|
167
|
+
* Every FE canon rule is an error. Existing debt is fixed before adoption; lowering architecture
|
|
168
|
+
* to warn teaches every later author that the boundary is optional and is not a supported rollout.
|
|
169
|
+
*/
|
|
170
|
+
export const recommended = Object.fromEntries(
|
|
171
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
/** Which tree each law governs: `src` unless the law says otherwise. */
|
|
175
|
+
const scopeOf = (entry) => entry.scope ?? "src"
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The levels the source rules ask for: every law except the e2e one.
|
|
179
|
+
*
|
|
180
|
+
* The audit compares a printed config of a PRODUCTION probe file with a map, so the map must hold
|
|
181
|
+
* only the rules that reach that file. The e2e rules govern another tree and are absent from it.
|
|
182
|
+
*/
|
|
183
|
+
export const sourceRecommended = Object.fromEntries(
|
|
184
|
+
CONTRIBUTIONS.filter((entry) => scopeOf(entry) === "src").flatMap((entry) => Object.entries(entry.recommended)),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
/** The levels the e2e rules ask for; they reach `e2e/**` and `playwright.config.*` only. */
|
|
188
|
+
export const e2eRecommended = Object.fromEntries(
|
|
189
|
+
CONTRIBUTIONS.filter((entry) => scopeOf(entry) === "e2e").flatMap((entry) => Object.entries(entry.recommended)),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
/** Flat-config options that make inline directives ineffective rather than merely forbidden. */
|
|
193
|
+
export const linterOptions = strictLinterOptions
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The Vietnamese why of each rule that carries a catalogue code: `{ code, vi, fixVi }`.
|
|
197
|
+
*
|
|
198
|
+
* Read by the harness, which quotes `vi` to the owner and files the finding under `code`.
|
|
199
|
+
*/
|
|
200
|
+
export { why }
|
|
201
|
+
|
|
202
|
+
/** The plugin object, shaped the way a flat config expects it. */
|
|
203
|
+
const plugin = {
|
|
204
|
+
meta: { name: "eslint-plugin-starci-fe" },
|
|
205
|
+
rules,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default plugin
|
|
209
|
+
|
|
210
|
+
export { LAYOUTS }
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The React Hooks plugin, from wherever the consuming repository installed it.
|
|
214
|
+
*
|
|
215
|
+
* A peer dependency is resolved from the package that asks for it, and a package linked by path
|
|
216
|
+
* (`file:`) is resolved from its REAL location - which has no `node_modules` of the repository that
|
|
217
|
+
* linked it. So the plugin is looked up from this package first and from the working directory
|
|
218
|
+
* second; a repository that installed `eslint-plugin-react-hooks` beside its `eslint.config.mjs`
|
|
219
|
+
* is found either way, and one that did not gets a message naming the install rather than a
|
|
220
|
+
* resolver stack trace.
|
|
221
|
+
*/
|
|
222
|
+
const loadReactHooks = () => {
|
|
223
|
+
for (const base of [import.meta.url, pathToFileURL(join(process.cwd(), "noop.js")).href]) {
|
|
224
|
+
try {
|
|
225
|
+
return createRequire(base)("eslint-plugin-react-hooks")
|
|
226
|
+
} catch {
|
|
227
|
+
// try the next place
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
throw new Error("starciFeConfig needs eslint-plugin-react-hooks 7 or newer: npm i -D eslint-plugin-react-hooks")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The React Hooks rules of a plugin, all at error.
|
|
235
|
+
*
|
|
236
|
+
* The plugin's own recommended set is the definition of "the hooks rules" - it carries the classic
|
|
237
|
+
* pair and the compiler-derived ones (`set-state-in-effect`, `refs`, `purity`, `immutability`) - and
|
|
238
|
+
* it ships several of them at `warn`. A warning is the level a team learns to scroll past, so the
|
|
239
|
+
* canon states every one as `error`. Four rules are named on purpose: a plugin too old to carry them
|
|
240
|
+
* would otherwise be adopted silently with the rules that catch effect-driven state and ref reads
|
|
241
|
+
* during render missing.
|
|
242
|
+
*
|
|
243
|
+
* @param {object} reactHooks - The plugin object.
|
|
244
|
+
* @returns {Record<string, "error">} Every recommended hooks rule at error.
|
|
245
|
+
*/
|
|
246
|
+
export const reactHooksRules = (reactHooks) => {
|
|
247
|
+
const configured = reactHooks.configs?.flat?.recommended?.rules ?? {}
|
|
248
|
+
for (const required of [
|
|
249
|
+
"react-hooks/set-state-in-effect",
|
|
250
|
+
"react-hooks/refs",
|
|
251
|
+
"react-hooks/rules-of-hooks",
|
|
252
|
+
"react-hooks/exhaustive-deps",
|
|
253
|
+
]) {
|
|
254
|
+
if (!(required in configured)) {
|
|
255
|
+
throw new Error(`eslint-plugin-react-hooks does not carry ${required}; install version 7 or newer`)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return Object.fromEntries(Object.keys(configured).map((name) => [name, "error"]))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Attach this canon to a repository: every rule, every level an error, both trees.
|
|
263
|
+
*
|
|
264
|
+
* A consuming `eslint.config.mjs` is three lines - it names its layout and spreads the result:
|
|
265
|
+
*
|
|
266
|
+
* import { starciFeConfig } from "@starci/eslint-canon-fe"
|
|
267
|
+
* export default [...starciFeConfig({ layout: "single-app" })]
|
|
268
|
+
*
|
|
269
|
+
* It owns nothing about the law: not which rules are on, not their level, not whether an inline
|
|
270
|
+
* comment may switch one off. NO RULE IS OFF - the factory throws rather than emit a block in which a
|
|
271
|
+
* published rule is missing, so a rule added to this package reaches every repository or the build of
|
|
272
|
+
* this package fails, and a repository cannot end up governed by a stale list.
|
|
273
|
+
*
|
|
274
|
+
* TWO BLOCKS. The first governs the product source (`src/**`, per layout) with every source rule and
|
|
275
|
+
* the React Hooks rules; the second governs `e2e/**` and `playwright.config.*` with the e2e rules and
|
|
276
|
+
* the escape-hatch fence. A repository's own plugins, ignores and language options stay its own
|
|
277
|
+
* business; what it may not keep is a second opinion about the law.
|
|
278
|
+
*
|
|
279
|
+
* @param {object} input - Attachment options.
|
|
280
|
+
* @param {"monorepo" | "single-app"} input.layout - Which shape this repository has.
|
|
281
|
+
* @returns {object[]} Two flat-config blocks.
|
|
282
|
+
*/
|
|
283
|
+
export const starciFeConfig = ({ layout } = {}) => {
|
|
284
|
+
const globs = LAYOUT_GLOBS[layout]
|
|
285
|
+
if (!globs) {
|
|
286
|
+
throw new Error(`unknown layout "${layout}" - expected one of: ${LAYOUTS.join(", ")}`)
|
|
287
|
+
}
|
|
288
|
+
const published = Object.keys(rules).map((name) => `starci-fe/${name}`)
|
|
289
|
+
const notRequested = published.filter((name) => recommended[name] !== "error")
|
|
290
|
+
if (notRequested.length > 0) {
|
|
291
|
+
throw new Error(`starciFeConfig: these published rules are not requested at error: ${notRequested.join(", ")}`)
|
|
292
|
+
}
|
|
293
|
+
const reactHooks = loadReactHooks()
|
|
294
|
+
const source = { ...sourceRecommended, ...reactHooksRules(reactHooks) }
|
|
295
|
+
// The escape-hatch fence is a source rule that must also cover the e2e tree.
|
|
296
|
+
const e2e = { ...e2eRecommended, "starci-fe/no-inline-lint-config": "error" }
|
|
297
|
+
return [
|
|
298
|
+
{
|
|
299
|
+
files: globs.src,
|
|
300
|
+
linterOptions: { ...linterOptions },
|
|
301
|
+
plugins: { "starci-fe": plugin, "react-hooks": reactHooks },
|
|
302
|
+
rules: source,
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
files: globs.e2e,
|
|
306
|
+
linterOptions: { ...linterOptions },
|
|
307
|
+
plugins: { "starci-fe": plugin },
|
|
308
|
+
rules: e2e,
|
|
309
|
+
},
|
|
310
|
+
]
|
|
311
|
+
}
|
package/lib/ast.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small AST readers shared by the laws that look at JSX attributes, calls and effect callbacks.
|
|
3
|
+
*
|
|
4
|
+
* ONE PLACE, because each reader answers "what does this node say" and two copies answer it
|
|
5
|
+
* slightly differently: a rule that treats `` `/a` `` as a string and a sibling that does not
|
|
6
|
+
* disagree about which hrefs are hardcoded.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The JSX attribute called `name` on an opening element, or null. */
|
|
10
|
+
export const attribute = (opening, name) =>
|
|
11
|
+
opening.attributes.find(
|
|
12
|
+
(entry) => entry.type === "JSXAttribute" && entry.name.type === "JSXIdentifier" && entry.name.name === name,
|
|
13
|
+
) ?? null
|
|
14
|
+
|
|
15
|
+
/** The expression an attribute carries: a literal, or the inside of `{...}`; null for a bare attribute. */
|
|
16
|
+
export const attributeValue = (entry) => {
|
|
17
|
+
if (!entry || !entry.value) return null
|
|
18
|
+
return entry.value.type === "JSXExpressionContainer" ? entry.value.expression : entry.value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The text of a string literal or of a template with no substitution; null for anything else. */
|
|
22
|
+
export const stringOf = (node) => {
|
|
23
|
+
if (!node) return null
|
|
24
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
25
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value.cooked ?? ""
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The leading text of a string literal or template (up to the first substitution); null for anything else. */
|
|
30
|
+
export const leadingTextOf = (node) => {
|
|
31
|
+
if (!node) return null
|
|
32
|
+
if (node.type === "TemplateLiteral") return node.quasis[0]?.value.cooked ?? ""
|
|
33
|
+
return stringOf(node)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `a.b.c` for a chain of plain member accesses ending in an identifier; null for anything computed. */
|
|
37
|
+
export const calleeName = (node) => {
|
|
38
|
+
if (!node) return null
|
|
39
|
+
if (node.type === "Identifier") return node.name
|
|
40
|
+
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") {
|
|
41
|
+
const head = calleeName(node.object)
|
|
42
|
+
return head === null ? null : `${head}.${node.property.name}`
|
|
43
|
+
}
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The name of a JSX element for a plain tag or a component; null for a member or namespaced name. */
|
|
48
|
+
export const tagName = (opening) => (opening.name.type === "JSXIdentifier" ? opening.name.name : null)
|
|
49
|
+
|
|
50
|
+
/** True for a function expression or an arrow function. */
|
|
51
|
+
export const isFunction = (node) => node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression"
|
|
52
|
+
|
|
53
|
+
/** True when the call is `useEffect(...)` or `useLayoutEffect(...)`, bare or through `React.`. */
|
|
54
|
+
export const isEffectCall = (node) => {
|
|
55
|
+
const name = calleeName(node.callee)
|
|
56
|
+
return name === "useEffect" || name === "useLayoutEffect" || name === "React.useEffect" || name === "React.useLayoutEffect"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The callback of an effect call when it is written inline, else null. */
|
|
60
|
+
export const effectCallback = (node) => {
|
|
61
|
+
const first = node.arguments[0]
|
|
62
|
+
return first && isFunction(first) ? first : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Every node under `root`, depth first, through the visitor keys of the parser in use.
|
|
67
|
+
* `enter` may return `false` to skip a node's children.
|
|
68
|
+
*/
|
|
69
|
+
export const walk = (sourceCode, root, enter) => {
|
|
70
|
+
const keys = sourceCode.visitorKeys
|
|
71
|
+
const visit = (node) => {
|
|
72
|
+
if (enter(node) === false) return
|
|
73
|
+
for (const key of keys[node.type] ?? []) {
|
|
74
|
+
const child = node[key]
|
|
75
|
+
if (Array.isArray(child)) {
|
|
76
|
+
for (const item of child) if (item && typeof item.type === "string") visit(item)
|
|
77
|
+
} else if (child && typeof child.type === "string") {
|
|
78
|
+
visit(child)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
visit(root)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** True when an identifier called `name` appears anywhere under `root`. */
|
|
86
|
+
export const mentions = (sourceCode, root, name) => {
|
|
87
|
+
let found = false
|
|
88
|
+
walk(sourceCode, root, (node) => {
|
|
89
|
+
if (found) return false
|
|
90
|
+
if (node.type === "Identifier" && node.name === name) found = true
|
|
91
|
+
return undefined
|
|
92
|
+
})
|
|
93
|
+
return found
|
|
94
|
+
}
|
package/lib/path.mjs
ADDED
package/lib/scope.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path predicates shared by the laws that scope themselves by where a file sits.
|
|
3
|
+
*
|
|
4
|
+
* ONE PLACE, because a scope written twice drifts: a rule that thinks `modules/config` is one
|
|
5
|
+
* spelling and a sibling that thinks it is another disagree about which file may read the
|
|
6
|
+
* environment, and the disagreement shows up as a finding nobody can satisfy.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { normalizePath } from "./path.mjs"
|
|
10
|
+
|
|
11
|
+
/** A spec or test file - it asserts about production shapes rather than being one. */
|
|
12
|
+
export const isSpecFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx|mts|cts)$/.test(normalizePath(filename))
|
|
13
|
+
|
|
14
|
+
/** Product source: under `src/`, and not a spec. */
|
|
15
|
+
export const isProductFile = (filename) => {
|
|
16
|
+
const file = normalizePath(filename)
|
|
17
|
+
return file.includes("/src/") && !isSpecFile(file)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The e2e tree: `e2e/**` and the Playwright config beside it. */
|
|
21
|
+
export const isE2eFile = (filename) => {
|
|
22
|
+
const file = normalizePath(filename)
|
|
23
|
+
return /(?:^|\/)e2e\//.test(file) || /(?:^|\/)playwright\.config\.[cm]?[jt]s$/.test(file)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The one place that may read the environment. */
|
|
27
|
+
export const isConfigModule = (filename) => /\/modules\/config\//.test(normalizePath(filename))
|
|
28
|
+
|
|
29
|
+
/** The one place that may call `fetch`. */
|
|
30
|
+
export const isApiClient = (filename) => /\/modules\/api\/client\.[cm]?tsx?$/.test(normalizePath(filename))
|
|
31
|
+
|
|
32
|
+
/** The route files Next mounts as a segment slot. */
|
|
33
|
+
export const ROUTE_SLOTS = ["page", "layout", "template", "loading", "not-found", "default", "route"]
|
|
34
|
+
|
|
35
|
+
/** The basename of a path without directories. */
|
|
36
|
+
export const baseName = (filename) => normalizePath(filename).split("/").pop() ?? ""
|
|
37
|
+
|
|
38
|
+
/** The file name with its extension removed. */
|
|
39
|
+
export const stem = (filename) => baseName(filename).replace(/\.[cm]?[jt]sx?$/, "")
|
|
40
|
+
|
|
41
|
+
/** The component tiers a workspace package holds directly under `src/` (`slots.yaml` `fe.package.ui`). */
|
|
42
|
+
export const PACKAGE_TIERS = ["composites", "branches", "leaves"]
|