@starci/eslint-canon-fe 1.4.1 → 1.4.3
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 +1363 -1295
- package/contracts.ts +1 -1
- package/file-layout.mjs +414 -310
- package/icon.mjs +12 -98
- package/landmark.mjs +242 -242
- package/lint-adoption.mjs +116 -116
- package/naming.mjs +36 -7
- package/package.json +38 -38
- package/props-and-slots.mjs +278 -14
- package/props.ts +33 -33
- package/served-locale.mjs +1 -1
- package/the-split.mjs +2 -2
- package/typography.mjs +83 -83
- package/vendor-boundary.mjs +85 -62
package/contract.mjs
CHANGED
|
@@ -1,1295 +1,1363 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `contract.md`.
|
|
3
|
-
*
|
|
4
|
-
* One idea, stated several ways: a structural node is described ONCE, by a key that owns the node's
|
|
5
|
-
* classes, the element it opens, and the reason its children sit that way. An author types the key
|
|
6
|
-
* and nothing else. Everything here exists to make the alternatives - a literal class string, a
|
|
7
|
-
* hand-composed class, a hand-painted marker, an invented key, a bare `div` - stop compiling in
|
|
8
|
-
* review the way a wrong tree already stops compiling in TypeScript.
|
|
9
|
-
*
|
|
10
|
-
* THE STRONGEST RULES HERE ARE NOT RULES. The class vocabulary and the host vocabulary are closed
|
|
11
|
-
* unions in `contracts.ts`, so an off-scale value is unrepresentable rather than forbidden. These
|
|
12
|
-
* rules cover what a type cannot see: which FILE wrote the string, and whether the key exists.
|
|
13
|
-
*
|
|
14
|
-
* TWO DELIBERATE DEPARTURES from the source this was ported from, both recorded so the next reader
|
|
15
|
-
* does not "restore" them:
|
|
16
|
-
*
|
|
17
|
-
* - NO RULE PATROLS WHAT FILLS A SLOT, and that was measured rather than assumed. A slot resolves
|
|
18
|
-
* to `LeafComponent<name, props>`, so the compiler was handed an inline arrow, a named function
|
|
19
|
-
* with no metadata, and metadata carrying the WRONG name: it refused all three. A rule here
|
|
20
|
-
* would re-check a door the type already holds shut, and cost a rule to keep it doing so.
|
|
21
|
-
* - `noUnknownSlotRole` is absent, and it is NOT coming back even though the child contract has.
|
|
22
|
-
* It policed a `slots` object against roles a key declared, and stopped being able to when
|
|
23
|
-
* contracts collapsed to classes-plus-a-reason. Entries declare their slots again now - but as
|
|
24
|
-
* a record the COMPILER reads, so a wrong leaf, wrong leaf props, a missing slot or a slot
|
|
25
|
-
* nobody declared are type errors before any rule runs. Re-adding a rule here would patrol a
|
|
26
|
-
* door the type already holds shut, which is the weaker of the two and costs a rule to keep.
|
|
27
|
-
* - The list of banned elements includes `ul`, `ol`, `li` and `form`. The original stopped at the
|
|
28
|
-
* neutral boxes, from an era when an entry could only open a `div`. An entry names its own host
|
|
29
|
-
* now, so a hand-written `<ul>` is a node with no key exactly like a hand-written `<div>` - and
|
|
30
|
-
* leaving it lawful is what let arrangements be filed among the leaves to escape these rules.
|
|
31
|
-
*
|
|
32
|
-
* This module also exports the path helpers and the table reader, because both describe the
|
|
33
|
-
* contract table rather than any one rule. Other law modules import them from here.
|
|
34
|
-
*/
|
|
35
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
36
|
-
import { dirname, join } from "node:path"
|
|
37
|
-
|
|
38
|
-
// -- where things are ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Every prefix a component tree can sit under.
|
|
42
|
-
*
|
|
43
|
-
* ONE TIER, TWO LAYOUTS. A single-app repository keeps the tree at `src/components/*`; a monorepo
|
|
44
|
-
* keeps the same tree, with the same tier names, in a shared package at `packages/ui/src/*`. Only
|
|
45
|
-
* the prefix differs, so a rule that writes the prefix out by hand works in one repository and is
|
|
46
|
-
* BLIND in the other - and blind here does not mean noisy, it means silently wrong in both
|
|
47
|
-
* directions at once. A leaf it cannot recognise is reported for writing the classes a leaf is
|
|
48
|
-
* supposed to write, while every rule that guards the leaf tier stops guarding anything.
|
|
49
|
-
*
|
|
50
|
-
* That is not hypothetical. Pointed at a monorepo, this file produced 46 errors across 28 correct
|
|
51
|
-
* files - every one of them a leaf declaring `meta = { shape: "leaf" }` in `packages/ui/src/leaves`
|
|
52
|
-
* - and the obvious reading was that the repository owed 46 fixes. It owed none.
|
|
53
|
-
*
|
|
54
|
-
* A layout added to this list is added to every rule at once, which is the only reason there is one
|
|
55
|
-
* list rather than a literal per predicate.
|
|
56
|
-
*/
|
|
57
|
-
export const COMPONENT_ROOTS = ["src/components", "packages/ui/src", "src"]
|
|
58
|
-
|
|
59
|
-
/** The contract folder, relative to the repository root. */
|
|
60
|
-
export const CONTRACT_DIR_RELATIVE = "src/components/contracts"
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* The entry table, relative to the repository root.
|
|
64
|
-
*
|
|
65
|
-
* THIS CONSTANT IS THE ONE THING HERE THAT CAN BE WRONG WITHOUT ANYTHING TURNING RED. When the
|
|
66
|
-
* table moved and this path did not, the reader returned null, every rule that reads it did
|
|
67
|
-
* nothing, and eslint reported a clean tree while an invented key walked through. The twin test
|
|
68
|
-
* asserts a real file parses for exactly that reason - never delete that test to make a move
|
|
69
|
-
* easier.
|
|
70
|
-
*/
|
|
71
|
-
export const CONTRACT_TABLE_RELATIVE = `${CONTRACT_DIR_RELATIVE}/index.ts`
|
|
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
|
-
export const
|
|
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
|
-
const
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
export const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
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
|
-
return
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
return
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const
|
|
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
|
-
return
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/**
|
|
543
|
-
const
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
)
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
type
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if (
|
|
597
|
-
const
|
|
598
|
-
if (!
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
716
|
-
},
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
type
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
*
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
//
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
return out
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
const
|
|
1251
|
-
return {
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `contract.md`.
|
|
3
|
+
*
|
|
4
|
+
* One idea, stated several ways: a structural node is described ONCE, by a key that owns the node's
|
|
5
|
+
* classes, the element it opens, and the reason its children sit that way. An author types the key
|
|
6
|
+
* and nothing else. Everything here exists to make the alternatives - a literal class string, a
|
|
7
|
+
* hand-composed class, a hand-painted marker, an invented key, a bare `div` - stop compiling in
|
|
8
|
+
* review the way a wrong tree already stops compiling in TypeScript.
|
|
9
|
+
*
|
|
10
|
+
* THE STRONGEST RULES HERE ARE NOT RULES. The class vocabulary and the host vocabulary are closed
|
|
11
|
+
* unions in `contracts.ts`, so an off-scale value is unrepresentable rather than forbidden. These
|
|
12
|
+
* rules cover what a type cannot see: which FILE wrote the string, and whether the key exists.
|
|
13
|
+
*
|
|
14
|
+
* TWO DELIBERATE DEPARTURES from the source this was ported from, both recorded so the next reader
|
|
15
|
+
* does not "restore" them:
|
|
16
|
+
*
|
|
17
|
+
* - NO RULE PATROLS WHAT FILLS A SLOT, and that was measured rather than assumed. A slot resolves
|
|
18
|
+
* to `LeafComponent<name, props>`, so the compiler was handed an inline arrow, a named function
|
|
19
|
+
* with no metadata, and metadata carrying the WRONG name: it refused all three. A rule here
|
|
20
|
+
* would re-check a door the type already holds shut, and cost a rule to keep it doing so.
|
|
21
|
+
* - `noUnknownSlotRole` is absent, and it is NOT coming back even though the child contract has.
|
|
22
|
+
* It policed a `slots` object against roles a key declared, and stopped being able to when
|
|
23
|
+
* contracts collapsed to classes-plus-a-reason. Entries declare their slots again now - but as
|
|
24
|
+
* a record the COMPILER reads, so a wrong leaf, wrong leaf props, a missing slot or a slot
|
|
25
|
+
* nobody declared are type errors before any rule runs. Re-adding a rule here would patrol a
|
|
26
|
+
* door the type already holds shut, which is the weaker of the two and costs a rule to keep.
|
|
27
|
+
* - The list of banned elements includes `ul`, `ol`, `li` and `form`. The original stopped at the
|
|
28
|
+
* neutral boxes, from an era when an entry could only open a `div`. An entry names its own host
|
|
29
|
+
* now, so a hand-written `<ul>` is a node with no key exactly like a hand-written `<div>` - and
|
|
30
|
+
* leaving it lawful is what let arrangements be filed among the leaves to escape these rules.
|
|
31
|
+
*
|
|
32
|
+
* This module also exports the path helpers and the table reader, because both describe the
|
|
33
|
+
* contract table rather than any one rule. Other law modules import them from here.
|
|
34
|
+
*/
|
|
35
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
36
|
+
import { dirname, join } from "node:path"
|
|
37
|
+
|
|
38
|
+
// -- where things are ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every prefix a component tree can sit under.
|
|
42
|
+
*
|
|
43
|
+
* ONE TIER, TWO LAYOUTS. A single-app repository keeps the tree at `src/components/*`; a monorepo
|
|
44
|
+
* keeps the same tree, with the same tier names, in a shared package at `packages/ui/src/*`. Only
|
|
45
|
+
* the prefix differs, so a rule that writes the prefix out by hand works in one repository and is
|
|
46
|
+
* BLIND in the other - and blind here does not mean noisy, it means silently wrong in both
|
|
47
|
+
* directions at once. A leaf it cannot recognise is reported for writing the classes a leaf is
|
|
48
|
+
* supposed to write, while every rule that guards the leaf tier stops guarding anything.
|
|
49
|
+
*
|
|
50
|
+
* That is not hypothetical. Pointed at a monorepo, this file produced 46 errors across 28 correct
|
|
51
|
+
* files - every one of them a leaf declaring `meta = { shape: "leaf" }` in `packages/ui/src/leaves`
|
|
52
|
+
* - and the obvious reading was that the repository owed 46 fixes. It owed none.
|
|
53
|
+
*
|
|
54
|
+
* A layout added to this list is added to every rule at once, which is the only reason there is one
|
|
55
|
+
* list rather than a literal per predicate.
|
|
56
|
+
*/
|
|
57
|
+
export const COMPONENT_ROOTS = ["src/components", "packages/ui/src", "src"]
|
|
58
|
+
|
|
59
|
+
/** The contract folder, relative to the repository root. */
|
|
60
|
+
export const CONTRACT_DIR_RELATIVE = "src/components/contracts"
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The entry table, relative to the repository root.
|
|
64
|
+
*
|
|
65
|
+
* THIS CONSTANT IS THE ONE THING HERE THAT CAN BE WRONG WITHOUT ANYTHING TURNING RED. When the
|
|
66
|
+
* table moved and this path did not, the reader returned null, every rule that reads it did
|
|
67
|
+
* nothing, and eslint reported a clean tree while an invented key walked through. The twin test
|
|
68
|
+
* asserts a real file parses for exactly that reason - never delete that test to make a move
|
|
69
|
+
* easier.
|
|
70
|
+
*/
|
|
71
|
+
export const CONTRACT_TABLE_RELATIVE = `${CONTRACT_DIR_RELATIVE}/index.ts`
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Where to tell THIS file's author the table is.
|
|
75
|
+
*
|
|
76
|
+
* `CONTRACT_TABLE_RELATIVE` is the single-app spelling, and a message is the one place a hardcoded
|
|
77
|
+
* prefix survives a monorepo without turning anything red: no predicate reads it, so nothing fails —
|
|
78
|
+
* the author is simply sent to a path their repository does not have, and the rule looks wrong rather
|
|
79
|
+
* than the message. Resolve it from the linted file instead, off the same closed list every predicate
|
|
80
|
+
* here already walks.
|
|
81
|
+
*
|
|
82
|
+
* @param filename - the file being linted.
|
|
83
|
+
* @returns the entry table as it is spelled in that file's layout.
|
|
84
|
+
*/
|
|
85
|
+
export const contractTableFor = (filename) => {
|
|
86
|
+
const path = normalizePath(filename || "")
|
|
87
|
+
const root = COMPONENT_ROOTS.find((candidate) => path.includes(`/${candidate}/`))
|
|
88
|
+
return root ? `${root}/contracts/index.ts` : CONTRACT_TABLE_RELATIVE
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The one component that turns an entry into a real element. */
|
|
92
|
+
export const CONTRACT_FRAME_RELATIVE = "src/components/branches/Tree"
|
|
93
|
+
|
|
94
|
+
/** The tier that owns its own interior, and is therefore outside these rules. */
|
|
95
|
+
export const LEAF_DIR_RELATIVE = "src/components/leaves"
|
|
96
|
+
|
|
97
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
98
|
+
export const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether a file sits in one named tier, under any supported layout.
|
|
102
|
+
*
|
|
103
|
+
* @param filename - the file being linted.
|
|
104
|
+
* @param segment - the tier path below the component root, e.g. `leaves` or `branches/Tree`.
|
|
105
|
+
* @returns true when the file is inside that tier in any layout.
|
|
106
|
+
*/
|
|
107
|
+
export const isInComponentTier = (filename, segment) =>
|
|
108
|
+
COMPONENT_ROOTS.some((root) => normalizePath(filename).includes(`/${root}/${segment}/`))
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Candidate paths for one component-tree file, one per supported layout.
|
|
112
|
+
*
|
|
113
|
+
* @param relative - path below the component root, e.g. `contracts/index.ts`.
|
|
114
|
+
* @returns one candidate per known root, most specific first.
|
|
115
|
+
*/
|
|
116
|
+
export const componentCandidates = (relative) => COMPONENT_ROOTS.map((root) => `${root}/${relative}`)
|
|
117
|
+
|
|
118
|
+
/** True when this file holds the entry table - the one place a class string or a reason is written. */
|
|
119
|
+
export const isContractTableFile = (filename) =>
|
|
120
|
+
componentCandidates("contracts/index.ts")
|
|
121
|
+
.some((candidate) => normalizePath(filename).endsWith(`/${candidate}`))
|
|
122
|
+
|
|
123
|
+
/** True when this file is the frame that renders an entry, and therefore paints its markers. */
|
|
124
|
+
export const isContractFrameFile = (filename) => isInComponentTier(filename, "branches/Tree")
|
|
125
|
+
|
|
126
|
+
/** Named surface branches own fixed vendor-wrapper mechanics around one checked content node. */
|
|
127
|
+
export const isSurfaceContractHostFile = (filename) =>
|
|
128
|
+
["SurfaceCard", "SurfaceAccordionCard", "SurfaceListCard", "SurfaceFormCard"]
|
|
129
|
+
.some((surface) => isInComponentTier(filename, `branches/${surface}`))
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* True when this file is a leaf.
|
|
133
|
+
*
|
|
134
|
+
* A leaf wraps ONE vendor primitive and owns the glue that holds one line together, so it writes
|
|
135
|
+
* that much itself and there is nothing for the contract table to tune.
|
|
136
|
+
*
|
|
137
|
+
* IT IS NOT "A FIXED CLUSTER". That wider wording used to sit here, and it is what let every
|
|
138
|
+
* arrangement in the codebase be filed in this folder: it was the only place a component could
|
|
139
|
+
* write its own classes, so anything lacking a key moved in. The cost was measurable rather than
|
|
140
|
+
* theoretical - entries ended up with class strings byte-for-byte identical to constants typed by
|
|
141
|
+
* hand in the leaf folder, and nothing could see the duplication because no rule reads both.
|
|
142
|
+
*
|
|
143
|
+
* THE EXEMPTION IS A FOLDER, which makes it a policy boundary rather than a type: anyone can escape
|
|
144
|
+
* these rules by filing a component here. What keeps a region out is a question a person asks -
|
|
145
|
+
* does this file arrange two contents? If yes it is a composite. No gate asks that question.
|
|
146
|
+
*/
|
|
147
|
+
export const isLeafFile = (filename) => isInComponentTier(filename, "leaves")
|
|
148
|
+
|
|
149
|
+
/** A twin test may build fixture markup by hand; product source may not. */
|
|
150
|
+
export const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|cjs)$/.test(normalizePath(filename))
|
|
151
|
+
|
|
152
|
+
/** Product source lives under `src/`; tooling and config are out of scope. */
|
|
153
|
+
export const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
|
|
154
|
+
|
|
155
|
+
/** Product source these rules govern: under `src/`, not a test, not a leaf, not the frame. */
|
|
156
|
+
export const isGovernedFile = (filename) => {
|
|
157
|
+
const file = normalizePath(filename)
|
|
158
|
+
if (!isSourceFile(file) || isTestFile(file)) return false
|
|
159
|
+
return !isLeafFile(file) && !isContractFrameFile(file)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// -- reading the table --------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** Parsed tables, keyed by path and invalidated by the file's mtime. */
|
|
165
|
+
const cache = new Map()
|
|
166
|
+
|
|
167
|
+
/** Walk up from a linted file to the contract table; null when none sits above it. */
|
|
168
|
+
export const findContractTable = (filename) => {
|
|
169
|
+
/*
|
|
170
|
+
* Every layout's table path is tried at each level, not just the single-app one. Walking up
|
|
171
|
+
* looking only for `src/components/contracts/index.ts` finds nothing in a monorepo, and the
|
|
172
|
+
* reader's own header records what that silence costs: it returns null, every rule that reads it
|
|
173
|
+
* does nothing, and eslint reports a clean tree while an invented key walks through.
|
|
174
|
+
*/
|
|
175
|
+
const candidates = componentCandidates("contracts/index.ts")
|
|
176
|
+
let dir = dirname(normalizePath(filename))
|
|
177
|
+
for (let depth = 0; depth < 40; depth += 1) {
|
|
178
|
+
for (const relative of candidates) {
|
|
179
|
+
const candidate = normalizePath(join(dir, relative))
|
|
180
|
+
if (existsSync(candidate)) return candidate
|
|
181
|
+
}
|
|
182
|
+
const parent = dirname(dir)
|
|
183
|
+
if (!parent || parent === dir) return null
|
|
184
|
+
dir = parent
|
|
185
|
+
}
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The text of one `buildX({ ... })` call, brace-balanced.
|
|
191
|
+
*
|
|
192
|
+
* READING TO THE END OF THE FILE IS NOT GOOD ENOUGH, and that is not a hypothetical. The moment a
|
|
193
|
+
* second table joined the first in this file, a reader that sliced to EOF collected unrelated keys
|
|
194
|
+
* as node keys. The balanced reader stops at the actual end of the selected table.
|
|
195
|
+
*/
|
|
196
|
+
const balancedCall = (source, builder) => {
|
|
197
|
+
const at = source.indexOf(`${builder}({`)
|
|
198
|
+
if (at < 0) return null
|
|
199
|
+
const open = source.indexOf("{", at)
|
|
200
|
+
let depth = 0
|
|
201
|
+
for (let i = open; i < source.length; i += 1) {
|
|
202
|
+
const ch = source[i]
|
|
203
|
+
if (ch === "{") depth += 1
|
|
204
|
+
else if (ch === "}") {
|
|
205
|
+
depth -= 1
|
|
206
|
+
if (depth === 0) return source.slice(open, i + 1)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Every key of one table, read off its own call and no other. */
|
|
213
|
+
const parseKeys = (source, builder) => {
|
|
214
|
+
const body = balancedCall(source, builder)
|
|
215
|
+
if (!body) return []
|
|
216
|
+
return [...body.matchAll(/^\s{4}"([a-z][a-z-]*)":\s*\{/gm)].map((hit) => hit[1])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Read the contract table that governs a linted file.
|
|
221
|
+
*
|
|
222
|
+
* Returns null when there is no table above the file, or when it cannot be read as keys. A rule
|
|
223
|
+
* that gets null must do NOTHING: a table nobody can read is a reason to stay quiet, never a reason
|
|
224
|
+
* to call every call site wrong.
|
|
225
|
+
*
|
|
226
|
+
* Parsing is deliberately textual. An ESLint rule runs under one parser on one file at a time and
|
|
227
|
+
* cannot import a TypeScript module.
|
|
228
|
+
*
|
|
229
|
+
* @param filename - The file being linted.
|
|
230
|
+
*/
|
|
231
|
+
/**
|
|
232
|
+
* The element one contract key opens, read from the table.
|
|
233
|
+
*
|
|
234
|
+
* WHY A RULE NEEDS THIS. The document's main landmark used to be drawn by a branch called `Main`,
|
|
235
|
+
* so a rule could look for that name. The element now belongs to the entry - `host: "main"` beside
|
|
236
|
+
* the classes and the children - because a `<main>` is a MEANING and meaning lives with the rest of
|
|
237
|
+
* what the key fixes. A rule that still looks for the branch reports a correct landmark as missing.
|
|
238
|
+
*
|
|
239
|
+
* Textual, for the same reason `readContracts` is: a rule runs under one parser on one file and
|
|
240
|
+
* cannot import a TypeScript module. Absent `host` means `div`, which is what the frame defaults to.
|
|
241
|
+
*
|
|
242
|
+
* @param filename - The file being linted, used to locate the table above it.
|
|
243
|
+
* @param key - The contract key to read.
|
|
244
|
+
* @returns The host element name, or null when the table or the key cannot be read.
|
|
245
|
+
*/
|
|
246
|
+
export const contractHostOf = (filename, key) => {
|
|
247
|
+
const path = findContractTable(filename)
|
|
248
|
+
if (!path) return null
|
|
249
|
+
let source = null
|
|
250
|
+
try {
|
|
251
|
+
source = readFileSync(path, "utf8")
|
|
252
|
+
} catch {
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
const opening = source.indexOf(`"${key}": {`)
|
|
256
|
+
if (opening === -1) return null
|
|
257
|
+
// The entry ends at the next key at the same indentation; a bounded window is enough and avoids
|
|
258
|
+
// brace-matching a file this rule only needs one field from.
|
|
259
|
+
/*
|
|
260
|
+
* The window ENDS at the next key at the same indentation, which is what the comment above has
|
|
261
|
+
* always said and what the code did not do. A fixed 2000-character slice runs straight into the
|
|
262
|
+
* entries below it, so an entry declaring no host inherited the first one it could see - and any
|
|
263
|
+
* key written a few lines above `routed-page-main` was reported as drawing the document's main
|
|
264
|
+
* landmark. The rule was right about the law and wrong about where one entry stops.
|
|
265
|
+
*/
|
|
266
|
+
const rest = source.slice(opening + 1)
|
|
267
|
+
const nextKey = rest.search(/\n[ ]{4}"[a-z0-9-]+": \{/)
|
|
268
|
+
const window = nextKey === -1 ? source.slice(opening) : source.slice(opening, opening + 1 + nextKey)
|
|
269
|
+
const host = window.match(/\bhost:\s*"([a-z]+)"/)
|
|
270
|
+
return host ? host[1] : "div"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const readContracts = (filename) => {
|
|
274
|
+
const path = findContractTable(filename)
|
|
275
|
+
if (!path) return null
|
|
276
|
+
let stamp = null
|
|
277
|
+
try {
|
|
278
|
+
stamp = statSync(path).mtimeMs
|
|
279
|
+
} catch {
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
const hit = cache.get(path)
|
|
283
|
+
if (hit && hit.stamp === stamp) return hit.value
|
|
284
|
+
let source = null
|
|
285
|
+
try {
|
|
286
|
+
source = readFileSync(path, "utf8")
|
|
287
|
+
} catch {
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
const keys = parseKeys(source, "buildContracts")
|
|
291
|
+
const value = keys.length > 0 ? { path, keys } : null
|
|
292
|
+
cache.set(path, { stamp, value })
|
|
293
|
+
return value
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// -- reading who uses the table -------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The repository root the table hangs from, or null when the path is not a table path.
|
|
300
|
+
*
|
|
301
|
+
* The longest matching layout wins, because `src/contracts/index.ts` is a suffix of nothing here but
|
|
302
|
+
* would happily claim a monorepo path if the shortest candidate were tried first, and the root it
|
|
303
|
+
* returned would be a directory that does not exist.
|
|
304
|
+
*/
|
|
305
|
+
const repositoryRootOf = (tablePath) => {
|
|
306
|
+
const path = normalizePath(tablePath)
|
|
307
|
+
const candidates = [...componentCandidates("contracts/index.ts")].sort((a, b) => b.length - a.length)
|
|
308
|
+
for (const candidate of candidates) {
|
|
309
|
+
if (path.endsWith(`/${candidate}`)) return path.slice(0, path.length - candidate.length - 1)
|
|
310
|
+
}
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Every directory a call site of a contract key can be written in, for the repository holding one
|
|
316
|
+
* table.
|
|
317
|
+
*
|
|
318
|
+
* THE WORKSPACE SIBLINGS ARE THE WHOLE POINT, and leaving them out is not a smaller answer, it is
|
|
319
|
+
* the wrong one. A monorepo keeps the table in the shared package and renders it from the apps: in
|
|
320
|
+
* `nivo-fe` a handful of references sit beside the table under `packages/ui/src` and the rest live
|
|
321
|
+
* in the `src` tree of each workspace under `apps`. A reader that walked only `COMPONENT_ROOTS`
|
|
322
|
+
* would see those few, call every other
|
|
323
|
+
* key dead, and hand back a list of deletions that would take working screens off the air - the same
|
|
324
|
+
* shape of failure `COMPONENT_ROOTS` itself was written to end.
|
|
325
|
+
*
|
|
326
|
+
* Roots nested inside another root are dropped, so a tree is walked once rather than once per prefix
|
|
327
|
+
* that happens to name it.
|
|
328
|
+
*
|
|
329
|
+
* @param tablePath - the contract table's own path.
|
|
330
|
+
* @returns existing search roots, or an empty list when the repository root cannot be resolved.
|
|
331
|
+
*/
|
|
332
|
+
export const contractSearchRoots = (tablePath) => {
|
|
333
|
+
const root = repositoryRootOf(tablePath)
|
|
334
|
+
if (!root) return []
|
|
335
|
+
const candidates = new Set(COMPONENT_ROOTS.map((relative) => `${root}/${relative}`))
|
|
336
|
+
for (const group of ["apps", "packages"]) {
|
|
337
|
+
let entries = []
|
|
338
|
+
try {
|
|
339
|
+
entries = readdirSync(`${root}/${group}`, { withFileTypes: true })
|
|
340
|
+
} catch {
|
|
341
|
+
entries = []
|
|
342
|
+
}
|
|
343
|
+
for (const entry of entries) {
|
|
344
|
+
if (entry.isDirectory()) candidates.add(`${root}/${group}/${entry.name}/src`)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const present = [...candidates].filter((dir) => {
|
|
348
|
+
try {
|
|
349
|
+
return statSync(dir).isDirectory()
|
|
350
|
+
} catch {
|
|
351
|
+
return false
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
return present.filter((dir) => !present.some((other) => other !== dir && dir.startsWith(`${other}/`)))
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Directories that hold copies of source rather than source, and are never a call site. */
|
|
358
|
+
const UNWALKED_DIRS = new Set(["node_modules", ".next", "dist", ".artifacts"])
|
|
359
|
+
|
|
360
|
+
/** Files a reference can be written in. */
|
|
361
|
+
const WALKED_FILES = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Every way a key is named outside the table.
|
|
365
|
+
*
|
|
366
|
+
* All five are textual on purpose. The walk reads hundreds of files that are not the file being
|
|
367
|
+
* linted, under whatever parser that file would need, and a reference is a quoted key in every one
|
|
368
|
+
* of these forms - so a regex reads exactly as much as an AST would and reads it once.
|
|
369
|
+
*
|
|
370
|
+
* A story and a test count. They render the key, which is what the question asks: a shape proven in
|
|
371
|
+
* Storybook and nowhere else is documented rather than dead, and deleting it deletes the proof.
|
|
372
|
+
*/
|
|
373
|
+
const REFERENCE_PATTERNS = [
|
|
374
|
+
/\bcontract\s*=\s*\{?\s*"([a-z][a-z0-9-]*)"/g,
|
|
375
|
+
/\bcontract\s*:\s*"([a-z][a-z0-9-]*)"/g,
|
|
376
|
+
/\bdefineContractComponent\(\s*"([a-z][a-z0-9-]*)"/g,
|
|
377
|
+
/\bdefineContractProjection\(\s*"([a-z][a-z0-9-]*)"/g,
|
|
378
|
+
/\bCONTRACTS\[\s*"([a-z][a-z0-9-]*)"\s*\]/g,
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A key does not always reach the frame written next to it, and the difference is not stylistic.
|
|
383
|
+
*
|
|
384
|
+
* A component that picks between sibling entries names them somewhere else entirely: in
|
|
385
|
+
* `starci-academy-fe`, `RankedUserRow` resolves its verdict through a helper typed `ContractKey` that
|
|
386
|
+
* RETURNS the key, and hands the frame a variable. The five forms above see nothing there, and three
|
|
387
|
+
* keys that draw the leaderboard on every load were reported as drawn by nobody.
|
|
388
|
+
*
|
|
389
|
+
* So a file that speaks the `ContractKey` type is read differently: every quoted key-shaped literal
|
|
390
|
+
* in it counts. That is deliberately generous, and generous is the correct direction - the cost of
|
|
391
|
+
* over-reading is a dead key surviving one more release, and the cost of under-reading is a rule
|
|
392
|
+
* whose finding is "delete the row that renders".
|
|
393
|
+
*
|
|
394
|
+
* The table itself is not read this way, or rather is not read at all: it is the one file skipped,
|
|
395
|
+
* so its own key list cannot vouch for anything.
|
|
396
|
+
*/
|
|
397
|
+
const TYPED_KEY_FILE = /\bContractKey\b/
|
|
398
|
+
const QUOTED_KEY = /"([a-z][a-z0-9]*(?:-[a-z0-9]+)+)"/g
|
|
399
|
+
|
|
400
|
+
/** Reference sets, keyed by table path. One eslint run is one process, so this is one walk per run. */
|
|
401
|
+
const referenceCache = new Map()
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Every contract key named anywhere in the repository outside the table itself.
|
|
405
|
+
*
|
|
406
|
+
* Returns null when the tree cannot be seen - no resolvable root, no directory to walk. A reader
|
|
407
|
+
* that cannot see the tree must stay quiet: the alternative is a rule that answers "nothing renders
|
|
408
|
+
* this" when the truth is "I looked nowhere", and that answer arrives as a list of deletions.
|
|
409
|
+
*
|
|
410
|
+
* The walk happens once per table per process and is cached, because the question is asked once for
|
|
411
|
+
* every key in the table and the answer is one traversal.
|
|
412
|
+
*
|
|
413
|
+
* @param tablePath - the contract table being linted.
|
|
414
|
+
* @returns the set of referenced keys, or null when the tree could not be walked.
|
|
415
|
+
*/
|
|
416
|
+
export const readContractReferences = (tablePath) => {
|
|
417
|
+
const table = normalizePath(tablePath)
|
|
418
|
+
const hit = referenceCache.get(table)
|
|
419
|
+
if (hit !== undefined) return hit
|
|
420
|
+
const roots = contractSearchRoots(table)
|
|
421
|
+
if (roots.length === 0) {
|
|
422
|
+
referenceCache.set(table, null)
|
|
423
|
+
return null
|
|
424
|
+
}
|
|
425
|
+
const found = new Set()
|
|
426
|
+
const pending = [...roots]
|
|
427
|
+
while (pending.length > 0) {
|
|
428
|
+
const dir = pending.pop()
|
|
429
|
+
let entries = []
|
|
430
|
+
try {
|
|
431
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
432
|
+
} catch {
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
for (const entry of entries) {
|
|
436
|
+
const path = `${dir}/${entry.name}`
|
|
437
|
+
if (entry.isDirectory()) {
|
|
438
|
+
if (!UNWALKED_DIRS.has(entry.name)) pending.push(path)
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
if (!WALKED_FILES.test(entry.name) || path === table) continue
|
|
442
|
+
let source = null
|
|
443
|
+
try {
|
|
444
|
+
source = readFileSync(path, "utf8")
|
|
445
|
+
} catch {
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
for (const pattern of REFERENCE_PATTERNS) {
|
|
449
|
+
for (const match of source.matchAll(pattern)) found.add(match[1])
|
|
450
|
+
}
|
|
451
|
+
if (TYPED_KEY_FILE.test(source)) {
|
|
452
|
+
for (const match of source.matchAll(QUOTED_KEY)) found.add(match[1])
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
referenceCache.set(table, found)
|
|
457
|
+
return found
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// -- shared AST helpers -------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
/** Class tokens that decide the SHAPE of a tree rather than the look of one value. */
|
|
463
|
+
const STRUCTURAL_EXACT = new Set([
|
|
464
|
+
"flex",
|
|
465
|
+
"inline-flex",
|
|
466
|
+
"grid",
|
|
467
|
+
"inline-grid",
|
|
468
|
+
"contents",
|
|
469
|
+
"absolute",
|
|
470
|
+
"relative",
|
|
471
|
+
"fixed",
|
|
472
|
+
"sticky",
|
|
473
|
+
])
|
|
474
|
+
|
|
475
|
+
/** Prefixed families that are equally structural (`gap-4`, `items-center`, `col-span-2`). */
|
|
476
|
+
const STRUCTURAL_PREFIX =
|
|
477
|
+
/^(?:flex-|grid-cols-|grid-rows-|col-|row-|gap-|items-|justify-|content-|place-|self-|order-|space-x-|space-y-|divide-|overflow-|inset-|top-|right-|bottom-|left-|z-|basis-|shrink|grow)/
|
|
478
|
+
|
|
479
|
+
/** Helpers that assemble a class string at runtime. */
|
|
480
|
+
const CLASS_COMPOSERS = new Set(["cn", "clsx", "classnames", "classNames", "twMerge", "twJoin", "cva", "tv"])
|
|
481
|
+
|
|
482
|
+
/** Markers the frame paints from the entry it renders. */
|
|
483
|
+
const CONTRACT_ATTRS = new Set(["data-node", "data-why"])
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Neutral boxes: elements with no meaning of their own, whose only job is to hold other elements.
|
|
487
|
+
*
|
|
488
|
+
* Opening one is always a shape decision, because there is no other reason to open one. These are
|
|
489
|
+
* refused unconditionally outside the frame.
|
|
490
|
+
*/
|
|
491
|
+
const NEUTRAL_HOSTS = new Set(["div", "section", "main", "header", "footer", "aside", "nav"])
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Elements chosen for MEANING, which an entry may also name as its host.
|
|
495
|
+
*
|
|
496
|
+
* These are refused only when they carry classes, and the distinction is load-bearing. A `<form>`
|
|
497
|
+
* exists to submit, a `<ul>` exists because its contents are a list - assistive technology reports
|
|
498
|
+
* the element, so it cannot be swapped for a neutral one, and wrapping a contract node in one
|
|
499
|
+
* decides no shape at all. What must still come from an entry is the SHAPE: the moment one of these
|
|
500
|
+
* carries a class, it has stopped being a semantic wrapper and become a node with no key.
|
|
501
|
+
*
|
|
502
|
+
* An earlier version refused these outright, on the reasoning that an entry can name them. That
|
|
503
|
+
* confused "the table CAN express this element" with "every use of this element is a node", and it
|
|
504
|
+
* flagged three honest wrappers that carried a submit handler and not one class.
|
|
505
|
+
*/
|
|
506
|
+
const SEMANTIC_HOSTS = new Set(["ul", "ol", "li", "form"])
|
|
507
|
+
|
|
508
|
+
/** Strip responsive/state variants and the important marker: `lg:hover:!flex` becomes `flex`. */
|
|
509
|
+
const bareToken = (token) => {
|
|
510
|
+
const withoutVariant = token.includes(":") ? token.slice(token.lastIndexOf(":") + 1) : token
|
|
511
|
+
return withoutVariant.replace(/^!/, "")
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The first structural token in a class string, or null when the string only styles a value. */
|
|
515
|
+
const structuralToken = (text) => {
|
|
516
|
+
for (const raw of String(text).trim().split(/\s+/).filter(Boolean)) {
|
|
517
|
+
const token = bareToken(raw)
|
|
518
|
+
if (STRUCTURAL_EXACT.has(token) || STRUCTURAL_PREFIX.test(token)) return token
|
|
519
|
+
}
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** True for a `className` / `class` JSX attribute. */
|
|
524
|
+
const isClassAttribute = (node) =>
|
|
525
|
+
node.type === "JSXAttribute" && node.name && (node.name.name === "className" || node.name.name === "class")
|
|
526
|
+
|
|
527
|
+
/** Static string carried by a JSX attribute (literal, or a template with no holes). */
|
|
528
|
+
const staticAttributeText = (node) => {
|
|
529
|
+
const value = node && node.value
|
|
530
|
+
if (!value) return null
|
|
531
|
+
if (value.type === "Literal" && typeof value.value === "string") return value.value
|
|
532
|
+
if (value.type === "JSXExpressionContainer") {
|
|
533
|
+
const expression = value.expression
|
|
534
|
+
if (expression.type === "Literal" && typeof expression.value === "string") return expression.value
|
|
535
|
+
if (expression.type === "TemplateLiteral" && expression.expressions.length === 0) {
|
|
536
|
+
return expression.quasis.map((quasi) => quasi.value.cooked).join(" ")
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** JSX element name, including `Foo.Bar`. */
|
|
543
|
+
const jsxElementName = (opening) => {
|
|
544
|
+
const name = opening && opening.name
|
|
545
|
+
if (!name) return null
|
|
546
|
+
if (name.type === "JSXIdentifier") return name.name
|
|
547
|
+
if (name.type === "JSXMemberExpression" && name.object && name.property) {
|
|
548
|
+
return `${name.object.name}.${name.property.name}`
|
|
549
|
+
}
|
|
550
|
+
return null
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Intrinsic (lowercase) tag name, or null for a component. */
|
|
554
|
+
const hostName = (opening) => {
|
|
555
|
+
const name = opening && opening.name
|
|
556
|
+
if (!name || name.type !== "JSXIdentifier") return null
|
|
557
|
+
return name.name === name.name.toLowerCase() ? name.name : null
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Static string value of a named attribute on an opening element. */
|
|
561
|
+
const attributeText = (opening, attributeName) => {
|
|
562
|
+
const attribute = (opening.attributes || []).find(
|
|
563
|
+
(candidate) => candidate.type === "JSXAttribute" && candidate.name && candidate.name.name === attributeName,
|
|
564
|
+
)
|
|
565
|
+
return attribute ? staticAttributeText(attribute) : null
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Property key as a plain string, for non-computed identifier / string keys. */
|
|
569
|
+
const propertyName = (node) => {
|
|
570
|
+
if (!node || node.type !== "Property" || node.computed) return null
|
|
571
|
+
if (node.key.type === "Identifier") return node.key.name
|
|
572
|
+
if (node.key.type === "Literal" && typeof node.key.value === "string") return node.key.value
|
|
573
|
+
return null
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// -- CONTRACT-1 ------------------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
/** A structural node takes its classes from a key, never from a literal written at the call site. */
|
|
579
|
+
export const noLiteralStructuralClass = {
|
|
580
|
+
meta: {
|
|
581
|
+
type: "problem",
|
|
582
|
+
docs: { description: "Structural classes come from a contract key, never from a literal class string." },
|
|
583
|
+
schema: [],
|
|
584
|
+
messages: {
|
|
585
|
+
structural:
|
|
586
|
+
"`{{cls}}` is a structural class written as a literal, so this node's shape is decided here instead of in the contract table. Add or reuse a key in `{{table}}` and render it with `<Tree contract=\"...\" />`. The key already owns the classes, the element AND the reason - typing it is the only layout decision there is.",
|
|
587
|
+
hoisted:
|
|
588
|
+
"`{{name}}` holds `{{cls}}` - a structural class in a module constant. Hoisting it out of the JSX does not make it a decision this file may take; it only makes it one nothing can see. Move the whole string into a key in `{{table}}`.",
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
create(context) {
|
|
592
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
593
|
+
if (!isGovernedFile(file) || isContractTableFile(file) || isSurfaceContractHostFile(file)) return {}
|
|
594
|
+
return {
|
|
595
|
+
JSXAttribute(node) {
|
|
596
|
+
if (!isClassAttribute(node)) return
|
|
597
|
+
const text = staticAttributeText(node)
|
|
598
|
+
if (!text) return
|
|
599
|
+
const hit = structuralToken(text)
|
|
600
|
+
if (hit) context.report({ node, messageId: "structural", data: { cls: hit, table: contractTableFor(context.filename) } })
|
|
601
|
+
},
|
|
602
|
+
// A class string lifted into a module constant is the same decision one line further up, and
|
|
603
|
+
// it is INVISIBLE to the arm above - this is not hypothetical, it is how an off-scale `py-1.5`
|
|
604
|
+
// survived every rule in the source this was ported from. The variable is where the shape is
|
|
605
|
+
// decided, so the variable is where the report belongs.
|
|
606
|
+
VariableDeclarator(node) {
|
|
607
|
+
const init = node.init
|
|
608
|
+
if (!init || !node.id || node.id.type !== "Identifier") return
|
|
609
|
+
let text = null
|
|
610
|
+
if (init.type === "Literal" && typeof init.value === "string") text = init.value
|
|
611
|
+
if (init.type === "TemplateLiteral" && init.expressions.length === 0) {
|
|
612
|
+
text = init.quasis.map((quasi) => quasi.value.cooked).join(" ")
|
|
613
|
+
}
|
|
614
|
+
if (text === null) return
|
|
615
|
+
const hit = structuralToken(text)
|
|
616
|
+
if (!hit) return
|
|
617
|
+
context.report({
|
|
618
|
+
node,
|
|
619
|
+
messageId: "hoisted",
|
|
620
|
+
data: { name: node.id.name, cls: hit, table: CONTRACT_TABLE_RELATIVE },
|
|
621
|
+
})
|
|
622
|
+
},
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// -- CONTRACT-2 ------------------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
/** A class string is never assembled at runtime - that is a second table with no keys. */
|
|
630
|
+
export const noClassCompositionOutsideContract = {
|
|
631
|
+
meta: {
|
|
632
|
+
type: "problem",
|
|
633
|
+
docs: { description: "Class strings are not assembled at runtime; a key already carries the whole string." },
|
|
634
|
+
schema: [],
|
|
635
|
+
messages: {
|
|
636
|
+
composer:
|
|
637
|
+
"`{{name}}(...)` assembles a class string at runtime - a second contract table with no keys, no reasons and nothing anybody can read back. The difference you are branching on is a real distinction: give it a key, or a named prop on the component that owns the node.",
|
|
638
|
+
interpolated:
|
|
639
|
+
"`className` is built by interpolation, so the class string exists only while this component runs and nothing can read it back. Move the whole string into a key in `{{table}}` and pass the key.",
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
create(context) {
|
|
643
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
644
|
+
if (!isGovernedFile(file) || isContractTableFile(file)) return {}
|
|
645
|
+
return {
|
|
646
|
+
CallExpression(node) {
|
|
647
|
+
const callee = node.callee
|
|
648
|
+
if (callee && callee.type === "Identifier" && CLASS_COMPOSERS.has(callee.name)) {
|
|
649
|
+
context.report({ node, messageId: "composer", data: { name: callee.name } })
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
JSXAttribute(node) {
|
|
653
|
+
if (!isClassAttribute(node)) return
|
|
654
|
+
const value = node.value
|
|
655
|
+
if (!value || value.type !== "JSXExpressionContainer") return
|
|
656
|
+
const expression = value.expression
|
|
657
|
+
const interpolated =
|
|
658
|
+
(expression.type === "TemplateLiteral" && expression.expressions.length > 0) ||
|
|
659
|
+
(expression.type === "BinaryExpression" && expression.operator === "+")
|
|
660
|
+
if (interpolated) context.report({ node, messageId: "interpolated", data: { table: CONTRACT_TABLE_RELATIVE } })
|
|
661
|
+
},
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// -- CONTRACT-6 ------------------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
/** Every entry states why its node exists, in a sentence that is not the key again. */
|
|
669
|
+
export const contractWhyIsAReason = {
|
|
670
|
+
meta: {
|
|
671
|
+
type: "problem",
|
|
672
|
+
docs: { description: "Every entry explains why its node exists, and the reason is not the key again." },
|
|
673
|
+
schema: [],
|
|
674
|
+
messages: {
|
|
675
|
+
tooShort:
|
|
676
|
+
"`{{key}}` has a reason too short to be one. One clause naming what breaks, wraps or overflows when this node is removed - not a label.",
|
|
677
|
+
restates:
|
|
678
|
+
"`{{key}}` has a reason built only from the words in the key, so it says the key twice and nothing else. Write the fact that made the node exist: what wraps, what overflows, what stops being pressable.",
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
create(context) {
|
|
682
|
+
if (!isContractTableFile(context.filename || context.getFilename())) return {}
|
|
683
|
+
return {
|
|
684
|
+
Property(node) {
|
|
685
|
+
if (propertyName(node) !== "why") return
|
|
686
|
+
if (!node.value || node.value.type !== "Literal" || typeof node.value.value !== "string") return
|
|
687
|
+
const owner = node.parent && node.parent.parent
|
|
688
|
+
const key = owner && owner.type === "Property" ? propertyName(owner) : "this entry"
|
|
689
|
+
const words = node.value.value.trim().split(/\s+/).filter(Boolean)
|
|
690
|
+
if (words.length < 12) {
|
|
691
|
+
context.report({ node, messageId: "tooShort", data: { key } })
|
|
692
|
+
return
|
|
693
|
+
}
|
|
694
|
+
const keyWords = new Set(String(key).split("-"))
|
|
695
|
+
const restates = words.every((word) => keyWords.has(word.toLowerCase().replace(/[^a-z]/g, "")))
|
|
696
|
+
if (restates) context.report({ node, messageId: "restates", data: { key } })
|
|
697
|
+
},
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// -- CONTRACT-7 ------------------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
/** Structural hosts come from a key rendered by the frame, not from hand-written markup. */
|
|
705
|
+
export const noStructuralHostOutsideContractFrame = {
|
|
706
|
+
meta: {
|
|
707
|
+
type: "problem",
|
|
708
|
+
docs: { description: "Structural host elements are rendered by the frame from a key." },
|
|
709
|
+
schema: [],
|
|
710
|
+
messages: {
|
|
711
|
+
host:
|
|
712
|
+
"`<{{tag}}>` written here is a node with no key: nothing records what classes it should carry, which children belong inside it, or why it exists. Name the shape in `{{table}}` and render it with `<Tree contract=\"...\" />` - and if the ELEMENT is the problem rather than the classes, the entry names its own host, so `<ul>`, `<form>` and `<nav>` are keys too. If no key fits, that is the finding, not a reason to open a div.",
|
|
713
|
+
styledSemantic:
|
|
714
|
+
"`<{{tag}}>` carries classes, so it has stopped being a semantic wrapper and become a node with no key. Opening one for its MEANING is fine - a form submits, a list is a list, and assistive technology reports the element - but the shape it wears belongs to an entry. Move the classes into a key in `{{table}}` and give that entry `host: \"{{tag}}\"`.",
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
create(context) {
|
|
718
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
719
|
+
if (!isGovernedFile(file) || isSurfaceContractHostFile(file)) return {}
|
|
720
|
+
return {
|
|
721
|
+
JSXOpeningElement(node) {
|
|
722
|
+
const tag = hostName(node)
|
|
723
|
+
if (!tag) return
|
|
724
|
+
if (NEUTRAL_HOSTS.has(tag)) {
|
|
725
|
+
context.report({ node, messageId: "host", data: { tag, table: contractTableFor(context.filename) } })
|
|
726
|
+
return
|
|
727
|
+
}
|
|
728
|
+
if (!SEMANTIC_HOSTS.has(tag)) return
|
|
729
|
+
// A semantic element decides no shape until it carries one.
|
|
730
|
+
const styled = (node.attributes || []).some((attr) => isClassAttribute(attr))
|
|
731
|
+
if (styled) context.report({ node, messageId: "styledSemantic", data: { tag, table: contractTableFor(context.filename) } })
|
|
732
|
+
},
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// -- CONTRACT-7B -----------------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A leaf may own one atomic host. It may not arrange structural hosts into a second component tree.
|
|
741
|
+
*
|
|
742
|
+
* This complements CONTRACT-7: leaves are exempt there because their job is to reach one primitive
|
|
743
|
+
* host. The exemption ends the moment that primitive contains another structural host, or one
|
|
744
|
+
* parent owns several structural host siblings.
|
|
745
|
+
*/
|
|
746
|
+
export const noStructuralArrangementInLeaf = {
|
|
747
|
+
meta: {
|
|
748
|
+
type: "problem",
|
|
749
|
+
docs: { description: "A leaf owns one atomic host, never a structural arrangement." },
|
|
750
|
+
schema: [],
|
|
751
|
+
messages: {
|
|
752
|
+
arrangement:
|
|
753
|
+
"This leaf {{kind}} structural hosts, so it is a composite/contract tree rather than one atomic primitive. Move this ownership to a branch rendered through `<Tree contract=\"...\" />`. `meta.shape`, `data-tier`, aliases, constants, helper callbacks and `defineLeafComponent` do not exempt source JSX structure.",
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
create(context) {
|
|
757
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
758
|
+
if (!isLeafFile(file) || isTestFile(file) || isContractFrameFile(file)) return {}
|
|
759
|
+
|
|
760
|
+
const aliases = new Map()
|
|
761
|
+
const structuralHosts = new Set([...NEUTRAL_HOSTS, ...SEMANTIC_HOSTS])
|
|
762
|
+
|
|
763
|
+
const staticHostValue = (node) => {
|
|
764
|
+
if (!node) return null
|
|
765
|
+
if (node.type === "Literal" && typeof node.value === "string") return node.value
|
|
766
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
|
|
767
|
+
return node.quasis.map((quasi) => quasi.value.cooked).join("")
|
|
768
|
+
}
|
|
769
|
+
return null
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const structuralHostName = (opening) => {
|
|
773
|
+
const intrinsic = hostName(opening)
|
|
774
|
+
if (intrinsic && structuralHosts.has(intrinsic)) return intrinsic
|
|
775
|
+
const name = jsxElementName(opening)
|
|
776
|
+
const alias = name && aliases.get(name)
|
|
777
|
+
return alias && structuralHosts.has(alias) ? alias : null
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const directStructuralOpening = (child) =>
|
|
781
|
+
child && child.type === "JSXElement" && structuralHostName(child.openingElement)
|
|
782
|
+
? child.openingElement
|
|
783
|
+
: null
|
|
784
|
+
|
|
785
|
+
const hasStructuralAncestor = (opening) => {
|
|
786
|
+
let current = opening.parent
|
|
787
|
+
while (current) {
|
|
788
|
+
if (
|
|
789
|
+
current.type === "JSXElement" &&
|
|
790
|
+
current.openingElement !== opening &&
|
|
791
|
+
structuralHostName(current.openingElement)
|
|
792
|
+
) {
|
|
793
|
+
return true
|
|
794
|
+
}
|
|
795
|
+
current = current.parent
|
|
796
|
+
}
|
|
797
|
+
return false
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const reportSiblingArrangement = (parent) => {
|
|
801
|
+
const openings = (parent.children || []).map(directStructuralOpening).filter(Boolean)
|
|
802
|
+
if (openings.length < 2) return
|
|
803
|
+
context.report({ node: openings[1], messageId: "arrangement", data: { kind: "owns sibling" } })
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
Program(node) {
|
|
808
|
+
for (const statement of node.body || []) {
|
|
809
|
+
if (statement.type !== "VariableDeclaration") continue
|
|
810
|
+
for (const declaration of statement.declarations || []) {
|
|
811
|
+
if (!declaration.id || declaration.id.type !== "Identifier") continue
|
|
812
|
+
const value = staticHostValue(declaration.init)
|
|
813
|
+
if (value && structuralHosts.has(value)) aliases.set(declaration.id.name, value)
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
JSXOpeningElement(node) {
|
|
818
|
+
if (!structuralHostName(node) || !hasStructuralAncestor(node)) return
|
|
819
|
+
context.report({ node, messageId: "arrangement", data: { kind: "nests" } })
|
|
820
|
+
},
|
|
821
|
+
JSXElement(node) {
|
|
822
|
+
// A structural parent is already handled as nested ownership for each structural child.
|
|
823
|
+
// Sibling ownership is needed for an atomic/custom parent that opens several host roots.
|
|
824
|
+
if (structuralHostName(node.openingElement)) return
|
|
825
|
+
reportSiblingArrangement(node)
|
|
826
|
+
},
|
|
827
|
+
JSXFragment: reportSiblingArrangement,
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// -- CONTRACT-8 ------------------------------------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
/** Contract markers are painted by the frame, never hand-written on a host element. */
|
|
835
|
+
export const noHandWrittenContractAttrs = {
|
|
836
|
+
meta: {
|
|
837
|
+
type: "problem",
|
|
838
|
+
docs: { description: "Contract markers are emitted by the frame, never hand-written." },
|
|
839
|
+
schema: [],
|
|
840
|
+
messages: {
|
|
841
|
+
marker:
|
|
842
|
+
"`{{attr}}` is a marker painted by the frame from the entry it renders. Written by hand it claims a contract nothing enforces, and the node reads as contract-owned to every test that walks these attributes. Render the key instead of describing it.",
|
|
843
|
+
},
|
|
844
|
+
},
|
|
845
|
+
create(context) {
|
|
846
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
847
|
+
if (!isSourceFile(file) || isContractFrameFile(file) || isTestFile(file)) return {}
|
|
848
|
+
return {
|
|
849
|
+
JSXAttribute(node) {
|
|
850
|
+
const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
|
|
851
|
+
if (!name || !CONTRACT_ATTRS.has(name)) return
|
|
852
|
+
context.report({ node, messageId: "marker", data: { attr: name } })
|
|
853
|
+
},
|
|
854
|
+
}
|
|
855
|
+
},
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// -- CONTRACT-9 ------------------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
/** A key that is not in the table describes nothing, and the message says which keys do exist. */
|
|
861
|
+
export const noUnknownContractKey = {
|
|
862
|
+
meta: {
|
|
863
|
+
type: "problem",
|
|
864
|
+
docs: { description: "A contract key must exist in the table." },
|
|
865
|
+
schema: [],
|
|
866
|
+
messages: {
|
|
867
|
+
unknown:
|
|
868
|
+
"`{{key}}` is not a contract key, so it describes no classes, no element and no reason. Existing keys: {{keys}}. A new key is justified by a node shape none of these express - never by wanting a different gap - and its NAME must fix what goes inside it, or it stops constraining anything.",
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
create(context) {
|
|
872
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
873
|
+
if (!isSourceFile(file)) return {}
|
|
874
|
+
const contracts = readContracts(file)
|
|
875
|
+
if (!contracts) return {}
|
|
876
|
+
const report = (node, key) => {
|
|
877
|
+
if (contracts.keys.includes(key)) return
|
|
878
|
+
context.report({ node, messageId: "unknown", data: { key, keys: contracts.keys.join(", ") } })
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
JSXOpeningElement(node) {
|
|
882
|
+
if (jsxElementName(node) !== "Tree") return
|
|
883
|
+
const key = attributeText(node, "contract")
|
|
884
|
+
if (key) report(node, key)
|
|
885
|
+
},
|
|
886
|
+
CallExpression(node) {
|
|
887
|
+
if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "contractSpec") return
|
|
888
|
+
const argument = node.arguments[0]
|
|
889
|
+
if (argument && argument.type === "Literal" && typeof argument.value === "string") {
|
|
890
|
+
report(argument, argument.value)
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
}
|
|
894
|
+
},
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// -- CONTRACT-4 ------------------------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
/** The element an entry names is worn by the frame alone; every other wearer erases it. */
|
|
900
|
+
export const onlyTheFrameWearsANode = {
|
|
901
|
+
meta: {
|
|
902
|
+
type: "problem",
|
|
903
|
+
docs: { description: "An entry's node is rendered by the frame, never worn by a component's own element." },
|
|
904
|
+
schema: [],
|
|
905
|
+
messages: {
|
|
906
|
+
worn:
|
|
907
|
+
"`contractNodeProps` hands back an entry's classes and its markers and NOT its element, so calling it outside the frame puts one key's contract on an element THIS file chose. The entry says `ol` and the document gets whatever the props were spread onto: the list leaves the accessibility tree, nothing announces how many items there are - and the key still resolves, the markers still read correct and every gate stays green, which is why this is the failure with no red anywhere. Render the entry's own node through the frame inside your wrapper - `<Tree contract=\"...\" render={...} />` in the vendor body - the way the surface branches now do.",
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
create(context) {
|
|
911
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
912
|
+
/*
|
|
913
|
+
* The frame is the only exemption, and a test is not a second one. What a twin test wants to
|
|
914
|
+
* know is what reaches the document, which it reads off the rendered frame; a test that spreads
|
|
915
|
+
* the props itself proves its own fixture and nothing about the product.
|
|
916
|
+
*/
|
|
917
|
+
if (!isSourceFile(file) || isContractFrameFile(file)) return {}
|
|
918
|
+
return {
|
|
919
|
+
CallExpression(node) {
|
|
920
|
+
const callee = node.callee
|
|
921
|
+
if (!callee) return
|
|
922
|
+
const named =
|
|
923
|
+
callee.type === "Identifier"
|
|
924
|
+
? callee.name
|
|
925
|
+
: callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier"
|
|
926
|
+
? callee.property.name
|
|
927
|
+
: null
|
|
928
|
+
if (named !== "contractNodeProps") return
|
|
929
|
+
context.report({ node, messageId: "worn" })
|
|
930
|
+
},
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Static string carried by a node, or null when it is anything else. */
|
|
936
|
+
const literalString = (node) =>
|
|
937
|
+
node && node.type === "Literal" && typeof node.value === "string" ? node.value : null
|
|
938
|
+
|
|
939
|
+
/** Every string in an array literal, or null when one element is not a static string. */
|
|
940
|
+
const literalStringList = (node) => {
|
|
941
|
+
if (!node || node.type !== "ArrayExpression") return null
|
|
942
|
+
const out = []
|
|
943
|
+
for (const element of node.elements) {
|
|
944
|
+
const text = literalString(element)
|
|
945
|
+
if (text === null) return null
|
|
946
|
+
out.push(text)
|
|
947
|
+
}
|
|
948
|
+
return out
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** The three ways a slot names what fills it. */
|
|
952
|
+
const SLOT_IDENTITY_KEYS = new Set(["contract", "composite", "leaf"])
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* One slot as a comparable string: its declared identity, whether it is optional, whether it repeats.
|
|
956
|
+
*
|
|
957
|
+
* Alternatives are a SET - a slot accepting `["a", "b"]` accepts exactly what `["b", "a"]` accepts.
|
|
958
|
+
* The identity carries which of `contract`, `composite` and `leaf` named it, because those are
|
|
959
|
+
* three different vocabularies and the same word in two of them is not the same child.
|
|
960
|
+
*/
|
|
961
|
+
const slotShape = (node) => {
|
|
962
|
+
if (!node || node.type !== "ObjectExpression") return null
|
|
963
|
+
let identity = null
|
|
964
|
+
let optional = false
|
|
965
|
+
let repeats = false
|
|
966
|
+
for (const property of node.properties) {
|
|
967
|
+
const name = propertyName(property)
|
|
968
|
+
if (name === null) return null
|
|
969
|
+
if (SLOT_IDENTITY_KEYS.has(name)) {
|
|
970
|
+
if (identity !== null) return null
|
|
971
|
+
const one = literalString(property.value)
|
|
972
|
+
const many = one === null ? literalStringList(property.value) : null
|
|
973
|
+
if (one === null && many === null) return null
|
|
974
|
+
identity = `${name}:${(many ? [...new Set(many)].sort() : [one]).join("|")}`
|
|
975
|
+
continue
|
|
976
|
+
}
|
|
977
|
+
if (name === "optional" || name === "repeats") {
|
|
978
|
+
const flag = property.value
|
|
979
|
+
if (!flag || flag.type !== "Literal" || typeof flag.value !== "boolean") return null
|
|
980
|
+
if (name === "optional") optional = flag.value
|
|
981
|
+
else repeats = flag.value
|
|
982
|
+
}
|
|
983
|
+
// `props` and `restingCount` are read past on purpose: a literal constraint and a placeholder
|
|
984
|
+
// count are not the shape the slot holds.
|
|
985
|
+
}
|
|
986
|
+
if (identity === null) return null
|
|
987
|
+
return `${identity}/optional:${optional}/repeats:${repeats}`
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/** The slot record as a comparable string, with the names sorted so declaration order says nothing. */
|
|
991
|
+
const childrenShape = (node) => {
|
|
992
|
+
if (!node) return ""
|
|
993
|
+
if (node.type !== "ObjectExpression") return null
|
|
994
|
+
const slots = []
|
|
995
|
+
for (const property of node.properties) {
|
|
996
|
+
const name = propertyName(property)
|
|
997
|
+
if (name === null) return null
|
|
998
|
+
const shape = slotShape(property.value)
|
|
999
|
+
if (shape === null) return null
|
|
1000
|
+
slots.push(`${name}=${shape}`)
|
|
1001
|
+
}
|
|
1002
|
+
return slots.sort().join(",")
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* The shape one entry spells, as a string two entries can be compared by.
|
|
1007
|
+
*
|
|
1008
|
+
* WHAT IS IN IT IS THE WHOLE RULE. Classes as a MULTISET, because the order they are typed in is
|
|
1009
|
+
* not a decision the node takes; the host, because the element is part of what the key fixes; and
|
|
1010
|
+
* the slots by name, declared identity, `optional` and `repeats`, because that is what the entry
|
|
1011
|
+
* promises is inside.
|
|
1012
|
+
*
|
|
1013
|
+
* WHAT IS LEFT OUT IS THE WHOLE POINT. The key's name, its reason, its `restingCount` and the
|
|
1014
|
+
* literal `props` on a slot are all absent, so an entry differing only in one of them lands on the
|
|
1015
|
+
* same string as its twin. A different resting count IS the same shape: CONTRACT-9 refuses a key
|
|
1016
|
+
* for a different gap, and a key for a different placeholder count is that same widening.
|
|
1017
|
+
*
|
|
1018
|
+
* A `host` that is absent is not folded into `div`. The frame defaults there, but naming the
|
|
1019
|
+
* element is a decision the entry took and leaving it unnamed is not the same statement, so a pair
|
|
1020
|
+
* disagreeing about it is never called a copy.
|
|
1021
|
+
*
|
|
1022
|
+
* @param node - the entry's object expression.
|
|
1023
|
+
* @returns the shape string, or null when any part of the entry cannot be read statically.
|
|
1024
|
+
*/
|
|
1025
|
+
const entryShape = (node) => {
|
|
1026
|
+
if (!node || node.type !== "ObjectExpression") return null
|
|
1027
|
+
let classes = []
|
|
1028
|
+
let host = ""
|
|
1029
|
+
let children = ""
|
|
1030
|
+
for (const property of node.properties) {
|
|
1031
|
+
const name = propertyName(property)
|
|
1032
|
+
if (name === null) return null
|
|
1033
|
+
// Both spellings, because the table and the type that checks it have carried both.
|
|
1034
|
+
if (name === "classes" || name === "classNames") {
|
|
1035
|
+
classes = literalStringList(property.value)
|
|
1036
|
+
if (classes === null) return null
|
|
1037
|
+
} else if (name === "host") {
|
|
1038
|
+
host = literalString(property.value)
|
|
1039
|
+
if (host === null) return null
|
|
1040
|
+
} else if (name === "children") {
|
|
1041
|
+
children = childrenShape(property.value)
|
|
1042
|
+
if (children === null) return null
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return `classes[${[...classes].sort().join(" ")}] host[${host}] children{${children}}`
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** An entry whose shape another entry already spells is that entry under a second name. */
|
|
1049
|
+
export const noDuplicateEntryShape = {
|
|
1050
|
+
meta: {
|
|
1051
|
+
type: "problem",
|
|
1052
|
+
docs: { description: "Two entries may not spell one shape under two names." },
|
|
1053
|
+
schema: [],
|
|
1054
|
+
messages: {
|
|
1055
|
+
duplicate:
|
|
1056
|
+
"`{{key}}` spells the shape `{{other}}` already spells: the same classes in any order, the same host, and the same slots holding the same things. A different reason, a different `restingCount` or a different name is not a different shape. Reuse `{{other}}`, or state in `why` the relationship this entry expresses that `{{other}}` cannot - and if neither is true, `{{key}}` and `{{other}}` are one entry under a name that fits both.",
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
create(context) {
|
|
1060
|
+
if (!isContractTableFile(context.filename || context.getFilename())) return {}
|
|
1061
|
+
return {
|
|
1062
|
+
/*
|
|
1063
|
+
* The entries are read off the `buildContracts` call rather than off every object in the
|
|
1064
|
+
* file, for the reason the textual reader records: a second table in the same file would
|
|
1065
|
+
* otherwise be compared against the first, and two vocabularies would report each other.
|
|
1066
|
+
*
|
|
1067
|
+
* An entry that cannot be read statically is skipped rather than reported. A table nobody can
|
|
1068
|
+
* read is a reason to stay quiet, never a reason to call an entry a copy.
|
|
1069
|
+
*/
|
|
1070
|
+
CallExpression(node) {
|
|
1071
|
+
if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "buildContracts") return
|
|
1072
|
+
const table = node.arguments[0]
|
|
1073
|
+
if (!table || table.type !== "ObjectExpression") return
|
|
1074
|
+
const firstByShape = new Map()
|
|
1075
|
+
for (const property of table.properties) {
|
|
1076
|
+
const key = propertyName(property)
|
|
1077
|
+
if (key === null) continue
|
|
1078
|
+
const shape = entryShape(property.value)
|
|
1079
|
+
if (shape === null) continue
|
|
1080
|
+
const other = firstByShape.get(shape)
|
|
1081
|
+
// The pair is reported once, on the later entry, because the earlier one is the key the
|
|
1082
|
+
// author is being sent back to.
|
|
1083
|
+
if (other === undefined) firstByShape.set(shape, key)
|
|
1084
|
+
else context.report({ node: property, messageId: "duplicate", data: { key, other } })
|
|
1085
|
+
}
|
|
1086
|
+
},
|
|
1087
|
+
}
|
|
1088
|
+
},
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* The class families an entry may not hold, and what each of them is really claiming.
|
|
1093
|
+
*
|
|
1094
|
+
* A cursor and a hover state claim the node reacts to a pointer. A focus state claims it takes the
|
|
1095
|
+
* keyboard. `group` claims something inside it answers this node's hover. A text colour and an
|
|
1096
|
+
* alignment are the paint of one value rather than the relationship between several. None of them
|
|
1097
|
+
* is an arrangement, and the table cannot be told when the claim stops being true: an entry cannot
|
|
1098
|
+
* know that this call site passed no handler, so it goes on drawing a pointer over a dead node.
|
|
1099
|
+
*/
|
|
1100
|
+
const INTERACTION_CLASS = /^(?:cursor-|group$|group\/|hover:|active:|focus:|focus-visible:|disabled:|aria-[a-z-]+:|data-\[)/
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* ALIGNMENT IS NOT PAINT, and the line is drawn here rather than by taste.
|
|
1104
|
+
*
|
|
1105
|
+
* `text-center` says how the content of every child sits inside this node, which is the same kind
|
|
1106
|
+
* of statement as `items-center` and is inherited by children that never asked - so it belongs to
|
|
1107
|
+
* the node that arranges them. A text COLOUR says what one value looks like, and the leaf that
|
|
1108
|
+
* draws that value already owns it; an entry that names one is a second author for something it
|
|
1109
|
+
* cannot see. So `text-left` and `text-center` stay legal here, and `text-foreground` does not.
|
|
1110
|
+
*/
|
|
1111
|
+
const PAINT_CLASS = /^(?:text-(?:foreground|muted|accent|success|warning|danger)$|decoration-|underline$)/
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* A GROUND AND AN ELEVATION MAKE A RAISED OBJECT, and a raised object is a component.
|
|
1115
|
+
*
|
|
1116
|
+
* `bg-surface` lifts the node off the page and `shadow-*` says how far - and either alone already
|
|
1117
|
+
* says it, which is why either alone is refused. Neither is a statement about how children stand
|
|
1118
|
+
* together; both are statements about what the node IS. What it becomes is a card, and a card
|
|
1119
|
+
* already has an owner: a named surface branch draws it, owns its inset, its edge and the vendor
|
|
1120
|
+
* wrapper underneath. An entry that draws it too puts a second card inside the first, and nothing
|
|
1121
|
+
* can see the doubling because the entry and the branch never read each other.
|
|
1122
|
+
*
|
|
1123
|
+
* THE NEIGHBOURS ARE DELIBERATELY LEFT OUT, each for its own reason.
|
|
1124
|
+
*
|
|
1125
|
+
* - `bg-background` is the app frame's ground - the page a navbar or a mobile action bar sits on,
|
|
1126
|
+
* drawn by chrome no card branch owns. It raises nothing, so refusing it would take away the
|
|
1127
|
+
* only ground those nodes have.
|
|
1128
|
+
* - `rounded-*` and `border*` stay legal because an edge also CLIPS and DIVIDES: entries use it to
|
|
1129
|
+
* round the ends of a joined list and to rule one band off from the next. A machine cannot tell
|
|
1130
|
+
* those apart from the corner of a card, so the separation is left to the migration.
|
|
1131
|
+
* - `divide-y`, `divide-separator` and `overflow-hidden` describe relationships BETWEEN children,
|
|
1132
|
+
* which is the one thing an entry is for.
|
|
1133
|
+
*/
|
|
1134
|
+
const RAISED_OBJECT_CLASS = /^(?:bg-surface(?:-|$)|shadow(?:-|$))/
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* A ground alone is not a raised object, and this is the one shape that tells them apart.
|
|
1138
|
+
*
|
|
1139
|
+
* `shadow-*` is elevation and needs no companion: nothing casts a shadow without standing above
|
|
1140
|
+
* something. A ground does not settle it - a landing page whose sections alternate their ground so a
|
|
1141
|
+
* reader can count them carries the same token and is not a card.
|
|
1142
|
+
*
|
|
1143
|
+
* The difference is not the corner. Plenty of cards have no radius, and a band can be rounded. The
|
|
1144
|
+
* difference is that a BAND RUNS EDGE TO EDGE AND RULES ITSELF OFF: it takes the full measure and
|
|
1145
|
+
* separates itself from the next band with one border, because it has no boundary of its own to do
|
|
1146
|
+
* it with. An object stops before the edge and is bounded by being an object.
|
|
1147
|
+
*
|
|
1148
|
+
* So a ground is refused unless the entry is written as a band, and a machine can read that: the
|
|
1149
|
+
* full measure, plus a rule along one edge.
|
|
1150
|
+
*/
|
|
1151
|
+
const BAND_WIDTH_CLASS = /^w-full$/
|
|
1152
|
+
const BAND_RULE_CLASS = /^border-[bt]$/
|
|
1153
|
+
|
|
1154
|
+
/** Whether this entry is written as a full-bleed band, which is the one ground an entry may own. */
|
|
1155
|
+
const isBand = (classes) =>
|
|
1156
|
+
classes.some((value) => BAND_WIDTH_CLASS.test(value)) && classes.some((value) => BAND_RULE_CLASS.test(value))
|
|
1157
|
+
|
|
1158
|
+
/** An entry describes how children stand together; how the node reacts, paints or rises is a branch's. */
|
|
1159
|
+
export const noInteractionClassInEntry = {
|
|
1160
|
+
meta: {
|
|
1161
|
+
type: "problem",
|
|
1162
|
+
docs: { description: "Entry classes are the arrangement, never the behaviour, the paint or the object." },
|
|
1163
|
+
schema: [],
|
|
1164
|
+
messages: {
|
|
1165
|
+
interaction:
|
|
1166
|
+
"`{{key}}` carries `{{value}}`, which is how the node REACTS rather than how its children stand together. The thing that presses owns the handler, the disabled state and therefore the cursor - so the entry cannot know when that promise is off, and goes on drawing it over a dead node. Draw the control in a named branch and put this key's node inside it.",
|
|
1167
|
+
paint:
|
|
1168
|
+
"`{{key}}` carries `{{value}}`, which is the paint of one value rather than a relationship between children. A leaf owns how its own words are set, and a branch owns the surface it draws; an entry that paints is a second author for something it cannot see.",
|
|
1169
|
+
raised:
|
|
1170
|
+
"`{{key}}` carries `{{value}}`, which gives the node a ground or an elevation of its own. A ground and an elevation make the node a raised OBJECT rather than an arrangement of children, and a raised object is a component with an owner - so the entry is claiming a thing the surface branch around it already draws, drawn twice and read back nowhere. Put this key's node inside that branch. The app frame's own ground is a different thing and stays legal: `bg-background` is the page chrome sits on, not a raised object. So does an edge - `rounded-*` and `border*` also clip and divide - and so do `divide-y` and `overflow-hidden`, which describe the children.",
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
create(context) {
|
|
1174
|
+
if (!isContractTableFile(context.filename || context.getFilename())) return {}
|
|
1175
|
+
return {
|
|
1176
|
+
CallExpression(node) {
|
|
1177
|
+
if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "buildContracts") return
|
|
1178
|
+
const table = node.arguments[0]
|
|
1179
|
+
if (!table || table.type !== "ObjectExpression") return
|
|
1180
|
+
for (const property of table.properties) {
|
|
1181
|
+
const key = propertyName(property)
|
|
1182
|
+
if (key === null || !property.value || property.value.type !== "ObjectExpression") continue
|
|
1183
|
+
for (const field of property.value.properties) {
|
|
1184
|
+
const name = propertyName(field)
|
|
1185
|
+
if (name !== "classes" && name !== "classNames") continue
|
|
1186
|
+
if (!field.value || field.value.type !== "ArrayExpression") continue
|
|
1187
|
+
const written = field.value.elements
|
|
1188
|
+
.map((element) => literalString(element))
|
|
1189
|
+
.filter((value) => value !== null && value !== "")
|
|
1190
|
+
const band = isBand(written)
|
|
1191
|
+
for (const element of field.value.elements) {
|
|
1192
|
+
const value = literalString(element)
|
|
1193
|
+
if (value === null || value === "") continue
|
|
1194
|
+
// A ground with nothing bounding it is a band, and a band is the only ground an entry
|
|
1195
|
+
// may still own. An elevation is refused wherever it appears.
|
|
1196
|
+
const raises = RAISED_OBJECT_CLASS.test(value)
|
|
1197
|
+
&& (!band || /^shadow(?:-|$)/.test(value))
|
|
1198
|
+
const messageId = INTERACTION_CLASS.test(value)
|
|
1199
|
+
? "interaction"
|
|
1200
|
+
: PAINT_CLASS.test(value)
|
|
1201
|
+
? "paint"
|
|
1202
|
+
: raises ? "raised" : null
|
|
1203
|
+
if (messageId !== null) context.report({ node: element, messageId, data: { key, value } })
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
},
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// -- CONTRACT-13 -----------------------------------------------------------------------------------
|
|
1213
|
+
|
|
1214
|
+
/** Every contract key one entry declares as a child of its own, including alternatives. */
|
|
1215
|
+
const childContractKeys = (entry) => {
|
|
1216
|
+
const out = []
|
|
1217
|
+
if (!entry || entry.type !== "ObjectExpression") return out
|
|
1218
|
+
for (const property of entry.properties) {
|
|
1219
|
+
if (propertyName(property) !== "children") continue
|
|
1220
|
+
const slots = property.value
|
|
1221
|
+
if (!slots || slots.type !== "ObjectExpression") continue
|
|
1222
|
+
for (const slot of slots.properties) {
|
|
1223
|
+
const shape = slot.value
|
|
1224
|
+
if (!shape || shape.type !== "ObjectExpression") continue
|
|
1225
|
+
for (const field of shape.properties) {
|
|
1226
|
+
if (propertyName(field) !== "contract") continue
|
|
1227
|
+
const one = literalString(field.value)
|
|
1228
|
+
// A slot accepting `["a", "b"]` renders whichever it is handed, so both are drawn.
|
|
1229
|
+
const many = one === null ? literalStringList(field.value) : null
|
|
1230
|
+
if (one !== null) out.push(one)
|
|
1231
|
+
else if (many !== null) out.push(...many)
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
return out
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/** A key nobody renders is a promise about a node that does not exist. */
|
|
1239
|
+
export const noDeadContractKey = {
|
|
1240
|
+
meta: {
|
|
1241
|
+
type: "problem",
|
|
1242
|
+
docs: { description: "Every key in the table is rendered somewhere, or it is not vocabulary." },
|
|
1243
|
+
schema: [],
|
|
1244
|
+
messages: {
|
|
1245
|
+
dead:
|
|
1246
|
+
"`{{key}}` is in the table and nothing draws it: no `contract=\"{{key}}\"`, no `defineContractComponent(\"{{key}}\")`, no `defineContractProjection(\"{{key}}\")`, no `CONTRACTS[\"{{key}}\"]`, and no slot of another entry - searched under {{roots}}. An entry is a promise about a node standing in the document, so a key with no user is a promise about nothing: it survives every rename because renaming follows call sites and it has none, it travels whole into the next repository, and it makes the table longer than the code that reads it until telling a described screen from an intended one means searching the source. Delete it. A shape wanted for work that has not started belongs in the plan record, where an unbuilt node is exactly what a reader expects to find.",
|
|
1247
|
+
},
|
|
1248
|
+
},
|
|
1249
|
+
create(context) {
|
|
1250
|
+
const file = normalizePath(context.filename || context.getFilename())
|
|
1251
|
+
if (!isContractTableFile(file)) return {}
|
|
1252
|
+
/*
|
|
1253
|
+
* A table inside a plan record is a record. A design candidate carries a COPY of the vocabulary
|
|
1254
|
+
* and renders the one page it was built to answer, so most of that copy is drawn by nobody -
|
|
1255
|
+
* which is not a finding, it is what a plan record is: the place this rule sends an unbuilt
|
|
1256
|
+
* shape to. Measured on `starci-academy-fe`, two candidates would have opened a hundred and
|
|
1257
|
+
* thirty deletions against a tree nothing ships.
|
|
1258
|
+
*/
|
|
1259
|
+
if (file.includes("/.artifacts/")) return {}
|
|
1260
|
+
/*
|
|
1261
|
+
* No tree, no finding. `readContractReferences` returns null when the repository root cannot be
|
|
1262
|
+
* resolved from this path or nothing can be walked, and the only honest report from a reader
|
|
1263
|
+
* that looked nowhere is silence - the alternative is every key in the vocabulary reported dead
|
|
1264
|
+
* at once, which reads as a table to empty rather than as a rule that is blind.
|
|
1265
|
+
*/
|
|
1266
|
+
const referenced = readContractReferences(file)
|
|
1267
|
+
if (referenced === null) return {}
|
|
1268
|
+
const roots = contractSearchRoots(file).join(", ")
|
|
1269
|
+
return {
|
|
1270
|
+
CallExpression(node) {
|
|
1271
|
+
if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "buildContracts") return
|
|
1272
|
+
const table = node.arguments[0]
|
|
1273
|
+
if (!table || table.type !== "ObjectExpression") return
|
|
1274
|
+
// A child slot is a call site the walk cannot see, because the only file naming that key is
|
|
1275
|
+
// the one file the walk skips. Collected first, so declaration order says nothing.
|
|
1276
|
+
const asChild = new Set()
|
|
1277
|
+
for (const property of table.properties) {
|
|
1278
|
+
for (const key of childContractKeys(property.value)) asChild.add(key)
|
|
1279
|
+
}
|
|
1280
|
+
for (const property of table.properties) {
|
|
1281
|
+
const key = propertyName(property)
|
|
1282
|
+
if (key === null || referenced.has(key) || asChild.has(key)) continue
|
|
1283
|
+
context.report({ node: property, messageId: "dead", data: { key, roots } })
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
}
|
|
1287
|
+
},
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/** The rules this law contributes to the plugin. */
|
|
1291
|
+
/** Every registry entry declares a closed, typed child grammar. */
|
|
1292
|
+
export const contractChildrenAreTyped = {
|
|
1293
|
+
meta: {
|
|
1294
|
+
type: "problem",
|
|
1295
|
+
docs: { description: "Every contract entry declares children as closed leaf/composite/contract slot specs." },
|
|
1296
|
+
schema: [],
|
|
1297
|
+
messages: {
|
|
1298
|
+
missing:
|
|
1299
|
+
"Contract `{{key}}` has no `children` grammar. Even an empty node declares `children: {}` so the absence of content is typed rather than implied.",
|
|
1300
|
+
untyped:
|
|
1301
|
+
"Child `{{slot}}` on contract `{{key}}` is not a closed slot spec. Declare at least one typed owner identity: `leaf`, `composite`, or `contract`, plus its constraints.",
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
create(context) {
|
|
1305
|
+
if (!isContractTableFile(context.filename || context.getFilename())) return {}
|
|
1306
|
+
return {
|
|
1307
|
+
CallExpression(node) {
|
|
1308
|
+
if (node.callee?.type !== "Identifier" || node.callee.name !== "buildContracts") return
|
|
1309
|
+
const table = node.arguments?.[0]
|
|
1310
|
+
if (!table || table.type !== "ObjectExpression") return
|
|
1311
|
+
for (const entry of table.properties || []) {
|
|
1312
|
+
if (entry.type !== "Property" || entry.value?.type !== "ObjectExpression") continue
|
|
1313
|
+
const key = propertyName(entry) ?? "<computed>"
|
|
1314
|
+
const children = entry.value.properties.find((property) =>
|
|
1315
|
+
property.type === "Property" && propertyName(property) === "children")
|
|
1316
|
+
if (!children || children.value?.type !== "ObjectExpression") {
|
|
1317
|
+
context.report({ node: children || entry, messageId: "missing", data: { key } })
|
|
1318
|
+
continue
|
|
1319
|
+
}
|
|
1320
|
+
for (const slot of children.value.properties || []) {
|
|
1321
|
+
const slotName = propertyName(slot) ?? "<computed>"
|
|
1322
|
+
if (slot.type !== "Property" || slot.value?.type !== "ObjectExpression") {
|
|
1323
|
+
context.report({ node: slot, messageId: "untyped", data: { key, slot: slotName } })
|
|
1324
|
+
continue
|
|
1325
|
+
}
|
|
1326
|
+
const identities = slot.value.properties.filter((property) =>
|
|
1327
|
+
property.type === "Property" && ["leaf", "composite", "contract"].includes(propertyName(property)))
|
|
1328
|
+
if (identities.length === 0) {
|
|
1329
|
+
context.report({ node: slot, messageId: "untyped", data: { key, slot: slotName } })
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** The rules this law contributes to the plugin. */
|
|
1339
|
+
|
|
1340
|
+
export const rules = {
|
|
1341
|
+
"contract-children-are-typed": contractChildrenAreTyped,
|
|
1342
|
+
"no-literal-structural-class": noLiteralStructuralClass,
|
|
1343
|
+
"no-interaction-class-in-entry": noInteractionClassInEntry,
|
|
1344
|
+
"no-class-composition-outside-contract": noClassCompositionOutsideContract,
|
|
1345
|
+
"contract-why-is-a-reason": contractWhyIsAReason,
|
|
1346
|
+
"no-structural-host-outside-contract-frame": noStructuralHostOutsideContractFrame,
|
|
1347
|
+
"no-structural-arrangement-in-leaf": noStructuralArrangementInLeaf,
|
|
1348
|
+
"no-hand-written-contract-attrs": noHandWrittenContractAttrs,
|
|
1349
|
+
"no-unknown-contract-key": noUnknownContractKey,
|
|
1350
|
+
"no-duplicate-entry-shape": noDuplicateEntryShape,
|
|
1351
|
+
"only-the-frame-wears-a-node": onlyTheFrameWearsANode,
|
|
1352
|
+
"no-dead-contract-key": noDeadContractKey,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
1357
|
+
*
|
|
1358
|
+
* A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on:
|
|
1359
|
+
* a repository carrying debt turns a rule down to `warn` while it burns the count down, and back to
|
|
1360
|
+
* `error` at zero. What never changes is that a rule at `error` is a broken build rather than a
|
|
1361
|
+
* warning to triage.
|
|
1362
|
+
*/
|
|
1363
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|