basalt-ui 1.23.0 → 1.23.1
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 +22 -13
- package/CHANGELOG.md +9 -0
- package/MIGRATING.md +86 -4
- package/README.md +35 -16
- package/agent/rules/basalt-app.md +8 -2
- package/agent/rules/basalt-charts.md +38 -2
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-tokens.md +18 -5
- package/agent/skills/basalt-app/SKILL.md +25 -12
- package/agent/skills/basalt-charts/SKILL.md +4 -2
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/dist/charts/kinds/BandStrip.d.ts +20 -6
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +59 -6
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +13 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +182 -24
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +29 -3
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/BandStrip.test.tsx +87 -5
- package/src/charts/kinds/BandStrip.tsx +105 -12
- package/src/charts/kinds/MirroredBars.test.tsx +39 -0
- package/src/charts/kinds/MirroredBars.tsx +38 -2
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +312 -35
- package/src/cli/tokens-css.test.ts +62 -0
- package/src/guard/check-source.test.ts +73 -0
- package/src/guard/index.ts +67 -7
package/src/cli/index.ts
CHANGED
|
@@ -1352,10 +1352,25 @@ function readFrameworkVersion(pkgRoot: string): string {
|
|
|
1352
1352
|
}
|
|
1353
1353
|
}
|
|
1354
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* How a seeded script, a seeded CI step, or a piece of doctor advice should invoke the CLI.
|
|
1357
|
+
*
|
|
1358
|
+
* The LOCAL bin wherever basalt actually resolved; `bunx basalt-ui` only as the pre-install
|
|
1359
|
+
* fallback. `bunx` does not re-resolve a package it has cached, so a seeded `bunx basalt-ui` lets a
|
|
1360
|
+
* consumer pin one version and be gated by another — which is exactly what happened across three
|
|
1361
|
+
* consumer repos, in CI, in package.json scripts and in a `.claude` PreToolUse hook, all of them
|
|
1362
|
+
* placed here. Every emitted invocation goes through this function so there is one place for it to
|
|
1363
|
+
* be right.
|
|
1364
|
+
*/
|
|
1365
|
+
function basaltBinCommand(install: BasaltInstall, cwd: string): string {
|
|
1366
|
+
if (install.dir === null) return 'bunx basalt-ui'
|
|
1367
|
+
const binDir = resolve(install.dir, '..', '.bin')
|
|
1368
|
+
return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1355
1371
|
/** Build the template-variable map from the framework root + consumer cwd/config. */
|
|
1356
1372
|
function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
|
|
1357
1373
|
const install = findBasaltInstall(cwd)
|
|
1358
|
-
const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
|
|
1359
1374
|
return {
|
|
1360
1375
|
APP_NAME: readPackageName(cwd),
|
|
1361
1376
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
@@ -1364,10 +1379,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
|
|
|
1364
1379
|
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
|
|
1365
1380
|
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
|
|
1366
1381
|
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
|
|
1367
|
-
BASALT_BIN:
|
|
1368
|
-
binDir === null
|
|
1369
|
-
? 'bunx basalt-ui'
|
|
1370
|
-
: `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
|
|
1382
|
+
BASALT_BIN: basaltBinCommand(install, cwd),
|
|
1371
1383
|
}
|
|
1372
1384
|
}
|
|
1373
1385
|
|
|
@@ -2542,7 +2554,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
2542
2554
|
` "PreToolUse": [\n` +
|
|
2543
2555
|
` {\n` +
|
|
2544
2556
|
` "matcher": "Write|Edit|MultiEdit",\n` +
|
|
2545
|
-
` "hooks": [{ "type": "command", "command": "
|
|
2557
|
+
` "hooks": [{ "type": "command", "command": "${ctx.vars.BASALT_BIN} guard-hook" }]\n` +
|
|
2546
2558
|
` }\n` +
|
|
2547
2559
|
` ]\n` +
|
|
2548
2560
|
` }`,
|
|
@@ -2603,6 +2615,21 @@ type SyncOptions = {
|
|
|
2603
2615
|
* install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
|
|
2604
2616
|
* "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
|
|
2605
2617
|
*/
|
|
2618
|
+
/**
|
|
2619
|
+
* The sentence `sync` prints when the cwd carries no install but an ancestor does.
|
|
2620
|
+
*
|
|
2621
|
+
* Shared with `doctor` deliberately. The two commands already share `resolveProjectDir`, but not
|
|
2622
|
+
* the ADVICE: `sync` named the parent install (1.22.0's "stop scaffolding a second consumer" fix)
|
|
2623
|
+
* while `doctor`, in the same directory, kept prescribing `basalt-ui init` — so following doctor
|
|
2624
|
+
* literally performed the exact mistake that fix exists to prevent. One function, one sentence.
|
|
2625
|
+
*/
|
|
2626
|
+
function parentInstallAdvice(cwd: string, parent: string, verb: string): string {
|
|
2627
|
+
return (
|
|
2628
|
+
`This repo's install is at ${relativePosix(cwd, parent)} (${parent}) — run ${verb} there, ` +
|
|
2629
|
+
'or set BASALT_CWD to it.'
|
|
2630
|
+
)
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2606
2633
|
function findManifestAbove(dir: string): string | null {
|
|
2607
2634
|
let current = resolve(dir, '..')
|
|
2608
2635
|
for (;;) {
|
|
@@ -2681,8 +2708,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2681
2708
|
(elsewhere === null
|
|
2682
2709
|
? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
|
|
2683
2710
|
'package really is meant to be a consumer.'
|
|
2684
|
-
: `
|
|
2685
|
-
'sync there, or set BASALT_CWD to it.'),
|
|
2711
|
+
: ` ${parentInstallAdvice(cwd, elsewhere, 'sync')}`),
|
|
2686
2712
|
)
|
|
2687
2713
|
return 1
|
|
2688
2714
|
}
|
|
@@ -3365,6 +3391,10 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3365
3391
|
// ── Profile ────────────────────────────────────────────────────────────────
|
|
3366
3392
|
const manifestAbs = resolve(cwd, MANIFEST_PATH)
|
|
3367
3393
|
const manifestExists = existsSync(manifestAbs)
|
|
3394
|
+
// Non-null ⇒ this directory is a package UNDER a configured repo, not an unscaffolded consumer.
|
|
3395
|
+
// Every remedy below that would otherwise say "run `basalt-ui init`" points at the parent
|
|
3396
|
+
// instead — see {@link parentInstallAdvice}.
|
|
3397
|
+
const parentInstall = manifestExists ? null : findManifestAbove(cwd)
|
|
3368
3398
|
const profile = inferredProfile(cwd, cfg, flags)
|
|
3369
3399
|
if (profile === 'tokens-only') {
|
|
3370
3400
|
lines.push(
|
|
@@ -3388,6 +3418,12 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3388
3418
|
pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
|
|
3389
3419
|
} else if (manifestExists) {
|
|
3390
3420
|
pass(`${MANIFEST_PATH} exists`)
|
|
3421
|
+
} else if (parentInstall !== null) {
|
|
3422
|
+
fail(
|
|
3423
|
+
`${MANIFEST_PATH} missing here, but this is not an unscaffolded consumer. ` +
|
|
3424
|
+
`${parentInstallAdvice(cwd, parentInstall, 'doctor')} \`basalt-ui init\` is NOT the fix: ` +
|
|
3425
|
+
'it would scaffold a SECOND consumer beside the real one.',
|
|
3426
|
+
)
|
|
3391
3427
|
} else {
|
|
3392
3428
|
fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
|
|
3393
3429
|
}
|
|
@@ -3549,7 +3585,11 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3549
3585
|
if (oxlintrcRaw === null) {
|
|
3550
3586
|
fail(
|
|
3551
3587
|
'.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
|
|
3552
|
-
'active.
|
|
3588
|
+
'active. ' +
|
|
3589
|
+
(parentInstall === null
|
|
3590
|
+
? 'Run `basalt-ui init` to seed it.'
|
|
3591
|
+
: `${parentInstallAdvice(cwd, parentInstall, 'doctor')} The preset is seeded once, at ` +
|
|
3592
|
+
'the install — not per package.'),
|
|
3553
3593
|
)
|
|
3554
3594
|
} else {
|
|
3555
3595
|
// JSONC: oxlint accepts comments and real consumer configs carry them.
|
|
@@ -3642,40 +3682,61 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3642
3682
|
`${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
|
|
3643
3683
|
'command runs check-theme — the theme guard is not gating commits. Either add ' +
|
|
3644
3684
|
`"extends: [${lefthookCorrect}]", or add your own command running ` +
|
|
3645
|
-
|
|
3685
|
+
`\`${basaltBinCommand(install, cwd)} check-theme\` (with \`root:\` if it must run in a ` +
|
|
3686
|
+
'subdirectory).',
|
|
3646
3687
|
)
|
|
3647
3688
|
} else {
|
|
3648
3689
|
warn(
|
|
3649
3690
|
`${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
|
|
3650
3691
|
'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
|
|
3651
3692
|
'config it never read — treat it as advisory. If the guard really is unwired, add ' +
|
|
3652
|
-
`"extends: [${lefthookCorrect}]" or your own command running
|
|
3693
|
+
`"extends: [${lefthookCorrect}]" or your own command running ` +
|
|
3694
|
+
`\`${basaltBinCommand(install, cwd)} check-theme\`.`,
|
|
3653
3695
|
)
|
|
3654
3696
|
}
|
|
3655
3697
|
|
|
3656
|
-
// ── Warn check 8:
|
|
3657
|
-
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references
|
|
3658
|
-
//
|
|
3659
|
-
//
|
|
3660
|
-
//
|
|
3698
|
+
// ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
|
|
3699
|
+
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
|
|
3700
|
+
// (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
|
|
3701
|
+
// is read (see {@link readAppIconsOption}) rather than assumed. Only runs when a public/ dir
|
|
3702
|
+
// exists at all: apps that don't use the plugin, or don't use Vite's public-dir convention, get
|
|
3703
|
+
// no false warning.
|
|
3661
3704
|
const publicDir = resolve(cwd, 'public')
|
|
3662
3705
|
if (profile === 'framework' && existsSync(publicDir)) {
|
|
3663
|
-
const
|
|
3664
|
-
|
|
3665
|
-
'
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
'
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
const missingIcons = iconFiles.filter((f) => !existsSync(resolve(publicDir, f)))
|
|
3672
|
-
if (missingIcons.length > 0) {
|
|
3673
|
-
warn(
|
|
3674
|
-
`public/ is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
|
|
3675
|
-
'them (e.g. via realfavicongenerator.net) and place them at the public/ root.',
|
|
3706
|
+
const icons = readAppIconsOption(cwd)
|
|
3707
|
+
if (icons.kind === 'none') {
|
|
3708
|
+
pass('basaltAppPlugin is configured with no icons — nothing to check')
|
|
3709
|
+
} else if (icons.kind === 'explicit') {
|
|
3710
|
+
// The app NAMED its icons, so those are the only files it owes. Checking the six defaults
|
|
3711
|
+
// here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
|
|
3712
|
+
const missing = icons.sources.filter(
|
|
3713
|
+
(src) => !existsSync(resolve(publicDir, src.replace(/^\//, ''))),
|
|
3676
3714
|
)
|
|
3715
|
+
if (missing.length > 0) {
|
|
3716
|
+
warn(
|
|
3717
|
+
`basaltAppPlugin declares icon(s) that are not in public/: ${missing.join(', ')} — the ` +
|
|
3718
|
+
'manifest and the head links will point at a 404. Place them, or drop them from the ' +
|
|
3719
|
+
'`icons` array.',
|
|
3720
|
+
)
|
|
3721
|
+
} else {
|
|
3722
|
+
pass(
|
|
3723
|
+
`public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's \`icons\` option names`,
|
|
3724
|
+
)
|
|
3725
|
+
}
|
|
3677
3726
|
} else {
|
|
3678
|
-
|
|
3727
|
+
const dir = icons.kind === 'default' ? icons.dir : null
|
|
3728
|
+
const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
|
|
3729
|
+
const shown = dir === null ? 'public/' : `public/${dir.replace(/^\//, '')}`
|
|
3730
|
+
const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
|
|
3731
|
+
if (missingIcons.length > 0) {
|
|
3732
|
+
warn(
|
|
3733
|
+
`${shown} is missing basaltAppPlugin icon file(s): ${missingIcons.join(', ')} — generate ` +
|
|
3734
|
+
`them (e.g. via realfavicongenerator.net) and place them at the ${shown} root, or name ` +
|
|
3735
|
+
"the icons this app really has via the plugin's `icons` array.",
|
|
3736
|
+
)
|
|
3737
|
+
} else {
|
|
3738
|
+
pass(`${shown} has all of basaltAppPlugin's default icon files`)
|
|
3739
|
+
}
|
|
3679
3740
|
}
|
|
3680
3741
|
}
|
|
3681
3742
|
|
|
@@ -3882,6 +3943,100 @@ export function info(flags: string[]): number {
|
|
|
3882
3943
|
return 0
|
|
3883
3944
|
}
|
|
3884
3945
|
|
|
3946
|
+
/** The six filenames `basaltAppPlugin` references when `icons` is left at its default. */
|
|
3947
|
+
const DEFAULT_APP_ICON_FILES = [
|
|
3948
|
+
'favicon.ico',
|
|
3949
|
+
'favicon.svg',
|
|
3950
|
+
'favicon-96x96.png',
|
|
3951
|
+
'apple-touch-icon.png',
|
|
3952
|
+
'web-app-manifest-192x192.png',
|
|
3953
|
+
'web-app-manifest-512x512.png',
|
|
3954
|
+
] as const
|
|
3955
|
+
|
|
3956
|
+
const VITE_CONFIG_FILES = [
|
|
3957
|
+
'vite.config.ts',
|
|
3958
|
+
'vite.config.mts',
|
|
3959
|
+
'vite.config.cts',
|
|
3960
|
+
'vite.config.js',
|
|
3961
|
+
'vite.config.mjs',
|
|
3962
|
+
'vite.config.cjs',
|
|
3963
|
+
] as const
|
|
3964
|
+
|
|
3965
|
+
/**
|
|
3966
|
+
* What `basaltAppPlugin`'s `icons` option says this app actually has.
|
|
3967
|
+
*
|
|
3968
|
+
* `unknown` = no vite config, no `basaltAppPlugin(` call, or an `icons` value this bounded read
|
|
3969
|
+
* cannot classify — all three fall back to the six-default check, so the gate can only ever get
|
|
3970
|
+
* NARROWER than it was, never blinder.
|
|
3971
|
+
*/
|
|
3972
|
+
type AppIconsOption =
|
|
3973
|
+
| { kind: 'default'; dir: string | null }
|
|
3974
|
+
| { kind: 'none' }
|
|
3975
|
+
| { kind: 'explicit'; sources: readonly string[] }
|
|
3976
|
+
| { kind: 'unknown' }
|
|
3977
|
+
|
|
3978
|
+
/** The balanced `(`…`)` / `{`…`}` / `[`…`]` span starting at `open`, or null if it never closes. */
|
|
3979
|
+
function balancedSpan(text: string, open: number): string | null {
|
|
3980
|
+
const pairs: Record<string, string> = { '(': ')', '{': '}', '[': ']' }
|
|
3981
|
+
const closer = pairs[text[open] as string]
|
|
3982
|
+
if (closer === undefined) return null
|
|
3983
|
+
let depth = 0
|
|
3984
|
+
for (let i = open; i < text.length; i++) {
|
|
3985
|
+
const ch = text[i] as string
|
|
3986
|
+
if (ch === text[open]) depth++
|
|
3987
|
+
else if (ch === closer) {
|
|
3988
|
+
depth--
|
|
3989
|
+
if (depth === 0) return text.slice(open, i + 1)
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
return null
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
/**
|
|
3996
|
+
* Read the `icons` option out of the consumer's vite config.
|
|
3997
|
+
*
|
|
3998
|
+
* 1.23.0 let `icons` name an app's REAL icon files; doctor's icon check kept demanding the six
|
|
3999
|
+
* default filenames and never read the option, so adopting the release's headline feature produced
|
|
4000
|
+
* a brand-new warning — on the very repo the feature was written for. A warning that fires BECAUSE
|
|
4001
|
+
* you adopted the new API teaches people to ignore doctor.
|
|
4002
|
+
*
|
|
4003
|
+
* doctor does not run the build, so the plugin's resolved answer is not available to it; this is a
|
|
4004
|
+
* bounded textual read of the config file, the same shape the guard uses on source. It is
|
|
4005
|
+
* deliberately conservative: anything it cannot classify returns `unknown` and the old check runs.
|
|
4006
|
+
*/
|
|
4007
|
+
function readAppIconsOption(cwd: string): AppIconsOption {
|
|
4008
|
+
const configPath = VITE_CONFIG_FILES.map((f) => resolve(cwd, f)).find((f) => existsSync(f))
|
|
4009
|
+
if (configPath === undefined) return { kind: 'unknown' }
|
|
4010
|
+
const text = readIfExists(configPath)
|
|
4011
|
+
if (text === null) return { kind: 'unknown' }
|
|
4012
|
+
const call = text.indexOf('basaltAppPlugin(')
|
|
4013
|
+
if (call === -1) return { kind: 'unknown' }
|
|
4014
|
+
const args = balancedSpan(text, call + 'basaltAppPlugin'.length)
|
|
4015
|
+
if (args === null) return { kind: 'unknown' }
|
|
4016
|
+
const key = args.search(/\bicons\s*:/)
|
|
4017
|
+
if (key === -1) return { kind: 'default', dir: null }
|
|
4018
|
+
const valueStart = args.indexOf(':', key) + 1
|
|
4019
|
+
const rest = args.slice(valueStart).trimStart()
|
|
4020
|
+
if (rest.startsWith('false')) return { kind: 'none' }
|
|
4021
|
+
const offset = args.length - rest.length
|
|
4022
|
+
if (rest.startsWith('[')) {
|
|
4023
|
+
const list = balancedSpan(args, offset)
|
|
4024
|
+
if (list === null) return { kind: 'unknown' }
|
|
4025
|
+
const sources = [...list.matchAll(/\bsrc\s*:\s*['"`]([^'"`]+)['"`]/g)].map(
|
|
4026
|
+
(m) => m[1] as string,
|
|
4027
|
+
)
|
|
4028
|
+
// An empty array means "this app has no icons" to the plugin, same as `false`.
|
|
4029
|
+
return sources.length === 0 ? { kind: 'none' } : { kind: 'explicit', sources }
|
|
4030
|
+
}
|
|
4031
|
+
if (rest.startsWith('{')) {
|
|
4032
|
+
const obj = balancedSpan(args, offset)
|
|
4033
|
+
if (obj === null) return { kind: 'unknown' }
|
|
4034
|
+
const dir = /\bdir\s*:\s*['"`]([^'"`]+)['"`]/.exec(obj)
|
|
4035
|
+
return { kind: 'default', dir: dir === null ? null : (dir[1] as string) }
|
|
4036
|
+
}
|
|
4037
|
+
return { kind: 'unknown' }
|
|
4038
|
+
}
|
|
4039
|
+
|
|
3885
4040
|
/** Read the value that follows a `--flag` in an argv slice; `undefined` when absent or terminal. */
|
|
3886
4041
|
function flagValue(flags: string[], name: string): string | undefined {
|
|
3887
4042
|
const i = flags.indexOf(name)
|
|
@@ -3904,6 +4059,32 @@ function generatedHeader(version: string, command: string, flags: string[]): str
|
|
|
3904
4059
|
return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
|
|
3905
4060
|
}
|
|
3906
4061
|
|
|
4062
|
+
/**
|
|
4063
|
+
* The generated file with its provenance line (line 2) blanked — what `--check` actually compares.
|
|
4064
|
+
*
|
|
4065
|
+
* Line 2 carries the EMITTING VERSION, and `--check` gated the whole file byte-for-byte, so every
|
|
4066
|
+
* basalt-ui release forced a mandatory no-op commit in every consumer that commits a generated
|
|
4067
|
+
* sheet: regenerate, one line changes, nothing else, ship it. That is the rot pattern removed from
|
|
4068
|
+
* DESIGN.md this cycle, made worse by a gate that is byte-equality.
|
|
4069
|
+
*
|
|
4070
|
+
* The version STAYS in the file rather than being dropped the way DESIGN.md's was, because the
|
|
4071
|
+
* consumer this command exists for — a framework-free site running `bunx basalt-ui tokens:css` with
|
|
4072
|
+
* no basalt in its dependency tree at all — has no other record of which version emitted it, and no
|
|
4073
|
+
* `doctor` to ask. What changes is that the line no longer gates: drift is measured over the token
|
|
4074
|
+
* VALUES, which is the only part of the file a release can meaningfully change.
|
|
4075
|
+
*
|
|
4076
|
+
* Blanked, not dropped, so the two sides stay line-aligned and the reported line counts still match
|
|
4077
|
+
* the files on disk. The `@generated` exemption is untouched by all of this — the guard requires
|
|
4078
|
+
* line 1 verbatim and line 2 parsing as the provenance line, and both are still emitted exactly as
|
|
4079
|
+
* {@link generatedHeader} builds them.
|
|
4080
|
+
*/
|
|
4081
|
+
function withoutProvenanceLine(css: string): string {
|
|
4082
|
+
const lines = css.split('\n')
|
|
4083
|
+
if (lines.length < 2) return css
|
|
4084
|
+
lines[1] = ''
|
|
4085
|
+
return lines.join('\n')
|
|
4086
|
+
}
|
|
4087
|
+
|
|
3907
4088
|
/**
|
|
3908
4089
|
* Space- and number-normalize the argument list of a legacy color function so the emitted file
|
|
3909
4090
|
* survives a normal repo's formatter.
|
|
@@ -3966,14 +4147,22 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
|
|
|
3966
4147
|
)
|
|
3967
4148
|
return 1
|
|
3968
4149
|
}
|
|
3969
|
-
if (onDisk === content) {
|
|
3970
|
-
|
|
4150
|
+
if (withoutProvenanceLine(onDisk) === withoutProvenanceLine(content)) {
|
|
4151
|
+
const staleProvenance = onDisk.split('\n')[1] !== content.split('\n')[1]
|
|
4152
|
+
console.log(
|
|
4153
|
+
`✓ ${command} --check: ${shown} is up to date.` +
|
|
4154
|
+
(staleProvenance
|
|
4155
|
+
? ' (Its provenance line still names an older basalt-ui; that line is deliberately not ' +
|
|
4156
|
+
'gated — re-run without --check whenever you want it refreshed.)'
|
|
4157
|
+
: ''),
|
|
4158
|
+
)
|
|
3971
4159
|
return 0
|
|
3972
4160
|
}
|
|
3973
4161
|
console.error(
|
|
3974
4162
|
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
3975
4163
|
`(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
|
|
3976
|
-
|
|
4164
|
+
"re-run the same command without --check and commit the result. The `@generated` header's " +
|
|
4165
|
+
'provenance line is excluded from this comparison, so a version bump alone never trips it.',
|
|
3977
4166
|
)
|
|
3978
4167
|
return 1
|
|
3979
4168
|
}
|
|
@@ -4217,9 +4406,68 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
4217
4406
|
return 0
|
|
4218
4407
|
}
|
|
4219
4408
|
|
|
4409
|
+
/**
|
|
4410
|
+
* Every flag each subcommand accepts — the table the unknown-flag gate reads.
|
|
4411
|
+
*
|
|
4412
|
+
* An unrecognized flag used to be SILENTLY IGNORED and the command exited 0: `check-theme
|
|
4413
|
+
* --audit-allow` scanned normally and reported success, `doctor --json` printed the human report.
|
|
4414
|
+
* That is the same fail-open shape `--version` had, and it is worse on a CLI agents drive
|
|
4415
|
+
* programmatically, where a typo'd gate reads as a passing gate.
|
|
4416
|
+
*/
|
|
4417
|
+
const COMMAND_FLAGS: Record<string, readonly string[]> = {
|
|
4418
|
+
init: ['--with-router', '--with-query', '--merge-lint'],
|
|
4419
|
+
sync: ['--force', '--check', '--tokens-only', '--framework'],
|
|
4420
|
+
'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
|
|
4421
|
+
'check-coverage': [],
|
|
4422
|
+
info: ['--json'],
|
|
4423
|
+
doctor: ['--tokens-only', '--framework'],
|
|
4424
|
+
'guard-hook': [],
|
|
4425
|
+
'tokens:css': [
|
|
4426
|
+
'--out',
|
|
4427
|
+
'--check',
|
|
4428
|
+
'--selector-attribute',
|
|
4429
|
+
'--selector-class',
|
|
4430
|
+
'--light-class',
|
|
4431
|
+
'--dark-value',
|
|
4432
|
+
'--light-value',
|
|
4433
|
+
'--default-scheme',
|
|
4434
|
+
'--media-fallback',
|
|
4435
|
+
'--only',
|
|
4436
|
+
'--no-legacy-aliases',
|
|
4437
|
+
],
|
|
4438
|
+
'fonts:css': ['--out', '--check'],
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
/** Flags whose VALUE is the next argv token — skipped by the gate so `--out dist/x.css` does not
|
|
4442
|
+
* read `dist/x.css` as a second flag. */
|
|
4443
|
+
const VALUE_FLAGS = new Set([
|
|
4444
|
+
'--out',
|
|
4445
|
+
'--selector-attribute',
|
|
4446
|
+
'--selector-class',
|
|
4447
|
+
'--light-class',
|
|
4448
|
+
'--dark-value',
|
|
4449
|
+
'--light-value',
|
|
4450
|
+
'--default-scheme',
|
|
4451
|
+
'--only',
|
|
4452
|
+
])
|
|
4453
|
+
|
|
4454
|
+
/** The first flag `cmd` does not accept, or null. Unknown commands return null — the dispatcher's
|
|
4455
|
+
* default branch already names those. */
|
|
4456
|
+
function unknownFlag(cmd: string, flags: readonly string[]): string | null {
|
|
4457
|
+
const allowed = COMMAND_FLAGS[cmd]
|
|
4458
|
+
if (allowed === undefined) return null
|
|
4459
|
+
for (let i = 0; i < flags.length; i++) {
|
|
4460
|
+
const flag = flags[i] as string
|
|
4461
|
+
if (!flag.startsWith('-')) continue
|
|
4462
|
+
if (!allowed.includes(flag)) return flag
|
|
4463
|
+
if (VALUE_FLAGS.has(flag)) i++
|
|
4464
|
+
}
|
|
4465
|
+
return null
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4220
4468
|
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
4221
4469
|
const USAGE =
|
|
4222
|
-
'Usage: basalt-ui
|
|
4470
|
+
'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
|
|
4223
4471
|
' sync [--force] [--check] [--tokens-only|--framework] |\n' +
|
|
4224
4472
|
' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
|
|
4225
4473
|
' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
@@ -4250,7 +4498,10 @@ const USAGE =
|
|
|
4250
4498
|
'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
|
|
4251
4499
|
'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
|
|
4252
4500
|
'--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
|
|
4253
|
-
'Every subcommand accepts --help / -h to print this message and exit without running
|
|
4501
|
+
'Every subcommand accepts --help / -h to print this message and exit without running.\n' +
|
|
4502
|
+
'--version / -v prints the resolved package version and exits 0 — the one command that proves\n' +
|
|
4503
|
+
'WHICH basalt-ui ran, which a `bunx` cache can otherwise make a lie. An unknown command or an\n' +
|
|
4504
|
+
'unrecognized flag exits 1 and names what it did not understand; neither is ever ignored.'
|
|
4254
4505
|
|
|
4255
4506
|
/**
|
|
4256
4507
|
* CLI dispatcher — parses argv (subcommand + flags) and returns the command's exit code. The bin
|
|
@@ -4275,6 +4526,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4275
4526
|
console.log(USAGE)
|
|
4276
4527
|
return 0
|
|
4277
4528
|
}
|
|
4529
|
+
// Before dispatch, for the same reason --help is: this answers "which basalt-ui is running" and
|
|
4530
|
+
// must never run a command to do it. `bunx` does not re-resolve a cached package, so a consumer
|
|
4531
|
+
// can pin 1.23.0, invoke `bunx basalt-ui`, and be gated by a months-old copy — the version is the
|
|
4532
|
+
// only thing that catches it, and it has to be one bare line an agent can compare.
|
|
4533
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
4534
|
+
console.log(readFrameworkVersion(packageRoot()))
|
|
4535
|
+
return 0
|
|
4536
|
+
}
|
|
4537
|
+
if (cmd !== undefined) {
|
|
4538
|
+
const bad = unknownFlag(cmd, flags)
|
|
4539
|
+
if (bad !== null) {
|
|
4540
|
+
console.error(
|
|
4541
|
+
`basalt-ui ${cmd}: unrecognized flag '${bad}'. ` +
|
|
4542
|
+
`Accepted: ${(COMMAND_FLAGS[cmd] ?? []).join(' ') || '(none)'} --help\n`,
|
|
4543
|
+
)
|
|
4544
|
+
return 1
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4278
4547
|
switch (cmd) {
|
|
4279
4548
|
case 'init':
|
|
4280
4549
|
return init(cwd, {
|
|
@@ -4302,6 +4571,14 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4302
4571
|
case 'fonts:css':
|
|
4303
4572
|
return fontsCss(flags, cwd)
|
|
4304
4573
|
default:
|
|
4574
|
+
// Named, not just implied by a usage dump: a full-screen help block with no error line reads
|
|
4575
|
+
// like the command ran and chose to print help. Reported by a consumer who passed
|
|
4576
|
+
// `"check-theme --audit-allows"` as ONE argument and could not tell what had been rejected.
|
|
4577
|
+
console.error(
|
|
4578
|
+
cmd === undefined
|
|
4579
|
+
? 'basalt-ui: no command given.\n'
|
|
4580
|
+
: `basalt-ui: unknown command '${cmd}'.\n`,
|
|
4581
|
+
)
|
|
4305
4582
|
console.error(USAGE)
|
|
4306
4583
|
return 1
|
|
4307
4584
|
}
|
|
@@ -14,6 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSy
|
|
|
14
14
|
import { tmpdir } from 'node:os'
|
|
15
15
|
import { join, resolve } from 'node:path'
|
|
16
16
|
|
|
17
|
+
import { GENERATED_HEADER_LINE } from '../guard'
|
|
17
18
|
import { buildPaletteCss } from '../tokens'
|
|
18
19
|
import { normalizeColorFunctions, run, tokensCss } from './index'
|
|
19
20
|
|
|
@@ -118,3 +119,64 @@ describe('tokens:css', () => {
|
|
|
118
119
|
expect(readdirSync(dir)).toEqual(['package.json'])
|
|
119
120
|
})
|
|
120
121
|
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `--check` gates the token VALUES, never the provenance line.
|
|
125
|
+
*
|
|
126
|
+
* Line 2 of the generated header carries the emitting version, and `--check` used to compare the
|
|
127
|
+
* whole file byte for byte — so every basalt-ui release forced a mandatory no-op commit in every
|
|
128
|
+
* consumer that commits a generated sheet: regenerate, one line moves, nothing else, ship it. That
|
|
129
|
+
* is the rot pattern removed from DESIGN.md this cycle, on a file whose gate is byte-equality.
|
|
130
|
+
*/
|
|
131
|
+
describe('tokens:css --check and the provenance line', () => {
|
|
132
|
+
/** Capture stdout/stderr so the check's own sentence can be asserted. */
|
|
133
|
+
function check(out: string): { code: number; log: string } {
|
|
134
|
+
const originalLog = console.log
|
|
135
|
+
const originalError = console.error
|
|
136
|
+
let log = ''
|
|
137
|
+
const sink = (...args: unknown[]) => {
|
|
138
|
+
log += `${args.join(' ')}\n`
|
|
139
|
+
}
|
|
140
|
+
console.log = sink
|
|
141
|
+
console.error = sink
|
|
142
|
+
try {
|
|
143
|
+
return { code: tokensCss(['--check', '--out', out], dir), log }
|
|
144
|
+
} finally {
|
|
145
|
+
console.log = originalLog
|
|
146
|
+
console.error = originalError
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
it('passes when only the emitting VERSION on line 2 differs', () => {
|
|
151
|
+
const emitted = emit([])
|
|
152
|
+
const lines = emitted.split('\n')
|
|
153
|
+
lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+/, 'basalt-ui 0.0.1')
|
|
154
|
+
writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
|
|
155
|
+
|
|
156
|
+
const { code, log } = check('out.css')
|
|
157
|
+
expect(code).toBe(0)
|
|
158
|
+
expect(log).toContain('is up to date')
|
|
159
|
+
expect(log).toContain('provenance line still names an older basalt-ui')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('says nothing about provenance when the file is byte-identical', () => {
|
|
163
|
+
emit([])
|
|
164
|
+
const { code, log } = check('out.css')
|
|
165
|
+
expect(code).toBe(0)
|
|
166
|
+
expect(log).not.toContain('provenance line')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('STILL fails when a token value actually moved', () => {
|
|
170
|
+
const emitted = emit([])
|
|
171
|
+
writeFileSync(resolve(dir, 'out.css'), emitted.replace('--vx-neutral', '--vx-neutrall'))
|
|
172
|
+
const { code, log } = check('out.css')
|
|
173
|
+
expect(code).toBe(1)
|
|
174
|
+
expect(log).toContain('differs from what')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('the header itself is unchanged — the `@generated` exemption needs both lines verbatim', () => {
|
|
178
|
+
const lines = emit([]).split('\n')
|
|
179
|
+
expect(lines[0]).toBe(GENERATED_HEADER_LINE)
|
|
180
|
+
expect(lines[1]).toMatch(/^\/\* basalt-ui [\d.]+ — `basalt-ui tokens:css --out out\.css` \*\/$/)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -800,6 +800,79 @@ describe('chart-missing-aria-label', () => {
|
|
|
800
800
|
const f = find(text)
|
|
801
801
|
expect(kinds(f)).not.toContain('chart-missing-aria-label')
|
|
802
802
|
})
|
|
803
|
+
|
|
804
|
+
// ── tag provenance ─────────────────────────────────────────────────────────
|
|
805
|
+
//
|
|
806
|
+
// The rule keys on a tag NAME, so a consumer's OWN component sharing a shipped kind's name
|
|
807
|
+
// collected an unactionable `ariaLabel` demand (reported against 1.23.0: a hand-composed local
|
|
808
|
+
// `MirroredBars`, 235 lines, nothing to do with the kind). The gate is a one-directional
|
|
809
|
+
// narrowing — skip only what this file DEFINES and does not import from basalt-ui.
|
|
810
|
+
|
|
811
|
+
it('does NOT flag a tag this file DEFINES itself (a local component sharing a kind name)', () => {
|
|
812
|
+
const text = [
|
|
813
|
+
'function MirroredBars({ data }: Props) {',
|
|
814
|
+
' return <svg />',
|
|
815
|
+
'}',
|
|
816
|
+
'export function Panel() {',
|
|
817
|
+
' return <MirroredBars data={points} />',
|
|
818
|
+
'}',
|
|
819
|
+
].join('\n')
|
|
820
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
it('skips a locally defined kind name however it is declared (const/memo, not just function)', () => {
|
|
824
|
+
const text = ['const BandStrip = memo(Inner)', '<BandStrip data={rows} />'].join('\n')
|
|
825
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
it('STILL flags when the same file also imports that name from basalt-ui (a real shadow)', () => {
|
|
829
|
+
const text = [
|
|
830
|
+
"import { MirroredBars } from 'basalt-ui/charts'",
|
|
831
|
+
'function Panel() {',
|
|
832
|
+
' return <MirroredBars data={points} />',
|
|
833
|
+
'}',
|
|
834
|
+
].join('\n')
|
|
835
|
+
expect(kinds(find(text))).toContain('chart-missing-aria-label')
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it('STILL flags a tag imported from a consumer BARREL that re-exports the kind', () => {
|
|
839
|
+
const text = ["import { MultiLine } from '../charts'", '<MultiLine data={points} />'].join('\n')
|
|
840
|
+
expect(kinds(find(text))).toContain('chart-missing-aria-label')
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('STILL flags a file with no imports and no local definition (coverage is not import-gated)', () => {
|
|
844
|
+
expect(kinds(find('<Donut data={slices} />'))).toContain('chart-missing-aria-label')
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('honours an aliased basalt import, since the alias is the tag the JSX writes', () => {
|
|
848
|
+
const text = [
|
|
849
|
+
"import { Bars as BasaltBars } from 'basalt-ui/charts'",
|
|
850
|
+
'const Bars = () => null',
|
|
851
|
+
'<Bars data={points} />',
|
|
852
|
+
].join('\n')
|
|
853
|
+
// `Bars` is defined locally here and only `BasaltBars` came from basalt — the local one is skipped.
|
|
854
|
+
expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
|
|
855
|
+
})
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
describe('unframed-chart — the same provenance gate', () => {
|
|
859
|
+
it('does NOT flag a consumer’s OWN ChartLegend taking an items array', () => {
|
|
860
|
+
const text = [
|
|
861
|
+
'function ChartLegend({ items }: Props) {',
|
|
862
|
+
' return <ul />',
|
|
863
|
+
'}',
|
|
864
|
+
'const view = <ChartLegend items={[{ label: "a" }]} />',
|
|
865
|
+
].join('\n')
|
|
866
|
+
expect(kinds(find(text))).not.toContain('unframed-chart')
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('still flags the shipped ChartLegend built from an inline array', () => {
|
|
870
|
+
const text = [
|
|
871
|
+
"import { ChartLegend } from 'basalt-ui/charts'",
|
|
872
|
+
'const view = <ChartLegend items={[{ label: "a" }]} />',
|
|
873
|
+
].join('\n')
|
|
874
|
+
expect(kinds(find(text))).toContain('unframed-chart')
|
|
875
|
+
})
|
|
803
876
|
})
|
|
804
877
|
|
|
805
878
|
// ── 16. card-with-border ─────────────────────────────────────────────────────
|