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/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": "bunx basalt-ui guard-hook" }]\n` +
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
- : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) — run ` +
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. Run `basalt-ui init` to seed it.',
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
- '`bunx basalt-ui check-theme` (with `root:` if it must run in a subdirectory).',
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 \`bunx basalt-ui check-theme\`.`,
3693
+ `"extends: [${lefthookCorrect}]" or your own command running ` +
3694
+ `\`${basaltBinCommand(install, cwd)} check-theme\`.`,
3653
3695
  )
3654
3696
  }
3655
3697
 
3656
- // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
3657
- // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
3658
- // default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
3659
- // apps that don't use the plugin, or don't use Vite's public-dir convention, get no false
3660
- // warning. Best-effort: a custom `icons.dir` isn't visible here, so this checks the root only.
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 iconFiles = [
3664
- 'favicon.ico',
3665
- 'favicon.svg',
3666
- 'favicon-96x96.png',
3667
- 'apple-touch-icon.png',
3668
- 'web-app-manifest-192x192.png',
3669
- 'web-app-manifest-512x512.png',
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
- pass("public/ has all of basaltAppPlugin's default icon files")
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
- console.log(`✓ ${command} --check: ${shown} is up to date.`)
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
- 're-run the same command without --check and commit the result.',
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 <init [--with-router] [--with-query] [--merge-lint] |\n' +
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 ─────────────────────────────────────────────────────