@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 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
- /** 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[ ]{4}"[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-7B -----------------------------------------------------------------------------------
720
-
721
- /**
722
- * A leaf may own one atomic host. It may not arrange structural hosts into a second component tree.
723
- *
724
- * This complements CONTRACT-7: leaves are exempt there because their job is to reach one primitive
725
- * host. The exemption ends the moment that primitive contains another structural host, or one
726
- * parent owns several structural host siblings.
727
- */
728
- export const noStructuralArrangementInLeaf = {
729
- meta: {
730
- type: "problem",
731
- docs: { description: "A leaf owns one atomic host, never a structural arrangement." },
732
- schema: [],
733
- messages: {
734
- arrangement:
735
- "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.",
736
- },
737
- },
738
- create(context) {
739
- const file = normalizePath(context.filename || context.getFilename())
740
- if (!isLeafFile(file) || isTestFile(file) || isContractFrameFile(file)) return {}
741
-
742
- const aliases = new Map()
743
- const structuralHosts = new Set([...NEUTRAL_HOSTS, ...SEMANTIC_HOSTS])
744
-
745
- const staticHostValue = (node) => {
746
- if (!node) return null
747
- if (node.type === "Literal" && typeof node.value === "string") return node.value
748
- if (node.type === "TemplateLiteral" && node.expressions.length === 0) {
749
- return node.quasis.map((quasi) => quasi.value.cooked).join("")
750
- }
751
- return null
752
- }
753
-
754
- const structuralHostName = (opening) => {
755
- const intrinsic = hostName(opening)
756
- if (intrinsic && structuralHosts.has(intrinsic)) return intrinsic
757
- const name = jsxElementName(opening)
758
- const alias = name && aliases.get(name)
759
- return alias && structuralHosts.has(alias) ? alias : null
760
- }
761
-
762
- const directStructuralOpening = (child) =>
763
- child && child.type === "JSXElement" && structuralHostName(child.openingElement)
764
- ? child.openingElement
765
- : null
766
-
767
- const hasStructuralAncestor = (opening) => {
768
- let current = opening.parent
769
- while (current) {
770
- if (
771
- current.type === "JSXElement" &&
772
- current.openingElement !== opening &&
773
- structuralHostName(current.openingElement)
774
- ) {
775
- return true
776
- }
777
- current = current.parent
778
- }
779
- return false
780
- }
781
-
782
- const reportSiblingArrangement = (parent) => {
783
- const openings = (parent.children || []).map(directStructuralOpening).filter(Boolean)
784
- if (openings.length < 2) return
785
- context.report({ node: openings[1], messageId: "arrangement", data: { kind: "owns sibling" } })
786
- }
787
-
788
- return {
789
- Program(node) {
790
- for (const statement of node.body || []) {
791
- if (statement.type !== "VariableDeclaration") continue
792
- for (const declaration of statement.declarations || []) {
793
- if (!declaration.id || declaration.id.type !== "Identifier") continue
794
- const value = staticHostValue(declaration.init)
795
- if (value && structuralHosts.has(value)) aliases.set(declaration.id.name, value)
796
- }
797
- }
798
- },
799
- JSXOpeningElement(node) {
800
- if (!structuralHostName(node) || !hasStructuralAncestor(node)) return
801
- context.report({ node, messageId: "arrangement", data: { kind: "nests" } })
802
- },
803
- JSXElement(node) {
804
- // A structural parent is already handled as nested ownership for each structural child.
805
- // Sibling ownership is needed for an atomic/custom parent that opens several host roots.
806
- if (structuralHostName(node.openingElement)) return
807
- reportSiblingArrangement(node)
808
- },
809
- JSXFragment: reportSiblingArrangement,
810
- }
811
- },
812
- }
813
-
814
- // -- CONTRACT-8 ------------------------------------------------------------------------------------
815
-
816
- /** Contract markers are painted by the frame, never hand-written on a host element. */
817
- export const noHandWrittenContractAttrs = {
818
- meta: {
819
- type: "problem",
820
- docs: { description: "Contract markers are emitted by the frame, never hand-written." },
821
- schema: [],
822
- messages: {
823
- marker:
824
- "`{{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.",
825
- },
826
- },
827
- create(context) {
828
- const file = normalizePath(context.filename || context.getFilename())
829
- if (!isSourceFile(file) || isContractFrameFile(file) || isTestFile(file)) return {}
830
- return {
831
- JSXAttribute(node) {
832
- const name = node.name && node.name.type === "JSXIdentifier" ? node.name.name : null
833
- if (!name || !CONTRACT_ATTRS.has(name)) return
834
- context.report({ node, messageId: "marker", data: { attr: name } })
835
- },
836
- }
837
- },
838
- }
839
-
840
- // -- CONTRACT-9 ------------------------------------------------------------------------------------
841
-
842
- /** A key that is not in the table describes nothing, and the message says which keys do exist. */
843
- export const noUnknownContractKey = {
844
- meta: {
845
- type: "problem",
846
- docs: { description: "A contract key must exist in the table." },
847
- schema: [],
848
- messages: {
849
- unknown:
850
- "`{{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.",
851
- },
852
- },
853
- create(context) {
854
- const file = normalizePath(context.filename || context.getFilename())
855
- if (!isSourceFile(file)) return {}
856
- const contracts = readContracts(file)
857
- if (!contracts) return {}
858
- const report = (node, key) => {
859
- if (contracts.keys.includes(key)) return
860
- context.report({ node, messageId: "unknown", data: { key, keys: contracts.keys.join(", ") } })
861
- }
862
- return {
863
- JSXOpeningElement(node) {
864
- if (jsxElementName(node) !== "Tree") return
865
- const key = attributeText(node, "contract")
866
- if (key) report(node, key)
867
- },
868
- CallExpression(node) {
869
- if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "contractSpec") return
870
- const argument = node.arguments[0]
871
- if (argument && argument.type === "Literal" && typeof argument.value === "string") {
872
- report(argument, argument.value)
873
- }
874
- },
875
- }
876
- },
877
- }
878
-
879
- // -- CONTRACT-4 ------------------------------------------------------------------------------------
880
-
881
- /** The element an entry names is worn by the frame alone; every other wearer erases it. */
882
- export const onlyTheFrameWearsANode = {
883
- meta: {
884
- type: "problem",
885
- docs: { description: "An entry's node is rendered by the frame, never worn by a component's own element." },
886
- schema: [],
887
- messages: {
888
- worn:
889
- "`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.",
890
- },
891
- },
892
- create(context) {
893
- const file = normalizePath(context.filename || context.getFilename())
894
- /*
895
- * The frame is the only exemption, and a test is not a second one. What a twin test wants to
896
- * know is what reaches the document, which it reads off the rendered frame; a test that spreads
897
- * the props itself proves its own fixture and nothing about the product.
898
- */
899
- if (!isSourceFile(file) || isContractFrameFile(file)) return {}
900
- return {
901
- CallExpression(node) {
902
- const callee = node.callee
903
- if (!callee) return
904
- const named =
905
- callee.type === "Identifier"
906
- ? callee.name
907
- : callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier"
908
- ? callee.property.name
909
- : null
910
- if (named !== "contractNodeProps") return
911
- context.report({ node, messageId: "worn" })
912
- },
913
- }
914
- },
915
- }
916
-
917
- /** Static string carried by a node, or null when it is anything else. */
918
- const literalString = (node) =>
919
- node && node.type === "Literal" && typeof node.value === "string" ? node.value : null
920
-
921
- /** Every string in an array literal, or null when one element is not a static string. */
922
- const literalStringList = (node) => {
923
- if (!node || node.type !== "ArrayExpression") return null
924
- const out = []
925
- for (const element of node.elements) {
926
- const text = literalString(element)
927
- if (text === null) return null
928
- out.push(text)
929
- }
930
- return out
931
- }
932
-
933
- /** The three ways a slot names what fills it. */
934
- const SLOT_IDENTITY_KEYS = new Set(["contract", "composite", "leaf"])
935
-
936
- /**
937
- * One slot as a comparable string: its declared identity, whether it is optional, whether it repeats.
938
- *
939
- * Alternatives are a SET - a slot accepting `["a", "b"]` accepts exactly what `["b", "a"]` accepts.
940
- * The identity carries which of `contract`, `composite` and `leaf` named it, because those are
941
- * three different vocabularies and the same word in two of them is not the same child.
942
- */
943
- const slotShape = (node) => {
944
- if (!node || node.type !== "ObjectExpression") return null
945
- let identity = null
946
- let optional = false
947
- let repeats = false
948
- for (const property of node.properties) {
949
- const name = propertyName(property)
950
- if (name === null) return null
951
- if (SLOT_IDENTITY_KEYS.has(name)) {
952
- if (identity !== null) return null
953
- const one = literalString(property.value)
954
- const many = one === null ? literalStringList(property.value) : null
955
- if (one === null && many === null) return null
956
- identity = `${name}:${(many ? [...new Set(many)].sort() : [one]).join("|")}`
957
- continue
958
- }
959
- if (name === "optional" || name === "repeats") {
960
- const flag = property.value
961
- if (!flag || flag.type !== "Literal" || typeof flag.value !== "boolean") return null
962
- if (name === "optional") optional = flag.value
963
- else repeats = flag.value
964
- }
965
- // `props` and `restingCount` are read past on purpose: a literal constraint and a placeholder
966
- // count are not the shape the slot holds.
967
- }
968
- if (identity === null) return null
969
- return `${identity}/optional:${optional}/repeats:${repeats}`
970
- }
971
-
972
- /** The slot record as a comparable string, with the names sorted so declaration order says nothing. */
973
- const childrenShape = (node) => {
974
- if (!node) return ""
975
- if (node.type !== "ObjectExpression") return null
976
- const slots = []
977
- for (const property of node.properties) {
978
- const name = propertyName(property)
979
- if (name === null) return null
980
- const shape = slotShape(property.value)
981
- if (shape === null) return null
982
- slots.push(`${name}=${shape}`)
983
- }
984
- return slots.sort().join(",")
985
- }
986
-
987
- /**
988
- * The shape one entry spells, as a string two entries can be compared by.
989
- *
990
- * WHAT IS IN IT IS THE WHOLE RULE. Classes as a MULTISET, because the order they are typed in is
991
- * not a decision the node takes; the host, because the element is part of what the key fixes; and
992
- * the slots by name, declared identity, `optional` and `repeats`, because that is what the entry
993
- * promises is inside.
994
- *
995
- * WHAT IS LEFT OUT IS THE WHOLE POINT. The key's name, its reason, its `restingCount` and the
996
- * literal `props` on a slot are all absent, so an entry differing only in one of them lands on the
997
- * same string as its twin. A different resting count IS the same shape: CONTRACT-9 refuses a key
998
- * for a different gap, and a key for a different placeholder count is that same widening.
999
- *
1000
- * A `host` that is absent is not folded into `div`. The frame defaults there, but naming the
1001
- * element is a decision the entry took and leaving it unnamed is not the same statement, so a pair
1002
- * disagreeing about it is never called a copy.
1003
- *
1004
- * @param node - the entry's object expression.
1005
- * @returns the shape string, or null when any part of the entry cannot be read statically.
1006
- */
1007
- const entryShape = (node) => {
1008
- if (!node || node.type !== "ObjectExpression") return null
1009
- let classes = []
1010
- let host = ""
1011
- let children = ""
1012
- for (const property of node.properties) {
1013
- const name = propertyName(property)
1014
- if (name === null) return null
1015
- // Both spellings, because the table and the type that checks it have carried both.
1016
- if (name === "classes" || name === "classNames") {
1017
- classes = literalStringList(property.value)
1018
- if (classes === null) return null
1019
- } else if (name === "host") {
1020
- host = literalString(property.value)
1021
- if (host === null) return null
1022
- } else if (name === "children") {
1023
- children = childrenShape(property.value)
1024
- if (children === null) return null
1025
- }
1026
- }
1027
- return `classes[${[...classes].sort().join(" ")}] host[${host}] children{${children}}`
1028
- }
1029
-
1030
- /** An entry whose shape another entry already spells is that entry under a second name. */
1031
- export const noDuplicateEntryShape = {
1032
- meta: {
1033
- type: "problem",
1034
- docs: { description: "Two entries may not spell one shape under two names." },
1035
- schema: [],
1036
- messages: {
1037
- duplicate:
1038
- "`{{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.",
1039
- },
1040
- },
1041
- create(context) {
1042
- if (!isContractTableFile(context.filename || context.getFilename())) return {}
1043
- return {
1044
- /*
1045
- * The entries are read off the `buildContracts` call rather than off every object in the
1046
- * file, for the reason the textual reader records: a second table in the same file would
1047
- * otherwise be compared against the first, and two vocabularies would report each other.
1048
- *
1049
- * An entry that cannot be read statically is skipped rather than reported. A table nobody can
1050
- * read is a reason to stay quiet, never a reason to call an entry a copy.
1051
- */
1052
- CallExpression(node) {
1053
- if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "buildContracts") return
1054
- const table = node.arguments[0]
1055
- if (!table || table.type !== "ObjectExpression") return
1056
- const firstByShape = new Map()
1057
- for (const property of table.properties) {
1058
- const key = propertyName(property)
1059
- if (key === null) continue
1060
- const shape = entryShape(property.value)
1061
- if (shape === null) continue
1062
- const other = firstByShape.get(shape)
1063
- // The pair is reported once, on the later entry, because the earlier one is the key the
1064
- // author is being sent back to.
1065
- if (other === undefined) firstByShape.set(shape, key)
1066
- else context.report({ node: property, messageId: "duplicate", data: { key, other } })
1067
- }
1068
- },
1069
- }
1070
- },
1071
- }
1072
-
1073
- /**
1074
- * The class families an entry may not hold, and what each of them is really claiming.
1075
- *
1076
- * A cursor and a hover state claim the node reacts to a pointer. A focus state claims it takes the
1077
- * keyboard. `group` claims something inside it answers this node's hover. A text colour and an
1078
- * alignment are the paint of one value rather than the relationship between several. None of them
1079
- * is an arrangement, and the table cannot be told when the claim stops being true: an entry cannot
1080
- * know that this call site passed no handler, so it goes on drawing a pointer over a dead node.
1081
- */
1082
- const INTERACTION_CLASS = /^(?:cursor-|group$|group\/|hover:|active:|focus:|focus-visible:|disabled:|aria-[a-z-]+:|data-\[)/
1083
-
1084
- /**
1085
- * ALIGNMENT IS NOT PAINT, and the line is drawn here rather than by taste.
1086
- *
1087
- * `text-center` says how the content of every child sits inside this node, which is the same kind
1088
- * of statement as `items-center` and is inherited by children that never asked - so it belongs to
1089
- * the node that arranges them. A text COLOUR says what one value looks like, and the leaf that
1090
- * draws that value already owns it; an entry that names one is a second author for something it
1091
- * cannot see. So `text-left` and `text-center` stay legal here, and `text-foreground` does not.
1092
- */
1093
- const PAINT_CLASS = /^(?:text-(?:foreground|muted|accent|success|warning|danger)$|decoration-|underline$)/
1094
-
1095
- /**
1096
- * A GROUND AND AN ELEVATION MAKE A RAISED OBJECT, and a raised object is a component.
1097
- *
1098
- * `bg-surface` lifts the node off the page and `shadow-*` says how far - and either alone already
1099
- * says it, which is why either alone is refused. Neither is a statement about how children stand
1100
- * together; both are statements about what the node IS. What it becomes is a card, and a card
1101
- * already has an owner: a named surface branch draws it, owns its inset, its edge and the vendor
1102
- * wrapper underneath. An entry that draws it too puts a second card inside the first, and nothing
1103
- * can see the doubling because the entry and the branch never read each other.
1104
- *
1105
- * THE NEIGHBOURS ARE DELIBERATELY LEFT OUT, each for its own reason.
1106
- *
1107
- * - `bg-background` is the app frame's ground - the page a navbar or a mobile action bar sits on,
1108
- * drawn by chrome no card branch owns. It raises nothing, so refusing it would take away the
1109
- * only ground those nodes have.
1110
- * - `rounded-*` and `border*` stay legal because an edge also CLIPS and DIVIDES: entries use it to
1111
- * round the ends of a joined list and to rule one band off from the next. A machine cannot tell
1112
- * those apart from the corner of a card, so the separation is left to the migration.
1113
- * - `divide-y`, `divide-separator` and `overflow-hidden` describe relationships BETWEEN children,
1114
- * which is the one thing an entry is for.
1115
- */
1116
- const RAISED_OBJECT_CLASS = /^(?:bg-surface(?:-|$)|shadow(?:-|$))/
1117
-
1118
- /**
1119
- * A ground alone is not a raised object, and this is the one shape that tells them apart.
1120
- *
1121
- * `shadow-*` is elevation and needs no companion: nothing casts a shadow without standing above
1122
- * something. A ground does not settle it - a landing page whose sections alternate their ground so a
1123
- * reader can count them carries the same token and is not a card.
1124
- *
1125
- * The difference is not the corner. Plenty of cards have no radius, and a band can be rounded. The
1126
- * difference is that a BAND RUNS EDGE TO EDGE AND RULES ITSELF OFF: it takes the full measure and
1127
- * separates itself from the next band with one border, because it has no boundary of its own to do
1128
- * it with. An object stops before the edge and is bounded by being an object.
1129
- *
1130
- * So a ground is refused unless the entry is written as a band, and a machine can read that: the
1131
- * full measure, plus a rule along one edge.
1132
- */
1133
- const BAND_WIDTH_CLASS = /^w-full$/
1134
- const BAND_RULE_CLASS = /^border-[bt]$/
1135
-
1136
- /** Whether this entry is written as a full-bleed band, which is the one ground an entry may own. */
1137
- const isBand = (classes) =>
1138
- classes.some((value) => BAND_WIDTH_CLASS.test(value)) && classes.some((value) => BAND_RULE_CLASS.test(value))
1139
-
1140
- /** An entry describes how children stand together; how the node reacts, paints or rises is a branch's. */
1141
- export const noInteractionClassInEntry = {
1142
- meta: {
1143
- type: "problem",
1144
- docs: { description: "Entry classes are the arrangement, never the behaviour, the paint or the object." },
1145
- schema: [],
1146
- messages: {
1147
- interaction:
1148
- "`{{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.",
1149
- paint:
1150
- "`{{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.",
1151
- raised:
1152
- "`{{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.",
1153
- },
1154
- },
1155
- create(context) {
1156
- if (!isContractTableFile(context.filename || context.getFilename())) return {}
1157
- return {
1158
- CallExpression(node) {
1159
- if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "buildContracts") return
1160
- const table = node.arguments[0]
1161
- if (!table || table.type !== "ObjectExpression") return
1162
- for (const property of table.properties) {
1163
- const key = propertyName(property)
1164
- if (key === null || !property.value || property.value.type !== "ObjectExpression") continue
1165
- for (const field of property.value.properties) {
1166
- const name = propertyName(field)
1167
- if (name !== "classes" && name !== "classNames") continue
1168
- if (!field.value || field.value.type !== "ArrayExpression") continue
1169
- const written = field.value.elements
1170
- .map((element) => literalString(element))
1171
- .filter((value) => value !== null && value !== "")
1172
- const band = isBand(written)
1173
- for (const element of field.value.elements) {
1174
- const value = literalString(element)
1175
- if (value === null || value === "") continue
1176
- // A ground with nothing bounding it is a band, and a band is the only ground an entry
1177
- // may still own. An elevation is refused wherever it appears.
1178
- const raises = RAISED_OBJECT_CLASS.test(value)
1179
- && (!band || /^shadow(?:-|$)/.test(value))
1180
- const messageId = INTERACTION_CLASS.test(value)
1181
- ? "interaction"
1182
- : PAINT_CLASS.test(value)
1183
- ? "paint"
1184
- : raises ? "raised" : null
1185
- if (messageId !== null) context.report({ node: element, messageId, data: { key, value } })
1186
- }
1187
- }
1188
- }
1189
- },
1190
- }
1191
- },
1192
- }
1193
-
1194
- // -- CONTRACT-13 -----------------------------------------------------------------------------------
1195
-
1196
- /** Every contract key one entry declares as a child of its own, including alternatives. */
1197
- const childContractKeys = (entry) => {
1198
- const out = []
1199
- if (!entry || entry.type !== "ObjectExpression") return out
1200
- for (const property of entry.properties) {
1201
- if (propertyName(property) !== "children") continue
1202
- const slots = property.value
1203
- if (!slots || slots.type !== "ObjectExpression") continue
1204
- for (const slot of slots.properties) {
1205
- const shape = slot.value
1206
- if (!shape || shape.type !== "ObjectExpression") continue
1207
- for (const field of shape.properties) {
1208
- if (propertyName(field) !== "contract") continue
1209
- const one = literalString(field.value)
1210
- // A slot accepting `["a", "b"]` renders whichever it is handed, so both are drawn.
1211
- const many = one === null ? literalStringList(field.value) : null
1212
- if (one !== null) out.push(one)
1213
- else if (many !== null) out.push(...many)
1214
- }
1215
- }
1216
- }
1217
- return out
1218
- }
1219
-
1220
- /** A key nobody renders is a promise about a node that does not exist. */
1221
- export const noDeadContractKey = {
1222
- meta: {
1223
- type: "problem",
1224
- docs: { description: "Every key in the table is rendered somewhere, or it is not vocabulary." },
1225
- schema: [],
1226
- messages: {
1227
- dead:
1228
- "`{{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.",
1229
- },
1230
- },
1231
- create(context) {
1232
- const file = normalizePath(context.filename || context.getFilename())
1233
- if (!isContractTableFile(file)) return {}
1234
- /*
1235
- * A table inside a plan record is a record. A design candidate carries a COPY of the vocabulary
1236
- * and renders the one page it was built to answer, so most of that copy is drawn by nobody -
1237
- * which is not a finding, it is what a plan record is: the place this rule sends an unbuilt
1238
- * shape to. Measured on `starci-academy-fe`, two candidates would have opened a hundred and
1239
- * thirty deletions against a tree nothing ships.
1240
- */
1241
- if (file.includes("/.artifacts/")) return {}
1242
- /*
1243
- * No tree, no finding. `readContractReferences` returns null when the repository root cannot be
1244
- * resolved from this path or nothing can be walked, and the only honest report from a reader
1245
- * that looked nowhere is silence - the alternative is every key in the vocabulary reported dead
1246
- * at once, which reads as a table to empty rather than as a rule that is blind.
1247
- */
1248
- const referenced = readContractReferences(file)
1249
- if (referenced === null) return {}
1250
- const roots = contractSearchRoots(file).join(", ")
1251
- return {
1252
- CallExpression(node) {
1253
- if (!node.callee || node.callee.type !== "Identifier" || node.callee.name !== "buildContracts") return
1254
- const table = node.arguments[0]
1255
- if (!table || table.type !== "ObjectExpression") return
1256
- // A child slot is a call site the walk cannot see, because the only file naming that key is
1257
- // the one file the walk skips. Collected first, so declaration order says nothing.
1258
- const asChild = new Set()
1259
- for (const property of table.properties) {
1260
- for (const key of childContractKeys(property.value)) asChild.add(key)
1261
- }
1262
- for (const property of table.properties) {
1263
- const key = propertyName(property)
1264
- if (key === null || referenced.has(key) || asChild.has(key)) continue
1265
- context.report({ node: property, messageId: "dead", data: { key, roots } })
1266
- }
1267
- },
1268
- }
1269
- },
1270
- }
1271
-
1272
- /** The rules this law contributes to the plugin. */
1273
- export const rules = {
1274
- "no-literal-structural-class": noLiteralStructuralClass,
1275
- "no-interaction-class-in-entry": noInteractionClassInEntry,
1276
- "no-class-composition-outside-contract": noClassCompositionOutsideContract,
1277
- "contract-why-is-a-reason": contractWhyIsAReason,
1278
- "no-structural-host-outside-contract-frame": noStructuralHostOutsideContractFrame,
1279
- "no-structural-arrangement-in-leaf": noStructuralArrangementInLeaf,
1280
- "no-hand-written-contract-attrs": noHandWrittenContractAttrs,
1281
- "no-unknown-contract-key": noUnknownContractKey,
1282
- "no-duplicate-entry-shape": noDuplicateEntryShape,
1283
- "only-the-frame-wears-a-node": onlyTheFrameWearsANode,
1284
- "no-dead-contract-key": noDeadContractKey,
1285
- }
1286
-
1287
- /**
1288
- * The level this law asks for, as the plugin's own opinion.
1289
- *
1290
- * A consuming repository's `eslint.config.mjs` stays the authority on what is actually switched on:
1291
- * a repository carrying debt turns a rule down to `warn` while it burns the count down, and back to
1292
- * `error` at zero. What never changes is that a rule at `error` is a broken build rather than a
1293
- * warning to triage.
1294
- */
1295
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
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"]))