@starci/eslint-canon-fe 1.4.5 → 3.0.0

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