@starci/eslint-canon-fe 4.0.0 → 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 (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +34 -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 +1 -2
  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 +290 -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 +8 -4
  43. package/served-locale.mjs +160 -160
  44. package/shape-slot.mjs +1 -2
  45. package/size-and-state-budget.mjs +196 -0
  46. package/spec-quality.mjs +239 -0
  47. package/the-split.mjs +133 -134
  48. package/tokens.mjs +267 -268
  49. package/translation.mjs +262 -122
  50. package/transport.mjs +404 -0
  51. package/type-safety.mjs +156 -74
  52. package/typography.mjs +82 -83
package/naming.mjs CHANGED
@@ -1,75 +1,75 @@
1
- /**
2
- * The rules that hold `naming.md`.
3
- *
4
- * Two spellings, both of which work, which is exactly why they are rules: nothing corrects the
5
- * second one, so a file written on a Tuesday reads differently from its neighbour and every diff
6
- * afterwards carries noise that has nothing to do with the change.
7
- *
8
- * Neither rule can see intent, and both are deliberately narrow because of it. NAMING-1 fires on
9
- * shape alone - a module-level `function` - which is exact. NAMING-2 fires on the `handle` prefix
10
- * alone, which catches the rename-at-the-boundary case and nothing else; a name that merely
11
- * describes an action is not its business.
12
- */
13
-
14
- /** Parents that make a function declaration module-level rather than nested. */
15
- const MODULE_LEVEL_PARENTS = new Set(["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"])
16
-
17
- // -- NAMING-1 --------------------------------------------------------------------------------------
18
-
19
- /** A module-level function is an arrow const, so nothing exists before the line that declares it. */
20
- export const preferArrowExport = {
21
- meta: {
22
- type: "suggestion",
23
- docs: { description: "Module-level functions use an arrow const, not a `function` declaration." },
24
- schema: [],
25
- messages: {
26
- fn: "`function {{name}}` is hoisted, so it exists before the line that declares it and the order of this file stops promising anything about what is defined when. Write `const {{name}} = (...) => {...}` - a const cannot be used before it exists, so the file reads in the order it runs.",
27
- },
28
- },
29
- create(context) {
30
- return {
31
- FunctionDeclaration(node) {
32
- const parent = node.parent && node.parent.type
33
- if (!MODULE_LEVEL_PARENTS.has(parent)) return
34
- context.report({
35
- node: node.id || node,
36
- messageId: "fn",
37
- data: { name: (node.id && node.id.name) || "default" },
38
- })
39
- },
40
- }
41
- },
42
- }
43
-
44
- // -- NAMING-2 --------------------------------------------------------------------------------------
45
-
46
- /** Something a reader triggers is named `onX`, so the name survives being passed. */
1
+ /**
2
+ * The rules that hold `naming.md`.
3
+ *
4
+ * Two spellings, both of which work, which is exactly why they are rules: nothing corrects the
5
+ * second one, so a file written on a Tuesday reads differently from its neighbour and every diff
6
+ * afterwards carries noise that has nothing to do with the change.
7
+ *
8
+ * Neither rule can see intent, and both are deliberately narrow because of it. NAMING-1 fires on
9
+ * shape alone - a module-level `function` - which is exact. NAMING-2 fires on the `handle` prefix
10
+ * alone, which catches the rename-at-the-boundary case and nothing else; a name that merely
11
+ * describes an action is not its business.
12
+ */
13
+
14
+ /** Parents that make a function declaration module-level rather than nested. */
15
+ const MODULE_LEVEL_PARENTS = new Set(["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"])
16
+
17
+ // -- NAMING-1 --------------------------------------------------------------------------------------
18
+
19
+ /** A module-level function is an arrow const, so nothing exists before the line that declares it. */
20
+ export const preferArrowExport = {
21
+ meta: {
22
+ type: "suggestion",
23
+ docs: { description: "Module-level functions use an arrow const, not a `function` declaration." },
24
+ schema: [],
25
+ messages: {
26
+ fn: "`function {{name}}` is hoisted, so it exists before the line that declares it and the order of this file stops promising anything about what is defined when. Write `const {{name}} = (...) => {...}` - a const cannot be used before it exists, so the file reads in the order it runs.",
27
+ },
28
+ },
29
+ create(context) {
30
+ return {
31
+ FunctionDeclaration(node) {
32
+ const parent = node.parent && node.parent.type
33
+ if (!MODULE_LEVEL_PARENTS.has(parent)) return
34
+ context.report({
35
+ node: node.id || node,
36
+ messageId: "fn",
37
+ data: { name: (node.id && node.id.name) || "default" },
38
+ })
39
+ },
40
+ }
41
+ },
42
+ }
43
+
44
+ // -- NAMING-2 --------------------------------------------------------------------------------------
45
+
46
+ /** Something a reader triggers is named `onX`, so the name survives being passed. */
47
47
  export const handlerOnPrefix = {
48
- meta: {
49
- type: "suggestion",
50
- docs: { description: "Handlers are named `onX`, not `handleX`." },
51
- schema: [],
52
- messages: {
53
- handle: "`{{name}}` is renamed at every boundary it crosses - the slot is `on`, the DOM attribute is `onClick`, and the props type says `on{{rest}}`. Call it `on{{rest}}` at birth and the name is the same at the declaration, at the call site and in the type.",
54
- },
55
- },
56
- create(context) {
57
- const flag = (node, name) => {
58
- if (!name || !/^handle[A-Z]/.test(name)) return
59
- context.report({ node, messageId: "handle", data: { name, rest: name.slice("handle".length) } })
60
- }
61
- return {
62
- VariableDeclarator(node) {
63
- if (node.id && node.id.type === "Identifier") flag(node.id, node.id.name)
64
- },
65
- JSXAttribute(node) {
66
- if (node.name) flag(node.name, node.name.name)
67
- },
68
- TSPropertySignature(node) {
69
- if (node.key && node.key.type === "Identifier") flag(node.key, node.key.name)
70
- },
71
- }
72
- },
48
+ meta: {
49
+ type: "suggestion",
50
+ docs: { description: "Handlers are named `onX`, not `handleX`." },
51
+ schema: [],
52
+ messages: {
53
+ handle: "`{{name}}` is renamed at every boundary it crosses - the slot is `on`, the DOM attribute is `onClick`, and the props type says `on{{rest}}`. Call it `on{{rest}}` at birth and the name is the same at the declaration, at the call site and in the type.",
54
+ },
55
+ },
56
+ create(context) {
57
+ const flag = (node, name) => {
58
+ if (!name || !/^handle[A-Z]/.test(name)) return
59
+ context.report({ node, messageId: "handle", data: { name, rest: name.slice("handle".length) } })
60
+ }
61
+ return {
62
+ VariableDeclarator(node) {
63
+ if (node.id && node.id.type === "Identifier") flag(node.id, node.id.name)
64
+ },
65
+ JSXAttribute(node) {
66
+ if (node.name) flag(node.name, node.name.name)
67
+ },
68
+ TSPropertySignature(node) {
69
+ if (node.key && node.key.type === "Identifier") flag(node.key, node.key.name)
70
+ },
71
+ }
72
+ },
73
73
  }
74
74
 
75
75
  // -- machine-only identity -------------------------------------------------------------------------
@@ -99,81 +99,81 @@ export const noDirectConstAlias = {
99
99
  }
100
100
  },
