basalt-ui 1.21.0 → 1.23.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 (85) hide show
  1. package/AGENTS.md +11 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +205 -9
  4. package/README.md +63 -16
  5. package/agent/rules/basalt-app.md +39 -9
  6. package/agent/rules/basalt-charts.md +41 -6
  7. package/agent/rules/basalt-router.md +1 -1
  8. package/agent/rules/basalt-state.md +4 -4
  9. package/agent/rules/basalt-tokens.md +25 -4
  10. package/agent/skills/basalt-app/SKILL.md +4 -3
  11. package/agent/skills/basalt-charts/SKILL.md +23 -8
  12. package/agent/templates/DESIGN.md.tpl +3 -1
  13. package/configs/oxlint-plugin.js +86 -9
  14. package/configs/oxlint-plugin.test.ts +173 -0
  15. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  16. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  17. package/dist/charts/hooks/useBandPlot.js +127 -0
  18. package/dist/charts/index.d.ts +4 -0
  19. package/dist/charts/index.d.ts.map +1 -1
  20. package/dist/charts/index.js +14 -0
  21. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  22. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  23. package/dist/charts/kinds/BandStrip.js +218 -0
  24. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/Bars.d.ts +5 -1
  27. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  28. package/dist/charts/kinds/Bars.js +2 -0
  29. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  31. package/dist/charts/kinds/MirroredBars.js +315 -0
  32. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  34. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  35. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  36. package/dist/charts/kinds/MultiLine.js +2 -0
  37. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  38. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  39. package/dist/charts/kinds/StackedArea.js +2 -0
  40. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  41. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  42. package/dist/charts/kinds/ZonedLine.js +2 -0
  43. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  44. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  45. package/dist/charts/primitives/CartesianChart.js +5 -2
  46. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  47. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  48. package/dist/charts/primitives/HatchPattern.js +31 -0
  49. package/dist/cli/index.d.ts +9 -1
  50. package/dist/cli/index.d.ts.map +1 -1
  51. package/dist/cli/index.js +275 -48
  52. package/dist/cli/shipped-versions.test.d.ts +2 -0
  53. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  54. package/dist/guard/index.d.ts +67 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +70 -11
  57. package/dist/vite.d.ts +44 -2
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +71 -26
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  65. package/src/charts/kinds/BandStrip.tsx +364 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  69. package/src/charts/kinds/MirroredBars.tsx +455 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/check-theme.test.ts +35 -0
  77. package/src/cli/help.test.ts +5 -1
  78. package/src/cli/index.ts +552 -79
  79. package/src/cli/shipped-versions.test.ts +83 -0
  80. package/src/cli/toolchain-wiring.test.ts +362 -6
  81. package/src/cli/waiver-audit.test.ts +88 -4
  82. package/src/guard/check-source.test.ts +293 -0
  83. package/src/guard/index.ts +209 -20
  84. package/src/vite.test.ts +112 -0
  85. package/src/vite.ts +147 -31
