@starci/eslint-canon-fe 1.0.0

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