basalt-ui 1.19.1 → 1.20.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 (78) hide show
  1. package/AGENTS.md +15 -1
  2. package/CHANGELOG.md +20 -0
  3. package/MIGRATING.md +228 -0
  4. package/README.md +53 -9
  5. package/agent/rules/basalt-app.md +43 -0
  6. package/agent/rules/basalt-charts.md +26 -14
  7. package/agent/rules/basalt-mantine.md +25 -10
  8. package/agent/rules/basalt-router.md +22 -10
  9. package/agent/rules/basalt-tokens.md +75 -6
  10. package/agent/skills/basalt-app/SKILL.md +42 -6
  11. package/configs/check.yml +11 -4
  12. package/configs/lefthook.yml +21 -2
  13. package/configs/oxlint-plugin.js +515 -25
  14. package/configs/oxlint-plugin.test.ts +383 -10
  15. package/configs/oxlint.json +10 -1
  16. package/dist/agent/stick-to-bottom.js +1 -1
  17. package/dist/agent-chat/thread-detail-panel.js +1 -1
  18. package/dist/agent-chat/thread-message.js +2 -2
  19. package/dist/agent-chat/tool-chip.js +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/Donut.js +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +2 -2
  23. package/dist/charts/primitives/ChartTooltip.js +1 -1
  24. package/dist/cli/index.d.ts +107 -15
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +610 -72
  27. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  28. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  29. package/dist/dashboard/delta-badge.js +2 -2
  30. package/dist/dashboard/stat-card.js +1 -1
  31. package/dist/data/virtual-list.js +5 -5
  32. package/dist/guard/index.d.ts +56 -2
  33. package/dist/guard/index.d.ts.map +1 -1
  34. package/dist/guard/index.js +326 -59
  35. package/dist/guard/types.d.ts +25 -5
  36. package/dist/guard/types.d.ts.map +1 -1
  37. package/dist/register.d.ts +25 -0
  38. package/dist/register.d.ts.map +1 -1
  39. package/dist/shell/app-mobile-nav.module.css +11 -3
  40. package/dist/surfaces.d.ts +1 -1
  41. package/dist/surfaces.js +1 -1
  42. package/dist/tokens/index.d.ts +3 -3
  43. package/dist/tokens/index.d.ts.map +1 -1
  44. package/dist/tokens/index.js +2 -0
  45. package/dist/tokens/palette.d.ts +35 -27
  46. package/dist/tokens/palette.d.ts.map +1 -1
  47. package/dist/tokens/palette.js +24 -18
  48. package/dist/tokens.css +19 -17
  49. package/dist/vite-color-mix.js +2 -2
  50. package/llms.txt +3 -3
  51. package/package.json +4 -3
  52. package/src/agent/stick-to-bottom.tsx +1 -1
  53. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  54. package/src/agent-chat/thread-message.tsx +3 -3
  55. package/src/agent-chat/tool-chip.tsx +1 -1
  56. package/src/charts/kinds/Donut.tsx +2 -1
  57. package/src/charts/primitives/ChartLegend.tsx +2 -2
  58. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  59. package/src/cli/index.ts +1070 -99
  60. package/src/cli/init-sync.test.ts +19 -3
  61. package/src/cli/tokens-css.test.ts +27 -14
  62. package/src/cli/toolchain-wiring.test.ts +694 -0
  63. package/src/dashboard/delta-badge.tsx +3 -3
  64. package/src/dashboard/stat-card.tsx +1 -1
  65. package/src/data/virtual-list.tsx +8 -8
  66. package/src/guard/check-source.test.ts +663 -11
  67. package/src/guard/index.ts +727 -94
  68. package/src/guard/types.ts +29 -4
  69. package/src/register.ts +32 -0
  70. package/src/shell/app-mobile-nav.module.css +11 -3
  71. package/src/shell/app-mobile-nav.tsx +1 -1
  72. package/src/surfaces.ts +1 -1
  73. package/src/theme/density-relations.test.ts +8 -5
  74. package/src/theme/spacing.test.ts +2 -0
  75. package/src/theme-toggle/index.tsx +2 -2
  76. package/src/tokens/index.ts +5 -3
  77. package/src/tokens/palette.ts +24 -18
  78. package/src/vite-color-mix.ts +2 -2
@@ -77,16 +77,156 @@ import { fileURLToPath } from 'node:url'
77
77
 
78
78
  // ── Shared helpers ──────────────────────────────────────────────────────────────────────────────