101
101
  }
102
-
103
- /** The rules this law contributes to the plugin. */
104
- // -- NAMING-3 --------------------------------------------------------------------------------------
105
-
106
- /** Letters that exist in Vietnamese and not in English, in the forms a path can carry. */
107
- const SECOND_LANGUAGE_PATH = /[àáâãèéêìíòóôõùúýăđĩũơưạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ]/i
108
-
109
- /**
110
- * Second-language words spelled in ASCII, as a route or folder segment writes them.
111
- *
112
- * A path cannot carry diacritics, so `cấp phát` reaches the filesystem as `cap-phat` and every
113
- * accent-based check passes it. The segments below are the ones this product actually produced.
114
- * A list is the honest instrument here: guessing at Vietnamese-shaped ASCII would refuse `dang`
115
- * in `dangerous` and `cap` in `capacity`, and a rule that fires on English words is one a
116
- * repository turns off.
117
- */
118
- const ROMANISED = [
119
- "dang-nhap", "dang-ky", "dang-xuat", "cap-phat", "khoa-hoc", "hoc-vien", "gioi-thieu",
120
- "lien-he", "tai-khoan", "thanh-toan", "gio-hang", "tin-tuc", "san-pham", "bang-gia",
121
- "dieu-khoan", "chinh-sach", "trang-chu", "quan-ly", "cai-dat", "ho-so",
122
- ]
123
-
124
- /** A route segment or folder name, lowercased, from a normalized path. */
125
- const segmentsOf = (filename) =>
126
- String(filename || "").replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean)
127
-
128
- /**
129
- * A path names things in one language: the one every reader of this repository shares.
130
- *
131
- * WHY A PATH AND NOT ONLY THE SOURCE. `no-second-language-in-source` reads identifiers, comments
132
- * and strings, and cannot see the file it is reading. So a route could be `app/cap-phat/page.tsx`
133
- * with every identifier inside it in English, and nothing said a word - while the URL, the import
134
- * specifier, the folder in every editor sidebar and the path in every stack trace stayed in a
135
- * language half the readers do not have.
136
- *
137
- * A ROUTE SEGMENT IS ALSO A PUBLIC NAME. It is the address a customer sees and a support ticket
138
- * quotes, so this is not only an authoring question: the product's own URLs stop being readable to
139
- * anybody outside one language.
140
- */
141
- export const noSecondLanguageInPath = {
142
- meta: {
143
- type: "problem",
144
- docs: { description: "File and route names are written in the repository's one shared language." },
145
- schema: [],
146
- messages: {
147
- path:
148
- "`{{segment}}` names this file in a second language. A path is read by more people than the code inside it - it is the URL a customer quotes, the folder every editor shows, and the specifier every import repeats - and unlike a comment it cannot be skimmed past. Rename the segment; the words a person READS belong in the locale catalogue, never in the address.",
149
- },
150
- },
151
- create(context) {
152
- const filename = context.filename || context.getFilename()
153
- const offending = segmentsOf(filename).find(
154
- (segment) => SECOND_LANGUAGE_PATH.test(segment) || ROMANISED.includes(segment.replace(/[()[\]]/g, "")),
155
- )
156
- if (!offending) return {}
157
- return {
158
- Program(node) {
159
- context.report({ node, messageId: "path", data: { segment: offending } })
160
- },
161
- }
162
- },
163
- }
164
-
102
+
103
+ /** The rules this law contributes to the plugin. */
104
+ // -- NAMING-3 --------------------------------------------------------------------------------------
105
+
106
+ /** Letters that exist in Vietnamese and not in English, in the forms a path can carry. */
107
+ const SECOND_LANGUAGE_PATH = /[àáâãèéêìíòóôõùúýăđĩũơưạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ]/i
108
+
109
+ /**
110
+ * Second-language words spelled in ASCII, as a route or folder segment writes them.
111
+ *
112
+ * A path cannot carry diacritics, so `cấp phát` reaches the filesystem as `cap-phat` and every
113
+ * accent-based check passes it. The segments below are the ones this product actually produced.
114
+ * A list is the honest instrument here: guessing at Vietnamese-shaped ASCII would refuse `dang`
115
+ * in `dangerous` and `cap` in `capacity`, and a rule that fires on English words is one a
116
+ * repository turns off.
117
+ */
118
+ const ROMANISED = [
119
+ "dang-nhap", "dang-ky", "dang-xuat", "cap-phat", "khoa-hoc", "hoc-vien", "gioi-thieu",
120
+ "lien-he", "tai-khoan", "thanh-toan", "gio-hang", "tin-tuc", "san-pham", "bang-gia",
121
+ "dieu-khoan", "chinh-sach", "trang-chu", "quan-ly", "cai-dat", "ho-so",
122
+ ]
123
+
124
+ /** A route segment or folder name, lowercased, from a normalized path. */
125
+ const segmentsOf = (filename) =>
126
+ String(filename || "").replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean)
127
+
128
+ /**
129
+ * A path names things in one language: the one every reader of this repository shares.
130
+ *
131
+ * WHY A PATH AND NOT ONLY THE SOURCE. `no-second-language-in-source` reads identifiers, comments
132
+ * and strings, and cannot see the file it is reading. So a route could be `app/cap-phat/page.tsx`
133
+ * with every identifier inside it in English, and nothing said a word - while the URL, the import
134
+ * specifier, the folder in every editor sidebar and the path in every stack trace stayed in a
135
+ * language half the readers do not have.
136
+ *
137
+ * A ROUTE SEGMENT IS ALSO A PUBLIC NAME. It is the address a customer sees and a support ticket
138
+ * quotes, so this is not only an authoring question: the product's own URLs stop being readable to
139
+ * anybody outside one language.
140
+ */
141
+ export const noSecondLanguageInPath = {
142
+ meta: {
143
+ type: "problem",
144
+ docs: { description: "File and route names are written in the repository's one shared language." },
145
+ schema: [],
146
+ messages: {
147
+ path:
148
+ "`{{segment}}` names this file in a second language. A path is read by more people than the code inside it - it is the URL a customer quotes, the folder every editor shows, and the specifier every import repeats - and unlike a comment it cannot be skimmed past. Rename the segment; the words a person READS belong in the locale catalogue, never in the address.",
149
+ },
150
+ },
151
+ create(context) {
152
+ const filename = context.filename || context.getFilename()
153
+ const offending = segmentsOf(filename).find(
154
+ (segment) => SECOND_LANGUAGE_PATH.test(segment) || ROMANISED.includes(segment.replace(/[()[\]]/g, "")),
155
+ )
156
+ if (!offending) return {}
157
+ return {
158
+ Program(node) {
159
+ context.report({ node, messageId: "path", data: { segment: offending } })
160
+ },
161
+ }
162
+ },
163
+ }
164
+
165
165
  export const rules = {
166
166
  "prefer-arrow-export": preferArrowExport,
167
167
  "handler-on-prefix": handlerOnPrefix,
168
168
  "no-second-language-in-path": noSecondLanguageInPath,
169
169
  "no-direct-const-alias": noDirectConstAlias,
170
170
  }
