@starci/eslint-canon-fe 9.2.0 → 9.4.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 (178) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +5 -1
  3. package/dist/brand-values.d.ts +28 -0
  4. package/dist/brand-values.js +172 -0
  5. package/dist/button-intent.d.ts +11 -0
  6. package/dist/button-intent.js +361 -0
  7. package/dist/callback-shape.d.ts +7 -0
  8. package/dist/callback-shape.js +396 -0
  9. package/dist/class-names.d.ts +19 -0
  10. package/dist/class-names.js +170 -0
  11. package/dist/client-boundary.d.ts +42 -0
  12. package/dist/client-boundary.js +404 -0
  13. package/dist/comments.d.ts +47 -0
  14. package/dist/comments.js +156 -0
  15. package/dist/control-height.d.ts +7 -0
  16. package/dist/control-height.js +83 -0
  17. package/dist/drawing.d.ts +31 -0
  18. package/dist/drawing.js +376 -0
  19. package/dist/env-owner.d.ts +22 -0
  20. package/dist/env-owner.js +291 -0
  21. package/dist/fe-stack.d.ts +10 -0
  22. package/dist/fe-stack.js +73 -0
  23. package/dist/file-layout.d.ts +71 -0
  24. package/dist/file-layout.js +465 -0
  25. package/dist/formatting.d.ts +25 -0
  26. package/dist/formatting.js +104 -0
  27. package/dist/function-budget.d.ts +9 -0
  28. package/dist/function-budget.js +48 -0
  29. package/dist/grammar-shim.d.ts +5 -0
  30. package/dist/grammar-shim.js +126 -0
  31. package/dist/hooks-folder.d.ts +34 -0
  32. package/dist/hooks-folder.js +320 -0
  33. package/dist/hygiene.d.ts +39 -0
  34. package/dist/hygiene.js +540 -0
  35. package/dist/index.d.ts +105 -0
  36. package/dist/index.js +310 -0
  37. package/{landmark.mjs → dist/landmark.d.ts} +4 -6
  38. package/dist/landmark.js +6 -0
  39. package/dist/lib/ast.d.ts +36 -0
  40. package/dist/lib/ast.js +94 -0
  41. package/dist/lib/bindings.d.ts +84 -0
  42. package/dist/lib/bindings.js +168 -0
  43. package/dist/lib/class-text.d.ts +3 -0
  44. package/dist/lib/class-text.js +30 -0
  45. package/dist/lib/config.d.ts +81 -0
  46. package/dist/lib/config.js +141 -0
  47. package/dist/lib/drawing-scope.d.ts +5 -0
  48. package/{lib/drawing-scope.mjs → dist/lib/drawing-scope.js} +6 -11
  49. package/dist/lib/eslint-boundary.d.ts +23 -0
  50. package/dist/lib/eslint-boundary.js +6 -0
  51. package/dist/lib/fe-stack.d.ts +49 -0
  52. package/dist/lib/fe-stack.js +98 -0
  53. package/dist/lib/hfs-view.d.ts +74 -0
  54. package/dist/lib/hfs-view.js +6 -0
  55. package/dist/lib/hfs.d.ts +34 -0
  56. package/dist/lib/hfs.js +72 -0
  57. package/dist/lib/next.d.ts +4 -0
  58. package/dist/lib/next.js +10 -0
  59. package/dist/lib/own-hook-origin.d.ts +17 -0
  60. package/dist/lib/own-hook-origin.js +52 -0
  61. package/dist/lib/package-root.d.ts +4 -0
  62. package/dist/lib/package-root.js +10 -0
  63. package/dist/lib/path.d.ts +2 -0
  64. package/{lib/path.mjs → dist/lib/path.js} +1 -1
  65. package/dist/lib/platform.d.ts +26 -0
  66. package/{lib/platform.mjs → dist/lib/platform.js} +38 -35
  67. package/dist/lib/program-for.d.ts +5 -0
  68. package/dist/lib/program-for.js +23 -0
  69. package/dist/lib/pure-owner-helper.d.ts +10 -0
  70. package/dist/lib/pure-owner-helper.js +156 -0
  71. package/dist/lib/react.d.ts +16 -0
  72. package/dist/lib/react.js +22 -0
  73. package/dist/lib/redux-hook-origin.d.ts +4 -0
  74. package/dist/lib/redux-hook-origin.js +50 -0
  75. package/dist/lib/rule.d.ts +28 -0
  76. package/dist/lib/rule.js +5 -0
  77. package/dist/lib/scope.d.ts +88 -0
  78. package/{lib/scope.mjs → dist/lib/scope.js} +83 -93
  79. package/dist/lib/typed-class-values.d.ts +13 -0
  80. package/dist/lib/typed-class-values.js +92 -0
  81. package/dist/lib/types.d.ts +44 -0
  82. package/dist/lib/types.js +64 -0
  83. package/dist/lib/why.d.ts +350 -0
  84. package/dist/lib/why.js +350 -0
  85. package/dist/lint-escape-hatch.d.ts +24 -0
  86. package/{lint-escape-hatch.mjs → dist/lint-escape-hatch.js} +38 -45
  87. package/dist/lists.d.ts +31 -0
  88. package/dist/lists.js +202 -0
  89. package/dist/loading.d.ts +34 -0
  90. package/dist/loading.js +157 -0
  91. package/dist/naming.d.ts +33 -0
  92. package/dist/naming.js +176 -0
  93. package/dist/native-controls.d.ts +24 -0
  94. package/dist/native-controls.js +105 -0
  95. package/dist/next-conventions.d.ts +48 -0
  96. package/dist/next-conventions.js +452 -0
  97. package/dist/project-graph.d.ts +5 -0
  98. package/{project-graph.mjs → dist/project-graph.js} +8 -12
  99. package/dist/props-and-slots.d.ts +19 -0
  100. package/dist/props-and-slots.js +320 -0
  101. package/dist/served-locale.d.ts +38 -0
  102. package/dist/served-locale.js +263 -0
  103. package/dist/shape-slot.d.ts +18 -0
  104. package/dist/shape-slot.js +324 -0
  105. package/dist/size-and-state-budget.d.ts +46 -0
  106. package/dist/size-and-state-budget.js +424 -0
  107. package/dist/size-growth.d.ts +24 -0
  108. package/dist/size-growth.js +64 -0
  109. package/dist/sonar-parity.d.ts +5 -0
  110. package/{sonar-parity.mjs → dist/sonar-parity.js} +11 -7
  111. package/dist/surface-rules.d.ts +18 -0
  112. package/dist/surface-rules.js +491 -0
  113. package/dist/swr-family.d.ts +15 -0
  114. package/dist/swr-family.js +419 -0
  115. package/dist/the-split.d.ts +27 -0
  116. package/dist/the-split.js +157 -0
  117. package/dist/tokens.d.ts +9 -0
  118. package/dist/tokens.js +35 -0
  119. package/dist/translation.d.ts +51 -0
  120. package/dist/translation.js +403 -0
  121. package/dist/transport.d.ts +73 -0
  122. package/dist/transport.js +885 -0
  123. package/dist/type-safety.d.ts +43 -0
  124. package/dist/type-safety.js +139 -0
  125. package/dist/ui-rulings.d.ts +11 -0
  126. package/dist/ui-rulings.js +121 -0
  127. package/package.json +31 -9
  128. package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
  129. package/runtime/scripts/lib/package-at.mjs +5 -5
  130. package/brand-values.mjs +0 -172
  131. package/button-intent.mjs +0 -320
  132. package/callback-shape.mjs +0 -332
  133. package/class-names.mjs +0 -149
  134. package/client-boundary.mjs +0 -400
  135. package/comments.mjs +0 -159
  136. package/control-height.mjs +0 -77
  137. package/drawing.mjs +0 -315
  138. package/env-owner.mjs +0 -244
  139. package/fe-stack.mjs +0 -86
  140. package/file-layout.mjs +0 -472
  141. package/formatting.mjs +0 -110
  142. package/function-budget.mjs +0 -49
  143. package/grammar-shim.mjs +0 -123
  144. package/hooks-folder.mjs +0 -300
  145. package/hygiene.mjs +0 -513
  146. package/index.mjs +0 -317
  147. package/lib/ast.mjs +0 -97
  148. package/lib/bindings.mjs +0 -159
  149. package/lib/class-text.mjs +0 -29
  150. package/lib/config.mjs +0 -142
  151. package/lib/fe-stack.mjs +0 -77
  152. package/lib/hfs.mjs +0 -49
  153. package/lib/next.mjs +0 -6
  154. package/lib/own-hook-origin.mjs +0 -53
  155. package/lib/program-for.mjs +0 -22
  156. package/lib/pure-owner-helper.mjs +0 -148
  157. package/lib/react.mjs +0 -28
  158. package/lib/redux-hook-origin.mjs +0 -44
  159. package/lib/typed-class-values.mjs +0 -81
  160. package/lib/types.mjs +0 -63
  161. package/lib/why.mjs +0 -352
  162. package/lists.mjs +0 -193
  163. package/loading.mjs +0 -156
  164. package/naming.mjs +0 -185
  165. package/native-controls.mjs +0 -108
  166. package/next-conventions.mjs +0 -447
  167. package/props-and-slots.mjs +0 -276
  168. package/served-locale.mjs +0 -243
  169. package/size-and-state-budget.mjs +0 -376
  170. package/size-growth.mjs +0 -62
  171. package/surface-rules.mjs +0 -443
  172. package/swr-family.mjs +0 -326
  173. package/the-split.mjs +0 -155
  174. package/tokens.mjs +0 -39
  175. package/translation.mjs +0 -392
  176. package/transport.mjs +0 -830
  177. package/type-safety.mjs +0 -147
  178. package/ui-rulings.mjs +0 -134
