@starci/eslint-canon-fe 5.1.2 → 6.0.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +17 -18
  3. package/brand-values.mjs +1 -3
  4. package/class-names.mjs +9 -9
  5. package/client-boundary.mjs +125 -37
  6. package/comments.mjs +14 -21
  7. package/docs/client-boundary.md +26 -1
  8. package/docs/grammar-boundary.md +33 -0
  9. package/docs/hygiene.md +15 -4
  10. package/docs/lint-escape-hatch.md +3 -1
  11. package/docs/next-conventions.md +26 -0
  12. package/docs/size-growth.md +29 -0
  13. package/docs/spec-quality.md +15 -7
  14. package/docs/status-colors.md +32 -0
  15. package/docs/translation.md +4 -0
  16. package/docs/transport.md +36 -9
  17. package/e2e-shape.mjs +20 -23
  18. package/env-owner.mjs +1 -1
  19. package/file-layout.mjs +105 -103
  20. package/formatting.mjs +2 -4
  21. package/grammar-boundary.mjs +85 -9
  22. package/hooks-folder.mjs +17 -16
  23. package/hygiene.mjs +370 -92
  24. package/icon.mjs +21 -20
  25. package/index.mjs +27 -85
  26. package/landmark.mjs +0 -11
  27. package/lib/bindings.mjs +144 -0
  28. package/lib/config.mjs +79 -0
  29. package/lib/hfs.mjs +48 -0
  30. package/lib/next.mjs +6 -0
  31. package/lib/platform.mjs +66 -0
  32. package/lib/react.mjs +28 -0
  33. package/lib/scope.mjs +119 -19
  34. package/lib/status-tones.generated.mjs +9 -0
  35. package/lib/types.mjs +63 -0
  36. package/lib/why.mjs +41 -11
  37. package/lint-escape-hatch.mjs +17 -15
  38. package/loading.mjs +9 -16
  39. package/naming.mjs +8 -2
  40. package/next-conventions.mjs +109 -25
  41. package/package.json +9 -4
  42. package/props-and-slots.mjs +19 -13
  43. package/runtime/engine/plain-object.mjs +5 -0
  44. package/runtime/engine/runtime-root.mjs +32 -0
  45. package/runtime/engine/yaml.mjs +161 -0
  46. package/runtime/knowledge/hfs/slots.yaml +1072 -0
  47. package/runtime/scripts/lib/git.mjs +53 -0
  48. package/runtime/scripts/lib/glob.mjs +23 -0
  49. package/runtime/scripts/lib/hfs-allows.mjs +42 -0
  50. package/runtime/scripts/lib/hfs-slots.mjs +722 -0
  51. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  52. package/runtime/scripts/lib/next-contract.mjs +14 -0
  53. package/runtime/scripts/lib/path-key.mjs +17 -0
  54. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  55. package/served-locale.mjs +30 -16
  56. package/shape-slot.mjs +22 -20
  57. package/size-and-state-budget.mjs +41 -23
  58. package/size-growth.mjs +58 -0
  59. package/spec-quality.mjs +95 -16
  60. package/status-colors.mjs +139 -0
  61. package/the-split.mjs +13 -13
  62. package/tokens.mjs +26 -34
  63. package/translation.mjs +52 -19
  64. package/transport.mjs +437 -89
  65. package/type-safety.mjs +8 -17
  66. package/typography.mjs +7 -11
  67. package/vendor-boundary.mjs +5 -27
  68. package/lint-adoption.mjs +0 -79
package/icon.mjs CHANGED
@@ -12,13 +12,14 @@
12
12
  * generalises on its own, and the gap between them is exactly where a third step gets invented.
13
13
  */
14
14
 
15
- import { normalizePath } from "./lib/path.mjs"
15
+ import { hfsOf } from "./lib/hfs.mjs"
16
+ import { fileOf, isComponentFile, isProductSource, kindOfFile, roleOfFile } from "./lib/scope.mjs"
16
17
 
17
- /** The one module allowed to name a glyph from a library. */
18
- const ICON_MODULE_RELATIVE = "leaves/Icon/index.tsx"
18
+ /** The one module allowed to name a glyph from a library: the entry of the leaf owner named `Icon`. */
19
+ const ICON_LEAF = { kind: "leaves", owner: "Icon" }
19
20
 
20
- /** Repeated goal / metric cells are text-led in the legacy reference. */
21
- const LABELLED_PROGRESS_ROW_RELATIVE = "composites/LabelledProgressRow/index.tsx"
21
+ /** Repeated goal / metric cells are text-led in the legacy reference: the entry of the composite owner named `LabelledProgressRow`. */
22
+ const LABELLED_PROGRESS_ROW = { kind: "composites", owner: "LabelledProgressRow" }
22
23
 