79
79
 
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) {
80
+ /**
81
+ * Rule ids a `theme-allow` may name — this plugin's own rules plus `src/guard`'s kinds. Duplicated
82
+ * by hand for the same reason `majorOf` is: this file loads standalone out of a consumer's
83
+ * node_modules and must not import from the package.
84
+ *
85
+ * Exported, and `oxlint-plugin.test.ts` asserts it is EXACTLY the plugin's own rule ids plus
86
+ * `GUARD_RULES`'s keys. Forgetting an entry here is not a cosmetic omission — an id this set does
87
+ * not know is treated as a typo, so the annotation naming it waives nothing (see
88
+ * {@link parseThemeAllow}); before the fail-closed change it was worse still, silently widening to
89
+ * a blanket waiver.
90
+ */
91
+ export const KNOWN_RULE_IDS = new Set([
92
+ // this plugin
93
+ 'no-raw-font-size',
94
+ 'raw-size-literal',
95
+ 'card-inset',
96
+ 'chart-in-raw-surface',
97
+ 'hand-rolled-plot',
98
+ 'chart-legend-literal',
99
+ 'hand-rolled-shell',
100
+ 'shadow-basalt-export',
101
+ 'raw-scroll-container',
102
+ 'visx-boundary',
103
+ 'visx-tooltip',
104
+ 'token-layer-boundary',
105
+ // These three honour `basalt-agent-allow`, never `theme-allow` — but they are real ids, so an
106
+ // annotation naming one must parse as a (useless) scoped annotation rather than as prose.
107
+ 'agent-resume-guard',
108
+ 'agent-no-raw-usechat',
109
+ 'ai-sdk-major',
110
+ // src/guard kinds
111
+ 'raw-hex',
112
+ 'raw-color-fn',
113
+ 'localstorage-theme',
114
+ 'off-identity-accent',
115
+ 'mantine-shade-index',
116
+ 'raw-spacing',
117
+ 'raw-radius',
118
+ 'raw-surface',
119
+ 'card-with-border',
120
+ 'off-system-surface-var',
121
+ 'raw-html-layout',
122
+ 'inline-spacing',
123
+ 'inline-display',
124
+ 'raw-visx-axis',
125
+ 'raw-motion-value',
126
+ 'unframed-chart',
127
+ 'chart-missing-aria-label',
128
+ 'raw-form-control',
129
+ 'sub-16-input-font',
130
+ 'raw-font-family',
131
+ 'theme-allow-unscoped',
132
+ 'surface-shadow-override',
133
+ 'css-raw-surface',
134
+ 'inline-font-size',
135
+ 'hidden-inline-style',
136
+ ])
137
+
138
+ const ALLOW_RULE_TOKEN = /^(?:basalt\/)?([a-z][a-z0-9-]*)(?=$|[\s,:—–])/
139
+ const ALLOW_REASON_SEPARATOR = /^(?:—|–|-{1,2}|:)\s*/
140
+ /** Shortest string accepted as a written reason — enough to exclude a stray separator. */
141
+ const MIN_ALLOW_REASON_LENGTH = 4
142
+
143
+ /**
144
+ * Parse one comment's `theme-allow` annotation, or `null` when it carries none.
145
+ *
146
+ * `{ rules: [], unknownRules: [] }` is the legacy bare form and covers every rule; a non-empty
147
+ * `rules` scopes the exception to exactly those ids. A word that occupies the id slot but names no
148
+ * known rule lands in `unknownRules` and FAILS CLOSED — the annotation then waives only the ids it
149
+ * got right, never everything. `theme-allow raw-hexx — reason` used to consume no id, fall through
150
+ * to the empty-`rules` branch and be read as the blanket form, so one mistyped character escalated
151
+ * a scoped waiver into a whole-line one, i.e. weaker than before the scoping existed.
152
+ *
153
+ * A prose reason is therefore introduced with a separator (`—`, `–`, `-`, `:`), which is how every
154
+ * annotation in the wild already writes it. Mirrors `parseAllowAnnotation` in `src/guard/index.ts`.
155
+ *
156
+ * The id slot closes at the first space that no comma opened: the first word is always a claimed
157
+ * id (that is where a typo lands), but after a resolved id only a `,` keeps the list open, so
158
+ * `theme-allow raw-surface sub-scale legend corner` reads `sub-scale` as prose rather than as a
159
+ * typo. See the guard's copy for the full reasoning.
160
+ */
161
+ function parseThemeAllow(commentValue) {
162
+ const at = commentValue.indexOf('theme-allow')
163
+ if (at === -1) return null
164
+ let remainder = commentValue.slice(at + 'theme-allow'.length).replace(/^[\s,]+/, '')
165
+ const rules = []
166
+ const unknownRules = []
167
+ let inIdSlot = true
168
+ for (;;) {
169
+ const token = ALLOW_RULE_TOKEN.exec(remainder)
170
+ if (token === null) break
171
+ if (!KNOWN_RULE_IDS.has(token[1])) {
172
+ if (inIdSlot) unknownRules.push(token[1])
173
+ break
174
+ }
175
+ rules.push(token[1])
176
+ const after = remainder.slice(token[0].length)
177
+ remainder = after.replace(/^[\s,]+/, '')
178
+ inIdSlot = /^\s*,/.test(after)
179
+ }
180
+ const reason = remainder.replace(ALLOW_REASON_SEPARATOR, '').trim()
181
+ return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
182
+ }
183
+
184
+ /**
185
+ * True when a `theme-allow` covering `ruleId` sits on `node`'s own source line or the line above.
186
+ *
187
+ * The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
188
+ * raw-hex — …` written for a color no longer silently switches off `card-inset` on the same line.
189
+ * Only a BARE `theme-allow` still covers everything — that is the shape every existing consumer
190
+ * has, and `src/guard`'s `theme-allow-unscoped` is the (warning) nudge off it, not a hard break.
191
+ * An annotation that reached for an id and missed covers only the ids it got right.
192
+ */
193
+ function hasThemeAllow(context, node, ruleId) {
82
194
  const sourceCode = context.sourceCode ?? context.getSourceCode?.()
83
195
  const comments = sourceCode?.getAllComments?.() ?? []
84
196
  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),
89
- )
197
+ return comments.some((comment) => {
198
+ if (comment.loc.end.line !== nodeLine && comment.loc.end.line !== nodeLine - 1) return false
199
+ const allow = parseThemeAllow(comment.value)
200
+ if (allow === null) return false
201
+ if (allow.rules.includes(ruleId)) return true
202
+ return allow.rules.length === 0 && allow.unknownRules.length === 0
203
+ })
204
+ }
205
+
206
+ /**
207
+ * True when the FILE carries a written declaration for `ruleId` — a `theme-allow` that both names
208
+ * the rule and gives a reason, anywhere in the file.
209
+ *
210
+ * This is what a file-scoped exception costs now. `hand-rolled-plot` used to grant a whole file
211
+ * permanent immunity off any `theme-allow` that happened to sit on its first assembly node — a
212
+ * 604-line chart file went unpoliced forever, and a comment written about a colour disabled the
213
+ * chart rule as a side effect. Splitting the two placements apart is the fix: a bare or unrelated
214
+ * comment waives ONE node, a named-with-a-reason comment declares the whole file.
215
+ */
216
+ function hasFileDeclaration(context, ruleId) {
217
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
218
+ const comments = sourceCode?.getAllComments?.() ?? []
219
+ return comments.some((comment) => {
220
+ const allow = parseThemeAllow(comment.value)
221
+ return allow !== null && allow.hasReason && allow.rules.includes(ruleId)
222
+ })
223
+ }
224
+
225
+ /** Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP. */
226
+ const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|[\\/])__tests__[\\/]/
227
+
228
+ function isTestFile(context) {
229
+ return TEST_FILE.test(getFilename(context))
90
230
  }
