basalt-ui 1.19.1 → 1.21.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 +19 -1
- package/CHANGELOG.md +39 -0
- package/MIGRATING.md +326 -0
- package/README.md +87 -9
- package/agent/rules/basalt-app.md +84 -0
- package/agent/rules/basalt-charts.md +32 -17
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-mantine.md +25 -10
- package/agent/rules/basalt-router.md +79 -15
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +132 -6
- package/agent/skills/basalt-app/SKILL.md +45 -6
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/check.yml +11 -4
- package/configs/lefthook.yml +59 -7
- package/configs/oxlint-plugin.js +633 -30
- package/configs/oxlint-plugin.test.ts +594 -15
- package/configs/oxlint.json +10 -1
- package/dist/agent/stick-to-bottom.js +1 -1
- package/dist/agent-chat/thread-detail-panel.js +1 -1
- package/dist/agent-chat/thread-message.js +2 -2
- package/dist/agent-chat/tool-chip.js +1 -1
- package/dist/charts/kinds/Bars.d.ts +1 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.d.ts +1 -1
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +1 -1
- package/dist/charts/kinds/DualPanel.d.ts +1 -1
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.d.ts +1 -1
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.d.ts +1 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.d.ts +1 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts +1 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/primitives/ChartLegend.js +2 -2
- package/dist/charts/primitives/ChartTooltip.js +1 -1
- package/dist/cli/index.d.ts +146 -20
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +901 -101
- package/dist/cli/toolchain-wiring.test.d.ts +2 -0
- package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/dashboard/delta-badge.js +2 -2
- package/dist/dashboard/stat-card.js +1 -1
- package/dist/data/virtual-list.js +5 -5
- package/dist/guard/index.d.ts +101 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +474 -62
- package/dist/guard/types.d.ts +25 -5
- package/dist/guard/types.d.ts.map +1 -1
- package/dist/register.d.ts +25 -0
- package/dist/register.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
- package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/multi-search-param-store.js +4 -1
- package/dist/router-tanstack/search-param-store.d.ts +29 -5
- package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
- package/dist/router-tanstack/search-param-store.js +31 -3
- package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
- package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
- package/dist/shell/app-mobile-nav.module.css +11 -3
- package/dist/shell/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/surfaces.d.ts +1 -1
- package/dist/surfaces.js +1 -1
- package/dist/tokens/index.d.ts +5 -3
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +7 -1
- package/dist/tokens/palette.d.ts +35 -27
- package/dist/tokens/palette.d.ts.map +1 -1
- package/dist/tokens/palette.js +24 -18
- package/dist/tokens/text-scale.test.d.ts +2 -0
- package/dist/tokens/text-scale.test.d.ts.map +1 -0
- package/dist/tokens.css +21 -17
- package/dist/vite-color-mix.js +2 -2
- package/dist/vite.d.ts +24 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +54 -13
- package/llms.txt +3 -3
- package/package.json +4 -3
- package/src/agent/stick-to-bottom.tsx +1 -1
- package/src/agent-chat/thread-detail-panel.tsx +1 -1
- package/src/agent-chat/thread-message.tsx +3 -3
- package/src/agent-chat/tool-chip.tsx +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +3 -2
- package/src/charts/kinds/DualPanel.tsx +2 -2
- package/src/charts/kinds/Heatmap.tsx +1 -1
- package/src/charts/kinds/MultiLine.tsx +1 -1
- package/src/charts/kinds/StackedArea.tsx +1 -1
- package/src/charts/kinds/ZonedLine.tsx +1 -1
- package/src/charts/primitives/ChartLegend.tsx +2 -2
- package/src/charts/primitives/ChartTooltip.tsx +1 -1
- package/src/cli/index.ts +1614 -164
- package/src/cli/init-sync.test.ts +21 -3
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/tokens-css.test.ts +27 -14
- package/src/cli/toolchain-wiring.test.ts +856 -0
- package/src/cli/waiver-audit.test.ts +131 -0
- package/src/dashboard/delta-badge.tsx +3 -3
- package/src/dashboard/stat-card.tsx +1 -1
- package/src/data/virtual-list.tsx +8 -8
- package/src/guard/check-source.test.ts +1104 -12
- package/src/guard/index.ts +1106 -103
- package/src/guard/types.ts +29 -4
- package/src/register.ts +32 -0
- package/src/router-tanstack/multi-search-param-store.ts +16 -1
- package/src/router-tanstack/search-param-store.test.ts +135 -0
- package/src/router-tanstack/search-param-store.ts +90 -8
- package/src/shell/app-mobile-nav.module.css +11 -3
- package/src/shell/app-mobile-nav.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/surfaces.ts +1 -1
- package/src/theme/density-relations.test.ts +8 -5
- package/src/theme/spacing.test.ts +2 -0
- package/src/theme-toggle/index.tsx +2 -2
- package/src/tokens/index.ts +28 -3
- package/src/tokens/palette.ts +24 -18
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite-color-mix.ts +2 -2
- package/src/vite.test.ts +125 -6
- package/src/vite.ts +148 -20
package/src/cli/index.ts
CHANGED
|
@@ -32,7 +32,16 @@ import {
|
|
|
32
32
|
import { dirname, isAbsolute, relative, resolve } from 'node:path'
|
|
33
33
|
import { fileURLToPath } from 'node:url'
|
|
34
34
|
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
checkSource,
|
|
37
|
+
DEFAULT_GUARD_CONFIG,
|
|
38
|
+
GENERATED_HEADER_LINE,
|
|
39
|
+
guardKindRemedy,
|
|
40
|
+
GUARD_RULES,
|
|
41
|
+
guardWaiverHint,
|
|
42
|
+
TOKENS_ONLY_DISABLED_KINDS,
|
|
43
|
+
unmatchedExemptPatterns,
|
|
44
|
+
} from '../guard'
|
|
36
45
|
import type { Finding, GuardConfig, GuardKind, GuardSeverity } from '../guard'
|
|
37
46
|
import { evaluateGuardHook } from '../guard/guard-hook'
|
|
38
47
|
import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from '../surfaces'
|
|
@@ -45,6 +54,28 @@ export type BasaltConfig = {
|
|
|
45
54
|
roots?: string[]
|
|
46
55
|
/** Files exempt from the scan (they ARE the palette source). Default: argo's exempt set. */
|
|
47
56
|
exempt?: string[]
|
|
57
|
+
/**
|
|
58
|
+
* Declares this package a tokens-only consumer: it uses the `--vx-*` layer and no Mantine. Turns
|
|
59
|
+
* off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
|
|
60
|
+
* factory, leaving the color and typography kinds live. Default: `'mantine'`.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately not inferred from a missing `@mantine/core`: silencing 17 rules (the live count is
|
|
63
|
+
* `TOKENS_ONLY_DISABLED_KINDS.size`, which the check-theme banner prints) is a decision, and
|
|
64
|
+
* a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
|
|
65
|
+
* without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
|
|
66
|
+
*/
|
|
67
|
+
profile?: 'mantine' | 'tokens-only'
|
|
68
|
+
/**
|
|
69
|
+
* Extra files to scan, named individually and relative to this package — for a design surface
|
|
70
|
+
* that lives outside every root and outside the `index.html` / `public/` conventions the walker
|
|
71
|
+
* already derives. Also the ONLY way a `.json` is ever scanned: the guard understands JSON as
|
|
72
|
+
* markup, but a consumer repo's JSON is overwhelmingly config, fixtures and lockfiles, so
|
|
73
|
+
* blanket-scanning it is a false-positive generator rather than a guard.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* { include: ['app/manifest.json', 'emails/template.html'] }
|
|
77
|
+
*/
|
|
78
|
+
include?: string[]
|
|
48
79
|
/** Named spacing-scale steps (px) flagged when used as a raw spacing prop. Default: 10/12/16/20/32. */
|
|
49
80
|
spacingSteps?: number[]
|
|
50
81
|
/** Per-kind severity override — `'warn'` reports without failing, `'error'` fails the build.
|
|
@@ -156,15 +187,27 @@ export type BasaltConfig = {
|
|
|
156
187
|
/**
|
|
157
188
|
* Per-rule, per-path exemptions — complements whole-file `exempt` (which skips ALL rules for a
|
|
158
189
|
* file) and the hardcoded per-kind `appliesTo` scoping (e.g. `raw-visx-axis` → chart files
|
|
159
|
-
* only). Each value is a list of
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* and
|
|
190
|
+
* only). Each value is a list of patterns matched against a finding's relative path. A pattern
|
|
191
|
+
* may be a whole path segment (`'agent'` matches `src/agent/x.tsx`, not `src/agenting.ts`), a
|
|
192
|
+
* relative path (`'public/site.webmanifest'`), a directory prefix, or a glob — `*` stops at `/`,
|
|
193
|
+
* `**` does not, and a slash-free glob also matches the basename, so `'*.module.css'` works.
|
|
194
|
+
* A trailing `/` is stripped. An entry that suppresses nothing is reported, and
|
|
195
|
+
* `check-theme --audit-allows` exits 1 on it. Default: `{}` (no exemptions).
|
|
196
|
+
*
|
|
197
|
+
* Two forms per kind. The bare array is paths only. The object form adds the REASON, which is
|
|
198
|
+
* the half a `theme-allow` carries and this key could not: JSON has no comments, so a
|
|
199
|
+
* `.webmanifest` / `.json` finding's only escape is this key, and it was un-reviewable by
|
|
200
|
+
* construction — the rationale ended up in a CLAUDE.md paragraph nobody reads next to the diff.
|
|
201
|
+
* `check-theme --audit-allows` prints the reason (or names its absence) beside what the entry
|
|
202
|
+
* still suppresses. The array form stays supported and unchanged; nothing here is required.
|
|
163
203
|
*
|
|
164
204
|
* @example
|
|
165
205
|
* { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
|
|
206
|
+
* @example
|
|
207
|
+
* { exemptRules: { 'raw-hex': { paths: ['site.webmanifest'],
|
|
208
|
+
* reason: 'a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var' } } }
|
|
166
209
|
*/
|
|
167
|
-
exemptRules?: Partial<Record<GuardKind,
|
|
210
|
+
exemptRules?: Partial<Record<GuardKind, ExemptRuleEntry>>
|
|
168
211
|
/**
|
|
169
212
|
* Declares an intentional `ai` package major-version skew across workspace packages, exempting
|
|
170
213
|
* `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
|
|
@@ -186,6 +229,38 @@ export type BasaltConfig = {
|
|
|
186
229
|
aiMajorSkewReason?: string
|
|
187
230
|
}
|
|
188
231
|
|
|
232
|
+
/**
|
|
233
|
+
* One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
|
|
234
|
+
* it exists. See `BasaltConfig.exemptRules`.
|
|
235
|
+
*/
|
|
236
|
+
type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
|
|
237
|
+
|
|
238
|
+
/** The paths half of an exemption entry, whichever form it was written in. */
|
|
239
|
+
function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
|
|
240
|
+
if (entry === undefined) return []
|
|
241
|
+
return Array.isArray(entry) ? entry : entry.paths
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The recorded reason for an exemption entry, or null for the bare-array form. */
|
|
245
|
+
function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
|
|
246
|
+
if (entry === undefined || Array.isArray(entry)) return null
|
|
247
|
+
return entry.reason.trim().length > 0 ? entry.reason.trim() : null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The guard-shaped `exemptRules` — paths only. The reason the object form carries never reaches
|
|
252
|
+
* the guard: it is accountability for a human reading a diff and for `--audit-allows`, and giving
|
|
253
|
+
* the scan a second shape to understand would only be a way for the two to disagree.
|
|
254
|
+
*/
|
|
255
|
+
function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
|
|
256
|
+
if (cfg.exemptRules === undefined) return DEFAULT_GUARD_CONFIG.exemptRules
|
|
257
|
+
const out: Partial<Record<GuardKind, string[]>> = {}
|
|
258
|
+
for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
|
|
259
|
+
out[kind as GuardKind] = exemptRulePaths(entry)
|
|
260
|
+
}
|
|
261
|
+
return out
|
|
262
|
+
}
|
|
263
|
+
|
|
189
264
|
const DEFAULT_ROOT = 'src'
|
|
190
265
|
const DEFAULT_ROOTS = [DEFAULT_ROOT]
|
|
191
266
|
|
|
@@ -284,10 +359,22 @@ function readBasaltConfig(cwd: string): BasaltConfig {
|
|
|
284
359
|
}
|
|
285
360
|
|
|
286
361
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
362
|
+
* Extensions the walker collects. `html?`/`webmanifest` close argo's finding that raw hex ships in
|
|
363
|
+
* `index.html`'s `theme-color` and the webmanifest's `theme_color`/`background_color` — the two
|
|
364
|
+
* places whose colors nothing re-derives on a scheme change, and which the scan never reached.
|
|
365
|
+
*/
|
|
366
|
+
const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Recursively collect scannable files under a root, skipping dependency/build dirs. Node+Bun-safe.
|
|
289
370
|
* `checkSource` is already syntax-aware per extension (`guardSyntaxFor`); the walker was the only
|
|
290
|
-
* gap.
|
|
371
|
+
* gap, twice over — `.css` (CSS modules / `styles.css`) and now `.html`/`.webmanifest`.
|
|
372
|
+
*
|
|
373
|
+
* `.json` is deliberately NOT here even though the guard resolves it as markup: a consumer repo is
|
|
374
|
+
* full of JSON that is configuration, fixtures and lockfiles, and blanket-scanning it produces
|
|
375
|
+
* exactly the class of false positive already reported against a `data.json` test fixture. A JSON
|
|
376
|
+
* that IS a design surface (a `manifest.json` rather than a `*.webmanifest`) is reachable by naming
|
|
377
|
+
* it in `basalt.include`.
|
|
291
378
|
*/
|
|
292
379
|
function walkSourceFiles(dir: string, out: string[] = []): string[] {
|
|
293
380
|
let entries: string[]
|
|
@@ -306,71 +393,293 @@ function walkSourceFiles(dir: string, out: string[] = []): string[] {
|
|
|
306
393
|
continue
|
|
307
394
|
}
|
|
308
395
|
if (isDir) walkSourceFiles(abs, out)
|
|
309
|
-
else if (
|
|
396
|
+
else if (SCANNABLE_EXT.test(name)) out.push(abs)
|
|
310
397
|
}
|
|
311
398
|
return out
|
|
312
399
|
}
|
|
313
400
|
|
|
401
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
// Resolution — where basalt is installed, and where the consumer's basalt config lives
|
|
403
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/** A path relative to `fromDir`, POSIX-separated and always explicitly relative (`./` / `../`). */
|
|
406
|
+
function relativePosix(fromDir: string, toPath: string): string {
|
|
407
|
+
const rel = relative(fromDir, toPath).replace(/\\/g, '/')
|
|
408
|
+
if (rel === '') return '.'
|
|
409
|
+
return rel.startsWith('.') ? rel : `./${rel}`
|
|
410
|
+
}
|
|
411
|
+
|
|
314
412
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* `
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
'
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
413
|
+
* Where `basalt-ui` actually resolves from, seen from a consumer directory.
|
|
414
|
+
*
|
|
415
|
+
* Every toolchain seam basalt seeds (`.oxlintrc.json`'s `extends`, `lefthook.yml`'s `extends`, the
|
|
416
|
+
* CI `run:` steps) used to hardcode `./node_modules/basalt-ui` at the repo ROOT. Under bun's
|
|
417
|
+
* isolated linker a library package that declares basalt a peer + devDependency gets
|
|
418
|
+
* `<pkg>/node_modules/basalt-ui` and NOTHING at the root, so all three silently failed to resolve
|
|
419
|
+
* and `bunx` fetched a second, different copy from npm instead. Resolution therefore walks
|
|
420
|
+
* OUTWARD from the consumer dir first, then across the workspace packages — and every caller
|
|
421
|
+
* renders its paths from the answer rather than assuming the root.
|
|
422
|
+
*/
|
|
423
|
+
type BasaltInstall = {
|
|
424
|
+
/** Absolute path of the resolved `node_modules/basalt-ui` directory, or null when unresolvable. */
|
|
425
|
+
dir: string | null
|
|
426
|
+
/** The resolved copy's own version, or null. */
|
|
427
|
+
version: string | null
|
|
428
|
+
/** How it was found — `null` when unresolved. `'workspace'` is the isolated-linker case. */
|
|
429
|
+
how: 'cwd' | 'ancestor' | 'workspace' | null
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const UNRESOLVED_INSTALL: BasaltInstall = { dir: null, version: null, how: null }
|
|
433
|
+
|
|
434
|
+
/** `<dir>/node_modules/basalt-ui` when it holds a readable package.json, else null. */
|
|
435
|
+
function basaltInstallAt(dir: string): { dir: string; version: string | null } | null {
|
|
436
|
+
const installDir = resolve(dir, 'node_modules', 'basalt-ui')
|
|
437
|
+
const raw = readIfExists(resolve(installDir, 'package.json'))
|
|
438
|
+
if (raw === null) return null
|
|
439
|
+
try {
|
|
440
|
+
const pkg = JSON.parse(raw) as { version?: string }
|
|
441
|
+
return { dir: installDir, version: pkg.version ?? null }
|
|
442
|
+
} catch {
|
|
443
|
+
return { dir: installDir, version: null }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Resolve the installed basalt-ui: cwd, then every ancestor, then the workspace packages. */
|
|
448
|
+
function findBasaltInstall(cwd: string): BasaltInstall {
|
|
449
|
+
const own = basaltInstallAt(cwd)
|
|
450
|
+
if (own !== null) return { ...own, how: 'cwd' }
|
|
451
|
+
|
|
452
|
+
let current = dirname(cwd)
|
|
453
|
+
for (;;) {
|
|
454
|
+
const found = basaltInstallAt(current)
|
|
455
|
+
if (found !== null) return { ...found, how: 'ancestor' }
|
|
456
|
+
const parent = dirname(current)
|
|
457
|
+
if (parent === current) break
|
|
458
|
+
current = parent
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const pkg of collectWorkspacePackages(cwd).packages) {
|
|
462
|
+
if (pkg.dir === cwd) continue
|
|
463
|
+
const found = basaltInstallAt(pkg.dir)
|
|
464
|
+
if (found !== null) return { ...found, how: 'workspace' }
|
|
465
|
+
}
|
|
466
|
+
return UNRESOLVED_INSTALL
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The `extends`/`run:` path a seeded config should carry to reach a shipped asset — rendered from
|
|
471
|
+
* where basalt ACTUALLY resolves, relative to the directory the config lives in. Falls back to the
|
|
472
|
+
* root-relative form when nothing resolves (a pre-install `init`), so the seed is still the shape a
|
|
473
|
+
* later `bun install` makes true.
|
|
474
|
+
*/
|
|
475
|
+
function shippedAssetPath(install: BasaltInstall, fromDir: string, asset: string): string {
|
|
476
|
+
if (install.dir === null) return `./node_modules/basalt-ui/${asset}`
|
|
477
|
+
return `${relativePosix(fromDir, install.dir)}/${asset}`
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** True when `dir` is a basalt-configured project — it carries the manifest or a `basalt` key. */
|
|
481
|
+
function hasBasaltProject(dir: string): boolean {
|
|
482
|
+
if (existsSync(resolve(dir, MANIFEST_PATH))) return true
|
|
483
|
+
const raw = readIfExists(resolve(dir, 'package.json'))
|
|
484
|
+
if (raw === null) return false
|
|
485
|
+
try {
|
|
486
|
+
return (JSON.parse(raw) as { basalt?: unknown }).basalt !== undefined
|
|
487
|
+
} catch {
|
|
488
|
+
return false
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Which directory a project-scoped command (`check-theme`, `doctor`) should actually read.
|
|
494
|
+
*
|
|
495
|
+
* The shipped lefthook preset and every `bunx basalt-ui …` in the seeded CI run at the REPO ROOT,
|
|
496
|
+
* while in a monorepo the `basalt` config and the manifest live in a package below it — verified to
|
|
497
|
+
* produce `0 files scanned` and a `manifest missing` failure, i.e. a gate that never gates. Rather
|
|
498
|
+
* than make every consumer hand-write a `--cwd`, a command with no basalt project at its own cwd
|
|
499
|
+
* relocates to the single workspace package that has one, and says so. Two or more candidates is
|
|
500
|
+
* genuinely ambiguous and reported as such; `BASALT_CWD` is the explicit override.
|
|
501
|
+
*/
|
|
502
|
+
type ProjectResolution = {
|
|
503
|
+
/** The directory to read. Equals the invocation cwd unless a relocation happened. */
|
|
504
|
+
dir: string
|
|
505
|
+
/** The invocation cwd when the command relocated away from it, else null. */
|
|
506
|
+
relocatedFrom: string | null
|
|
507
|
+
/** Candidate project dirs when relocation was impossible because several exist, else null. */
|
|
508
|
+
ambiguous: string[] | null
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
512
|
+
const override = process.env['BASALT_CWD']
|
|
513
|
+
if (override !== undefined && override.length > 0) {
|
|
514
|
+
const dir = isAbsolute(override) ? override : resolve(cwd, override)
|
|
515
|
+
return { dir, relocatedFrom: dir === cwd ? null : cwd, ambiguous: null }
|
|
516
|
+
}
|
|
517
|
+
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
518
|
+
|
|
519
|
+
const candidates = collectWorkspacePackages(cwd)
|
|
520
|
+
.packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
|
|
521
|
+
.map((pkg) => pkg.dir)
|
|
522
|
+
if (candidates.length === 1) {
|
|
523
|
+
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
524
|
+
}
|
|
525
|
+
if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates }
|
|
526
|
+
return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Which shape of consumer a project-scoped command is looking at.
|
|
531
|
+
*
|
|
532
|
+
* A tokens-only consumer took the `--vx-*` layer and nothing else. Every guard kind whose remedy is
|
|
533
|
+
* a Mantine component or the React theme factory is meaningless there — telling a Mantine-free app
|
|
534
|
+
* to swap its `<select>` for `@mantine/core`'s `Select` is advice it must not take.
|
|
535
|
+
*
|
|
536
|
+
* DECLARED, never inferred, when the answer SILENCES something. The two callers want opposite
|
|
537
|
+
* failure directions and so do not share a default:
|
|
538
|
+
*
|
|
539
|
+
* - `check-theme` (`declaredProfile`) turns 17 kinds OFF, so it moves on an explicit signal only —
|
|
540
|
+
* `--tokens-only`, or `"basalt": { "profile": "tokens-only" }`. Inferring it from the ABSENCE of
|
|
541
|
+
* `@mantine/core` would silence the Mantine half of the guard on any repo that keeps its Mantine
|
|
542
|
+
* dependency in a workspace package rather than the one holding the basalt config, which is
|
|
543
|
+
* precisely this round's "reports green while enforcing nothing" failure with the guard's own
|
|
544
|
+
* hand on the switch.
|
|
545
|
+
* - `doctor` ({@link inferredProfile}) only changes which ADVICE it prints, never what it enforces,
|
|
546
|
+
* so it may infer from absence and then tell the consumer to write the key down.
|
|
547
|
+
*/
|
|
548
|
+
function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
549
|
+
if (flags.includes('--tokens-only')) return 'tokens-only'
|
|
550
|
+
if (flags.includes('--framework')) return 'framework'
|
|
551
|
+
return cfg.profile === 'tokens-only' ? 'tokens-only' : 'framework'
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* `--tokens-only` and `--framework` are alternatives, so passing both is a contradiction rather
|
|
556
|
+
* than a precedence question — the same reading `tokens:css` already applies to `--selector-class`
|
|
557
|
+
* vs `--selector-attribute`. Silently preferring one meant a CI step that accumulated both flags
|
|
558
|
+
* enforced whichever the code happened to test first.
|
|
559
|
+
*/
|
|
560
|
+
function conflictingProfileFlags(flags: readonly string[]): boolean {
|
|
561
|
+
return flags.includes('--tokens-only') && flags.includes('--framework')
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** True when nothing anywhere in the workspace declares Mantine — see {@link inferredProfile}. */
|
|
565
|
+
function workspaceHasMantine(cwd: string): boolean {
|
|
566
|
+
if (hasDependency(cwd, '@mantine/core')) return true
|
|
567
|
+
return collectWorkspacePackages(cwd).packages.some((pkg) =>
|
|
568
|
+
hasDependency(pkg.dir, '@mantine/core'),
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* `doctor`'s profile: the declaration if there is one, else inferred from no scaffold manifest and
|
|
574
|
+
* no `@mantine/core` anywhere in the workspace. Advice only — see {@link declaredProfile}.
|
|
575
|
+
*/
|
|
576
|
+
function inferredProfile(cwd: string, cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
577
|
+
if (
|
|
578
|
+
flags.includes('--tokens-only') ||
|
|
579
|
+
flags.includes('--framework') ||
|
|
580
|
+
cfg.profile !== undefined
|
|
581
|
+
) {
|
|
582
|
+
return declaredProfile(cfg, flags)
|
|
583
|
+
}
|
|
584
|
+
return !existsSync(resolve(cwd, MANIFEST_PATH)) && !workspaceHasMantine(cwd)
|
|
585
|
+
? 'tokens-only'
|
|
586
|
+
: 'framework'
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* The relative paths `check-theme` would actually scan for a given config — the ONE place the walk
|
|
591
|
+
* + `SKIP` + `exempt` filtering lives, so `doctor`'s "the guard sees zero files" check can never
|
|
592
|
+
* disagree with what `check-theme` does.
|
|
593
|
+
*/
|
|
594
|
+
function scannableFiles(cwd: string, cfg: BasaltConfig): string[] {
|
|
595
|
+
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg))
|
|
596
|
+
const seen = new Set<string>()
|
|
597
|
+
const out: string[] = []
|
|
598
|
+
const add = (abs: string): void => {
|
|
599
|
+
// Normalize to forward slashes so path matching (SKIP, exempt, isChartFile) is
|
|
600
|
+
// identical on Windows (where `relative` yields backslashes) and POSIX.
|
|
601
|
+
const rel = relative(cwd, abs).replace(/\\/g, '/')
|
|
602
|
+
if (rel.startsWith('..') || SKIP.test(rel) || exempt.has(rel) || seen.has(rel)) return
|
|
603
|
+
seen.add(rel)
|
|
604
|
+
out.push(rel)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const root of resolveRoots(cfg)) {
|
|
608
|
+
const rootAbs = resolve(cwd, root)
|
|
609
|
+
for (const f of walkSourceFiles(rootAbs)) add(f)
|
|
610
|
+
for (const f of appShellFiles(rootAbs)) add(f)
|
|
611
|
+
}
|
|
612
|
+
// Explicitly named files, the one route by which a `.json` is ever scanned.
|
|
613
|
+
for (const rel of cfg.include ?? []) add(resolve(cwd, rel))
|
|
614
|
+
return out
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The app-shell files that sit BESIDE a source root rather than inside it — `index.html` and
|
|
619
|
+
* anything under `public/`.
|
|
620
|
+
*
|
|
621
|
+
* Widening the walker's extension filter alone would still have missed every one of them: argo
|
|
622
|
+
* configures `roots: ['apps/dashboard/src']`, while the raw hex lives in `apps/dashboard/index.html`
|
|
623
|
+
* and `apps/dashboard/public/site.webmanifest`. Both locations are the Vite convention basalt's own
|
|
624
|
+
* `basaltViteConfig`/`basaltAppPlugin` assume, so deriving them from each root's parent needs no
|
|
625
|
+
* config and holds for a monorepo package the same way it holds at a repo root. Nothing else in the
|
|
626
|
+
* parent is walked — a sibling `docs/` full of throwaway HTML stays unscanned.
|
|
627
|
+
*/
|
|
628
|
+
function appShellFiles(rootAbs: string): string[] {
|
|
629
|
+
const appDir = dirname(rootAbs)
|
|
630
|
+
const out: string[] = []
|
|
631
|
+
for (const name of ['index.html', 'index.htm']) {
|
|
632
|
+
const abs = resolve(appDir, name)
|
|
633
|
+
if (existsSync(abs)) out.push(abs)
|
|
634
|
+
}
|
|
635
|
+
out.push(...walkSourceFiles(resolve(appDir, 'public')))
|
|
636
|
+
return out
|
|
363
637
|
}
|
|
364
638
|
|
|
365
639
|
/**
|
|
366
640
|
* Theme guard — thin FS walker over the headless `../guard` core. Reads BasaltConfig, builds a
|
|
367
641
|
* GuardConfig, walks roots, calls checkSource per file, collects Finding[], groups/reports, returns
|
|
368
642
|
* 0 (clean) / 1 (violations). A `theme-allow` comment exempts a line.
|
|
643
|
+
*
|
|
644
|
+
* Runs against `resolveProjectDir(cwd)`, not `cwd` — see that type's doc for why a root-invoked
|
|
645
|
+
* hook or CI step has to find the package the config lives in rather than scan nothing and pass.
|
|
369
646
|
*/
|
|
370
|
-
export function checkTheme(
|
|
647
|
+
export function checkTheme(
|
|
648
|
+
invocationCwd: string = process.cwd(),
|
|
649
|
+
flags: readonly string[] = [],
|
|
650
|
+
): number {
|
|
651
|
+
if (conflictingProfileFlags(flags)) {
|
|
652
|
+
console.error(
|
|
653
|
+
'basalt-ui check-theme: --tokens-only and --framework are alternatives — pass one.',
|
|
654
|
+
)
|
|
655
|
+
return 1
|
|
656
|
+
}
|
|
657
|
+
const project = resolveProjectDir(invocationCwd)
|
|
658
|
+
if (project.ambiguous !== null) {
|
|
659
|
+
console.error(
|
|
660
|
+
`✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
661
|
+
`workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
662
|
+
'run it from one of them, or set BASALT_CWD to pick.',
|
|
663
|
+
)
|
|
664
|
+
return 1
|
|
665
|
+
}
|
|
666
|
+
const cwd = project.dir
|
|
667
|
+
if (project.relocatedFrom !== null) {
|
|
668
|
+
console.log(
|
|
669
|
+
`basalt-ui check-theme: no basalt config at ${project.relocatedFrom} — running in ` +
|
|
670
|
+
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.`,
|
|
671
|
+
)
|
|
672
|
+
}
|
|
371
673
|
const cfg = readBasaltConfig(cwd)
|
|
372
|
-
const roots = cfg
|
|
373
|
-
const
|
|
674
|
+
const roots = resolveRoots(cfg)
|
|
675
|
+
const profile = declaredProfile(cfg, flags)
|
|
676
|
+
if (profile === 'tokens-only') {
|
|
677
|
+
console.log(
|
|
678
|
+
'basalt-ui check-theme: tokens-only profile — ' +
|
|
679
|
+
`${TOKENS_ONLY_DISABLED_KINDS.size} Mantine-coupled kinds are off; the color and typography ` +
|
|
680
|
+
'kinds still apply. Pass --framework to force the full set.',
|
|
681
|
+
)
|
|
682
|
+
}
|
|
374
683
|
|
|
375
684
|
const guardCfg: GuardConfig = {
|
|
376
685
|
spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
|
|
@@ -390,25 +699,18 @@ export function checkTheme(cwd: string = process.cwd()): number {
|
|
|
390
699
|
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
391
700
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
392
701
|
allowComment: 'theme-allow',
|
|
393
|
-
exemptRules: cfg
|
|
702
|
+
exemptRules: resolveExemptRules(cfg),
|
|
394
703
|
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
704
|
+
...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
395
705
|
}
|
|
396
706
|
|
|
397
707
|
const findings: Finding[] = []
|
|
398
|
-
|
|
399
|
-
for (const
|
|
400
|
-
|
|
401
|
-
// Normalize to forward slashes so path matching (SKIP, exempt, isChartFile) is
|
|
402
|
-
// identical on Windows (where `relative` yields backslashes) and POSIX.
|
|
403
|
-
const rel = relative(cwd, f).replace(/\\/g, '/')
|
|
404
|
-
if (SKIP.test(rel) || exempt.has(rel)) continue
|
|
405
|
-
scannedCount += 1
|
|
406
|
-
const text = readFileSync(f, 'utf8')
|
|
407
|
-
findings.push(...checkSource(text, rel, guardCfg))
|
|
408
|
-
}
|
|
708
|
+
const scanned = scannableFiles(cwd, cfg)
|
|
709
|
+
for (const rel of scanned) {
|
|
710
|
+
findings.push(...checkSource(readFileSync(resolve(cwd, rel), 'utf8'), rel, guardCfg))
|
|
409
711
|
}
|
|
410
712
|
|
|
411
|
-
if (
|
|
713
|
+
if (scanned.length === 0) {
|
|
412
714
|
// A configured-but-wrong root is never intentional, and silently scanning 0 files under the
|
|
413
715
|
// built-in defaults (argo's pre-migration layout) is the same failure mode for every other
|
|
414
716
|
// consumer — both cases fail loud instead of warn-plus-green.
|
|
@@ -427,6 +729,25 @@ export function checkTheme(cwd: string = process.cwd()): number {
|
|
|
427
729
|
return 1
|
|
428
730
|
}
|
|
429
731
|
|
|
732
|
+
// The audit is a REPORT over the same scan, not a second scan with different rules — it runs
|
|
733
|
+
// after the 0-files gate so "every waiver is dead" can never mean "nothing was read".
|
|
734
|
+
if (flags.includes('--audit-allows')) return auditAllows(cwd, cfg, guardCfg, scanned)
|
|
735
|
+
|
|
736
|
+
// An exemption that matched nothing is reported, never silently honoured. A WARNING rather than a
|
|
737
|
+
// failure: the entry enforced exactly nothing before this line existed, so failing on it would
|
|
738
|
+
// break a green build at upgrade time over config that was already inert — `--audit-allows` is
|
|
739
|
+
// the lane that exits non-zero on a dead waiver, for a consumer who opts into that gate.
|
|
740
|
+
for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
|
|
741
|
+
console.error(
|
|
742
|
+
`⚠ basalt-ui check-theme: "basalt.exemptRules" entry ${unmatched.kind}: ` +
|
|
743
|
+
`"${unmatched.pattern}" ` +
|
|
744
|
+
(unmatched.reason === 'unknown-kind'
|
|
745
|
+
? 'names no guard kind — it exempts nothing at all (a typo, or a kind that was renamed).'
|
|
746
|
+
: 'matched none of the scanned files — it suppresses nothing, and reads as coverage in a ' +
|
|
747
|
+
'config review while enforcing as much as an empty object.'),
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
430
751
|
if (findings.length === 0) {
|
|
431
752
|
console.log('✓ Theme guard: no off-palette colors.')
|
|
432
753
|
return 0
|
|
@@ -469,16 +790,14 @@ export function checkTheme(cwd: string = process.cwd()): number {
|
|
|
469
790
|
report(errors, `✖ Theme guard: ${errors.length} off-palette / off-identity violation(s)\n`)
|
|
470
791
|
}
|
|
471
792
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
]
|
|
481
|
-
console.error(fixLine.join(' '))
|
|
793
|
+
// Remedies come from the guard's own rule registry, never from a copy kept here: the local table
|
|
794
|
+
// this replaced had no entry for any of the five kinds 1.20.0 added, so exactly the new findings
|
|
795
|
+
// — the ones whose whole argument is "this looks correct and is not" — printed no argument.
|
|
796
|
+
const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted()
|
|
797
|
+
// And the closer is per FILE CLASS: prescribing a `theme-allow` comment to a .webmanifest (which
|
|
798
|
+
// has no comment syntax) is what pushed two consumers into a blanket exemption instead.
|
|
799
|
+
const waiverHints = [...new Set(findings.map((f) => guardWaiverHint(f.relPath)))].toSorted()
|
|
800
|
+
console.error(['Fix:', ...presentKinds.map(guardKindRemedy), ...waiverHints].join(' '))
|
|
482
801
|
|
|
483
802
|
// A violation in a file that looks like the palette source itself is likely intentional — point
|
|
484
803
|
// at the exempt escape hatch instead of leaving the author to search for it.
|
|
@@ -493,6 +812,201 @@ export function checkTheme(cwd: string = process.cwd()): number {
|
|
|
493
812
|
return errors.length > 0 ? 1 : 0
|
|
494
813
|
}
|
|
495
814
|
|
|
815
|
+
// ── check-theme --audit-allows — every waiver, and whether it still suppresses anything ─────────
|
|
816
|
+
|
|
817
|
+
/** The token an audit probe substitutes for `theme-allow`. Contains no substring of it, on purpose. */
|
|
818
|
+
const AUDIT_NEUTRALIZED_TOKEN = 'basalt-audit-neutralized'
|
|
819
|
+
|
|
820
|
+
/** `relPath:line:kind` — a finding's identity for set arithmetic across two runs of the same file. */
|
|
821
|
+
function findingKey(f: Finding): string {
|
|
822
|
+
return `${f.relPath}:${f.line}:${f.kind}`
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Everything allowed between a comment opener (or the start of the line) and the token, for it to
|
|
827
|
+
* be an ANNOTATION rather than prose mentioning one.
|
|
828
|
+
*
|
|
829
|
+
* Mirrors the guard's `ANNOTATION_PREFIX`, which is module-private. It decides only which lines
|
|
830
|
+
* this REPORT lists — what each one suppresses is answered by re-running `checkSource`, so the two
|
|
831
|
+
* cannot disagree about enforcement, only about whether a doc sentence gets a line in the audit.
|
|
832
|
+
* Worth an export from the guard if it ever gains a third caller.
|
|
833
|
+
*/
|
|
834
|
+
const AUDIT_ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The rule ids an annotation NAMES, as written. Classification only — what it actually waives is
|
|
838
|
+
* decided by re-running the guard.
|
|
839
|
+
*
|
|
840
|
+
* It exists for one distinction the behavioural probe cannot make: an annotation scoped to an
|
|
841
|
+
* oxlint PLUGIN rule (`hand-rolled-plot`, `raw-scroll-container`, …) suppresses nothing `checkSource`
|
|
842
|
+
* can see, because those rules live in the plugin. Calling it dead would tell someone to delete a
|
|
843
|
+
* live waiver — the exact failure this command exists to prevent, pointed the other way.
|
|
844
|
+
*/
|
|
845
|
+
function annotationRuleIds(rest: string): string[] {
|
|
846
|
+
const head = (rest.replace(/^-file\b/, '').split(/—|–|:|\s-\s/)[0] ?? '').trim()
|
|
847
|
+
return head
|
|
848
|
+
.split(',')
|
|
849
|
+
.map((word) => word.trim().replace(/^basalt\//, ''))
|
|
850
|
+
.filter((word) => /^[a-z][a-z0-9-]*$/.test(word))
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** True when the token on this line opens an annotation — comment form, or the JSON member form. */
|
|
854
|
+
function isAllowAnnotationLine(line: string, token: string): boolean {
|
|
855
|
+
const at = line.indexOf(token)
|
|
856
|
+
if (at === -1) return false
|
|
857
|
+
if (line.includes(`"basalt:${token}`)) return true
|
|
858
|
+
return AUDIT_ANNOTATION_PREFIX.test(line.slice(0, at))
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* `check-theme --audit-allows` — list every active waiver and prove, per waiver, whether it still
|
|
863
|
+
* suppresses anything.
|
|
864
|
+
*
|
|
865
|
+
* The accountability release shipped `theme-allow-unscoped`, which reports a waiver that names no
|
|
866
|
+
* rule — but a waiver that is perfectly well written and covers a finding that no longer exists is
|
|
867
|
+
* invisible to it, and to everything else. Two consumers found five of those by hand, by editing
|
|
868
|
+
* the token out and re-running the guard. This is that method, automated: for each annotation the
|
|
869
|
+
* file is re-checked with THAT occurrence neutralized, and the findings that appear are exactly
|
|
870
|
+
* what it suppresses. Nothing is inferred from the annotation's text, so the audit cannot disagree
|
|
871
|
+
* with the scan — it IS the scan, run twice.
|
|
872
|
+
*
|
|
873
|
+
* `basalt.exemptRules` entries are audited the same way, one pattern at a time. That also answers
|
|
874
|
+
* the question the key could never answer for itself: a pattern that matches no scanned file (a
|
|
875
|
+
* real relative path, say, where the matcher takes whole path SEGMENTS) suppresses nothing and is
|
|
876
|
+
* reported as dead rather than passing silently.
|
|
877
|
+
*
|
|
878
|
+
* Exit 1 when anything is dead, so it can be wired into CI as a gate. A waiver nobody re-checks is
|
|
879
|
+
* how a rule quietly stops applying to the file that needed the exception most.
|
|
880
|
+
*/
|
|
881
|
+
function auditAllows(
|
|
882
|
+
cwd: string,
|
|
883
|
+
cfg: BasaltConfig,
|
|
884
|
+
guardCfg: GuardConfig,
|
|
885
|
+
scanned: string[],
|
|
886
|
+
): number {
|
|
887
|
+
const token = guardCfg.allowComment
|
|
888
|
+
const lines: string[] = [`\nbasalt-ui check-theme --audit-allows — ${cwd}\n`]
|
|
889
|
+
let dead = 0
|
|
890
|
+
let live = 0
|
|
891
|
+
let outOfReach = 0
|
|
892
|
+
let unaccountable = 0
|
|
893
|
+
|
|
894
|
+
// ── theme-allow annotations ─────────────────────────────────────────────────
|
|
895
|
+
const waiverLines: string[] = []
|
|
896
|
+
const sources = new Map<string, string>()
|
|
897
|
+
for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), 'utf8'))
|
|
898
|
+
|
|
899
|
+
for (const rel of scanned) {
|
|
900
|
+
const text = sources.get(rel) ?? ''
|
|
901
|
+
if (!text.includes(token)) continue
|
|
902
|
+
const baseline = checkSource(text, rel, guardCfg)
|
|
903
|
+
const baselineKeys = new Set(
|
|
904
|
+
baseline.filter((f) => f.kind !== 'theme-allow-unscoped').map(findingKey),
|
|
905
|
+
)
|
|
906
|
+
const unscopedAt = new Map(
|
|
907
|
+
baseline.filter((f) => f.kind === 'theme-allow-unscoped').map((f) => [f.line, f.token]),
|
|
908
|
+
)
|
|
909
|
+
const fileLines = text.split('\n')
|
|
910
|
+
for (const [index, line] of fileLines.entries()) {
|
|
911
|
+
if (!isAllowAnnotationLine(line ?? '', token)) continue
|
|
912
|
+
const probe = [...fileLines]
|
|
913
|
+
probe[index] = (line ?? '').replaceAll(token, AUDIT_NEUTRALIZED_TOKEN)
|
|
914
|
+
const revealed = checkSource(probe.join('\n'), rel, guardCfg)
|
|
915
|
+
.filter((f) => f.kind !== 'theme-allow-unscoped' && !baselineKeys.has(findingKey(f)))
|
|
916
|
+
.map((f) => `${f.kind}@${f.line}`)
|
|
917
|
+
const note = unscopedAt.get(index + 1)
|
|
918
|
+
if (note !== undefined) unaccountable++
|
|
919
|
+
const site = ` ${rel}:${index + 1}`.padEnd(52)
|
|
920
|
+
const suffix = note === undefined ? '' : ` [${note}]`
|
|
921
|
+
const ids = annotationRuleIds((line ?? '').slice((line ?? '').indexOf(token) + token.length))
|
|
922
|
+
if (revealed.length > 0) {
|
|
923
|
+
live++
|
|
924
|
+
waiverLines.push(`${site} suppresses ${[...new Set(revealed)].join(', ')}${suffix}`)
|
|
925
|
+
} else if (ids.length > 0 && !ids.some((id) => Object.hasOwn(GUARD_RULES, id))) {
|
|
926
|
+
outOfReach++
|
|
927
|
+
waiverLines.push(
|
|
928
|
+
`${site} scoped to ${ids.join(', ')} — not a check-theme kind, so this audit cannot ` +
|
|
929
|
+
`judge it (an oxlint plugin rule, or a typo theme-allow-unscoped would report)${suffix}`,
|
|
930
|
+
)
|
|
931
|
+
} else {
|
|
932
|
+
dead++
|
|
933
|
+
waiverLines.push(`${site} SUPPRESSES NOTHING — dead, delete it${suffix}`)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
lines.push(`${token} annotations (${waiverLines.length}):`)
|
|
939
|
+
lines.push(...(waiverLines.length === 0 ? [' (none)'] : waiverLines))
|
|
940
|
+
|
|
941
|
+
// ── basalt.exemptRules entries ──────────────────────────────────────────────
|
|
942
|
+
const exemptLines: string[] = []
|
|
943
|
+
let exemptCount = 0
|
|
944
|
+
const declared = Object.entries(cfg.exemptRules ?? {}) as [GuardKind, ExemptRuleEntry][]
|
|
945
|
+
if (declared.length > 0) {
|
|
946
|
+
const baselineKeys = new Set<string>()
|
|
947
|
+
for (const rel of scanned) {
|
|
948
|
+
for (const f of checkSource(sources.get(rel) ?? '', rel, guardCfg))
|
|
949
|
+
baselineKeys.add(findingKey(f))
|
|
950
|
+
}
|
|
951
|
+
for (const [kind, entry] of declared) {
|
|
952
|
+
const reason = exemptRuleReason(entry)
|
|
953
|
+
for (const pattern of exemptRulePaths(entry)) {
|
|
954
|
+
exemptCount++
|
|
955
|
+
const probeCfg: GuardConfig = {
|
|
956
|
+
...guardCfg,
|
|
957
|
+
exemptRules: {
|
|
958
|
+
...guardCfg.exemptRules,
|
|
959
|
+
[kind]: exemptRulePaths(entry).filter((p) => p !== pattern),
|
|
960
|
+
},
|
|
961
|
+
}
|
|
962
|
+
const revealed = new Set<string>()
|
|
963
|
+
for (const rel of scanned) {
|
|
964
|
+
for (const f of checkSource(sources.get(rel) ?? '', rel, probeCfg)) {
|
|
965
|
+
if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath)
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
const site = ` ${kind}: "${pattern}"`.padEnd(52)
|
|
969
|
+
const why = reason === null ? ' [no reason recorded]' : ` — ${reason}`
|
|
970
|
+
if (revealed.size === 0) {
|
|
971
|
+
dead++
|
|
972
|
+
const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
|
|
973
|
+
(u) => u.kind === kind && u.pattern === pattern,
|
|
974
|
+
)
|
|
975
|
+
exemptLines.push(
|
|
976
|
+
`${site} SUPPRESSES NOTHING — ` +
|
|
977
|
+
(unmatched === undefined
|
|
978
|
+
? 'the files it matches have no such finding; delete it'
|
|
979
|
+
: unmatched.reason === 'unknown-kind'
|
|
980
|
+
? 'it names no guard kind (a typo, or a renamed kind)'
|
|
981
|
+
: 'it matches no scanned file at all') +
|
|
982
|
+
why,
|
|
983
|
+
)
|
|
984
|
+
} else {
|
|
985
|
+
live++
|
|
986
|
+
exemptLines.push(
|
|
987
|
+
`${site} suppresses findings in ${[...revealed].toSorted().join(', ')}${why}`,
|
|
988
|
+
)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
lines.push(`\nbasalt.exemptRules entries (${exemptCount}):`)
|
|
994
|
+
lines.push(...(exemptLines.length === 0 ? [' (none)'] : exemptLines))
|
|
995
|
+
|
|
996
|
+
lines.push(
|
|
997
|
+
`\n${live} live, ${dead} dead, ${outOfReach} outside check-theme's reach, ${unaccountable} ` +
|
|
998
|
+
'unaccountable (reported as theme-allow-unscoped by a normal run).',
|
|
999
|
+
)
|
|
1000
|
+
if (dead > 0) {
|
|
1001
|
+
lines.push(
|
|
1002
|
+
'A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently ' +
|
|
1003
|
+
'cover the next real finding on that line.',
|
|
1004
|
+
)
|
|
1005
|
+
}
|
|
1006
|
+
console.log(lines.join('\n'))
|
|
1007
|
+
return dead > 0 ? 1 : 0
|
|
1008
|
+
}
|
|
1009
|
+
|
|
496
1010
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
497
1011
|
// init / sync — managed-files engine
|
|
498
1012
|
// ──────────────────────────────────────────────────────────────────────────────
|
|
@@ -557,6 +1071,16 @@ type TemplateVars = {
|
|
|
557
1071
|
SERIES_MODULE_PATH: string
|
|
558
1072
|
/** The consumer's configured roots as space-separated quoted oxfmt globs, e.g. `'apps/web/src/**'`. */
|
|
559
1073
|
ROOTS_GLOBS: string
|
|
1074
|
+
/** Path to the shipped oxlint preset from the consumer dir — RESOLVED, never assumed at the root. */
|
|
1075
|
+
OXLINT_PRESET_PATH: string
|
|
1076
|
+
/** Path to the shipped lefthook preset from the consumer dir — same resolution. */
|
|
1077
|
+
LEFTHOOK_PRESET_PATH: string
|
|
1078
|
+
/**
|
|
1079
|
+
* How a seeded script or CI step invokes the CLI. The locally-installed bin when basalt resolves
|
|
1080
|
+
* (so the step can never silently run a DIFFERENT version that `bunx` fetched from npm), and
|
|
1081
|
+
* `bunx basalt-ui` only as the pre-install fallback.
|
|
1082
|
+
*/
|
|
1083
|
+
BASALT_BIN: string
|
|
560
1084
|
}
|
|
561
1085
|
|
|
562
1086
|
// The block markers the CLAUDE.md template emits. The begin marker carries the framework version
|
|
@@ -603,12 +1127,20 @@ function readFrameworkVersion(pkgRoot: string): string {
|
|
|
603
1127
|
|
|
604
1128
|
/** Build the template-variable map from the framework root + consumer cwd/config. */
|
|
605
1129
|
function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
|
|
1130
|
+
const install = findBasaltInstall(cwd)
|
|
1131
|
+
const binDir = install.dir === null ? null : resolve(install.dir, '..', '.bin')
|
|
606
1132
|
return {
|
|
607
1133
|
APP_NAME: readPackageName(cwd),
|
|
608
1134
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
609
1135
|
ACCENT_HUE: cfg.accentHue ?? 'blue',
|
|
610
1136
|
SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
|
|
611
1137
|
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
|
|
1138
|
+
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/oxlint.json'),
|
|
1139
|
+
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
|
|
1140
|
+
BASALT_BIN:
|
|
1141
|
+
binDir === null
|
|
1142
|
+
? 'bunx basalt-ui'
|
|
1143
|
+
: `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`,
|
|
612
1144
|
}
|
|
613
1145
|
}
|
|
614
1146
|
|
|
@@ -658,7 +1190,12 @@ function applyBlock(host: string, block: string): string {
|
|
|
658
1190
|
* Explicit `--with-router` / `--with-query` CLI flags force-include a router/query scaffold even
|
|
659
1191
|
* when the peer isn't detected in the consumer's package.json yet (e.g. it's about to be added).
|
|
660
1192
|
*/
|
|
661
|
-
type ScaffoldFlags = {
|
|
1193
|
+
type ScaffoldFlags = {
|
|
1194
|
+
withRouter?: boolean
|
|
1195
|
+
withQuery?: boolean
|
|
1196
|
+
/** `--merge-lint`: splice the shipped preset's `extends` into an `.oxlintrc.json` init would keep. */
|
|
1197
|
+
mergeLint?: boolean
|
|
1198
|
+
}
|
|
662
1199
|
|
|
663
1200
|
/** Router/query peer presence, resolved from detection + explicit flags. */
|
|
664
1201
|
type PeerFlags = { hasRouter: boolean; hasQuery: boolean }
|
|
@@ -854,12 +1391,31 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
|
|
|
854
1391
|
dest: 'lefthook.yml',
|
|
855
1392
|
mode: 'seed',
|
|
856
1393
|
source: 'configs/lefthook.yml',
|
|
857
|
-
render: () =>
|
|
1394
|
+
render: (ctx) =>
|
|
858
1395
|
'# Seeded by basalt-ui init — you own this file; sync never touches it again.\n' +
|
|
859
1396
|
'# The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and\n' +
|
|
860
1397
|
'# auto-updates with the basalt-ui package. Add your own commands alongside.\n' +
|
|
1398
|
+
'# The path is RESOLVED from where basalt-ui actually installed, not assumed at the\n' +
|
|
1399
|
+
'# repo root — under an isolated linker it lives beside the package that depends on it.\n' +
|
|
1400
|
+
'# Run `lefthook install` once: the file is inert until the git hooks are written.\n' +
|
|
1401
|
+
'#\n' +
|
|
1402
|
+
'# NOTE: an extends target WINS on a colliding key — a `run:`/`glob:` you write under one of\n' +
|
|
1403
|
+
"# the preset's command names is discarded silently. `env:`, `exclude:`, `skip:` and your own\n" +
|
|
1404
|
+
'# command names merge. See the preset itself for the sanctioned override seams.\n' +
|
|
861
1405
|
'extends:\n' +
|
|
862
|
-
|
|
1406
|
+
` - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, '')}\n` +
|
|
1407
|
+
// The one seam that has to be RENDERED rather than documented: the preset is read in place
|
|
1408
|
+
// out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
|
|
1409
|
+
// default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
|
|
1410
|
+
// copy; this pins it to the local one whenever basalt actually resolved.
|
|
1411
|
+
(ctx.vars.BASALT_BIN.startsWith('bunx ')
|
|
1412
|
+
? ''
|
|
1413
|
+
: '\n# The theme guard runs the LOCAL bin, not a copy `bunx` fetches from npm.\n' +
|
|
1414
|
+
'pre-commit:\n' +
|
|
1415
|
+
' commands:\n' +
|
|
1416
|
+
' check-theme:\n' +
|
|
1417
|
+
' env:\n' +
|
|
1418
|
+
` BASALT_BIN: ${ctx.vars.BASALT_BIN}\n`),
|
|
863
1419
|
}
|
|
864
1420
|
|
|
865
1421
|
// CI is inherently repo-shaped (a monorepo's check.yml needs its own scripts), and GitHub Actions
|
|
@@ -885,7 +1441,7 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
|
|
|
885
1441
|
dest: '.oxlintrc.json',
|
|
886
1442
|
mode: 'seed',
|
|
887
1443
|
source: 'configs/oxlint.json',
|
|
888
|
-
render: () =>
|
|
1444
|
+
render: (ctx) => `{\n "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]\n}\n`,
|
|
889
1445
|
}
|
|
890
1446
|
|
|
891
1447
|
// ── Seed scaffolds: written by init, never overwritten by sync ─────────────
|
|
@@ -1157,17 +1713,385 @@ function renderContext(cwd: string): RenderContext {
|
|
|
1157
1713
|
return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) }
|
|
1158
1714
|
}
|
|
1159
1715
|
|
|
1716
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1717
|
+
// init — describing the repo it is actually in
|
|
1718
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1719
|
+
|
|
1720
|
+
/**
|
|
1721
|
+
* Read → mutate → write the consumer's package.json, preserving its own indentation and trailing
|
|
1722
|
+
* newline. `mutate` returns false to abort the write (nothing to change). Returns whether a write
|
|
1723
|
+
* happened. Deliberately a JSON round-trip rather than a textual splice: key ORDER is preserved by
|
|
1724
|
+
* `JSON.parse`/`stringify`, and anything fancier would be a formatter this CLI has no business being.
|
|
1725
|
+
*/
|
|
1726
|
+
function patchPackageJson(cwd: string, mutate: (pkg: Record<string, unknown>) => boolean): boolean {
|
|
1727
|
+
const abs = resolve(cwd, 'package.json')
|
|
1728
|
+
const raw = readIfExists(abs)
|
|
1729
|
+
if (raw === null) return false
|
|
1730
|
+
let pkg: Record<string, unknown>
|
|
1731
|
+
try {
|
|
1732
|
+
pkg = JSON.parse(raw) as Record<string, unknown>
|
|
1733
|
+
} catch {
|
|
1734
|
+
return false
|
|
1735
|
+
}
|
|
1736
|
+
if (!mutate(pkg)) return false
|
|
1737
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? ' '
|
|
1738
|
+
writeFileSync(abs, JSON.stringify(pkg, null, indent) + (raw.endsWith('\n') ? '\n' : ''))
|
|
1739
|
+
return true
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/**
|
|
1743
|
+
* The source roots for THIS repo, inferred from its actual layout — the fix for the single most
|
|
1744
|
+
* reported false-green: without `basalt.roots` a workspace repo scaffolds a `check-theme` that
|
|
1745
|
+
* scans zero files and a CI `oxfmt 'src/**'` that matches nothing, while `doctor` reports green.
|
|
1746
|
+
*
|
|
1747
|
+
* A workspace repo resolves to the `src/` of every workspace package that depends on basalt or
|
|
1748
|
+
* Mantine (a backend package's `src/` has nothing for a design guard to say); with no such package
|
|
1749
|
+
* it falls back to every workspace `src/`, and a single-package repo to plain `src`. Empty when
|
|
1750
|
+
* nothing plausible exists — `init` says so rather than writing a root that matches nothing.
|
|
1751
|
+
*/
|
|
1752
|
+
function detectRoots(cwd: string): string[] {
|
|
1753
|
+
const workspacePackages = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd)
|
|
1754
|
+
const withSrc = workspacePackages.filter((pkg) => existsSync(resolve(pkg.dir, 'src')))
|
|
1755
|
+
const uiPackages = withSrc.filter(
|
|
1756
|
+
(pkg) => hasDependency(pkg.dir, 'basalt-ui') || hasDependency(pkg.dir, '@mantine/core'),
|
|
1757
|
+
)
|
|
1758
|
+
const chosen = uiPackages.length > 0 ? uiPackages : withSrc
|
|
1759
|
+
if (chosen.length > 0) {
|
|
1760
|
+
return chosen.map((pkg) => relativePosix(cwd, resolve(pkg.dir, 'src')).replace(/^\.\//, ''))
|
|
1761
|
+
}
|
|
1762
|
+
return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : []
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
/** What `reconcileRoots` found — the one roots decision `init` and `sync` both report from. */
|
|
1766
|
+
type RootsState =
|
|
1767
|
+
/** The key was absent and has just been written. */
|
|
1768
|
+
| { readonly kind: 'wrote'; readonly roots: string[] }
|
|
1769
|
+
/** The key is absent and nothing plausible could be inferred. */
|
|
1770
|
+
| { readonly kind: 'undetectable' }
|
|
1771
|
+
/** The key is already declared. `unscanned` names inferable roots it does not cover. */
|
|
1772
|
+
| { readonly kind: 'declared'; readonly declared: string[]; readonly unscanned: string[] }
|
|
1773
|
+
|
|
1774
|
+
/** True when `root` is `detected` itself or an ancestor of it. */
|
|
1775
|
+
function rootCovers(root: string, detected: string): boolean {
|
|
1776
|
+
const r = root.replace(/\/+$/, '')
|
|
1777
|
+
return detected === r || detected.startsWith(`${r}/`)
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Read — and when absent and `write` is set, seed — `basalt.roots`.
|
|
1782
|
+
*
|
|
1783
|
+
* Shared by `init` and `sync` because the key going missing is the SAME false green in both: the
|
|
1784
|
+
* built-in `src` default happens to resolve from a package cwd, so `guard-scan` passes, `check-theme`
|
|
1785
|
+
* scans something, and the repo is silently guarding a subset of itself. `init` wrote it and `sync`
|
|
1786
|
+
* did not — which meant the fix reached new scaffolds only, while every existing consumer (the ones
|
|
1787
|
+
* who by definition upgrade rather than scaffold) stayed on the undeclared default.
|
|
1788
|
+
*
|
|
1789
|
+
* A DECLARED value is never overwritten, not even when the layout disagrees with it. `roots` is a
|
|
1790
|
+
* subset by construction in a monorepo — a React-free package deliberately left out is the normal
|
|
1791
|
+
* case, not drift — so a disagreement is reported as one line naming what is not covered, and the
|
|
1792
|
+
* consumer decides. Silently widening someone's deliberate scope would turn an upgrade into an
|
|
1793
|
+
* unplanned lint debt.
|
|
1794
|
+
*/
|
|
1795
|
+
function reconcileRoots(cwd: string, opts: { write: boolean }): RootsState {
|
|
1796
|
+
const declared = readBasaltConfig(cwd).roots
|
|
1797
|
+
const detected = detectRoots(cwd)
|
|
1798
|
+
if (declared !== undefined) {
|
|
1799
|
+
const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)))
|
|
1800
|
+
return { kind: 'declared', declared, unscanned }
|
|
1801
|
+
}
|
|
1802
|
+
if (detected.length === 0) return { kind: 'undetectable' }
|
|
1803
|
+
if (!opts.write) return { kind: 'declared', declared: [], unscanned: detected }
|
|
1804
|
+
const wrote = patchPackageJson(cwd, (pkg) => {
|
|
1805
|
+
const basalt = (pkg['basalt'] ?? {}) as Record<string, unknown>
|
|
1806
|
+
if (basalt['roots'] !== undefined) return false
|
|
1807
|
+
pkg['basalt'] = { ...basalt, roots: detected }
|
|
1808
|
+
return true
|
|
1809
|
+
})
|
|
1810
|
+
return wrote ? { kind: 'wrote', roots: detected } : { kind: 'undetectable' }
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/** The lines `init`/`sync` print for a roots reconciliation. Empty when there is nothing to say. */
|
|
1814
|
+
function rootsNotices(state: RootsState): { log: string[]; error: string[] } {
|
|
1815
|
+
if (state.kind === 'wrote') {
|
|
1816
|
+
return {
|
|
1817
|
+
log: [
|
|
1818
|
+
`wrote "basalt": { "roots": ${JSON.stringify(state.roots)} } to package.json — ` +
|
|
1819
|
+
'check-theme, the CI oxfmt globs and the default scan exemption all derive from it. ' +
|
|
1820
|
+
'Correct it if your sources live elsewhere.',
|
|
1821
|
+
],
|
|
1822
|
+
error: [],
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (state.kind === 'undetectable') {
|
|
1826
|
+
return {
|
|
1827
|
+
log: [],
|
|
1828
|
+
error: [
|
|
1829
|
+
'could not infer "basalt.roots" — no src/ here and no workspace package with one. Set ' +
|
|
1830
|
+
'"basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with ' +
|
|
1831
|
+
'"0 files scanned".',
|
|
1832
|
+
],
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (state.unscanned.length === 0) return { log: [], error: [] }
|
|
1836
|
+
if (state.declared.length === 0) {
|
|
1837
|
+
return {
|
|
1838
|
+
log: [],
|
|
1839
|
+
error: [
|
|
1840
|
+
`"basalt.roots" is not set — the guard is running on the built-in default ("${DEFAULT_ROOT}"). ` +
|
|
1841
|
+
`This layout suggests ${JSON.stringify(state.unscanned)}; declare it in package.json.`,
|
|
1842
|
+
],
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
return {
|
|
1846
|
+
log: [
|
|
1847
|
+
`keeping your "basalt.roots" (${JSON.stringify(state.declared)}) — never overwritten. The ` +
|
|
1848
|
+
`layout also has ${JSON.stringify(state.unscanned)}, which nothing scans; add it if it ` +
|
|
1849
|
+
'should be guarded, or leave it if the exclusion is deliberate.',
|
|
1850
|
+
],
|
|
1851
|
+
error: [],
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* Strip JSONC comments (and trailing commas) so `.oxlintrc.json` can be READ. oxlint accepts JSONC
|
|
1857
|
+
* and real consumer configs use it — a plain `JSON.parse` there reported "not valid JSON" on a
|
|
1858
|
+
* perfectly good config. String-aware, so a `//` inside a value survives.
|
|
1859
|
+
*
|
|
1860
|
+
* Only ever used for reading. The trailing-comma pass is a plain regex and could in principle mangle
|
|
1861
|
+
* a `, }` sequence inside a string; the failure direction is a parse error, which the caller already
|
|
1862
|
+
* treats as "cannot tell", never as "passes".
|
|
1863
|
+
*/
|
|
1864
|
+
function stripJsonc(text: string): string {
|
|
1865
|
+
let out = ''
|
|
1866
|
+
let inString = false
|
|
1867
|
+
let escaped = false
|
|
1868
|
+
let i = 0
|
|
1869
|
+
while (i < text.length) {
|
|
1870
|
+
const ch = text[i] as string
|
|
1871
|
+
if (inString) {
|
|
1872
|
+
out += ch
|
|
1873
|
+
if (escaped) escaped = false
|
|
1874
|
+
else if (ch === '\\') escaped = true
|
|
1875
|
+
else if (ch === '"') inString = false
|
|
1876
|
+
i += 1
|
|
1877
|
+
continue
|
|
1878
|
+
}
|
|
1879
|
+
if (ch === '"') {
|
|
1880
|
+
inString = true
|
|
1881
|
+
out += ch
|
|
1882
|
+
i += 1
|
|
1883
|
+
continue
|
|
1884
|
+
}
|
|
1885
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
1886
|
+
while (i < text.length && text[i] !== '\n') i += 1
|
|
1887
|
+
continue
|
|
1888
|
+
}
|
|
1889
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
1890
|
+
i += 2
|
|
1891
|
+
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1
|
|
1892
|
+
i += 2
|
|
1893
|
+
continue
|
|
1894
|
+
}
|
|
1895
|
+
out += ch
|
|
1896
|
+
i += 1
|
|
1897
|
+
}
|
|
1898
|
+
return out.replace(/,(\s*[}\]])/g, '$1')
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/** Parse a JSONC document, or null when it does not parse at all. */
|
|
1902
|
+
function parseJsonc(text: string): Record<string, unknown> | null {
|
|
1903
|
+
try {
|
|
1904
|
+
return JSON.parse(stripJsonc(text)) as Record<string, unknown>
|
|
1905
|
+
} catch {
|
|
1906
|
+
return null
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
|
|
1911
|
+
type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* The `extends` entry pointing at the shipped oxlint preset, or null when none does.
|
|
1915
|
+
*
|
|
1916
|
+
* Returns the ENTRY rather than a boolean because the string alone proves nothing: a consumer that
|
|
1917
|
+
* upgraded (or moved to an isolated linker) keeps a perfectly well-shaped
|
|
1918
|
+
* `./node_modules/basalt-ui/configs/oxlint.json` that resolves to nothing, and oxlint then refuses
|
|
1919
|
+
* to start with `NotFound` while a check built to prove the framework is ON reports green. The
|
|
1920
|
+
* caller resolves this against the config's own directory — see `extendsSeam`.
|
|
1921
|
+
*/
|
|
1922
|
+
function basaltPresetEntry(entries: unknown): string | null {
|
|
1923
|
+
if (!Array.isArray(entries)) return null
|
|
1924
|
+
const match = entries.find(
|
|
1925
|
+
(entry): entry is string =>
|
|
1926
|
+
typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
|
|
1927
|
+
)
|
|
1928
|
+
return match ?? null
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// ── The two `extends` seams init writes once and nothing ever revisits ───────────────────────────
|
|
1932
|
+
//
|
|
1933
|
+
// `.oxlintrc.json` and `lefthook.yml` are consumer-owned after the first write: `sync` never
|
|
1934
|
+
// touches either. So 1.20.0's path-resolution fix reached NEW scaffolds only — every existing
|
|
1935
|
+
// monorepo consumer kept a root-relative `extends` that resolves to nothing. oxlint at least
|
|
1936
|
+
// hard-errors (`NotFound`). lefthook does not: a missing extends target merges to ZERO commands and
|
|
1937
|
+
// exits 0, so `lefthook dump` is clean and the repo silently has no pre-commit gate at all.
|
|
1938
|
+
//
|
|
1939
|
+
// Both seams therefore need a checker that resolves the path rather than pattern-matching the
|
|
1940
|
+
// string, and both `doctor` (the gate) and `sync` (the command an upgrade actually runs) report
|
|
1941
|
+
// through it.
|
|
1942
|
+
|
|
1943
|
+
const LEFTHOOK_CONFIG_NAMES = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml']
|
|
1944
|
+
|
|
1945
|
+
/** The lefthook config present at `dir` (repo-root-relative name), or null when there is none. */
|
|
1946
|
+
function findLefthookConfig(dir: string): string | null {
|
|
1947
|
+
return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* The `extends:` entries of a lefthook YAML — the block-sequence form init seeds and the inline
|
|
1952
|
+
* array form a consumer may write instead.
|
|
1953
|
+
*
|
|
1954
|
+
* A three-line reader rather than a YAML dependency: this parses ONE top-level key whose value is a
|
|
1955
|
+
* list of plain scalars, and a wrong answer costs a diagnostic, never a write.
|
|
1956
|
+
*/
|
|
1957
|
+
function yamlUnquote(raw: string): string {
|
|
1958
|
+
return raw.trim().replace(/^['"]|['"]$/g, '')
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
function yamlExtendsEntries(text: string): string[] {
|
|
1962
|
+
const lines = text.split('\n')
|
|
1963
|
+
const out: string[] = []
|
|
1964
|
+
for (const [index, line] of lines.entries()) {
|
|
1965
|
+
const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? '')
|
|
1966
|
+
if (inline !== null) {
|
|
1967
|
+
out.push(...(inline[1] ?? '').split(',').map(yamlUnquote).filter(Boolean))
|
|
1968
|
+
continue
|
|
1969
|
+
}
|
|
1970
|
+
if (!/^extends:\s*(#.*)?$/.test(line ?? '')) continue
|
|
1971
|
+
for (const next of lines.slice(index + 1)) {
|
|
1972
|
+
if (/^\s*(#.*)?$/.test(next ?? '')) continue
|
|
1973
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? '')
|
|
1974
|
+
if (item === null) break
|
|
1975
|
+
out.push(yamlUnquote(item[1] ?? ''))
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
return out
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/** The state of one shipped-preset `extends` seam in a consumer-owned config. */
|
|
1982
|
+
type ExtendsSeam =
|
|
1983
|
+
/** No such config file — nothing is wired, and that is a legitimate consumer choice. */
|
|
1984
|
+
| { readonly kind: 'no-file' }
|
|
1985
|
+
/** The file exists but names no basalt preset. */
|
|
1986
|
+
| { readonly kind: 'unwired'; readonly file: string }
|
|
1987
|
+
/** It names one, and the path resolves to nothing — the silent-gate case. */
|
|
1988
|
+
| { readonly kind: 'broken'; readonly file: string; readonly entry: string }
|
|
1989
|
+
| { readonly kind: 'ok'; readonly file: string; readonly entry: string }
|
|
1990
|
+
|
|
1991
|
+
/**
|
|
1992
|
+
* Resolve a lefthook config's basalt `extends` entry against the directory the config lives in —
|
|
1993
|
+
* which is how lefthook itself resolves it.
|
|
1994
|
+
*/
|
|
1995
|
+
function inspectLefthookSeam(repoRoot: string): ExtendsSeam {
|
|
1996
|
+
const file = findLefthookConfig(repoRoot)
|
|
1997
|
+
if (file === null) return { kind: 'no-file' }
|
|
1998
|
+
const raw = readIfExists(resolve(repoRoot, file))
|
|
1999
|
+
if (raw === null) return { kind: 'no-file' }
|
|
2000
|
+
const entry = yamlExtendsEntries(raw).find((e) => e.endsWith('basalt-ui/configs/lefthook.yml'))
|
|
2001
|
+
if (entry === undefined) return { kind: 'unwired', file }
|
|
2002
|
+
return existsSync(resolve(repoRoot, entry))
|
|
2003
|
+
? { kind: 'ok', file, entry }
|
|
2004
|
+
: { kind: 'broken', file, entry }
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Splice the shipped preset into an EXISTING `.oxlintrc.json` (`init --merge-lint`). Opt-in, not
|
|
2009
|
+
* automatic: turning the framework on adds real lint debt to previously-clean code (see the
|
|
2010
|
+
* lint-debt notice `init` prints), and that is a decision, not a scaffold step. Prepends the preset
|
|
2011
|
+
* so the consumer's own `extends` entries still win.
|
|
2012
|
+
*/
|
|
2013
|
+
function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
|
|
2014
|
+
const abs = resolve(cwd, '.oxlintrc.json')
|
|
2015
|
+
const raw = readIfExists(abs)
|
|
2016
|
+
if (raw === null) return 'absent'
|
|
2017
|
+
const cfg = parseJsonc(raw)
|
|
2018
|
+
if (cfg === null) return 'unreadable'
|
|
2019
|
+
if (basaltPresetEntry(cfg['extends']) !== null) return 'already'
|
|
2020
|
+
// Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
|
|
2021
|
+
// which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
|
|
2022
|
+
if (stripJsonc(raw) !== raw) return 'has-comments'
|
|
2023
|
+
const existing = Array.isArray(cfg['extends'])
|
|
2024
|
+
? (cfg['extends'] as unknown[]).filter((entry) => typeof entry === 'string')
|
|
2025
|
+
: []
|
|
2026
|
+
// Rebuild rather than spread, so `extends` keeps its original position when it was already there
|
|
2027
|
+
// and lands first when it wasn't — a diff a human reviews should not reshuffle the whole file.
|
|
2028
|
+
const merged: Record<string, unknown> = 'extends' in cfg ? {} : { extends: [presetPath] }
|
|
2029
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
2030
|
+
merged[key] = key === 'extends' ? [presetPath, ...existing] : value
|
|
2031
|
+
}
|
|
2032
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? ' '
|
|
2033
|
+
writeFileSync(abs, JSON.stringify(merged, null, indent) + (raw.endsWith('\n') ? '\n' : ''))
|
|
2034
|
+
return 'added'
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* What a consumer LOSES by `init` keeping a file it already had — the half the aggregate
|
|
2039
|
+
* `20 written, 2 kept` never said. A kept `.oxlintrc.json` is the headline case: the framework's
|
|
2040
|
+
* whole lint half stays off, and every gate downstream reports green over it.
|
|
2041
|
+
*/
|
|
2042
|
+
const KEPT_FILE_COST: Record<string, string> = {
|
|
2043
|
+
'.oxlintrc.json':
|
|
2044
|
+
'the shipped oxlint preset (the basalt/* design rules AND the jsx-a11y / import / promise / ' +
|
|
2045
|
+
'unicorn plugins) is NOT active — nothing else turns it on, and every gate stays green ' +
|
|
2046
|
+
'without it. Re-run with `--merge-lint` to splice the extends in, or add it by hand.',
|
|
2047
|
+
'lefthook.yml':
|
|
2048
|
+
'the pre-commit oxlint / oxfmt / check-theme jobs are not wired — add `extends: ' +
|
|
2049
|
+
'[<preset>]` to your own file.',
|
|
2050
|
+
'.github/workflows/check.yml':
|
|
2051
|
+
'CI does not run check-theme or the sync drift gate — copy those steps into your own workflow.',
|
|
2052
|
+
'.oxfmtrc.json': 'your existing formatter config stands; basalt formats nothing on its own.',
|
|
2053
|
+
'DESIGN.md': 'the design brief Claude reads is yours, not the seeded one.',
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1160
2056
|
/** Scaffold all managed files into the consumer repo, then write the manifest. Idempotent. Returns 0. */
|
|
1161
2057
|
export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags = {}): number {
|
|
2058
|
+
// ── Describe the repo BEFORE rendering anything ───────────────────────────
|
|
2059
|
+
// Every roots-derived seed (the CI oxfmt globs, DESIGN.md's series path, the default scan
|
|
2060
|
+
// exemption) renders from `basalt.roots`, so the key has to exist first. Writing it is what
|
|
2061
|
+
// stops a workspace repo scaffolding a guard that scans zero files while doctor reports green.
|
|
2062
|
+
const install = findBasaltInstall(cwd)
|
|
2063
|
+
if (install.dir === null) {
|
|
2064
|
+
console.error(
|
|
2065
|
+
`⚠ basalt-ui init: basalt-ui does not resolve from ${cwd} or any directory above it — the ` +
|
|
2066
|
+
'seeded `extends` paths and CI steps are being written against the repo root by assumption. ' +
|
|
2067
|
+
'Install basalt-ui here (or at the repo root) and re-run `basalt-ui init` so they resolve.',
|
|
2068
|
+
)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
const rootsState = reconcileRoots(cwd, { write: true })
|
|
2072
|
+
|
|
2073
|
+
// Built AFTER the roots patch — every roots-derived template variable reads the key just written.
|
|
1162
2074
|
const ctx = renderContext(cwd)
|
|
2075
|
+
|
|
2076
|
+
// `basalt-tokens.md` tells the consumer to wire `oxlint . && basalt-ui check-theme` into their
|
|
2077
|
+
// lint, and init's own closing message says to run check-theme next — but nothing ever added a
|
|
2078
|
+
// script, so the guard stayed manual for exactly as long as someone remembered it.
|
|
2079
|
+
const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`
|
|
2080
|
+
const seededLintScript = patchPackageJson(cwd, (pkg) => {
|
|
2081
|
+
const scripts = (pkg['scripts'] ?? {}) as Record<string, unknown>
|
|
2082
|
+
if (scripts['lint:basalt'] !== undefined) return false
|
|
2083
|
+
pkg['scripts'] = { ...scripts, 'lint:basalt': lintScript }
|
|
2084
|
+
return true
|
|
2085
|
+
})
|
|
2086
|
+
|
|
1163
2087
|
const manifest = readManifest(cwd)
|
|
1164
2088
|
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
1165
2089
|
const peers = resolvePeerFlags(cwd, scaffoldFlags)
|
|
1166
2090
|
const placement = resolvePlacement(cwd)
|
|
1167
2091
|
const files = managedFiles(peers, placement)
|
|
1168
2092
|
|
|
1169
|
-
|
|
1170
|
-
|
|
2093
|
+
const writtenFiles: string[] = []
|
|
2094
|
+
const keptFiles: string[] = []
|
|
1171
2095
|
const missingSources: string[] = []
|
|
1172
2096
|
|
|
1173
2097
|
for (const file of files) {
|
|
@@ -1186,25 +2110,59 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1186
2110
|
// as locally drifted (skip unless --force), never silently clobbering a file the consumer
|
|
1187
2111
|
// authored before init.
|
|
1188
2112
|
manifest.files[file.dest] = sha256(normalizeForLedger(state.desired))
|
|
1189
|
-
|
|
2113
|
+
keptFiles.push(file.dest)
|
|
1190
2114
|
continue
|
|
1191
2115
|
}
|
|
1192
2116
|
|
|
1193
2117
|
const hash = writeUnit(file, cwd, state.desired)
|
|
1194
2118
|
manifest.files[file.dest] = hash
|
|
1195
|
-
|
|
2119
|
+
writtenFiles.push(file.dest)
|
|
1196
2120
|
}
|
|
1197
2121
|
|
|
2122
|
+
const mergeLint: MergeLintResult | null =
|
|
2123
|
+
scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null
|
|
2124
|
+
|
|
1198
2125
|
manifest.basaltVersion = ctx.vars.BASALT_VERSION
|
|
1199
2126
|
manifest.spacingScale = { ...deriveSpacing(0).scale }
|
|
1200
2127
|
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
1201
2128
|
|
|
1202
|
-
console.log(
|
|
2129
|
+
console.log(
|
|
2130
|
+
`basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`,
|
|
2131
|
+
)
|
|
1203
2132
|
if (missingSources.length > 0) {
|
|
1204
2133
|
console.log(
|
|
1205
2134
|
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
|
|
1206
2135
|
)
|
|
1207
2136
|
}
|
|
2137
|
+
// Naming the kept files is the fix for the report's headline false-green: `20 written, 2 kept`
|
|
2138
|
+
// named neither the file nor the consequence, so a repo that already had an `.oxlintrc.json`
|
|
2139
|
+
// ended up with the framework's lint half off and every gate downstream reporting green.
|
|
2140
|
+
if (keptFiles.length > 0) {
|
|
2141
|
+
console.log(`\nKept (already present — basalt did not touch them):`)
|
|
2142
|
+
for (const dest of keptFiles) {
|
|
2143
|
+
const cost = KEPT_FILE_COST[dest]
|
|
2144
|
+
console.log(cost === undefined ? ` · ${dest}` : ` · ${dest} — ${cost}`)
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
if (mergeLint !== null) {
|
|
2148
|
+
const mergeMessage: Record<MergeLintResult, string> = {
|
|
2149
|
+
added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
|
|
2150
|
+
already: '.oxlintrc.json already extends the shipped preset — nothing to do',
|
|
2151
|
+
absent:
|
|
2152
|
+
'no existing .oxlintrc.json to merge into (the seeded one already extends the preset)',
|
|
2153
|
+
unreadable: '.oxlintrc.json is not valid JSON — merge the extends entry by hand',
|
|
2154
|
+
'has-comments':
|
|
2155
|
+
'.oxlintrc.json carries comments; rewriting it would delete them (usually the reason each ' +
|
|
2156
|
+
`rule is off). Add "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"] as the first entry by hand.`,
|
|
2157
|
+
}
|
|
2158
|
+
console.log(`basalt-ui init: --merge-lint — ${mergeMessage[mergeLint]}`)
|
|
2159
|
+
}
|
|
2160
|
+
const rootsMessages = rootsNotices(rootsState)
|
|
2161
|
+
for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`)
|
|
2162
|
+
for (const line of rootsMessages.error) console.error(`⚠ basalt-ui init: ${line}`)
|
|
2163
|
+
if (seededLintScript) {
|
|
2164
|
+
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
|
|
2165
|
+
}
|
|
1208
2166
|
// lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
|
|
1209
2167
|
// Actions reads config from anywhere but the repo root, so a package living in a subdirectory
|
|
1210
2168
|
// skips both rather than relocating them into a spot nothing reads.
|
|
@@ -1212,12 +2170,15 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1212
2170
|
console.log(
|
|
1213
2171
|
`basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
1214
2172
|
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
1215
|
-
|
|
2173
|
+
`${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its ` +
|
|
2174
|
+
`check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` ` +
|
|
2175
|
+
'so the guard runs where your basalt config actually lives.',
|
|
1216
2176
|
)
|
|
1217
2177
|
console.log(
|
|
1218
2178
|
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
1219
2179
|
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
1220
|
-
|
|
2180
|
+
`repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, 'configs/check.yml')} ` +
|
|
2181
|
+
'into your root CI workflow instead.',
|
|
1221
2182
|
)
|
|
1222
2183
|
}
|
|
1223
2184
|
if (placement.relocatedQueryClient !== null) {
|
|
@@ -1254,15 +2215,46 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1254
2215
|
` ]\n` +
|
|
1255
2216
|
` }`,
|
|
1256
2217
|
)
|
|
2218
|
+
// `init` on an existing app is a LINT-DEBT EVENT, not a no-op — the shipped preset turns on whole
|
|
2219
|
+
// oxlint plugins the repo was never linted against, so previously-clean code lands with real
|
|
2220
|
+
// findings on the first run. Naming the plugins (derived from the preset, so the count can't
|
|
2221
|
+
// drift) is what turns that from a nasty surprise into a scheduled triage.
|
|
2222
|
+
const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot)
|
|
2223
|
+
if (presetPlugins.length > 0) {
|
|
2224
|
+
console.log(
|
|
2225
|
+
`\nLint debt: adopting the shipped oxlint preset on an EXISTING app is not a no-op — it turns ` +
|
|
2226
|
+
`on ${presetPlugins.length} plugins (${presetPlugins.join(', ')}) plus the basalt/* design ` +
|
|
2227
|
+
'rules, on code never linted against them. Run `oxlint .` now and triage the count before ' +
|
|
2228
|
+
'your next commit; turn a rule off in your own .oxlintrc.json with a written reason rather ' +
|
|
2229
|
+
'than blanket-disabling a plugin.',
|
|
2230
|
+
)
|
|
2231
|
+
}
|
|
2232
|
+
console.log(
|
|
2233
|
+
'Activate the hooks: `lefthook install` — the seeded lefthook.yml is inert until the git hooks ' +
|
|
2234
|
+
'are written, and looks configured either way.',
|
|
2235
|
+
)
|
|
1257
2236
|
// First adoption on a previously guard-clean repo can surface a wall of findings (the 1.0 guard
|
|
1258
2237
|
// adds several rule kinds beyond a legacy local guard) — steer toward tuning config, not mass-allow.
|
|
1259
2238
|
console.log(
|
|
1260
|
-
|
|
1261
|
-
'in package.json for anything that fires — do not
|
|
2239
|
+
`\nFirst run: run \`${ctx.vars.BASALT_BIN} check-theme\` next (or \`bun run lint:basalt\`), then ` +
|
|
2240
|
+
'tune the per-rule `basalt.*` config keys in package.json for anything that fires — do not ' +
|
|
2241
|
+
'mass-`theme-allow` findings. Then `basalt-ui doctor` to confirm the wiring actually took.',
|
|
1262
2242
|
)
|
|
1263
2243
|
return 0
|
|
1264
2244
|
}
|
|
1265
2245
|
|
|
2246
|
+
/** The oxlint plugins the shipped preset switches on — read from the preset, never restated. */
|
|
2247
|
+
function readOxlintPresetPlugins(pkgRoot: string): string[] {
|
|
2248
|
+
const raw = readSource(pkgRoot, 'configs/oxlint.json')
|
|
2249
|
+
if (raw === null) return []
|
|
2250
|
+
try {
|
|
2251
|
+
const plugins = (JSON.parse(raw) as { plugins?: unknown }).plugins
|
|
2252
|
+
return isStringArray(plugins) ? plugins : []
|
|
2253
|
+
} catch {
|
|
2254
|
+
return []
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
|
|
1266
2258
|
type SyncOptions = { force?: boolean; check?: boolean }
|
|
1267
2259
|
|
|
1268
2260
|
/**
|
|
@@ -1273,6 +2265,8 @@ type SyncOptions = { force?: boolean; check?: boolean }
|
|
|
1273
2265
|
* - `--force` overwrites locally-drifted files instead of skipping them.
|
|
1274
2266
|
*/
|
|
1275
2267
|
export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): number {
|
|
2268
|
+
// Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
|
|
2269
|
+
const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
|
|
1276
2270
|
const ctx = renderContext(cwd)
|
|
1277
2271
|
const manifest = readManifest(cwd)
|
|
1278
2272
|
if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
@@ -1350,6 +2344,35 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
|
|
|
1350
2344
|
)
|
|
1351
2345
|
}
|
|
1352
2346
|
|
|
2347
|
+
const rootsMessages = rootsNotices(rootsState)
|
|
2348
|
+
for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`)
|
|
2349
|
+
for (const line of rootsMessages.error) console.error(`⚠ basalt-ui sync: ${line}`)
|
|
2350
|
+
|
|
2351
|
+
// The two `extends` seams sync does NOT own. Reported here because sync is the command an
|
|
2352
|
+
// UPGRADE runs, and an upgrade is exactly when a resolved path goes stale — 1.20.0 fixed the
|
|
2353
|
+
// paths `init` renders and reached no existing consumer at all. Informational: these are
|
|
2354
|
+
// consumer-owned files, and `doctor` is the gate that fails on them.
|
|
2355
|
+
const install = findBasaltInstall(cwd)
|
|
2356
|
+
const lefthookSeam = inspectLefthookSeam(placement.repoRoot)
|
|
2357
|
+
if (lefthookSeam.kind === 'broken') {
|
|
2358
|
+
console.error(
|
|
2359
|
+
`⚠ basalt-ui sync: ${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not ` +
|
|
2360
|
+
'exist — lefthook merges a missing extends target into ZERO commands and still exits 0, ' +
|
|
2361
|
+
'so there is no pre-commit gate. Repoint it at ' +
|
|
2362
|
+
`"${shippedAssetPath(install, placement.repoRoot, 'configs/lefthook.yml')}".`,
|
|
2363
|
+
)
|
|
2364
|
+
}
|
|
2365
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
|
|
2366
|
+
const oxlintEntry =
|
|
2367
|
+
oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.['extends'])
|
|
2368
|
+
if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
|
|
2369
|
+
console.error(
|
|
2370
|
+
`⚠ basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist — oxlint ` +
|
|
2371
|
+
'refuses to start on a missing extends target (`NotFound`). Repoint it at ' +
|
|
2372
|
+
`"${shippedAssetPath(install, cwd, 'configs/oxlint.json')}".`,
|
|
2373
|
+
)
|
|
2374
|
+
}
|
|
2375
|
+
|
|
1353
2376
|
if (opts.check) {
|
|
1354
2377
|
if (driftLines.length > 0) {
|
|
1355
2378
|
console.error('basalt-ui sync --check: locally-drifted managed files:')
|
|
@@ -1480,7 +2503,7 @@ export function checkCoverage(): number {
|
|
|
1480
2503
|
for (const exportKey of pkgExports) {
|
|
1481
2504
|
if (exportKey === '.' || exportKey === './styles.css' || exportKey.startsWith('./configs/'))
|
|
1482
2505
|
continue
|
|
1483
|
-
if (!(exportKey
|
|
2506
|
+
if (!Object.hasOwn(SURFACES, exportKey)) {
|
|
1484
2507
|
failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`)
|
|
1485
2508
|
}
|
|
1486
2509
|
}
|
|
@@ -1577,8 +2600,25 @@ type DoctorResult = {
|
|
|
1577
2600
|
hardFailures: number
|
|
1578
2601
|
/** Number of warnings (informational only). */
|
|
1579
2602
|
warnings: number
|
|
2603
|
+
/**
|
|
2604
|
+
* Number of checks that could not RUN (as opposed to ran and passed). Counted separately and
|
|
2605
|
+
* exits non-zero on its own: under bun's isolated linker `doctor` used to drop two of its five
|
|
2606
|
+
* checks and still print "All checks passed", which is the same false-green this whole surface
|
|
2607
|
+
* exists to remove. A check that cannot run is not a check that passed.
|
|
2608
|
+
*/
|
|
2609
|
+
skipped: number
|
|
1580
2610
|
}
|
|
1581
2611
|
|
|
2612
|
+
/**
|
|
2613
|
+
* Which shape of consumer `doctor` is looking at.
|
|
2614
|
+
*
|
|
2615
|
+
* `framework` — the Mantine app the scaffold is for. `tokens-only` — a consumer that took the
|
|
2616
|
+
* `--vx-*` layer and nothing else (rollhook: no Mantine, no manifest, `tokens:css` output). Telling
|
|
2617
|
+
* the second to "run `basalt-ui init`" is the wrong instruction: init scaffolds Mantine rules,
|
|
2618
|
+
* skills and a DESIGN.md it will never read. Auto-detected, forced by `--framework` / `--tokens-only`.
|
|
2619
|
+
*/
|
|
2620
|
+
type DoctorProfile = 'framework' | 'tokens-only'
|
|
2621
|
+
|
|
1582
2622
|
type WorkspacePackage = { readonly name: string; readonly dir: string }
|
|
1583
2623
|
|
|
1584
2624
|
/** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
|
|
@@ -1783,9 +2823,27 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
1783
2823
|
* .basalt/manifest.json at the last init/sync. File-level drift is `sync --check`'s job, not
|
|
1784
2824
|
* doctor's.
|
|
1785
2825
|
*
|
|
2826
|
+
* The checks are numbered in the order they run and print — there is no gap, and the number in a
|
|
2827
|
+
* section header below is the number in the report.
|
|
2828
|
+
*
|
|
1786
2829
|
* Hard failures (exit non-zero):
|
|
1787
2830
|
* 1. .basalt/manifest.json exists (init was run).
|
|
1788
|
-
*
|
|
2831
|
+
* 2. `basalt-resolves`: basalt-ui resolves from here. Unresolvable used to make checks silently
|
|
2832
|
+
* VANISH from the report (5 checks became 3) while the footer still read "All checks passed".
|
|
2833
|
+
* It is also the moment `bunx basalt-ui` stops using the pinned copy.
|
|
2834
|
+
* 6. `guard-scan`: `check-theme` would scan MORE than zero files. `check-theme` already exits 1
|
|
2835
|
+
* on "0 files scanned"; doctor disagreeing with it in the same repo is the bug.
|
|
2836
|
+
* 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset AND
|
|
2837
|
+
* that path RESOLVES. `init` keeps an existing config, so the framework's whole lint half can
|
|
2838
|
+
* be off with nothing to say so — one repo carried six real violations invisibly across five
|
|
2839
|
+
* minors that way. Matching the string alone reproduced the same false green one layer in: a
|
|
2840
|
+
* well-shaped entry pointing at a path that no longer exists passed this check in the very
|
|
2841
|
+
* tree where oxlint refused to start with `NotFound`.
|
|
2842
|
+
* 8. `lefthook-preset`: the repo-root lefthook config's `extends` target resolves. lefthook
|
|
2843
|
+
* merges a MISSING target into zero commands and exits 0 — no error, a clean `lefthook dump`,
|
|
2844
|
+
* and no pre-commit gate. Nothing but a check can see that. Not-extending-at-all is a warning
|
|
2845
|
+
* (a consumer may run their own hooks); extending a path that isn't there is a hard failure.
|
|
2846
|
+
* 10. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
|
|
1789
2847
|
* major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
|
|
1790
2848
|
* a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
|
|
1791
2849
|
* ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
|
|
@@ -1798,24 +2856,52 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
1798
2856
|
* or invalid (empty/non-string) still hard-fails exactly as if the key were absent.
|
|
1799
2857
|
*
|
|
1800
2858
|
* Warnings (non-fatal):
|
|
1801
|
-
*
|
|
2859
|
+
* 3. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
|
|
1802
2860
|
* (a mismatch means the package was upgraded but `sync` never ran — the placed doctrine is
|
|
1803
2861
|
* stale).
|
|
1804
|
-
*
|
|
1805
|
-
* (skipped when
|
|
1806
|
-
*
|
|
2862
|
+
* 4. The spacing scale still matches the one stamped into the manifest at the last init/sync
|
|
2863
|
+
* (skipped when there is no manifest to compare against; a neutral pass when this CLI is
|
|
2864
|
+
* demonstrably not the installed package — see the check).
|
|
2865
|
+
* 5. The running CLI's own version matches the installed basalt-ui (catches a stale
|
|
1807
2866
|
* `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
|
|
1808
|
-
*
|
|
2867
|
+
* 9. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
|
|
1809
2868
|
* a public/ dir exists at all — skipped otherwise).
|
|
1810
|
-
*
|
|
1811
|
-
* stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
1812
|
-
* through unnoticed.
|
|
2869
|
+
* 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
|
|
2870
|
+
* exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
|
|
2871
|
+
* skew slips through unnoticed.
|
|
2872
|
+
*
|
|
2873
|
+
* An AMBIGUOUS project (no config here, several workspace packages carrying one) short-circuits
|
|
2874
|
+
* before any of them, exactly as `check-theme` does: running the remaining checks against the wrong
|
|
2875
|
+
* root buries the one real error under spurious ones.
|
|
1813
2876
|
*
|
|
1814
|
-
*
|
|
2877
|
+
* SKIPPED is now a third outcome beside pass/warn/fail and exits non-zero on its own — a check that
|
|
2878
|
+
* cannot run is not a check that passed. "Not applicable to this profile" is a pass, not a skip.
|
|
2879
|
+
*
|
|
2880
|
+
* Returns the exit code: 0 = all good, 1 = one or more hard failures or unrunnable checks.
|
|
1815
2881
|
*/
|
|
1816
|
-
export function doctor(
|
|
2882
|
+
export function doctor(invocationCwd: string = process.cwd(), flags: string[] = []): number {
|
|
2883
|
+
if (conflictingProfileFlags(flags)) {
|
|
2884
|
+
console.error('basalt-ui doctor: --tokens-only and --framework are alternatives — pass one.')
|
|
2885
|
+
return 1
|
|
2886
|
+
}
|
|
2887
|
+
const project = resolveProjectDir(invocationCwd)
|
|
2888
|
+
// Ambiguity is terminal, exactly as it is for check-theme. Falling back to `invocationCwd` ran
|
|
2889
|
+
// every remaining check against the WRONG root and buried the one real error under spurious
|
|
2890
|
+
// ones — guard-scan reporting 0 files, .oxlintrc.json "missing" — which is this round's own
|
|
2891
|
+
// false-report bug class reappearing inside the fix for it.
|
|
2892
|
+
if (project.ambiguous !== null) {
|
|
2893
|
+
console.error(
|
|
2894
|
+
`\nbasalt-ui doctor — ${invocationCwd}\n\n` +
|
|
2895
|
+
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace ` +
|
|
2896
|
+
`packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
2897
|
+
'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
|
|
2898
|
+
'1 hard failure(s), 0 warning(s).\n',
|
|
2899
|
+
)
|
|
2900
|
+
return 1
|
|
2901
|
+
}
|
|
2902
|
+
const cwd = project.dir
|
|
1817
2903
|
const cfg = readBasaltConfig(cwd)
|
|
1818
|
-
const result: DoctorResult = { hardFailures: 0, warnings: 0 }
|
|
2904
|
+
const result: DoctorResult = { hardFailures: 0, warnings: 0, skipped: 0 }
|
|
1819
2905
|
const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
|
|
1820
2906
|
|
|
1821
2907
|
function pass(msg: string): void {
|
|
@@ -1825,32 +2911,85 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1825
2911
|
lines.push(` ⚠ ${msg}`)
|
|
1826
2912
|
result.warnings++
|
|
1827
2913
|
}
|
|
2914
|
+
function skip(msg: string): void {
|
|
2915
|
+
lines.push(` ⊘ SKIPPED — ${msg}`)
|
|
2916
|
+
result.skipped++
|
|
2917
|
+
}
|
|
1828
2918
|
function fail(msg: string): void {
|
|
1829
2919
|
lines.push(` ✖ ${msg}`)
|
|
1830
2920
|
result.hardFailures++
|
|
1831
2921
|
}
|
|
1832
2922
|
|
|
1833
|
-
|
|
2923
|
+
if (project.relocatedFrom !== null) {
|
|
2924
|
+
lines.push(
|
|
2925
|
+
` → no basalt config at ${project.relocatedFrom} — reporting on ` +
|
|
2926
|
+
`${relativePosix(project.relocatedFrom, cwd)}, where it lives.\n`,
|
|
2927
|
+
)
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
// ── Profile ────────────────────────────────────────────────────────────────
|
|
1834
2931
|
const manifestAbs = resolve(cwd, MANIFEST_PATH)
|
|
1835
2932
|
const manifestExists = existsSync(manifestAbs)
|
|
1836
|
-
|
|
2933
|
+
const profile = inferredProfile(cwd, cfg, flags)
|
|
2934
|
+
if (profile === 'tokens-only') {
|
|
2935
|
+
lines.push(
|
|
2936
|
+
' profile: tokens-only — checking the token layer only. The scaffold checks below are not\n' +
|
|
2937
|
+
' applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n' +
|
|
2938
|
+
' use for. Pass --framework to force the full profile.\n',
|
|
2939
|
+
)
|
|
2940
|
+
// check-theme will NOT infer this — it silences 17 kinds, so it moves only on a declaration.
|
|
2941
|
+
// Doctor is the surface that can safely detect the shape, so it is the one that has to say so.
|
|
2942
|
+
if (cfg.profile === undefined) {
|
|
2943
|
+
lines.push(
|
|
2944
|
+
' → inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n' +
|
|
2945
|
+
' down as "basalt": { "profile": "tokens-only" } in package.json — check-theme does not\n' +
|
|
2946
|
+
' infer it, so today it still reports the Mantine-only kinds against this repo.\n',
|
|
2947
|
+
)
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
// ── Hard check 1: manifest exists ──────────────────────────────────────────
|
|
2952
|
+
if (profile === 'tokens-only') {
|
|
2953
|
+
pass(`${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to reconcile`)
|
|
2954
|
+
} else if (manifestExists) {
|
|
1837
2955
|
pass(`${MANIFEST_PATH} exists`)
|
|
1838
2956
|
} else {
|
|
1839
2957
|
fail(`${MANIFEST_PATH} missing — run \`basalt-ui init\` to scaffold the consumer repo`)
|
|
1840
2958
|
}
|
|
1841
2959
|
|
|
1842
|
-
// ──
|
|
2960
|
+
// ── Hard check 2: basalt-ui resolves from here ─────────────────────────────
|
|
2961
|
+
// Unresolvable used to make checks 3 and 5 VANISH from the report while the footer still read
|
|
2962
|
+
// "All checks passed" — 5 checks silently became 3. It is also the moment `bunx basalt-ui`
|
|
2963
|
+
// stops using the pinned copy and quietly downloads a different one from npm, so the failure
|
|
2964
|
+
// has to be loud rather than absent.
|
|
2965
|
+
const install = findBasaltInstall(cwd)
|
|
2966
|
+
const installedVersion = install.version
|
|
2967
|
+
if (install.dir === null) {
|
|
2968
|
+
fail(
|
|
2969
|
+
`basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package — ` +
|
|
2970
|
+
'so every version check below is unrunnable, `extends: [<preset>]` cannot resolve, and ' +
|
|
2971
|
+
'`bunx basalt-ui` will silently fetch a DIFFERENT copy from npm instead of your pinned ' +
|
|
2972
|
+
'one. Add basalt-ui as a dependency here (or at the repo root) and re-run.',
|
|
2973
|
+
)
|
|
2974
|
+
} else if (install.how === 'cwd') {
|
|
2975
|
+
pass(
|
|
2976
|
+
`basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? 'unknown version'})`,
|
|
2977
|
+
)
|
|
2978
|
+
} else {
|
|
2979
|
+
pass(
|
|
2980
|
+
`basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? 'unknown version'}, ` +
|
|
2981
|
+
`found via the ${install.how}) — seeded \`extends\` paths and CI steps must use that path, ` +
|
|
2982
|
+
'not ./node_modules/basalt-ui.',
|
|
2983
|
+
)
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// ── Warn check 3: installed basalt-ui version matches the manifest ─────────
|
|
1843
2987
|
// THE one version axis. The manifest records the version whose init/sync placed the doctrine;
|
|
1844
2988
|
// node_modules is what the app actually resolves. A mismatch means "upgrade landed, sync didn't".
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
const consumerPkg = JSON.parse(consumerPkgRaw) as { version?: string }
|
|
1850
|
-
installedVersion = consumerPkg.version ?? null
|
|
1851
|
-
} catch {
|
|
1852
|
-
warn('could not parse node_modules/basalt-ui/package.json — version checks skipped')
|
|
1853
|
-
}
|
|
2989
|
+
if (profile === 'framework' && manifestExists && installedVersion === null) {
|
|
2990
|
+
skip(
|
|
2991
|
+
'installed-vs-manifest version — basalt-ui did not resolve, so there is nothing to compare',
|
|
2992
|
+
)
|
|
1854
2993
|
}
|
|
1855
2994
|
if (manifestExists && installedVersion !== null) {
|
|
1856
2995
|
const manifestVersion = readManifest(cwd).basaltVersion
|
|
@@ -1871,7 +3010,7 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1871
3010
|
|
|
1872
3011
|
const cliVersion = readFrameworkVersion(packageRoot())
|
|
1873
3012
|
|
|
1874
|
-
// ── Warn check
|
|
3013
|
+
// ── Warn check 4: the spacing scale has not moved under the app ────────────
|
|
1875
3014
|
// The only check here that reports a change in RENDERED OUTPUT rather than in placed files. A
|
|
1876
3015
|
// retune of the spacing bases moves every surface in an app calling `createBasaltTheme()` bare,
|
|
1877
3016
|
// and majors are banned by design, so nothing in the version number says so — 1.2.0's retune
|
|
@@ -1883,14 +3022,23 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1883
3022
|
// `deriveSpacing` here is the RUNNING CLI's, which a stale `bunx basalt-ui` fetch makes a
|
|
1884
3023
|
// different package. Rather than load the installed tokens entry (async, and doctor is sync),
|
|
1885
3024
|
// the comparison is skipped whenever the two versions are known to disagree: a false "matches"
|
|
1886
|
-
// is worse than no answer. Check
|
|
3025
|
+
// is worse than no answer. Check 5 below names that disagreement, so this stays a neutral note.
|
|
1887
3026
|
const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion
|
|
1888
|
-
if (
|
|
3027
|
+
if (profile === 'tokens-only') {
|
|
3028
|
+
pass('spacing scale: n/a — a tokens-only consumer renders no basalt Mantine theme')
|
|
3029
|
+
} else if (!manifestExists) {
|
|
3030
|
+
// Printing NOTHING is the failure SKIPPED exists to end: the check simply vanished from the
|
|
3031
|
+
// report whenever there was no manifest, which is precisely when it is least safe to assume.
|
|
3032
|
+
skip(
|
|
3033
|
+
`spacing scale — no ${MANIFEST_PATH} records the scale this app last synced with, so there ` +
|
|
3034
|
+
'is nothing to compare against',
|
|
3035
|
+
)
|
|
3036
|
+
} else if (!cliIsTheInstall) {
|
|
1889
3037
|
pass(
|
|
1890
3038
|
`spacing scale not compared — this CLI (${cliVersion}) is not the installed basalt-ui ` +
|
|
1891
3039
|
`(${installedVersion}), so its scale is not the one the app renders with`,
|
|
1892
3040
|
)
|
|
1893
|
-
} else
|
|
3041
|
+
} else {
|
|
1894
3042
|
const recorded = readManifest(cwd).spacingScale
|
|
1895
3043
|
const current = deriveSpacing(0).scale as Record<string, number>
|
|
1896
3044
|
if (recorded === undefined) {
|
|
@@ -1916,29 +3064,137 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1916
3064
|
}
|
|
1917
3065
|
}
|
|
1918
3066
|
|
|
1919
|
-
// ── Warn check
|
|
3067
|
+
// ── Warn check 5: running CLI version matches the consumer's installed basalt-ui ────
|
|
1920
3068
|
// Catches the failure mode where bunx fetches a stale published package instead of the local
|
|
1921
3069
|
// install — the CLI that ran doctor and the package resolved from the consumer's node_modules
|
|
1922
3070
|
// silently disagree.
|
|
1923
|
-
if (installedVersion
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
3071
|
+
if (installedVersion === null) {
|
|
3072
|
+
skip('CLI-vs-installed version — basalt-ui did not resolve, so there is no installed version')
|
|
3073
|
+
} else if (installedVersion !== cliVersion) {
|
|
3074
|
+
warn(
|
|
3075
|
+
`running CLI version (${cliVersion}) differs from the installed basalt-ui version at ` +
|
|
3076
|
+
`${install.dir === null ? 'node_modules' : relativePosix(cwd, install.dir)} (${installedVersion}) — ` +
|
|
3077
|
+
'likely a stale `bunx basalt-ui` fetch from npm; run the local bin ' +
|
|
3078
|
+
'(`node_modules/.bin/basalt-ui`, or a package.json script) so the pinned copy is the one ' +
|
|
3079
|
+
'that runs.',
|
|
3080
|
+
)
|
|
3081
|
+
} else {
|
|
3082
|
+
pass(`CLI version (${cliVersion}) matches the installed basalt-ui`)
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
// ── Hard check 6: the guard would actually scan something ──────────────────
|
|
3086
|
+
// `check-theme` exits 1 on "0 files scanned" — doctor reporting all-green in the SAME repo is
|
|
3087
|
+
// the disagreement consumers reported: init ran, doctor passed, and the palette guard was a
|
|
3088
|
+
// no-op the whole time because no `basalt.roots` described the workspace layout.
|
|
3089
|
+
if (profile === 'tokens-only') {
|
|
3090
|
+
pass('guard-scan: n/a — the theme guard is a Mantine-app check')
|
|
3091
|
+
} else {
|
|
3092
|
+
const scanned = scannableFiles(cwd, cfg)
|
|
3093
|
+
if (scanned.length === 0) {
|
|
3094
|
+
fail(
|
|
3095
|
+
`guard-scan: check-theme would scan 0 files — the roots (${resolveRoots(cfg).join(', ')}) ` +
|
|
3096
|
+
`match nothing under ${cwd}, so the palette guard is a no-op that still exits green in ` +
|
|
3097
|
+
'every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.',
|
|
1929
3098
|
)
|
|
1930
3099
|
} else {
|
|
1931
|
-
pass(
|
|
3100
|
+
pass(
|
|
3101
|
+
`guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(', ')}`,
|
|
3102
|
+
)
|
|
1932
3103
|
}
|
|
1933
3104
|
}
|
|
1934
3105
|
|
|
1935
|
-
// ──
|
|
3106
|
+
// ── Hard check 7: the consumer's oxlint config extends the shipped preset ──
|
|
3107
|
+
// `init` KEEPS an existing `.oxlintrc.json`, so a repo can carry the whole scaffold with the
|
|
3108
|
+
// framework's lint half switched off and nothing anywhere saying so. One repo ran five minors
|
|
3109
|
+
// that way and surfaced six real `basalt/no-raw-font-size` errors the moment it was wired.
|
|
3110
|
+
if (profile === 'tokens-only') {
|
|
3111
|
+
pass('oxlint-preset: n/a — the shipped preset is a Mantine/React preset')
|
|
3112
|
+
} else {
|
|
3113
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
|
|
3114
|
+
if (oxlintrcRaw === null) {
|
|
3115
|
+
fail(
|
|
3116
|
+
'.oxlintrc.json missing — the shipped oxlint preset (the basalt/* design rules) is not ' +
|
|
3117
|
+
'active. Run `basalt-ui init` to seed it.',
|
|
3118
|
+
)
|
|
3119
|
+
} else {
|
|
3120
|
+
// JSONC: oxlint accepts comments and real consumer configs carry them.
|
|
3121
|
+
const parsed = parseJsonc(oxlintrcRaw)
|
|
3122
|
+
if (parsed === null) {
|
|
3123
|
+
skip(
|
|
3124
|
+
'.oxlintrc.json does not parse as JSON/JSONC — cannot tell whether it extends the preset',
|
|
3125
|
+
)
|
|
3126
|
+
} else {
|
|
3127
|
+
const entry = basaltPresetEntry(parsed['extends'])
|
|
3128
|
+
const correct = shippedAssetPath(install, cwd, 'configs/oxlint.json')
|
|
3129
|
+
if (entry === null) {
|
|
3130
|
+
fail(
|
|
3131
|
+
'.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
|
|
3132
|
+
`and oxlint reports green without them. Add "extends": ["${correct}"], or re-run ` +
|
|
3133
|
+
'`basalt-ui init --merge-lint` to splice it in.',
|
|
3134
|
+
)
|
|
3135
|
+
} else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
|
|
3136
|
+
// The path is the one basalt resolves from and the asset still isn't there — a partial
|
|
3137
|
+
// or corrupted install, not a stale path. Saying "repoint it at <the same string>" would
|
|
3138
|
+
// be the unhelpful shape of a correct diagnosis.
|
|
3139
|
+
fail(
|
|
3140
|
+
`.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
|
|
3141
|
+
'basalt-ui resolves there but ships no `configs/oxlint.json` at that path, so oxlint ' +
|
|
3142
|
+
'refuses to start (`NotFound`) and nothing is linted. Reinstall basalt-ui.',
|
|
3143
|
+
)
|
|
3144
|
+
} else if (!existsSync(resolve(cwd, entry))) {
|
|
3145
|
+
// The string is right and the file is not there. oxlint refuses to start at all
|
|
3146
|
+
// (`invalid config file … NotFound`), so a green line here is a check that proves the
|
|
3147
|
+
// framework is ON while the linter is dead — the same false-green class this check was
|
|
3148
|
+
// added to close, one layer in. `install` already computed the true path in this run.
|
|
3149
|
+
fail(
|
|
3150
|
+
`.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
|
|
3151
|
+
'oxlint refuses to start on a missing extends target (`NotFound`), so nothing is ' +
|
|
3152
|
+
`linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`,
|
|
3153
|
+
)
|
|
3154
|
+
} else {
|
|
3155
|
+
pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`)
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
// ── Hard check 8: the lefthook preset's extends target actually exists ─────
|
|
3162
|
+
// The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
|
|
3163
|
+
// zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
|
|
3164
|
+
// exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
|
|
3165
|
+
// Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
|
|
3166
|
+
// remedy line someone reads after a failure.
|
|
3167
|
+
const repoRoot = findRepoRoot(cwd)
|
|
3168
|
+
const lefthookSeam =
|
|
3169
|
+
profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookSeam(repoRoot)
|
|
3170
|
+
const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
|
|
3171
|
+
if (lefthookSeam.kind === 'n/a') {
|
|
3172
|
+
pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
|
|
3173
|
+
} else if (lefthookSeam.kind === 'no-file') {
|
|
3174
|
+
pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
|
|
3175
|
+
} else if (lefthookSeam.kind === 'unwired') {
|
|
3176
|
+
warn(
|
|
3177
|
+
`${lefthookSeam.file} does not extend the shipped lefthook preset — the oxlint / oxfmt / ` +
|
|
3178
|
+
`check-theme pre-commit jobs are not wired. Add "extends: [${lefthookCorrect}]" to it.`,
|
|
3179
|
+
)
|
|
3180
|
+
} else if (lefthookSeam.kind === 'broken') {
|
|
3181
|
+
fail(
|
|
3182
|
+
`${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist ` +
|
|
3183
|
+
`(${resolve(repoRoot, lefthookSeam.entry)}) — lefthook merges a missing extends ` +
|
|
3184
|
+
'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
|
|
3185
|
+
`\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
|
|
3186
|
+
)
|
|
3187
|
+
} else {
|
|
3188
|
+
pass(`${lefthookSeam.file} extends the shipped lefthook preset (${lefthookSeam.entry})`)
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
|
|
1936
3192
|
// basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
|
|
1937
3193
|
// default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
|
|
1938
3194
|
// apps that don't use the plugin, or don't use Vite's public-dir convention, get no false
|
|
1939
3195
|
// warning. Best-effort: a custom `icons.dir` isn't visible here, so this checks the root only.
|
|
1940
3196
|
const publicDir = resolve(cwd, 'public')
|
|
1941
|
-
if (existsSync(publicDir)) {
|
|
3197
|
+
if (profile === 'framework' && existsSync(publicDir)) {
|
|
1942
3198
|
const iconFiles = [
|
|
1943
3199
|
'favicon.ico',
|
|
1944
3200
|
'favicon.svg',
|
|
@@ -1958,7 +3214,7 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1958
3214
|
}
|
|
1959
3215
|
}
|
|
1960
3216
|
|
|
1961
|
-
// ── Hard check
|
|
3217
|
+
// ── Hard check 9: ai package major version parity across workspace packages ─
|
|
1962
3218
|
// basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
|
|
1963
3219
|
// run scoped to one workspace package only ever sees that package's own `ai` major and is
|
|
1964
3220
|
// perfectly happy. The real defect is CROSS-package (argo defect 1: apps/api on ai@5 producing a
|
|
@@ -2014,17 +3270,22 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
2014
3270
|
}
|
|
2015
3271
|
|
|
2016
3272
|
// ── Report ──────────────────────────────────────────────────────────────────
|
|
3273
|
+
// "All checks passed" is only printable when every check ran AND passed. A skipped check counts
|
|
3274
|
+
// against the exit code on its own — the report that dropped two of five checks and still
|
|
3275
|
+
// claimed green is the bug this footer exists to make impossible.
|
|
2017
3276
|
lines.push('')
|
|
2018
|
-
if (result.hardFailures === 0 && result.warnings === 0) {
|
|
3277
|
+
if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
|
|
2019
3278
|
lines.push('All checks passed.')
|
|
2020
3279
|
} else {
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
3280
|
+
const parts: string[] = []
|
|
3281
|
+
if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`)
|
|
3282
|
+
if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`)
|
|
3283
|
+
parts.push(`${result.warnings} warning(s)`)
|
|
3284
|
+
lines.push(`${parts.join(', ')}.`)
|
|
2024
3285
|
}
|
|
2025
3286
|
lines.push('')
|
|
2026
3287
|
|
|
2027
|
-
if (result.hardFailures > 0) {
|
|
3288
|
+
if (result.hardFailures > 0 || result.skipped > 0) {
|
|
2028
3289
|
console.error(lines.join('\n'))
|
|
2029
3290
|
return 1
|
|
2030
3291
|
}
|
|
@@ -2164,6 +3425,99 @@ function flagValue(flags: string[], name: string): string | undefined {
|
|
|
2164
3425
|
return value === undefined || value.startsWith('--') ? undefined : value
|
|
2165
3426
|
}
|
|
2166
3427
|
|
|
3428
|
+
/**
|
|
3429
|
+
* The generated-file header: the canonical marker line (imported from the guard, which owns it),
|
|
3430
|
+
* then the version + invocation that produced the file.
|
|
3431
|
+
*
|
|
3432
|
+
* Both lines are load-bearing, not decoration — `checkSource` only exempts a stylesheet whose line
|
|
3433
|
+
* 1 is the marker verbatim AND whose line 2 parses as this provenance line. Reword either half and
|
|
3434
|
+
* basalt starts reporting its own output again (116 findings in rollhook's token sheet).
|
|
3435
|
+
*/
|
|
3436
|
+
function generatedHeader(version: string, command: string, flags: string[]): string {
|
|
3437
|
+
const shown = flags.filter((f) => f !== '--check')
|
|
3438
|
+
const invocation = shown.length === 0 ? command : `${command} ${shown.join(' ')}`
|
|
3439
|
+
return `${GENERATED_HEADER_LINE}\n/* basalt-ui ${version} — \`basalt-ui ${invocation}\` */\n`
|
|
3440
|
+
}
|
|
3441
|
+
|
|
3442
|
+
/**
|
|
3443
|
+
* Space- and number-normalize the argument list of a legacy color function so the emitted file
|
|
3444
|
+
* survives a normal repo's formatter.
|
|
3445
|
+
*
|
|
3446
|
+
* This is FORMATTING ONLY and deliberately the CLI's single deviation from "print exactly what
|
|
3447
|
+
* `buildPaletteCss` returned": `rgba(255,255,255,0.6)` and `rgba(255, 255, 255, 0.6)` are the same
|
|
3448
|
+
* colour, the shipped palette emits the first form on the dark side and the second on the light
|
|
3449
|
+
* side, and a consumer committing the output ate a lint-ignore entry for the difference. The token
|
|
3450
|
+
* VALUES are untouched — nothing here can change what basalt's tokens are.
|
|
3451
|
+
*
|
|
3452
|
+
* Trailing zeros go the same way and for the same reason: prettier rewrites `0.10` → `0.1`, so the
|
|
3453
|
+
* two `rgba(28, 25, 23, 0.10)` alphas in the light shadow tokens were the whole reason the one
|
|
3454
|
+
* framework-free consumer still could not lint its committed sheet — `--fix` and `tokens:css
|
|
3455
|
+
* --check` disagreed forever, and the lint-ignore entry survived two releases. Only a bare decimal
|
|
3456
|
+
* literal is touched (never a `%`, a `var()` or a unit), and `0.10`/`0.1` are the same number.
|
|
3457
|
+
*
|
|
3458
|
+
* Exported for tests, so the expectation can be computed rather than restated.
|
|
3459
|
+
*/
|
|
3460
|
+
export function normalizeColorFunctions(css: string): string {
|
|
3461
|
+
return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn: string, args: string) => {
|
|
3462
|
+
const parts = args.split(',').map((part) => normalizeCssNumber(part.trim()))
|
|
3463
|
+
return `${fn}(${parts.join(', ')})`
|
|
3464
|
+
})
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
/** `0.10` → `0.1`, `1.0` → `1`; anything that is not a bare decimal literal is returned verbatim. */
|
|
3468
|
+
function normalizeCssNumber(arg: string): string {
|
|
3469
|
+
if (!/^\d+\.\d+$/.test(arg)) return arg
|
|
3470
|
+
return arg.replace(/\.?0+$/, '')
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
/** A scheme-class emission uses this sentinel attribute, then rewrites it to a class selector. */
|
|
3474
|
+
const SCHEME_CLASS_SENTINEL = 'data-basalt-scheme-class'
|
|
3475
|
+
|
|
3476
|
+
/**
|
|
3477
|
+
* Write (or drift-check) a generated stylesheet. `--check` makes no writes and exits 1 when the
|
|
3478
|
+
* file on disk differs from what would be emitted — the CI gate for a committed `tokens:css`
|
|
3479
|
+
* artifact, mirroring `sync --check`. Without `--out` the content goes to stdout unchanged.
|
|
3480
|
+
*/
|
|
3481
|
+
function emitGeneratedCss(content: string, flags: string[], cwd: string, command: string): number {
|
|
3482
|
+
const out = flagValue(flags, '--out')
|
|
3483
|
+
const check = flags.includes('--check')
|
|
3484
|
+
if (out === undefined) {
|
|
3485
|
+
if (check) {
|
|
3486
|
+
console.error(
|
|
3487
|
+
`${command}: --check needs --out <path> — there is nothing to compare stdout to.`,
|
|
3488
|
+
)
|
|
3489
|
+
return 1
|
|
3490
|
+
}
|
|
3491
|
+
process.stdout.write(content)
|
|
3492
|
+
return 0
|
|
3493
|
+
}
|
|
3494
|
+
const target = isAbsolute(out) ? out : resolve(cwd, out)
|
|
3495
|
+
const shown = relative(cwd, target) || target
|
|
3496
|
+
if (check) {
|
|
3497
|
+
const onDisk = readIfExists(target)
|
|
3498
|
+
if (onDisk === null) {
|
|
3499
|
+
console.error(
|
|
3500
|
+
`✖ ${command} --check: ${shown} does not exist — run without --check to write it.`,
|
|
3501
|
+
)
|
|
3502
|
+
return 1
|
|
3503
|
+
}
|
|
3504
|
+
if (onDisk === content) {
|
|
3505
|
+
console.log(`✓ ${command} --check: ${shown} is up to date.`)
|
|
3506
|
+
return 0
|
|
3507
|
+
}
|
|
3508
|
+
console.error(
|
|
3509
|
+
`✖ ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today ` +
|
|
3510
|
+
`(on disk ${onDisk.split('\n').length} lines, emitted ${content.split('\n').length}) — ` +
|
|
3511
|
+
're-run the same command without --check and commit the result.',
|
|
3512
|
+
)
|
|
3513
|
+
return 1
|
|
3514
|
+
}
|
|
3515
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
3516
|
+
writeFileSync(target, content)
|
|
3517
|
+
console.log(`wrote ${content.split('\n').length} lines → ${shown}`)
|
|
3518
|
+
return 0
|
|
3519
|
+
}
|
|
3520
|
+
|
|
2167
3521
|
/**
|
|
2168
3522
|
* tokens:css — emit the `--vx-*` stylesheet, optionally retargeted.
|
|
2169
3523
|
*
|
|
@@ -2173,12 +3527,16 @@ function flagValue(flags: string[], name: string): string | undefined {
|
|
|
2173
3527
|
* same artifact for a consumer that does install; this command exists for the ones that shouldn't
|
|
2174
3528
|
* have to just to change a selector.
|
|
2175
3529
|
*
|
|
2176
|
-
* It parses flags and calls `buildPaletteCss` —
|
|
2177
|
-
*
|
|
2178
|
-
*
|
|
3530
|
+
* It parses flags and calls `buildPaletteCss` for the token VALUES — the CLI and the API must not
|
|
3531
|
+
* be able to disagree about what basalt's tokens are. What it adds is strictly file framing for an
|
|
3532
|
+
* artifact a consumer COMMITS: the `@generated` header (the guard's skip marker), a trailing
|
|
3533
|
+
* newline, and `rgba()` argument spacing. All three were reported by the one framework-free
|
|
3534
|
+
* consumer as the reasons the output could not simply be committed.
|
|
2179
3535
|
*/
|
|
2180
3536
|
export function tokensCss(flags: string[], cwd: string = process.cwd()): number {
|
|
2181
3537
|
const attribute = flagValue(flags, '--selector-attribute')
|
|
3538
|
+
const schemeClass = flagValue(flags, '--selector-class')
|
|
3539
|
+
const lightClass = flagValue(flags, '--light-class')
|
|
2182
3540
|
const darkValue = flagValue(flags, '--dark-value')
|
|
2183
3541
|
const lightValue = flagValue(flags, '--light-value')
|
|
2184
3542
|
const defaultScheme = flagValue(flags, '--default-scheme')
|
|
@@ -2195,14 +3553,39 @@ export function tokensCss(flags: string[], cwd: string = process.cwd()): number
|
|
|
2195
3553
|
console.error(`tokens:css: --only must be core or all (got '${only}')`)
|
|
2196
3554
|
return 1
|
|
2197
3555
|
}
|
|
3556
|
+
if (schemeClass !== undefined && attribute !== undefined) {
|
|
3557
|
+
console.error(
|
|
3558
|
+
'tokens:css: --selector-class and --selector-attribute are alternatives — pass one.',
|
|
3559
|
+
)
|
|
3560
|
+
return 1
|
|
3561
|
+
}
|
|
3562
|
+
if (schemeClass !== undefined && /[^\w-]/.test(schemeClass)) {
|
|
3563
|
+
console.error(
|
|
3564
|
+
`tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`,
|
|
3565
|
+
)
|
|
3566
|
+
return 1
|
|
3567
|
+
}
|
|
3568
|
+
if (lightClass !== undefined && /[^\w-]/.test(lightClass)) {
|
|
3569
|
+
console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`)
|
|
3570
|
+
return 1
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
// A class selector is Tailwind's universal dark convention (`<html class="dark">`) and was
|
|
3574
|
+
// reachable before only by parking dark on the bare `:root` via --default-scheme. `buildPaletteCss`
|
|
3575
|
+
// emits attribute selectors, so the class form is produced by emitting against a sentinel
|
|
3576
|
+
// attribute this CLI chose itself and rewriting exactly those selectors — a rewrite of strings
|
|
3577
|
+
// the command generated deterministically, never of a token value.
|
|
3578
|
+
const resolvedDarkValue = schemeClass !== undefined ? schemeClass : darkValue
|
|
3579
|
+
const resolvedLightValue = schemeClass !== undefined ? (lightClass ?? 'light') : lightValue
|
|
3580
|
+
const resolvedAttribute = schemeClass !== undefined ? SCHEME_CLASS_SENTINEL : attribute
|
|
2198
3581
|
|
|
2199
3582
|
const scheme = {
|
|
2200
|
-
...(
|
|
2201
|
-
...(
|
|
2202
|
-
...(
|
|
3583
|
+
...(resolvedAttribute === undefined ? {} : { attribute: resolvedAttribute }),
|
|
3584
|
+
...(resolvedDarkValue === undefined ? {} : { darkValue: resolvedDarkValue }),
|
|
3585
|
+
...(resolvedLightValue === undefined ? {} : { lightValue: resolvedLightValue }),
|
|
2203
3586
|
}
|
|
2204
3587
|
|
|
2205
|
-
|
|
3588
|
+
let css = buildPaletteCss({
|
|
2206
3589
|
...(Object.keys(scheme).length === 0 ? {} : { scheme }),
|
|
2207
3590
|
...(defaultScheme === undefined
|
|
2208
3591
|
? {}
|
|
@@ -2216,16 +3599,59 @@ export function tokensCss(flags: string[], cwd: string = process.cwd()): number
|
|
|
2216
3599
|
...(flags.includes('--no-legacy-aliases') ? { legacyAliases: false } : {}),
|
|
2217
3600
|
})
|
|
2218
3601
|
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
3602
|
+
if (schemeClass !== undefined) {
|
|
3603
|
+
css = css.replace(
|
|
3604
|
+
new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, 'g'),
|
|
3605
|
+
(_match, value: string) => `.${value}`,
|
|
3606
|
+
)
|
|
2223
3607
|
}
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
3608
|
+
|
|
3609
|
+
const version = readFrameworkVersion(packageRoot())
|
|
3610
|
+
const content = `${generatedHeader(version, 'tokens:css', flags)}${normalizeColorFunctions(css)}\n`
|
|
3611
|
+
return emitGeneratedCss(content, flags, cwd, 'tokens:css')
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
/** Every `--basalt-font-*` declaration in the shipped stylesheet, in source order. */
|
|
3615
|
+
function readShippedFontDecls(pkgRoot: string): { name: string; value: string }[] {
|
|
3616
|
+
const css = readSource(pkgRoot, 'dist/styles.css') ?? readSource(pkgRoot, 'src/styles.css')
|
|
3617
|
+
if (css === null) return []
|
|
3618
|
+
const decls: { name: string; value: string }[] = []
|
|
3619
|
+
for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
|
|
3620
|
+
decls.push({
|
|
3621
|
+
name: match[1] as string,
|
|
3622
|
+
value: (match[2] as string).replace(/\s+/g, ' ').trim(),
|
|
3623
|
+
})
|
|
3624
|
+
}
|
|
3625
|
+
return decls
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
/**
|
|
3629
|
+
* fonts:css — emit the shipped `--basalt-font-*` stacks as plain CSS.
|
|
3630
|
+
*
|
|
3631
|
+
* The typeface half of the framework-free route, which had no supported path at all: `tokens.css`
|
|
3632
|
+
* emits no font vars, `styles.css` is the one place the defaults live and a framework-free consumer
|
|
3633
|
+
* is told not to import it, and `buildFontsCss` returns `''` unless the caller already knows the
|
|
3634
|
+
* stacks. The one non-Mantine consumer lost its font identity entirely and hardcoded the stacks —
|
|
3635
|
+
* which the theme guard then flagged as `raw-font-family`.
|
|
3636
|
+
*
|
|
3637
|
+
* The declarations are READ from the shipped stylesheet rather than restated here, so this command
|
|
3638
|
+
* and `basalt-ui/styles.css` can never name different typefaces. Note the fonts themselves are
|
|
3639
|
+
* `@fontsource-variable/*` packages: without them the stacks fall through to their own system
|
|
3640
|
+
* fallbacks, which is the intended framework-free behaviour.
|
|
3641
|
+
*/
|
|
3642
|
+
export function fontsCss(flags: string[], cwd: string = process.cwd()): number {
|
|
3643
|
+
const pkgRoot = packageRoot()
|
|
3644
|
+
const decls = readShippedFontDecls(pkgRoot)
|
|
3645
|
+
if (decls.length === 0) {
|
|
3646
|
+
console.error(
|
|
3647
|
+
'fonts:css: could not read the shipped font stacks from styles.css — this is a packaging ' +
|
|
3648
|
+
'bug in basalt-ui, not a config error.',
|
|
3649
|
+
)
|
|
3650
|
+
return 1
|
|
3651
|
+
}
|
|
3652
|
+
const body = `:root {\n${decls.map((d) => ` ${d.name}: ${d.value};`).join('\n')}\n}\n`
|
|
3653
|
+
const header = generatedHeader(readFrameworkVersion(pkgRoot), 'fonts:css', flags)
|
|
3654
|
+
return emitGeneratedCss(`${header}${body}`, flags, cwd, 'fonts:css')
|
|
2229
3655
|
}
|
|
2230
3656
|
|
|
2231
3657
|
/**
|
|
@@ -2284,8 +3710,11 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
2284
3710
|
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
2285
3711
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
2286
3712
|
allowComment: 'theme-allow',
|
|
2287
|
-
exemptRules: cfg
|
|
3713
|
+
exemptRules: resolveExemptRules(cfg),
|
|
2288
3714
|
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
3715
|
+
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
3716
|
+
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
3717
|
+
...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
2289
3718
|
}
|
|
2290
3719
|
|
|
2291
3720
|
// Honor the consumer's roots / exempt / skip config so the hook never blocks edits to exempted
|
|
@@ -2325,14 +3754,32 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
2325
3754
|
|
|
2326
3755
|
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
2327
3756
|
const USAGE =
|
|
2328
|
-
'Usage: basalt-ui <init [--with-router] [--with-query] | sync [--force] [--check]
|
|
2329
|
-
'check-theme | check-coverage | info [--json]
|
|
2330
|
-
'
|
|
3757
|
+
'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n' +
|
|
3758
|
+
' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
|
|
3759
|
+
' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
3760
|
+
'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
|
|
3761
|
+
' --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n' +
|
|
3762
|
+
' `basalt.exemptRules` entry, with what each one still suppresses — proved by re-running the\n' +
|
|
3763
|
+
' guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n' +
|
|
3764
|
+
' suppresses nothing (dead), which is what makes it usable as a CI gate.\n\n' +
|
|
3765
|
+
'tokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n' +
|
|
3766
|
+
' [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n' +
|
|
2331
3767
|
' [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n' +
|
|
2332
3768
|
' [--no-legacy-aliases]\n' +
|
|
2333
3769
|
' Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n' +
|
|
3770
|
+
' --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n' +
|
|
3771
|
+
' --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n' +
|
|
2334
3772
|
' --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n' +
|
|
2335
3773
|
' which are emitted by default as aliases of the canonical kebab-case names.\n\n' +
|
|
3774
|
+
'fonts:css [--out <path>] [--check]\n' +
|
|
3775
|
+
' Emit the shipped --basalt-font-* stacks as plain CSS — the typeface half of the token layer,\n' +
|
|
3776
|
+
' otherwise reachable only by importing styles.css.\n\n' +
|
|
3777
|
+
'doctor [--tokens-only|--framework]\n' +
|
|
3778
|
+
' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
|
|
3779
|
+
' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
|
|
3780
|
+
'check-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\n' +
|
|
3781
|
+
'basalt config when invoked from a repo root that has none — several carrying one is ambiguous\n' +
|
|
3782
|
+
'and exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\n' +
|
|
2336
3783
|
'Every subcommand accepts --help / -h to print this message and exit without running.'
|
|
2337
3784
|
|
|
2338
3785
|
/**
|
|
@@ -2363,21 +3810,24 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
2363
3810
|
return init(cwd, {
|
|
2364
3811
|
withRouter: flags.includes('--with-router'),
|
|
2365
3812
|
withQuery: flags.includes('--with-query'),
|
|
3813
|
+
mergeLint: flags.includes('--merge-lint'),
|
|
2366
3814
|
})
|
|
2367
3815
|
case 'sync':
|
|
2368
3816
|
return sync({ force: flags.includes('--force'), check: flags.includes('--check') }, cwd)
|
|
2369
3817
|
case 'check-theme':
|
|
2370
|
-
return checkTheme(cwd)
|
|
3818
|
+
return checkTheme(cwd, flags)
|
|
2371
3819
|
case 'check-coverage':
|
|
2372
3820
|
return checkCoverage()
|
|
2373
3821
|
case 'info':
|
|
2374
3822
|
return info(flags)
|
|
2375
3823
|
case 'doctor':
|
|
2376
|
-
return doctor(cwd)
|
|
3824
|
+
return doctor(cwd, flags)
|
|
2377
3825
|
case 'guard-hook':
|
|
2378
3826
|
return guardHook(cwd)
|
|
2379
3827
|
case 'tokens:css':
|
|
2380
3828
|
return tokensCss(flags, cwd)
|
|
3829
|
+
case 'fonts:css':
|
|
3830
|
+
return fontsCss(flags, cwd)
|
|
2381
3831
|
default:
|
|
2382
3832
|
console.error(USAGE)
|
|
2383
3833
|
return 1
|