basalt-ui 1.19.1 → 1.21.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.
Files changed (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. package/src/vite.ts +148 -20
@@ -9,10 +9,13 @@
9
9
  * `./oxlint-plugin.js` — a consumer `extends`-ing that preset inherits `jsPlugins` too, and the
10
10
  * path resolves relative to the preset file, not the consumer's own config.
11
11
  *
12
- * The four design-guard rules below support the same `theme-allow` escape as `src/guard`: skip a
13
- * reported node if a line comment containing `theme-allow` sits on the node's own line or the line
14
- * immediately above it. The three boundary rules deliberately do NOT — see their own comment for
15
- * why.
12
+ * The design-guard rules below support the same `theme-allow` escape as `src/guard`, and the same
13
+ * two-scope grammar: `theme-allow [<id>…] [— <why>]` at the START of a comment on the reported
14
+ * node's own line or the line above skips THAT node; `theme-allow-file <id>… — <why>` anywhere in
15
+ * the file skips every node of the named rules in it. Both halves are load-bearing — see
16
+ * {@link parseThemeAllows} for why the prefix matters and {@link hasFileDeclaration} for why file
17
+ * scope has to be spelled. The three boundary rules deliberately honour neither — see their own
18
+ * comment for why.
16
19
  *
17
20
  * ── The two agent-chat guard rules (`agent-resume-guard` / `agent-no-raw-usechat`) + `ai-sdk-major` ──
18
21
  * These are also correctness/architecture boundaries, not design guidance, so they do NOT honour
@@ -77,18 +80,218 @@ import { fileURLToPath } from 'node:url'
77
80
 
78
81
  // ── Shared helpers ──────────────────────────────────────────────────────────────────────────────
79
82
 
80
- /** True when a `theme-allow` line comment sits on `node`'s own source line or the line above it. */
81
- function hasThemeAllow(context, node) {
83
+ /**
84
+ * Rule ids a `theme-allow` may name — this plugin's own rules plus `src/guard`'s kinds. Duplicated
85
+ * by hand for the same reason `majorOf` is: this file loads standalone out of a consumer's
86
+ * node_modules and must not import from the package.
87
+ *
88
+ * Exported, and `oxlint-plugin.test.ts` asserts it is EXACTLY the plugin's own rule ids plus
89
+ * `GUARD_RULES`'s keys. Forgetting an entry here is not a cosmetic omission — an id this set does
90
+ * not know is treated as a typo, so the annotation naming it waives nothing (see
91
+ * {@link parseThemeAllow}); before the fail-closed change it was worse still, silently widening to
92
+ * a blanket waiver.
93
+ */
94
+ export const KNOWN_RULE_IDS = new Set([
95
+ // this plugin
96
+ 'no-raw-font-size',
97
+ 'raw-size-literal',
98
+ 'card-inset',
99
+ 'chart-in-raw-surface',
100
+ 'hand-rolled-plot',
101
+ 'chart-legend-literal',
102
+ 'hand-rolled-shell',
103
+ 'shadow-basalt-export',
104
+ 'raw-scroll-container',
105
+ 'visx-boundary',
106
+ 'visx-tooltip',
107
+ 'token-layer-boundary',
108
+ // These three honour `basalt-agent-allow`, never `theme-allow` — but they are real ids, so an
109
+ // annotation naming one must parse as a (useless) scoped annotation rather than as prose.
110
+ 'agent-resume-guard',
111
+ 'agent-no-raw-usechat',
112
+ 'ai-sdk-major',
113
+ // src/guard kinds
114
+ 'raw-hex',
115
+ 'raw-color-fn',
116
+ 'localstorage-theme',
117
+ 'off-identity-accent',
118
+ 'mantine-shade-index',
119
+ 'raw-spacing',
120
+ 'raw-radius',
121
+ 'raw-surface',
122
+ 'card-with-border',
123
+ 'off-system-surface-var',
124
+ 'raw-html-layout',
125
+ 'inline-spacing',
126
+ 'inline-display',
127
+ 'raw-visx-axis',
128
+ 'raw-motion-value',
129
+ 'unframed-chart',
130
+ 'chart-missing-aria-label',
131
+ 'raw-form-control',
132
+ 'sub-16-input-font',
133
+ 'raw-font-family',
134
+ 'theme-allow-unscoped',
135
+ 'surface-shadow-override',
136
+ 'css-raw-surface',
137
+ 'inline-font-size',
138
+ 'hidden-inline-style',
139
+ ])
140
+
141
+ const ALLOW_RULE_TOKEN = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/
142
+ const ALLOW_REASON_SEPARATOR = /^(?:—|–|-{1,2}|:)\s*/
143
+ /** Shortest string accepted as a written reason — enough to exclude a stray separator. */
144
+ const MIN_ALLOW_REASON_LENGTH = 4
145
+
146
+ /**
147
+ * The annotation token, with its optional `-file` suffix.
148
+ *
149
+ * `(?![\w-])` keeps the two forms apart AND stops a longer word from parsing as the bare form:
150
+ * `theme-allow-unscoped` — the KIND NAME, written in prose constantly — used to consume no id, fall
151
+ * through to `rules: []` and read as a blanket waiver.
152
+ */
153
+ const ALLOW_TOKEN = /theme-allow(-file)?(?![\w-])/
154
+
155
+ /**
156
+ * Everything permitted between the start of a line INSIDE the comment and the token.
157
+ *
158
+ * The annotation has to START its comment (or a line of one — the `*` is a block-comment gutter).
159
+ * Without this the parser did a bare `indexOf('theme-allow')`, so a comment that merely MENTIONED
160
+ * the token in prose parsed as the blanket form and switched every rule off on the node below it.
161
+ * linewatch documented its own waivers in a docblock and thereby disarmed the file: a false
162
+ * NEGATIVE, and worse than the whole-file hole 1.20.0 closed. `src/guard`'s `ANNOTATION_PREFIX` is
163
+ * the same test against raw source text (which still carries its `//` / `/*` opener; a comment
164
+ * NODE's `value` does not, hence the two spellings).
165
+ */
166
+ const ALLOW_LINE_PREFIX = /^\s*\*?\s*$/
167
+
168
+ /** Parse the text following the token — ids, then the reason. See {@link parseThemeAllows}. */
169
+ function parseAllowBody(rest, scope) {
170
+ let remainder = rest.replace(/^[\s,]+/, '')
171
+ const rules = []
172
+ const unknownRules = []
173
+ let inIdSlot = true
174
+ for (;;) {
175
+ const token = ALLOW_RULE_TOKEN.exec(remainder)
176
+ if (token === null) break
177
+ if (!KNOWN_RULE_IDS.has(token[1])) {
178
+ if (inIdSlot) unknownRules.push(token[1])
179
+ break
180
+ }
181
+ rules.push(token[1])
182
+ const after = remainder.slice(token[0].length)
183
+ remainder = after.replace(/^[\s,]+/, '')
184
+ inIdSlot = /^\s*,/.test(after)
185
+ }
186
+ const reason = remainder.replace(ALLOW_REASON_SEPARATOR, '').trim()
187
+ return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH, scope }
188
+ }
189
+
190
+ /**
191
+ * Every annotation one comment carries. A docblock can hold more than one, so this returns a list.
192
+ *
193
+ * The grammar, and it is the same one `src/guard` parses:
194
+ *
195
+ * ```text
196
+ * theme-allow → this node, EVERY rule (the legacy bare form)
197
+ * theme-allow <id>[, <id>…] [— <why>] → this node, those rules
198
+ * theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules
199
+ * ```
200
+ *
201
+ * `{ rules: [], unknownRules: [] }` is the bare form; a non-empty `rules` scopes the exception to
202
+ * exactly those ids. A word that occupies the id slot but names no known rule lands in
203
+ * `unknownRules` and FAILS CLOSED — the annotation then waives only the ids it got right, never
204
+ * everything. `theme-allow raw-hexx — reason` used to consume no id, fall through to the
205
+ * empty-`rules` branch and be read as the blanket form, so one mistyped character escalated a
206
+ * scoped waiver into a whole-line one, i.e. weaker than before the scoping existed.
207
+ *
208
+ * A prose reason is therefore introduced with a separator (`—`, `–`, `-`, `:`), which is how every
209
+ * annotation in the wild already writes it.
210
+ *
211
+ * The id slot closes at the first space that no comma opened: the first word is always a claimed
212
+ * id (that is where a typo lands), but after a resolved id only a `,` keeps the list open, so
213
+ * `theme-allow raw-surface sub-scale legend corner` reads `sub-scale` as prose rather than as a
214
+ * typo. See the guard's copy for the full reasoning.
215
+ */
216
+ function parseThemeAllows(commentValue) {
217
+ const out = []
218
+ for (const rawLine of commentValue.split('\n')) {
219
+ const token = ALLOW_TOKEN.exec(rawLine)
220
+ if (token === null) continue
221
+ if (!ALLOW_LINE_PREFIX.test(rawLine.slice(0, token.index))) continue
222
+ const rest = rawLine.slice(token.index + token[0].length)
223
+ out.push(parseAllowBody(rest, token[1] === undefined ? 'line' : 'file'))
224
+ }
225
+ return out
226
+ }
227
+
228
+ /**
229
+ * Does one annotation cover `ruleId`?
230
+ *
231
+ * Only a BARE **node** annotation covers every rule. A bare `theme-allow-file` covers nothing: the
232
+ * blanket form is tolerable on one line, where a reader sees what it sits on; over a whole file it
233
+ * is a config exemption without the config review, so the widest waiver in the contract is the one
234
+ * that has to name what it waives.
235
+ */
236
+ function allowCovers(allow, ruleId) {
237
+ if (allow.rules.includes(ruleId)) return true
238
+ if (allow.scope === 'file') return false
239
+ return allow.rules.length === 0 && allow.unknownRules.length === 0
240
+ }
241
+
242
+ function allComments(context) {
82
243
  const sourceCode = context.sourceCode ?? context.getSourceCode?.()
83
- const comments = sourceCode?.getAllComments?.() ?? []
244
+ return sourceCode?.getAllComments?.() ?? []
245
+ }
246
+
247
+ /**
248
+ * True when a NODE-scoped `theme-allow` covering `ruleId` sits on `node`'s own source line or the
249
+ * line above — or when the file carries a `theme-allow-file` declaration for it.
250
+ *
251
+ * The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
252
+ * raw-hex — …` written for a color no longer silently switches off `card-inset` on the same line.
253
+ * An annotation that reached for an id and missed covers only the ids it got right.
254
+ */
255
+ function hasThemeAllow(context, node, ruleId) {
256
+ if (hasFileDeclaration(context, ruleId)) return true
84
257
  const nodeLine = node.loc.start.line
85
- return comments.some(
86
- (comment) =>
87
- comment.value.includes('theme-allow') &&
88
- (comment.loc.end.line === nodeLine || comment.loc.end.line === nodeLine - 1),
258
+ return allComments(context).some((comment) => {
259
+ if (comment.loc.end.line !== nodeLine && comment.loc.end.line !== nodeLine - 1) return false
260
+ return parseThemeAllows(comment.value).some(
261
+ (allow) => allow.scope === 'line' && allowCovers(allow, ruleId),
262
+ )
263
+ })
264
+ }
265
+
266
+ /**
267
+ * True when the FILE carries a written declaration for `ruleId` — a `theme-allow-file` naming it,
268
+ * anywhere in the file.
269
+ *
270
+ * File scope has to be SPELLED, and that is the second half of the 1.20.0 fix rather than a new
271
+ * idea. The first half landed: `hand-rolled-plot` stopped granting a whole file permanent immunity
272
+ * off any comment that happened to sit on its first assembly node, and now reports every node. The
273
+ * second half did not, because the promotion rule was "names a rule AND gives a reason" — which is
274
+ * exactly what the rule's own message asks a consumer to write, and what `theme-allow-unscoped`
275
+ * reports them for omitting. So there was one legal annotation shape and it was whole-file:
276
+ * `hasFileDeclaration` still returned early for every node, forever. linewatch ended up with one
277
+ * whole-file declaration per chart file — the thing 1.20.0 set out to eliminate — because per-node
278
+ * scoping was not expressible. Now it is: `theme-allow` is the node, `theme-allow-file` is the file.
279
+ */
280
+ function hasFileDeclaration(context, ruleId) {
281
+ return allComments(context).some((comment) =>
282
+ parseThemeAllows(comment.value).some(
283
+ (allow) => allow.scope === 'file' && allow.rules.includes(ruleId),
284
+ ),
89
285
  )
90
286
  }
91
287
 
288
+ /** Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP. */
289
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])__tests__[\\/]/
290
+
291
+ function isTestFile(context) {
292
+ return TEST_FILE.test(getFilename(context))
293
+ }
294
+
92
295
  /**
93
296
  * True when a `basalt-agent-allow` line comment sits on `node`'s own source line or the line above
94
297
  * it. A copy of `hasThemeAllow` with a different needle — deliberately NOT the same token, so a
@@ -146,6 +349,50 @@ const NO_RAW_FONT_SIZE_MESSAGE =
146
349
  'Raw font-size literal — route through VX.text.* (numbers) or --vx-text-* (CSS); em/relative ' +
147
350
  'ratios are allowed. (basalt/no-raw-font-size)'
148
351
 
352
+ /** JSX attributes whose value IS a style object — the only object literals this rule reaches into. */
353
+ const STYLE_ATTRS = new Set(['style', 'styles', 'sx'])
354
+ /** A binding or key whose name says the object is styling — `wrapperStyle`, `styles`, `rowStyles`. */
355
+ const STYLE_NAME = /styles?$/i
356
+ /** How far up the tree the style-context walk goes before giving up. */
357
+ const STYLE_CONTEXT_MAX_DEPTH = 12
358
+
359
+ /**
360
+ * Is this object property part of a STYLE object?
361
+ *
362
+ * `fontSize` is not a reserved word. The rule used to report every object property spelled that
363
+ * way, which fired on an Obsidian `data.json` fixture — `{ settings: { fontSize: 16 } }` inside a
364
+ * string, in a package with no React and no Mantine — where the "route it through VX.text.*" advice
365
+ * is not merely unhelpful, it is about a different domain. Requiring a style context (a
366
+ * `style`/`styles`/`sx` JSX attribute, a `…Style(s)` binding or key, or a `CSSProperties`
367
+ * annotation) keeps every shape that is really styling and drops the ones that never reach CSS.
368
+ */
369
+ function isInStyleContext(context, node) {
370
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
371
+ let current = node.parent
372
+ for (let depth = 0; current !== undefined && current !== null; depth++) {
373
+ if (depth > STYLE_CONTEXT_MAX_DEPTH) return false
374
+ if (current.type === 'JSXAttribute') return STYLE_ATTRS.has(current.name?.name)
375
+ if (current.type === 'Property') {
376
+ const key = current.key
377
+ const name = key?.type === 'Identifier' ? key.name : key?.value
378
+ if (typeof name === 'string' && STYLE_NAME.test(name)) return true
379
+ }
380
+ if (current.type === 'VariableDeclarator') {
381
+ const id = current.id
382
+ if (typeof id?.name === 'string' && STYLE_NAME.test(id.name)) return true
383
+ const annotation = id?.typeAnnotation
384
+ return (
385
+ annotation !== undefined &&
386
+ annotation !== null &&
387
+ (sourceCode?.getText?.(annotation) ?? '').includes('CSSProperties')
388
+ )
389
+ }
390
+ if (current.type === 'FunctionDeclaration' || current.type === 'Program') return false
391
+ current = current.parent
392
+ }
393
+ return false
394
+ }
395
+
149
396
  const noRawFontSize = {
150
397
  meta: {
151
398
  type: 'suggestion',
@@ -155,7 +402,7 @@ const noRawFontSize = {
155
402
  schema: [],
156
403
  },
157
404
  create(context) {
158
- if (getFilename(context).includes('/src/tokens/')) return {}
405
+ if (getFilename(context).includes('/src/tokens/') || isTestFile(context)) return {}
159
406
 
160
407
  return {
161
408
  JSXAttribute(node) {
@@ -163,7 +410,7 @@ const noRawFontSize = {
163
410
  if (name !== 'fz' && name !== 'fontSize') return
164
411
  const value = unwrapExpressionContainer(node.value)
165
412
  if (!isNumericLiteral(value)) return
166
- if (hasThemeAllow(context, node)) return
413
+ if (hasThemeAllow(context, node, 'no-raw-font-size')) return
167
414
  context.report({ node, message: NO_RAW_FONT_SIZE_MESSAGE })
168
415
  },
169
416
  Property(node) {
@@ -173,7 +420,8 @@ const noRawFontSize = {
173
420
  (key.type === 'Literal' && key.value === 'fontSize')
174
421
  if (!isFontSizeKey) return
175
422
  if (!isNumericLiteral(node.value)) return
176
- if (hasThemeAllow(context, node)) return
423
+ if (!isInStyleContext(context, node)) return
424
+ if (hasThemeAllow(context, node, 'no-raw-font-size')) return
177
425
  context.report({ node, message: NO_RAW_FONT_SIZE_MESSAGE })
178
426
  },
179
427
  }
@@ -225,7 +473,7 @@ const rawSizeLiteral = {
225
473
  if (typeof name !== 'string' || !SIZE_ATTRS.has(name)) return
226
474
  const value = unwrapExpressionContainer(node.value)
227
475
  if (!isCssLengthString(value)) return
228
- if (hasThemeAllow(context, node)) return
476
+ if (hasThemeAllow(context, node, 'raw-size-literal')) return
229
477
  context.report({ node, message: RAW_SIZE_LITERAL_MESSAGE })
230
478
  },
231
479
  }
@@ -276,7 +524,7 @@ const cardInset = {
276
524
  const isRadius = attrName === 'radius'
277
525
 
278
526
  if (!isOffPadding && !isOffPy && !isOffPx && !isRadius) continue
279
- if (hasThemeAllow(context, attr)) continue
527
+ if (hasThemeAllow(context, attr, 'card-inset')) continue
280
528
  context.report({ node: attr, message: CARD_INSET_MESSAGE })
281
529
  }
282
530
  },
@@ -350,7 +598,7 @@ const chartInRawSurface = {
350
598
  if (!CARD_TAGS.has(tagName)) return
351
599
  const hasChartDescendant = node.children.some((child) => subtreeHasChart(child))
352
600
  if (!hasChartDescendant) return
353
- if (hasThemeAllow(context, node)) return
601
+ if (hasThemeAllow(context, node, 'chart-in-raw-surface')) return
354
602
  context.report({ node, message: CHART_IN_RAW_SURFACE_MESSAGE })
355
603
  },
356
604
  }
@@ -396,7 +644,7 @@ const rawScrollContainer = {
396
644
  key.type === 'Identifier' ? key.name : key.type === 'Literal' ? key.value : undefined
397
645
  if (typeof keyName !== 'string' || !OVERFLOW_KEYS.has(keyName)) return
398
646
  if (!isStringLiteral(node.value) || !SCROLLING_VALUES.has(node.value.value)) return
399
- if (hasThemeAllow(context, node)) return
647
+ if (hasThemeAllow(context, node, 'raw-scroll-container')) return
400
648
  context.report({ node, message: RAW_SCROLL_CONTAINER_MESSAGE })
401
649
  },
402
650
  }
@@ -760,6 +1008,61 @@ function nearestAiMajor(startDir) {
760
1008
  return result
761
1009
  }
762
1010
 
1011
+ // One parsed manifest per path — the scope walk below re-reads the same ancestors for every file.
1012
+ const packageJsonCache = new Map()
1013
+
1014
+ function readPackageJson(pkgPath) {
1015
+ if (packageJsonCache.has(pkgPath)) return packageJsonCache.get(pkgPath)
1016
+ let pkg = null
1017
+ try {
1018
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
1019
+ } catch {
1020
+ pkg = null
1021
+ }
1022
+ packageJsonCache.set(pkgPath, pkg)
1023
+ return pkg
1024
+ }
1025
+
1026
+ /**
1027
+ * Is this file inside a package that actually consumes basalt-ui?
1028
+ *
1029
+ * Without this the rule compared EVERY package's `ai` major against basalt's own peer major, and
1030
+ * fired three errors in an rb workspace package (`apps/api`) that has no basalt-ui dependency, is
1031
+ * outside `basalt.roots`, and pins `ai@6` deliberately. `doctor`'s `ai-major-parity` scopes the same
1032
+ * concern correctly — two shipped enforcement surfaces disagreeing about one fact leaves a consumer
1033
+ * unable to tell which is right, which is worse than either being wrong.
1034
+ *
1035
+ * In scope when an ancestor package.json either depends on `basalt-ui`, or declares `basalt.roots`
1036
+ * that the file sits under. A declared `basalt.roots` that the file is NOT under is a positive
1037
+ * statement that this code is not basalt's — the walk stops there rather than continuing up to a
1038
+ * workspace root that happens to carry the dependency.
1039
+ *
1040
+ * **`roots` is consulted BEFORE the dependency, within one manifest**, and that ordering is the
1041
+ * whole fix. `init` produces exactly the layout that breaks the other way round: a monorepo ROOT
1042
+ * carrying `basalt-ui` as the devDependency the CLI is run from, AND `basalt.roots: ["apps/web/
1043
+ * src"]` naming where the app actually is. Dep-first returned in-scope on that root before `roots`
1044
+ * was ever read, so rb's `apps/api` — no basalt dependency, deliberately on a different `ai` major,
1045
+ * explicitly outside the declared roots — took three errors and needed a permanent override. The
1046
+ * more specific statement wins: `roots` says WHERE, the dependency only says whether.
1047
+ */
1048
+ function isBasaltScopedFile(filename) {
1049
+ let dir = dirname(filename)
1050
+ for (;;) {
1051
+ const pkg = readPackageJson(resolvePath(dir, 'package.json'))
1052
+ if (pkg !== null) {
1053
+ const roots = pkg.basalt?.roots
1054
+ if (Array.isArray(roots) && roots.length > 0) {
1055
+ return roots.some((root) => filename.startsWith(`${resolvePath(dir, root)}/`))
1056
+ }
1057
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
1058
+ if (deps['basalt-ui'] !== undefined) return true
1059
+ }
1060
+ const parent = dirname(dir)
1061
+ if (parent === dir) return false
1062
+ dir = parent
1063
+ }
1064
+ }
1065
+
763
1066
  function aiSdkMajorMessage(consumerMajor) {
764
1067
  return (
765
1068
  `This file's nearest package.json declares ai@${consumerMajor}, but basalt-ui declares the ` +
@@ -792,6 +1095,7 @@ const aiSdkMajor = {
792
1095
 
793
1096
  const filename = getFilename(context)
794
1097
  if (filename.length === 0) return
1098
+ if (!isBasaltScopedFile(filename)) return
795
1099
  const consumerMajor = nearestAiMajor(dirname(filename))
796
1100
  if (consumerMajor === null) return // no `ai` in the nearest package.json — nothing to compare
797
1101
  if (consumerMajor === BASALT_AI_MAJOR) return
@@ -818,6 +1122,19 @@ const PLOT_ASSEMBLY_TAGS = new Set([
818
1122
  'Crosshair',
819
1123
  ])
820
1124
 
1125
+ /**
1126
+ * KNOWN LIMIT, deliberately not closed: a chart drawn out of DOM instead of the chart layer is
1127
+ * structurally invisible here.
1128
+ *
1129
+ * argo has two — a 3×3 Mantine `SimpleGrid` matrix and absolutely-positioned `Box` bullet bars —
1130
+ * that import only `ChartCard` and `VX`, render no basalt primitive, and therefore cannot trip any
1131
+ * tag-name check. The candidate widenings all fail on false positives in a rule that SHIPS to
1132
+ * consumers: adding `ChartTooltipFloat`/`useChartSize` to this set flags `Donut` and `Heatmap`,
1133
+ * which compose them legitimately; "renders `ChartCard` and a raw `<svg>`" flags an inline icon in
1134
+ * a card header. Detecting "this Box grid is a chart" needs intent, not syntax. Naming the gap here
1135
+ * is the honest answer — a noisy rule gets switched off, and then it guards nothing at all.
1136
+ */
1137
+
821
1138
  /**
822
1139
  * Composing this means the file is USING the chart system rather than re-implementing it.
823
1140
  * `ChartFrame` deliberately does NOT count: composing the frame directly and then hand-assembling
@@ -847,7 +1164,9 @@ const HAND_ROLLED_PLOT_MESSAGE =
847
1164
  'owns the measured margins, both y scales, the axes, grid, shared cursor, crosshair and ' +
848
1165
  'tooltip. Hand-assembling them is how two charts stop matching. Compose CartesianChart and ' +
849
1166
  'render only marks. A genuinely non-single-plot shape (multi-pane, radial, matrix) composes ' +
850
- 'ChartFrame instead — add a theme-allow comment to declare that. (basalt/hand-rolled-plot)'
1167
+ 'ChartFrame instead — declare that with `theme-allow-file hand-rolled-plot — <why>` anywhere in ' +
1168
+ 'the file. To waive just THIS node and stay policed on the rest, write `theme-allow ' +
1169
+ 'hand-rolled-plot — <why>` on its own line above it. (basalt/hand-rolled-plot)'
851
1170
 
852
1171
  const handRolledPlot = {
853
1172
  meta: {
@@ -859,6 +1178,7 @@ const handRolledPlot = {
859
1178
  schema: [],
860
1179
  },
861
1180
  create(context) {
1181
+ if (isTestFile(context)) return {}
862
1182
  // File-scoped, not node-scoped: the verdict depends on whether a plot OWNER appears anywhere
863
1183
  // in the file, which is only known once the whole file has been walked.
864
1184
  const assemblyNodes = []
@@ -891,16 +1211,26 @@ const handRolledPlot = {
891
1211
  },
892
1212
  'Program:exit'() {
893
1213
  if (ownsPlot || definesOwner) return
894
- // Only the FIRST site is reported, and the whole check is FILE-scoped rather than
895
- // component-scoped. Both are deliberate. File scope is forced by the shape of the correct
896
- // pattern: `<CartesianChart>{() => <AxisLeftNumeric …/>}</CartesianChart>` puts the axis
897
- // inside a render-prop arrow, a different function from the component that owns the chart
898
- // — component scope would flag the canonical usage. One report because declaring an
899
- // exception is a decision about the FILE ("this shape isn't a single cartesian plot"), so
900
- // one `theme-allow` settles it instead of one comment per axis and overlay.
901
- const [first] = assemblyNodes.filter((node) => chartImports.has(node.name?.name))
902
- if (first === undefined || hasThemeAllow(context, first)) return
903
- context.report({ node: first, message: HAND_ROLLED_PLOT_MESSAGE })
1214
+ // The CHECK stays file-scoped — forced by the shape of the correct pattern:
1215
+ // `<CartesianChart>{() => <AxisLeftNumeric …/>}</CartesianChart>` puts the axis inside a
1216
+ // render-prop arrow, a different function from the component that owns the chart, so
1217
+ // component scope would flag the canonical usage.
1218
+ //
1219
+ // The SUPPRESSION no longer is. Reporting only the first node and testing the waiver there
1220
+ // meant one comment — any comment, about anything — bought a whole file permanent immunity:
1221
+ // every axis, overlay and crosshair added to that file afterwards went unreported forever
1222
+ // (linewatch: three files, one 604 lines). Now every assembly node is reported and waived on
1223
+ // its own, EXCEPT when the file carries a written declaration — a `theme-allow` that names
1224
+ // `basalt/hand-rolled-plot` and gives a reason. That is the shape a genuinely non-single-plot
1225
+ // kind already uses (`DualPanel`), and it is the difference between a line waiver and a
1226
+ // decision about the file.
1227
+ const nodes = assemblyNodes.filter((node) => chartImports.has(node.name?.name))
1228
+ if (nodes.length === 0) return
1229
+ if (hasFileDeclaration(context, 'hand-rolled-plot')) return
1230
+ for (const node of nodes) {
1231
+ if (hasThemeAllow(context, node, 'hand-rolled-plot')) continue
1232
+ context.report({ node, message: HAND_ROLLED_PLOT_MESSAGE })
1233
+ }
904
1234
  },
905
1235
  }
906
1236
  },
@@ -914,6 +1244,40 @@ const CHART_LEGEND_LITERAL_MESSAGE =
914
1244
  'is a second source of truth that silently goes stale: it keeps naming a series after the plot ' +
915
1245
  'stops drawing it. (basalt/chart-legend-literal)'
916
1246
 
1247
+ /**
1248
+ * Does this expression derive from the chart's own `series`?
1249
+ *
1250
+ * The contract is derivation from `series` — not derivation from *an* array. `items={refLines.map(
1251
+ * (r) => ({ key, label, color, shape }))}` and `items={PACE_ZONES.map(…)}` are hand-authored
1252
+ * legends by any reading: every field is written at the call site, and the list can name a band the
1253
+ * plot no longer draws exactly like a `[...]` literal can. Walks the base of a member/call chain so
1254
+ * `visibleSeries.filter(…).map(…)` and `deriveLegend(series)` both read as derived.
1255
+ */
1256
+ function derivesFromSeries(node, depth = 0) {
1257
+ if (node === null || node === undefined || depth > 8) return false
1258
+ if (node.type === 'Identifier') return /series$/i.test(node.name ?? '')
1259
+ if (node.type === 'MemberExpression') {
1260
+ return derivesFromSeries(node.object, depth + 1) || derivesFromSeries(node.property, depth + 1)
1261
+ }
1262
+ if (node.type === 'CallExpression') {
1263
+ if (node.callee?.type === 'Identifier' && node.callee.name === 'deriveLegend') return true
1264
+ return (
1265
+ derivesFromSeries(node.callee, depth + 1) ||
1266
+ (node.arguments ?? []).some((arg) => derivesFromSeries(arg, depth + 1))
1267
+ )
1268
+ }
1269
+ return false
1270
+ }
1271
+
1272
+ /** `X.map(…)` — the derivation-shaped form that is only derivation when `X` is the series. */
1273
+ function isMapCall(node) {
1274
+ return (
1275
+ node?.type === 'CallExpression' &&
1276
+ node.callee?.type === 'MemberExpression' &&
1277
+ node.callee.property?.name === 'map'
1278
+ )
1279
+ }
1280
+
917
1281
  const chartLegendLiteral = {
918
1282
  meta: {
919
1283
  type: 'suggestion',
@@ -921,6 +1285,7 @@ const chartLegendLiteral = {
921
1285
  schema: [],
922
1286
  },
923
1287
  create(context) {
1288
+ if (isTestFile(context)) return {}
924
1289
  const chartImports = new Set()
925
1290
 
926
1291
  return {
@@ -933,6 +1298,14 @@ const chartLegendLiteral = {
933
1298
  (attr) => attr.type === 'JSXAttribute' && attr.name?.name === 'items',
934
1299
  )
935
1300
  const expression = items?.value?.expression
1301
+ // The `.map()` half — a list built by mapping over something that is NOT the series. See
1302
+ // `derivesFromSeries`. Reported under the same id as the array-literal half because it is
1303
+ // the same defect (a second source of truth for the legend), just written as a call.
1304
+ if (isMapCall(expression) && !derivesFromSeries(expression.callee?.object)) {
1305
+ if (hasThemeAllow(context, node, 'chart-legend-literal')) return
1306
+ context.report({ node, message: CHART_LEGEND_LITERAL_MESSAGE })
1307
+ return
1308
+ }
936
1309
  if (expression?.type !== 'ArrayExpression') return
937
1310
  // Only a list that is ENTIRELY object literals is a hand-authored legend. An array that
938
1311
  // spreads or calls something (`[...deriveLegend(series), note]`) is derived-and-extended,
@@ -946,13 +1319,241 @@ const chartLegendLiteral = {
946
1319
  expression.elements.length > 0 &&
947
1320
  expression.elements.every((el) => el?.type === 'ObjectExpression')
948
1321
  if (!allObjectLiterals) return
949
- if (hasThemeAllow(context, node)) return
1322
+ if (hasThemeAllow(context, node, 'chart-legend-literal')) return
950
1323
  context.report({ node, message: CHART_LEGEND_LITERAL_MESSAGE })
951
1324
  },
952
1325
  }
953
1326
  },
954
1327
  }
955
1328
 
1329
+ // ── Rule 14 — shadow-basalt-export ───────────────────────────────────────────────────────────────
1330
+
1331
+ /** The package root — one directory above `configs/`, repo-locally AND inside node_modules. */
1332
+ const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
1333
+
1334
+ /**
1335
+ * A component-shaped name: starts uppercase and carries a lowercase letter, which excludes the
1336
+ * SCREAMING_CASE constants and the two-letter token namespaces in the same barrel.
1337
+ */
1338
+ const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
1339
+
1340
+ /**
1341
+ * The barrels a consumer can import a component FROM. The root one is not the whole surface: a
1342
+ * chart-layer fork (`ChartFrame`, `CartesianChart`, `ChartCard`, …) collided with nothing, because
1343
+ * `./charts` is deliberately NOT re-exported from `.` — so a consumer whose forks all live in the
1344
+ * chart layer, which is the layer forks actually live in, could never trip the rule. It was
1345
+ * correctly silent there for the wrong reason.
1346
+ *
1347
+ * Enumerated rather than globbed: `dist/**\/index.d.ts` would also sweep internal directory
1348
+ * barrels that are not published subpaths, and a name that is not importable is not a fork of
1349
+ * anything. Keep this in step with the `exports` map in package.json.
1350
+ */
1351
+ const BASALT_BARRELS = [
1352
+ ['index.d.ts'],
1353
+ ['charts', 'index.d.ts'],
1354
+ ['content', 'index.d.ts'],
1355
+ ['data', 'index.d.ts'],
1356
+ ['forms', 'index.d.ts'],
1357
+ ['agent', 'index.d.ts'],
1358
+ ['commands', 'index.d.ts'],
1359
+ ['notifications', 'index.d.ts'],
1360
+ ['connectivity', 'index.d.ts'],
1361
+ ]
1362
+
1363
+ /**
1364
+ * Every component-shaped VALUE export of basalt's published barrels, read once at module load from
1365
+ * the real `.d.ts` files — not a hand-maintained list that would go stale the first time an export
1366
+ * was renamed. `type` specifiers are skipped: a local `StatCardProps` is a normal thing to write, a
1367
+ * local `StatCard` is not.
1368
+ *
1369
+ * Empty (rule silent) when `dist` is unreadable, exactly like `BASALT_AI_MAJOR` — a lint run must
1370
+ * not depend on a build having happened.
1371
+ */
1372
+ const BASALT_EXPORTS = (() => {
1373
+ const names = new Set()
1374
+ for (const segments of BASALT_BARRELS) {
1375
+ let text
1376
+ try {
1377
+ text = readFileSync(resolvePath(PACKAGE_ROOT, 'dist', ...segments), 'utf8')
1378
+ } catch {
1379
+ continue
1380
+ }
1381
+ for (const block of text.matchAll(/export\s*\{([^}]*)\}(?:\s*from\s*['"]([^'"]*)['"])?/g)) {
1382
+ // A pass-through re-export of a THIRD-PARTY name is not a basalt composite. `./charts`
1383
+ // re-exports `Bar`, `Line`, `Pie`, `AreaClosed` … straight from `@visx/shape`, and a local
1384
+ // `Bar` is not a fork of anything basalt wrote — the playground's own demo tripped on it the
1385
+ // moment this rule started reading the charts barrel. Relative sources only.
1386
+ const source = block[2]
1387
+ if (source !== undefined && !source.startsWith('.')) continue
1388
+ for (const raw of block[1].split(',')) {
1389
+ const part = raw.trim()
1390
+ if (part === '' || part.startsWith('type ')) continue
1391
+ const name = (part.split(/\s+as\s+/).pop() ?? '').trim()
1392
+ if (COMPONENT_NAME.test(name)) names.add(name)
1393
+ }
1394
+ }
1395
+ }
1396
+ return names
1397
+ })()
1398
+
1399
+ function shadowBasaltExportMessage(name) {
1400
+ return (
1401
+ `Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
1402
+ "from 'basalt-ui' instead of re-rolling it. This is the one signal a forked composite emits: " +
1403
+ 'the palette guard can never see it, because a fork written by a token-fluent author uses ' +
1404
+ 'exactly the right tokens and passes every other check. A NAME collision is the whole of the ' +
1405
+ 'signal, so this is a tripwire, not coverage: rename the fork and the rule goes quiet ' +
1406
+ "(linewatch's forks are `Cell` and `Box`, rb's is `Stat`). Silence here is not evidence that " +
1407
+ 'nothing is forked. (basalt/shadow-basalt-export)'
1408
+ )
1409
+ }
1410
+
1411
+ /**
1412
+ * Flags a local declaration whose name collides with a live basalt export.
1413
+ *
1414
+ * The cheapest possible detector for the whole absorption class: argo shipped its own `EmptyState`
1415
+ * (a full duplicate of the shipped one) and rb a local `StatCard`, and both passed `check-theme`,
1416
+ * `oxlint` and `doctor` clean — off-palette code fails, a forked component that uses the correct
1417
+ * tokens does not. `warn`, not `error`: a name collision is evidence, not proof, and a consumer
1418
+ * with a genuinely different `Composer` should be told once, not blocked.
1419
+ */
1420
+ const shadowBasaltExport = {
1421
+ meta: {
1422
+ type: 'suggestion',
1423
+ docs: { description: 'Warn on a local component whose name collides with a basalt-ui export.' },
1424
+ schema: [],
1425
+ },
1426
+ create(context) {
1427
+ const filename = getFilename(context)
1428
+ // basalt's own source DEFINES these names; a consumer's does not.
1429
+ if (BASALT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
1430
+ if (isTestFile(context)) return {}
1431
+
1432
+ const report = (node, name) => {
1433
+ if (typeof name !== 'string' || !BASALT_EXPORTS.has(name)) return
1434
+ if (hasThemeAllow(context, node, 'shadow-basalt-export')) return
1435
+ context.report({ node, message: shadowBasaltExportMessage(name) })
1436
+ }
1437
+
1438
+ return {
1439
+ FunctionDeclaration(node) {
1440
+ report(node, node.id?.name)
1441
+ },
1442
+ VariableDeclarator(node) {
1443
+ report(node, node.id?.name)
1444
+ },
1445
+ ClassDeclaration(node) {
1446
+ report(node, node.id?.name)
1447
+ },
1448
+ }
1449
+ },
1450
+ }
1451
+
1452
+ // ── Rule 15 — hand-rolled-shell ──────────────────────────────────────────────────────────────────
1453
+
1454
+ /** Mantine parts that only ever appear when someone is assembling an application shell by hand. */
1455
+ // `NavLink` is deliberately NOT here: Mantine's NavLink is a legitimate standalone list row, and a
1456
+ // rule that fires on one inside a settings page would be switched off within a day. `Burger` and the
1457
+ // `AppShell.*` parts have no use outside assembling a shell.
1458
+ const SHELL_ASSEMBLY_TAGS = new Set(['Burger'])
1459
+ const SHELL_ASSEMBLY_MEMBER_OBJECT = 'AppShell'
1460
+
1461
+ const HAND_ROLLED_SHELL_MESSAGE =
1462
+ 'Application shell assembled by hand — AppShell parts / Burger / NavLink in a file that does not ' +
1463
+ 'render BasaltShell. The shell is the framework: BasaltShell owns the sidebar, the mobile tab ' +
1464
+ 'bar and its More sheet, breadcrumbs, the page header and the collapse state, all driven by ONE ' +
1465
+ 'typed nav definition (defineNav + useNav). A hand-rolled AppShell + burger drawer is the exact ' +
1466
+ 'layer the 1.19 nav contract replaces, and no palette guard can see it — it uses the right ' +
1467
+ 'tokens. Declare a deliberate exception with `theme-allow-file hand-rolled-shell — <why>`. ' +
1468
+ '(basalt/hand-rolled-shell)'
1469
+
1470
+ /**
1471
+ * The `hand-rolled-plot` shape, one layer up. rb shipped 266 lines of `AppShell` + `NavLink` +
1472
+ * `Burger` + `useDisclosure` mobile drawer and every gate passed it green.
1473
+ *
1474
+ * File-scoped and reported once: which shell a file uses is one decision, so one comment settles
1475
+ * it. The module that DEFINES `BasaltShell` is exempt by declaration, not by path — a rule saying
1476
+ * "compose X" cannot fire inside X.
1477
+ */
1478
+ const handRolledShell = {
1479
+ meta: {
1480
+ type: 'suggestion',
1481
+ docs: { description: 'Disallow assembling an app shell by hand instead of using BasaltShell.' },
1482
+ schema: [],
1483
+ },
1484
+ create(context) {
1485
+ if (isTestFile(context)) return {}
1486
+
1487
+ const assemblyNodes = []
1488
+ let mantineImported = false
1489
+ let ownsShell = false
1490
+
1491
+ const notesOwnerDefinition = (name) => {
1492
+ if (name === 'BasaltShell') ownsShell = true
1493
+ }
1494
+
1495
+ return {
1496
+ ImportDeclaration(node) {
1497
+ if ((node.source?.value ?? '').startsWith('@mantine/')) mantineImported = true
1498
+ },
1499
+ FunctionDeclaration(node) {
1500
+ notesOwnerDefinition(node.id?.name)
1501
+ },
1502
+ VariableDeclarator(node) {
1503
+ notesOwnerDefinition(node.id?.name)
1504
+ },
1505
+ JSXOpeningElement(node) {
1506
+ const name = node.name
1507
+ if (name?.type === 'JSXMemberExpression') {
1508
+ if (name.object?.name === SHELL_ASSEMBLY_MEMBER_OBJECT) assemblyNodes.push(node)
1509
+ return
1510
+ }
1511
+ if (name?.name === 'BasaltShell') ownsShell = true
1512
+ else if (SHELL_ASSEMBLY_TAGS.has(name?.name)) assemblyNodes.push(node)
1513
+ },
1514
+ 'Program:exit'() {
1515
+ if (ownsShell || !mantineImported) return
1516
+ const [first] = assemblyNodes
1517
+ if (first === undefined) return
1518
+ if (hasThemeAllow(context, first, 'hand-rolled-shell')) return
1519
+ if (hasFileDeclaration(context, 'hand-rolled-shell')) return
1520
+ context.report({ node: first, message: HAND_ROLLED_SHELL_MESSAGE })
1521
+ },
1522
+ }
1523
+ },
1524
+ }
1525
+
1526
+ // ── Grace ledger ────────────────────────────────────────────────────────────────────────────────
1527
+
1528
+ /**
1529
+ * Plugin rules still inside their grace minor — `warn` in the shipped consumer preset, `error`
1530
+ * repo-locally, with the promotion note as the value. This is the plugin's counterpart to
1531
+ * `src/guard`'s `GRACE_PERIOD_KINDS`, and it exists because its ABSENCE is what let three rules sit
1532
+ * at `warn` for up to twelve minors with nothing tracking them: `configs/oxlint.json` is generated
1533
+ * and its top-level keys are fixed by oxlint's own parser, so the ledger cannot live there.
1534
+ *
1535
+ * `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here must be
1536
+ * `warn` in the shipped preset, and one absent from here must be `error`. Deleting an entry IS the
1537
+ * promotion, and the test makes the config change mandatory in the same commit.
1538
+ */
1539
+ export const PLUGIN_RULE_GRACE = {
1540
+ 'hand-rolled-plot':
1541
+ 'widened AGAIN in the round-5 guard minor (file scope must now be spelled `theme-allow-file`, ' +
1542
+ 'so the named-with-a-reason shape every consumer wrote at 1.20.0 is a NODE waiver and the ' +
1543
+ 'remaining assembly nodes report) — grace restarts with the widening, per the "a rule the ' +
1544
+ 'current minor widens does not promote in that minor" doctrine. Promote one minor after ' +
1545
+ 'consumers have moved their file declarations to `theme-allow-file`.',
1546
+ 'chart-legend-literal':
1547
+ 'widened in the round-4 guard minor (a `.map()` over a non-series array now counts) — promote ' +
1548
+ 'to error one minor later, for the same reason as hand-rolled-plot above.',
1549
+ 'shadow-basalt-export':
1550
+ 'new in the round-4 guard minor, widened in round-5 (reads every published barrel, not just ' +
1551
+ 'the root one, so a chart-layer fork is visible) — promote to error one minor later, if ever. ' +
1552
+ 'A name collision is strong evidence and not proof, and renaming defeats it outright, so this ' +
1553
+ 'may be a permanent warn.',
1554
+ 'hand-rolled-shell': 'new in the round-4 guard minor — promote to error one minor later.',
1555
+ }
1556
+
956
1557
  // ── Plugin export ───────────────────────────────────────────────────────────────────────────────
957
1558
 
958
1559
  export default {
@@ -964,6 +1565,8 @@ export default {
964
1565
  'chart-in-raw-surface': chartInRawSurface,
965
1566
  'hand-rolled-plot': handRolledPlot,
966
1567
  'chart-legend-literal': chartLegendLiteral,
1568
+ 'shadow-basalt-export': shadowBasaltExport,
1569
+ 'hand-rolled-shell': handRolledShell,
967
1570
  'raw-scroll-container': rawScrollContainer,
968
1571
  'visx-boundary': visxBoundary,
969
1572
  'visx-tooltip': visxTooltip,