23
24
  /**
24
25
  * Package roots that ship glyphs.
@@ -58,15 +59,19 @@ const HEROICON_PACKAGES = new Set([
58
59
  */
59
60
  export const REACTION_ASSET_NAMES = new Set(["like", "love", "haha", "wow", "sad", "angry"])
60
61
 
62
+ /**
63
+ * True when the file is the entry (slot role `entry`) of the component owner named `owner` in the layer `kind`.
64
+ * The slot says which layer and which role; the owner's own folder name is the one fact it cannot say, so it is compared.
65
+ */
66
+ const isEntryOf = (context, { kind, owner }) =>
67
+ isComponentFile(context) && kindOfFile(context) === kind && roleOfFile(context) === "entry" &&
68
+ hfsOf(context).relative(fileOf(context)).split("/").slice(-2)[0] === owner
69
+
61
70
  /** True when this file is the icon leaf, which owns the meaning-to-glyph map. */
62
- const isIconLeafFile = (filename) => normalizePath(filename).endsWith(`/${ICON_MODULE_RELATIVE}`)
71
+ const isIconLeafFile = (context) => isEntryOf(context, ICON_LEAF)
63
72
 
64
73
  /** True for the reusable repeated metric cell whose reference contains no decorative glyph. */
65
- const isLabelledProgressRowFile = (filename) =>
66
- normalizePath(filename).endsWith(`/${LABELLED_PROGRESS_ROW_RELATIVE}`)
67
-
68
- /** Product source under `src/`; tooling and config are out of scope. */
69
- const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
74
+ const isLabelledProgressRowFile = (context) => isEntryOf(context, LABELLED_PROGRESS_ROW)
70
75
 
71
76
  /** True when an import source resolves into a glyph library, subpaths included. */
72
77
  const isGlyphImport = (source) => {
@@ -108,13 +113,12 @@ export const noVendorIconOutsideIconLeaf = {
108
113
  },
109
114
  },
110
115
  create(context) {
111
- const file = normalizePath(context.filename || context.getFilename())
112
- if (!isSourceFile(file) || isIconLeafFile(file)) return {}
116
+ if (!isProductSource(context) || isIconLeafFile(context)) return {}
113
117
  return {
114
118
  ImportDeclaration(node) {
115
119
  const source = node.source && node.source.value
116
120
  if (!isGlyphImport(source)) return
117
- context.report({ node, messageId: "vendor", data: { source, leaf: ICON_MODULE_RELATIVE } })
121
+ context.report({ node, messageId: "vendor", data: { source, leaf: `${ICON_LEAF.kind}/${ICON_LEAF.owner}/index.tsx` } })
118
122
  },
119
123
  }
120
124
  },
@@ -134,8 +138,7 @@ export const heroiconsIsTheGlyphVendor = {
134
138
  },
135
139
  },