171
-
172
- /**
173
- * The level this law asks for, as the plugin's own opinion.
174
- *
175
- * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
176
- * Both rules here are mechanical and auto-correctable by hand in seconds, so a repository adopting
177
- * them with existing debt should burn the count down rather than live at `warn`.
178
- */
179
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
171
+
172
+ /**
173
+ * The level this law asks for, as the plugin's own opinion.
174
+ *
175
+ * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
176
+ * Both rules here are mechanical and auto-correctable by hand in seconds, so a repository adopting
177
+ * them with existing debt should burn the count down rather than live at `warn`.
178
+ */
179
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The rule that holds `native-controls.md` (HFS R62, `FE_NATIVE_FORM_CONTROL`).
3
+ *
4
+ * THE GRAMMAR OWNS THE CONTROL. A bare `<select>`, `<input>`, `<textarea>` or `<button>` in product
5
+ * source is a control with no focus ring, no dark value, no density, no disabled or invalid state
6
+ * from the design system - it looks native on one platform and is styled by hand on the next, and
7
+ * the hand styling is where the private colour and pixel values came from (see `brand-values`).
8
+ * The renderer the grammar publishes is the same element with the state the product needs.
9
+ *
10
+ * NO TIER IS EXEMPT. The grammar package itself is not linted by this canon (it is where the native
11
+ * element legitimately lives); everything the product owns - leaves included - renders the grammar's
12
+ * control. If the grammar lacks the control, the fix is to add it to the grammar, not to draw one here.
13
+ */
14
+
15
+ import { attribute } from "./lib/ast.mjs"
16
+ import { isSpecFile } from "./lib/scope.mjs"
17
+
18
+ /** Intrinsic elements a reader operates, that the grammar renders for the product. */
19
+ const NATIVE = new Set(["select", "input", "textarea", "button"])
20
+
21
+ /** No native form control in product source. */
22
+ export const noNativeFormControl = {
23
+ meta: {
24
+ type: "problem",
25
+ docs: { description: "Use the grammar's renderers; never a bare select, input, textarea or button." },
26
+ schema: [],
27
+ messages: {
28
+ native:
29
+ "A bare `<{{tag}}>`. The grammar owns this control: it brings the focus ring, disabled and invalid states, density and the dark value, and a hand-styled native element brings none of them. Use the grammar's renderer; if it does not exist yet, add it to the grammar rather than drawing one here.",
30
+ },
31
+ },
32
+ create(context) {
33
+ if (isSpecFile(context.filename || context.getFilename())) return {}
34
+ return {
35
+ JSXOpeningElement(node) {
36
+ if (node.name.type !== "JSXIdentifier" || !NATIVE.has(node.name.name)) return
37
+ context.report({ node, messageId: "native", data: { tag: node.name.name } })
38
+ },
39
+ }
40
+ },
41
+ }
42
+
43
+ // -- NATIVE-2 --------------------------------------------------------------------------------------
44
+
45
+ /** No bare `<img>`; the framework's image owns size, format, lazy loading and layout shift. */
46
+ export const noNativeImg = {
47
+ meta: {
48
+ type: "problem",
49
+ docs: { description: "Use `next/image`, never a bare `<img>`." },
50
+ schema: [],
51
+ messages: {
52
+ img:
53
+ "A bare `<img>` ships the original file at its original size, reserves no space (the page jumps when it loads) and is never lazy by policy. Use `Image` from `next/image` with `width` and `height`, or `fill` and `sizes`, and an `alt` from the message catalogue.",
54
+ },
55
+ },
56
+ create(context) {
57
+ if (isSpecFile(context.filename || context.getFilename())) return {}
58
+ return {
59
+ JSXOpeningElement(node) {
60
+ if (node.name.type === "JSXIdentifier" && node.name.name === "img") context.report({ node, messageId: "img" })
61
+ },
62
+ }
63
+ },
64
+ }
65
+
66
+ // -- NATIVE-3 --------------------------------------------------------------------------------------
67
+
68
+ /** Every `next/image` reserves its space: a size, or `fill` inside a sized parent with `sizes`. */
69
+ export const imageHasSize = {
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description: "`next/image` carries `width` and `height`, or `fill` with `sizes`." },
73
+ schema: [],
74
+ messages: {
75
+ size:
76
+ "This `Image` has no `width` and `height` and is not `fill`. The browser cannot reserve its space, so the page shifts when it loads and the layout score falls. Give it both dimensions, or `fill` inside a sized parent.",
77
+ sizes:
78
+ "This `Image` is `fill` with no `sizes`. Next then assumes the image is as wide as the viewport and downloads the largest variant on every screen. State the rendered width: `sizes=\"(min-width: 768px) 33vw, 100vw\"`.",
79
+ },
80
+ },
81
+ create(context) {
82
+ if (isSpecFile(context.filename || context.getFilename())) return {}
83
+ const locals = new Set()
84
+ return {
85
+ ImportDeclaration(node) {
86
+ if (node.source.value !== "next/image") return
87
+ for (const specifier of node.specifiers) if (specifier.type === "ImportDefaultSpecifier") locals.add(specifier.local.name)
88
+ },
89
+ JSXOpeningElement(node) {
90
+ if (node.name.type !== "JSXIdentifier" || !locals.has(node.name.name)) return
91
+ // A spread may carry the size; the rule judges what it can read.
92
+ if (node.attributes.some((entry) => entry.type === "JSXSpreadAttribute")) return
93
+ const fill = attribute(node, "fill")
94
+ if (fill) {
95
+ if (!attribute(node, "sizes")) context.report({ node, messageId: "sizes" })
96
+ return
97
+ }
98
+ if (!attribute(node, "width") || !attribute(node, "height")) context.report({ node, messageId: "size" })
99
+ },
100
+ }
101
+ },
102
+ }
103
+
104
+ /** The rules this law contributes to the plugin. */
105
+ export const rules = {
106
+ "no-native-form-control": noNativeFormControl,
107
+ "no-native-img": noNativeImg,
108
+ "image-has-size": imageHasSize,
109
+ }
110
+
111
+ /** Every rule is an error. */
112
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))