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.
Files changed (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
@@ -151,6 +151,8 @@ describe('doctor — CLI vs installed basalt-ui version', () => {
151
151
  'node_modules/basalt-ui/package.json',
152
152
  JSON.stringify({ name: 'basalt-ui', version: cliVersion }),
153
153
  )
154
+ // The `extends` above has to RESOLVE, not merely read correctly — see the oxlint-preset check.
155
+ writeFixture('node_modules/basalt-ui/configs/oxlint.json', '{}')
154
156
  const { code, log } = capture(() => doctor(dir))
155
157
  expect(code).toBe(0)
156
158
  expect(log).toContain('matches the installed basalt-ui')
@@ -116,7 +116,7 @@ describe('classify ledger: cross-version format-only drift vs a real edit', () =
116
116
  // older version token — this is what makes the scenario cross-version, not same-version.
117
117
  const olderBlock = currentBlock.replace(/basalt-ui/g, 'basalt')
118
118
  expect(olderBlock).not.toBe(currentBlock)
119
- expect(olderBlock).toContain('bunx basalt sync')
119
+ expect(olderBlock).toContain('basalt sync')
120
120
 
121
121
  // What a PRE-1.0.2 CLI's writeUnit actually recorded at that sync: a RAW hash of the exact
122
122
  // bytes it wrote — the fix only normalizes hashes written by a CLI carrying it, so a genuine
@@ -7,7 +7,7 @@
7
7
  * false-greens shut. The unit under test is always "does the tool TELL you", not "does it work".
8
8
  */
9
9
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
10
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
11
11
  import { tmpdir } from 'node:os'
12
12
  import { join, resolve } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
@@ -20,6 +20,7 @@ import {
20
20
  init,
21
21
  MANIFEST_PATH,
22
22
  normalizeColorFunctions,
23
+ sync,
23
24
  tokensCss,
24
25
  } from './index.ts'
25
26
 
@@ -68,6 +69,9 @@ function installBasalt(at = ''): void {
68
69
  join(at, 'node_modules/basalt-ui/package.json'),
69
70
  JSON.stringify({ name: 'basalt-ui', version: CLI_VERSION }),
70
71
  )
72
+ // The shipped preset itself — `doctor`'s oxlint-preset check resolves the `extends` target now,
73
+ // so an install without it is (correctly) a failure.
74
+ write(join(at, 'node_modules/basalt-ui/configs/oxlint.json'), '{}')
71
75
  }
72
76
 
73
77
  /** A repo where every framework-profile doctor check passes. */
@@ -538,6 +542,18 @@ describe('tokens:css — a committable artifact', () => {
538
542
  expect(css).toContain('rgba(255, 255, 255, 0.6)')
539
543
  })
540
544
 
545
+ it('emits no trailing-zero alpha — the last thing keeping the sheet out of a linter', () => {
546
+ // prettier rewrites `rgba(28, 25, 23, 0.10)` → `0.1`, so the two light shadow tokens failed
547
+ // `format/prettier` while `--fix` put the file straight into `tokens:css --check` drift. The
548
+ // consumer's eslint-ignore entry for it survived two releases.
549
+ const css = emit([])
550
+ expect(css).not.toMatch(/rgba\([^)]*\d\.\d*0\)/)
551
+ expect(css).toContain('rgba(28, 25, 23, 0.1)')
552
+ expect(normalizeColorFunctions('a: rgba(1, 2, 3, 0.10); b: rgba(1, 2, 3, 1.0);')).toBe(
553
+ 'a: rgba(1, 2, 3, 0.1); b: rgba(1, 2, 3, 1);',
554
+ )
555
+ })
556
+
541
557
  it('changes no token VALUE — the body is buildPaletteCss modulo the formatting', () => {
542
558
  const body = emit([]).split('\n').slice(2).join('\n')
543
559
  expect(body).toBe(`${normalizeColorFunctions(buildPaletteCss())}\n`)
@@ -692,3 +708,338 @@ describe('fonts:css — the typeface half of the token layer', () => {
692
708
  expect(capture(() => fontsCss(['--out', 'fonts.css', '--check'], dir)).code).toBe(1)
693
709
  })
694
710
  })
711
+
712
+ // ──────────────────────────────────────────────────────────────────────────────
713
+ // Round 5: the same false-green class, one layer in — inside the checks that
714
+ // were added to prevent it.
715
+ // ──────────────────────────────────────────────────────────────────────────────
716
+
717
+ describe('doctor — the oxlint-preset check resolves the path, not just the string', () => {
718
+ it('hard-fails when the extends entry names a path that does not exist', () => {
719
+ healthyFixture()
720
+ // Exactly the state a monorepo consumer upgrades into: a well-shaped root-relative entry, and
721
+ // basalt installed beside the package that depends on it. `bunx oxlint .` dies with NotFound
722
+ // here, and 1.20.0's own check printed ✓ in the same tree.
723
+ rmSync(join(dir, 'node_modules/basalt-ui/configs/oxlint.json'))
724
+ const { code, log } = capture(() => doctor(dir))
725
+ expect(code).toBe(1)
726
+ expect(log).toContain('does not exist')
727
+ expect(log).toContain('NotFound')
728
+ expect(log).not.toContain('✓ .oxlintrc.json extends')
729
+ })
730
+
731
+ it('names the RESOLVED install path as the remedy when the entry points somewhere else', () => {
732
+ write(
733
+ 'package.json',
734
+ JSON.stringify({
735
+ name: 'root',
736
+ workspaces: ['packages/*'],
737
+ basalt: { roots: ['packages/web/src'] },
738
+ }),
739
+ )
740
+ write('packages/web/package.json', JSON.stringify({ name: 'web' }))
741
+ write('packages/web/src/app.tsx', 'export const App = () => null\n')
742
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
743
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
744
+ installBasalt('packages/web')
745
+ const { code, log } = capture(() => doctor(dir))
746
+ expect(code).toBe(1)
747
+ expect(log).toContain('packages/web/node_modules/basalt-ui/configs/oxlint.json')
748
+ })
749
+
750
+ it('passes — and quotes the entry — when the target really is there', () => {
751
+ healthyFixture()
752
+ const { code, log } = capture(() => doctor(dir))
753
+ expect(code).toBe(0)
754
+ expect(log).toContain('extends the shipped basalt-ui oxlint preset')
755
+ })
756
+ })
757
+
758
+ describe('doctor — the lefthook extends target, the seam that fails SILENTLY', () => {
759
+ it('hard-fails on a missing target: lefthook merges it to zero commands and exits 0', () => {
760
+ healthyFixture()
761
+ write('lefthook.yml', 'extends:\n - node_modules/basalt-ui/configs/lefthook.yml\n')
762
+ const { code, log } = capture(() => doctor(dir))
763
+ expect(code).toBe(1)
764
+ expect(log).toContain('ZERO commands')
765
+ })
766
+
767
+ it('passes when the target resolves', () => {
768
+ healthyFixture()
769
+ write('node_modules/basalt-ui/configs/lefthook.yml', 'pre-commit:\n commands: {}\n')
770
+ write('lefthook.yml', 'extends:\n - node_modules/basalt-ui/configs/lefthook.yml\n')
771
+ const { code, log } = capture(() => doctor(dir))
772
+ expect(code).toBe(0)
773
+ expect(log).toContain('extends the shipped lefthook preset')
774
+ })
775
+
776
+ it('passes a repo that spells the gate out instead of extending — the check is the GATE', () => {
777
+ // linewatch's root lefthook.yml runs all three jobs with `root: 'web/'`, because `extends`
778
+ // merges commands WITHOUT their working directory: the preset's `bunx oxlint` would run at the
779
+ // repo root where there is no .oxlintrc.json. Testing for the extends STRING warned at a
780
+ // correctly-wired repo and prescribed a change that would have broken it.
781
+ healthyFixture()
782
+ write(
783
+ 'lefthook.yml',
784
+ "pre-commit:\n commands:\n theme-guard:\n root: 'web/'\n" +
785
+ ' run: bunx basalt-ui check-theme\n',
786
+ )
787
+ const { code, log } = capture(() => doctor(dir))
788
+ expect(code).toBe(0)
789
+ expect(log).toContain('a pre-commit command runs check-theme directly — the gate exists')
790
+ })
791
+
792
+ it('is advisory, never confident, when neither the text nor `lefthook dump` settles it', () => {
793
+ // A check that is confidently wrong is worse than one that admits its limit: an `include:` or a
794
+ // remote config carries commands this reader never sees.
795
+ healthyFixture()
796
+ write('lefthook.yml', 'pre-commit:\n commands:\n mine:\n run: echo hi\n')
797
+ const { code, log } = capture(() => doctor(dir))
798
+ expect(code).toBe(0)
799
+ expect(log).toContain('cannot tell a missing gate from one wired through an `include:`')
800
+ })
801
+
802
+ it('is not applicable when the repo runs no lefthook at all', () => {
803
+ healthyFixture()
804
+ const { code, log } = capture(() => doctor(dir))
805
+ expect(code).toBe(0)
806
+ expect(log).toContain('lefthook-preset: n/a')
807
+ })
808
+ })
809
+
810
+ describe('sync — basalt.roots is drift, and sync is the drift command', () => {
811
+ it('backfills the inferred roots when the key was never written', () => {
812
+ write('package.json', JSON.stringify({ name: 'fixture' }))
813
+ write('src/app.tsx', 'export const App = () => null\n')
814
+ capture(() => init(dir))
815
+ // Undo init's write to reproduce a repo scaffolded before roots existed, then upgrade.
816
+ write('package.json', JSON.stringify({ name: 'fixture' }))
817
+ const { log } = capture(() => sync({}, dir))
818
+ expect(log).toContain('"roots": ["src"]')
819
+ expect((readPkg()['basalt'] as { roots: string[] }).roots).toEqual(['src'])
820
+ })
821
+
822
+ it('never overwrites a declared roots — it names what the declaration does not cover', () => {
823
+ write(
824
+ 'package.json',
825
+ JSON.stringify({
826
+ name: 'root',
827
+ workspaces: ['packages/*'],
828
+ basalt: { roots: ['packages/web/src'] },
829
+ }),
830
+ )
831
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
832
+ write(
833
+ 'packages/web/package.json',
834
+ JSON.stringify({ name: 'web', dependencies: { 'basalt-ui': '*' } }),
835
+ )
836
+ write('packages/web/src/app.tsx', 'export const App = () => null\n')
837
+ write(
838
+ 'packages/api/package.json',
839
+ JSON.stringify({ name: 'api', dependencies: { 'basalt-ui': '*' } }),
840
+ )
841
+ write('packages/api/src/server.ts', 'export const server = null\n')
842
+ const { log } = capture(() => sync({}, dir))
843
+ expect((readPkg()['basalt'] as { roots: string[] }).roots).toEqual(['packages/web/src'])
844
+ expect(log).toContain('keeping your "basalt.roots"')
845
+ expect(log).toContain('packages/api/src')
846
+ })
847
+
848
+ it('--check writes nothing, but still says the key is missing', () => {
849
+ write('package.json', JSON.stringify({ name: 'fixture' }))
850
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
851
+ write('src/app.tsx', 'export const App = () => null\n')
852
+ const { log } = capture(() => sync({ check: true }, dir))
853
+ expect(readPkg()['basalt']).toBeUndefined()
854
+ expect(log).toContain('"basalt.roots" is not set')
855
+ })
856
+
857
+ it('reports a lefthook extends target that no longer resolves', () => {
858
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
859
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
860
+ write('src/app.tsx', 'export const App = () => null\n')
861
+ write('lefthook.yml', 'extends:\n - node_modules/basalt-ui/configs/lefthook.yml\n')
862
+ const { log } = capture(() => sync({}, dir))
863
+ expect(log).toContain('ZERO commands')
864
+ })
865
+ })
866
+
867
+ describe('init — the seeded lefthook.yml pins the guard to the local bin', () => {
868
+ it('renders a BASALT_BIN env override, the one key an extends target cannot eat', () => {
869
+ write('package.json', JSON.stringify({ name: 'fixture' }))
870
+ write('src/app.tsx', 'export const App = () => null\n')
871
+ installBasalt()
872
+ capture(() => init(dir))
873
+ const seeded = read('lefthook.yml')
874
+ expect(seeded).toContain('BASALT_BIN: node_modules/.bin/basalt-ui')
875
+ expect(seeded).toContain('extends:')
876
+ })
877
+ })
878
+
879
+ // ── sync must never silently behave as init ───────────────────────────────────────────────────
880
+ //
881
+ // Found independently in argo and rb. Run from `apps/dashboard` — the package that actually
882
+ // depends on basalt-ui — `sync` printed `0 updated, 20 recreated`, wrote a second `basalt` key into
883
+ // that package.json, and scaffolded a complete competing install (`.basalt/`, `.oxlintrc.json`,
884
+ // `.oxfmtrc.json`, `DESIGN.md`, 13 rules, 3 skills, and in rb's case a `src/routes/__root.tsx`
885
+ // into an app that hand-writes its router) beside the real one at the repo root. Nothing warned.
886
+ // Both consumers reverted it by hand. `check-theme` and `doctor` already relocate; `sync` — the one
887
+ // command that WRITES — did not.
888
+
889
+ /** A workspace whose basalt install lives at the ROOT, with an app package below it. */
890
+ function installAtRoot(): void {
891
+ write(
892
+ 'package.json',
893
+ JSON.stringify({
894
+ name: 'root',
895
+ workspaces: ['apps/*'],
896
+ basalt: { roots: ['apps/dashboard/src'] },
897
+ }),
898
+ )
899
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
900
+ write(
901
+ 'apps/dashboard/package.json',
902
+ JSON.stringify({ name: 'dashboard', dependencies: { 'basalt-ui': '*' } }),
903
+ )
904
+ write('apps/dashboard/src/app.tsx', 'export const App = () => null\n')
905
+ }
906
+
907
+ describe('sync — a scaffold is init’s decision, never a drift refresh’s', () => {
908
+ it('refuses from a workspace sub-package and points at the install it found above', () => {
909
+ installAtRoot()
910
+ const { code, log } = capture(() => sync({}, join(dir, 'apps/dashboard')))
911
+ expect(code).toBe(1)
912
+ expect(log).toContain('refusing to scaffold')
913
+ expect(log).toContain("This repo's install is at ../..")
914
+ })
915
+
916
+ it('writes NOTHING when it refuses — not the files, and not the second basalt key', () => {
917
+ installAtRoot()
918
+ capture(() => sync({}, join(dir, 'apps/dashboard')))
919
+ // The `basalt.roots` backfill is half the damage: a second config site means check-theme
920
+ // silently scans a different tree depending on which directory it was invoked from.
921
+ expect(JSON.parse(read('apps/dashboard/package.json'))['basalt']).toBeUndefined()
922
+ expect(readdirSync(join(dir, 'apps/dashboard')).toSorted()).toEqual(['package.json', 'src'])
923
+ })
924
+
925
+ it('refuses in a repo that was never a consumer, and names init as the command that is', () => {
926
+ write('package.json', JSON.stringify({ name: 'fixture' }))
927
+ const { code, log } = capture(() => sync({}, dir))
928
+ expect(code).toBe(1)
929
+ expect(log).toContain('No manifest exists at any ancestor either')
930
+ expect(log).toContain('basalt-ui init')
931
+ })
932
+
933
+ it('relocates to the single workspace package carrying the install, exactly as doctor does', () => {
934
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
935
+ write('apps/web/package.json', JSON.stringify({ name: 'web', basalt: { roots: ['src'] } }))
936
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
937
+ write(join('apps/web', MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
938
+ const { code, log } = capture(() => sync({ check: true }, dir))
939
+ expect(log).toContain('running in ./apps/web, where it lives')
940
+ // --check on an unsynced package is legitimately stale; what matters is WHERE it looked.
941
+ expect(code).toBe(1)
942
+ })
943
+
944
+ it('short-circuits on an ambiguous workspace instead of picking one to scaffold', () => {
945
+ ambiguousWorkspace()
946
+ const { code, log } = capture(() => sync({}, dir))
947
+ expect(code).toBe(1)
948
+ expect(log).toContain('2 workspace packages carry one')
949
+ expect(log).toContain('BASALT_CWD')
950
+ })
951
+
952
+ it('says created, not recreated, for a file the ledger never recorded', () => {
953
+ // `0 updated, 20 recreated` described a scaffold as a refresh. "Recreated" means the ledger
954
+ // placed it once and it went missing; a file basalt never wrote here is CREATED.
955
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
956
+ write('src/app.tsx', 'export const App = () => null\n')
957
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
958
+ const { log } = capture(() => sync({}, dir))
959
+ expect(log).toMatch(/0 updated, \d+ created, 0 recreated/)
960
+ })
961
+
962
+ it('says recreated for a managed file the ledger recorded and someone deleted', () => {
963
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
964
+ write('src/app.tsx', 'export const App = () => null\n')
965
+ capture(() => init(dir))
966
+ const placed = '.claude/rules/basalt-tokens.md'
967
+ rmSync(join(dir, placed))
968
+ const { log } = capture(() => sync({}, dir))
969
+ expect(log).toContain('1 recreated')
970
+ })
971
+ })
972
+
973
+ // ── doctor's exit status, pinned per outcome ──────────────────────────────────────────────────
974
+ //
975
+ // argo reported two hard failures exiting 0. It does not reproduce (see the report), but the gap
976
+ // that let 1.20.0 ship a SKIPPED-exits-0 bug was that the tests asserted printed TEXT and never the
977
+ // status. These assert the status for every outcome, including the combinations.
978
+
979
+ describe('doctor — exit status per outcome, not per printed string', () => {
980
+ it('pass-only exits 0 and is the only state that may print "All checks passed"', () => {
981
+ healthyFixture()
982
+ const { code, log } = capture(() => doctor(dir))
983
+ expect([code, log.includes('All checks passed')]).toEqual([0, true])
984
+ })
985
+
986
+ it('warn-only exits 0 — a warning is a schedule, not a build break', () => {
987
+ healthyFixture()
988
+ write(
989
+ 'node_modules/basalt-ui/package.json',
990
+ JSON.stringify({ name: 'basalt-ui', version: '0.4.2' }),
991
+ )
992
+ const { code, log } = capture(() => doctor(dir))
993
+ expect(log).toContain('warning(s)')
994
+ expect(log).not.toContain('hard failure')
995
+ expect(log).not.toContain('All checks passed')
996
+ expect(code).toBe(0)
997
+ })
998
+
999
+ it('one hard failure exits 1', () => {
1000
+ healthyFixture()
1001
+ rmSync(join(dir, '.oxlintrc.json'))
1002
+ const { code, log } = capture(() => doctor(dir))
1003
+ expect(log).toContain('1 hard failure(s)')
1004
+ expect(code).toBe(1)
1005
+ })
1006
+
1007
+ it('TWO hard failures exit 1 — the shape argo reported as exiting 0', () => {
1008
+ // argo's exact pair: no manifest and no .oxlintrc.json, in a Mantine repo (so the profile
1009
+ // stays `framework` and both checks are real rather than n/a).
1010
+ healthyFixture()
1011
+ write(
1012
+ 'package.json',
1013
+ JSON.stringify({
1014
+ name: 'fixture',
1015
+ dependencies: { '@mantine/core': '^9.3.0' },
1016
+ basalt: { roots: ['src'] },
1017
+ }),
1018
+ )
1019
+ rmSync(join(dir, '.oxlintrc.json'))
1020
+ rmSync(join(dir, MANIFEST_PATH))
1021
+ const { code, log } = capture(() => doctor(dir))
1022
+ expect(log).toContain('2 hard failure(s)')
1023
+ expect(code).toBe(1)
1024
+ })
1025
+
1026
+ it('a SKIPPED check exits 1 on its own, with no failure and no warning', () => {
1027
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
1028
+ write('src/app.tsx', 'export const App = () => null\n')
1029
+ write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
1030
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
1031
+ const { code, log } = capture(() => doctor(dir))
1032
+ expect(log).toContain('SKIPPED')
1033
+ expect(code).toBe(1)
1034
+ })
1035
+
1036
+ it('an ambiguous project exits 1 before any check runs', () => {
1037
+ ambiguousWorkspace()
1038
+ expect(capture(() => doctor(dir)).code).toBe(1)
1039
+ })
1040
+
1041
+ it('contradictory profile flags exit 1', () => {
1042
+ healthyFixture()
1043
+ expect(capture(() => doctor(dir, ['--tokens-only', '--framework'])).code).toBe(1)
1044
+ })
1045
+ })
@@ -0,0 +1,211 @@
1
+ /**
2
+ * `check-theme --audit-allows` — the accountability half of the escape hatch.
3
+ *
4
+ * 1.20.0 shipped `theme-allow-unscoped`, which reports a waiver that names no rule. It cannot see
5
+ * the other failure: a perfectly well-written waiver whose finding no longer exists. Two consumers
6
+ * found five of those by hand — editing the token out and re-running the guard — and that manual
7
+ * method is what this command automates, which is why every assertion below is about a VERDICT
8
+ * derived from re-running the scan, never from parsing the annotation's text.
9
+ *
10
+ * Run: bun test packages/basalt-ui/src/cli/waiver-audit.test.ts
11
+ */
12
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ rmSync,
19
+ symlinkSync,
20
+ writeFileSync,
21
+ } from 'node:fs'
22
+ import { tmpdir } from 'node:os'
23
+ import { join, resolve } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
25
+
26
+ import { checkTheme } from './index.ts'
27
+
28
+ const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url))
29
+
30
+ let dir: string
31
+
32
+ function write(relPath: string, content: string): void {
33
+ const abs = join(dir, relPath)
34
+ mkdirSync(resolve(abs, '..'), { recursive: true })
35
+ writeFileSync(abs, content, 'utf8')
36
+ }
37
+
38
+ function audit(): { code: number; log: string } {
39
+ const originalLog = console.log
40
+ const originalError = console.error
41
+ let log = ''
42
+ const sink = (...args: unknown[]) => {
43
+ log += `${args.join(' ')}\n`
44
+ }
45
+ console.log = sink
46
+ console.error = sink
47
+ try {
48
+ return { code: checkTheme(dir, ['--audit-allows']), log }
49
+ } finally {
50
+ console.log = originalLog
51
+ console.error = originalError
52
+ }
53
+ }
54
+
55
+ /** A repo whose only source file is the one under test. */
56
+ function fixture(source: string, basalt: Record<string, unknown> = {}): void {
57
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'], ...basalt } }))
58
+ write('src/app.tsx', source)
59
+ }
60
+
61
+ beforeEach(() => {
62
+ dir = mkdtempSync(resolve(tmpdir(), 'basalt-audit-'))
63
+ })
64
+
65
+ afterEach(() => {
66
+ rmSync(dir, { recursive: true, force: true })
67
+ })
68
+
69
+ describe('check-theme --audit-allows — theme-allow annotations', () => {
70
+ it('names what a live waiver still suppresses, and exits 0', () => {
71
+ fixture("export const c = '#ff0000' // theme-allow raw-hex — a deliberate brand literal\n")
72
+ const { code, log } = audit()
73
+ expect(log).toContain('suppresses raw-hex@1')
74
+ expect(code).toBe(0)
75
+ })
76
+
77
+ it('calls a waiver over a line with no finding DEAD, and exits 1', () => {
78
+ fixture('export const c = 1 // theme-allow raw-hex — nothing here fires any more\n')
79
+ const { code, log } = audit()
80
+ expect(log).toContain('SUPPRESSES NOTHING')
81
+ expect(code).toBe(1)
82
+ })
83
+
84
+ it('does not report prose that merely mentions the token', () => {
85
+ // The guard stopped treating a mid-comment mention as an annotation for exactly this reason;
86
+ // an audit that listed them as dead waivers would send someone editing documentation.
87
+ fixture('// Waive it with a `theme-allow raw-hex — why` comment.\nexport const c = 1\n')
88
+ const { code, log } = audit()
89
+ expect(log).toContain('annotations (0)')
90
+ expect(code).toBe(0)
91
+ })
92
+
93
+ it('says oxlint could not be run rather than calling a plugin-rule waiver dead', () => {
94
+ // `hand-rolled-plot` lives in the plugin, so `checkSource` can never see what it suppresses.
95
+ // With no oxlint reachable from the fixture the honest answer is "cannot judge" — reporting
96
+ // "delete it" here would be the command telling someone to remove a live waiver.
97
+ fixture(
98
+ '// theme-allow hand-rolled-plot — DualPanel is not a single cartesian plot\nexport const c = 1\n',
99
+ )
100
+ const { code, log } = audit()
101
+ expect(log).toContain('oxlint could not be run here')
102
+ expect(log).not.toContain('SUPPRESSES NOTHING')
103
+ expect(code).toBe(0)
104
+ })
105
+
106
+ it('names the scope it audited, so "0 dead" is never read as "0 dead anywhere"', () => {
107
+ // The audit reads exactly what check-theme reads. A waiver in a file outside `basalt.roots` is
108
+ // invisible to it — not because it is fine, but because nothing scanned it.
109
+ fixture("export const c = '#ff0000' // theme-allow raw-hex — brand literal\n")
110
+ const { log } = audit()
111
+ expect(log).toContain('Scope: the 1 file(s) check-theme scans under basalt.roots (src)')
112
+ })
113
+
114
+ it('marks an unaccountable waiver while still reporting what it covers', () => {
115
+ fixture("export const c = '#ff0000' // theme-allow\n")
116
+ const { log } = audit()
117
+ expect(log).toContain('suppresses raw-hex@1')
118
+ expect(log).toContain('no rule id')
119
+ expect(log).toContain('1 unaccountable')
120
+ })
121
+ })
122
+
123
+ describe('check-theme --audit-allows — basalt.exemptRules', () => {
124
+ it('prints the reason from the object form, and says so when there is none', () => {
125
+ fixture("export const c = '#ff0000'\n", {
126
+ exemptRules: { 'raw-hex': { paths: ['src'], reason: 'a vendor asset basalt does not own' } },
127
+ })
128
+ const { log } = audit()
129
+ expect(log).toContain('a vendor asset basalt does not own')
130
+ expect(log).toContain('suppresses findings in src/app.tsx')
131
+
132
+ fixture("export const c = '#ff0000'\n", { exemptRules: { 'raw-hex': ['src'] } })
133
+ expect(audit().log).toContain('[no reason recorded]')
134
+ })
135
+
136
+ it('the object form really does exempt — the reason is accountability, not a second config', () => {
137
+ fixture("export const c = '#ff0000'\n", {
138
+ exemptRules: { 'raw-hex': { paths: ['src'], reason: 'a vendor asset basalt does not own' } },
139
+ })
140
+ expect(checkTheme(dir, [])).toBe(0)
141
+ })
142
+
143
+ it('reports a pattern that matches no scanned file as dead, and exits 1', () => {
144
+ // rollhook wrote a real, correct relative path, it matched nothing, and nothing said so — the
145
+ // finding kept failing the build with the config sitting right there looking like the answer.
146
+ fixture("export const c = '#ff0000'\n", { exemptRules: { 'raw-hex': ['does-not-exist'] } })
147
+ const { code, log } = audit()
148
+ expect(log).toContain('matches no scanned file')
149
+ expect(code).toBe(1)
150
+ })
151
+ })
152
+
153
+ // ── the oxlint half ───────────────────────────────────────────────────────────────────────────
154
+ //
155
+ // The `error`-severity design rules live in the oxlint plugin, not in `checkSource`. Until this,
156
+ // the audit declined to judge them — 8 of 8 annotations in argo, 11 of 14 in linewatch — so the
157
+ // exit-1 gate covered an empty set. These prove the method generalizes: neutralize the annotation,
158
+ // re-run oxlint over that one file, and the findings that appear are what it covers.
159
+
160
+ const REPO_ROOT = resolve(PKG_ROOT, '../..')
161
+ const OXLINT_BIN = resolve(REPO_ROOT, 'node_modules/.bin/oxlint')
162
+
163
+ /** Make oxlint reachable from the fixture and point it at the shipped preset (plugin included). */
164
+ function withOxlint(): void {
165
+ mkdirSync(join(dir, 'node_modules/.bin'), { recursive: true })
166
+ symlinkSync(OXLINT_BIN, join(dir, 'node_modules/.bin/oxlint'))
167
+ write('.oxlintrc.json', JSON.stringify({ extends: [resolve(PKG_ROOT, 'configs/oxlint.json')] }))
168
+ }
169
+
170
+ describe.skipIf(!existsSync(OXLINT_BIN))('check-theme --audit-allows — oxlint plugin rules', () => {
171
+ it('proves a plugin-rule waiver LIVE, naming the rule and the line it covers', () => {
172
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
173
+ write(
174
+ 'src/charts/plot.tsx',
175
+ "import { AxisLeftNumeric } from 'basalt-ui/charts'\n\n" +
176
+ '// theme-allow hand-rolled-plot — this pane is not a single cartesian plot\n' +
177
+ 'export const Plot = () => <AxisLeftNumeric />\n',
178
+ )
179
+ withOxlint()
180
+ const { code, log } = audit()
181
+ expect(log).toContain('suppresses hand-rolled-plot@4 (oxlint)')
182
+ expect(code).toBe(0)
183
+ })
184
+
185
+ it('calls a plugin-rule waiver over nothing DEAD, and exits 1 — the gate now covers them', () => {
186
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
187
+ write(
188
+ 'src/charts/plot.tsx',
189
+ '// theme-allow hand-rolled-plot — this pane is not a single cartesian plot\n' +
190
+ 'export const Plot = () => null\n',
191
+ )
192
+ withOxlint()
193
+ const { code, log } = audit()
194
+ expect(log).toContain('SUPPRESSES NOTHING')
195
+ expect(log).toContain('re-running oxlint')
196
+ expect(code).toBe(1)
197
+ })
198
+
199
+ it('leaves no probe file behind — the neutralized copy is a temp file basalt owns', () => {
200
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
201
+ write(
202
+ 'src/charts/plot.tsx',
203
+ "import { AxisLeftNumeric } from 'basalt-ui/charts'\n\n" +
204
+ '// theme-allow hand-rolled-plot — this pane is not a single cartesian plot\n' +
205
+ 'export const Plot = () => <AxisLeftNumeric />\n',
206
+ )
207
+ withOxlint()
208
+ audit()
209
+ expect(readdirSync(join(dir, 'src/charts'))).toEqual(['plot.tsx'])
210
+ })
211
+ })