@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/lists.mjs ADDED
@@ -0,0 +1,197 @@
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
+ import { isSpecFile } from "./lib/scope.mjs"
20
+
21
+ /** `.map(callback)` where the callback is written inline; the call node, else null. */
22
+ const mapCallback = (call) => {
23
+ if (call.type !== "CallExpression" || call.callee.type !== "MemberExpression" || call.callee.computed) return null
24
+ if (call.callee.property.type !== "Identifier" || call.callee.property.name !== "map") return null
25
+ const receiver = calleeName(call.callee.object)
26
+ if (receiver !== null && /(?:^|\.)Children$/.test(receiver)) return null
27
+ const callback = call.arguments[0]
28
+ return callback && isFunction(callback) ? callback : null
29
+ }
30
+
31
+ /** The JSX a callback returns: its expression body, or the value of each of its own `return` statements. */
32
+ const returnedJsx = (callback) => {
33
+ const found = []
34
+ const take = (node) => {
35
+ if (!node) return
36
+ if (node.type === "JSXElement" || node.type === "JSXFragment") found.push(node)
37
+ else if (node.type === "ConditionalExpression") {
38
+ take(node.consequent)
39
+ take(node.alternate)
40
+ } else if (node.type === "LogicalExpression") take(node.right)
41
+ }
42
+ if (callback.body.type !== "BlockStatement") {
43
+ take(callback.body)
44
+ return found
45
+ }
46
+ const scan = (node) => {
47
+ if (!node || typeof node.type !== "string") return
48
+ if (node.type === "ReturnStatement") return take(node.argument)
49
+ if (node.type === "BlockStatement") node.body.forEach(scan)
50
+ else if (node.type === "IfStatement") {
51
+ scan(node.consequent)
52
+ scan(node.alternate)
53
+ } else if (node.type === "SwitchStatement") node.cases.forEach((entry) => entry.consequent.forEach(scan))
54
+ else if (node.type === "TryStatement") {
55
+ scan(node.block)
56
+ scan(node.handler?.body)
57
+ }
58
+ }
59
+ scan(callback.body)
60
+ return found
61
+ }
62
+
63
+ /**
64
+ * True when the rows have no identity to lose: the receiver is an array written in place, or the callback
65
+ * ignores the item (`(_, index) => ...`), which is how a skeleton or a placeholder count is rendered.
66
+ */
67
+ const isPositional = (call, callback) => {
68
+ if (call.callee.object.type === "ArrayExpression") return true
69
+ const item = callback.params[0]
70
+ return Boolean(item) && item.type === "Identifier" && item.name.startsWith("_")
71
+ }
72
+
73
+ // -- LISTS-1 ---------------------------------------------------------------------------------------
74
+
75
+ /** Every element a `.map` callback returns carries a `key`. */
76
+ export const listItemHasKey = {
77
+ meta: {
78
+ type: "problem",
79
+ docs: { description: "An element returned from `.map` carries a `key`." },
80
+ schema: [],
81
+ messages: {
82
+ missing:
83
+ "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}`.",
84
+ fragment:
85
+ "A `<>...</>` returned from `.map` cannot carry a key. Use `<Fragment key={...}>` (import `Fragment` from `react`) so each row has an identity.",
86
+ },
87
+ },
88
+ create(context) {
89
+ if (isSpecFile(context.filename || context.getFilename())) return {}
90
+ return {
91
+ CallExpression(call) {
92
+ const callback = mapCallback(call)
93
+ if (!callback) return
94
+ for (const element of returnedJsx(callback)) {
95
+ if (element.type === "JSXFragment") context.report({ node: element, messageId: "fragment" })
96
+ else if (!attribute(element.openingElement, "key")) context.report({ node: element.openingElement, messageId: "missing" })
97
+ }
98
+ },
99
+ }
100
+ },
101
+ }
102
+
103
+ // -- LISTS-2 ---------------------------------------------------------------------------------------
104
+
105
+ /** The key is never the position, and never made up on every render. */
106
+ export const noIndexKey = {
107
+ meta: {
108
+ type: "problem",
109
+ docs: { description: "A list key is stable data identity, never the map index or a random value." },
110
+ schema: [],
111
+ messages: {
112
+ index:
113
+ "`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}`).",
114
+ random:
115
+ "`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.",
116
+ },
117
+ },
118
+ create(context) {
119
+ if (isSpecFile(context.filename || context.getFilename())) return {}
120
+ const source = context.sourceCode ?? context.getSourceCode()
121
+ return {
122
+ CallExpression(call) {
123
+ const callback = mapCallback(call)
124
+ if (!callback) return
125
+ const indexParam = callback.params[1]
126
+ const indexName = indexParam && indexParam.type === "Identifier" ? indexParam.name : null
127
+ for (const element of returnedJsx(callback)) {
128
+ if (element.type !== "JSXElement") continue
129
+ const key = attribute(element.openingElement, "key")
130
+ const value = attributeValue(key)
131
+ if (!value) continue
132
+ const text = source.getText(value)
133
+ if (/\b(?:Math\.random|Date\.now|randomUUID|nanoid|uuid)\b/.test(text)) {
134
+ context.report({ node: key, messageId: "random" })
135
+ } else if (indexName && !isPositional(call, callback) && mentions(source, value, indexName)) {
136
+ context.report({ node: key, messageId: "index" })
137
+ }
138
+ }
139
+ },
140
+ }
141
+ },
142
+ }
143
+
144
+ // -- LISTS-3 ---------------------------------------------------------------------------------------
145
+
146
+ /** True for `<Component>` and `<a.B>`, false for a DOM tag: only a component can be memoised. */
147
+ const isComponentTag = (opening) => {
148
+ if (opening.name.type === "JSXMemberExpression") return true
149
+ return opening.name.type === "JSXIdentifier" && /^[A-Z]/.test(opening.name.name)
150
+ }
151
+
152
+ /** True when the nearest function around `node` is a `.map` callback. */
153
+ const insideMapCallback = (node) => {
154
+ for (let current = node.parent; current; current = current.parent) {
155
+ if (!isFunction(current)) continue
156
+ return current.parent?.type === "CallExpression" && current.parent.arguments[0] === current && mapCallback(current.parent) === current
157
+ }
158
+ return false
159
+ }
160
+
161
+ /** A component in a mapped list gets no object or array literal for a prop. */
162
+ export const noInlineLiteralPropInList = {
163
+ meta: {
164
+ type: "problem",
165
+ docs: { description: "No object or array literal as a prop of a component rendered inside `.map`." },
166
+ schema: [],
167
+ messages: {
168
+ literal:
169
+ "`{{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.",
170
+ },
171
+ },
172
+ create(context) {
173
+ if (isSpecFile(context.filename || context.getFilename())) return {}
174
+ return {
175
+ JSXAttribute(node) {
176
+ const opening = node.parent
177
+ if (!opening || opening.type !== "JSXOpeningElement" || !isComponentTag(opening)) return
178
+ if (node.name.type !== "JSXIdentifier" || node.name.name === "key") return
179
+ const value = attributeValue(node)
180
+ if (!value || (value.type !== "ObjectExpression" && value.type !== "ArrayExpression")) return
181
+ if (value.type === "ArrayExpression" && value.elements.length === 0) return
182
+ if (!insideMapCallback(node)) return
183
+ context.report({ node, messageId: "literal", data: { prop: node.name.name } })
184
+ },
185
+ }
186
+ },
187
+ }
188
+
189
+ /** The rules this law contributes to the plugin. */
190
+ export const rules = {
191
+ "list-item-has-key": listItemHasKey,
192
+ "no-index-key": noIndexKey,
193
+ "no-inline-literal-prop-in-list": noInlineLiteralPropInList,
194
+ }
195
+
196
+ /** Every rule is an error. */
197
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/loading.mjs CHANGED
@@ -1,164 +1,163 @@
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
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
16
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
17
-
18
- /** The component tree; a placeholder in a test fixture is a fixture, not a second tree. */
19
- const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
20
-
21
- /** A twin test may build a resting shape by hand to assert against it. */
22
- const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
23
-
24
- /** Product component source these rules govern. */
25
- const isGoverned = (filename) => isComponentFile(filename) && !isTestFile(filename)
26
-
27
- /** A flag that means "waiting", in the spellings this codebase uses. */
28
- const WAITING_FLAG = /\bis(?:Loading|Skeleton|Pending)\b/
29
-
30
- /** The name of a component whose whole job is to mirror another one's shape. */
31
- const TWIN_NAME = /^[A-Za-z0-9]*Skeleton$/
32
-
33
- /** JSX element name, including `Foo.Bar`. */
34
- const jsxElementName = (opening) => {
35
- const name = opening && opening.name
36
- if (!name) return null
37
- if (name.type === "JSXIdentifier") return name.name
38
- if (name.type === "JSXMemberExpression" && name.object && name.property) {
39
- return `${name.object.name}.${name.property.name}`
40
- }
41
- return null
42
- }
43
-
44
- // -- LOADING-1 -------------------------------------------------------------------------------------
45
-
46
- /** No component exists whose job is to mirror another component's shape. */
47
- export const noRestingTwinComponent = {
48
- meta: {
49
- type: "problem",
50
- docs: { description: "No component whose whole job is to mirror another one's shape." },
51
- schema: [],
52
- messages: {
53
- twin:
54
- "`{{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.",
55
- },
56
- },
57
- create(context) {
58
- const file = normalizePath(context.filename || context.getFilename())
59
- if (!isGoverned(file)) return {}
60
- const hit = file.match(/\/([A-Za-z0-9]*Skeleton)\/index\.tsx?$/) || file.match(/\/([A-Za-z0-9]*Skeleton)\.tsx?$/)
61
- if (!hit || !TWIN_NAME.test(hit[1])) return {}
62
- return {
63
- Program(node) {
64
- context.report({ node, messageId: "twin", data: { name: hit[1] } })
65
- },
66
- }
67
- },
68
- }
69
-
70
- // -- LOADING-1, handed in from outside --------------------------------------------------------------
71
-
72
- /** A ready-made placeholder is the same second tree, passed as a prop. */
73
- export const noPlaceholderProp = {
74
- meta: {
75
- type: "problem",
76
- docs: { description: "No prop hands a component a ready-made resting shape to render." },
77
- schema: [],
78
- messages: {
79
- prop:
80
- "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.",
81
- import:
82
- "`{{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.",
83
- },
84
- },
85
- create(context) {
86
- if (!isGoverned(context.filename || context.getFilename())) return {}
87
- return {
88
- JSXAttribute(node) {
89
- const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
90
- if (name !== "skeleton" && name !== "placeholder" && name !== "fallback") return
91
- const value = node.value
92
- if (!value || value.type !== "JSXExpressionContainer") return
93
- const expression = value.expression
94
- if (expression.type === "JSXElement" || expression.type === "JSXFragment") {
95
- context.report({ node, messageId: "prop" })
96
- }
97
- },
98
- ImportDeclaration(node) {
99
- const source = node.source && node.source.value
100
- if (typeof source !== "string" || !/^\.\.?\//.test(source)) return
101
- for (const specifier of node.specifiers || []) {
102
- const local = specifier.local && specifier.local.name
103
- // A bare `Skeleton` is the primitive a component rests WITH, not a twin of one.
104
- if (!local || local === "Skeleton" || !TWIN_NAME.test(local)) continue
105
- context.report({ node: specifier, messageId: "import", data: { name: local } })
106
- }
107
- },
108
- }
109
- },
110
- }
111
-
112
- // -- LOADING-2 -------------------------------------------------------------------------------------
113
-
114
- /** A caller never picks between a resting shape and a real one. */
115
- export const noRestingBranchAtCallSite = {
116
- meta: {
117
- type: "problem",
118
- docs: { description: "A waiting flag must not choose between two different components." },
119
- schema: [],
120
- messages: {
121
- branch:
122
- "`{{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.",
123
- },
124
- },
125
- create(context) {
126
- if (!isGoverned(context.filename || context.getFilename())) return {}
127
- const source = context.sourceCode || context.getSourceCode()
128
- /** Root element name of one arm, or null when the arm is not an element. */
129
- const armName = (expression) => {
130
- if (!expression) return null
131
- if (expression.type === "JSXElement") return jsxElementName(expression.openingElement)
132
- if (expression.type === "JSXFragment") return "<>"
133
- return null
134
- }
135
- return {
136
- ConditionalExpression(node) {
137
- const test = source.getText(node.test)
138
- if (!WAITING_FLAG.test(test)) return
139
- const left = armName(node.consequent)
140
- const right = armName(node.alternate)
141
- // Both arms must be real elements AND differ. `null` on one side is LOADING-5 - a control
142
- // that has nowhere to go yet - and that is correct rather than a second tree.
143
- if (!left || !right || left === right) return
144
- context.report({ node, messageId: "branch", data: { flag: test.trim().slice(0, 40) } })
145
- },
146
- }
147
- },
148
- }
149
-
150
- /** The rules this law contributes to the plugin. */
151
- export const rules = {
152
- "no-resting-twin-component": noRestingTwinComponent,
153
- "no-placeholder-prop": noPlaceholderProp,
154
- "no-resting-branch-at-call-site": noRestingBranchAtCallSite,
155
- }
156
-
157
- /**
158
- * The level this law asks for, as the plugin's own opinion.
159
- *
160
- * A repository adopting these with history should expect the twin rule to report a folder at a
161
- * time: hand-kept placeholder trees arrive in families, one per screen, and each is a real shape
162
- * that has to be folded back into the component it copies rather than deleted.
163
- */
164
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
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 { normalizePath } from "./lib/path.mjs"
16
+
17
+ /** The component tree; a placeholder in a test fixture is a fixture, not a second tree. */
18
+ const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
19
+
20
+ /** A twin test may build a resting shape by hand to assert against it. */
21
+ const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
22
+
23
+ /** Product component source these rules govern. */
24
+ const isGoverned = (filename) => isComponentFile(filename) && !isTestFile(filename)
25
+
26
+ /** A flag that means "waiting", in the spellings this codebase uses. */
27
+ const WAITING_FLAG = /\bis(?:Loading|Skeleton|Pending)\b/
28
+
29
+ /** The name of a component whose whole job is to mirror another one's shape. */
30
+ const TWIN_NAME = /^[A-Za-z0-9]*Skeleton$/
31
+
32
+ /** JSX element name, including `Foo.Bar`. */
33
+ const jsxElementName = (opening) => {
34
+ const name = opening && opening.name
35
+ if (!name) return null
36
+ if (name.type === "JSXIdentifier") return name.name
37
+ if (name.type === "JSXMemberExpression" && name.object && name.property) {
38
+ return `${name.object.name}.${name.property.name}`
39
+ }
40
+ return null
41
+ }
42
+
43
+ // -- LOADING-1 -------------------------------------------------------------------------------------
44
+
45
+ /** No component exists whose job is to mirror another component's shape. */
46
+ export const noRestingTwinComponent = {
47
+ meta: {
48
+ type: "problem",
49
+ docs: { description: "No component whose whole job is to mirror another one's shape." },
50
+ schema: [],
51
+ messages: {
52
+ twin:
53
+ "`{{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.",
54
+ },
55
+ },
56
+ create(context) {
57
+ const file = normalizePath(context.filename || context.getFilename())
58
+ if (!isGoverned(file)) return {}
59
+ const hit = file.match(/\/([A-Za-z0-9]*Skeleton)\/index\.tsx?$/) || file.match(/\/([A-Za-z0-9]*Skeleton)\.tsx?$/)
60
+ if (!hit || !TWIN_NAME.test(hit[1])) return {}
61
+ return {
62
+ Program(node) {
63
+ context.report({ node, messageId: "twin", data: { name: hit[1] } })
64
+ },
65
+ }
66
+ },
67
+ }
68
+
69
+ // -- LOADING-1, handed in from outside --------------------------------------------------------------
70
+
71
+ /** A ready-made placeholder is the same second tree, passed as a prop. */
72
+ export const noPlaceholderProp = {
73
+ meta: {
74
+ type: "problem",
75
+ docs: { description: "No prop hands a component a ready-made resting shape to render." },
76
+ schema: [],
77
+ messages: {
78
+ prop:
79
+ "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.",
80
+ import:
81
+ "`{{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.",
82
+ },
83
+ },
84
+ create(context) {
85
+ if (!isGoverned(context.filename || context.getFilename())) return {}
86
+ return {
87
+ JSXAttribute(node) {
88
+ const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
89
+ if (name !== "skeleton" && name !== "placeholder" && name !== "fallback") return
90
+ const value = node.value
91
+ if (!value || value.type !== "JSXExpressionContainer") return
92
+ const expression = value.expression
93
+ if (expression.type === "JSXElement" || expression.type === "JSXFragment") {
94
+ context.report({ node, messageId: "prop" })
95
+ }
96
+ },
97
+ ImportDeclaration(node) {
98
+ const source = node.source && node.source.value
99
+ if (typeof source !== "string" || !/^\.\.?\//.test(source)) return
100
+ for (const specifier of node.specifiers || []) {
101
+ const local = specifier.local && specifier.local.name
102
+ // A bare `Skeleton` is the primitive a component rests WITH, not a twin of one.
103
+ if (!local || local === "Skeleton" || !TWIN_NAME.test(local)) continue
104
+ context.report({ node: specifier, messageId: "import", data: { name: local } })
105
+ }
106
+ },
107
+ }
108
+ },
109
+ }
110
+
111
+ // -- LOADING-2 -------------------------------------------------------------------------------------
112
+
113
+ /** A caller never picks between a resting shape and a real one. */
114
+ export const noRestingBranchAtCallSite = {
115
+ meta: {
116
+ type: "problem",
117
+ docs: { description: "A waiting flag must not choose between two different components." },
118
+ schema: [],
119
+ messages: {
120
+ branch:
121
+ "`{{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.",
122
+ },
123
+ },
124
+ create(context) {
125
+ if (!isGoverned(context.filename || context.getFilename())) return {}
126
+ const source = context.sourceCode || context.getSourceCode()
127
+ /** Root element name of one arm, or null when the arm is not an element. */
128
+ const armName = (expression) => {
129
+ if (!expression) return null
130
+ if (expression.type === "JSXElement") return jsxElementName(expression.openingElement)
131
+ if (expression.type === "JSXFragment") return "<>"
132
+ return null
133
+ }
134
+ return {
135
+ ConditionalExpression(node) {
136
+ const test = source.getText(node.test)
137
+ if (!WAITING_FLAG.test(test)) return
138
+ const left = armName(node.consequent)
139
+ const right = armName(node.alternate)
140
+ // Both arms must be real elements AND differ. `null` on one side is LOADING-5 - a control
141
+ // that has nowhere to go yet - and that is correct rather than a second tree.
142
+ if (!left || !right || left === right) return
143
+ context.report({ node, messageId: "branch", data: { flag: test.trim().slice(0, 40) } })
144
+ },
145
+ }
146
+ },
147
+ }
148
+
149
+ /** The rules this law contributes to the plugin. */
150
+ export const rules = {
151
+ "no-resting-twin-component": noRestingTwinComponent,
152
+ "no-placeholder-prop": noPlaceholderProp,
153
+ "no-resting-branch-at-call-site": noRestingBranchAtCallSite,
154
+ }
155
+
156
+ /**
157
+ * The level this law asks for, as the plugin's own opinion.
158
+ *
159
+ * A repository adopting these with history should expect the twin rule to report a folder at a
160
+ * time: hand-kept placeholder trees arrive in families, one per screen, and each is a real shape
161
+ * that has to be folded back into the component it copies rather than deleted.
162
+ */
163
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))