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.
- package/AGENTS.md +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/app-mobile-nav.module.css +11 -3
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +21 -17
- package/dist/vite-color-mix.js +2 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.module.css +11 -3
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
package/configs/oxlint-plugin.js
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
/**
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
(comment)
|
|
87
|
-
|
|
88
|
-
(
|
|
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 (
|
|
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 —
|
|
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
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
// one
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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,
|