@starci/eslint-canon-fe 1.4.3 → 1.4.5
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/contract.mjs +25 -1
- package/package.json +1 -1
- package/vendor-boundary.mjs +618 -549
package/contract.mjs
CHANGED
|
@@ -152,11 +152,35 @@ export const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|c
|
|
|
152
152
|
/** Product source lives under `src/`; tooling and config are out of scope. */
|
|
153
153
|
export const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* True for a branch that draws content parsed from an authored document.
|
|
157
|
+
*
|
|
158
|
+
* WHY THE TABLE CANNOT HOLD THESE. An entry declares every slot inside it, by name - that is
|
|
159
|
+
* `CONTRACT-11`, and it is what makes an entry a constraint rather than a container. A markdown
|
|
160
|
+
* document has no such list: the children of one `ul` are whichever of a dozen node kinds the author
|
|
161
|
+
* typed, in whichever order, nested however deep. Any `children` record written for it would be a
|
|
162
|
+
* guess, and a guess in the table is worse than no entry at all, because the next reader takes it
|
|
163
|
+
* for a rule somebody checked.
|
|
164
|
+
*
|
|
165
|
+
* The same is true one level down, which is why the list has three members rather than one. A
|
|
166
|
+
* highlighted code region is a string of vendor HTML injected into the node; a rendered diagram is
|
|
167
|
+
* an SVG produced from authored source the same way. Neither has slots to name, and neither is a
|
|
168
|
+
* different case from the document that contains them.
|
|
169
|
+
*
|
|
170
|
+
* THIS IS A FOLDER EXEMPTION, exactly like the leaf one above, and it carries the same cost: anyone
|
|
171
|
+
* can escape these rules by filing a component here. What keeps a file out is a question a person
|
|
172
|
+
* asks - does this component receive its children as PARSED CONTENT rather than as a designed
|
|
173
|
+
* arrangement? A block that merely has many children is not this; it has a shape somebody chose, and
|
|
174
|
+
* a shape somebody chose is what the table is for.
|
|
175
|
+
*/
|
|
176
|
+
export const isAuthoredDocumentBranch = (filename) =>
|
|
177
|
+
/\/src\/components\/branches\/(?:Article|MarkdownCodeBlock|MermaidDiagram)\//.test(normalizePath(filename))
|
|
178
|
+
|
|
155
179
|
/** Product source these rules govern: under `src/`, not a test, not a leaf, not the frame. */
|
|
156
180
|
export const isGovernedFile = (filename) => {
|
|
157
181
|
const file = normalizePath(filename)
|
|
158
182
|
if (!isSourceFile(file) || isTestFile(file)) return false
|
|
159
|
-
return !isLeafFile(file) && !isContractFrameFile(file)
|
|
183
|
+
return !isLeafFile(file) && !isContractFrameFile(file) && !isAuthoredDocumentBranch(file)
|
|
160
184
|
}
|
|
161
185
|
|
|
162
186
|
// -- reading the table --------------------------------------------------------------------------
|
package/package.json
CHANGED
package/vendor-boundary.mjs
CHANGED
|
@@ -1,549 +1,618 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rule that holds `vendor-boundary.md`.
|
|
3
|
-
*
|
|
4
|
-
* IT IS CHECKED IN BOTH DIRECTIONS, and the second direction is the whole reason this is a policy
|
|
5
|
-
* rather than a hole. Outward, a component that imports the library from the wrong folder is
|
|
6
|
-
* misfiled - that half is obvious. Inward, a file sitting in a wrapper folder that imports nothing
|
|
7
|
-
* is an ordinary component holding an exemption it does not need, and without that check the folder
|
|
8
|
-
* becomes the place difficult things go. The first thing to opt in is always something that was
|
|
9
|
-
* hard to place.
|
|
10
|
-
*
|
|
11
|
-
* THE GLYPH LIBRARY IS SOMEBODY ELSE'S RULE, deliberately. `icon.mjs` owns it, because a rule that
|
|
12
|
-
* names one vendor protects one vendor - and the gap between two such rules is where a caret came
|
|
13
|
-
* to be imported straight from a glyph package at a size that existed nowhere else, reported by
|
|
14
|
-
* nothing.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
18
|
-
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
19
|
-
|
|
20
|
-
/** Dot form of a two-part JSX member name such as `Checkbox.Content`. */
|
|
21
|
-
const memberName = (node) => {
|
|
22
|
-
if (node?.type !== "JSXMemberExpression") return null
|
|
23
|
-
if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
|
|
24
|
-
return `${node.object.name}.${node.property.name}`
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
|
|
28
|
-
const VENDOR_PACKAGE_PREFIX = "@heroui/"
|
|
29
|
-
|
|
30
|
-
/** Named mechanics branches own the closed vendor interaction machines. */
|
|
31
|
-
const MECHANICS_BRANCH = /\/src\/components\/branches\/(?:ModalBranch|DrawerBranch|DropdownBranch)\//
|
|
32
|
-
const LEGACY_SHELL_DIR = "/src/components/shells/"
|
|
33
|
-
|
|
34
|
-
/** Named surface branches may own the vendor wrapper they project a content contract into. */
|
|
35
|
-
const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
276
|
-
},
|
|
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
|
-
|
|
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
|
-
},
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
|
|
549
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The rule that holds `vendor-boundary.md`.
|
|
3
|
+
*
|
|
4
|
+
* IT IS CHECKED IN BOTH DIRECTIONS, and the second direction is the whole reason this is a policy
|
|
5
|
+
* rather than a hole. Outward, a component that imports the library from the wrong folder is
|
|
6
|
+
* misfiled - that half is obvious. Inward, a file sitting in a wrapper folder that imports nothing
|
|
7
|
+
* is an ordinary component holding an exemption it does not need, and without that check the folder
|
|
8
|
+
* becomes the place difficult things go. The first thing to opt in is always something that was
|
|
9
|
+
* hard to place.
|
|
10
|
+
*
|
|
11
|
+
* THE GLYPH LIBRARY IS SOMEBODY ELSE'S RULE, deliberately. `icon.mjs` owns it, because a rule that
|
|
12
|
+
* names one vendor protects one vendor - and the gap between two such rules is where a caret came
|
|
13
|
+
* to be imported straight from a glyph package at a size that existed nowhere else, reported by
|
|
14
|
+
* nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
18
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
19
|
+
|
|
20
|
+
/** Dot form of a two-part JSX member name such as `Checkbox.Content`. */
|
|
21
|
+
const memberName = (node) => {
|
|
22
|
+
if (node?.type !== "JSXMemberExpression") return null
|
|
23
|
+
if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
|
|
24
|
+
return `${node.object.name}.${node.property.name}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The component library this boundary owns. Its glyph counterpart belongs to `icon.mjs`. */
|
|
28
|
+
const VENDOR_PACKAGE_PREFIX = "@heroui/"
|
|
29
|
+
|
|
30
|
+
/** Named mechanics branches own the closed vendor interaction machines. */
|
|
31
|
+
const MECHANICS_BRANCH = /\/src\/components\/branches\/(?:ModalBranch|DrawerBranch|DropdownBranch)\//
|
|
32
|
+
const LEGACY_SHELL_DIR = "/src/components/shells/"
|
|
33
|
+
|
|
34
|
+
/** Named surface branches may own the vendor wrapper they project a content contract into. */
|
|
35
|
+
const SURFACE_BRANCH = /\/src\/components\/branches\/(?:SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)\//
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `TableBranch` owns the vendor table, and it is a BRANCH because a leaf could not hold it.
|
|
39
|
+
*
|
|
40
|
+
* A leaf's `props` must satisfy `ComponentData`, which is JSON to the bottom. A table cell carries
|
|
41
|
+
* inline content - a link mid-phrase, a path set in code - and none of that survives a JSON slot, so
|
|
42
|
+
* a leaf could only accept cells already flattened to text. Flattening is the loss the table exists
|
|
43
|
+
* to prevent, which is why the owner sits here beside the surface family rather than in `leaves/`.
|
|
44
|
+
*/
|
|
45
|
+
const TABLE_BRANCH = /\/src\/components\/branches\/TableBranch\//
|
|
46
|
+
|
|
47
|
+
const isAllowedVendorOwner = (file) =>
|
|
48
|
+
file.includes("/src/components/leaves/")
|
|
49
|
+
|| MECHANICS_BRANCH.test(file)
|
|
50
|
+
|| SURFACE_BRANCH.test(file)
|
|
51
|
+
|| TABLE_BRANCH.test(file)
|
|
52
|
+
|
|
53
|
+
/** True for a file inside the component tree, where the boundary applies. */
|
|
54
|
+
const isComponentFile = (filename) => normalizePath(filename).includes("/src/components/")
|
|
55
|
+
|
|
56
|
+
/** True when an import names the component library, subpaths included. */
|
|
57
|
+
const isVendorImport = (source) => typeof source === "string" && source.startsWith(VENDOR_PACKAGE_PREFIX)
|
|
58
|
+
|
|
59
|
+
/** Read a two-part JSX member such as `Modal.Body`. */
|
|
60
|
+
const jsxMemberName = (node) => {
|
|
61
|
+
if (node?.type !== "JSXMemberExpression") return null
|
|
62
|
+
if (node.object?.type !== "JSXIdentifier" || node.property?.type !== "JSXIdentifier") return null
|
|
63
|
+
return `${node.object.name}.${node.property.name}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// -- VENDOR-1 · VENDOR-2 ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** The library belongs to two folders, and a wrapper folder that wraps nothing is misfiled. */
|
|
69
|
+
export const vendorBoundary = {
|
|
70
|
+
meta: {
|
|
71
|
+
type: "problem",
|
|
72
|
+
docs: { description: "Vendor imports belong to leaves and named mechanics or surface branches." },
|
|
73
|
+
schema: [],
|
|
74
|
+
messages: {
|
|
75
|
+
outside:
|
|
76
|
+
"This component does not own a vendor primitive. Vendor imports belong to leaves, ModalBranch/DrawerBranch/DropdownBranch, TableBranch, and the named SurfaceCard family branches that project contracts into vendor bodies.",
|
|
77
|
+
emptyBranch:
|
|
78
|
+
"ModalBranch/DrawerBranch/DropdownBranch must wrap their vendor interaction primitive; otherwise this is an ordinary branch in a privileged mechanics path.",
|
|
79
|
+
legacyShell:
|
|
80
|
+
"`components/shells` is a deleted structural tier. Move vendor mechanics into the named Branch owner; framework route content closes outside the component tier into a named contract projection.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
create(context) {
|
|
84
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
85
|
+
// Outside the component tree, a provider standing the library up for the whole application is a
|
|
86
|
+
// different thing from a component pulling in a widget.
|
|
87
|
+
if (!isComponentFile(file)) return {}
|
|
88
|
+
const isLegacyShell = file.includes(LEGACY_SHELL_DIR)
|
|
89
|
+
const isMechanicsBranch = MECHANICS_BRANCH.test(file)
|
|
90
|
+
const isTestFile = /\.(?:test|spec)\.[jt]sx?$/.test(file)
|
|
91
|
+
const isAllowed = isAllowedVendorOwner(file)
|
|
92
|
+
let importsVendor = false
|
|
93
|
+
return {
|
|
94
|
+
ImportDeclaration(node) {
|
|
95
|
+
if (!isVendorImport(node.source && node.source.value)) return
|
|
96
|
+
importsVendor = true
|
|
97
|
+
if (isAllowed) return
|
|
98
|
+
context.report({ node, messageId: "outside" })
|
|
99
|
+
},
|
|
100
|
+
"Program:exit"(node) {
|
|
101
|
+
if (isTestFile) return
|
|
102
|
+
if (isLegacyShell) context.report({ node, messageId: "legacyShell" })
|
|
103
|
+
else if (isMechanicsBranch && !importsVendor) context.report({ node, messageId: "emptyBranch" })
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// -- VENDOR-6 --------------------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** The modal mechanics branch owns one zero-inset vendor body as the scroll region. */
|
|
112
|
+
export const modalBranchOwnsScrollBody = {
|
|
113
|
+
meta: {
|
|
114
|
+
type: "problem",
|
|
115
|
+
docs: { description: "ModalBranch keeps one zero-inset Modal.Body as its scroll region." },
|
|
116
|
+
schema: [],
|
|
117
|
+
messages: {
|
|
118
|
+
missing:
|
|
119
|
+
"ModalBranch must keep one `Modal.Body` around its named content projection: that vendor body is the dialog's scroll region, not a second content surface.",
|
|
120
|
+
inset:
|
|
121
|
+
"ModalBranch's body must be `className=\"p-0\"`. The mounted contract owns layout; vendor body padding plus contract padding creates a doubled inset.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
create(context) {
|
|
125
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
126
|
+
if (!/\/src\/components\/branches\/ModalBranch\/index\.tsx$/.test(file)) return {}
|
|
127
|
+
let hasBody = false
|
|
128
|
+
return {
|
|
129
|
+
JSXOpeningElement(node) {
|
|
130
|
+
if (jsxMemberName(node.name) !== "Modal.Body") return
|
|
131
|
+
hasBody = true
|
|
132
|
+
const className = (node.attributes || []).find((attribute) =>
|
|
133
|
+
attribute.type === "JSXAttribute"
|
|
134
|
+
&& attribute.name?.type === "JSXIdentifier"
|
|
135
|
+
&& attribute.name.name === "className")
|
|
136
|
+
if (className?.value?.type === "Literal" && className.value.value === "p-0") return
|
|
137
|
+
context.report({ node, messageId: "inset" })
|
|
138
|
+
},
|
|
139
|
+
"Program:exit"(node) {
|
|
140
|
+
if (!hasBody) context.report({ node, messageId: "missing" })
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -- VENDOR-7 --------------------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** The house Field fixes the vendor input variant used on bounded form surfaces. */
|
|
149
|
+
export const fieldInputUsesSecondaryVariant = {
|
|
150
|
+
meta: {
|
|
151
|
+
type: "problem",
|
|
152
|
+
docs: { description: "The Field leaf uses HeroUI Input's secondary surface variant." },
|
|
153
|
+
schema: [],
|
|
154
|
+
messages: {
|
|
155
|
+
variant:
|
|
156
|
+
"The house Field sits inside a bounded form surface. Its HeroUI Input must use `variant=\"secondary\"`; the default variant draws a competing field surface.",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
create(context) {
|
|
160
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
161
|
+
if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
|
|
162
|
+
const inputBindings = new Set()
|
|
163
|
+
return {
|
|
164
|
+
ImportDeclaration(node) {
|
|
165
|
+
if (node.source?.value !== "@heroui/react") return
|
|
166
|
+
for (const specifier of node.specifiers || []) {
|
|
167
|
+
if (specifier.imported?.name === "Input" && specifier.local?.name) inputBindings.add(specifier.local.name)
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
JSXOpeningElement(node) {
|
|
171
|
+
const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
|
|
172
|
+
if (!name || !inputBindings.has(name)) return
|
|
173
|
+
const variant = (node.attributes || []).find((attribute) =>
|
|
174
|
+
attribute.type === "JSXAttribute"
|
|
175
|
+
&& attribute.name?.type === "JSXIdentifier"
|
|
176
|
+
&& attribute.name.name === "variant")
|
|
177
|
+
if (variant?.value?.type === "Literal" && variant.value.value === "secondary") return
|
|
178
|
+
context.report({ node, messageId: "variant" })
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// -- VENDOR-9 --------------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** Field labels stay textual instead of inferring decorative glyphs from the input kind. */
|
|
187
|
+
export const fieldLabelIsTextOnly = {
|
|
188
|
+
meta: {
|
|
189
|
+
type: "problem",
|
|
190
|
+
docs: { description: "The house Field label contains no inferred kind icon." },
|
|
191
|
+
schema: [],
|
|
192
|
+
messages: {
|
|
193
|
+
icon:
|
|
194
|
+
"Field labels are text-only in the source design. Do not infer a decorative icon from the input kind; an icon belongs only to a control with its own action, such as password visibility.",
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
create(context) {
|
|
198
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
199
|
+
if (!/\/src\/components\/leaves\/Field\/index\.tsx$/.test(file)) return {}
|
|
200
|
+
const iconBindings = new Set()
|
|
201
|
+
return {
|
|
202
|
+
ImportDeclaration(node) {
|
|
203
|
+
const source = normalizePath(node.source?.value)
|
|
204
|
+
if (!/\/components\/leaves\/Icon$/.test(source)) return
|
|
205
|
+
for (const specifier of node.specifiers || []) {
|
|
206
|
+
if (specifier.imported?.name === "Icon" && specifier.local?.name) iconBindings.add(specifier.local.name)
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
JSXOpeningElement(node) {
|
|
210
|
+
const name = node.name?.type === "JSXIdentifier" ? node.name.name : null
|
|
211
|
+
if (!name || !iconBindings.has(name)) return
|
|
212
|
+
let ancestor = node.parent
|
|
213
|
+
while (ancestor) {
|
|
214
|
+
if (
|
|
215
|
+
ancestor.type === "JSXElement"
|
|
216
|
+
&& ancestor.openingElement?.name?.type === "JSXIdentifier"
|
|
217
|
+
&& ancestor.openingElement.name.name === "label"
|
|
218
|
+
) {
|
|
219
|
+
context.report({ node, messageId: "icon" })
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
ancestor = ancestor.parent
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// -- VENDOR-8 --------------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/** An overlay is already bounded and cannot directly mount a second named surface branch. */
|
|
232
|
+
export const noSurfaceBranchInOverlay = {
|
|
233
|
+
meta: {
|
|
234
|
+
type: "problem",
|
|
235
|
+
docs: { description: "An overlay does not directly mount a named surface branch." },
|
|
236
|
+
schema: [],
|
|
237
|
+
messages: {
|
|
238
|
+
nested:
|
|
239
|
+
"This overlay is already the bounded surface. Do not mount `{{surface}}` inside it; use headings, spacing, rows and controls directly.",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
create(context) {
|
|
243
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
244
|
+
if (!file.includes("/src/components/overlays/")) return {}
|
|
245
|
+
return {
|
|
246
|
+
ImportDeclaration(node) {
|
|
247
|
+
const source = normalizePath(node.source?.value)
|
|
248
|
+
const match = source.match(/\/components\/branches\/(SurfaceCard|SurfaceAccordionCard|SurfaceListCard|SurfaceFormCard)$/)
|
|
249
|
+
if (match) context.report({ node, messageId: "nested", data: { surface: match[1] } })
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// -- VENDOR-10 -------------------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/** TextLink delegates link interaction and hover treatment to HeroUI Link. */
|
|
258
|
+
export const textLinkUsesHeroLink = {
|
|
259
|
+
meta: {
|
|
260
|
+
type: "problem",
|
|
261
|
+
docs: { description: "TextLink wraps HeroUI Link instead of hand-drawing link behavior." },
|
|
262
|
+
schema: [],
|
|
263
|
+
messages: {
|
|
264
|
+
missing: "TextLink must import HeroUI `Link`. The vendor owns link hover, focus and interaction states.",
|
|
265
|
+
handmade: "Do not rebuild a HeroUI Link with a raw button or local className. Wrap HeroUI `Link` and pass `onPress`.",
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
create(context) {
|
|
269
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
270
|
+
if (!/\/src\/components\/leaves\/TextLink\/index\.tsx$/.test(file)) return {}
|
|
271
|
+
let hasHeroLink = false
|
|
272
|
+
return {
|
|
273
|
+
ImportDeclaration(node) {
|
|
274
|
+
if (node.source?.value !== "@heroui/react") return
|
|
275
|
+
hasHeroLink ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Link")
|
|
276
|
+
},
|
|
277
|
+
JSXOpeningElement(node) {
|
|
278
|
+
if (node.name?.type === "JSXIdentifier" && node.name.name === "button") context.report({ node, messageId: "handmade" })
|
|
279
|
+
const className = (node.attributes || []).find(
|
|
280
|
+
(attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "className",
|
|
281
|
+
)
|
|
282
|
+
const classText = className?.value?.type === "Literal" ? String(className.value.value || "") : ""
|
|
283
|
+
if (/(?:hover:|underline)/.test(classText)) {
|
|
284
|
+
context.report({ node: className, messageId: "handmade" })
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
"Program:exit"(node) {
|
|
288
|
+
if (!hasHeroLink) context.report({ node, messageId: "missing" })
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// -- VENDOR-11 -------------------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/** The account block owns the product choices; DropdownBranch alone owns HeroUI mechanics. */
|
|
297
|
+
export const accountControlOwnsDropdown = {
|
|
298
|
+
meta: {
|
|
299
|
+
type: "problem",
|
|
300
|
+
docs: { description: "AccountMenu is a block over DropdownBranch; only the branch imports HeroUI Dropdown." },
|
|
301
|
+
schema: [],
|
|
302
|
+
messages: {
|
|
303
|
+
dropdown: "DropdownBranch must import HeroUI `Dropdown`; vendor mechanics belong to the branch.",
|
|
304
|
+
branch: "AccountMenu's pure block half must compose DropdownBranch instead of importing HeroUI directly.",
|
|
305
|
+
vendor: "AccountMenu is a product block. Import DropdownBranch; do not own HeroUI Dropdown here.",
|
|
306
|
+
menu: "ShellNav must render the AccountMenu block from `components/blocks/auth/AccountMenu`.",
|
|
307
|
+
direct: "The account IconButton may not carry an action in ShellNav. Guests open AccountMenu; its Sign in or Sign up choice opens the modal.",
|
|
308
|
+
pieces: "AccountMenu passes typed section data to DropdownBranch; it must not import Branch Section or Item pieces and rebuild vendor anatomy itself.",
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
create(context) {
|
|
312
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
313
|
+
const isDropdown = /\/src\/components\/branches\/DropdownBranch\/index\.tsx$/.test(file)
|
|
314
|
+
const isMenu = /\/src\/components\/blocks\/auth\/AccountMenu\/component\.tsx$/.test(file)
|
|
315
|
+
const isNav = /\/src\/components\/layouts\/ShellNav\/component\.tsx$/.test(file)
|
|
316
|
+
if (!isDropdown && !isMenu && !isNav) return {}
|
|
317
|
+
let hasOwner = false
|
|
318
|
+
return {
|
|
319
|
+
ExportNamedDeclaration(node) {
|
|
320
|
+
if (!isDropdown) return
|
|
321
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
322
|
+
if (/\bDropdownBranch(?:Item|Section)\b/.test(source.getText(node))) {
|
|
323
|
+
context.report({ node, messageId: "pieces" })
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
ImportDeclaration(node) {
|
|
327
|
+
if (isDropdown && node.source?.value === "@heroui/react") {
|
|
328
|
+
hasOwner ||= (node.specifiers || []).some((specifier) => specifier.imported?.name === "Dropdown")
|
|
329
|
+
}
|
|
330
|
+
if (isMenu && node.source?.value === "@heroui/react") context.report({ node, messageId: "vendor" })
|
|
331
|
+
if (isMenu && /\/components\/branches\/DropdownBranch$/.test(normalizePath(node.source?.value))) {
|
|
332
|
+
hasOwner = true
|
|
333
|
+
if ((node.specifiers || []).some((specifier) =>
|
|
334
|
+
/^(?:DropdownBranchSection|DropdownBranchItem)/.test(specifier.imported?.name || ""))) {
|
|
335
|
+
context.report({ node, messageId: "pieces" })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (isNav && /\/components\/blocks\/auth\/AccountMenu$/.test(normalizePath(node.source?.value))) hasOwner = true
|
|
339
|
+
},
|
|
340
|
+
JSXOpeningElement(node) {
|
|
341
|
+
if (!isNav || node.name?.type !== "JSXIdentifier" || node.name.name !== "IconButton") return
|
|
342
|
+
const props = (node.attributes || []).find((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "props")
|
|
343
|
+
const expression = props?.value?.type === "JSXExpressionContainer" ? props.value.expression : null
|
|
344
|
+
const isAccount = expression?.type === "ObjectExpression" && expression.properties.some((property) =>
|
|
345
|
+
property.type === "Property" && property.key?.name === "icon" && property.value?.value === "account")
|
|
346
|
+
const hasAction = (node.attributes || []).some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.name === "on")
|
|
347
|
+
if (isAccount && hasAction) context.report({ node, messageId: "direct" })
|
|
348
|
+
},
|
|
349
|
+
"Program:exit"(node) {
|
|
350
|
+
if (hasOwner) return
|
|
351
|
+
context.report({ node, messageId: isDropdown ? "dropdown" : isMenu ? "branch" : "menu" })
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// -- VENDOR-13 -------------------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
/** Checkbox.Content owns the control, indicator and label so every visible word toggles it. */
|
|
360
|
+
export const checkboxKeepsCompoundAnatomy = {
|
|
361
|
+
meta: {
|
|
362
|
+
type: "problem",
|
|
363
|
+
docs: { description: "Keep the required HeroUI Checkbox nesting inside the house leaf." },
|
|
364
|
+
schema: [],
|
|
365
|
+
messages: {
|
|
366
|
+
anatomy:
|
|
367
|
+
"Checkbox must nest `HeroCheckbox.Control` (and its Indicator) inside `HeroCheckbox.Content`. Sibling Control and Content leave the visible label outside the checkbox's press target.",
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
create(context) {
|
|
371
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
372
|
+
if (!/\/src\/components\/leaves\/Checkbox\/index\.tsx$/.test(file)) return {}
|
|
373
|
+
let hasContent = false
|
|
374
|
+
let controlInsideContent = false
|
|
375
|
+
let indicatorInsideControl = false
|
|
376
|
+
const hasAncestor = (node, expected) => {
|
|
377
|
+
let ancestor = node.parent
|
|
378
|
+
while (ancestor) {
|
|
379
|
+
if (ancestor.type === "JSXElement" && memberName(ancestor.openingElement?.name) === expected) return true
|
|
380
|
+
ancestor = ancestor.parent
|
|
381
|
+
}
|
|
382
|
+
return false
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
JSXOpeningElement(node) {
|
|
386
|
+
const name = memberName(node.name)
|
|
387
|
+
if (name === "HeroCheckbox.Content") hasContent = true
|
|
388
|
+
if (name === "HeroCheckbox.Control" && hasAncestor(node, "HeroCheckbox.Content")) controlInsideContent = true
|
|
389
|
+
if (name === "HeroCheckbox.Indicator" && hasAncestor(node, "HeroCheckbox.Control")) indicatorInsideControl = true
|
|
390
|
+
},
|
|
391
|
+
"Program:exit"(node) {
|
|
392
|
+
if (!hasContent || !controlInsideContent || !indicatorInsideControl) {
|
|
393
|
+
context.report({ node, messageId: "anatomy" })
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// -- VENDOR-14 -------------------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/** Internal StarCi navigation is an action resolved by connected code, never an href. */
|
|
403
|
+
export const noInternalStarciHref = {
|
|
404
|
+
meta: {
|
|
405
|
+
type: "problem",
|
|
406
|
+
docs: { description: "Route internal StarCi destinations through connected router.push actions." },
|
|
407
|
+
schema: [],
|
|
408
|
+
messages: {
|
|
409
|
+
internal:
|
|
410
|
+
"Internal StarCi navigation must not use `href`. Keep the destination in connected code, report an id/press from the pure component, and call `router.push` there.",
|
|
411
|
+
leaf:
|
|
412
|
+
"This internal-navigation leaf must not declare or render `href`; it reports `on.press` and leaves routing to its connected owner.",
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
create(context) {
|
|
416
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
417
|
+
if (!file.includes("/src/") || /\.(?:test|spec)\.[jt]sx?$/.test(file)) return {}
|
|
418
|
+
const internalOnlyLeaf = /\/src\/components\/leaves\/(?:Checkbox|NavLink|QuickActionRow|SeeMoreLink)\/index\.tsx$/.test(file)
|
|
419
|
+
const literalText = (node) => {
|
|
420
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value
|
|
421
|
+
if (node?.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0]?.value?.cooked || ""
|
|
422
|
+
return null
|
|
423
|
+
}
|
|
424
|
+
const isInternal = (value) => value.startsWith("/") || /^https?:\/\/(?:www\.)?academy\.starci\.org(?:\/|$)/i.test(value)
|
|
425
|
+
return {
|
|
426
|
+
JSXAttribute(node) {
|
|
427
|
+
if (node.name?.name !== "href") return
|
|
428
|
+
if (internalOnlyLeaf) {
|
|
429
|
+
context.report({ node, messageId: "leaf" })
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
const value = node.value?.type === "JSXExpressionContainer" ? literalText(node.value.expression) : literalText(node.value)
|
|
433
|
+
if (value !== null && isInternal(value)) context.report({ node, messageId: "internal" })
|
|
434
|
+
},
|
|
435
|
+
Property(node) {
|
|
436
|
+
const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
|
|
437
|
+
const value = literalText(node.value)
|
|
438
|
+
if ((key === "href" || key === "externalHref") && value !== null && isInternal(value)) {
|
|
439
|
+
context.report({ node, messageId: "internal" })
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
TSPropertySignature(node) {
|
|
443
|
+
if (!internalOnlyLeaf || node.key?.name !== "href") return
|
|
444
|
+
context.report({ node, messageId: "leaf" })
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// -- VENDOR-12 -------------------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
/** The auth overlay projects one already-owned column and adds no vertical inset of its own. */
|
|
453
|
+
export const authOverlayOwnsSingleContentHost = {
|
|
454
|
+
meta: {
|
|
455
|
+
type: "problem",
|
|
456
|
+
docs: { description: "Authentication has one zero-inset content host inside ModalBranch or DrawerBranch." },
|
|
457
|
+
schema: [],
|
|
458
|
+
messages: {
|
|
459
|
+
duplicate: "SignInOverlay must project through one host only: do not open a second `Tree`/`ContractContent` inside a branch that already receives `contract`/`render` directly, and do not open `Tree` by hand instead of importing `ContractContent`.",
|
|
460
|
+
missing: "SignInOverlay must host its content either by importing `ContractContent` for its already-hosted projection, or by passing `contract` and `render` props directly to `ModalBranch`/`DrawerBranch`.",
|
|
461
|
+
inset: "`centred-page-column` must not add py/pt/pb. ModalBranch is zero-inset and the auth content touches that scroll region without a second vertical padding band.",
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
create(context) {
|
|
465
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
466
|
+
const isOverlay = /\/src\/components\/overlays\/auth\/SignInOverlay\/component\.tsx$/.test(file)
|
|
467
|
+
const isContracts = /\/src\/components\/contracts\/index\.ts$/.test(file)
|
|
468
|
+
if (!isOverlay && !isContracts) return {}
|
|
469
|
+
let hasContent = false
|
|
470
|
+
|
|
471
|
+
/*
|
|
472
|
+
* Two call shapes satisfy the same invariant - one contract-driven content host, zero extra
|
|
473
|
+
* vertical inset - and both are recognised here.
|
|
474
|
+
*
|
|
475
|
+
* (a) THE OLD SHAPE: the overlay imports `ContractContent` and mounts it itself as the branch's
|
|
476
|
+
* children. `ContractContent` IS the checked host, so importing it is enough to credit the
|
|
477
|
+
* overlay with owning one.
|
|
478
|
+
*
|
|
479
|
+
* (b) THE NEW SHAPE: `ModalBranch`/`DrawerBranch` were migrated to take `{contract, render}`
|
|
480
|
+
* directly and build `<Tree contract render />` internally (SLOTS-4). An overlay that hands both
|
|
481
|
+
* props straight to the branch has delegated the same host to the branch itself; there is
|
|
482
|
+
* nothing left for the overlay to import.
|
|
483
|
+
*
|
|
484
|
+
* Matched by JSX ATTRIBUTE NAME, not by where `ModalBranch`/`DrawerBranch` resolve from - same
|
|
485
|
+
* reasoning VENDOR-12 already applies to `ContractContent`/`Tree` below: canon vocabulary means
|
|
486
|
+
* the same thing wherever it is imported from.
|
|
487
|
+
*/
|
|
488
|
+
const MECHANICS_BRANCH_NAMES = new Set(["ModalBranch", "DrawerBranch"])
|
|
489
|
+
const hasJsxAttribute = (attributes, attrName) =>
|
|
490
|
+
(attributes || []).some((attribute) =>
|
|
491
|
+
attribute.type === "JSXAttribute"
|
|
492
|
+
&& attribute.name?.type === "JSXIdentifier"
|
|
493
|
+
&& attribute.name.name === attrName)
|
|
494
|
+
const jsxChildName = (child) => {
|
|
495
|
+
if (child?.type !== "JSXElement") return null
|
|
496
|
+
const name = child.openingElement?.name
|
|
497
|
+
return name?.type === "JSXIdentifier" ? name.name : null
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
ImportDeclaration(node) {
|
|
502
|
+
if (!isOverlay) return
|
|
503
|
+
/*
|
|
504
|
+
* Matched by the NAME imported, not by where it came from - see the comment above.
|
|
505
|
+
*/
|
|
506
|
+
for (const specifier of node.specifiers || []) {
|
|
507
|
+
if (specifier.imported?.name === "ContractContent") hasContent = true
|
|
508
|
+
// Opening `Tree` by hand is the one thing neither shape ever does: both shapes close the
|
|
509
|
+
// host either inside ContractContent or inside the mechanics branch itself.
|
|
510
|
+
if (specifier.imported?.name === "Tree") context.report({ node: specifier, messageId: "duplicate" })
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
JSXElement(node) {
|
|
514
|
+
if (!isOverlay) return
|
|
515
|
+
const name = node.openingElement?.name
|
|
516
|
+
if (name?.type !== "JSXIdentifier" || !MECHANICS_BRANCH_NAMES.has(name.name)) return
|
|
517
|
+
const attributes = node.openingElement.attributes
|
|
518
|
+
if (!hasJsxAttribute(attributes, "contract") || !hasJsxAttribute(attributes, "render")) return
|
|
519
|
+
// The branch itself now owns the host: shape (b).
|
|
520
|
+
hasContent = true
|
|
521
|
+
// A second content host nested as children, when the branch already receives contract/render
|
|
522
|
+
// directly, duplicates the host the branch already opened.
|
|
523
|
+
const nestedHost = (node.children || []).some((child) =>
|
|
524
|
+
jsxChildName(child) === "Tree" || jsxChildName(child) === "ContractContent")
|
|
525
|
+
if (nestedHost) context.report({ node, messageId: "duplicate" })
|
|
526
|
+
},
|
|
527
|
+
Property(node) {
|
|
528
|
+
if (!isContracts) return
|
|
529
|
+
const key = node.key?.type === "Literal" ? node.key.value : node.key?.name
|
|
530
|
+
if (key !== "centred-page-column") return
|
|
531
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
532
|
+
if (/["'`](?:py|pt|pb)-/.test(source.getText(node))) context.report({ node, messageId: "inset" })
|
|
533
|
+
},
|
|
534
|
+
"Program:exit"(node) {
|
|
535
|
+
if (isOverlay && !hasContent) context.report({ node, messageId: "missing" })
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* `VENDOR-15` - a table is written through `TableBranch` or it is not written.
|
|
543
|
+
*
|
|
544
|
+
* WHY THIS RULE EXISTS BESIDE `vendor-boundary`. That rule refuses a vendor IMPORT written in the
|
|
545
|
+
* wrong file, so it can only see boundaries that were crossed by importing something. A table built
|
|
546
|
+
* by hand imports nothing at all: it is `<table>` and `<tr>` and a column of borders, and the vendor
|
|
547
|
+
* it stands in for is never named anywhere in the file. That is the quieter failure and the one this
|
|
548
|
+
* rule is for - it refuses the ABSENCE, where its neighbour refuses the presence.
|
|
549
|
+
*
|
|
550
|
+
* WHAT IT READS. The tag names of the table family, and the ARIA roles that build a table without
|
|
551
|
+
* them. Reading both matters because the two substitutes look nothing alike in source: one is a
|
|
552
|
+
* semantic `<table>` somebody thought was harmless, the other is a grid of `div`s wearing
|
|
553
|
+
* `role="row"`. Neither is refused inside `TableBranch`, which is the one file that may draw them.
|
|
554
|
+
*
|
|
555
|
+
* WHAT IT CANNOT SEE. A grid of plain `div`s carrying no role at all, an element name resolved at
|
|
556
|
+
* runtime, and a table reached through a re-export under another name. It reads what is written; a
|
|
557
|
+
* hand-built grid that declares neither tag nor role is invisible to it, which is the same blind
|
|
558
|
+
* spot every anatomy rule in this file carries.
|
|
559
|
+
*/
|
|
560
|
+
const TABLE_TAGS = new Set(["table", "thead", "tbody", "tfoot", "tr", "th", "td"])
|
|
561
|
+
const TABLE_ROLES = new Set(["table", "grid", "row", "rowgroup", "columnheader", "rowheader", "gridcell"])
|
|
562
|
+
|
|
563
|
+
export const tablesGoThroughTableBranch = {
|
|
564
|
+
meta: {
|
|
565
|
+
type: "problem",
|
|
566
|
+
docs: { description: "Rows and columns are drawn by TableBranch, never rebuilt by hand." },
|
|
567
|
+
schema: [],
|
|
568
|
+
messages: {
|
|
569
|
+
tag: "`<{{name}}>` is table anatomy written by hand. `TableBranch` owns HeroUI `Table` and its compound parts; pass it cells and let it rebuild the header, the row-header column and the scroll container.",
|
|
570
|
+
role: "`role=\"{{name}}\"` builds a table out of neutral elements. `TableBranch` owns the vendor that already reports these roles; a hand-built grid loses its keyboard model and its accessible name.",
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
create(context) {
|
|
574
|
+
const file = normalizePath(context.filename || context.getFilename?.() || "")
|
|
575
|
+
// The owner may draw what it owns. Everything else in the component tier may not.
|
|
576
|
+
if (!isComponentFile(file) || TABLE_BRANCH.test(file)) return {}
|
|
577
|
+
return {
|
|
578
|
+
JSXOpeningElement(node) {
|
|
579
|
+
const name = node.name
|
|
580
|
+
if (name?.type === "JSXIdentifier" && TABLE_TAGS.has(name.name)) {
|
|
581
|
+
context.report({ node: name, messageId: "tag", data: { name: name.name } })
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
for (const attribute of node.attributes || []) {
|
|
585
|
+
if (attribute.type !== "JSXAttribute" || attribute.name?.name !== "role") continue
|
|
586
|
+
const value = attribute.value
|
|
587
|
+
if (value?.type !== "Literal" || typeof value.value !== "string") continue
|
|
588
|
+
if (!TABLE_ROLES.has(value.value)) continue
|
|
589
|
+
context.report({ node: attribute, messageId: "role", data: { name: value.value } })
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The rules this law contributes to the plugin. */
|
|
597
|
+
export const rules = {
|
|
598
|
+
"vendor-boundary": vendorBoundary,
|
|
599
|
+
"modal-branch-owns-scroll-body": modalBranchOwnsScrollBody,
|
|
600
|
+
"field-input-uses-secondary-variant": fieldInputUsesSecondaryVariant,
|
|
601
|
+
"field-label-is-text-only": fieldLabelIsTextOnly,
|
|
602
|
+
"no-surface-branch-in-overlay": noSurfaceBranchInOverlay,
|
|
603
|
+
"text-link-uses-hero-link": textLinkUsesHeroLink,
|
|
604
|
+
"account-control-owns-dropdown": accountControlOwnsDropdown,
|
|
605
|
+
"auth-overlay-owns-single-content-host": authOverlayOwnsSingleContentHost,
|
|
606
|
+
"checkbox-keeps-compound-anatomy": checkboxKeepsCompoundAnatomy,
|
|
607
|
+
"no-internal-starci-href": noInternalStarciHref,
|
|
608
|
+
"tables-go-through-table-branch": tablesGoThroughTableBranch,
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
613
|
+
*
|
|
614
|
+
* A repository adopting this with history should expect the outward half to report and the inward
|
|
615
|
+
* half to be silent until named mechanics branches exist. Creating those owners is the migration;
|
|
616
|
+
* no anonymous wrapper tier is introduced.
|
|
617
|
+
*/
|
|
618
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|