basalt-ui 1.23.1 → 1.24.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 +11 -6
- package/CHANGELOG.md +14 -0
- package/MIGRATING.md +138 -4
- package/README.md +123 -32
- package/agent/rules/basalt-data.md +45 -13
- package/agent/rules/basalt-query.md +57 -0
- package/agent/skills/basalt-app/SKILL.md +7 -0
- package/dist/cli/index.d.ts +4 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +101 -17
- package/dist/dashboard/empty-state.d.ts +6 -2
- package/dist/dashboard/empty-state.d.ts.map +1 -1
- package/dist/dashboard/empty-state.js +1 -1
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +8 -0
- package/dist/dashboard/query-state.d.ts +96 -0
- package/dist/dashboard/query-state.d.ts.map +1 -0
- package/dist/dashboard/query-state.js +123 -0
- package/dist/dashboard/query-state.test.d.ts +2 -0
- package/dist/dashboard/query-state.test.d.ts.map +1 -0
- package/dist/data/data-table.d.ts +61 -4
- package/dist/data/data-table.d.ts.map +1 -1
- package/dist/data/data-table.js +41 -4
- package/dist/data/data-table.test.d.ts +2 -0
- package/dist/data/data-table.test.d.ts.map +1 -0
- package/dist/data/table.d.ts +1 -1
- package/dist/data/table.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +21 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/query/error-message.d.ts +41 -0
- package/dist/query/error-message.d.ts.map +1 -0
- package/dist/query/error-message.js +30 -0
- package/dist/query/error-message.test.d.ts +2 -0
- package/dist/query/error-message.test.d.ts.map +1 -0
- package/dist/query/index.d.ts +1 -19
- package/dist/query/index.d.ts.map +1 -1
- package/dist/query/index.js +2 -14
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/llms.txt +3 -3
- package/package.json +1 -1
- package/src/cli/check-theme.test.ts +34 -0
- package/src/cli/index.ts +222 -44
- package/src/cli/tokens-css.test.ts +73 -6
- package/src/cli/toolchain-wiring.test.ts +146 -0
- package/src/dashboard/empty-state.tsx +9 -3
- package/src/dashboard/index.ts +11 -0
- package/src/dashboard/query-state.test.tsx +152 -0
- package/src/dashboard/query-state.tsx +272 -0
- package/src/data/data-table.test.tsx +158 -0
- package/src/data/data-table.tsx +123 -5
- package/src/data/table.ts +6 -1
- package/src/guard/check-source.test.ts +141 -0
- package/src/guard/index.ts +102 -8
- package/src/index.ts +9 -0
- package/src/query/error-message.test.ts +67 -0
- package/src/query/error-message.ts +70 -0
- package/src/query/index.ts +2 -34
- package/src/surfaces.ts +1 -1
package/src/cli/index.ts
CHANGED
|
@@ -365,8 +365,14 @@ function readBasaltConfig(cwd: string): BasaltConfig {
|
|
|
365
365
|
* Extensions the walker collects. `html?`/`webmanifest` close argo's finding that raw hex ships in
|
|
366
366
|
* `index.html`'s `theme-color` and the webmanifest's `theme_color`/`background_color` — the two
|
|
367
367
|
* places whose colors nothing re-derives on a scheme change, and which the scan never reached.
|
|
368
|
+
*
|
|
369
|
+
* `astro`/`jsx`/`vue` close the same gap one component layer up. rollhook's marketing site is
|
|
370
|
+
* Astro: `apps/marketing/src` holds two `.astro` templates that are its ENTIRE markup layer, and
|
|
371
|
+
* check-theme reported a clean 4-file scan without ever opening either. The colour and typography
|
|
372
|
+
* kinds apply to them exactly as they do to `.html`, which was already scanned; `.jsx` and `.vue`
|
|
373
|
+
* were missing from the same regex, so a plain-JS or Vue consumer was unguarded end to end.
|
|
368
374
|
*/
|
|
369
|
-
const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/
|
|
375
|
+
const SCANNABLE_EXT = /\.(?:tsx?|jsx|astro|vue|css|html?|webmanifest)$/
|
|
370
376
|
|
|
371
377
|
/**
|
|
372
378
|
* Recursively collect scannable files under a root, skipping dependency/build dirs. Node+Bun-safe.
|
|
@@ -539,6 +545,36 @@ function descendantProjects(cwd: string, depth = 2): string[] {
|
|
|
539
545
|
return out
|
|
540
546
|
}
|
|
541
547
|
|
|
548
|
+
/**
|
|
549
|
+
* The nearest ANCESTOR of `cwd`, within the same repo, that is a basalt project — or null.
|
|
550
|
+
*
|
|
551
|
+
* The descend direction was handled and announced; ascend was not, and it fabricated instead. From
|
|
552
|
+
* `apps/demo` in a two-root repo, `readBasaltConfig` answered `{}`, `resolveRoots` substituted the
|
|
553
|
+
* built-in `['src']`, and check-theme scanned 22 of the repo's 44 guarded files, reported
|
|
554
|
+
* `✓ no off-palette colors`, and printed the invention back as `basalt.roots (src)`. Its
|
|
555
|
+
* `--audit-allows` then reported `0 live` for a repo carrying a live waiver — a clean slate from
|
|
556
|
+
* the one command whose entire job is to deny one. `doctor`, standing in that same directory,
|
|
557
|
+
* already resolved the parent install and named it, so the two commands disagreed about the same
|
|
558
|
+
* repo.
|
|
559
|
+
*
|
|
560
|
+
* Bounded by {@link findRepoRoot}, inclusive: an ancestor outside the repo is not this project's
|
|
561
|
+
* config. `repoRoot === cwd` means either cwd IS the root (nothing above it to ascend to) or there
|
|
562
|
+
* is no `.git` anywhere (no bound to walk within) — both answer null, which leaves a standalone
|
|
563
|
+
* unconfigured consumer on the built-in defaults exactly as before.
|
|
564
|
+
*/
|
|
565
|
+
function ascendantProject(cwd: string): string | null {
|
|
566
|
+
const repoRoot = findRepoRoot(cwd)
|
|
567
|
+
if (repoRoot === cwd) return null
|
|
568
|
+
let current = dirname(cwd)
|
|
569
|
+
for (;;) {
|
|
570
|
+
if (hasBasaltProject(current)) return current
|
|
571
|
+
if (current === repoRoot) return null
|
|
572
|
+
const parent = dirname(current)
|
|
573
|
+
if (parent === current) return null
|
|
574
|
+
current = parent
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
542
578
|
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
543
579
|
const override = process.env['BASALT_CWD']
|
|
544
580
|
if (override !== undefined && override.length > 0) {
|
|
@@ -558,6 +594,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
558
594
|
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
559
595
|
}
|
|
560
596
|
if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates }
|
|
597
|
+
|
|
598
|
+
// Nothing at cwd and nothing below it: look UP before falling back to a default. See
|
|
599
|
+
// {@link ascendantProject} — the fabricated `roots: ['src']` this replaces is what let a scan
|
|
600
|
+
// pass over half a repo and an audit report zero waivers for a repo that has one.
|
|
601
|
+
const above = ascendantProject(cwd)
|
|
602
|
+
if (above !== null) return { dir: above, relocatedFrom: cwd, ambiguous: null }
|
|
603
|
+
|
|
561
604
|
return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
562
605
|
}
|
|
563
606
|
|
|
@@ -3325,8 +3368,10 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
3325
3368
|
* demonstrably not the installed package — see the check).
|
|
3326
3369
|
* 5. The running CLI's own version matches the installed basalt-ui (catches a stale
|
|
3327
3370
|
* `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
|
|
3328
|
-
* 9. `basaltAppPlugin`
|
|
3329
|
-
*
|
|
3371
|
+
* 9. The icon files `basaltAppPlugin` (basalt-ui/vite) references exist under the app package's
|
|
3372
|
+
* public/ — the package found via `basalt.roots`, not just cwd, so a root-invoked run still
|
|
3373
|
+
* reaches it. Never silently omitted: no plugin call anywhere is a pass that says so, a
|
|
3374
|
+
* plugin call with no public/ beside it is a SKIPPED.
|
|
3330
3375
|
* 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
|
|
3331
3376
|
* exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
3332
3377
|
* skew slips through unnoticed.
|
|
@@ -3698,14 +3743,33 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3698
3743
|
// ── Warn check 8: the icon files basaltAppPlugin actually references exist ────
|
|
3699
3744
|
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references six filenames by default
|
|
3700
3745
|
// (the realfavicongenerator convention), but since 1.23.0 an app can NAME its own — so the option
|
|
3701
|
-
// is read (see {@link readAppIconsOption}) rather than assumed.
|
|
3702
|
-
//
|
|
3703
|
-
//
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3746
|
+
// is read (see {@link readAppIconsOption}) rather than assumed.
|
|
3747
|
+
//
|
|
3748
|
+
// It used to read `cwd` alone, and in a monorepo the vite config lives in the app PACKAGE while
|
|
3749
|
+
// the only invocation that exits 0 is the repo root. The check therefore vanished from the root
|
|
3750
|
+
// run with no line at all — the failure mode SKIPPED exists to eliminate — leaving no single
|
|
3751
|
+
// invocation that both passes and checks the icons. It now resolves the app package the same way
|
|
3752
|
+
// the scan does, off `basalt.roots`; see {@link findAppPluginDir}.
|
|
3753
|
+
if (profile === 'framework') {
|
|
3754
|
+
const appDir = findAppPluginDir(cwd, cfg)
|
|
3755
|
+
const iconsIn = appDir ?? cwd
|
|
3756
|
+
const icons = readAppIconsOption(iconsIn)
|
|
3757
|
+
const here = appDir === null || appDir === cwd ? '' : `${relativePosix(cwd, appDir)}/`
|
|
3758
|
+
const publicDir = resolve(iconsIn, 'public')
|
|
3759
|
+
if (appDir === null && !existsSync(publicDir)) {
|
|
3760
|
+
// No `basaltAppPlugin(` anywhere under the declared roots and no public/ here: nothing in
|
|
3761
|
+
// this project references icon files, so there is nothing to check — and saying so is the
|
|
3762
|
+
// point. A check that vanishes without a word is indistinguishable from one that passed.
|
|
3763
|
+
pass('app icons: no `basaltAppPlugin(` under this project and no public/ — nothing to check')
|
|
3764
|
+
} else if (icons.kind === 'none') {
|
|
3708
3765
|
pass('basaltAppPlugin is configured with no icons — nothing to check')
|
|
3766
|
+
} else if (!existsSync(publicDir)) {
|
|
3767
|
+
skip(
|
|
3768
|
+
`app icons — ${here === '' ? 'this package' : here.replace(/\/$/, '')} configures ` +
|
|
3769
|
+
'basaltAppPlugin, but there is no `public/` beside its vite config, so the files it ' +
|
|
3770
|
+
"references cannot be located. Use Vite's public-dir convention, or set `icons: false` " +
|
|
3771
|
+
'if this app ships none.',
|
|
3772
|
+
)
|
|
3709
3773
|
} else if (icons.kind === 'explicit') {
|
|
3710
3774
|
// The app NAMED its icons, so those are the only files it owes. Checking the six defaults
|
|
3711
3775
|
// here is what told 1.23.0's flagship adopter to generate five files it deliberately lacks.
|
|
@@ -3714,19 +3778,20 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3714
3778
|
)
|
|
3715
3779
|
if (missing.length > 0) {
|
|
3716
3780
|
warn(
|
|
3717
|
-
`basaltAppPlugin declares icon(s) that are not in public/: ${missing.join(', ')} —
|
|
3718
|
-
'manifest and the head links will point at a 404. Place them, or drop them from
|
|
3719
|
-
'`icons` array.',
|
|
3781
|
+
`basaltAppPlugin declares icon(s) that are not in ${here}public/: ${missing.join(', ')} — ` +
|
|
3782
|
+
'the manifest and the head links will point at a 404. Place them, or drop them from ' +
|
|
3783
|
+
'the `icons` array.',
|
|
3720
3784
|
)
|
|
3721
3785
|
} else {
|
|
3722
3786
|
pass(
|
|
3723
|
-
|
|
3787
|
+
`${here}public/ has all ${icons.sources.length} icon file(s) basaltAppPlugin's ` +
|
|
3788
|
+
'`icons` option names',
|
|
3724
3789
|
)
|
|
3725
3790
|
}
|
|
3726
3791
|
} else {
|
|
3727
3792
|
const dir = icons.kind === 'default' ? icons.dir : null
|
|
3728
3793
|
const iconRoot = dir === null ? publicDir : resolve(publicDir, dir.replace(/^\//, ''))
|
|
3729
|
-
const shown = dir === null ?
|
|
3794
|
+
const shown = dir === null ? `${here}public/` : `${here}public/${dir.replace(/^\//, '')}`
|
|
3730
3795
|
const missingIcons = DEFAULT_APP_ICON_FILES.filter((f) => !existsSync(resolve(iconRoot, f)))
|
|
3731
3796
|
if (missingIcons.length > 0) {
|
|
3732
3797
|
warn(
|
|
@@ -3962,6 +4027,43 @@ const VITE_CONFIG_FILES = [
|
|
|
3962
4027
|
'vite.config.cjs',
|
|
3963
4028
|
] as const
|
|
3964
4029
|
|
|
4030
|
+
/**
|
|
4031
|
+
* The directory whose vite config actually calls `basaltAppPlugin(` — cwd, or the app package the
|
|
4032
|
+
* declared roots point into. Null when no vite config under this project calls it.
|
|
4033
|
+
*
|
|
4034
|
+
* `basalt.roots: ["apps/web/src"]` already names where the app is; the icon check just never used
|
|
4035
|
+
* it, so from the repo root — the only invocation that can exit 0 on a monorepo layout — it found
|
|
4036
|
+
* no vite config and silently emitted nothing. Each root is walked from its own directory up to
|
|
4037
|
+
* cwd, nearest first, so `apps/web/src` resolves to `apps/web`, the package that owns both the
|
|
4038
|
+
* vite config and the `public/` tree.
|
|
4039
|
+
*/
|
|
4040
|
+
function findAppPluginDir(cwd: string, cfg: BasaltConfig): string | null {
|
|
4041
|
+
const seen = new Set<string>()
|
|
4042
|
+
const candidates: string[] = []
|
|
4043
|
+
const add = (dir: string): void => {
|
|
4044
|
+
if (seen.has(dir)) return
|
|
4045
|
+
seen.add(dir)
|
|
4046
|
+
candidates.push(dir)
|
|
4047
|
+
}
|
|
4048
|
+
add(cwd)
|
|
4049
|
+
for (const root of resolveRoots(cfg)) {
|
|
4050
|
+
let current = resolve(cwd, root)
|
|
4051
|
+
// A root that escapes cwd (`../shared`) is not this project's app package — never walk out.
|
|
4052
|
+
while (current !== cwd && !relative(cwd, current).startsWith('..')) {
|
|
4053
|
+
add(current)
|
|
4054
|
+
const parent = dirname(current)
|
|
4055
|
+
if (parent === current) break
|
|
4056
|
+
current = parent
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
for (const dir of candidates) {
|
|
4060
|
+
const configPath = VITE_CONFIG_FILES.map((f) => resolve(dir, f)).find((f) => existsSync(f))
|
|
4061
|
+
if (configPath === undefined) continue
|
|
4062
|
+
if (readIfExists(configPath)?.includes('basaltAppPlugin(') === true) return dir
|
|
4063
|
+
}
|
|
4064
|
+
return null
|
|
4065
|
+
}
|
|
4066
|
+
|
|
3965
4067
|
/**
|
|
3966
4068
|
* What `basaltAppPlugin`'s `icons` option says this app actually has.
|
|
3967
4069
|
*
|
|
@@ -4060,31 +4162,83 @@ function generatedHeader(version: string, command: string, flags: string[]): str
|
|
|
4060
4162
|
}
|
|
4061
4163
|
|
|
4062
4164
|
/**
|
|
4063
|
-
* The
|
|
4165
|
+
* The provenance line, split so only the VERSION token can be neutralized.
|
|
4166
|
+
*
|
|
4167
|
+
* Deliberately the same shape as the guard's `GENERATED_PROVENANCE_LINE` (`src/guard/index.ts`),
|
|
4168
|
+
* which is what the `@generated` exemption tests line 2 against — the two must keep agreeing, so
|
|
4169
|
+
* this is written to match, not to be lenient. A line that does not match is left VERBATIM, which
|
|
4170
|
+
* is what makes a deleted, reworded or forged header fail `--check` instead of being blanked into
|
|
4171
|
+
* agreement.
|
|
4172
|
+
*/
|
|
4173
|
+
const PROVENANCE_VERSION =
|
|
4174
|
+
/^(\/\* basalt-ui )(\d+\.\d+\.\d+\S*)( — `basalt-ui (?:tokens:css|fonts:css)[^`]*` \*\/)$/
|
|
4175
|
+
|
|
4176
|
+
/** The provenance line's version token, or null when line 2 is not a provenance line at all. */
|
|
4177
|
+
function provenanceVersion(css: string): string | null {
|
|
4178
|
+
const match = PROVENANCE_VERSION.exec(css.split('\n')[1] ?? '')
|
|
4179
|
+
return match === null ? null : (match[2] as string)
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
/**
|
|
4183
|
+
* The generated file with only the provenance line's VERSION TOKEN neutralized — what `--check`
|
|
4184
|
+
* actually compares.
|
|
4185
|
+
*
|
|
4186
|
+
* `--check` used to gate the whole file byte-for-byte, so every basalt-ui release forced a
|
|
4187
|
+
* mandatory no-op commit in every consumer that commits a generated sheet. 1.23.1 fixed that by
|
|
4188
|
+
* blanking the whole of line 2 — and line 2 is the line that carries the exact invocation, which
|
|
4189
|
+
* line 1 tells the reader to regenerate with. The gate stopped verifying the one instruction it
|
|
4190
|
+
* hands out: rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
|
|
4191
|
+
* --with-legacy-aliases` and `--check` passed clean.
|
|
4064
4192
|
*
|
|
4065
|
-
*
|
|
4066
|
-
*
|
|
4067
|
-
*
|
|
4068
|
-
* DESIGN.md this cycle, made worse by a gate that is byte-equality.
|
|
4193
|
+
* So the version token is replaced by a sentinel and the rest of the line still gates. A release
|
|
4194
|
+
* alone never trips the check; a changed flag, a reworded header, and a header that is simply gone
|
|
4195
|
+
* all do.
|
|
4069
4196
|
*
|
|
4070
|
-
* The version STAYS in the file rather than being dropped the
|
|
4071
|
-
*
|
|
4072
|
-
*
|
|
4073
|
-
* `doctor` to ask. What changes is that the line no longer gates: drift is measured over the token
|
|
4074
|
-
* VALUES, which is the only part of the file a release can meaningfully change.
|
|
4197
|
+
* The version STAYS in the file rather than being dropped, because the consumer this command exists
|
|
4198
|
+
* for — a framework-free site running `bunx basalt-ui tokens:css` with no basalt in its dependency
|
|
4199
|
+
* tree at all — has no other record of which version emitted it, and no `doctor` to ask.
|
|
4075
4200
|
*
|
|
4076
|
-
*
|
|
4077
|
-
*
|
|
4078
|
-
*
|
|
4079
|
-
*
|
|
4201
|
+
* Line-aligned, never dropped, so the reported line counts still match the files on disk. The
|
|
4202
|
+
* `@generated` exemption is untouched by all of this: nothing here changes what
|
|
4203
|
+
* {@link generatedHeader} emits, and {@link PROVENANCE_VERSION} is matched to the guard's own
|
|
4204
|
+
* provenance regex rather than replacing it.
|
|
4080
4205
|
*/
|
|
4081
|
-
function
|
|
4206
|
+
function withoutProvenanceVersion(css: string): string {
|
|
4082
4207
|
const lines = css.split('\n')
|
|
4083
|
-
|
|
4084
|
-
|
|
4208
|
+
const line = lines[1]
|
|
4209
|
+
if (line === undefined) return css
|
|
4210
|
+
const match = PROVENANCE_VERSION.exec(line)
|
|
4211
|
+
if (match === null) return css
|
|
4212
|
+
lines[1] = `${match[1] as string}<version>${match[3] as string}`
|
|
4085
4213
|
return lines.join('\n')
|
|
4086
4214
|
}
|
|
4087
4215
|
|
|
4216
|
+
/** The leading `major.minor.patch` of a version string as numbers, or null when it has none. */
|
|
4217
|
+
function versionTriple(version: string): number[] | null {
|
|
4218
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version)
|
|
4219
|
+
return match === null ? null : [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
/**
|
|
4223
|
+
* How the on-disk provenance version relates to the one that just ran — measured, not assumed.
|
|
4224
|
+
*
|
|
4225
|
+
* The success message asserted the file "still names an older basalt-ui" without ever reading the
|
|
4226
|
+
* token, so `0.0.1-nonsense` got the same sentence. Both sides are parsed; the numeric triples are
|
|
4227
|
+
* compared and anything that does not order cleanly is reported as merely different.
|
|
4228
|
+
*/
|
|
4229
|
+
function describeVersionSkew(onDisk: string, current: string): string {
|
|
4230
|
+
const a = versionTriple(onDisk)
|
|
4231
|
+
const b = versionTriple(current)
|
|
4232
|
+
if (a === null || b === null) return 'a different'
|
|
4233
|
+
for (let i = 0; i < 3; i++) {
|
|
4234
|
+
const left = a[i] as number
|
|
4235
|
+
const right = b[i] as number
|
|
4236
|
+
if (left < right) return 'an older'
|
|
4237
|
+
if (left > right) return 'a newer'
|
|
4238
|
+
}
|
|
4239
|
+
return 'a different'
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4088
4242
|
/**
|
|
4089
4243
|
* Space- and number-normalize the argument list of a legacy color function so the emitted file
|
|
4090
4244
|
* survives a normal repo's formatter.
|
|
@@ -4147,22 +4301,46 @@ function emitGeneratedCss(content: string, flags: string[], cwd: string, command
|
|
|
4147
4301
|
)
|
|
4148
4302
|
return 1
|
|
4149
4303
|
}
|
|
4150
|
-
if (
|
|
4151
|
-
const
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4304
|
+
if (withoutProvenanceVersion(onDisk) === withoutProvenanceVersion(content)) {
|
|
4305
|
+
const diskVersion = provenanceVersion(onDisk)
|
|
4306
|
+
const currentVersion = provenanceVersion(content)
|
|
4307
|
+
const skew =
|
|
4308
|
+
diskVersion !== null && currentVersion !== null && diskVersion !== currentVersion
|
|
4309
|
+
? ` (Its provenance line names basalt-ui ${diskVersion}, ` +
|
|
4310
|
+
`${describeVersionSkew(diskVersion, currentVersion)} release than the ` +
|
|
4311
|
+
`${currentVersion} that just ran — only that version token is excluded from the ` +
|
|
4312
|
+
'comparison. Re-run without --check whenever you want it refreshed.)'
|
|
4313
|
+
: ''
|
|
4314
|
+
console.log(`✓ ${command} --check: ${shown} is up to date.${skew}`)
|
|
4159
4315
|
return 0
|
|
4160
4316
|
}
|
|
4317
|
+
// Name the provenance line when it is the ONLY thing that moved: the diff is one line the
|
|
4318
|
+
// reader is about to scroll past, and a rewritten invocation is the failure that bought this
|
|
4319
|
+
// branch — line 1 tells them to regenerate with line 2, so line 2 has to be true.
|
|
4320
|
+
const diskLines = onDisk.split('\n')
|
|
4321
|
+
const wantLines = content.split('\n')
|
|
4322
|
+
const provenanceOnly =
|
|
4323
|
+
diskLines.length === wantLines.length &&
|
|
4324
|
+
diskLines.every((line, i) => i === 1 || line === wantLines[i]) &&
|
|
4325
|
+
diskLines[1] !== wantLines[1]
|
|
4326
|
+
if (provenanceOnly) {
|
|
4327
|
+
console.error(
|
|
4328
|
+
`✖ ${command} --check: ${shown}'s \`@generated\` provenance line does not match the ` +
|
|
4329
|
+
'command that produced this content — the token values agree, line 2 does not.\n' +
|
|
4330
|
+
` on disk: ${diskLines[1]}\n` +
|
|
4331
|
+
` emitted: ${wantLines[1]}\n` +
|
|
4332
|
+
'Line 1 tells the reader to regenerate with line 2, so line 2 is gated: run the emitted ' +
|
|
4333
|
+
'invocation without --check and commit the result, or change the flags this check runs ' +
|
|
4334
|
+
'with to the ones the file was really built from. Only the version token is exempt.',
|
|
4335
|
+
)
|
|
4336
|
+
return 1
|
|
4337
|
+
}
|
|
4161
4338
|
console.error(
|
|
4162
4339
|
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
4163
|
-
`(on disk ${
|
|
4164
|
-
|
|
4165
|
-
|
|
4340
|
+
`(on disk ${diskLines.length} lines, emitted ${wantLines.length}) — ` +
|
|
4341
|
+
're-run the same command without --check and commit the result. Only the `@generated` ' +
|
|
4342
|
+
"provenance line's VERSION token is excluded from this comparison, so a release alone " +
|
|
4343
|
+
'never trips it — but its flags do.',
|
|
4166
4344
|
)
|
|
4167
4345
|
return 1
|
|
4168
4346
|
}
|
|
@@ -14,7 +14,7 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSy
|
|
|
14
14
|
import { tmpdir } from 'node:os'
|
|
15
15
|
import { join, resolve } from 'node:path'
|
|
16
16
|
|
|
17
|
-
import { GENERATED_HEADER_LINE } from '../guard'
|
|
17
|
+
import { DEFAULT_GUARD_CONFIG, GENERATED_HEADER_LINE, checkSource } from '../guard'
|
|
18
18
|
import { buildPaletteCss } from '../tokens'
|
|
19
19
|
import { normalizeColorFunctions, run, tokensCss } from './index'
|
|
20
20
|
|
|
@@ -121,12 +121,17 @@ describe('tokens:css', () => {
|
|
|
121
121
|
})
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
|
-
* `--check` gates the token VALUES
|
|
124
|
+
* `--check` gates the token VALUES and the provenance INVOCATION — only the version token is free.
|
|
125
125
|
*
|
|
126
126
|
* Line 2 of the generated header carries the emitting version, and `--check` used to compare the
|
|
127
|
-
* whole file byte for byte
|
|
128
|
-
* consumer that commits a generated sheet
|
|
129
|
-
* is the
|
|
127
|
+
* whole file byte for byte, so every basalt-ui release forced a mandatory no-op commit in every
|
|
128
|
+
* consumer that commits a generated sheet. 1.23.1 fixed that by blanking the whole of line 2 —
|
|
129
|
+
* which is also the line carrying the exact invocation line 1 tells the reader to regenerate with.
|
|
130
|
+
* rollhook's header was rewritten from `--only core --no-legacy-aliases` to `--only all
|
|
131
|
+
* --with-legacy-aliases` and `--check` passed clean.
|
|
132
|
+
*
|
|
133
|
+
* Three-way contract, one test each below: a version-only change passes, a line-2 flag change
|
|
134
|
+
* fails, a token drift fails.
|
|
130
135
|
*/
|
|
131
136
|
describe('tokens:css --check and the provenance line', () => {
|
|
132
137
|
/** Capture stdout/stderr so the check's own sentence can be asserted. */
|
|
@@ -156,7 +161,54 @@ describe('tokens:css --check and the provenance line', () => {
|
|
|
156
161
|
const { code, log } = check('out.css')
|
|
157
162
|
expect(code).toBe(0)
|
|
158
163
|
expect(log).toContain('is up to date')
|
|
159
|
-
|
|
164
|
+
// Parsed, not asserted: the old sentence claimed "an older basalt-ui" without ever reading
|
|
165
|
+
// the token, so `0.0.1-nonsense` got the same claim. Both versions are named now.
|
|
166
|
+
expect(log).toContain('names basalt-ui 0.0.1, an older release than the')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('calls a NEWER on-disk version newer — the old sentence said "older" either way', () => {
|
|
170
|
+
const lines = emit([]).split('\n')
|
|
171
|
+
lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+ —/, 'basalt-ui 999.0.0 —')
|
|
172
|
+
writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
|
|
173
|
+
|
|
174
|
+
const { code, log } = check('out.css')
|
|
175
|
+
expect(code).toBe(0)
|
|
176
|
+
expect(log).toContain('names basalt-ui 999.0.0, a newer release than the')
|
|
177
|
+
expect(log).not.toContain('an older release')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('calls a same-triple prerelease merely different rather than ordering it', () => {
|
|
181
|
+
const lines = emit([]).split('\n')
|
|
182
|
+
const current = /basalt-ui (\d+\.\d+\.\d+)/.exec(lines[1] as string)?.[1] as string
|
|
183
|
+
lines[1] = (lines[1] as string).replace(`basalt-ui ${current} —`, `basalt-ui ${current}-rc.1 —`)
|
|
184
|
+
writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
|
|
185
|
+
|
|
186
|
+
const { code, log } = check('out.css')
|
|
187
|
+
expect(code).toBe(0)
|
|
188
|
+
expect(log).toContain(`names basalt-ui ${current}-rc.1, a different release than the`)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('FAILS when line 2 names different flags — the invocation line 1 points at is gated', () => {
|
|
192
|
+
const emitted = emit([])
|
|
193
|
+
const lines = emitted.split('\n')
|
|
194
|
+
lines[1] = (lines[1] as string).replace(
|
|
195
|
+
'`basalt-ui tokens:css --out out.css`',
|
|
196
|
+
'`basalt-ui tokens:css --only all --with-legacy-aliases --out out.css`',
|
|
197
|
+
)
|
|
198
|
+
writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
|
|
199
|
+
|
|
200
|
+
const { code, log } = check('out.css')
|
|
201
|
+
expect(code).toBe(1)
|
|
202
|
+
expect(log).toContain('provenance line does not match the command that produced this content')
|
|
203
|
+
expect(log).toContain('--only all --with-legacy-aliases')
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('FAILS when the provenance line is deleted outright — a blank line is not a free pass', () => {
|
|
207
|
+
const lines = emit([]).split('\n')
|
|
208
|
+
lines[1] = ''
|
|
209
|
+
writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
|
|
210
|
+
|
|
211
|
+
expect(check('out.css').code).toBe(1)
|
|
160
212
|
})
|
|
161
213
|
|
|
162
214
|
it('says nothing about provenance when the file is byte-identical', () => {
|
|
@@ -174,9 +226,24 @@ describe('tokens:css --check and the provenance line', () => {
|
|
|
174
226
|
expect(log).toContain('differs from what')
|
|
175
227
|
})
|
|
176
228
|
|
|
229
|
+
it('STILL fails on token drift even when the version token ALSO differs', () => {
|
|
230
|
+
const lines = emit([]).replace('--vx-neutral', '--vx-neutrall').split('\n')
|
|
231
|
+
lines[1] = (lines[1] as string).replace(/basalt-ui [\d.]+ —/, 'basalt-ui 0.0.1 —')
|
|
232
|
+
writeFileSync(resolve(dir, 'out.css'), lines.join('\n'))
|
|
233
|
+
expect(check('out.css').code).toBe(1)
|
|
234
|
+
})
|
|
235
|
+
|
|
177
236
|
it('the header itself is unchanged — the `@generated` exemption needs both lines verbatim', () => {
|
|
178
237
|
const lines = emit([]).split('\n')
|
|
179
238
|
expect(lines[0]).toBe(GENERATED_HEADER_LINE)
|
|
180
239
|
expect(lines[1]).toMatch(/^\/\* basalt-ui [\d.]+ — `basalt-ui tokens:css --out out\.css` \*\/$/)
|
|
181
240
|
})
|
|
241
|
+
|
|
242
|
+
it('the emitted header is still guard-exempt — checkSource reports nothing in it', () => {
|
|
243
|
+
// The exemption requires line 1 verbatim AND line 2 parsing as the provenance line. `--check`
|
|
244
|
+
// now reads line 2 too, so the two shapes have to keep agreeing; this is the assertion that
|
|
245
|
+
// notices if the emitter drifts to satisfy the gate.
|
|
246
|
+
const emitted = emit([])
|
|
247
|
+
expect(checkSource(emitted, 'src/tokens.css', DEFAULT_GUARD_CONFIG)).toEqual([])
|
|
248
|
+
})
|
|
182
249
|
})
|
|
@@ -1210,3 +1210,149 @@ describe('DESIGN.md carries no version, and sync heals the ones already written'
|
|
|
1210
1210
|
expect(read('DESIGN.md')).toBe(owned)
|
|
1211
1211
|
})
|
|
1212
1212
|
})
|
|
1213
|
+
|
|
1214
|
+
// ── The ascend direction ──────────────────────────────────────────────────────
|
|
1215
|
+
// Round 9, basalt-ui-obsidian: from `apps/demo` — a package with no `basalt` key of its own —
|
|
1216
|
+
// `check-theme` invented `roots: ["src"]`, scanned 22 of the repo's 44 guarded files, printed
|
|
1217
|
+
// `✓ no off-palette colors`, and reported the invention back as `basalt.roots (src)`. Its
|
|
1218
|
+
// `--audit-allows` then reported `0 live` for a repo carrying one live waiver: a clean slate from
|
|
1219
|
+
// the one command whose entire job is to deny one. `doctor`, standing in the same directory,
|
|
1220
|
+
// already resolved the parent. The descend direction announced itself; only ascend was silent.
|
|
1221
|
+
describe('check-theme / doctor — ascend to the parent config instead of fabricating one', () => {
|
|
1222
|
+
/** Repo root owns the config and names TWO roots; neither package declares anything. */
|
|
1223
|
+
function twoRootRepo(): void {
|
|
1224
|
+
write('.git/HEAD', 'ref: refs/heads/master\n')
|
|
1225
|
+
write(
|
|
1226
|
+
'package.json',
|
|
1227
|
+
JSON.stringify({
|
|
1228
|
+
name: 'fixture',
|
|
1229
|
+
workspaces: ['packages/*', 'apps/*'],
|
|
1230
|
+
basalt: { roots: ['packages/lib/src', 'apps/demo/src'] },
|
|
1231
|
+
}),
|
|
1232
|
+
)
|
|
1233
|
+
write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
|
|
1234
|
+
write('apps/demo/package.json', JSON.stringify({ name: 'demo' }))
|
|
1235
|
+
write('apps/demo/src/App.tsx', 'export const App = () => null\n')
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
it('scans the WHOLE declared surface from a package that declares nothing, and says so', () => {
|
|
1239
|
+
twoRootRepo()
|
|
1240
|
+
// The violation sits in the sibling root — the half the fabricated `src` default never read.
|
|
1241
|
+
write('packages/lib/src/Card.tsx', `export const C = () => <div style={{ borderRadius: 8 }} />`)
|
|
1242
|
+
|
|
1243
|
+
const { code, log } = capture(() => checkTheme(join(dir, 'apps/demo')))
|
|
1244
|
+
expect(log).toContain('running in ../.., where it lives')
|
|
1245
|
+
expect(code).toBe(1)
|
|
1246
|
+
expect(log).toContain('raw-surface')
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
it('--audit-allows reports the repo’s real waiver count, not zero', () => {
|
|
1250
|
+
twoRootRepo()
|
|
1251
|
+
write(
|
|
1252
|
+
'packages/lib/src/Card.tsx',
|
|
1253
|
+
'export const C = () => <div style={{ borderRadius: 8 }} /> // theme-allow: legacy widget\n',
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
const { code, log } = capture(() => checkTheme(join(dir, 'apps/demo'), ['--audit-allows']))
|
|
1257
|
+
expect(code).toBe(0)
|
|
1258
|
+
expect(log).toContain('1 live, 0 dead')
|
|
1259
|
+
expect(log).toContain('basalt.roots (packages/lib/src, apps/demo/src)')
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
it('doctor and check-theme now answer with the SAME project dir', () => {
|
|
1263
|
+
twoRootRepo()
|
|
1264
|
+
write('packages/lib/src/Card.tsx', 'export const C = () => null\n')
|
|
1265
|
+
write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
|
|
1266
|
+
write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
|
|
1267
|
+
installBasalt()
|
|
1268
|
+
|
|
1269
|
+
const { log } = capture(() => doctor(join(dir, 'apps/demo')))
|
|
1270
|
+
expect(log).toContain('reporting on ../.., where it lives')
|
|
1271
|
+
expect(log).toContain(`basalt-ui doctor — ${dir}`)
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
it('never ascends out of the repo — no .git above cwd means no ancestor to read', () => {
|
|
1275
|
+
// No `.git` anywhere: the walk has no bound, so it refuses to walk at all and the built-in
|
|
1276
|
+
// defaults apply exactly as they did before. A standalone consumer is untouched by this.
|
|
1277
|
+
write('package.json', JSON.stringify({ name: 'root', basalt: { roots: ['packages/lib/src'] } }))
|
|
1278
|
+
write('packages/lib/package.json', JSON.stringify({ name: 'lib' }))
|
|
1279
|
+
write('packages/lib/src/App.tsx', 'export const App = () => null\n')
|
|
1280
|
+
|
|
1281
|
+
const { code, log } = capture(() => checkTheme(join(dir, 'packages/lib')))
|
|
1282
|
+
expect(log).not.toContain('where it lives')
|
|
1283
|
+
expect(code).toBe(0)
|
|
1284
|
+
})
|
|
1285
|
+
|
|
1286
|
+
it('BASALT_CWD still wins over the ascend', () => {
|
|
1287
|
+
twoRootRepo()
|
|
1288
|
+
write('packages/lib/src/Card.tsx', 'export const C = () => null\n')
|
|
1289
|
+
write('apps/demo/package.json', JSON.stringify({ name: 'demo', basalt: { roots: ['src'] } }))
|
|
1290
|
+
process.env['BASALT_CWD'] = join(dir, 'apps/demo')
|
|
1291
|
+
try {
|
|
1292
|
+
const { code, log } = capture(() => checkTheme(dir, ['--audit-allows']))
|
|
1293
|
+
expect(code).toBe(0)
|
|
1294
|
+
expect(log).toContain('basalt.roots (src)')
|
|
1295
|
+
} finally {
|
|
1296
|
+
delete process.env['BASALT_CWD']
|
|
1297
|
+
}
|
|
1298
|
+
})
|
|
1299
|
+
})
|
|
1300
|
+
|
|
1301
|
+
// ── The icons check, reachable wherever doctor runs ───────────────────────────
|
|
1302
|
+
// Round 9, rb and argo: the root run — the ONLY invocation that can exit 0 on a monorepo layout —
|
|
1303
|
+
// omitted the icons check entirely, with no `⊘ SKIPPED` line. It read `cwd/public` and `cwd`'s
|
|
1304
|
+
// vite config, and in a monorepo both live in the app package. A check that vanishes without a
|
|
1305
|
+
// word is indistinguishable from one that passed.
|
|
1306
|
+
describe('doctor — the icons check is reachable from the root, or says why not', () => {
|
|
1307
|
+
/** A monorepo whose app package owns the vite config, the public/ tree and nothing else. */
|
|
1308
|
+
function appPackageRepo(viteConfig: string): void {
|
|
1309
|
+
write('.git/HEAD', 'ref: refs/heads/master\n')
|
|
1310
|
+
write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['apps/web/src'] } }))
|
|
1311
|
+
write('apps/web/src/app.tsx', 'export const App = () => null\n')
|
|
1312
|
+
write('apps/web/vite.config.ts', viteConfig)
|
|
1313
|
+
write('.oxlintrc.json', '{ "extends": ["./node_modules/basalt-ui/configs/oxlint.json"] }')
|
|
1314
|
+
write(MANIFEST_PATH, JSON.stringify({ version: 1, files: {}, basaltVersion: CLI_VERSION }))
|
|
1315
|
+
installBasalt()
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
it('reads the app package’s `icons` array from a ROOT-invoked run', () => {
|
|
1319
|
+
appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
|
|
1320
|
+
write('apps/web/public/favicon.svg', '<svg/>')
|
|
1321
|
+
|
|
1322
|
+
const { log } = capture(() => doctor(dir))
|
|
1323
|
+
expect(log).toContain("apps/web/public/ has all 1 icon file(s) basaltAppPlugin's `icons`")
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
it('reports a missing icon from the root too — it is not just reachable, it judges', () => {
|
|
1327
|
+
appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
|
|
1328
|
+
write('apps/web/public/.keep', '')
|
|
1329
|
+
|
|
1330
|
+
const { log } = capture(() => doctor(dir))
|
|
1331
|
+
expect(log).toContain('basaltAppPlugin declares icon(s) that are not in ./apps/web/public/')
|
|
1332
|
+
})
|
|
1333
|
+
|
|
1334
|
+
it('SKIPS loudly when the plugin is configured but has no public/ beside it', () => {
|
|
1335
|
+
appPackageRepo("export default basaltAppPlugin({ icons: [{ src: '/favicon.svg' }] })\n")
|
|
1336
|
+
|
|
1337
|
+
const { code, log } = capture(() => doctor(dir))
|
|
1338
|
+
expect(log).toContain('⊘ SKIPPED — app icons — ./apps/web configures basaltAppPlugin')
|
|
1339
|
+
expect(code).toBe(1)
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
it('states the absence rather than omitting the line when nothing uses the plugin', () => {
|
|
1343
|
+
write('.git/HEAD', 'ref: refs/heads/master\n')
|
|
1344
|
+
healthyFixture()
|
|
1345
|
+
|
|
1346
|
+
const { code, log } = capture(() => doctor(dir))
|
|
1347
|
+
expect(log).toContain('no `basaltAppPlugin(` under this project')
|
|
1348
|
+
expect(code).toBe(0)
|
|
1349
|
+
})
|
|
1350
|
+
|
|
1351
|
+
it('`icons: false` is nothing to check, from the root as from the package', () => {
|
|
1352
|
+
appPackageRepo('export default basaltAppPlugin({ icons: false })\n')
|
|
1353
|
+
|
|
1354
|
+
const { code, log } = capture(() => doctor(dir))
|
|
1355
|
+
expect(log).toContain('basaltAppPlugin is configured with no icons')
|
|
1356
|
+
expect(code).toBe(0)
|
|
1357
|
+
})
|
|
1358
|
+
})
|
|
@@ -24,8 +24,12 @@ export type EmptyStateProps = {
|
|
|
24
24
|
icon?: ReactNode
|
|
25
25
|
/** Head-font title (18px, weight 550, ink). */
|
|
26
26
|
title: string
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Muted 15px description, capped at ~360px so it reads as a short explanation. Optional: a
|
|
29
|
+
* compact panel ("No data yet") should not have to invent a second sentence, and five argo
|
|
30
|
+
* features wrapped this component solely to avoid doing so.
|
|
31
|
+
*/
|
|
32
|
+
description?: string
|
|
29
33
|
/** Optional call-to-action rendered below the description. */
|
|
30
34
|
action?: ReactNode
|
|
31
35
|
/** `'page'` (default) = generous padding for a full-page state; `'section'` = compact. */
|
|
@@ -60,7 +64,9 @@ export function EmptyState({
|
|
|
60
64
|
>
|
|
61
65
|
{title}
|
|
62
66
|
</span>
|
|
63
|
-
|
|
67
|
+
{description !== undefined && (
|
|
68
|
+
<span style={{ fontSize: VX.text.md, color: VX.muted, maxWidth: 360 }}>{description}</span>
|
|
69
|
+
)}
|
|
64
70
|
{action}
|
|
65
71
|
</Stack>
|
|
66
72
|
)
|