136
140
  create(context) {
137
- const file = normalizePath(context.filename || context.getFilename())
138
- if (!isSourceFile(file)) return {}
141
+ if (!isProductSource(context)) return {}
139
142
  return {
140
143
  ImportDeclaration(node) {
141
144
  const source = node.source && node.source.value
@@ -160,8 +163,7 @@ export const noOffScaleGlyphSize = {
160
163
  },
161
164
  },
162
165
  create(context) {
163
- const file = normalizePath(context.filename || context.getFilename())
164
- if (!isSourceFile(file)) return {}
166
+ if (!isProductSource(context)) return {}
165
167
  const scan = (node, text) => {
166
168
  if (!text) return
167
169
  const hit = text.match(OFF_SCALE_GLYPH)
@@ -193,8 +195,7 @@ export const noDecorativeIconInMetricCell = {
193
195
  },
194
196
  },
195
197
  create(context) {
196
- const file = normalizePath(context.filename || context.getFilename())
197
- if (!isLabelledProgressRowFile(file)) return {}
198
+ if (!isLabelledProgressRowFile(context)) return {}
198
199
  return {
199
200
  JSXOpeningElement(node) {
200
201
  if (node.name?.type === "JSXIdentifier" && node.name.name === "Icon") {
package/index.mjs CHANGED
@@ -22,11 +22,7 @@ import { recommended as classNamesRecommended, rules as classNamesRules } from "
22
22
  import { recommended as e2eShapeRecommended, rules as e2eShapeRules, scope as e2eShapeScope } from "./e2e-shape.mjs"
23
23
  import { recommended as envOwnerRecommended, rules as envOwnerRules } from "./env-owner.mjs"
24
24
  import { recommended as fileLayoutRecommended, rules as fileLayoutRules } from "./file-layout.mjs"
25
- import {
26
- audits as grammarBoundaryAudits,
27
- recommended as grammarBoundaryRecommended,
28
- rules as grammarBoundaryRules,
29
- } from "./grammar-boundary.mjs"
25
+ import { recommended as grammarBoundaryRecommended, rules as grammarBoundaryRules } from "./grammar-boundary.mjs"
30
26
  import { recommended as formattingRecommended, rules as formattingRules } from "./formatting.mjs"
31
27
  import { recommended as hooksFolderRecommended, rules as hooksFolderRules } from "./hooks-folder.mjs"
32
28
  import { recommended as hygieneRecommended, rules as hygieneRules } from "./hygiene.mjs"
@@ -34,18 +30,7 @@ import { recommended as iconRecommended, rules as iconRules } from "./icon.mjs"
34
30
  import { recommended as landmarkRecommended, rules as landmarkRules } from "./landmark.mjs"
35
31
  import { recommended as listsRecommended, rules as listsRules } from "./lists.mjs"
36
32
  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"
33
+ import { recommended as lintEscapeRecommended, rules as lintEscapeRules } from "./lint-escape-hatch.mjs"
49
34
  import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
50
35
  import { recommended as nativeControlsRecommended, rules as nativeControlsRules } from "./native-controls.mjs"
51
36
  import { recommended as nextConventionsRecommended, rules as nextConventionsRules } from "./next-conventions.mjs"
@@ -53,7 +38,9 @@ import { recommended as propsRecommended, rules as propsRules } from "./props-an
53
38
  import { recommended as servedLocaleRecommended, rules as servedLocaleRules } from "./served-locale.mjs"
54
39
  import { recommended as shapeSlotRecommended, rules as shapeSlotRules } from "./shape-slot.mjs"
55
40
  import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-and-state-budget.mjs"
41
+ import { recommended as sizeGrowthRecommended, rules as sizeGrowthRules } from "./size-growth.mjs"
56
42
  import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
43
+ import { recommended as statusColorsRecommended, rules as statusColorsRules } from "./status-colors.mjs"
57
44
  import { recommended as splitRecommended, rules as splitRules } from "./the-split.mjs"
58
45
  import { recommended as tokensRecommended, rules as tokensRules } from "./tokens.mjs"
59
46
  import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
@@ -61,6 +48,7 @@ import { recommended as translationRecommended, rules as translationRules } from
61
48
  import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
62
49
  import { recommended as typographyRecommended, rules as typographyRules } from "./typography.mjs"
63
50
  import { recommended as vendorRecommended, rules as vendorRules } from "./vendor-boundary.mjs"
51
+ import { buildFeConfig } from "./lib/config.mjs"
64
52
  import { why } from "./lib/why.mjs"
65
53
 
66
54
  /** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
@@ -76,12 +64,7 @@ const CONTRIBUTIONS = [
76
64
  { law: "e2e-shape", rules: e2eShapeRules, recommended: e2eShapeRecommended, scope: e2eShapeScope },
77
65
  { law: "env-owner", rules: envOwnerRules, recommended: envOwnerRecommended },
78
66
  { law: "file-layout", rules: fileLayoutRules, recommended: fileLayoutRecommended },
79
- {
80
- law: "grammar-boundary",
81
- rules: grammarBoundaryRules,
82
- recommended: grammarBoundaryRecommended,
83
- audits: grammarBoundaryAudits,
84
- },
67
+ { law: "grammar-boundary", rules: grammarBoundaryRules, recommended: grammarBoundaryRecommended },
85
68
  { law: "formatting", rules: formattingRules, recommended: formattingRecommended },
86
69
  { law: "hooks-folder", rules: hooksFolderRules, recommended: hooksFolderRecommended },
87
70
  { law: "hygiene", rules: hygieneRules, recommended: hygieneRecommended },
@@ -89,12 +72,6 @@ const CONTRIBUTIONS = [
89
72
  { law: "landmark", rules: landmarkRules, recommended: landmarkRecommended },
90
73
  { law: "lists", rules: listsRules, recommended: listsRecommended },
91
74
  { law: "loading", rules: loadingRules, recommended: loadingRecommended },
92
- {
93
- law: "lint-adoption",
94
- rules: lintAdoptionRules,
95
- recommended: lintAdoptionRecommended,
96
- audits: lintAdoptionAudits,
97
- },
98
75
  { law: "lint-escape-hatch", rules: lintEscapeRules, recommended: lintEscapeRecommended },
99
76
  { law: "naming", rules: namingRules, recommended: namingRecommended },
100
77
  { law: "native-controls", rules: nativeControlsRules, recommended: nativeControlsRecommended },
@@ -103,7 +80,9 @@ const CONTRIBUTIONS = [
103
80
  { law: "served-locale", rules: servedLocaleRules, recommended: servedLocaleRecommended },
104
81
  { law: "shape-slot", rules: shapeSlotRules, recommended: shapeSlotRecommended },
105
82
  { law: "size-and-state-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
83
+ { law: "size-growth", rules: sizeGrowthRules, recommended: sizeGrowthRecommended },
106
84
  { law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
85
+ { law: "status-colors", rules: statusColorsRules, recommended: statusColorsRecommended },
107
86
  { law: "the-split", rules: splitRules, recommended: splitRecommended },
108
87
  { law: "tokens", rules: tokensRules, recommended: tokensRecommended },
109
88
  { law: "translation", rules: translationRules, recommended: translationRecommended },
@@ -113,21 +92,9 @@ const CONTRIBUTIONS = [
113
92
  { law: "vendor-boundary", rules: vendorRules, recommended: vendorRecommended },
114
93
  ]
115
94
 
116
- /** Every gathered law, including repository audits that publish no AST rule. */
95
+ /** Every gathered law. */
117
96
  export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
118
97
 
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
98
  /**
132
99
  * Which law declares each rule.
133
100
  *
@@ -190,7 +157,8 @@ export const e2eRecommended = Object.fromEntries(
190
157
  )
191
158
 
192
159
  /** Flat-config options that make inline directives ineffective rather than merely forbidden. */
193
- export const linterOptions = strictLinterOptions
160
+ export { linterOptions } from "./lib/config.mjs"
161
+ export { loadHfs } from "./lib/hfs.mjs"
194
162
 
195
163
  /**
196
164
  * The Vietnamese why of each rule that carries a catalogue code: `{ code, vi, fixVi }`.
@@ -207,8 +175,6 @@ const plugin = {
207
175
 
208
176
  export default plugin
209
177
 
210
- export { LAYOUTS }
211
-
212
178
  /**
213
179
  * The React Hooks plugin, from wherever the consuming repository installed it.
214
180
  *
@@ -259,53 +225,29 @@ export const reactHooksRules = (reactHooks) => {
259
225
  }
260
226
 
261
227
  /**
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:
228
+ * The whole flat config of a front end: `export default starciFeConfig({ hfs: loadHfs(import.meta.url) })`.
265
229
  *
266
- * import { starciFeConfig } from "@starci/eslint-canon-fe"
267
- * export default [...starciFeConfig({ layout: "single-app" })]
230
+ * It owns nothing the repository could choose: which files are linted comes from the HFS profile (every app's and every
231
+ * workspace package's `src/`, and the e2e tree), not which rules are on, not their level, not whether an inline comment may
232
+ * switch one off. NO RULE IS OFF - the factory throws rather than emit a block in which a published rule is missing or
233
+ * below error, so a rule added to this package reaches every repository or the build of this package fails.
268
234
  *
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.
235
+ * @param {{ hfs: object }} input - The HFS view of the repository (`loadHfs(import.meta.url)`).
236
+ * @returns {object[]} The flat config: ignores, the typed source block, the e2e block.
282
237
  */
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
- }
238
+ export const starciFeConfig = ({ hfs } = {}) => {
288
239
  const published = Object.keys(rules).map((name) => `starci-fe/${name}`)
289
240
  const notRequested = published.filter((name) => recommended[name] !== "error")
290
241
  if (notRequested.length > 0) {
291
242
  throw new Error(`starciFeConfig: these published rules are not requested at error: ${notRequested.join(", ")}`)
292
243
  }
293
244
  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
- ]
245
+ return buildFeConfig({
246
+ hfs,
247
+ plugin,
248
+ reactHooks,
249
+ source: { ...sourceRecommended, ...reactHooksRules(reactHooks) },
250
+ // The escape-hatch fence is a source rule that must also cover the e2e tree.
251
+ e2e: { ...e2eRecommended, "starci-fe/no-inline-lint-config": "error" },
252
+ })
311
253
  }
package/landmark.mjs CHANGED
@@ -5,17 +5,6 @@
5
5
  * application may pass children through layouts and pages normally.
6
6
  */
7
7
 
8
- const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
9
-
10
- /** Whether a file is a Next route layout. */
11
- export const isRouteLayoutFile = (filename) => /\/app\/(?:.*\/)?layout\.tsx$/.test(normalize(filename))
12
-
13
- /** Whether a file is a Next route entry. */
14
- export const isRouteFile = (filename) => /\/app\/(?:.*\/)?(?:layout|page)\.tsx$/.test(normalize(filename))
15
-
16
- /** Whether a file owns a page component. */
17
- export const isPageTierFile = (filename) => /\/src\/components\/pages\//.test(normalize(filename))
18
-
19
8
  /** Named branch owners that may provide landmark semantics. */
20
9
  export const LANDMARK_BRANCHES = new Set(["Main"])
21
10
 
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Bindings of the front-end canon: what an identifier resolves to through the scope manager, which module an import
3
+ * comes from, which function a name stands for, and a stable KEY for an expression that names a place (a variable, a
4
+ * member path, `this.x`). A rule that must decide "is this the same handle / target / listener" compares keys, never
5
+ * spellings: two `id` variables in different scopes have different keys, `timerRef.current` has one key wherever it is
6
+ * written.
7
+ */
8
+
9
+ /** The globals that all name the browser's global object. */
10
+ const GLOBAL_OBJECT = new Set(["window", "globalThis", "self"])
11
+
12
+ /** Wrappers that change a node's type, not its identity. */
13
+ const TRANSPARENT = new Set(["TSAsExpression", "TSNonNullExpression", "TSSatisfiesExpression", "TSTypeAssertion", "ChainExpression", "ParenthesizedExpression"])
14
+
15
+ /**
16
+ * The node without the wrappers that only change its type (`x as T`, `x!`, `x?.y`).
17
+ *
18
+ * @param {object} node - An ESTree node.
19
+ * @returns {object} The inner node.
20
+ */
21
+ export const unwrap = (node) => {
22
+ let current = node
23
+ while (current && TRANSPARENT.has(current.type)) current = current.expression
24
+ return current
25
+ }
26
+
27
+ /**
28
+ * The node's parent with the type-only wrappers skipped upward.
29
+ *
30
+ * @param {object} node - An ESTree node.
31
+ * @returns {object|null} The first parent that is not a type-only wrapper.
32
+ */
33
+ export const parentOf = (node) => {
34
+ let current = node.parent
35
+ while (current && TRANSPARENT.has(current.type)) current = current.parent
36
+ return current ?? null
37
+ }
38
+
39
+ /** True for a function expression, an arrow function or a function declaration. */
40
+ export const isFn = (node) =>
41
+ Boolean(node) && (node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression" || node.type === "FunctionDeclaration")
42
+
43
+ /**
44
+ * The scope-manager variable an identifier resolves to, or null for a global.
45
+ *
46
+ * @param {object} context - The ESLint rule context.
47
+ * @param {object} identifier - An ESTree identifier.
48
+ * @returns {object|null} The variable.
49
+ */
50
+ export const variableOf = (context, identifier) => {
51
+ for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
52
+ const variable = scope.set.get(identifier.name)
53
+ if (variable) return variable
54
+ }
55
+ return null
56
+ }
57
+
58
+ /**
59
+ * Where an identifier was imported from.
60
+ *
61
+ * @param {object} context - The ESLint rule context.
62
+ * @param {object} identifier - An ESTree identifier.
63
+ * @returns {{ source: string, imported: string|null }|null} The module specifier and the imported name (`null` for a default or namespace import), or null when the identifier is not an import.
64
+ */
65
+ export const importOf = (context, identifier) => {
66
+ const variable = variableOf(context, identifier)
67
+ const definition = variable?.defs.find((entry) => entry.type === "ImportBinding")
68
+ if (!definition) return null
69
+ const specifier = definition.node
70
+ const source = String(definition.parent.source.value)
71
+ if (specifier.type === "ImportSpecifier") return { source, imported: specifier.imported.name ?? String(specifier.imported.value) }
72
+ return { source, imported: null }
73
+ }
74
+
75
+ /**
76
+ * The function an expression stands for: the function itself, or the function a same-file variable or declaration holds.
77
+ *
78
+ * @param {object} context - The ESLint rule context.
79
+ * @param {object} node - An ESTree expression.
80
+ * @returns {object|null} The function node, or null when it cannot be resolved in this file.
81
+ */
82
+ export const functionOf = (context, node) => {
83
+ const inner = unwrap(node)
84
+ if (!inner) return null
85
+ if (isFn(inner)) return inner
86
+ if (inner.type !== "Identifier") return null
87
+ const variable = variableOf(context, inner)
88
+ for (const definition of variable?.defs ?? []) {
89
+ if (definition.type === "FunctionName" && definition.node.type === "FunctionDeclaration") return definition.node
90
+ if (definition.type === "Variable" && definition.node.id.type === "Identifier") {
91
+ const init = unwrap(definition.node.init)
92
+ if (isFn(init)) return init
93
+ }
94
+ }
95
+ return null
96
+ }
97
+
98
+ /** A stable numeric id per scope-manager variable, so keys compare by binding and not by spelling. */
99
+ const VARIABLE_IDS = new WeakMap()
100
+ let nextVariableId = 1
101
+ const idOf = (variable) => {
102
+ if (!VARIABLE_IDS.has(variable)) VARIABLE_IDS.set(variable, nextVariableId++)
103
+ return VARIABLE_IDS.get(variable)
104
+ }
105
+
106
+ /**
107
+ * The key of an expression that names a place: `v12` for a variable, `v12.current` for a member path, `this.timer` for
108
+ * a member of `this`, `g:window` for the browser's global object under any of its names. Anything else has no key.
109
+ *
110
+ * @param {object} context - The ESLint rule context.
111
+ * @param {object} node - An ESTree expression.
112
+ * @returns {string|null} The key.
113
+ */
114
+ export const keyOf = (context, node) => {
115
+ const inner = unwrap(node)
116
+ if (!inner) return null
117
+ if (inner.type === "Identifier") {
118
+ const variable = variableOf(context, inner)
119
+ if (variable && variable.defs.length > 0) return `v${idOf(variable)}`
120
+ return GLOBAL_OBJECT.has(inner.name) ? "g:window" : `g:${inner.name}`
121
+ }
122
+ if (inner.type === "ThisExpression") return "this"
123
+ if (inner.type === "MemberExpression") {
124
+ const head = keyOf(context, inner.object)
125
+ if (head === null) return null
126
+ if (!inner.computed && inner.property.type === "Identifier") return `${head}.${inner.property.name}`
127
+ if (inner.computed && inner.property.type === "Literal") return `${head}[${String(inner.property.value)}]`
128
+ return null
129
+ }
130
+ return null
131
+ }
132
+
133
+ /**
134
+ * The receiver and the member name of a call written as `receiver.name(...)`, or the bare name of `name(...)`.
135
+ *
136
+ * @param {object} call - An ESTree call expression.
137
+ * @returns {{ receiver: object|null, name: string }|null} The parts, or null for a computed or exotic callee.
138
+ */
139
+ export const calleeParts = (call) => {
140
+ const callee = unwrap(call.callee)
141
+ if (callee.type === "Identifier") return { receiver: null, name: callee.name }
142
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") return { receiver: callee.object, name: callee.property.name }
143
+ return null
144
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The one flat config of a StarCi front end.
3
+ *
4
+ * A repository's `eslint.config.mjs` is a managed file (R05/R16/R17), rendered by `hfs sync` as exactly:
5
+ *
6
+ * import { loadHfs, starciFeConfig } from "@starci/eslint-canon-fe"
7
+ * export default starciFeConfig({ hfs: loadHfs(import.meta.url) })
8
+ *
9
+ * The factory owns everything else: which files are linted (the source of every app and every workspace package, and the
10
+ * e2e tree), typed linting on the source (`parserOptions.projectService`, so a rule asks what a node IS rather than what
11
+ * it is called), the ignores, the linter options that make a disable comment impossible, every canon rule at `error`,
12
+ * the React Hooks rules at `error`, and `settings.starci.hfs` - the slot view each path-scoped rule asks instead of a path
13
+ * pattern. It takes no rule overrides, globs or ignores: there is no parameter through which a repository could weaken
14
+ * the canon.
15
+ */
16
+ import tsParser from "@typescript-eslint/parser"
17
+
18
+ /** A disable directive is a weakening of lint, and one that suppresses nothing is dead weight. */
19
+ export const linterOptions = Object.freeze({ noInlineConfig: true, reportUnusedDisableDirectives: "error" })
20
+
21
+ /** The product source of a front end: every app's `src/` and every workspace package's `src/` (slots fe.* and repo.packages). */
22
+ export const SOURCE_FILES = Object.freeze(["apps/*/src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"])
23
+
24
+ /** The e2e tree (slots fe.e2e, fe.e2e-support) and the Playwright config beside it. */
25
+ export const E2E_FILES = Object.freeze(["e2e/**/*.{ts,tsx}", "playwright.config.ts"])
26
+
27
+ /** Build output, installed packages, generated wire types and the harness's report directory are never linted. */
28
+ export const IGNORED = Object.freeze(["**/node_modules/**", "**/.next/**", "**/dist/**", "**/coverage/**", "**/__generated__/**", ".starci/**"])
29
+
30
+ const levelOf = (setting) => (Array.isArray(setting) ? setting[0] : setting)
31
+
32
+ /**
33
+ * The flat config of one front-end repository.
34
+ *
35
+ * @param {object} input - Attachment options.
36
+ * @param {object} input.hfs - The HFS view of the repository (`loadHfs(import.meta.url)`).
37
+ * @param {object} input.plugin - The canon plugin.
38
+ * @param {Record<string, unknown>} input.source - The source-tree levels (canon rules plus the React Hooks rules).
39
+ * @param {Record<string, unknown>} input.e2e - The e2e-tree levels.
40
+ * @param {object} input.reactHooks - The React Hooks plugin.
41
+ * @returns {Array<object>} The flat config: one ignore block, one typed source block and one e2e block.
42
+ */
43
+ export const buildFeConfig = ({ hfs, plugin, source, e2e, reactHooks }) => {
44
+ if (!hfs || typeof hfs.slotOf !== "function" || typeof hfs.repoRoot !== "string") {
45
+ throw new Error("starciFeConfig needs { hfs: loadHfs(import.meta.url) } - the rules read the repository's slots through it")
46
+ }
47
+ if (hfs.profile !== "fe") throw new Error(`starciFeConfig lints a front end; hfs.json declares profile ${hfs.profile}`)
48
+ for (const [tree, levels] of [["source", source], ["e2e", e2e]]) {
49
+ if (Object.keys(levels).length === 0) throw new Error(`starciFeConfig received an empty ${tree} recommendation - a config with no rules is not adoption`)
50
+ const weak = Object.entries(levels).filter(([, setting]) => levelOf(setting) !== "error").map(([name]) => name)
51
+ if (weak.length > 0) throw new Error(`starciFeConfig refuses ${tree} rules not at error: ${weak.join(", ")}`)
52
+ }
53
+ const settings = { starci: { hfs } }
54
+ return [
55
+ { ignores: [...IGNORED] },
56
+ {
57
+ files: [...SOURCE_FILES],
58
+ languageOptions: {
59
+ parser: tsParser,
60
+ ecmaVersion: "latest",
61
+ sourceType: "module",
62
+ parserOptions: { projectService: true, tsconfigRootDir: hfs.repoRoot, ecmaFeatures: { jsx: true } },
63
+ },
64
+ linterOptions: { ...linterOptions },
65
+ plugins: { "starci-fe": plugin, "react-hooks": reactHooks },
66
+ settings,
67
+ rules: { ...source },
68
+ },
69
+ {
70
+ // The e2e tree is typed by tsconfig.e2e.json, which the project service does not open; its rules read syntax only.
71
+ files: [...E2E_FILES],
72
+ languageOptions: { parser: tsParser, ecmaVersion: "latest", sourceType: "module" },
73
+ linterOptions: { ...linterOptions },
74
+ plugins: { "starci-fe": plugin },
75
+ settings,
76
+ rules: { ...e2e },
77
+ },
78
+ ]
79
+ }
package/lib/hfs.mjs ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one door between the front-end lint rules and the HFS slot manifest of the repository being linted.
3
+ *
4
+ * A consuming `eslint.config.mjs` is the managed one-liner `export default starciFeConfig({ hfs: loadHfs(import.meta.url) })`.
5
+ * `loadHfs` reads the repository's `hfs.json` beside that config file and the slot manifest this package ships in
6
+ * `runtime/` (a byte copy of the runtime's `knowledge/hfs/slots.yaml`, `scripts/lib/hfs-slots.mjs` and
7
+ * `scripts/lib/hfs-view.mjs`, refreshed by `packages/hfs/scripts/sync-runtime.mjs`), and returns the view the factory puts
8
+ * in `settings.starci.hfs`.
9
+ *
10
+ * Every path-scoped rule asks `slotOf` / `tierOf` here instead of testing a path with a regular expression. A rule that
11
+ * needs the view and finds none stops the lint run with an error naming the fix: a rule must not guess.
12
+ */
13
+ import { dirname, join } from "node:path"
14
+ import { fileURLToPath } from "node:url"
15
+ import { declaredHfsView, openHfsView } from "../runtime/scripts/lib/hfs-view.mjs"
16
+
17
+ const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
18
+
19
+ /**
20
+ * The HFS view of the repository whose `eslint.config.mjs` calls this.
21
+ *
22
+ * @param {string} configUrl - `import.meta.url` of the repository's `eslint.config.mjs`.
23
+ * @returns {object} The frozen HFS view.
24
+ */
25
+ export const loadHfs = (configUrl) => openHfsView({ runtimeRoot: RUNTIME, repoRoot: dirname(fileURLToPath(configUrl)) })
26
+
27
+ /**
28
+ * The HFS view of an in-memory declaration, for rule tests.
29
+ *
30
+ * @param {object} declaration - An `hfs.json` object.
31
+ * @param {string} repoRoot - The absolute root the linted filenames live under.
32
+ * @returns {object} The frozen HFS view.
33
+ */
34
+ export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot })
35
+
36
+ /**
37
+ * The HFS view a rule runs under; a rule that needs it and gets none refuses to guess.
38
+ *
39
+ * @param {object} context - The ESLint rule context.
40
+ * @returns {object} The HFS view from `settings.starci.hfs`.
41
+ */
42
+ export const hfsOf = (context) => {
43
+ const hfs = context.settings?.starci?.hfs
44
+ if (!hfs || typeof hfs.slotOf !== "function") {
45
+ throw new Error(`${context.id ?? "a starci-fe rule"} needs settings.starci.hfs: lint through starciFeConfig({ hfs: loadHfs(import.meta.url) })`)
46
+ }
47
+ return hfs
48
+ }
package/lib/next.mjs ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Next.js's own contract for route segment files, read from the runtime's one statement of it
3
+ * (`scripts/lib/next-contract.mjs`, shipped in `runtime/` by `packages/hfs/scripts/sync-runtime.mjs`): the architecture
4
+ * machine and these laws hold the same list by construction.
5
+ */
6
+ export { NEXT_RESERVED_EXPORTS, NEXT_ROUTE_SEGMENT_STEMS } from "../runtime/scripts/lib/next-contract.mjs"
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Platform APIs of the front-end canon, decided by resolution: an identifier or member is "the platform's" when the
3
+ * TypeScript symbol it binds to is declared in a default library file or in an ambient global declaration, so a local
4
+ * function that happens to be called `setTimeout` or `fetch` is not mistaken for the browser's.
5
+ */
6
+ import ts from "typescript"
7
+ import { typed } from "./types.mjs"
8
+
9
+ /** True when a declaration sits in the default lib, in a global script declaration file, or in a `declare global` block. */
10
+ const isGlobalDeclaration = (program, declaration) => {
11
+ const file = declaration.getSourceFile()
12
+ if (program.isSourceFileDefaultLibrary(file)) return true
13
+ for (let current = declaration.parent; current; current = current.parent) {
14
+ if (ts.isModuleDeclaration(current) && current.flags & ts.NodeFlags.GlobalAugmentation) return true
15
+ }
16
+ return file.isDeclarationFile && !ts.isExternalModule(file)
17
+ }
18
+
19
+ /**
20
+ * True when the symbol at a node (an identifier, or the property of a member access) is declared by the platform.
21
+ *
22
+ * @param {object} context - The ESLint rule context.
23
+ * @param {object} node - An ESTree identifier, or a member expression (its property is asked).
24
+ * @returns {boolean} Whether every declaration of the symbol is a platform (global, ambient) declaration.
25
+ */
26
+ export const isPlatform = (context, node) => {
27
+ const { program, checker, toTs } = typed(context)
28
+ const target = node.type === "MemberExpression" ? node.property : node
29
+ const tsNode = toTs(target)
30
+ if (!tsNode) return false
31
+ const symbol = checker.getSymbolAtLocation(tsNode)
32
+ const declarations = symbol?.getDeclarations?.() ?? []
33
+ return declarations.length > 0 && declarations.every((declaration) => isGlobalDeclaration(program, declaration))
34
+ }
35
+
36
+ /**
37
+ * True when the type of an expression carries no information (`any`, `unknown` or an error type).
38
+ *
39
+ * @param {object} context - The ESLint rule context.
40
+ * @param {object} node - An ESTree expression.
41
+ * @returns {boolean} Whether the checker could not decide what the expression is.
42
+ */
43
+ export const isUnresolvedType = (context, node) => {
44
+ const { checker, toTs } = typed(context)
45
+ const tsNode = toTs(node)
46
+ if (!tsNode) return true
47
+ const type = checker.getTypeAtLocation(tsNode)
48
+ return Boolean(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
49
+ }
50
+
51
+ /**
52
+ * The property names of the type of an expression, after union members are read.
53
+ *
54
+ * @param {object} context - The ESLint rule context.
55
+ * @param {object} node - An ESTree expression.
56
+ * @returns {Set<string>} The names of the properties every member of the type could carry.
57
+ */
58
+ export const propertyNamesOf = (context, node) => {
59
+ const { checker, toTs } = typed(context)
60
+ const tsNode = toTs(node)
61
+ const names = new Set()
62
+ if (!tsNode) return names
63
+ const type = checker.getTypeAtLocation(tsNode)
64
+ for (const part of type.isUnion?.() ? type.types : [type]) for (const property of part.getProperties?.() ?? []) names.add(property.getName())
65
+ return names
66
+ }