basalt-ui 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +14 -0
- package/MIGRATING.md +93 -10
- package/README.md +20 -7
- package/agent/rules/basalt-app.md +15 -9
- package/agent/rules/basalt-charts.md +2 -2
- package/agent/rules/basalt-router.md +1 -1
- package/agent/rules/basalt-state.md +4 -4
- package/agent/rules/basalt-tokens.md +9 -4
- package/agent/skills/basalt-app/SKILL.md +4 -3
- package/agent/skills/basalt-charts/SKILL.md +1 -1
- package/configs/oxlint-plugin.js +37 -1
- package/configs/oxlint-plugin.test.ts +116 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +230 -42
- package/dist/guard/index.d.ts +67 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +40 -2
- package/dist/vite.d.ts +4 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +21 -13
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +434 -68
- package/src/cli/toolchain-wiring.test.ts +192 -3
- package/src/cli/waiver-audit.test.ts +84 -4
- package/src/guard/check-source.test.ts +197 -0
- package/src/guard/index.ts +129 -6
- package/src/vite.test.ts +33 -0
- package/src/vite.ts +28 -14
|
@@ -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'
|
|
@@ -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('
|
|
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('
|
|
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,171 @@ 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 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
|
+
})
|
|
@@ -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 {
|
|
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,27 @@ describe('check-theme --audit-allows — theme-allow annotations', () => {
|
|
|
79
90
|
expect(code).toBe(0)
|
|
80
91
|
})
|
|
81
92
|
|
|
82
|
-
it('
|
|
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
|
-
//
|
|
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
|
|
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. 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
|
+
|
|
94
114
|
it('marks an unaccountable waiver while still reporting what it covers', () => {
|
|
95
115
|
fixture("export const c = '#ff0000' // theme-allow\n")
|
|
96
116
|
const { log } = audit()
|
|
@@ -129,3 +149,63 @@ describe('check-theme --audit-allows — basalt.exemptRules', () => {
|
|
|
129
149
|
expect(code).toBe(1)
|
|
130
150
|
})
|
|
131
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
|
+
})
|
|
@@ -13,10 +13,14 @@ import { SPACE_SCALE } from '../tokens/palette'
|
|
|
13
13
|
import {
|
|
14
14
|
checkSource,
|
|
15
15
|
DEFAULT_GUARD_CONFIG,
|
|
16
|
+
findAllowAnnotations,
|
|
16
17
|
GENERATED_HEADER_LINE,
|
|
17
18
|
guardKindRemedy,
|
|
18
19
|
guardWaiverHint,
|
|
19
20
|
GUARD_RULES,
|
|
21
|
+
neutralizeAllowAnnotation,
|
|
22
|
+
NEUTRALIZED_ALLOW_TOKEN,
|
|
23
|
+
PLUGIN_RULE_IDS,
|
|
20
24
|
TOKENS_ONLY_DISABLED_KINDS,
|
|
21
25
|
unmatchedExemptPatterns,
|
|
22
26
|
} from './index'
|
|
@@ -2733,3 +2737,196 @@ describe('inline-spacing — the style-object test', () => {
|
|
|
2733
2737
|
expect(kinds(f)).toContain('inline-spacing')
|
|
2734
2738
|
})
|
|
2735
2739
|
})
|
|
2740
|
+
|
|
2741
|
+
// ── theme-allow comment shapes ───────────────────────────────────────────────────────────────────
|
|
2742
|
+
|
|
2743
|
+
/**
|
|
2744
|
+
* The prefix contract, pinned per comment shape.
|
|
2745
|
+
*
|
|
2746
|
+
* Three holes have been found in this rule in three rounds — two false positives and one false
|
|
2747
|
+
* NEGATIVE (a docblock documenting a file's waivers disarmed the file). So every shape a consumer
|
|
2748
|
+
* actually writes gets a fixture, and the oxlint plugin's copy is pinned identically in
|
|
2749
|
+
* `configs/oxlint-plugin.test.ts`: the two halves of one contract must agree on what an annotation
|
|
2750
|
+
* IS, and may differ only on which rules each can judge.
|
|
2751
|
+
*/
|
|
2752
|
+
describe('theme-allow comment shapes', () => {
|
|
2753
|
+
const REASON = 'deliberate legacy value'
|
|
2754
|
+
const waives = (relPath: string, src: string): boolean =>
|
|
2755
|
+
checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
|
|
2756
|
+
|
|
2757
|
+
it.each([
|
|
2758
|
+
['own line //', PATH, `// theme-allow raw-hex — ${REASON}\nconst a = '#f00'\n`],
|
|
2759
|
+
[
|
|
2760
|
+
'indented //',
|
|
2761
|
+
PATH,
|
|
2762
|
+
`function f() {\n // theme-allow raw-hex — ${REASON}\n const a = '#f00'\n}\n`,
|
|
2763
|
+
],
|
|
2764
|
+
['trailing //', PATH, `const a = '#f00' // theme-allow raw-hex — ${REASON}\n`],
|
|
2765
|
+
['trailing block', PATH, `const a = '#f00' /* theme-allow raw-hex — ${REASON} */\n`],
|
|
2766
|
+
[
|
|
2767
|
+
'JSX expression comment',
|
|
2768
|
+
PATH,
|
|
2769
|
+
`export const C = () => (\n <div>\n {/* theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
|
|
2770
|
+
],
|
|
2771
|
+
[
|
|
2772
|
+
'JSX expression comment, token on its own wrapped line',
|
|
2773
|
+
PATH,
|
|
2774
|
+
`export const C = () => (\n <div>\n {/*\n theme-allow raw-hex — ${REASON} */}\n <span style={{ color: '#f00' }} />\n </div>\n)\n`,
|
|
2775
|
+
],
|
|
2776
|
+
['block gutter *', PATH, `/**\n * theme-allow raw-hex — ${REASON}\n */\nconst a = '#f00'\n`],
|
|
2777
|
+
['docblock opener /**', PATH, `/** theme-allow raw-hex — ${REASON} */\nconst a = '#f00'\n`],
|
|
2778
|
+
['css trailing', 'src/a.css', `a { color: #f00; /* theme-allow raw-hex — ${REASON} */ }\n`],
|
|
2779
|
+
[
|
|
2780
|
+
'css reflowed onto continuation lines',
|
|
2781
|
+
'src/a.css',
|
|
2782
|
+
`a {\n color: var(\n --x,\n #f00\n ); /* theme-allow raw-hex — ${REASON} */\n}\n`,
|
|
2783
|
+
],
|
|
2784
|
+
[
|
|
2785
|
+
'html comment',
|
|
2786
|
+
'index.html',
|
|
2787
|
+
`<!-- theme-allow raw-hex — ${REASON} -->\n<b style="color: #f00"></b>\n`,
|
|
2788
|
+
],
|
|
2789
|
+
[
|
|
2790
|
+
'html trailing comment',
|
|
2791
|
+
'index.html',
|
|
2792
|
+
`<b style="color: #f00"></b> <!-- theme-allow raw-hex — ${REASON} -->\n`,
|
|
2793
|
+
],
|
|
2794
|
+
[
|
|
2795
|
+
'JSON member form',
|
|
2796
|
+
'public/site.webmanifest',
|
|
2797
|
+
`{\n "basalt:theme-allow-file": "raw-hex — ${REASON}",\n "theme_color": "#f00"\n}\n`,
|
|
2798
|
+
],
|
|
2799
|
+
])('%s waives', (_name, relPath, src) => {
|
|
2800
|
+
expect(waives(relPath as string, src as string)).toBe(true)
|
|
2801
|
+
})
|
|
2802
|
+
|
|
2803
|
+
// The false negative the prefix rule closed: a sentence that MENTIONS the token is not one.
|
|
2804
|
+
it.each([
|
|
2805
|
+
[
|
|
2806
|
+
'mid-sentence in a line comment',
|
|
2807
|
+
`// we normally write a theme-allow raw-hex here\nconst a = '#f00'\n`,
|
|
2808
|
+
],
|
|
2809
|
+
[
|
|
2810
|
+
'mid-sentence in a docblock',
|
|
2811
|
+
`/**\n * Each value below is escaped with a theme-allow raw-hex annotation.\n */\nconst a = '#f00'\n`,
|
|
2812
|
+
],
|
|
2813
|
+
['inside a string literal', `const doc = 'theme-allow raw-hex'\nconst a = '#f00'\n`],
|
|
2814
|
+
])('%s does NOT waive', (_name, src) => {
|
|
2815
|
+
expect(waives(PATH, src as string)).toBe(false)
|
|
2816
|
+
})
|
|
2817
|
+
|
|
2818
|
+
// The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
|
|
2819
|
+
// classification, so the annotation does not reach the statement below it.
|
|
2820
|
+
it('a trailing annotation on a real closing brace stays on its own line', () => {
|
|
2821
|
+
const src = `function f() {\n const a = 1\n} // theme-allow raw-hex — ${REASON}\nconst b = '#0f0'\n`
|
|
2822
|
+
expect(
|
|
2823
|
+
checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
|
|
2824
|
+
.filter((f) => f.kind === 'raw-hex')
|
|
2825
|
+
.map((f) => f.line),
|
|
2826
|
+
).toEqual([4])
|
|
2827
|
+
})
|
|
2828
|
+
|
|
2829
|
+
// A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
|
|
2830
|
+
// would silently waive the statement below it too.
|
|
2831
|
+
it('a trailing annotation does not reach the line below', () => {
|
|
2832
|
+
const src = `const a = '#f00' // theme-allow raw-hex — ${REASON}\nconst b = '#0f0'\n`
|
|
2833
|
+
expect(
|
|
2834
|
+
checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
|
|
2835
|
+
).toHaveLength(1)
|
|
2836
|
+
})
|
|
2837
|
+
})
|
|
2838
|
+
|
|
2839
|
+
// ── findAllowAnnotations / neutralizeAllowAnnotation ─────────────────────────────────────────────
|
|
2840
|
+
|
|
2841
|
+
describe('findAllowAnnotations', () => {
|
|
2842
|
+
const cfg = DEFAULT_GUARD_CONFIG
|
|
2843
|
+
|
|
2844
|
+
it('classifies each named id by whose reach it falls in', () => {
|
|
2845
|
+
const src = [
|
|
2846
|
+
`// theme-allow raw-hex — a guard kind`,
|
|
2847
|
+
`const a = '#f00'`,
|
|
2848
|
+
`// theme-allow hand-rolled-plot — a plugin rule, outside checkSource's reach`,
|
|
2849
|
+
`const b = 1`,
|
|
2850
|
+
`// theme-allow raw-hexx — a typo`,
|
|
2851
|
+
`const c = 2`,
|
|
2852
|
+
`// theme-allow`,
|
|
2853
|
+
`const d = 3`,
|
|
2854
|
+
].join('\n')
|
|
2855
|
+
const sites = findAllowAnnotations(src, PATH, cfg)
|
|
2856
|
+
|
|
2857
|
+
expect(sites.map((s) => s.line)).toEqual([1, 3, 5, 7])
|
|
2858
|
+
expect(sites[0]).toMatchObject({ guardKinds: ['raw-hex'], pluginRules: [], bare: false })
|
|
2859
|
+
expect(sites[1]).toMatchObject({ guardKinds: [], pluginRules: ['hand-rolled-plot'] })
|
|
2860
|
+
expect(sites[2]).toMatchObject({ rules: [], unknownRules: ['raw-hexx'], bare: false })
|
|
2861
|
+
expect(sites[3]).toMatchObject({ bare: true, hasReason: false })
|
|
2862
|
+
})
|
|
2863
|
+
|
|
2864
|
+
it('records the file-declaration form once, at the line it is written on', () => {
|
|
2865
|
+
const src = `// theme-allow-file raw-hex — the whole palette fixture\nconst a = '#f00'\n`
|
|
2866
|
+
expect(findAllowAnnotations(src, PATH, cfg)).toMatchObject([
|
|
2867
|
+
{ line: 1, scope: 'file', guardKinds: ['raw-hex'] },
|
|
2868
|
+
])
|
|
2869
|
+
})
|
|
2870
|
+
|
|
2871
|
+
it('carries the source line verbatim, and finds nothing in a file with no annotation', () => {
|
|
2872
|
+
expect(findAllowAnnotations(`const a = '#f00'\n`, PATH, cfg)).toEqual([])
|
|
2873
|
+
expect(
|
|
2874
|
+
findAllowAnnotations(` // theme-allow raw-hex — why not\nconst a = 1\n`, PATH, cfg)[0]?.text,
|
|
2875
|
+
).toBe(' // theme-allow raw-hex — why not')
|
|
2876
|
+
})
|
|
2877
|
+
|
|
2878
|
+
// It shares `collectAllowAnnotations` with `checkSource`, so it cannot list a line the scan does
|
|
2879
|
+
// not honour — the mirrored regex `--audit-allows` carried was already one alternation behind.
|
|
2880
|
+
it('agrees with checkSource about what is prose and what is an annotation', () => {
|
|
2881
|
+
expect(
|
|
2882
|
+
findAllowAnnotations(`// a theme-allow raw-hex goes here\nconst a = 1\n`, PATH, cfg),
|
|
2883
|
+
).toEqual([])
|
|
2884
|
+
expect(
|
|
2885
|
+
findAllowAnnotations(`/** theme-allow raw-hex — deliberate */\nconst a = 1\n`, PATH, cfg),
|
|
2886
|
+
).toHaveLength(1)
|
|
2887
|
+
})
|
|
2888
|
+
|
|
2889
|
+
it('finds the JSON member form in a manifest', () => {
|
|
2890
|
+
const src = `{\n "basalt:theme-allow-file": "raw-hex — brand color"\n}\n`
|
|
2891
|
+
expect(findAllowAnnotations(src, 'public/site.webmanifest', cfg)).toMatchObject([
|
|
2892
|
+
{ line: 2, scope: 'file', guardKinds: ['raw-hex'] },
|
|
2893
|
+
])
|
|
2894
|
+
})
|
|
2895
|
+
})
|
|
2896
|
+
|
|
2897
|
+
describe('neutralizeAllowAnnotation', () => {
|
|
2898
|
+
const cfg = DEFAULT_GUARD_CONFIG
|
|
2899
|
+
|
|
2900
|
+
it('neutralizes ONE annotation and reveals exactly what it suppressed', () => {
|
|
2901
|
+
const src = [
|
|
2902
|
+
`const a = '#f00' // theme-allow raw-hex — first`,
|
|
2903
|
+
`const b = '#0f0' // theme-allow raw-hex — second`,
|
|
2904
|
+
].join('\n')
|
|
2905
|
+
expect(checkSource(src, PATH, cfg).filter((f) => f.kind === 'raw-hex')).toEqual([])
|
|
2906
|
+
|
|
2907
|
+
const probe = neutralizeAllowAnnotation(src, 1, cfg)
|
|
2908
|
+
expect(probe).toContain(NEUTRALIZED_ALLOW_TOKEN)
|
|
2909
|
+
expect(
|
|
2910
|
+
checkSource(probe, PATH, cfg)
|
|
2911
|
+
.filter((f) => f.kind === 'raw-hex')
|
|
2912
|
+
.map((f) => f.line),
|
|
2913
|
+
).toEqual([1])
|
|
2914
|
+
})
|
|
2915
|
+
|
|
2916
|
+
it('leaves a line with no token, and an out-of-range line, untouched', () => {
|
|
2917
|
+
const src = `const a = 1\nconst b = 2\n`
|
|
2918
|
+
expect(neutralizeAllowAnnotation(src, 1, cfg)).toBe(src)
|
|
2919
|
+
expect(neutralizeAllowAnnotation(src, 99, cfg)).toBe(src)
|
|
2920
|
+
})
|
|
2921
|
+
|
|
2922
|
+
it('the neutralized token shares no substring with the annotation it replaces', () => {
|
|
2923
|
+
expect(NEUTRALIZED_ALLOW_TOKEN).not.toContain(cfg.allowComment)
|
|
2924
|
+
expect(NEUTRALIZED_ALLOW_TOKEN.includes('theme-allow')).toBe(false)
|
|
2925
|
+
})
|
|
2926
|
+
})
|
|
2927
|
+
|
|
2928
|
+
describe('PLUGIN_RULE_IDS', () => {
|
|
2929
|
+
it('names no guard kind — the two registries are disjoint by construction', () => {
|
|
2930
|
+
for (const id of PLUGIN_RULE_IDS) expect(Object.hasOwn(GUARD_RULES, id)).toBe(false)
|
|
2931
|
+
})
|
|
2932
|
+
})
|