@starci/eslint-canon-fe 1.1.0 → 1.3.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.
- package/file-layout.mjs +22 -2
- package/package.json +1 -1
- package/props-and-slots.mjs +452 -339
- package/vendor-boundary.mjs +549 -510
package/props-and-slots.mjs
CHANGED
|
@@ -1,339 +1,452 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `props-and-slots.md`.
|
|
3
|
-
*
|
|
4
|
-
* ALMOST ALL OF THIS LAW IS HELD BY A TYPE, not by a rule. The slot aliases in `props.ts` are the
|
|
5
|
-
* fence: a fifth slot does not fail review, it fails to compile, and there is nothing left for a
|
|
6
|
-
* rule to patrol once the shape itself refuses. That is the stronger arrangement and it is why this
|
|
7
|
-
* module is small.
|
|
8
|
-
*
|
|
9
|
-
* What a type cannot see is a shape with no NAME. An inline object type at the parameter satisfies
|
|
10
|
-
* every constraint the alias imposes and is still wrong, because the wrongness is not about which
|
|
11
|
-
* fields exist - it is that nothing else can refer to them. A compiler has no opinion about whether
|
|
12
|
-
* a shape is findable.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier, isTestFile } from "./contract.mjs"
|
|
16
|
-
|
|
17
|
-
/** True when a parameter type contains an anonymous object shape, including inside intersections. */
|
|
18
|
-
const isInlineObjectType = (node) => {
|
|
19
|
-
if (!node) return false
|
|
20
|
-
if (node.type === "TSTypeLiteral") return true
|
|
21
|
-
if (node.type === "TSParenthesizedType") return isInlineObjectType(node.typeAnnotation)
|
|
22
|
-
if (node.type === "TSIntersectionType" || node.type === "TSUnionType") {
|
|
23
|
-
return (node.types || []).some(isInlineObjectType)
|
|
24
|
-
}
|
|
25
|
-
return false
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// -- SLOTS-3 ---------------------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
/** A parameter's complete shape is named in the module, never assembled at the parameter. */
|
|
31
|
-
export const noInlineParameterType = {
|
|
32
|
-
meta: {
|
|
33
|
-
type: "suggestion",
|
|
34
|
-
docs: { description: "A parameter takes one named props type, not an inline or intersected object shape." },
|
|
35
|
-
schema: [],
|
|
36
|
-
messages: {
|
|
37
|
-
inline:
|
|
38
|
-
"This parameter's shape is written inline, so it has no name and nothing can refer to it - not an import, not the twin that tests this file, not somebody looking for what this component accepts. Declare it in the module and name it here. The cost is one line; the difference is between a contract and a signature.",
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
create(context) {
|
|
42
|
-
const checkParams = (node) => {
|
|
43
|
-
for (const param of node.params || []) {
|
|
44
|
-
if (!param) continue
|
|
45
|
-
const declared = param.typeAnnotation?.typeAnnotation
|
|
46
|
-
if (!isInlineObjectType(declared)) continue
|
|
47
|
-
context.report({ node: param.typeAnnotation || param, messageId: "inline" })
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
ArrowFunctionExpression: checkParams,
|
|
52
|
-
FunctionExpression: checkParams,
|
|
53
|
-
FunctionDeclaration: checkParams,
|
|
54
|
-
TSEmptyBodyFunctionExpression: checkParams,
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// -- SLOTS-4 ---------------------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* True for a component file the slot fence governs.
|
|
63
|
-
*
|
|
64
|
-
* TWO EXEMPTIONS AND ONE LAYOUT FIX, all of which this predicate got wrong at once.
|
|
65
|
-
*
|
|
66
|
-
* It read `/src/components/` as a literal, so in a monorepo - where the same tier sits at
|
|
67
|
-
* `packages/ui/src/*` - the rule applied to NOTHING. That repository reported no violations, which
|
|
68
|
-
* looked like compliance and was silence: the fence was not holding anywhere in it.
|
|
69
|
-
*
|
|
70
|
-
* The registry table is exempt because `ContractSpec.children` is not a children hole; it is the
|
|
71
|
-
* NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
|
|
72
|
-
* slot to stop describing what it admits instead.
|
|
73
|
-
*
|
|
74
|
-
* @param filename - the file being linted.
|
|
75
|
-
*/
|
|
76
|
-
const isGoverned = (filename) => {
|
|
77
|
-
const path = String(filename || "").replace(/\\/g, "/")
|
|
78
|
-
if (isContractTableFile(path)) return false
|
|
79
|
-
/*
|
|
80
|
-
* The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
|
|
81
|
-
* catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
|
|
82
|
-
* matches every file under `src/`, which pulls routed pages into a rule about component slots and
|
|
83
|
-
* reports a page for taking children - the one thing a page legitimately does.
|
|
84
|
-
*/
|
|
85
|
-
return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
if (!
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (!
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `props-and-slots.md`.
|
|
3
|
+
*
|
|
4
|
+
* ALMOST ALL OF THIS LAW IS HELD BY A TYPE, not by a rule. The slot aliases in `props.ts` are the
|
|
5
|
+
* fence: a fifth slot does not fail review, it fails to compile, and there is nothing left for a
|
|
6
|
+
* rule to patrol once the shape itself refuses. That is the stronger arrangement and it is why this
|
|
7
|
+
* module is small.
|
|
8
|
+
*
|
|
9
|
+
* What a type cannot see is a shape with no NAME. An inline object type at the parameter satisfies
|
|
10
|
+
* every constraint the alias imposes and is still wrong, because the wrongness is not about which
|
|
11
|
+
* fields exist - it is that nothing else can refer to them. A compiler has no opinion about whether
|
|
12
|
+
* a shape is findable.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { COMPONENT_ROOTS, isContractTableFile, isInComponentTier, isTestFile } from "./contract.mjs"
|
|
16
|
+
|
|
17
|
+
/** True when a parameter type contains an anonymous object shape, including inside intersections. */
|
|
18
|
+
const isInlineObjectType = (node) => {
|
|
19
|
+
if (!node) return false
|
|
20
|
+
if (node.type === "TSTypeLiteral") return true
|
|
21
|
+
if (node.type === "TSParenthesizedType") return isInlineObjectType(node.typeAnnotation)
|
|
22
|
+
if (node.type === "TSIntersectionType" || node.type === "TSUnionType") {
|
|
23
|
+
return (node.types || []).some(isInlineObjectType)
|
|
24
|
+
}
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// -- SLOTS-3 ---------------------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** A parameter's complete shape is named in the module, never assembled at the parameter. */
|
|
31
|
+
export const noInlineParameterType = {
|
|
32
|
+
meta: {
|
|
33
|
+
type: "suggestion",
|
|
34
|
+
docs: { description: "A parameter takes one named props type, not an inline or intersected object shape." },
|
|
35
|
+
schema: [],
|
|
36
|
+
messages: {
|
|
37
|
+
inline:
|
|
38
|
+
"This parameter's shape is written inline, so it has no name and nothing can refer to it - not an import, not the twin that tests this file, not somebody looking for what this component accepts. Declare it in the module and name it here. The cost is one line; the difference is between a contract and a signature.",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
const checkParams = (node) => {
|
|
43
|
+
for (const param of node.params || []) {
|
|
44
|
+
if (!param) continue
|
|
45
|
+
const declared = param.typeAnnotation?.typeAnnotation
|
|
46
|
+
if (!isInlineObjectType(declared)) continue
|
|
47
|
+
context.report({ node: param.typeAnnotation || param, messageId: "inline" })
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
ArrowFunctionExpression: checkParams,
|
|
52
|
+
FunctionExpression: checkParams,
|
|
53
|
+
FunctionDeclaration: checkParams,
|
|
54
|
+
TSEmptyBodyFunctionExpression: checkParams,
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// -- SLOTS-4 ---------------------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* True for a component file the slot fence governs.
|
|
63
|
+
*
|
|
64
|
+
* TWO EXEMPTIONS AND ONE LAYOUT FIX, all of which this predicate got wrong at once.
|
|
65
|
+
*
|
|
66
|
+
* It read `/src/components/` as a literal, so in a monorepo - where the same tier sits at
|
|
67
|
+
* `packages/ui/src/*` - the rule applied to NOTHING. That repository reported no violations, which
|
|
68
|
+
* looked like compliance and was silence: the fence was not holding anywhere in it.
|
|
69
|
+
*
|
|
70
|
+
* The registry table is exempt because `ContractSpec.children` is not a children hole; it is the
|
|
71
|
+
* NAMED CHILD GRAMMAR that replaces one. Reporting it asks the file that abolished the anonymous
|
|
72
|
+
* slot to stop describing what it admits instead.
|
|
73
|
+
*
|
|
74
|
+
* @param filename - the file being linted.
|
|
75
|
+
*/
|
|
76
|
+
const isGoverned = (filename) => {
|
|
77
|
+
const path = String(filename || "").replace(/\\/g, "/")
|
|
78
|
+
if (isContractTableFile(path)) return false
|
|
79
|
+
/*
|
|
80
|
+
* The bare `src` root is dropped here, and only here. `COMPONENT_ROOTS` carries it as a
|
|
81
|
+
* catch-all so a reader that walks up from any file still finds the table; used as a FENCE it
|
|
82
|
+
* matches every file under `src/`, which pulls routed pages into a rule about component slots and
|
|
83
|
+
* reports a page for taking children - the one thing a page legitimately does.
|
|
84
|
+
*/
|
|
85
|
+
return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when a function's whole body does nothing but turn `children` into a component
|
|
90
|
+
* reference and hand that reference to exactly one other component under a named prop.
|
|
91
|
+
*
|
|
92
|
+
* THIS IS SLOTS-4'S OWN EXCEPTION, READ FROM SHAPE, NOT FROM A NAME. The rule's message
|
|
93
|
+
* already promises it: a framework route layout may receive `ReactNode` outside the
|
|
94
|
+
* component tier, but must close it into a named projection before a component sees it.
|
|
95
|
+
* A shell that crosses `children` over a client/server boundary - wrapping it in
|
|
96
|
+
* `useCallback` so it survives as a function reference rather than an unserialisable
|
|
97
|
+
* inline component - IS that closing act; there is no contract key to name because the
|
|
98
|
+
* shell is deliberately generic across callers. What makes the exception SAFE to keep
|
|
99
|
+
* narrow is that the shape disqualifies itself the moment the body does one more thing:
|
|
100
|
+
* a second return, a conditional, a data read, an element of its own, or a forwarded
|
|
101
|
+
* value that never passes through the closure all fall through to the ordinary report.
|
|
102
|
+
*
|
|
103
|
+
* - Every statement but the last is a `const x = ...` naming either a plain member/param
|
|
104
|
+
* read (`const Frame = input.frame`) or a `useCallback(...)` call - nothing else.
|
|
105
|
+
* - The last statement is the only `return`, and it returns exactly one JSX element.
|
|
106
|
+
* - That element's attributes only spread or forward existing bindings, and at least one
|
|
107
|
+
* of them carries the `useCallback` result - the children-derived reference - by name.
|
|
108
|
+
*/
|
|
109
|
+
const isChildrenBoundaryConverter = (fn) => {
|
|
110
|
+
if (!fn || fn.body?.type !== "BlockStatement") return false
|
|
111
|
+
const statements = fn.body.body
|
|
112
|
+
let closureVar = null
|
|
113
|
+
let returnNode = null
|
|
114
|
+
|
|
115
|
+
for (const statement of statements) {
|
|
116
|
+
if (statement.type === "ReturnStatement") {
|
|
117
|
+
if (returnNode) return false // a second return means this does more than one thing
|
|
118
|
+
returnNode = statement.argument
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
if (statement.type !== "VariableDeclaration" || statement.declarations.length !== 1) return false
|
|
122
|
+
const [declarator] = statement.declarations
|
|
123
|
+
const init = declarator.init
|
|
124
|
+
if (declarator.id.type !== "Identifier" || !init) return false
|
|
125
|
+
if (init.type === "MemberExpression" || init.type === "Identifier") continue // a plain prop read
|
|
126
|
+
if (init.type === "CallExpression" && init.callee?.type === "Identifier" && init.callee.name === "useCallback") {
|
|
127
|
+
const callback = init.arguments[0]
|
|
128
|
+
if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
closureVar = declarator.id.name
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
return false // any other statement is the component doing something of its own
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!closureVar || !returnNode || returnNode.type !== "JSXElement") return false
|
|
138
|
+
|
|
139
|
+
let carriesClosure = false
|
|
140
|
+
for (const attribute of returnNode.openingElement.attributes || []) {
|
|
141
|
+
if (attribute.type === "JSXSpreadAttribute") continue
|
|
142
|
+
if (attribute.type !== "JSXAttribute" || !attribute.value) return false
|
|
143
|
+
const raw = attribute.value.type === "JSXExpressionContainer" ? attribute.value.expression : attribute.value
|
|
144
|
+
const value = raw?.type === "TSAsExpression" ? raw.expression : raw
|
|
145
|
+
if (value?.type !== "Identifier") return false
|
|
146
|
+
if (value.name === closureVar) carriesClosure = true
|
|
147
|
+
}
|
|
148
|
+
return carriesClosure
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A container takes `contract` and `render`, never `children`.
|
|
153
|
+
*
|
|
154
|
+
* THE TYPE CANNOT CATCH THIS ONE, which is why it is here. `props.ts` refuses a fourth slot on the
|
|
155
|
+
* aliases it defines, but nothing stops a file declaring its own props shape by hand and putting
|
|
156
|
+
* `children` in it. What the alias makes unrepresentable, a hand-written interface makes ordinary.
|
|
157
|
+
*
|
|
158
|
+
* Vendor mechanics branches still take a named contract/render projection. Framework route
|
|
159
|
+
* layouts sit outside the component tier and close their ReactNode into that projection before it
|
|
160
|
+
* reaches a component.
|
|
161
|
+
*
|
|
162
|
+
* ONE CLOSED EXCEPTION LIVES INSIDE THE COMPONENT TIER: a boundary-converter shell whose
|
|
163
|
+
* whole body matches {@link isChildrenBoundaryConverter}. It is closed by shape, checked
|
|
164
|
+
* against the actual function the props type belongs to, never by filename or export name
|
|
165
|
+
* - so nothing can opt in by being called `RouteShell`, and nothing loses the exemption by
|
|
166
|
+
* being renamed.
|
|
167
|
+
*/
|
|
168
|
+
export const noChildrenSlot = {
|
|
169
|
+
meta: {
|
|
170
|
+
type: "problem",
|
|
171
|
+
docs: { description: "Every component container takes contract and render, never children." },
|
|
172
|
+
schema: [],
|
|
173
|
+
messages: {
|
|
174
|
+
slot:
|
|
175
|
+
"`children` accepts markup that has already been built, so its shape cannot be checked. Take contract + render instead. A framework route layout may receive ReactNode outside the component tier, but it must close that value into a named contract projection before handing it to a component.",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
create(context) {
|
|
179
|
+
if (!isGoverned(context.filename || context.getFilename())) return {}
|
|
180
|
+
|
|
181
|
+
// Deferred to Program:exit so the boundary-converter exemption - which has to look at
|
|
182
|
+
// the whole file to find the function a props type belongs to - can clear a candidate
|
|
183
|
+
// before it is ever reported.
|
|
184
|
+
const candidates = []
|
|
185
|
+
const componentsByPropsType = new Map()
|
|
186
|
+
|
|
187
|
+
const enclosingFunction = (node) => {
|
|
188
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
189
|
+
if (["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"].includes(current.type)) {
|
|
190
|
+
return current
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const enclosingTypeName = (node) => {
|
|
197
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
198
|
+
if (current.type === "TSInterfaceDeclaration" || current.type === "TSTypeAliasDeclaration") {
|
|
199
|
+
return current.id?.name || null
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The name of the type a function's single parameter is annotated with, if any. */
|
|
206
|
+
const parameterTypeName = (fn) => {
|
|
207
|
+
const annotation = fn.params?.[0]?.typeAnnotation?.typeAnnotation
|
|
208
|
+
return annotation?.type === "TSTypeReference" && annotation.typeName?.type === "Identifier"
|
|
209
|
+
? annotation.typeName.name
|
|
210
|
+
: null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
TSPropertySignature(node) {
|
|
215
|
+
if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
|
|
216
|
+
candidates.push({ node: node.key, typeName: enclosingTypeName(node) })
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
Property(node) {
|
|
220
|
+
// a destructured `children` in a parameter, which is the same slot arriving by another door
|
|
221
|
+
if (node.parent && node.parent.type !== "ObjectPattern") return
|
|
222
|
+
if (node.parent.parent && node.parent.parent.type === "VariableDeclarator") return
|
|
223
|
+
if (node.key && node.key.type === "Identifier" && node.key.name === "children") {
|
|
224
|
+
candidates.push({ node: node.key, fn: enclosingFunction(node) })
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
":function"(fn) {
|
|
228
|
+
const typeName = parameterTypeName(fn)
|
|
229
|
+
if (typeName) componentsByPropsType.set(typeName, fn)
|
|
230
|
+
},
|
|
231
|
+
"Program:exit"() {
|
|
232
|
+
for (const candidate of candidates) {
|
|
233
|
+
const fn = candidate.fn || (candidate.typeName && componentsByPropsType.get(candidate.typeName))
|
|
234
|
+
if (fn && isChildrenBoundaryConverter(fn)) continue
|
|
235
|
+
context.report({ node: candidate.node, messageId: "slot" })
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Name of a plain TypeScript property key. */
|
|
243
|
+
const propertyName = (node) => {
|
|
244
|
+
const key = node?.key ?? node?.property
|
|
245
|
+
if (!key) return null
|
|
246
|
+
if (key.type === "Identifier") return key.name
|
|
247
|
+
if (key.type === "Literal" && typeof key.value === "string") return key.value
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Product source inside a supported component root. */
|
|
252
|
+
const isComponentSource = (filename) => {
|
|
253
|
+
const path = String(filename || "").replace(/\\/g, "/")
|
|
254
|
+
return COMPONENT_ROOTS.filter((root) => root !== "src").some((root) => path.includes(`/${root}/`))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// -- SLOTS-5 · SLOTS-6 ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
/** A caller cannot reach into one named internal part and style it. */
|
|
260
|
+
export const noPerPartClassNameProp = {
|
|
261
|
+
meta: {
|
|
262
|
+
type: "problem",
|
|
263
|
+
docs: { description: "No <part>ClassName prop exposes a component's internal nodes." },
|
|
264
|
+
schema: [],
|
|
265
|
+
messages: {
|
|
266
|
+
perPart:
|
|
267
|
+
"`{{prop}}` lets a caller restyle a node it does not own. Replace the CSS door with a named semantic prop, and keep the appearance decision inside the component or its contract.",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
create(context) {
|
|
271
|
+
const filename = context.filename || context.getFilename()
|
|
272
|
+
if (isTestFile(filename) || !isComponentSource(filename)) return {}
|
|
273
|
+
return {
|
|
274
|
+
TSPropertySignature(node) {
|
|
275
|
+
const name = propertyName(node)
|
|
276
|
+
if (!name || name === "className" || !/^[a-z][A-Za-z0-9]*ClassName$/.test(name)) return
|
|
277
|
+
context.report({ node, messageId: "perPart", data: { prop: name } })
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Public house components never expose className/classNames placement doors. */
|
|
284
|
+
export const noPublicClassNameProp = {
|
|
285
|
+
meta: {
|
|
286
|
+
type: "problem",
|
|
287
|
+
docs: { description: "House components own appearance and expose no public className/classNames prop." },
|
|
288
|
+
schema: [],
|
|
289
|
+
messages: {
|
|
290
|
+
declaration:
|
|
291
|
+
"Public component prop `{{prop}}` is a CSS placement door. Expose a semantic variant or a named contract instead.",
|
|
292
|
+
usage:
|
|
293
|
+
"Do not pass `{{prop}}` to house component `{{component}}`; its owner or parent contract decides appearance.",
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
create(context) {
|
|
297
|
+
const filename = context.filename || context.getFilename()
|
|
298
|
+
if (isTestFile(filename)) return {}
|
|
299
|
+
const inComponent = isComponentSource(filename)
|
|
300
|
+
const bindings = new Set()
|
|
301
|
+
return {
|
|
302
|
+
ImportDeclaration(node) {
|
|
303
|
+
const source = String(node.source?.value || "").replace(/\\/g, "/")
|
|
304
|
+
if (!/(?:^|\/)components\//.test(source)) return
|
|
305
|
+
for (const specifier of node.specifiers || []) {
|
|
306
|
+
if (specifier.local?.name) bindings.add(specifier.local.name)
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
TSPropertySignature(node) {
|
|
310
|
+
if (!inComponent) return
|
|
311
|
+
const name = propertyName(node)
|
|
312
|
+
if (name === "className" || name === "classNames") {
|
|
313
|
+
context.report({ node, messageId: "declaration", data: { prop: name } })
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
JSXOpeningElement(node) {
|
|
317
|
+
const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
|
|
318
|
+
if (!component || !bindings.has(component)) return
|
|
319
|
+
for (const attribute of node.attributes || []) {
|
|
320
|
+
if (attribute.type !== "JSXAttribute") continue
|
|
321
|
+
const prop = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null
|
|
322
|
+
if (prop === "className" || prop === "classNames") {
|
|
323
|
+
context.report({ node: attribute, messageId: "usage", data: { prop, component } })
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** CSS-shaped layout props are not public API above the atomic leaf tier. */
|
|
332
|
+
const FRAME_CSS_PROPS = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"])
|
|
333
|
+
|
|
334
|
+
export const noPublicFrameCssProps = {
|
|
335
|
+
meta: {
|
|
336
|
+
type: "problem",
|
|
337
|
+
docs: { description: "Non-leaf component contracts expose semantic decisions, not CSS-shaped frame props." },
|
|
338
|
+
schema: [],
|
|
339
|
+
messages: {
|
|
340
|
+
css:
|
|
341
|
+
"`{{prop}}` is a public CSS/frame decision above the leaf tier. Move the arrangement into a named contract or expose the semantic state that selects one.",
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
create(context) {
|
|
345
|
+
const filename = context.filename || context.getFilename()
|
|
346
|
+
if (isTestFile(filename) || !isComponentSource(filename) || isInComponentTier(filename, "leaves")) return {}
|
|
347
|
+
return {
|
|
348
|
+
TSPropertySignature(node) {
|
|
349
|
+
const name = propertyName(node)
|
|
350
|
+
if (name && FRAME_CSS_PROPS.has(name)) context.report({ node, messageId: "css", data: { prop: name } })
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** String literal keys named by Omit/Pick/Exclude. */
|
|
357
|
+
const typeKeys = (node, out = []) => {
|
|
358
|
+
if (!node) return out
|
|
359
|
+
if (node.type === "TSUnionType") {
|
|
360
|
+
for (const member of node.types || []) typeKeys(member, out)
|
|
361
|
+
return out
|
|
362
|
+
}
|
|
363
|
+
if (node.type === "TSLiteralType" && node.literal?.type === "Literal" && typeof node.literal.value === "string") {
|
|
364
|
+
out.push(node.literal.value)
|
|
365
|
+
}
|
|
366
|
+
return out
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Hiding a CSS door with a utility type is not closing the owner that exposed it. */
|
|
370
|
+
export const noCssDoorTypeLaundering = {
|
|
371
|
+
meta: {
|
|
372
|
+
type: "problem",
|
|
373
|
+
docs: { description: "Omit/Pick/Exclude cannot hide className/classNames/style doors." },
|
|
374
|
+
schema: [],
|
|
375
|
+
messages: {
|
|
376
|
+
utility:
|
|
377
|
+
"`{{utility}}` of `{{prop}}` launders a CSS door instead of closing it. Remove the prop from the owning public contract and every consumer.",
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
create(context) {
|
|
381
|
+
const file = String(context.filename || context.getFilename()).replace(/\\/g, "/")
|
|
382
|
+
if (isTestFile(file) || !file.includes("/src/")) return {}
|
|
383
|
+
return {
|
|
384
|
+
TSTypeReference(node) {
|
|
385
|
+
const utility = node.typeName?.type === "Identifier" ? node.typeName.name : null
|
|
386
|
+
if (!utility || !["Omit", "Pick", "Exclude"].includes(utility)) return
|
|
387
|
+
const params = node.typeArguments?.params || node.typeParameters?.params || []
|
|
388
|
+
const prop = typeKeys(params[1]).find((key) => key === "className" || key === "classNames" || key === "style")
|
|
389
|
+
if (prop) context.report({ node, messageId: "utility", data: { utility, prop } })
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// -- SLOTS-7 ---------------------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
/** A joined-list surface receives domain collections through named props, never a generic items lane. */
|
|
398
|
+
export const noSurfaceListItemsSlot = {
|
|
399
|
+
meta: {
|
|
400
|
+
type: "problem",
|
|
401
|
+
docs: { description: "SurfaceListCard receives collection data through named props, never items." },
|
|
402
|
+
schema: [],
|
|
403
|
+
messages: {
|
|
404
|
+
items:
|
|
405
|
+
"`items` creates a second runtime-data lane beside `props` and makes SurfaceListCard know every domain collection. Put the collection in the render component's named props type (for example `tasks`) and pass it through `props`.",
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
create(context) {
|
|
409
|
+
const filename = String(context.filename || context.getFilename()).replace(/\\/g, "/")
|
|
410
|
+
if (!filename.includes("/src/")) return {}
|
|
411
|
+
const bindings = new Set()
|
|
412
|
+
return {
|
|
413
|
+
ImportDeclaration(node) {
|
|
414
|
+
const source = String(node.source?.value || "").replace(/\\/g, "/")
|
|
415
|
+
if (!/(?:^|\/)components\/branches\/SurfaceListCard$/.test(source)) return
|
|
416
|
+
for (const specifier of node.specifiers || []) {
|
|
417
|
+
const imported = specifier.imported?.name
|
|
418
|
+
if (imported === "SurfaceListCard" && specifier.local?.name) bindings.add(specifier.local.name)
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
JSXOpeningElement(node) {
|
|
422
|
+
const component = node.name?.type === "JSXIdentifier" ? node.name.name : null
|
|
423
|
+
if (!component || !bindings.has(component)) return
|
|
424
|
+
for (const attribute of node.attributes || []) {
|
|
425
|
+
if (attribute.type !== "JSXAttribute") continue
|
|
426
|
+
if (attribute.name?.type === "JSXIdentifier" && attribute.name.name === "items") {
|
|
427
|
+
context.report({ node: attribute, messageId: "items" })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** The rules this law contributes to the plugin. */
|
|
436
|
+
export const rules = {
|
|
437
|
+
"no-inline-parameter-type": noInlineParameterType,
|
|
438
|
+
"no-children-slot": noChildrenSlot,
|
|
439
|
+
"no-per-part-classname-prop": noPerPartClassNameProp,
|
|
440
|
+
"no-public-classname-prop": noPublicClassNameProp,
|
|
441
|
+
"no-public-frame-css-props": noPublicFrameCssProps,
|
|
442
|
+
"no-css-door-type-laundering": noCssDoorTypeLaundering,
|
|
443
|
+
"no-surface-list-items-slot": noSurfaceListItemsSlot,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
448
|
+
*
|
|
449
|
+
* Exact and mechanical: it fires on a syntactic shape rather than on a judgement, so there is no
|
|
450
|
+
* false-positive risk that would justify adopting it at `warn`.
|
|
451
|
+
*/
|
|
452
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|