@@ -0,0 +1,83 @@
1
+ /**
2
+ * No shipped artifact may name a basalt version npm never served.
3
+ *
4
+ * Three rounds running, the newest `MIGRATING.md` heading named a phantom — `1.20.1` (shipped as
5
+ * 1.21.0), then `1.21.1` (shipped as 1.22.0) — because the docs pass runs BEFORE semantic-release
6
+ * computes the number, so whatever is written is a guess. That would be a docs problem alone if it
7
+ * stayed in docs. It does not: `sync` copies `agent/rules/**` and `agent/skills/**` verbatim into
8
+ * every consumer, so "Since 1.21.1" is currently shipped, as fact, into seven repos.
9
+ *
10
+ * This is the gate that ends it, and it is mechanical rather than editorial: every basalt-shaped
11
+ * version literal in a file a consumer receives must appear as a release heading in CHANGELOG.md —
12
+ * the one file `semantic-release` writes AFTER the number exists. A guessed version fails here
13
+ * before it can ship.
14
+ *
15
+ * Deliberately NOT a ban on version literals: `agent/rules/**` is release-history prose and
16
+ * "widened at 1.21.0, promoted at 1.22.0" is exactly the sentence a consumer needs. What it may
17
+ * not do is name a release that does not exist.
18
+ *
19
+ * The complementary half — no version stamped into a file nothing ever rewrites — lives in
20
+ * `toolchain-wiring.test.ts` ("DESIGN.md carries no version").
21
+ *
22
+ * Run: bun test packages/basalt-ui/src/cli/shipped-versions.test.ts
23
+ */
24
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
25
+ import { join, relative, resolve } from 'node:path'
26
+ import { fileURLToPath } from 'node:url'
27
+ import { describe, expect, it } from 'bun:test'
28
+
29
+ const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url))
30
+
31
+ /** Every version CHANGELOG.md records, plus the one package.json currently declares. */
32
+ const RELEASED = new Set<string>([
33
+ ...(
34
+ readFileSync(resolve(PKG_ROOT, 'CHANGELOG.md'), 'utf8').match(/^#+\s*\[?(\d+\.\d+\.\d+)/gm) ??
35
+ []
36
+ ).map((h) => h.replace(/^#+\s*\[?/, '')),
37
+ (JSON.parse(readFileSync(resolve(PKG_ROOT, 'package.json'), 'utf8')) as { version: string })
38
+ .version,
39
+ ])
40
+
41
+ /**
42
+ * basalt's own version line is 0.x / 1.x, so a literal with any other major belongs to a peer
43
+ * (visx 4.0.0, motion 12.42.0, the fontsource 5.2.x trio) and is none of this gate's business. A
44
+ * `^`/`~`/`@`-prefixed literal is a dependency RANGE for the same reason — `vite-plugin-pwa
45
+ * (^1.3.0)` shares a major with basalt and is still not a claim about basalt.
46
+ */
47
+ const BASALT_VERSION_LITERAL = /(^|[^-~^@\w.])([01]\.\d+\.\d+)(?![\w.])/g
48
+
49
+ function versionsIn(content: string): string[] {
50
+ return [...content.matchAll(BASALT_VERSION_LITERAL)].map((m) => m[2] as string)
51
+ }
52
+
53
+ /** Every file under `dir`, recursively. */
54
+ function filesUnder(dir: string): string[] {
55
+ return readdirSync(dir).flatMap((name) => {
56
+ const abs = join(dir, name)
57
+ return statSync(abs).isDirectory() ? filesUnder(abs) : [abs]
58
+ })
59
+ }
60
+
61
+ // Everything `init`/`sync` place in a consumer repo, plus the presets they `extends` into.
62
+ const SHIPPED = [
63
+ ...filesUnder(resolve(PKG_ROOT, 'agent')),
64
+ resolve(PKG_ROOT, 'configs/check.yml'),
65
+ resolve(PKG_ROOT, 'configs/lefthook.yml'),
66
+ ]
67
+
68
+ describe('shipped agent assets name only versions that exist', () => {
69
+ it.each(SHIPPED.map((abs) => [relative(PKG_ROOT, abs), abs] as const))(
70
+ '%s',
71
+ (_rel, abs: string) => {
72
+ const unreleased = [...new Set(versionsIn(readFileSync(abs, 'utf8')))].filter(
73
+ (v) => !RELEASED.has(v),
74
+ )
75
+ expect(unreleased).toEqual([])
76
+ },
77
+ )
78
+
79
+ it('reads a real changelog, so an empty RELEASED set can never make this vacuous', () => {
80
+ expect(RELEASED.size).toBeGreaterThan(20)
81
+ expect(RELEASED.has('1.21.1')).toBe(false)
82
+ })
83
+ })
@@ -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'
@@ -344,7 +344,7 @@ describe('check-theme — finds the config a root-invoked hook cannot see', () =
344
344
  it('reports ambiguity rather than guessing when two packages carry a config', () => {
345
345
  ambiguousWorkspace()
346
346
  const { code, log } = capture(() => checkTheme(dir))
347
- expect(log).toContain('2 workspace packages carry one')
347
+ expect(log).toContain('2 packages below it carry one')
348
348
  expect(log).toContain('BASALT_CWD')
349
349
  expect(code).toBe(1)
350
350
  })
@@ -363,7 +363,7 @@ describe('doctor — an ambiguous project is terminal, exactly as it is for chec
363
363
  it('short-circuits instead of running every check against the wrong root', () => {
364
364
  ambiguousWorkspace()
365
365
  const { code, log } = capture(() => doctor(dir))
366
- expect(log).toContain('2 workspace packages carry one')
366
+ expect(log).toContain('2 packages below it carry one')
367
367
  expect(log).toContain('BASALT_CWD')
368
368
  expect(code).toBe(1)
369
369
  // The bug this pins shut: falling back to the invocation cwd ran the remaining checks against
@@ -381,7 +381,7 @@ describe('doctor — an ambiguous project is terminal, exactly as it is for chec
381
381
  it('still runs normally once BASALT_CWD picks a package', () => {
382
382
  ambiguousWorkspace()
383
383
  const { code, log } = capture(() => doctor(join(dir, 'apps/a')))
384
- expect(log).not.toContain('workspace packages carry one')
384
+ expect(log).not.toContain('packages below it carry one')
385
385
  expect(code).toBe(1) // no install / no manifest there — but the checks RAN
386
386
  expect(log).toContain('guard-scan')
387
387
  })
@@ -773,12 +773,30 @@ describe('doctor — the lefthook extends target, the seam that fails SILENTLY',
773
773
  expect(log).toContain('extends the shipped lefthook preset')
774
774
  })
775
775
 
776
- it('warns (never fails) on a lefthook.yml that wires its own hooks instead', () => {
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.
777
795
  healthyFixture()
778
796
  write('lefthook.yml', 'pre-commit:\n commands:\n mine:\n run: echo hi\n')
779
797
  const { code, log } = capture(() => doctor(dir))
780
798
  expect(code).toBe(0)
781
- expect(log).toContain('does not extend the shipped lefthook preset')
799
+ expect(log).toContain('cannot tell a missing gate from one wired through an `include:`')
782
800
  })
783
801
 
784
802
  it('is not applicable when the repo runs no lefthook at all', () => {
@@ -810,6 +828,7 @@ describe('sync — basalt.roots is drift, and sync is the drift command', () =>
810
828
  basalt: { roots: ['packages/web/src'] },
811
829
  }),
812
830
  )
831
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
813
832
  write(
814
833
  'packages/web/package.json',
815
834
  JSON.stringify({ name: 'web', dependencies: { 'basalt-ui': '*' } }),
@@ -828,6 +847,7 @@ describe('sync — basalt.roots is drift, and sync is the drift command', () =>
828
847
 
829
848
  it('--check writes nothing, but still says the key is missing', () => {
830
849
  write('package.json', JSON.stringify({ name: 'fixture' }))
850
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
831
851
  write('src/app.tsx', 'export const App = () => null\n')
832
852
  const { log } = capture(() => sync({ check: true }, dir))
833
853
  expect(readPkg()['basalt']).toBeUndefined()
@@ -836,6 +856,7 @@ describe('sync — basalt.roots is drift, and sync is the drift command', () =>
836
856
 
837
857
  it('reports a lefthook extends target that no longer resolves', () => {
838
858
  write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
859
+ write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {} }))
839
860
  write('src/app.tsx', 'export const App = () => null\n')
840
861
  write('lefthook.yml', 'extends:\n - node_modules/basalt-ui/configs/lefthook.yml\n')
841
862
  const { log } = capture(() => sync({}, dir))
@@ -854,3 +875,338 @@ describe('init — the seeded lefthook.yml pins the guard to the local bin', ()
854
875
  expect(seeded).toContain('extends:')
855
876
  })
856
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 packages below it 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
+ })
1046
+
1047
+ // ── the project resolver, for a repo that declares no workspaces ───────────────────────────────
1048
+ //
1049
+ // linewatch keeps its whole basalt consumer in `web/` and declares no `workspaces` field anywhere.
1050
+ // Measured at its repo root, all three project-scoped commands got that wrong in a different way:
1051
+ // `check-theme` printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred
1052
+ // `tokens-only` for a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as
1053
+ // the remedy — the one command that WOULD have written a competing install at the root. The
1054
+ // workspace-declaration path already handled image-share's `apps/admin`; nothing handled this.
1055
+
1056
+ /** linewatch's shape: no `workspaces` field, the whole consumer one directory down. */
1057
+ function undeclaredChild(child = 'web'): void {
1058
+ write('package.json', JSON.stringify({ name: 'root' }))
1059
+ write(`${child}/package.json`, JSON.stringify({ name: child, basalt: { roots: ['src'] } }))
1060
+ write(`${child}/src/app.tsx`, "export const c = '#ff0000'\n")
1061
+ write(join(child, MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
1062
+ }
1063
+
1064
+ describe('resolveProjectDir — a repo that declares no workspaces still has a layout', () => {
1065
+ it('sync relocates into the undeclared child instead of refusing with init as the advice', () => {
1066
+ undeclaredChild()
1067
+ const { log } = capture(() => sync({ check: true }, dir))
1068
+ expect(log).toContain('running in ./web, where it lives')
1069
+ expect(log).not.toContain('basalt-ui init')
1070
+ })
1071
+
1072
+ it('sync writes NOTHING at the root it was invoked from', () => {
1073
+ undeclaredChild()
1074
+ capture(() => sync({}, dir))
1075
+ expect(readdirSync(dir).toSorted()).toEqual(['package.json', 'web'])
1076
+ expect(readPkg()['basalt']).toBeUndefined()
1077
+ })
1078
+
1079
+ it('check-theme scans the child rather than reporting green over zero files', () => {
1080
+ undeclaredChild()
1081
+ const { code, log } = capture(() => checkTheme(dir, []))
1082
+ expect(log).toContain('running in ./web, where it lives')
1083
+ expect(log).not.toContain('no off-palette colors')
1084
+ expect(code).toBe(1)
1085
+ })
1086
+
1087
+ it('doctor reports on the child, so it cannot infer tokens-only from the empty root', () => {
1088
+ undeclaredChild()
1089
+ const { log } = capture(() => doctor(dir, []))
1090
+ expect(log).toContain(join(dir, 'web'))
1091
+ expect(log).not.toContain('profile: tokens-only')
1092
+ })
1093
+
1094
+ it('two undeclared children are ambiguous, named, and never guessed between', () => {
1095
+ undeclaredChild('web')
1096
+ undeclaredChild('admin')
1097
+ const { code, log } = capture(() => sync({}, dir))
1098
+ expect(code).toBe(1)
1099
+ expect(log).toContain('2 packages below it carry one')
1100
+ expect(log).toContain('./admin')
1101
+ expect(log).toContain('./web')
1102
+ expect(readdirSync(dir)).not.toContain('DESIGN.md')
1103
+ })
1104
+
1105
+ it('a declared workspaces field still wins — the layout scan is a fallback, not a union', () => {
1106
+ // `stray/` carries a basalt key but is not a workspace package. Treating both as candidates
1107
+ // would turn a working single-candidate repo into an ambiguity error on upgrade.
1108
+ write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
1109
+ write('apps/web/package.json', JSON.stringify({ name: 'web', basalt: { roots: ['src'] } }))
1110
+ write('apps/web/src/app.tsx', 'export const App = () => null\n')
1111
+ write(join('apps/web', MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
1112
+ write('stray/package.json', JSON.stringify({ name: 'stray', basalt: { roots: ['src'] } }))
1113
+ const { log } = capture(() => sync({ check: true }, dir))
1114
+ expect(log).toContain('running in ./apps/web, where it lives')
1115
+ })
1116
+ })
1117
+
1118
+ // ── sync honours the profile doctor already reads ──────────────────────────────────────────────
1119
+ //
1120
+ // rollhook is tokens-only in both apps and declares it. `sync` told it to run `basalt-ui init` —
1121
+ // the exact advice `doctor`, in the same directory at the same version, exists to prevent — and
1122
+ // exited 1, which also put `sync --check` out of reach of the one CI drift gate every other
1123
+ // consumer runs.
1124
+
1125
+ describe('sync — tokens-only is a pass, not a refusal', () => {
1126
+ function tokensOnly(): void {
1127
+ write(
1128
+ 'package.json',
1129
+ JSON.stringify({ name: 'marketing', basalt: { roots: ['src'], profile: 'tokens-only' } }),
1130
+ )
1131
+ write('src/app.ts', 'export const c = 1\n')
1132
+ }
1133
+
1134
+ it('reports n/a and exits 0, mirroring doctor’s manifest row', () => {
1135
+ tokensOnly()
1136
+ const { code, log } = capture(() => sync({}, dir))
1137
+ expect(code).toBe(0)
1138
+ expect(log).toContain('n/a — a tokens-only consumer has no scaffold to reconcile')
1139
+ expect(log).not.toContain('basalt-ui init')
1140
+ })
1141
+
1142
+ it('is wirable into CI: --check exits 0 instead of failing on a scaffold it must not have', () => {
1143
+ tokensOnly()
1144
+ expect(capture(() => sync({ check: true, flags: [] }, dir)).code).toBe(0)
1145
+ })
1146
+
1147
+ it('writes nothing — no manifest, no DESIGN.md, no basalt.roots backfill', () => {
1148
+ tokensOnly()
1149
+ capture(() => sync({}, dir))
1150
+ expect(readdirSync(dir).toSorted()).toEqual(['package.json', 'src'])
1151
+ })
1152
+
1153
+ it('--framework forces the full profile back on', () => {
1154
+ tokensOnly()
1155
+ const { code, log } = capture(() => sync({ flags: ['--framework'] }, dir))
1156
+ expect(code).toBe(1)
1157
+ expect(log).toContain('refusing to scaffold')
1158
+ })
1159
+
1160
+ it('contradictory profile flags exit 1, exactly as they do for check-theme and doctor', () => {
1161
+ tokensOnly()
1162
+ expect(capture(() => sync({ flags: ['--tokens-only', '--framework'] }, dir)).code).toBe(1)
1163
+ })
1164
+ })
1165
+
1166
+ // ── no shipped artifact carries a version number that goes stale ───────────────────────────────
1167
+ //
1168
+ // `DESIGN.md` is a seed: written once at init, then consumer-owned and never reconciled. Its
1169
+ // opener stamped `{{BASALT_VERSION}}`, so across the seven consumers the same line read 1.0.0,
1170
+ // 1.9.0, 1.21.0 and 1.22.0 under an identical 1.22.0 install — reported three rounds running as
1171
+ // four separate doc bugs. It was one bug: a version number in a file nothing ever rewrites.
1172
+
1173
+ describe('DESIGN.md carries no version, and sync heals the ones already written', () => {
1174
+ function seeded(): void {
1175
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
1176
+ write('src/app.tsx', 'export const App = () => null\n')
1177
+ capture(() => init(dir))
1178
+ }
1179
+
1180
+ it('a fresh scaffold stamps no version at all', () => {
1181
+ seeded()
1182
+ expect(read('DESIGN.md')).not.toMatch(/Managed by basalt-ui \(\d+\.\d+\.\d+\)/)
1183
+ expect(read('DESIGN.md')).toContain('run `basalt-ui doctor` for the version')
1184
+ })
1185
+
1186
+ it('sync rewrites a stale opener in place and leaves the rest of the file alone', () => {
1187
+ seeded()
1188
+ const consumerOwned = '\n## Series\n\nOurs: hrv, rhr.\n'
1189
+ write('DESIGN.md', `# app — Design\n\n> Managed by basalt-ui (1.9.0). Thin.\n${consumerOwned}`)
1190
+ capture(() => sync({}, dir))
1191
+ expect(read('DESIGN.md')).toContain(
1192
+ '> Managed by basalt-ui — run `basalt-ui doctor` for the version. Thin.',
1193
+ )
1194
+ expect(read('DESIGN.md')).toContain(consumerOwned)
1195
+ })
1196
+
1197
+ it('--check writes nothing, including the heal', () => {
1198
+ seeded()
1199
+ const stale = '# app — Design\n\n> Managed by basalt-ui (1.9.0). Thin.\n'
1200
+ write('DESIGN.md', stale)
1201
+ capture(() => sync({ check: true }, dir))
1202
+ expect(read('DESIGN.md')).toBe(stale)
1203
+ })
1204
+
1205
+ it('leaves an opener the consumer already rewrote by hand untouched', () => {
1206
+ seeded()
1207
+ const owned = '# app — Design\n\n> Our own words entirely.\n'
1208
+ write('DESIGN.md', owned)
1209
+ capture(() => sync({}, dir))
1210
+ expect(read('DESIGN.md')).toBe(owned)
1211
+ })
1212
+ })
@@ -10,12 +10,23 @@
10
10
  * Run: bun test packages/basalt-ui/src/cli/waiver-audit.test.ts
11
11
  */
12
12
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
13
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ rmSync,
19
+ symlinkSync,
20
+ writeFileSync,
21
+ } from 'node:fs'
14
22
  import { tmpdir } from 'node:os'
15
23
  import { join, resolve } from 'node:path'
24
+ import { fileURLToPath } from 'node:url'
16
25
 
17
26
  import { checkTheme } from './index.ts'
18
27
 
28
+ const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url))
29
+
19
30
  let dir: string
20
31
 
21
32
  function write(relPath: string, content: string): void {
@@ -79,18 +90,31 @@ describe('check-theme --audit-allows — theme-allow annotations', () => {
79
90
  expect(code).toBe(0)
80
91
  })
81
92
 
82
- it('refuses to judge a waiver scoped to an oxlint plugin rule instead of calling it dead', () => {
93
+ it('says oxlint could not be run rather than calling a plugin-rule waiver dead', () => {
83
94
  // `hand-rolled-plot` lives in the plugin, so `checkSource` can never see what it suppresses.
84
- // Reporting "delete it" here would be the command telling someone to remove a live waiver.
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.
85
97
  fixture(
86
98
  '// theme-allow hand-rolled-plot — DualPanel is not a single cartesian plot\nexport const c = 1\n',
87
99
  )
88
100
  const { code, log } = audit()
89
- expect(log).toContain('not a check-theme kind')
101
+ expect(log).toContain('oxlint could not be run here')
90
102
  expect(log).not.toContain('SUPPRESSES NOTHING')
91
103
  expect(code).toBe(0)
92
104
  })
93
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, and that is WIDER than `basalt.roots` — two
108
+ // consumers reported a live `public/site.webmanifest` waiver under a scope line claiming
109
+ // files outside roots are not audited. The line has to name every class the scan reaches, or
110
+ // it is a second false statement in the sentence that exists to prevent one.
111
+ fixture("export const c = '#ff0000' // theme-allow raw-hex — brand literal\n")
112
+ const { log } = audit()
113
+ expect(log).toContain('Scope: the 1 file(s) check-theme scans — everything under basalt.roots')
114
+ expect(log).toContain('sibling index.html and public/ tree')
115
+ expect(log).toContain('basalt.include')
116
+ })
117
+
94
118
  it('marks an unaccountable waiver while still reporting what it covers', () => {
95
119
  fixture("export const c = '#ff0000' // theme-allow\n")
96
120
  const { log } = audit()
@@ -129,3 +153,63 @@ describe('check-theme --audit-allows — basalt.exemptRules', () => {
129
153
  expect(code).toBe(1)
130
154
  })
131
155
  })
156
+
157
+ // ── the oxlint half ───────────────────────────────────────────────────────────────────────────
158
+ //
159
+ // The `error`-severity design rules live in the oxlint plugin, not in `checkSource`. Until this,
160
+ // the audit declined to judge them — 8 of 8 annotations in argo, 11 of 14 in linewatch — so the
161
+ // exit-1 gate covered an empty set. These prove the method generalizes: neutralize the annotation,
162
+ // re-run oxlint over that one file, and the findings that appear are what it covers.
163
+
164
+ const REPO_ROOT = resolve(PKG_ROOT, '../..')
165
+ const OXLINT_BIN = resolve(REPO_ROOT, 'node_modules/.bin/oxlint')
166
+
167
+ /** Make oxlint reachable from the fixture and point it at the shipped preset (plugin included). */
168
+ function withOxlint(): void {
169
+ mkdirSync(join(dir, 'node_modules/.bin'), { recursive: true })
170
+ symlinkSync(OXLINT_BIN, join(dir, 'node_modules/.bin/oxlint'))
171
+ write('.oxlintrc.json', JSON.stringify({ extends: [resolve(PKG_ROOT, 'configs/oxlint.json')] }))
172
+ }
173
+
174
+ describe.skipIf(!existsSync(OXLINT_BIN))('check-theme --audit-allows — oxlint plugin rules', () => {
175
+ it('proves a plugin-rule waiver LIVE, naming the rule and the line it covers', () => {
176
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
177
+ write(
178
+ 'src/charts/plot.tsx',
179
+ "import { AxisLeftNumeric } from 'basalt-ui/charts'\n\n" +
180
+ '// theme-allow hand-rolled-plot — this pane is not a single cartesian plot\n' +
181
+ 'export const Plot = () => <AxisLeftNumeric />\n',
182
+ )
183
+ withOxlint()
184
+ const { code, log } = audit()
185
+ expect(log).toContain('suppresses hand-rolled-plot@4 (oxlint)')
186
+ expect(code).toBe(0)
187
+ })
188
+
189
+ it('calls a plugin-rule waiver over nothing DEAD, and exits 1 — the gate now covers them', () => {
190
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
191
+ write(
192
+ 'src/charts/plot.tsx',
193
+ '// theme-allow hand-rolled-plot — this pane is not a single cartesian plot\n' +
194
+ 'export const Plot = () => null\n',
195
+ )
196
+ withOxlint()
197
+ const { code, log } = audit()
198
+ expect(log).toContain('SUPPRESSES NOTHING')
199
+ expect(log).toContain('re-running oxlint')
200
+ expect(code).toBe(1)
201
+ })
202
+
203
+ it('leaves no probe file behind — the neutralized copy is a temp file basalt owns', () => {
204
+ write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
205
+ write(
206
+ 'src/charts/plot.tsx',
207
+ "import { AxisLeftNumeric } from 'basalt-ui/charts'\n\n" +
208
+ '// theme-allow hand-rolled-plot — this pane is not a single cartesian plot\n' +
209
+ 'export const Plot = () => <AxisLeftNumeric />\n',
210
+ )
211
+ withOxlint()
212
+ audit()
213
+ expect(readdirSync(join(dir, 'src/charts'))).toEqual(['plot.tsx'])
214
+ })
215
+ })