91
231
 
92
232
  /**
@@ -146,6 +286,50 @@ const NO_RAW_FONT_SIZE_MESSAGE =
146
286
  'Raw font-size literal — route through VX.text.* (numbers) or --vx-text-* (CSS); em/relative ' +
147
287
  'ratios are allowed. (basalt/no-raw-font-size)'
148
288
 
289
+ /** JSX attributes whose value IS a style object — the only object literals this rule reaches into. */
290
+ const STYLE_ATTRS = new Set(['style', 'styles', 'sx'])
291
+ /** A binding or key whose name says the object is styling — `wrapperStyle`, `styles`, `rowStyles`. */
292
+ const STYLE_NAME = /styles?$/i
293
+ /** How far up the tree the style-context walk goes before giving up. */
294
+ const STYLE_CONTEXT_MAX_DEPTH = 12
295
+
296
+ /**
297
+ * Is this object property part of a STYLE object?
298
+ *
299
+ * `fontSize` is not a reserved word. The rule used to report every object property spelled that
300
+ * way, which fired on an Obsidian `data.json` fixture — `{ settings: { fontSize: 16 } }` inside a
301
+ * string, in a package with no React and no Mantine — where the "route it through VX.text.*" advice
302
+ * is not merely unhelpful, it is about a different domain. Requiring a style context (a
303
+ * `style`/`styles`/`sx` JSX attribute, a `…Style(s)` binding or key, or a `CSSProperties`
304
+ * annotation) keeps every shape that is really styling and drops the ones that never reach CSS.
305
+ */
306
+ function isInStyleContext(context, node) {
307
+ const sourceCode = context.sourceCode ?? context.getSourceCode?.()
308
+ let current = node.parent
309
+ for (let depth = 0; current !== undefined && current !== null; depth++) {
310
+ if (depth > STYLE_CONTEXT_MAX_DEPTH) return false
311
+ if (current.type === 'JSXAttribute') return STYLE_ATTRS.has(current.name?.name)
312
+ if (current.type === 'Property') {
313
+ const key = current.key
314
+ const name = key?.type === 'Identifier' ? key.name : key?.value
315
+ if (typeof name === 'string' && STYLE_NAME.test(name)) return true
316
+ }
317
+ if (current.type === 'VariableDeclarator') {
318
+ const id = current.id
319
+ if (typeof id?.name === 'string' && STYLE_NAME.test(id.name)) return true
320
+ const annotation = id?.typeAnnotation
321
+ return (
322
+ annotation !== undefined &&
323
+ annotation !== null &&
324
+ (sourceCode?.getText?.(annotation) ?? '').includes('CSSProperties')
325
+ )
326
+ }
327
+ if (current.type === 'FunctionDeclaration' || current.type === 'Program') return false
328
+ current = current.parent
329
+ }
330
+ return false
331
+ }
332
+
149
333
  const noRawFontSize = {
150
334
  meta: {
151
335
  type: 'suggestion',
@@ -155,7 +339,7 @@ const noRawFontSize = {
155
339
  schema: [],
156
340
  },
157
341
  create(context) {
158
- if (getFilename(context).includes('/src/tokens/')) return {}
342
+ if (getFilename(context).includes('/src/tokens/') || isTestFile(context)) return {}
159
343
 
160
344
  return {
161
345
  JSXAttribute(node) {
@@ -163,7 +347,7 @@ const noRawFontSize = {
163
347
  if (name !== 'fz' && name !== 'fontSize') return
164
348
  const value = unwrapExpressionContainer(node.value)
165
349
  if (!isNumericLiteral(value)) return
166
- if (hasThemeAllow(context, node)) return
350
+ if (hasThemeAllow(context, node, 'no-raw-font-size')) return
167
351
  context.report({ node, message: NO_RAW_FONT_SIZE_MESSAGE })
168
352
  },
169
353
  Property(node) {
@@ -173,7 +357,8 @@ const noRawFontSize = {
173
357
  (key.type === 'Literal' && key.value === 'fontSize')
174
358
  if (!isFontSizeKey) return
175
359
  if (!isNumericLiteral(node.value)) return
176
- if (hasThemeAllow(context, node)) return
360
+ if (!isInStyleContext(context, node)) return
361
+ if (hasThemeAllow(context, node, 'no-raw-font-size')) return
177
362
  context.report({ node, message: NO_RAW_FONT_SIZE_MESSAGE })
178
363
  },
179
364
  }
@@ -225,7 +410,7 @@ const rawSizeLiteral = {
225
410
  if (typeof name !== 'string' || !SIZE_ATTRS.has(name)) return
226
411
  const value = unwrapExpressionContainer(node.value)
227
412
  if (!isCssLengthString(value)) return
228
- if (hasThemeAllow(context, node)) return
413
+ if (hasThemeAllow(context, node, 'raw-size-literal')) return
229
414
  context.report({ node, message: RAW_SIZE_LITERAL_MESSAGE })
230
415
  },
231
416
  }
@@ -276,7 +461,7 @@ const cardInset = {
276
461
  const isRadius = attrName === 'radius'
277
462
 
278
463
  if (!isOffPadding && !isOffPy && !isOffPx && !isRadius) continue
279
- if (hasThemeAllow(context, attr)) continue
464
+ if (hasThemeAllow(context, attr, 'card-inset')) continue
280
465
  context.report({ node: attr, message: CARD_INSET_MESSAGE })
281
466
  }
282
467
  },
@@ -350,7 +535,7 @@ const chartInRawSurface = {
350
535
  if (!CARD_TAGS.has(tagName)) return
351
536
  const hasChartDescendant = node.children.some((child) => subtreeHasChart(child))
352
537
  if (!hasChartDescendant) return
353
- if (hasThemeAllow(context, node)) return
538
+ if (hasThemeAllow(context, node, 'chart-in-raw-surface')) return
354
539
  context.report({ node, message: CHART_IN_RAW_SURFACE_MESSAGE })
355
540
  },
356
541
  }
@@ -396,7 +581,7 @@ const rawScrollContainer = {
396
581
  key.type === 'Identifier' ? key.name : key.type === 'Literal' ? key.value : undefined
397
582
  if (typeof keyName !== 'string' || !OVERFLOW_KEYS.has(keyName)) return
398
583
  if (!isStringLiteral(node.value) || !SCROLLING_VALUES.has(node.value.value)) return
399
- if (hasThemeAllow(context, node)) return
584
+ if (hasThemeAllow(context, node, 'raw-scroll-container')) return
400
585
  context.report({ node, message: RAW_SCROLL_CONTAINER_MESSAGE })
401
586
  },
402
587
  }
@@ -760,6 +945,53 @@ function nearestAiMajor(startDir) {
760
945
  return result
761
946
  }
762
947
 
948
+ // One parsed manifest per path — the scope walk below re-reads the same ancestors for every file.
949
+ const packageJsonCache = new Map()
950
+
951
+ function readPackageJson(pkgPath) {
952
+ if (packageJsonCache.has(pkgPath)) return packageJsonCache.get(pkgPath)
953
+ let pkg = null
954
+ try {
955
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
956
+ } catch {
957
+ pkg = null
958
+ }
959
+ packageJsonCache.set(pkgPath, pkg)
960
+ return pkg
961
+ }
962
+
963
+ /**
964
+ * Is this file inside a package that actually consumes basalt-ui?
965
+ *
966
+ * Without this the rule compared EVERY package's `ai` major against basalt's own peer major, and
967
+ * fired three errors in an rb workspace package (`apps/api`) that has no basalt-ui dependency, is
968
+ * outside `basalt.roots`, and pins `ai@6` deliberately. `doctor`'s `ai-major-parity` scopes the same
969
+ * concern correctly — two shipped enforcement surfaces disagreeing about one fact leaves a consumer
970
+ * unable to tell which is right, which is worse than either being wrong.
971
+ *
972
+ * In scope when an ancestor package.json either depends on `basalt-ui`, or declares `basalt.roots`
973
+ * that the file sits under. A declared `basalt.roots` that the file is NOT under is a positive
974
+ * statement that this code is not basalt's — the walk stops there rather than continuing up to a
975
+ * workspace root that happens to carry the dependency.
976
+ */
977
+ function isBasaltScopedFile(filename) {
978
+ let dir = dirname(filename)
979
+ for (;;) {
980
+ const pkg = readPackageJson(resolvePath(dir, 'package.json'))
981
+ if (pkg !== null) {
982
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
983
+ if (deps['basalt-ui'] !== undefined) return true
984
+ const roots = pkg.basalt?.roots
985
+ if (Array.isArray(roots) && roots.length > 0) {
986
+ return roots.some((root) => filename.startsWith(`${resolvePath(dir, root)}/`))
987
+ }
988
+ }
989
+ const parent = dirname(dir)
990
+ if (parent === dir) return false
991
+ dir = parent
992
+ }
993
+ }
994
+
763
995
  function aiSdkMajorMessage(consumerMajor) {
764
996
  return (
765
997
  `This file's nearest package.json declares ai@${consumerMajor}, but basalt-ui declares the ` +
@@ -792,6 +1024,7 @@ const aiSdkMajor = {
792
1024
 
793
1025
  const filename = getFilename(context)
794
1026
  if (filename.length === 0) return
1027
+ if (!isBasaltScopedFile(filename)) return
795
1028
  const consumerMajor = nearestAiMajor(dirname(filename))
796
1029
  if (consumerMajor === null) return // no `ai` in the nearest package.json — nothing to compare
797
1030
  if (consumerMajor === BASALT_AI_MAJOR) return
@@ -818,6 +1051,19 @@ const PLOT_ASSEMBLY_TAGS = new Set([
818
1051
  'Crosshair',
819
1052
  ])
820
1053
 
1054
+ /**
1055
+ * KNOWN LIMIT, deliberately not closed: a chart drawn out of DOM instead of the chart layer is
1056
+ * structurally invisible here.
1057
+ *
1058
+ * argo has two — a 3×3 Mantine `SimpleGrid` matrix and absolutely-positioned `Box` bullet bars —
1059
+ * that import only `ChartCard` and `VX`, render no basalt primitive, and therefore cannot trip any
1060
+ * tag-name check. The candidate widenings all fail on false positives in a rule that SHIPS to
1061
+ * consumers: adding `ChartTooltipFloat`/`useChartSize` to this set flags `Donut` and `Heatmap`,
1062
+ * which compose them legitimately; "renders `ChartCard` and a raw `<svg>`" flags an inline icon in
1063
+ * a card header. Detecting "this Box grid is a chart" needs intent, not syntax. Naming the gap here
1064
+ * is the honest answer — a noisy rule gets switched off, and then it guards nothing at all.
1065
+ */
1066
+
821
1067
  /**
822
1068
  * Composing this means the file is USING the chart system rather than re-implementing it.
823
1069
  * `ChartFrame` deliberately does NOT count: composing the frame directly and then hand-assembling
@@ -859,6 +1105,7 @@ const handRolledPlot = {
859
1105
  schema: [],
860
1106
  },
861
1107
  create(context) {
1108
+ if (isTestFile(context)) return {}
862
1109
  // File-scoped, not node-scoped: the verdict depends on whether a plot OWNER appears anywhere
863
1110
  // in the file, which is only known once the whole file has been walked.
864
1111
  const assemblyNodes = []
@@ -891,16 +1138,26 @@ const handRolledPlot = {
891
1138
  },
892
1139
  'Program:exit'() {
893
1140
  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 })
1141
+ // The CHECK stays file-scoped — forced by the shape of the correct pattern:
1142
+ // `<CartesianChart>{() => <AxisLeftNumeric …/>}</CartesianChart>` puts the axis inside a
1143
+ // render-prop arrow, a different function from the component that owns the chart, so
1144
+ // component scope would flag the canonical usage.
1145
+ //
1146
+ // The SUPPRESSION no longer is. Reporting only the first node and testing the waiver there
1147
+ // meant one comment — any comment, about anything — bought a whole file permanent immunity:
1148
+ // every axis, overlay and crosshair added to that file afterwards went unreported forever
1149
+ // (linewatch: three files, one 604 lines). Now every assembly node is reported and waived on
1150
+ // its own, EXCEPT when the file carries a written declaration — a `theme-allow` that names
1151
+ // `basalt/hand-rolled-plot` and gives a reason. That is the shape a genuinely non-single-plot
1152
+ // kind already uses (`DualPanel`), and it is the difference between a line waiver and a
1153
+ // decision about the file.
1154
+ const nodes = assemblyNodes.filter((node) => chartImports.has(node.name?.name))
1155
+ if (nodes.length === 0) return
1156
+ if (hasFileDeclaration(context, 'hand-rolled-plot')) return
1157
+ for (const node of nodes) {
1158
+ if (hasThemeAllow(context, node, 'hand-rolled-plot')) continue
1159
+ context.report({ node, message: HAND_ROLLED_PLOT_MESSAGE })
1160
+ }
904
1161
  },
905
1162
  }
906
1163
  },
@@ -914,6 +1171,40 @@ const CHART_LEGEND_LITERAL_MESSAGE =
914
1171
  'is a second source of truth that silently goes stale: it keeps naming a series after the plot ' +
915
1172
  'stops drawing it. (basalt/chart-legend-literal)'
916
1173
 
1174
+ /**
1175
+ * Does this expression derive from the chart's own `series`?
1176
+ *
1177
+ * The contract is derivation from `series` — not derivation from *an* array. `items={refLines.map(
1178
+ * (r) => ({ key, label, color, shape }))}` and `items={PACE_ZONES.map(…)}` are hand-authored
1179
+ * legends by any reading: every field is written at the call site, and the list can name a band the
1180
+ * plot no longer draws exactly like a `[...]` literal can. Walks the base of a member/call chain so
1181
+ * `visibleSeries.filter(…).map(…)` and `deriveLegend(series)` both read as derived.
1182
+ */
1183
+ function derivesFromSeries(node, depth = 0) {
1184
+ if (node === null || node === undefined || depth > 8) return false
1185
+ if (node.type === 'Identifier') return /series$/i.test(node.name ?? '')
1186
+ if (node.type === 'MemberExpression') {
1187
+ return derivesFromSeries(node.object, depth + 1) || derivesFromSeries(node.property, depth + 1)
1188
+ }
1189
+ if (node.type === 'CallExpression') {
1190
+ if (node.callee?.type === 'Identifier' && node.callee.name === 'deriveLegend') return true
1191
+ return (
1192
+ derivesFromSeries(node.callee, depth + 1) ||
1193
+ (node.arguments ?? []).some((arg) => derivesFromSeries(arg, depth + 1))
1194
+ )
1195
+ }
1196
+ return false
1197
+ }
1198
+
1199
+ /** `X.map(…)` — the derivation-shaped form that is only derivation when `X` is the series. */
1200
+ function isMapCall(node) {
1201
+ return (
1202
+ node?.type === 'CallExpression' &&
1203
+ node.callee?.type === 'MemberExpression' &&
1204
+ node.callee.property?.name === 'map'
1205
+ )
1206
+ }
1207
+
917
1208
  const chartLegendLiteral = {
918
1209
  meta: {
919
1210
  type: 'suggestion',
@@ -921,6 +1212,7 @@ const chartLegendLiteral = {
921
1212
  schema: [],
922
1213
  },
923
1214
  create(context) {
1215
+ if (isTestFile(context)) return {}
924
1216
  const chartImports = new Set()
925
1217
 
926
1218
  return {
@@ -933,6 +1225,14 @@ const chartLegendLiteral = {
933
1225
  (attr) => attr.type === 'JSXAttribute' && attr.name?.name === 'items',
934
1226
  )
935
1227
  const expression = items?.value?.expression
1228
+ // The `.map()` half — a list built by mapping over something that is NOT the series. See
1229
+ // `derivesFromSeries`. Reported under the same id as the array-literal half because it is
1230
+ // the same defect (a second source of truth for the legend), just written as a call.
1231
+ if (isMapCall(expression) && !derivesFromSeries(expression.callee?.object)) {
1232
+ if (hasThemeAllow(context, node, 'chart-legend-literal')) return
1233
+ context.report({ node, message: CHART_LEGEND_LITERAL_MESSAGE })
1234
+ return
1235
+ }
936
1236
  if (expression?.type !== 'ArrayExpression') return
937
1237
  // Only a list that is ENTIRELY object literals is a hand-authored legend. An array that
938
1238
  // spreads or calls something (`[...deriveLegend(series), note]`) is derived-and-extended,
@@ -946,13 +1246,201 @@ const chartLegendLiteral = {
946
1246
  expression.elements.length > 0 &&
947
1247
  expression.elements.every((el) => el?.type === 'ObjectExpression')
948
1248
  if (!allObjectLiterals) return
949
- if (hasThemeAllow(context, node)) return
1249
+ if (hasThemeAllow(context, node, 'chart-legend-literal')) return
950
1250
  context.report({ node, message: CHART_LEGEND_LITERAL_MESSAGE })
951
1251
  },
952
1252
  }
953
1253
  },
954
1254
  }
955
1255
 
1256
+ // ── Rule 14 — shadow-basalt-export ───────────────────────────────────────────────────────────────
1257
+
1258
+ /** The package root — one directory above `configs/`, repo-locally AND inside node_modules. */
1259
+ const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
1260
+
1261
+ /**
1262
+ * A component-shaped name: starts uppercase and carries a lowercase letter, which excludes the
1263
+ * SCREAMING_CASE constants and the two-letter token namespaces in the same barrel.
1264
+ */
1265
+ const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
1266
+
1267
+ /**
1268
+ * Every component-shaped VALUE export of the root barrel, read once at module load from
1269
+ * `../dist/index.d.ts` — the real barrel, not a hand-maintained list that would go stale the first
1270
+ * time an export was renamed. `type` specifiers are skipped: a local `StatCardProps` is a normal
1271
+ * thing to write, a local `StatCard` is not.
1272
+ *
1273
+ * Empty (rule silent) when `dist` is unreadable, exactly like `BASALT_AI_MAJOR` — a lint run must
1274
+ * not depend on a build having happened.
1275
+ */
1276
+ const BASALT_ROOT_EXPORTS = (() => {
1277
+ const names = new Set()
1278
+ try {
1279
+ const text = readFileSync(resolvePath(PACKAGE_ROOT, 'dist', 'index.d.ts'), 'utf8')
1280
+ for (const block of text.matchAll(/export\s*\{([^}]*)\}/g)) {
1281
+ for (const raw of block[1].split(',')) {
1282
+ const part = raw.trim()
1283
+ if (part === '' || part.startsWith('type ')) continue
1284
+ const name = (part.split(/\s+as\s+/).pop() ?? '').trim()
1285
+ if (COMPONENT_NAME.test(name)) names.add(name)
1286
+ }
1287
+ }
1288
+ } catch {
1289
+ return names
1290
+ }
1291
+ return names
1292
+ })()
1293
+
1294
+ function shadowBasaltExportMessage(name) {
1295
+ return (
1296
+ `Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
1297
+ "from 'basalt-ui' instead of re-rolling it. This is the one signal a forked composite emits: " +
1298
+ 'the palette guard can never see it, because a fork written by a token-fluent author uses ' +
1299
+ 'exactly the right tokens and passes every other check. (basalt/shadow-basalt-export)'
1300
+ )
1301
+ }
1302
+
1303
+ /**
1304
+ * Flags a local declaration whose name collides with a live basalt export.
1305
+ *
1306
+ * The cheapest possible detector for the whole absorption class: argo shipped its own `EmptyState`
1307
+ * (a full duplicate of the shipped one) and rb a local `StatCard`, and both passed `check-theme`,
1308
+ * `oxlint` and `doctor` clean — off-palette code fails, a forked component that uses the correct
1309
+ * tokens does not. `warn`, not `error`: a name collision is evidence, not proof, and a consumer
1310
+ * with a genuinely different `Composer` should be told once, not blocked.
1311
+ */
1312
+ const shadowBasaltExport = {
1313
+ meta: {
1314
+ type: 'suggestion',
1315
+ docs: { description: 'Warn on a local component whose name collides with a basalt-ui export.' },
1316
+ schema: [],
1317
+ },
1318
+ create(context) {
1319
+ const filename = getFilename(context)
1320
+ // basalt's own source DEFINES these names; a consumer's does not.
1321
+ if (BASALT_ROOT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
1322
+ if (isTestFile(context)) return {}
1323
+
1324
+ const report = (node, name) => {
1325
+ if (typeof name !== 'string' || !BASALT_ROOT_EXPORTS.has(name)) return
1326
+ if (hasThemeAllow(context, node, 'shadow-basalt-export')) return
1327
+ context.report({ node, message: shadowBasaltExportMessage(name) })
1328
+ }
1329
+
1330
+ return {
1331
+ FunctionDeclaration(node) {
1332
+ report(node, node.id?.name)
1333
+ },
1334
+ VariableDeclarator(node) {
1335
+ report(node, node.id?.name)
1336
+ },
1337
+ ClassDeclaration(node) {
1338
+ report(node, node.id?.name)
1339
+ },
1340
+ }
1341
+ },
1342
+ }
1343
+
1344
+ // ── Rule 15 — hand-rolled-shell ──────────────────────────────────────────────────────────────────
1345
+
1346
+ /** Mantine parts that only ever appear when someone is assembling an application shell by hand. */
1347
+ // `NavLink` is deliberately NOT here: Mantine's NavLink is a legitimate standalone list row, and a
1348
+ // rule that fires on one inside a settings page would be switched off within a day. `Burger` and the
1349
+ // `AppShell.*` parts have no use outside assembling a shell.
1350
+ const SHELL_ASSEMBLY_TAGS = new Set(['Burger'])
1351
+ const SHELL_ASSEMBLY_MEMBER_OBJECT = 'AppShell'
1352
+
1353
+ const HAND_ROLLED_SHELL_MESSAGE =
1354
+ 'Application shell assembled by hand — AppShell parts / Burger / NavLink in a file that does not ' +
1355
+ 'render BasaltShell. The shell is the framework: BasaltShell owns the sidebar, the mobile tab ' +
1356
+ 'bar and its More sheet, breadcrumbs, the page header and the collapse state, all driven by ONE ' +
1357
+ 'typed nav definition (defineNav + useNav). A hand-rolled AppShell + burger drawer is the exact ' +
1358
+ 'layer the 1.19 nav contract replaces, and no palette guard can see it — it uses the right ' +
1359
+ 'tokens. (basalt/hand-rolled-shell)'
1360
+
1361
+ /**
1362
+ * The `hand-rolled-plot` shape, one layer up. rb shipped 266 lines of `AppShell` + `NavLink` +
1363
+ * `Burger` + `useDisclosure` mobile drawer and every gate passed it green.
1364
+ *
1365
+ * File-scoped and reported once: which shell a file uses is one decision, so one comment settles
1366
+ * it. The module that DEFINES `BasaltShell` is exempt by declaration, not by path — a rule saying
1367
+ * "compose X" cannot fire inside X.
1368
+ */
1369
+ const handRolledShell = {
1370
+ meta: {
1371
+ type: 'suggestion',
1372
+ docs: { description: 'Disallow assembling an app shell by hand instead of using BasaltShell.' },
1373
+ schema: [],
1374
+ },
1375
+ create(context) {
1376
+ if (isTestFile(context)) return {}
1377
+
1378
+ const assemblyNodes = []
1379
+ let mantineImported = false
1380
+ let ownsShell = false
1381
+
1382
+ const notesOwnerDefinition = (name) => {
1383
+ if (name === 'BasaltShell') ownsShell = true
1384
+ }
1385
+
1386
+ return {
1387
+ ImportDeclaration(node) {
1388
+ if ((node.source?.value ?? '').startsWith('@mantine/')) mantineImported = true
1389
+ },
1390
+ FunctionDeclaration(node) {
1391
+ notesOwnerDefinition(node.id?.name)
1392
+ },
1393
+ VariableDeclarator(node) {
1394
+ notesOwnerDefinition(node.id?.name)
1395
+ },
1396
+ JSXOpeningElement(node) {
1397
+ const name = node.name
1398
+ if (name?.type === 'JSXMemberExpression') {
1399
+ if (name.object?.name === SHELL_ASSEMBLY_MEMBER_OBJECT) assemblyNodes.push(node)
1400
+ return
1401
+ }
1402
+ if (name?.name === 'BasaltShell') ownsShell = true
1403
+ else if (SHELL_ASSEMBLY_TAGS.has(name?.name)) assemblyNodes.push(node)
1404
+ },
1405
+ 'Program:exit'() {
1406
+ if (ownsShell || !mantineImported) return
1407
+ const [first] = assemblyNodes
1408
+ if (first === undefined) return
1409
+ if (hasThemeAllow(context, first, 'hand-rolled-shell')) return
1410
+ if (hasFileDeclaration(context, 'hand-rolled-shell')) return
1411
+ context.report({ node: first, message: HAND_ROLLED_SHELL_MESSAGE })
1412
+ },
1413
+ }
1414
+ },
1415
+ }
1416
+
1417
+ // ── Grace ledger ────────────────────────────────────────────────────────────────────────────────
1418
+
1419
+ /**
1420
+ * Plugin rules still inside their grace minor — `warn` in the shipped consumer preset, `error`
1421
+ * repo-locally, with the promotion note as the value. This is the plugin's counterpart to
1422
+ * `src/guard`'s `GRACE_PERIOD_KINDS`, and it exists because its ABSENCE is what let three rules sit
1423
+ * at `warn` for up to twelve minors with nothing tracking them: `configs/oxlint.json` is generated
1424
+ * and its top-level keys are fixed by oxlint's own parser, so the ledger cannot live there.
1425
+ *
1426
+ * `oxlint-plugin.test.ts` asserts the two agree in both directions — a rule listed here must be
1427
+ * `warn` in the shipped preset, and one absent from here must be `error`. Deleting an entry IS the
1428
+ * promotion, and the test makes the config change mandatory in the same commit.
1429
+ */
1430
+ export const PLUGIN_RULE_GRACE = {
1431
+ 'hand-rolled-plot':
1432
+ 'widened in the round-4 guard minor (suppression scoped to the node; a file-scoped waiver now ' +
1433
+ 'needs a written declaration) — promote to error one minor later, once consumers have moved ' +
1434
+ 'their bare `theme-allow` comments to the declaration form.',
1435
+ 'chart-legend-literal':
1436
+ 'widened in the round-4 guard minor (a `.map()` over a non-series array now counts) — promote ' +
1437
+ 'to error one minor later, for the same reason as hand-rolled-plot above.',
1438
+ 'shadow-basalt-export':
1439
+ 'new in the round-4 guard minor — promote to error one minor later, if ever. A name collision ' +
1440
+ 'is strong evidence and not proof; this may be a permanent warn.',
1441
+ 'hand-rolled-shell': 'new in the round-4 guard minor — promote to error one minor later.',
1442
+ }
1443
+
956
1444
  // ── Plugin export ───────────────────────────────────────────────────────────────────────────────
957
1445
 
958
1446
  export default {
@@ -964,6 +1452,8 @@ export default {
964
1452
  'chart-in-raw-surface': chartInRawSurface,
965
1453
  'hand-rolled-plot': handRolledPlot,
966
1454
  'chart-legend-literal': chartLegendLiteral,
1455
+ 'shadow-basalt-export': shadowBasaltExport,
1456
+ 'hand-rolled-shell': handRolledShell,
967
1457
  'raw-scroll-container': rawScrollContainer,
968
1458
  'visx-boundary': visxBoundary,
969
1459
  'visx-tooltip': visxTooltip,