basalt-ui 1.20.0 → 1.22.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 +11 -5
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +231 -50
- package/README.md +57 -10
- package/agent/rules/basalt-app.md +49 -2
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +78 -16
- package/agent/skills/basalt-app/SKILL.md +5 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +218 -69
- package/configs/oxlint-plugin.test.ts +331 -9
- 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/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/cli/index.d.ts +55 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +521 -71
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +114 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +216 -33
- 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/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- 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 +4 -2
- package/dist/vite.d.ts +28 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- 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/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +978 -133
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +352 -1
- package/src/cli/waiver-audit.test.ts +211 -0
- package/src/guard/check-source.test.ts +638 -1
- package/src/guard/index.ts +556 -63
- 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.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +158 -6
- package/src/vite.ts +176 -34
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
|
|
@@ -141,27 +144,30 @@ const ALLOW_REASON_SEPARATOR = /^(?:—|–|-{1,2}|:)\s*/
|
|
|
141
144
|
const MIN_ALLOW_REASON_LENGTH = 4
|
|
142
145
|
|
|
143
146
|
/**
|
|
144
|
-
*
|
|
147
|
+
* The annotation token, with its optional `-file` suffix.
|
|
145
148
|
*
|
|
146
|
-
* `
|
|
147
|
-
* `
|
|
148
|
-
*
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
*
|
|
154
|
-
* annotation in the wild already writes it. Mirrors `parseAllowAnnotation` in `src/guard/index.ts`.
|
|
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.
|
|
155
157
|
*
|
|
156
|
-
* The
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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).
|
|
160
165
|
*/
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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,]+/, '')
|
|
165
171
|
const rules = []
|
|
166
172
|
const unknownRules = []
|
|
167
173
|
let inIdSlot = true
|
|
@@ -178,48 +184,105 @@ function parseThemeAllow(commentValue) {
|
|
|
178
184
|
inIdSlot = /^\s*,/.test(after)
|
|
179
185
|
}
|
|
180
186
|
const reason = remainder.replace(ALLOW_REASON_SEPARATOR, '').trim()
|
|
181
|
-
return { rules, unknownRules, hasReason: reason.length >= MIN_ALLOW_REASON_LENGTH }
|
|
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
|
|
182
226
|
}
|
|
183
227
|
|
|
184
228
|
/**
|
|
185
|
-
*
|
|
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) {
|
|
243
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode?.()
|
|
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.
|
|
186
250
|
*
|
|
187
251
|
* The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
|
|
188
252
|
* 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
253
|
* An annotation that reached for an id and missed covers only the ids it got right.
|
|
192
254
|
*/
|
|
193
255
|
function hasThemeAllow(context, node, ruleId) {
|
|
194
|
-
|
|
195
|
-
const comments = sourceCode?.getAllComments?.() ?? []
|
|
256
|
+
if (hasFileDeclaration(context, ruleId)) return true
|
|
196
257
|
const nodeLine = node.loc.start.line
|
|
197
|
-
return
|
|
258
|
+
return allComments(context).some((comment) => {
|
|
198
259
|
if (comment.loc.end.line !== nodeLine && comment.loc.end.line !== nodeLine - 1) return false
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return allow.rules.length === 0 && allow.unknownRules.length === 0
|
|
260
|
+
return parseThemeAllows(comment.value).some(
|
|
261
|
+
(allow) => allow.scope === 'line' && allowCovers(allow, ruleId),
|
|
262
|
+
)
|
|
203
263
|
})
|
|
204
264
|
}
|
|
205
265
|
|
|
206
266
|
/**
|
|
207
|
-
* True when the FILE carries a written declaration for `ruleId` — a `theme-allow`
|
|
208
|
-
*
|
|
267
|
+
* True when the FILE carries a written declaration for `ruleId` — a `theme-allow-file` naming it,
|
|
268
|
+
* anywhere in the file.
|
|
209
269
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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.
|
|
215
279
|
*/
|
|
216
280
|
function hasFileDeclaration(context, ruleId) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
})
|
|
281
|
+
return allComments(context).some((comment) =>
|
|
282
|
+
parseThemeAllows(comment.value).some(
|
|
283
|
+
(allow) => allow.scope === 'file' && allow.rules.includes(ruleId),
|
|
284
|
+
),
|
|
285
|
+
)
|
|
223
286
|
}
|
|
224
287
|
|
|
225
288
|
/** Test/spec files — design guidance does not apply to a fixture. Mirrors `src/cli`'s own SKIP. */
|
|
@@ -973,18 +1036,26 @@ function readPackageJson(pkgPath) {
|
|
|
973
1036
|
* that the file sits under. A declared `basalt.roots` that the file is NOT under is a positive
|
|
974
1037
|
* statement that this code is not basalt's — the walk stops there rather than continuing up to a
|
|
975
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.
|
|
976
1047
|
*/
|
|
977
1048
|
function isBasaltScopedFile(filename) {
|
|
978
1049
|
let dir = dirname(filename)
|
|
979
1050
|
for (;;) {
|
|
980
1051
|
const pkg = readPackageJson(resolvePath(dir, 'package.json'))
|
|
981
1052
|
if (pkg !== null) {
|
|
982
|
-
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
|
983
|
-
if (deps['basalt-ui'] !== undefined) return true
|
|
984
1053
|
const roots = pkg.basalt?.roots
|
|
985
1054
|
if (Array.isArray(roots) && roots.length > 0) {
|
|
986
1055
|
return roots.some((root) => filename.startsWith(`${resolvePath(dir, root)}/`))
|
|
987
1056
|
}
|
|
1057
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
|
|
1058
|
+
if (deps['basalt-ui'] !== undefined) return true
|
|
988
1059
|
}
|
|
989
1060
|
const parent = dirname(dir)
|
|
990
1061
|
if (parent === dir) return false
|
|
@@ -1093,7 +1164,9 @@ const HAND_ROLLED_PLOT_MESSAGE =
|
|
|
1093
1164
|
'owns the measured margins, both y scales, the axes, grid, shared cursor, crosshair and ' +
|
|
1094
1165
|
'tooltip. Hand-assembling them is how two charts stop matching. Compose CartesianChart and ' +
|
|
1095
1166
|
'render only marks. A genuinely non-single-plot shape (multi-pane, radial, matrix) composes ' +
|
|
1096
|
-
'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)'
|
|
1097
1170
|
|
|
1098
1171
|
const handRolledPlot = {
|
|
1099
1172
|
meta: {
|
|
@@ -1259,25 +1332,85 @@ const chartLegendLiteral = {
|
|
|
1259
1332
|
const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
|
|
1260
1333
|
|
|
1261
1334
|
/**
|
|
1262
|
-
* A component-shaped
|
|
1335
|
+
* A component-shaped NAME: starts uppercase and carries a lowercase letter, which excludes the
|
|
1263
1336
|
* SCREAMING_CASE constants and the two-letter token namespaces in the same barrel.
|
|
1337
|
+
*
|
|
1338
|
+
* A name test alone is not a component test — see {@link isComponentInit}. Every PascalCase binding
|
|
1339
|
+
* passes this, which is how obsidian's `SlugTracker` (a plain data class in a React-free package)
|
|
1340
|
+
* became a "forked component" the moment 1.21.0 widened the rule to all nine barrels.
|
|
1264
1341
|
*/
|
|
1265
1342
|
const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
|
|
1266
1343
|
|
|
1344
|
+
/** The two wrappers a function component is legitimately declared inside. */
|
|
1345
|
+
const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef'])
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Is this initializer component-SHAPED — a function, an arrow, or one wrapped in `memo` /
|
|
1349
|
+
* `forwardRef`?
|
|
1350
|
+
*
|
|
1351
|
+
* The second half of the fix for obsidian's false positive. `const Foo = new Map()` and
|
|
1352
|
+
* `class SlugTracker {}` share a name with a basalt export and nothing else; a fork of a component
|
|
1353
|
+
* is a function (or, historically, a class that `extends` one). Narrowing here costs the rule
|
|
1354
|
+
* nothing it was ever meant to catch and removes the whole "PascalCase binding" false-positive
|
|
1355
|
+
* class, which the nine-barrel widening made large.
|
|
1356
|
+
*/
|
|
1357
|
+
function isComponentInit(init) {
|
|
1358
|
+
if (init === null || init === undefined) return false
|
|
1359
|
+
if (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression') return true
|
|
1360
|
+
if (init.type !== 'CallExpression') return false
|
|
1361
|
+
const callee = init.callee
|
|
1362
|
+
const name = callee?.type === 'MemberExpression' ? callee.property?.name : callee?.name
|
|
1363
|
+
return typeof name === 'string' && COMPONENT_WRAPPERS.has(name)
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1267
1366
|
/**
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
*
|
|
1367
|
+
* The barrels a consumer can import a component FROM. The root one is not the whole surface: a
|
|
1368
|
+
* chart-layer fork (`ChartFrame`, `CartesianChart`, `ChartCard`, …) collided with nothing, because
|
|
1369
|
+
* `./charts` is deliberately NOT re-exported from `.` — so a consumer whose forks all live in the
|
|
1370
|
+
* chart layer, which is the layer forks actually live in, could never trip the rule. It was
|
|
1371
|
+
* correctly silent there for the wrong reason.
|
|
1372
|
+
*
|
|
1373
|
+
* Enumerated rather than globbed: `dist/**\/index.d.ts` would also sweep internal directory
|
|
1374
|
+
* barrels that are not published subpaths, and a name that is not importable is not a fork of
|
|
1375
|
+
* anything. Keep this in step with the `exports` map in package.json.
|
|
1376
|
+
*/
|
|
1377
|
+
const BASALT_BARRELS = [
|
|
1378
|
+
['index.d.ts'],
|
|
1379
|
+
['charts', 'index.d.ts'],
|
|
1380
|
+
['content', 'index.d.ts'],
|
|
1381
|
+
['data', 'index.d.ts'],
|
|
1382
|
+
['forms', 'index.d.ts'],
|
|
1383
|
+
['agent', 'index.d.ts'],
|
|
1384
|
+
['commands', 'index.d.ts'],
|
|
1385
|
+
['notifications', 'index.d.ts'],
|
|
1386
|
+
['connectivity', 'index.d.ts'],
|
|
1387
|
+
]
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Every component-shaped VALUE export of basalt's published barrels, read once at module load from
|
|
1391
|
+
* the real `.d.ts` files — not a hand-maintained list that would go stale the first time an export
|
|
1392
|
+
* was renamed. `type` specifiers are skipped: a local `StatCardProps` is a normal thing to write, a
|
|
1393
|
+
* local `StatCard` is not.
|
|
1272
1394
|
*
|
|
1273
1395
|
* Empty (rule silent) when `dist` is unreadable, exactly like `BASALT_AI_MAJOR` — a lint run must
|
|
1274
1396
|
* not depend on a build having happened.
|
|
1275
1397
|
*/
|
|
1276
|
-
const
|
|
1398
|
+
const BASALT_EXPORTS = (() => {
|
|
1277
1399
|
const names = new Set()
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1400
|
+
for (const segments of BASALT_BARRELS) {
|
|
1401
|
+
let text
|
|
1402
|
+
try {
|
|
1403
|
+
text = readFileSync(resolvePath(PACKAGE_ROOT, 'dist', ...segments), 'utf8')
|
|
1404
|
+
} catch {
|
|
1405
|
+
continue
|
|
1406
|
+
}
|
|
1407
|
+
for (const block of text.matchAll(/export\s*\{([^}]*)\}(?:\s*from\s*['"]([^'"]*)['"])?/g)) {
|
|
1408
|
+
// A pass-through re-export of a THIRD-PARTY name is not a basalt composite. `./charts`
|
|
1409
|
+
// re-exports `Bar`, `Line`, `Pie`, `AreaClosed` … straight from `@visx/shape`, and a local
|
|
1410
|
+
// `Bar` is not a fork of anything basalt wrote — the playground's own demo tripped on it the
|
|
1411
|
+
// moment this rule started reading the charts barrel. Relative sources only.
|
|
1412
|
+
const source = block[2]
|
|
1413
|
+
if (source !== undefined && !source.startsWith('.')) continue
|
|
1281
1414
|
for (const raw of block[1].split(',')) {
|
|
1282
1415
|
const part = raw.trim()
|
|
1283
1416
|
if (part === '' || part.startsWith('type ')) continue
|
|
@@ -1285,8 +1418,6 @@ const BASALT_ROOT_EXPORTS = (() => {
|
|
|
1285
1418
|
if (COMPONENT_NAME.test(name)) names.add(name)
|
|
1286
1419
|
}
|
|
1287
1420
|
}
|
|
1288
|
-
} catch {
|
|
1289
|
-
return names
|
|
1290
1421
|
}
|
|
1291
1422
|
return names
|
|
1292
1423
|
})()
|
|
@@ -1296,7 +1427,10 @@ function shadowBasaltExportMessage(name) {
|
|
|
1296
1427
|
`Local component '${name}' collides with the basalt-ui export of the same name — import it ` +
|
|
1297
1428
|
"from 'basalt-ui' instead of re-rolling it. This is the one signal a forked composite emits: " +
|
|
1298
1429
|
'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.
|
|
1430
|
+
'exactly the right tokens and passes every other check. A NAME collision is the whole of the ' +
|
|
1431
|
+
'signal, so this is a tripwire, not coverage: rename the fork and the rule goes quiet ' +
|
|
1432
|
+
"(linewatch's forks are `Cell` and `Box`, rb's is `Stat`). Silence here is not evidence that " +
|
|
1433
|
+
'nothing is forked. (basalt/shadow-basalt-export)'
|
|
1300
1434
|
)
|
|
1301
1435
|
}
|
|
1302
1436
|
|
|
@@ -1318,11 +1452,16 @@ const shadowBasaltExport = {
|
|
|
1318
1452
|
create(context) {
|
|
1319
1453
|
const filename = getFilename(context)
|
|
1320
1454
|
// basalt's own source DEFINES these names; a consumer's does not.
|
|
1321
|
-
if (
|
|
1455
|
+
if (BASALT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
|
|
1322
1456
|
if (isTestFile(context)) return {}
|
|
1457
|
+
// Scoped exactly like `ai-sdk-major`, and for the same reason: a package with no basalt-ui
|
|
1458
|
+
// dependency (or one outside a declared `basalt.roots`) cannot import the export it is accused
|
|
1459
|
+
// of forking, so the advice — "import it from 'basalt-ui' instead" — is not followable there.
|
|
1460
|
+
// obsidian's React-free `obsidian-vault-core` took the report and had to waive it.
|
|
1461
|
+
if (filename.length === 0 || !isBasaltScopedFile(filename)) return {}
|
|
1323
1462
|
|
|
1324
1463
|
const report = (node, name) => {
|
|
1325
|
-
if (typeof name !== 'string' || !
|
|
1464
|
+
if (typeof name !== 'string' || !BASALT_EXPORTS.has(name)) return
|
|
1326
1465
|
if (hasThemeAllow(context, node, 'shadow-basalt-export')) return
|
|
1327
1466
|
context.report({ node, message: shadowBasaltExportMessage(name) })
|
|
1328
1467
|
}
|
|
@@ -1332,9 +1471,14 @@ const shadowBasaltExport = {
|
|
|
1332
1471
|
report(node, node.id?.name)
|
|
1333
1472
|
},
|
|
1334
1473
|
VariableDeclarator(node) {
|
|
1474
|
+
if (!isComponentInit(node.init)) return
|
|
1335
1475
|
report(node, node.id?.name)
|
|
1336
1476
|
},
|
|
1477
|
+
// A class only counts when it EXTENDS something — a legacy class component. basalt ships no
|
|
1478
|
+
// class components, so a standalone `class X {}` sharing a name with one of its exports is a
|
|
1479
|
+
// collision between two unrelated things, not a fork.
|
|
1337
1480
|
ClassDeclaration(node) {
|
|
1481
|
+
if (node.superClass === null || node.superClass === undefined) return
|
|
1338
1482
|
report(node, node.id?.name)
|
|
1339
1483
|
},
|
|
1340
1484
|
}
|
|
@@ -1356,7 +1500,8 @@ const HAND_ROLLED_SHELL_MESSAGE =
|
|
|
1356
1500
|
'bar and its More sheet, breadcrumbs, the page header and the collapse state, all driven by ONE ' +
|
|
1357
1501
|
'typed nav definition (defineNav + useNav). A hand-rolled AppShell + burger drawer is the exact ' +
|
|
1358
1502
|
'layer the 1.19 nav contract replaces, and no palette guard can see it — it uses the right ' +
|
|
1359
|
-
'tokens.
|
|
1503
|
+
'tokens. Declare a deliberate exception with `theme-allow-file hand-rolled-shell — <why>`. ' +
|
|
1504
|
+
'(basalt/hand-rolled-shell)'
|
|
1360
1505
|
|
|
1361
1506
|
/**
|
|
1362
1507
|
* The `hand-rolled-plot` shape, one layer up. rb shipped 266 lines of `AppShell` + `NavLink` +
|
|
@@ -1429,15 +1574,19 @@ const handRolledShell = {
|
|
|
1429
1574
|
*/
|
|
1430
1575
|
export const PLUGIN_RULE_GRACE = {
|
|
1431
1576
|
'hand-rolled-plot':
|
|
1432
|
-
'widened in the round-
|
|
1433
|
-
'
|
|
1434
|
-
'
|
|
1577
|
+
'widened AGAIN in the round-5 guard minor (file scope must now be spelled `theme-allow-file`, ' +
|
|
1578
|
+
'so the named-with-a-reason shape every consumer wrote at 1.20.0 is a NODE waiver and the ' +
|
|
1579
|
+
'remaining assembly nodes report) — grace restarts with the widening, per the "a rule the ' +
|
|
1580
|
+
'current minor widens does not promote in that minor" doctrine. Promote one minor after ' +
|
|
1581
|
+
'consumers have moved their file declarations to `theme-allow-file`.',
|
|
1435
1582
|
'chart-legend-literal':
|
|
1436
1583
|
'widened in the round-4 guard minor (a `.map()` over a non-series array now counts) — promote ' +
|
|
1437
1584
|
'to error one minor later, for the same reason as hand-rolled-plot above.',
|
|
1438
1585
|
'shadow-basalt-export':
|
|
1439
|
-
'new in the round-4 guard minor
|
|
1440
|
-
'is
|
|
1586
|
+
'new in the round-4 guard minor, widened in round-5 (reads every published barrel, not just ' +
|
|
1587
|
+
'the root one, so a chart-layer fork is visible) — promote to error one minor later, if ever. ' +
|
|
1588
|
+
'A name collision is strong evidence and not proof, and renaming defeats it outright, so this ' +
|
|
1589
|
+
'may be a permanent warn.',
|
|
1441
1590
|
'hand-rolled-shell': 'new in the round-4 guard minor — promote to error one minor later.',
|
|
1442
1591
|
}
|
|
1443
1592
|
|