@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +41 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +100 -7
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +292 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +7 -3
  43. package/props-and-slots.mjs +4 -2
  44. package/served-locale.mjs +160 -160
  45. package/shape-slot.mjs +339 -0
  46. package/size-and-state-budget.mjs +196 -0
  47. package/spec-quality.mjs +239 -0
  48. package/the-split.mjs +133 -134
  49. package/tokens.mjs +267 -268
  50. package/translation.mjs +262 -122
  51. package/transport.mjs +404 -0
  52. package/type-safety.mjs +156 -74
  53. 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. The target's `eslint.config.mjs` stays the authority on
7
- * what is switched on and at what level - that is a different question from what EXISTS, and this
8
- * file answers only the second.
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 { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
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 iconRecommended, rules as iconRules } from "./icon.mjs"
24
- import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
25
- import { recommended as loadingRecommended, rules as loadingRules } from "./loading.mjs"
26
- import {
27
- audits as lintAdoptionAudits,
28
- recommended as lintAdoptionRecommended,
29
- rules as lintAdoptionRules,
30
- } from "./lint-adoption.mjs"
31
- import {
32
- linterOptions as strictLinterOptions,
33
- recommended as lintEscapeRecommended,
34
- rules as lintEscapeRules,
35
- } from "./lint-escape-hatch.mjs"
36
- import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
37
- import { recommended as propsRecommended, rules as propsRules } from "./props-and-slots.mjs"
38
- import { recommended as servedLocaleRecommended, rules as servedLocaleRules } from "./served-locale.mjs"
39
- import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
40
- import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
41
- import { recommended as translationRecommended, rules as translationRules } from "./translation.mjs"
42
- import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
43
- import { recommended as typographyRecommended, rules as typographyRules } from "./typography.mjs"
44
- import { recommended as vendorRecommended, rules as vendorRules } from "./vendor-boundary.mjs"
45
-
46
- /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
47
- const CONTRIBUTIONS = [
48
- { law: "comments", rules: commentsRules, recommended: commentsRecommended },
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: "icon", rules: iconRules, recommended: iconRecommended },
62
- { law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
63
- { law: "loading", rules: loadingRules, recommended: loadingRecommended },
64
- {
65
- law: "lint-adoption",
66
- rules: lintAdoptionRules,
67
- recommended: lintAdoptionRecommended,
68
- audits: lintAdoptionAudits,
69
- },
70
- { law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
71
- { law: "naming", rules: namingRules, recommended: namingRecommended },
72
- { law: "props-and-slots", rules: propsRules, recommended: propsRecommended },
73
- { law: "served-locale", rules: servedLocaleRules, recommended: servedLocaleRecommended },
74
- { law: "the-split", rules: splitRules, recommended: splitRecommended },
75
- { law: "tokens", rules: tokensRules, recommended: tokensRecommended },
76
- { law: "translation", rules: translationRules, recommended: translationRecommended },
77
- { law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
78
- { law: "typography", rules: typographyRules, recommended: typographyRecommended },
79
- { law: "vendor-boundary", rules: vendorRules, recommended: vendorRecommended },
80
- ]
81
-
82
- /** Every gathered law, including repository audits that publish no AST rule. */
83
- export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
84
-
85
- /** Which law declares each repository-level audit. */
86
- export const auditOwners = Object.fromEntries(
87
- CONTRIBUTIONS.flatMap((entry) =>
88
- Object.keys(entry.audits ?? {}).map((name) => [name, entry.law]),
89
- ),
90
- )
91
-
92
- /** Repository-level audits gathered beside the plugin. */
93
- export const audits = Object.fromEntries(
94
- CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.audits ?? {})),
95
- )
96
-
97
- /**
98
- * Which law declares each rule.
99
- *
100
- * Exported because a failing rule is a question about a LAW, and the shortest path from a build log
101
- * to the document that explains it is this map. It is also what the twin test walks to prove no two
102
- * laws claim one name.
103
- */
104
- export const ruleOwners = Object.fromEntries(
105
- CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
106
- )
107
-
108
- /**
109
- * Every (law, rule name) pair as DECLARED, before any collapsing.
110
- *
111
- * `ruleOwners` above and `rules` below are both built with `Object.fromEntries`, which silently
112
- * keeps the last writer when two laws declare one name. A guard that walks either of them cannot
113
- * see a collision - the duplicate is gone before it looks, so every name appears exactly once BY
114
- * CONSTRUCTION and the check passes forever while being blind.
115
- *
116
- * That is not hypothetical. The back-end twin had this exact shape, three rules were declared by two
117
- * laws each, one copy was discarded on import, and its guard reported green throughout. This axis
118
- * has no collision today; publishing the raw declarations is what keeps that a FACT rather than an
119
- * assumption nobody can test.
120
- */
121
- export const ruleDeclarations = CONTRIBUTIONS.flatMap((entry) =>
122
- Object.keys(entry.rules).map((name) => ({ law: entry.law, name })),
123
- )
124
-
125
- /** Every rule this canon publishes, keyed by its published name. */
126
- export const rules = Object.fromEntries(
127
- CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.rules)),
128
- )
129
-
130
- /**
131
- * The levels this canon asks for, as the plugin's own opinion.
132
- *
133
- * Every FE canon rule is an error. Existing debt is fixed before adoption; lowering architecture
134
- * to warn teaches every later author that the boundary is optional and is not a supported rollout.
135
- */
136
- export const recommended = Object.fromEntries(
137
- CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
138
- )
139
-
140
- /** Flat-config options that make inline directives ineffective rather than merely forbidden. */
141
- export const linterOptions = strictLinterOptions
142
-
143
- /** The plugin object, shaped the way a flat config expects it. */
144
- export default {
145
- meta: { name: "eslint-plugin-starci-fe" },
146
- rules,
147
- }
148
-
149
- /** Attach this canon to a repository: one block, one branch for its layout. */
150
- export { starciFeConfig, LAYOUTS } from "./lint-adoption.mjs"
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
@@ -0,0 +1,2 @@
1
+ /** Forward-slash form of a filename, so Windows paths compare like every other path. */
2
+ export const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
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"]