@starci/eslint-canon-fe 1.4.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -86
- package/class-names.mjs +145 -0
- package/file-layout.mjs +6 -7
- package/grammar-boundary.mjs +16 -0
- package/index.mjs +19 -4
- package/landmark.mjs +29 -242
- package/package.json +2 -4
- package/props-and-slots.mjs +148 -452
- package/the-split.mjs +17 -10
- package/vendor-boundary.mjs +24 -595
- package/contract.mjs +0 -1387
- package/contracts.ts +0 -312
- package/props.ts +0 -145
package/package.json
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/eslint-canon-fe",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "The StarCi front-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"private": false,
|
|
8
8
|
"exports": {
|
|
9
|
-
".": "./index.mjs"
|
|
10
|
-
"./contracts": "./contracts.ts",
|
|
11
|
-
"./props": "./props.ts"
|
|
9
|
+
".": "./index.mjs"
|
|
12
10
|
},
|
|
13
11
|
"main": "./index.mjs",
|
|
14
12
|
"files": [
|
package/props-and-slots.mjs
CHANGED
|
@@ -1,452 +1,148 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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"]))
|
|
1
|
+
/** Rules for named React props, ordinary children, and local visual ownership. */
|
|
2
|
+
|
|
3
|
+
const isTestFile = (filename) => /(?:^|[\\/])(?:[^\\/]+\.)?(?:spec|test)\.[cm]?[jt]sx?$/.test(String(filename || ""))
|
|
4
|
+
const isComponentSource = (filename) => /(?:^|[\\/])src[\\/]components[\\/]/.test(String(filename || "").replace(/\\/g, "/"))
|
|
5
|
+
const propertyName = (node) => {
|
|
6
|
+
const key = node?.key ?? node?.property
|
|
7
|
+
if (!key) return null
|
|
8
|
+
if (key.type === "Identifier") return key.name
|
|
9
|
+
if (key.type === "Literal" && typeof key.value === "string") return key.value
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Reject anonymous object types on named component parameters. */
|
|
14
|
+
export const noInlineParameterType = {
|
|
15
|
+
meta: {
|
|
16
|
+
type: "suggestion",
|
|
17
|
+
docs: { description: "A parameter uses a named props type, not an inline object shape." },
|
|
18
|
+
schema: [],
|
|
19
|
+
messages: { inline: "Name this parameter type in the module so the component API can be reused." },
|
|
20
|
+
},
|
|
21
|
+
create(context) {
|
|
22
|
+
const check = (node) => {
|
|
23
|
+
for (const param of node.params || []) {
|
|
24
|
+
const type = param?.typeAnnotation?.typeAnnotation
|
|
25
|
+
if (type?.type === "TSTypeLiteral" || type?.type === "TSIntersectionType" && type.types.some((item) => item.type === "TSTypeLiteral")) {
|
|
26
|
+
context.report({ node: param.typeAnnotation || param, messageId: "inline" })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { ArrowFunctionExpression: check, FunctionExpression: check, FunctionDeclaration: check }
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isExported = (node) => node.parent?.type === "ExportNamedDeclaration" || node.parent?.parent?.type === "ExportNamedDeclaration"
|
|
35
|
+
const containsJsx = (node, seen = new Set()) => {
|
|
36
|
+
if (!node || typeof node !== "object" || seen.has(node)) return false
|
|
37
|
+
seen.add(node)
|
|
38
|
+
if (node.type === "JSXElement" || node.type === "JSXFragment") return true
|
|
39
|
+
return Object.entries(node).some(([key, value]) => key !== "parent" && (Array.isArray(value) ? value.some((item) => containsJsx(item, seen)) : containsJsx(value, seen)))
|
|
40
|
+
}
|
|
41
|
+
const componentNameOf = (node) => {
|
|
42
|
+
if (node.type !== "VariableDeclarator" || node.id?.type !== "Identifier" || !/^[A-Z]/.test(node.id.name) || !isExported(node)) return null
|
|
43
|
+
return node.id.name
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Require exported React components to expose one named props parameter. */
|
|
47
|
+
export const publicComponentSignature = {
|
|
48
|
+
meta: {
|
|
49
|
+
type: "problem",
|
|
50
|
+
docs: { description: "Exported React components use one parameter named props with a matching XProps type." },
|
|
51
|
+
schema: [],
|
|
52
|
+
messages: {
|
|
53
|
+
parameter: "Exported component {{name}} must be an arrow const with exactly one parameter named props.",
|
|
54
|
+
type: "Exported component {{name}} must type props as {{expected}}.",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
create(context) {
|
|
58
|
+
const check = (node, name, fn) => {
|
|
59
|
+
if (!name || !fn) return
|
|
60
|
+
const params = fn.params || []
|
|
61
|
+
if (params.length !== 1 || params[0].type !== "Identifier" || params[0].name !== "props") {
|
|
62
|
+
context.report({ node: fn, messageId: "parameter", data: { name } })
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const type = params[0].typeAnnotation?.typeAnnotation
|
|
66
|
+
const expected = [name.endsWith("Base") ? `${name.slice(0, -4)}Props` : `${name}Props`]
|
|
67
|
+
const actual = type?.type === "TSTypeReference" && type.typeName?.type === "Identifier" ? type.typeName.name : null
|
|
68
|
+
if (!actual || !expected.includes(actual)) context.report({ node: params[0], messageId: "type", data: { name, expected: expected.join(" or ") } })
|
|
69
|
+
}
|
|
70
|
+
if (!isComponentSource(context.filename || context.getFilename()) || isTestFile(context.filename || context.getFilename())) return {}
|
|
71
|
+
return {
|
|
72
|
+
VariableDeclarator(node) {
|
|
73
|
+
const name = componentNameOf(node)
|
|
74
|
+
if (name && node.init?.type === "ArrowFunctionExpression" && containsJsx(node.init)) check(node, name, node.init)
|
|
75
|
+
},
|
|
76
|
+
FunctionDeclaration(node) {
|
|
77
|
+
if (!isExported(node) || !containsJsx(node)) return
|
|
78
|
+
context.report({ node, messageId: "parameter", data: { name: node.id?.name || "component" } })
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Prevent callers from styling internal component parts through named CSS props. */
|
|
85
|
+
export const noPerPartClassNameProp = {
|
|
86
|
+
meta: {
|
|
87
|
+
type: "problem",
|
|
88
|
+
docs: { description: "Components do not expose per-part class name props." },
|
|
89
|
+
schema: [],
|
|
90
|
+
messages: { perPart: "Keep internal part styling owned by the component." },
|
|
91
|
+
},
|
|
92
|
+
create(context) {
|
|
93
|
+
if (!isComponentSource(context.filename || context.getFilename()) || isTestFile(context.filename || context.getFilename())) return {}
|
|
94
|
+
return { TSPropertySignature(node) { const name = propertyName(node); if (name && name !== "className" && /^[a-z][A-Za-z0-9]*ClassName$/.test(name)) context.report({ node, messageId: "perPart" }) } }
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Keep component appearance local instead of exposing className placement doors. */
|
|
99
|
+
export const noPublicClassNameProp = {
|
|
100
|
+
meta: {
|
|
101
|
+
type: "problem",
|
|
102
|
+
docs: { description: "House components do not expose className or classNames props." },
|
|
103
|
+
schema: [],
|
|
104
|
+
messages: { declaration: "Component props must not expose {{prop}}.", usage: "Do not pass {{prop}} to house component {{component}}." },
|
|
105
|
+
},
|
|
106
|
+
create(context) {
|
|
107
|
+
const filename = context.filename || context.getFilename()
|
|
108
|
+
if (isTestFile(filename)) return {}
|
|
109
|
+
const bindings = new Set()
|
|
110
|
+
return {
|
|
111
|
+
ImportDeclaration(node) { if (!/components\//.test(String(node.source?.value || ""))) return; for (const specifier of node.specifiers || []) if (specifier.local?.name) bindings.add(specifier.local.name) },
|
|
112
|
+
TSPropertySignature(node) { if (isComponentSource(filename) && ["className", "classNames"].includes(propertyName(node))) context.report({ node, messageId: "declaration", data: { prop: propertyName(node) } }) },
|
|
113
|
+
JSXOpeningElement(node) { const component = node.name?.type === "JSXIdentifier" ? node.name.name : null; if (!component || !bindings.has(component)) return; for (const attribute of node.attributes || []) { const prop = attribute.name?.type === "JSXIdentifier" ? attribute.name.name : null; if (["className", "classNames"].includes(prop)) context.report({ node: attribute, messageId: "usage", data: { prop, component } }) } },
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Keep layout APIs semantic rather than exposing raw CSS decisions above leaves. */
|
|
119
|
+
export const noPublicFrameCssProps = {
|
|
120
|
+
meta: { type: "problem", docs: { description: "Non-leaf component props do not expose CSS-shaped frame decisions." }, schema: [], messages: { css: "Move {{prop}} into component-owned layout behavior." } },
|
|
121
|
+
create(context) {
|
|
122
|
+
const filename = context.filename || context.getFilename()
|
|
123
|
+
if (isTestFile(filename) || !isComponentSource(filename) || /[\\/]leaves[\\/]/.test(String(filename).replace(/\\/g, "/"))) return {}
|
|
124
|
+
const cssProps = new Set(["gap", "padding", "align", "justify", "className", "classNames", "style", "inline", "nested"])
|
|
125
|
+
return { TSPropertySignature(node) { const name = propertyName(node); if (cssProps.has(name)) context.report({ node, messageId: "css", data: { prop: name } }) } }
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Prevent utility types from reintroducing public styling doors. */
|
|
130
|
+
export const noCssDoorTypeLaundering = {
|
|
131
|
+
meta: { type: "problem", docs: { description: "Omit, Pick, and Exclude cannot hide styling props." }, schema: [], messages: { utility: "Remove {{prop}} from the owning public type instead of hiding it with {{utility}}." } },
|
|
132
|
+
create(context) {
|
|
133
|
+
if (!String(context.filename || context.getFilename()).replace(/\\/g, "/").includes("/src/")) return {}
|
|
134
|
+
return { TSTypeReference(node) { const utility = node.typeName?.type === "Identifier" ? node.typeName.name : null; const params = node.typeArguments?.params || node.typeParameters?.params || []; if (!utility || !["Omit", "Pick", "Exclude"].includes(utility)) return; const keys = params[1]?.type === "TSLiteralType" ? [params[1].literal?.value] : params[1]?.types?.map((item) => item.literal?.value) || []; const prop = keys.find((key) => ["className", "classNames", "style"].includes(key)); if (prop) context.report({ node, messageId: "utility", data: { utility, prop } }) } }
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const rules = {
|
|
139
|
+
"no-inline-parameter-type": noInlineParameterType,
|
|
140
|
+
"public-component-signature": publicComponentSignature,
|
|
141
|
+
"no-per-part-classname-prop": noPerPartClassNameProp,
|
|
142
|
+
"no-public-classname-prop": noPublicClassNameProp,
|
|
143
|
+
"no-public-frame-css-props": noPublicFrameCssProps,
|
|
144
|
+
"no-css-door-type-laundering": noCssDoorTypeLaundering,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** All rules in this law are mechanical errors once adopted. */
|
|
148
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|