package/lists.mjs DELETED
@@ -1,193 +0,0 @@
1
- /**
2
- * The rules that hold `lists.md` (HFS R65 `FE_SIZE_AND_STATE_BUDGET`, sub-check `FE_LIST_KEY`).
3
- *
4
- * A LIST IS ITEMS WITH IDENTITY. React reconciles a rendered list by `key`: the key is how it knows
5
- * that the third row is still the third row after the first was deleted. With no key React falls
6
- * back to position, with an index key it says position is identity, and either way a deleted row
7
- * hands its state (an open menu, a half-typed input, a focus ring) to its neighbour. Nothing fails a
8
- * type check and the bug appears only on the second interaction, on a real list.
9
- *
10
- * WHAT COUNTS AS A LIST. A `.map` callback that returns JSX. A static array written in place
11
- * (`[0, 1, 2].map(...)`) or a callback that ignores its item (`(_, index) => ...`, a skeleton) has no identity to lose, so an index key there is fine.
12
- *
13
- * THE MEMO TAX. A component prop written as `{{ ... }}` or `[...]` inside a `.map` callback is a new
14
- * object per row per render, so `memo` on the row never hits and the whole list re-renders on every
15
- * keystroke of its parent. Hoist the constant, or build it once above the map.
16
- */
17
-
18
- import { attribute, attributeValue, calleeName, isFunction, mentions } from "./lib/ast.mjs"
19
-
20
- /** `.map(callback)` where the callback is written inline; the call node, else null. */
21
- const mapCallback = (call) => {
22
- if (call.type !== "CallExpression" || call.callee.type !== "MemberExpression" || call.callee.computed) return null
23
- if (call.callee.property.type !== "Identifier" || call.callee.property.name !== "map") return null
24
- const receiver = calleeName(call.callee.object)
25
- if (receiver !== null && /(?:^|\.)Children$/.test(receiver)) return null
26
- const callback = call.arguments[0]
27
- return callback && isFunction(callback) ? callback : null
28
- }
29
-
30
- /** The JSX a callback returns: its expression body, or the value of each of its own `return` statements. */
31
- const returnedJsx = (callback) => {
32
- const found = []
33
- const take = (node) => {
34
- if (!node) return
35
- if (node.type === "JSXElement" || node.type === "JSXFragment") found.push(node)
36
- else if (node.type === "ConditionalExpression") {
37
- take(node.consequent)
38
- take(node.alternate)
39
- } else if (node.type === "LogicalExpression") take(node.right)
40
- }
41
- if (callback.body.type !== "BlockStatement") {
42
- take(callback.body)
43
- return found
44
- }
45
- const scan = (node) => {
46
- if (!node || typeof node.type !== "string") return
47
- if (node.type === "ReturnStatement") return take(node.argument)
48
- if (node.type === "BlockStatement") node.body.forEach(scan)
49
- else if (node.type === "IfStatement") {
50
- scan(node.consequent)
51
- scan(node.alternate)
52
- } else if (node.type === "SwitchStatement") node.cases.forEach((entry) => entry.consequent.forEach(scan))
53
- else if (node.type === "TryStatement") {
54
- scan(node.block)
55
- scan(node.handler?.body)
56
- }
57
- }
58
- scan(callback.body)
59
- return found
60
- }
61
-
62
- /**
63
- * True when the rows have no identity to lose: the receiver is an array written in place, or the callback
64
- * ignores the item (`(_, index) => ...`), which is how a skeleton or a placeholder count is rendered.
65
- */
66
- const isPositional = (call, callback) => {
67
- if (call.callee.object.type === "ArrayExpression") return true
68
- const item = callback.params[0]
69
- return Boolean(item) && item.type === "Identifier" && item.name.startsWith("_")
70
- }
71
-
72
- // -- LISTS-1 ---------------------------------------------------------------------------------------
73
-
74
- /** Every element a `.map` callback returns carries a `key`. */
75
- export const listItemHasKey = {
76
- meta: {
77
- type: "problem",
78
- docs: { description: "An element returned from `.map` carries a `key`." },
79
- schema: [],
80
- messages: {
81
- missing:
82
- "This element is returned from `.map` with no `key`. React then identifies rows by position, so deleting or reordering one hands its state (an open menu, typed text, focus) to its neighbour. Give it a stable key from the data: `key={row.id}`.",
83
- fragment:
84
- "A `<>...</>` returned from `.map` cannot carry a key. Use `<Fragment key={...}>` (import `Fragment` from `react`) so each row has an identity.",
85
- },
86
- },
87
- create(context) {
88
- return {
89
- CallExpression(call) {
90
- const callback = mapCallback(call)
91
- if (!callback) return
92
- for (const element of returnedJsx(callback)) {
93
- if (element.type === "JSXFragment") context.report({ node: element, messageId: "fragment" })
94
- else if (!attribute(element.openingElement, "key")) context.report({ node: element.openingElement, messageId: "missing" })
95
- }
96
- },
97
- }
98
- },
99
- }
100
-
101
- // -- LISTS-2 ---------------------------------------------------------------------------------------
102
-
103
- /** The key is never the position, and never made up on every render. */
104
- export const noIndexKey = {
105
- meta: {
106
- type: "problem",
107
- docs: { description: "A list key is stable data identity, never the map index or a random value." },
108
- schema: [],
109
- messages: {
110
- index:
111
- "`key` is built from the `.map` index. The index is the row's position, not its identity: after a delete or a reorder the wrong row keeps the state. Use an id the data carries (`key={row.id}`).",
112
- random:
113
- "`key` is generated during render (`Math.random`, `Date.now`, `crypto.randomUUID`). A new key on every render remounts every row, throwing away its state and its DOM. Use an id the data carries.",
114
- },
115
- },
116
- create(context) {
117
- const source = context.sourceCode ?? context.getSourceCode()
118
- return {
119
- CallExpression(call) {
120
- const callback = mapCallback(call)
121
- if (!callback) return
122
- const indexParam = callback.params[1]
123
- const indexName = indexParam && indexParam.type === "Identifier" ? indexParam.name : null
124
- for (const element of returnedJsx(callback)) {
125
- if (element.type !== "JSXElement") continue
126
- const key = attribute(element.openingElement, "key")
127
- const value = attributeValue(key)
128
- if (!value) continue
129
- const text = source.getText(value)
130
- if (/\b(?:Math\.random|Date\.now|randomUUID|nanoid|uuid)\b/.test(text)) {
131
- context.report({ node: key, messageId: "random" })
132
- } else if (indexName && !isPositional(call, callback) && mentions(source, value, indexName)) {
133
- context.report({ node: key, messageId: "index" })
134
- }
135
- }
136
- },
137
- }
138
- },
139
- }
140
-
141
- // -- LISTS-3 ---------------------------------------------------------------------------------------
142
-
143
- /** True for `<Component>` and `<a.B>`, false for a DOM tag: only a component can be memoised. */
144
- const isComponentTag = (opening) => {
145
- if (opening.name.type === "JSXMemberExpression") return true
146
- return opening.name.type === "JSXIdentifier" && /^[A-Z]/.test(opening.name.name)
147
- }
148
-
149
- /** True when the nearest function around `node` is a `.map` callback. */
150
- const insideMapCallback = (node) => {
151
- for (let current = node.parent; current; current = current.parent) {
152
- if (!isFunction(current)) continue
153
- return current.parent?.type === "CallExpression" && current.parent.arguments[0] === current && mapCallback(current.parent) === current
154
- }
155
- return false
156
- }
157
-
158
- /** A component in a mapped list gets no object or array literal for a prop. */
159
- export const noInlineLiteralPropInList = {
160
- meta: {
161
- type: "problem",
162
- docs: { description: "No object or array literal as a prop of a component rendered inside `.map`." },
163
- schema: [],
164
- messages: {
165
- literal:
166
- "`{{prop}}` is an object or array literal written inside `.map`: a new value per row per render, so a memoised row never hits and the whole list re-renders on every parent update. Hoist it to a constant above the component, or build it once before the map.",
167
- },
168
- },
169
- create(context) {
170
- return {
171
- JSXAttribute(node) {
172
- const opening = node.parent
173
- if (!opening || opening.type !== "JSXOpeningElement" || !isComponentTag(opening)) return
174
- if (node.name.type !== "JSXIdentifier" || node.name.name === "key") return
175
- const value = attributeValue(node)
176
- if (!value || (value.type !== "ObjectExpression" && value.type !== "ArrayExpression")) return
177
- if (value.type === "ArrayExpression" && value.elements.length === 0) return
178
- if (!insideMapCallback(node)) return
179
- context.report({ node, messageId: "literal", data: { prop: node.name.name } })
180
- },
181
- }
182
- },
183
- }
184
-
185
- /** The rules this law contributes to the plugin. */
186
- export const rules = {
187
- "list-item-has-key": listItemHasKey,
188
- "no-index-key": noIndexKey,
189
- "no-inline-literal-prop-in-list": noInlineLiteralPropInList,
190
- }
191
-
192
- /** Every rule is an error. */
193
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/loading.mjs DELETED
@@ -1,156 +0,0 @@
1
- /**
2
- * The rules that hold `loading.md`.
3
- *
4
- * Three shapes of the same mistake: a second tree describing the first. A twin component, a
5
- * ready-made placeholder handed in as a prop, and a ternary picking between two different
6
- * components at a call site. Each is correct on the day it is written and wrong the first time the
7
- * real shape changes.
8
- *
9
- * WHY THIS NEEDS RULES AT ALL, when so much of this canon is held by types. A resting shape has no
10
- * assertion to fail: it is not wrong in a way a compiler or a test can see, it is wrong on screen,
11
- * and only for the second somebody happens to be watching. That is exactly the class of defect a
12
- * lint rule is for - the one nothing else will ever report.
13
- */
14
-
15
- import { classOf, fileOf, isComponentFile } from "./lib/scope.mjs"
16
-
17
- /** Product component source these rules govern: a component owner's file, and not a spec (a placeholder in a test fixture is a fixture, not a second tree). */
18
- const isGoverned = (context) => isComponentFile(context)
19
-
20
- /** A flag that means "waiting", in the spellings this codebase uses. */
21
- const WAITING_FLAG = /\bis(?:Loading|Skeleton|Pending)\b/
22
-
23
- /** The name of a component whose whole job is to mirror another one's shape. */
24
- const TWIN_NAME = /^[A-Za-z0-9]*Skeleton$/
25
-
26
- /** JSX element name, including `Foo.Bar`. */
27
- const jsxElementName = (opening) => {
28
- const name = opening && opening.name
29
- if (!name) return null
30
- if (name.type === "JSXIdentifier") return name.name
31
- if (name.type === "JSXMemberExpression" && name.object && name.property) {
32
- return `${name.object.name}.${name.property.name}`
33
- }
34
- return null
35
- }
36
-
37
- // -- LOADING-1 -------------------------------------------------------------------------------------
38
-
39
- /** No component exists whose job is to mirror another component's shape. */
40
- export const noRestingTwinComponent = {
41
- meta: {
42
- type: "problem",
43
- docs: { description: "No component whose whole job is to mirror another one's shape." },
44
- schema: [],
45
- messages: {
46
- twin:
47
- "`{{name}}` is a hand-mirrored twin: a second description of a shape that already has one. It cannot be kept in step - it can only be noticed after it has already drifted, and a resting shape has no assertion to fail, so nothing will report the drift. Give the component it mirrors a resting state and let it rest as ITSELF.",
48
- },
49
- },
50
- create(context) {
51
- if (!isGoverned(context)) return {}
52
- const owner = classOf(context)
53
- if (owner.role !== "entry" || !TWIN_NAME.test(owner.bindings?.name ?? "")) return {}
54
- return {
55
- Program(node) {
56
- context.report({ node, messageId: "twin", data: { name: owner.bindings.name } })
57
- },
58
- }
59
- },
60
- }
61
-
62
- // -- LOADING-1, handed in from outside --------------------------------------------------------------
63
-
64
- /** A ready-made placeholder is the same second tree, passed as a prop. */
65
- export const noPlaceholderProp = {
66
- meta: {
67
- type: "problem",
68
- docs: { description: "No prop hands a component a ready-made resting shape to render." },
69
- schema: [],
70
- messages: {
71
- prop:
72
- "A resting shape handed in as markup is a second description of a tree that already has one, and it sits further from the shape it copies than a twin does - the component it describes cannot even see it. Pass the waiting flag down instead, and let each part rest as itself.",
73
- import:
74
- "`{{name}}` is a hand-kept resting tree imported from beside its subject. It drifts the first time the real shape changes, and nothing reports it. Thread the waiting flag down instead.",
75
- },
76
- },
77
- create(context) {
78
- if (!isGoverned(context)) return {}
79
- return {
80
- JSXAttribute(node) {
81
- const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
82
- if (name !== "skeleton" && name !== "placeholder" && name !== "fallback") return
83
- const value = node.value
84
- if (!value || value.type !== "JSXExpressionContainer") return
85
- const expression = value.expression
86
- if (expression.type === "JSXElement" || expression.type === "JSXFragment") {
87
- context.report({ node, messageId: "prop" })
88
- }
89
- },
90
- ImportDeclaration(node) {
91
- const source = node.source && node.source.value
92
- if (typeof source !== "string" || !/^\.\.?\//.test(source)) return
93
- for (const specifier of node.specifiers || []) {
94
- const local = specifier.local && specifier.local.name
95
- // A bare `Skeleton` is the primitive a component rests WITH, not a twin of one.
96
- if (!local || local === "Skeleton" || !TWIN_NAME.test(local)) continue
97
- context.report({ node: specifier, messageId: "import", data: { name: local } })
98
- }
99
- },
100
- }
101
- },
102
- }
103
-
104
- // -- LOADING-2 -------------------------------------------------------------------------------------
105
-
106
- /** A caller never picks between a resting shape and a real one. */
107
- export const noRestingBranchAtCallSite = {
108
- meta: {
109
- type: "problem",
110
- docs: { description: "A waiting flag must not choose between two different components." },
111
- schema: [],
112
- messages: {
113
- branch:
114
- "`{{flag}}` picks between two DIFFERENT elements, which is a resting shape written at the call site - and it drifts from the real one the first time the real one changes. Give the component below a waiting flag and pass it down; let it rest as ITSELF. The same component on both sides is the honest form: one description, two states.",
115
- },
116
- },
117
- create(context) {
118
- if (!isGoverned(context)) return {}
119
- const source = context.sourceCode || context.getSourceCode()
120
- /** Root element name of one arm, or null when the arm is not an element. */
121
- const armName = (expression) => {
122
- if (!expression) return null
123
- if (expression.type === "JSXElement") return jsxElementName(expression.openingElement)
124
- if (expression.type === "JSXFragment") return "<>"
125
- return null
126
- }
127
- return {
128
- ConditionalExpression(node) {
129
- const test = source.getText(node.test)
130
- if (!WAITING_FLAG.test(test)) return
131
- const left = armName(node.consequent)
132
- const right = armName(node.alternate)
133
- // Both arms must be real elements AND differ. `null` on one side is LOADING-5 - a control
134
- // that has nowhere to go yet - and that is correct rather than a second tree.
135
- if (!left || !right || left === right) return
136
- context.report({ node, messageId: "branch", data: { flag: test.trim().slice(0, 40) } })
137
- },
138
- }
139
- },
140
- }
141
-
142
- /** The rules this law contributes to the plugin. */
143
- export const rules = {
144
- "no-resting-twin-component": noRestingTwinComponent,
145
- "no-placeholder-prop": noPlaceholderProp,
146
- "no-resting-branch-at-call-site": noRestingBranchAtCallSite,
147
- }
148
-
149
- /**
150
- * The level this law asks for, as the plugin's own opinion.
151
- *
152
- * A repository adopting these with history should expect the twin rule to report a folder at a
153
- * time: hand-kept placeholder trees arrive in families, one per screen, and each is a real shape
154
- * that has to be folded back into the component it copies rather than deleted.
155
- */
156
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/naming.mjs DELETED
@@ -1,185 +0,0 @@
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
- import { hfsOf } from "./lib/hfs.mjs"
15
- import { NEXT_RESERVED_EXPORTS } from "./lib/next.mjs"
16
- import { fileOf, inSlot, roleOfFile } from "./lib/scope.mjs"
17
-
18
- /** Parents that make a function declaration module-level rather than nested. */
19
- const MODULE_LEVEL_PARENTS = new Set(["Program", "ExportNamedDeclaration", "ExportDefaultDeclaration"])
20
-
21
- // -- NAMING-1 --------------------------------------------------------------------------------------
22
-
23
- /** A module-level function is an arrow const, so nothing exists before the line that declares it. */
24
- export const preferArrowExport = {
25
- meta: {
26
- type: "suggestion",
27
- docs: { description: "Module-level functions use an arrow const, not a `function` declaration." },
28
- schema: [],
29
- messages: {
30
- 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.",
31
- },
32
- },
33
- create(context) {
34
- return {
35
- FunctionDeclaration(node) {
36
- const parent = node.parent && node.parent.type
37
- if (!MODULE_LEVEL_PARENTS.has(parent)) return
38
- context.report({
39
- node: node.id || node,
40
- messageId: "fn",
41
- data: { name: (node.id && node.id.name) || "default" },
42
- })
43
- },
44
- }
45
- },
46
- }
47
-
48
- // -- NAMING-2 --------------------------------------------------------------------------------------
49
-
50
- /** Something a reader triggers is named `onX`, so the name survives being passed. */
51
- export const handlerOnPrefix = {
52
- meta: {
53
- type: "suggestion",
54
- docs: { description: "Handlers are named `onX`, not `handleX`." },
55
- schema: [],
56
- messages: {
57
- 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.",
58
- },
59
- },
60
- create(context) {
61
- const flag = (node, name) => {
62
- if (!name || !/^handle[A-Z]/.test(name)) return
63
- context.report({ node, messageId: "handle", data: { name, rest: name.slice("handle".length) } })
64
- }
65
- return {
66
- VariableDeclarator(node) {
67
- if (node.id && node.id.type === "Identifier") flag(node.id, node.id.name)
68
- },
69
- JSXAttribute(node) {
70
- if (node.name) flag(node.name, node.name.name)
71
- },
72
- TSPropertySignature(node) {
73
- if (node.key && node.key.type === "Identifier") flag(node.key, node.key.name)
74
- },
75
- }
76
- },
77
- }
78
-
79
- // -- machine-only identity -------------------------------------------------------------------------
80
-
81
- /** A const introduces a value; it does not merely give an existing identifier a second name. */
82
- export const noDirectConstAlias = {
83
- meta: {
84
- type: "problem",
85
- docs: { description: "A const must not be a direct identifier-to-identifier alias." },
86
- schema: [],
87
- messages: {
88
- alias:
89
- "`const {{alias}} = {{original}}` gives one value two names without adding behavior. Use `{{original}}` directly, or make `{{alias}}` the result of the transformation it claims to represent.",
90
- },
91
- },
92
- create(context) {
93
- // A route segment file (slot `fe.route`, a role: page, layout, route, ...) is the one place Next dictates the exported name; `export const generateMetadata = x` is the contract.
94
- const segmentFile = inSlot(context, "fe.route") && roleOfFile(context) !== null
95
- return {
96
- VariableDeclarator(node) {
97
- if (node.parent?.kind !== "const") return
98
- if (node.id?.type !== "Identifier" || node.init?.type !== "Identifier") return
99
- if (segmentFile && NEXT_RESERVED_EXPORTS.has(node.id.name) && node.parent.parent?.type === "ExportNamedDeclaration") return
100
- context.report({
101
- node: node.id,
102
- messageId: "alias",
103
- data: { alias: node.id.name, original: node.init.name },
104
- })
105
- },
106
- }
107
- },
108
- }
109
-
110
- /** The rules this law contributes to the plugin. */
111
- // -- NAMING-3 --------------------------------------------------------------------------------------
112
-
113
- /** Letters that exist in Vietnamese and not in English, in the forms a path can carry. */
114
- const SECOND_LANGUAGE_PATH = /[\u00e0\u00e1\u00e2\u00e3\u00e8\u00e9\u00ea\u00ec\u00ed\u00f2\u00f3\u00f4\u00f5\u00f9\u00fa\u00fd\u0103\u0111\u0129\u0169\u01a1\u01b0\u1ea1\u1ea3\u1ea5\u1ea7\u1ea9\u1eab\u1ead\u1eaf\u1eb1\u1eb3\u1eb5\u1eb7\u1eb9\u1ebb\u1ebd\u1ebf\u1ec1\u1ec3\u1ec5\u1ec7\u1ec9\u1ecb\u1ecd\u1ecf\u1ed1\u1ed3\u1ed5\u1ed7\u1ed9\u1edb\u1edd\u1edf\u1ee1\u1ee3\u1ee5\u1ee7\u1ee9\u1eeb\u1eed\u1eef\u1ef1\u1ef3\u1ef5\u1ef7\u1ef9]/i
115
-
116
- /**
117
- * Second-language words spelled in ASCII, as a route or folder segment writes them.
118
- *
119
- * A path cannot carry diacritics, so a name written with them reaches the filesystem unaccented
120
- * (`cap-phat` below) and every accent-based check passes it. The segments below are the ones this product actually produced.
121
- * A list is the honest instrument here: guessing at Vietnamese-shaped ASCII would refuse `dang`
122
- * in `dangerous` and `cap` in `capacity`, and a rule that fires on English words is one a
123
- * repository turns off.
124
- */
125
- const ROMANISED = [
126
- "dang-nhap", "dang-ky", "dang-xuat", "cap-phat", "khoa-hoc", "hoc-vien", "gioi-thieu",
127
- "lien-he", "tai-khoan", "thanh-toan", "gio-hang", "tin-tuc", "san-pham", "bang-gia",
128
- "dieu-khoan", "chinh-sach", "trang-chu", "quan-ly", "cai-dat", "ho-so",
129
- ]
130
-
131
- /** A route segment or folder name, lowercased, from a normalized path. */
132
- const segmentsOf = (filename) =>
133
- String(filename || "").replace(/\\/g, "/").toLowerCase().split("/").filter(Boolean)
134
-
135
- /**
136
- * A path names things in one language: the one every reader of this repository shares.
137
- *
138
- * WHY A PATH AND NOT ONLY THE SOURCE. `no-second-language-in-source` reads identifiers, comments
139
- * and strings, and cannot see the file it is reading. So a route could be `app/cap-phat/page.tsx`
140
- * with every identifier inside it in English, and nothing said a word - while the URL, the import
141
- * specifier, the folder in every editor sidebar and the path in every stack trace stayed in a
142
- * language half the readers do not have.
143
- *
144
- * A ROUTE SEGMENT IS ALSO A PUBLIC NAME. It is the address a customer sees and a support ticket
145
- * quotes, so this is not only an authoring question: the product's own URLs stop being readable to
146
- * anybody outside one language.
147
- */
148
- const noSecondLanguageInPath = {
149
- meta: {
150
- type: "problem",
151
- docs: { description: "File and route names are written in the repository's one shared language." },
152
- schema: [],
153
- messages: {
154
- path:
155
- "`{{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.",
156
- },
157
- },
158
- create(context) {
159
- const offending = segmentsOf(hfsOf(context).relative(fileOf(context))).find(
160
- (segment) => SECOND_LANGUAGE_PATH.test(segment) || ROMANISED.includes(segment.replace(/[()[\]]/g, "")),
161
- )
162
- if (!offending) return {}
163
- return {
164
- Program(node) {
165
- context.report({ node, messageId: "path", data: { segment: offending } })
166
- },
167
- }
168
- },
169
- }
170
-
171
- export const rules = {
172
- "prefer-arrow-export": preferArrowExport,
173
- "handler-on-prefix": handlerOnPrefix,
174
- "no-second-language-in-path": noSecondLanguageInPath,
175
- "no-direct-const-alias": noDirectConstAlias,
176
- }
177
-
178
- /**
179
- * The level this law asks for, as the plugin's own opinion.
180
- *
181
- * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on.
182
- * Both rules here are mechanical and auto-correctable by hand in seconds, so a repository adopting
183
- * them with existing debt should burn the count down rather than live at `warn`.
184
- */
185
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
@@ -1,108 +0,0 @@
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
-
17
- /** Intrinsic elements a reader operates, that the grammar renders for the product. */
18
- const NATIVE = new Set(["select", "input", "textarea", "button"])
19
-
20
- /** No native form control in product source. */
21
- export const noNativeFormControl = {
22
- meta: {
23
- type: "problem",
24
- docs: { description: "Use the grammar's renderers; never a bare select, input, textarea or button." },
25
- schema: [],
26
- messages: {
27
- native:
28
- "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.",
29
- },
30
- },
31
- create(context) {
32
- return {
33
- JSXOpeningElement(node) {
34
- if (node.name.type !== "JSXIdentifier" || !NATIVE.has(node.name.name)) return
35
- context.report({ node, messageId: "native", data: { tag: node.name.name } })
36
- },
37
- }
38
- },
39
- }
40
-
41
- // -- NATIVE-2 --------------------------------------------------------------------------------------
42
-
43
- /** No bare `<img>`; the framework's image owns size, format, lazy loading and layout shift. */
44
- export const noNativeImg = {
45
- meta: {
46
- type: "problem",
47
- docs: { description: "Use `next/image`, never a bare `<img>`." },
48
- schema: [],
49
- messages: {
50
- img:
51
- "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.",
52
- },
53
- },
54
- create(context) {
55
- return {
56
- JSXOpeningElement(node) {
57
- if (node.name.type === "JSXIdentifier" && node.name.name === "img") context.report({ node, messageId: "img" })
58
- },
59
- }
60
- },
61
- }
62
-
63
- // -- NATIVE-3 --------------------------------------------------------------------------------------
64
-
65
- /** Every `next/image` reserves its space: a size, or `fill` inside a sized parent with `sizes`. */
66
- export const imageHasSize = {
67
- meta: {
68
- type: "problem",
69
- docs: { description: "`next/image` carries `width` and `height`, or `fill` with `sizes`." },
70
- schema: [],
71
- messages: {
72
- size:
73
- "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.",
74
- sizes:
75
- "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\"`.",
76
- },
77
- },
78
- create(context) {
79
- const locals = new Set()
80
- return {
81
- ImportDeclaration(node) {
82
- if (node.source.value !== "next/image") return
83
- for (const specifier of node.specifiers) if (specifier.type === "ImportDefaultSpecifier") locals.add(specifier.local.name)
84
- },
85
- JSXOpeningElement(node) {
86
- if (node.name.type !== "JSXIdentifier" || !locals.has(node.name.name)) return
87
- // A spread may carry the size; the rule judges what it can read.
88
- if (node.attributes.some((entry) => entry.type === "JSXSpreadAttribute")) return
89
- const fill = attribute(node, "fill")
90
- if (fill) {
91
- if (!attribute(node, "sizes")) context.report({ node, messageId: "sizes" })
92
- return
93
- }
94
- if (!attribute(node, "width") || !attribute(node, "height")) context.report({ node, messageId: "size" })
95
- },
96
- }
97
- },
98
- }
99
-
100
- /** The rules this law contributes to the plugin. */
101
- export const rules = {
102
- "no-native-form-control": noNativeFormControl,
103
- "no-native-img": noNativeImg,
104
- "image-has-size": imageHasSize,
105
- }
106
-
107
- /** Every rule is an error. */
108
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))