basalt-ui 1.33.0 → 1.34.1
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 +1 -1
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +115 -15
- package/README.md +3 -0
- package/agent/skills/basalt-app/SKILL.md +5 -3
- package/configs/oxlint-basalt.json +33 -0
- package/configs/oxlint-plugin.js +21 -6
- package/configs/oxlint-plugin.test.ts +98 -10
- package/configs/oxlint.json +2 -30
- package/dist/cli/check-theme.d.ts.map +1 -1
- package/dist/cli/check-theme.js +40 -36
- package/dist/cli/doctor.d.ts +5 -10
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +7 -5
- package/dist/cli/guard-hook.d.ts.map +1 -1
- package/dist/cli/guard-hook.js +6 -34
- package/dist/cli/index.d.ts +45 -83
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +73 -19
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +7 -16
- package/dist/common/vx-families.d.ts +7 -0
- package/dist/common/vx-families.d.ts.map +1 -0
- package/dist/common/vx-families.js +32 -0
- package/dist/common/vx-family.d.ts +11 -0
- package/dist/common/vx-family.d.ts.map +1 -0
- package/dist/common/vx-family.js +12 -0
- package/dist/guard/index.d.ts +2 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +11 -13
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/shell/app-mobile-nav.d.ts.map +1 -1
- package/dist/shell/app-mobile-nav.js +2 -1
- package/dist/shell/app-mobile-nav.module.css +12 -0
- package/dist/shell/app-sidebar.d.ts.map +1 -1
- package/dist/shell/app-sidebar.js +32 -36
- package/dist/shell/app-sidebar.module.css +6 -0
- package/dist/shell/index.d.ts +0 -1
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +0 -2
- package/dist/shell/nav-glyph.d.ts +14 -0
- package/dist/shell/nav-glyph.d.ts.map +1 -0
- package/dist/shell/nav-glyph.js +21 -0
- package/dist/shell/nav-types.d.ts +1 -0
- package/dist/shell/nav-types.d.ts.map +1 -1
- package/dist/shell/sidebar-search.d.ts.map +1 -1
- package/dist/shell/sidebar-search.js +1 -3
- package/dist/surfaces.d.ts +2 -2
- package/dist/surfaces.js +2 -2
- package/dist/tokens/index.d.ts +1 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +17 -1
- package/dist/tokens/size-classes.d.ts +12 -0
- package/dist/tokens/size-classes.d.ts.map +1 -1
- package/dist/tokens/size-classes.js +11 -5
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/src/cli/check-theme.ts +63 -37
- package/src/cli/doctor.ts +12 -15
- package/src/cli/guard-hook.ts +8 -35
- package/src/cli/index.ts +126 -113
- package/src/cli/sync.ts +7 -16
- package/src/common/vx-families.ts +36 -0
- package/src/common/vx-family.ts +22 -0
- package/src/guard/index.ts +22 -25
- package/src/index.ts +0 -2
- package/src/shell/app-mobile-nav.module.css +12 -0
- package/src/shell/app-mobile-nav.tsx +8 -1
- package/src/shell/app-sidebar.module.css +6 -0
- package/src/shell/app-sidebar.tsx +52 -50
- package/src/shell/index.tsx +0 -1
- package/src/shell/nav-glyph.tsx +38 -0
- package/src/shell/nav-types.ts +1 -0
- package/src/shell/sidebar-search.tsx +1 -5
- package/src/surfaces.ts +2 -2
- package/src/tokens/index.ts +28 -1
- package/src/tokens/size-classes.ts +18 -5
- package/dist/shell/use-breakpoint.d.ts +0 -10
- package/dist/shell/use-breakpoint.d.ts.map +0 -1
- package/dist/shell/use-breakpoint.js +0 -11
- package/src/shell/use-breakpoint.ts +0 -37
package/src/cli/guard-hook.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
7
7
|
|
|
8
|
-
import { DEFAULT_GUARD_CONFIG } from '../guard'
|
|
9
|
-
import type { GuardConfig } from '../guard'
|
|
10
8
|
import { evaluateGuardHook } from '../guard/guard-hook'
|
|
11
|
-
import { SKIP, declaredProfile, defaultExempt, readBasaltConfig,
|
|
9
|
+
import { SKIP, declaredProfile, defaultExempt, readBasaltConfig, resolveGuardConfig } from './index'
|
|
12
10
|
import { DEFAULT_ROOTS } from './config'
|
|
13
11
|
|
|
12
|
+
const ALLOW = '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n'
|
|
13
|
+
|
|
14
14
|
/**
|
|
15
15
|
* guard-hook — PreToolUse stdin adapter.
|
|
16
16
|
*
|
|
@@ -31,9 +31,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
31
31
|
}
|
|
32
32
|
} catch {
|
|
33
33
|
// Unreadable stdin → allow
|
|
34
|
-
process.stdout.write(
|
|
35
|
-
'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
|
|
36
|
-
)
|
|
34
|
+
process.stdout.write(ALLOW)
|
|
37
35
|
return 0
|
|
38
36
|
}
|
|
39
37
|
|
|
@@ -42,36 +40,13 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
42
40
|
payload = JSON.parse(raw)
|
|
43
41
|
} catch {
|
|
44
42
|
// Malformed JSON → allow (never block on a bad payload)
|
|
45
|
-
process.stdout.write(
|
|
46
|
-
'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
|
|
47
|
-
)
|
|
43
|
+
process.stdout.write(ALLOW)
|
|
48
44
|
return 0
|
|
49
45
|
}
|
|
50
46
|
|
|
51
47
|
const cfg = readBasaltConfig(cwd)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
|
|
55
|
-
forbiddenAccents: cfg.forbiddenAccents ?? DEFAULT_GUARD_CONFIG.forbiddenAccents,
|
|
56
|
-
mantineShadeIndex: cfg.mantineShadeIndex ?? DEFAULT_GUARD_CONFIG.mantineShadeIndex,
|
|
57
|
-
rawSurface: cfg.rawSurface ?? DEFAULT_GUARD_CONFIG.rawSurface,
|
|
58
|
-
cardWithBorder: cfg.cardWithBorder ?? DEFAULT_GUARD_CONFIG.cardWithBorder,
|
|
59
|
-
offSystemSurfaceVar: cfg.offSystemSurfaceVar ?? DEFAULT_GUARD_CONFIG.offSystemSurfaceVar,
|
|
60
|
-
rawHtmlLayout: cfg.rawHtmlLayout ?? DEFAULT_GUARD_CONFIG.rawHtmlLayout,
|
|
61
|
-
inlineSpacing: cfg.inlineSpacing ?? DEFAULT_GUARD_CONFIG.inlineSpacing,
|
|
62
|
-
inlineDisplay: cfg.inlineDisplay ?? DEFAULT_GUARD_CONFIG.inlineDisplay,
|
|
63
|
-
rawVisxAxis: cfg.rawVisxAxis ?? DEFAULT_GUARD_CONFIG.rawVisxAxis,
|
|
64
|
-
rawMotionValue: cfg.rawMotionValue ?? DEFAULT_GUARD_CONFIG.rawMotionValue,
|
|
65
|
-
chartMissingAriaLabel: cfg.chartMissingAriaLabel ?? DEFAULT_GUARD_CONFIG.chartMissingAriaLabel,
|
|
66
|
-
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
67
|
-
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
68
|
-
allowComment: 'theme-allow',
|
|
69
|
-
exemptRules: resolveExemptRules(cfg),
|
|
70
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
71
|
-
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
72
|
-
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
73
|
-
...(declaredProfile(cfg, []) === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
74
|
-
}
|
|
48
|
+
// Same profile as check-theme: never block an edit over advice a no-Mantine app cannot take.
|
|
49
|
+
const guardCfg = resolveGuardConfig(cfg, declaredProfile(cfg, []))
|
|
75
50
|
|
|
76
51
|
// Honor the consumer's roots / exempt / skip config so the hook never blocks edits to exempted
|
|
77
52
|
// palette source or files outside the guarded roots (mirrors checkTheme's file-walk scoping).
|
|
@@ -101,9 +76,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
101
76
|
}) + '\n',
|
|
102
77
|
)
|
|
103
78
|
} else {
|
|
104
|
-
process.stdout.write(
|
|
105
|
-
'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\n',
|
|
106
|
-
)
|
|
79
|
+
process.stdout.write(ALLOW)
|
|
107
80
|
}
|
|
108
81
|
return 0
|
|
109
82
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* basalt-ui CLI — `init`, `sync`, `check-theme
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `init` / `sync` scaffold and reconcile the framework's *agentic* surface into a consumer repo:
|
|
9
|
-
* Claude Code rules + skills, a managed CLAUDE.md block, a DESIGN.md seed, and the toolchain
|
|
10
|
-
* seeds (oxlint / oxfmt / lefthook / CI). Both are sha256-manifest driven for safe,
|
|
11
|
-
* idempotent three-way reconciliation. Dependency-free — Node/Bun built-ins only.
|
|
12
|
-
*
|
|
13
|
-
* Runtime-agnostic (Node or Bun) — built-ins only, no `bun`-module import, so the exported API is
|
|
14
|
-
* safe to import under plain Node. Config is read from the consuming package.json `"basalt"` key;
|
|
15
|
-
* argo's hardcoded values are the DEFAULTS.
|
|
2
|
+
* basalt-ui CLI — the dispatcher plus the plumbing `init`, `sync`, `check-theme` and `doctor` (each
|
|
3
|
+
* in its own file) share. `init`/`sync` reconcile the agentic surface and the toolchain seeds into
|
|
4
|
+
* a consumer repo through a sha256 manifest (three-way, idempotent). Runtime-agnostic and
|
|
5
|
+
* dependency-free — Node/Bun built-ins only, no `bun`-module import. Config is the consuming
|
|
6
|
+
* package.json's `"basalt"` key; argo's values are the defaults.
|
|
16
7
|
*/
|
|
17
8
|
import { spawnSync } from 'node:child_process'
|
|
18
9
|
import { createHash } from 'node:crypto'
|
|
@@ -43,17 +34,53 @@ import { DEFAULT_ROOT, exemptRulePaths, resolveRoots } from './config'
|
|
|
43
34
|
import type { BasaltConfig } from './config'
|
|
44
35
|
|
|
45
36
|
/**
|
|
46
|
-
* The
|
|
47
|
-
*
|
|
48
|
-
* the
|
|
37
|
+
* The consumer's `GuardConfig`: every `basalt` key the guard knows, over `DEFAULT_GUARD_CONFIG`.
|
|
38
|
+
* One builder for `check-theme` and `guard-hook`, so the hook never judges an edit by a different
|
|
39
|
+
* config than the build. `exemptRules` reaches the guard as paths only — the reason the object form
|
|
40
|
+
* carries is accountability for a human and for `--audit-allows`, not a second shape to scan.
|
|
41
|
+
*/
|
|
42
|
+
type PassthroughKey = Exclude<keyof GuardConfig, 'allowComment' | 'exemptRules' | 'profile'>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The `basalt` keys that reach the guard verbatim — named, never matched by name at runtime.
|
|
46
|
+
* `satisfies Record<…>` makes a GuardConfig key missing here, or one that is not a key, a tsc error.
|
|
49
47
|
*/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
const GUARD_PASSTHROUGH = {
|
|
49
|
+
spacingSteps: true,
|
|
50
|
+
rawRadius: true,
|
|
51
|
+
forbiddenAccents: true,
|
|
52
|
+
mantineShadeIndex: true,
|
|
53
|
+
rawSurface: true,
|
|
54
|
+
cardWithBorder: true,
|
|
55
|
+
offSystemSurfaceVar: true,
|
|
56
|
+
rawHtmlLayout: true,
|
|
57
|
+
inlineSpacing: true,
|
|
58
|
+
inlineDisplay: true,
|
|
59
|
+
rawVisxAxis: true,
|
|
60
|
+
rawMotionValue: true,
|
|
61
|
+
chartMissingAriaLabel: true,
|
|
62
|
+
rawFormControl: true,
|
|
63
|
+
sub16InputFont: true,
|
|
64
|
+
severity: true,
|
|
65
|
+
} as const satisfies Record<PassthroughKey, true>
|
|
66
|
+
|
|
67
|
+
export function resolveGuardConfig(cfg: BasaltConfig, profile: DoctorProfile): GuardConfig {
|
|
68
|
+
const exemptRules: Partial<Record<GuardKind, string[]>> = {}
|
|
69
|
+
for (const [kind, entry] of Object.entries(cfg.exemptRules ?? {})) {
|
|
70
|
+
exemptRules[kind as GuardKind] = exemptRulePaths(entry)
|
|
71
|
+
}
|
|
72
|
+
// Typed as the guard's shape, so a BasaltConfig field whose type drifts from it fails to compile.
|
|
73
|
+
const from: Partial<Pick<GuardConfig, PassthroughKey>> = cfg
|
|
74
|
+
const set = (Object.keys(GUARD_PASSTHROUGH) as PassthroughKey[]).filter(
|
|
75
|
+
(k) => (from[k] ?? null) !== null,
|
|
76
|
+
)
|
|
77
|
+
return {
|
|
78
|
+
...DEFAULT_GUARD_CONFIG,
|
|
79
|
+
...Object.fromEntries(set.map((k) => [k, from[k]])),
|
|
80
|
+
allowComment: 'theme-allow',
|
|
81
|
+
exemptRules,
|
|
82
|
+
...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
|
|
55
83
|
}
|
|
56
|
-
return out
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
/**
|
|
@@ -211,13 +238,12 @@ export function relativePosix(fromDir: string, toPath: string): string {
|
|
|
211
238
|
/**
|
|
212
239
|
* Where `basalt-ui` actually resolves from, seen from a consumer directory.
|
|
213
240
|
*
|
|
214
|
-
* Every
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* renders its paths from the answer rather than assuming the root.
|
|
241
|
+
* Every seam basalt seeds (oxlint/lefthook `extends`, the CI `run:` steps) used to hardcode
|
|
242
|
+
* `./node_modules/basalt-ui` at the repo ROOT; under bun's isolated linker a package that declares
|
|
243
|
+
* basalt a peer + devDependency gets it at `<pkg>/node_modules` and NOTHING at the root, so all three
|
|
244
|
+
* silently failed to resolve and `bunx` fetched a second, different copy. Resolution therefore walks
|
|
245
|
+
* OUTWARD from the consumer dir, then across the workspace packages — and every caller renders its
|
|
246
|
+
* paths from the answer rather than assuming the root.
|
|
221
247
|
*/
|
|
222
248
|
export type BasaltInstall = {
|
|
223
249
|
/** Absolute path of the resolved `node_modules/basalt-ui` directory, or null when unresolvable. */
|
|
@@ -310,23 +336,9 @@ export function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
310
336
|
}
|
|
311
337
|
|
|
312
338
|
/**
|
|
313
|
-
* Which shape of consumer a project-scoped command is looking at
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* a Mantine component or the React theme factory is meaningless there — telling a Mantine-free app
|
|
317
|
-
* to swap its `<select>` for `@mantine/core`'s `Select` is advice it must not take.
|
|
318
|
-
*
|
|
319
|
-
* DECLARED, never inferred, when the answer SILENCES something. The two callers want opposite
|
|
320
|
-
* failure directions and so do not share a default:
|
|
321
|
-
*
|
|
322
|
-
* - `check-theme` (`declaredProfile`) turns 17 kinds OFF, so it moves on an explicit signal only —
|
|
323
|
-
* `--tokens-only`, or `"basalt": { "profile": "tokens-only" }`. Inferring it from the ABSENCE of
|
|
324
|
-
* `@mantine/core` would silence the Mantine half of the guard on any repo that keeps its Mantine
|
|
325
|
-
* dependency in a workspace package rather than the one holding the basalt config, which is
|
|
326
|
-
* precisely this round's "reports green while enforcing nothing" failure with the guard's own
|
|
327
|
-
* hand on the switch.
|
|
328
|
-
* - `doctor` no longer infers a profile at all (workspace-wide Mantine detection was deleted along
|
|
329
|
-
* with the rest of the multi-repo discovery); it reads the same declaration `check-theme` does.
|
|
339
|
+
* Which shape of consumer a project-scoped command is looking at — DECLARED, never inferred
|
|
340
|
+
* (`--tokens-only`, or `"basalt": { "profile": "tokens-only" }`), because the answer switches the
|
|
341
|
+
* Mantine-remedy guard kinds off. Why that direction: package CLAUDE.md § CLI.
|
|
330
342
|
*/
|
|
331
343
|
export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
332
344
|
if (flags.includes('--tokens-only')) return 'tokens-only'
|
|
@@ -340,8 +352,21 @@ export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): Do
|
|
|
340
352
|
* vs `--selector-attribute`. Silently preferring one meant a CI step that accumulated both flags
|
|
341
353
|
* enforced whichever the code happened to test first.
|
|
342
354
|
*/
|
|
343
|
-
export function conflictingProfileFlags(flags: readonly string[]): boolean {
|
|
344
|
-
|
|
355
|
+
export function conflictingProfileFlags(command: string, flags: readonly string[]): boolean {
|
|
356
|
+
const both = flags.includes('--tokens-only') && flags.includes('--framework')
|
|
357
|
+
if (both)
|
|
358
|
+
console.error(
|
|
359
|
+
`basalt-ui ${command}: --tokens-only and --framework are alternatives — pass one.`,
|
|
360
|
+
)
|
|
361
|
+
return both
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Says so when `BASALT_CWD` moved the command off the directory it was invoked in. */
|
|
365
|
+
export function noteRelocation(command: string, project: ProjectResolution): void {
|
|
366
|
+
if (project.relocatedFrom === null) return
|
|
367
|
+
console.log(
|
|
368
|
+
`basalt-ui ${command}: BASALT_CWD relocated from ${project.relocatedFrom} to ${project.dir}.`,
|
|
369
|
+
)
|
|
345
370
|
}
|
|
346
371
|
|
|
347
372
|
/**
|
|
@@ -401,19 +426,11 @@ function appShellFiles(rootAbs: string): string[] {
|
|
|
401
426
|
const PLAIN_JSON_HINT_PATH = 'manifest.json'
|
|
402
427
|
|
|
403
428
|
/**
|
|
404
|
-
* The waiver closer for a file, gated on the profile.
|
|
405
|
-
*
|
|
406
|
-
* `
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* opted out of that layer: rollhook's Astro site has no `index.html` for the plugin to transform,
|
|
410
|
-
* imports no basalt JavaScript at all, and owns maskable icons the plugin does not emit. Leading
|
|
411
|
-
* with the plugin there is advice the consumer cannot take, in the one release that finally gave
|
|
412
|
-
* that file class a remedy.
|
|
413
|
-
*
|
|
414
|
-
* So under tokens-only a manifest is treated as what it is to that consumer — plain JSON, with the
|
|
415
|
-
* member as the whole answer — plus one sentence saying which remedy is being withheld and why.
|
|
416
|
-
* The remedy text still comes from the guard's own registry; only the class mapping is decided here.
|
|
429
|
+
* The waiver closer for a file, gated on the profile. `guardWaiverHint` leads a `.webmanifest` with
|
|
430
|
+
* `basaltAppPlugin` — right for a Mantine app, advice a `profile: tokens-only` consumer cannot take
|
|
431
|
+
* (rollhook's Astro site has no `index.html` for the plugin and owns its maskable icons). There the
|
|
432
|
+
* manifest is plain JSON — the member is the whole answer, plus one sentence on the withheld remedy.
|
|
433
|
+
* The remedy text still comes from the guard's registry; only the class mapping is decided here.
|
|
417
434
|
*/
|
|
418
435
|
export function waiverHintFor(relPath: string, profile: DoctorProfile): string {
|
|
419
436
|
if (profile !== 'tokens-only' || !relPath.endsWith('.webmanifest')) {
|
|
@@ -568,7 +585,7 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
|
|
|
568
585
|
ACCENT_HUE: cfg.accentHue ?? 'blue',
|
|
569
586
|
SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
|
|
570
587
|
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(' '),
|
|
571
|
-
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd,
|
|
588
|
+
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, OXLINT_FILES.preset),
|
|
572
589
|
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
|
|
573
590
|
BASALT_BIN: basaltBinCommand(install, cwd),
|
|
574
591
|
}
|
|
@@ -706,6 +723,17 @@ export function findRepoRoot(dir: string): string {
|
|
|
706
723
|
}
|
|
707
724
|
}
|
|
708
725
|
|
|
726
|
+
/** The AST lane's other half: an `oxlint` bin in `node_modules/.bin`, `dir` up to the repo root. */
|
|
727
|
+
export const OXLINT_NO_BIN =
|
|
728
|
+
'oxlint config found but no oxlint binary — the AST lane never runs; add oxlint as a devDependency'
|
|
729
|
+
export function oxlintBinResolves(dir: string): boolean {
|
|
730
|
+
const root = findRepoRoot(dir)
|
|
731
|
+
for (let d = dir; ; d = dirname(d)) {
|
|
732
|
+
if (existsSync(resolve(d, 'node_modules/.bin/oxlint'))) return true
|
|
733
|
+
if (d === root || dirname(d) === d) return false
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
709
737
|
/**
|
|
710
738
|
* First path under `dir` (recursive) whose basename is `filename`, or null. Skips dependency/build
|
|
711
739
|
* dirs, mirroring `walkSourceFiles`'s scoping.
|
|
@@ -873,7 +901,7 @@ export function managedFiles(
|
|
|
873
901
|
const oxlintrc: ManagedFile = {
|
|
874
902
|
dest: '.oxlintrc.json',
|
|
875
903
|
mode: 'seed',
|
|
876
|
-
source:
|
|
904
|
+
source: OXLINT_FILES.preset,
|
|
877
905
|
render: (ctx) => `{\n "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]\n}\n`,
|
|
878
906
|
}
|
|
879
907
|
|
|
@@ -1191,16 +1219,9 @@ export function normalizeForLedger(text: string): string {
|
|
|
1191
1219
|
|
|
1192
1220
|
/**
|
|
1193
1221
|
* Write a managed unit to disk (whole file, or marker-spliced region). Returns the unit's hash —
|
|
1194
|
-
* `sha256(normalizeForLedger(desired))`, NOT the raw bytes
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* dest — a stored NORMALIZED hash makes that same single entry survive both a downstream formatter
|
|
1198
|
-
* (the consumer's own lefthook oxfmt reformatting the written file/block after this function has
|
|
1199
|
-
* already returned) and a version bump that changed the template's own words since the recorded
|
|
1200
|
-
* entry — the argo cross-version case: current is v1.0.0's block, post-oxfmt; desired is v1.0.1's
|
|
1201
|
-
* (different words, e.g. the CLI bin rename). Recording the raw hash can never reconcile that;
|
|
1202
|
-
* recording the normalized hash lets `classify()` normalize `state.current` the same way and
|
|
1203
|
-
* compare directly against it, no separate historical-hash storage required.
|
|
1222
|
+
* `sha256(normalizeForLedger(desired))`, NOT the raw bytes: the normalized hash is what lets one
|
|
1223
|
+
* manifest entry survive a downstream formatter and a cross-version template change (`classify`'s
|
|
1224
|
+
* path 2 is the reader).
|
|
1204
1225
|
*/
|
|
1205
1226
|
export function writeUnit(file: ManagedFile, cwd: string, desired: string): string {
|
|
1206
1227
|
const destAbs = resolve(cwd, file.dest)
|
|
@@ -1217,29 +1238,16 @@ export function writeUnit(file: ManagedFile, cwd: string, desired: string): stri
|
|
|
1217
1238
|
* Classify a managed file against the manifest for a sync run.
|
|
1218
1239
|
* - missing : the managed unit is absent on disk → recreate.
|
|
1219
1240
|
* - current : on disk == desired → nothing to do.
|
|
1220
|
-
* - unchanged: on disk matches a KNOWN-SHIPPED rendering of this template
|
|
1221
|
-
*
|
|
1222
|
-
* 1. `sha256(current) === manifestHash` — legacy raw-hash path
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
* downstream formatter AND the template body changed cross-version in the same
|
|
1229
|
-
* upgrade, none of the three paths above match and it classifies `drifted`, needing
|
|
1230
|
-
* one `--force`. Once any sync since writes a normalized entry (path 2/3 or a
|
|
1231
|
-
* healed write), the gap is closed for good for that file. Deliberately not solved
|
|
1232
|
-
* by keeping a historical per-version ledger — that's a real but bounded tradeoff.
|
|
1233
|
-
* 2. `sha256(normalizeForLedger(current)) === manifestHash` — the ledger proper.
|
|
1234
|
-
* `manifestHash` normally holds `sha256(normalizeForLedger(<bytes written at the
|
|
1235
|
-
* last sync>))` (see `writeUnit`); normalizing `current` the same way and comparing
|
|
1236
|
-
* survives a downstream formatter reformatting the file AND a version bump that
|
|
1237
|
-
* changed the template's own words between the sync that wrote this manifest entry
|
|
1238
|
-
* and the version being synced now — the argo cross-version false positive.
|
|
1241
|
+
* - unchanged: on disk matches a KNOWN-SHIPPED rendering of this template (any one is sufficient) —
|
|
1242
|
+
* but != desired as-is → safe overwrite:
|
|
1243
|
+
* 1. `sha256(current) === manifestHash` — legacy raw-hash path (manifests written before
|
|
1244
|
+
* the ledger normalized hold the RAW hash; keep matching so an upgrade never
|
|
1245
|
+
* mass-classifies a pristine tree as drifted).
|
|
1246
|
+
* 2. `sha256(normalizeForLedger(current)) === manifestHash` — the ledger proper (see
|
|
1247
|
+
* `writeUnit`): survives a downstream formatter AND a version bump that changed the
|
|
1248
|
+
* template's own words since the manifest entry was written (the argo false positive).
|
|
1239
1249
|
* 3. `normalizeForLedger(current) === normalizeForLedger(desired)` — same-version
|
|
1240
|
-
* fallback
|
|
1241
|
-
* was dropped), but the on-disk bytes still match today's rendering modulo
|
|
1242
|
-
* formatting noise.
|
|
1250
|
+
* fallback when no manifest entry exists at all.
|
|
1243
1251
|
* - drifted : on disk matches NONE of the above (a real, word-level local edit) → skip unless
|
|
1244
1252
|
* --force.
|
|
1245
1253
|
*/
|
|
@@ -1461,22 +1469,26 @@ export function parseJsonc(text: string): Record<string, unknown> | null {
|
|
|
1461
1469
|
/** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
|
|
1462
1470
|
export type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
|
|
1463
1471
|
|
|
1472
|
+
/** The shipped oxlint files, package-relative — the one spelling every reader of them uses. */
|
|
1473
|
+
export const OXLINT_FILES = {
|
|
1474
|
+
preset: 'configs/oxlint.json',
|
|
1475
|
+
guards: 'configs/oxlint-basalt.json',
|
|
1476
|
+
plugin: 'configs/oxlint-plugin.js',
|
|
1477
|
+
} as const
|
|
1478
|
+
|
|
1479
|
+
/** Whether an `extends`/`jsPlugins` entry names that shipped basalt-ui file. */
|
|
1480
|
+
export const namesShipped = (entry: unknown, file: string): entry is string =>
|
|
1481
|
+
typeof entry === 'string' && entry.endsWith(`basalt-ui/${file}`)
|
|
1482
|
+
|
|
1464
1483
|
/**
|
|
1465
|
-
* The `extends` entry
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
1468
|
-
*
|
|
1469
|
-
* `./node_modules/basalt-ui/configs/oxlint.json` that resolves to nothing, and oxlint then refuses
|
|
1470
|
-
* to start with `NotFound` while a check built to prove the framework is ON reports green. The
|
|
1471
|
-
* caller resolves this against the config's own directory — see `extendsSeam`.
|
|
1484
|
+
* The `extends` entry naming a shipped preset (full or guards-only), or null. The ENTRY, not a
|
|
1485
|
+
* boolean: a well-shaped `./node_modules/basalt-ui/configs/oxlint.json` can resolve to nothing
|
|
1486
|
+
* (an upgrade, an isolated linker), and oxlint then dies `NotFound` while a check built to prove the
|
|
1487
|
+
* lane is ON reports green — callers resolve it against the config's own directory.
|
|
1472
1488
|
*/
|
|
1473
1489
|
export function basaltPresetEntry(entries: unknown): string | null {
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
(entry): entry is string =>
|
|
1477
|
-
typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
|
|
1478
|
-
)
|
|
1479
|
-
return match ?? null
|
|
1490
|
+
const presets = [OXLINT_FILES.preset, OXLINT_FILES.guards]
|
|
1491
|
+
return [entries].flat().find((e): e is string => presets.some((f) => namesShipped(e, f))) ?? null
|
|
1480
1492
|
}
|
|
1481
1493
|
|
|
1482
1494
|
// ── The lefthook gate — checked via `lefthook dump`, not hand-rolled YAML parsing ────────────────
|
|
@@ -1557,13 +1569,14 @@ export function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintRe
|
|
|
1557
1569
|
if (raw === null) return 'absent'
|
|
1558
1570
|
const cfg = parseJsonc(raw)
|
|
1559
1571
|
if (cfg === null) return 'unreadable'
|
|
1560
|
-
if (
|
|
1572
|
+
if ([cfg['extends']].flat().some((e) => namesShipped(e, OXLINT_FILES.preset))) return 'already'
|
|
1561
1573
|
// Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
|
|
1562
1574
|
// which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
|
|
1563
1575
|
if (stripJsonc(raw) !== raw) return 'has-comments'
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1576
|
+
// The guards-only preset is a subset of the full one — upgrading replaces it, never stacks it.
|
|
1577
|
+
const existing = [cfg['extends']]
|
|
1578
|
+
.flat()
|
|
1579
|
+
.filter((e): e is string => typeof e === 'string' && !namesShipped(e, OXLINT_FILES.guards))
|
|
1567
1580
|
// Rebuild rather than spread, so `extends` keeps its original position when it was already there
|
|
1568
1581
|
// and lands first when it wasn't — a diff a human reviews should not reshuffle the whole file.
|
|
1569
1582
|
const merged: Record<string, unknown> = 'extends' in cfg ? {} : { extends: [presetPath] }
|
package/src/cli/sync.ts
CHANGED
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
isStringArray,
|
|
21
21
|
managedFiles,
|
|
22
22
|
normalizeForLedger,
|
|
23
|
+
noteRelocation,
|
|
24
|
+
OXLINT_FILES,
|
|
23
25
|
parseJsonc,
|
|
24
26
|
placementNotices,
|
|
25
27
|
pruneRetiredManagedFiles,
|
|
@@ -44,14 +46,8 @@ import {
|
|
|
44
46
|
|
|
45
47
|
/** The oxlint plugins the shipped preset switches on — read from the preset, never restated. */
|
|
46
48
|
export function readOxlintPresetPlugins(pkgRoot: string): string[] {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
const plugins = (JSON.parse(raw) as { plugins?: unknown }).plugins
|
|
51
|
-
return isStringArray(plugins) ? plugins : []
|
|
52
|
-
} catch {
|
|
53
|
-
return []
|
|
54
|
-
}
|
|
49
|
+
const plugins = parseJsonc(readSource(pkgRoot, OXLINT_FILES.preset) ?? 'null')?.['plugins']
|
|
50
|
+
return isStringArray(plugins) ? plugins : []
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
export type SyncOptions = {
|
|
@@ -111,15 +107,10 @@ export function findManifestAbove(dir: string): string | null {
|
|
|
111
107
|
*/
|
|
112
108
|
export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
|
|
113
109
|
const syncFlags = opts.flags ?? []
|
|
114
|
-
if (conflictingProfileFlags(syncFlags))
|
|
115
|
-
console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
|
|
116
|
-
return 1
|
|
117
|
-
}
|
|
110
|
+
if (conflictingProfileFlags('sync', syncFlags)) return 1
|
|
118
111
|
const project = resolveProjectDir(invocationCwd)
|
|
119
112
|
const cwd = project.dir
|
|
120
|
-
|
|
121
|
-
console.log(`basalt-ui sync: BASALT_CWD relocated from ${project.relocatedFrom} to ${cwd}.`)
|
|
122
|
-
}
|
|
113
|
+
noteRelocation('sync', project)
|
|
123
114
|
|
|
124
115
|
// Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
|
|
125
116
|
// wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
|
|
@@ -265,7 +256,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
265
256
|
console.error(
|
|
266
257
|
`⚠ basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist — oxlint ` +
|
|
267
258
|
'refuses to start on a missing extends target (`NotFound`). Repoint it at ' +
|
|
268
|
-
`"${shippedAssetPath(install, cwd,
|
|
259
|
+
`"${shippedAssetPath(install, cwd, OXLINT_FILES.preset)}".`,
|
|
269
260
|
)
|
|
270
261
|
}
|
|
271
262
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// GENERATED by scripts/gen-vx-names.ts — do not edit. `vx-names.test.ts` fails on drift.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The families every emitted `--vx-*` name lives in (`basaltVxFamilies`) — basalt-owned:
|
|
5
|
+
* `unknown-vx-token` judges every name inside one, and `groupTokens`/`seriesTokens` warn on a
|
|
6
|
+
* consumer ref there.
|
|
7
|
+
*/
|
|
8
|
+
export const BASALT_VX_FAMILIES: readonly string[] = [
|
|
9
|
+
'accent',
|
|
10
|
+
'area',
|
|
11
|
+
'axis',
|
|
12
|
+
'bad',
|
|
13
|
+
'crosshair',
|
|
14
|
+
'divider',
|
|
15
|
+
'dot',
|
|
16
|
+
'faint',
|
|
17
|
+
'fill',
|
|
18
|
+
'good',
|
|
19
|
+
'grid',
|
|
20
|
+
'hit',
|
|
21
|
+
'ink',
|
|
22
|
+
'keyboard',
|
|
23
|
+
'legend',
|
|
24
|
+
'line',
|
|
25
|
+
'muted',
|
|
26
|
+
'neutral',
|
|
27
|
+
'prose',
|
|
28
|
+
'radius',
|
|
29
|
+
'shadow',
|
|
30
|
+
'space',
|
|
31
|
+
'status',
|
|
32
|
+
'surface',
|
|
33
|
+
'text',
|
|
34
|
+
'tooltip',
|
|
35
|
+
'warn',
|
|
36
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `--vx-*` name's FAMILY — its leading lowercase run: `--vx-surface-bg` → `surface`, `--vx-ink2`
|
|
3
|
+
* → `ink`, `--vx-tooltipBg` → `tooltip`, `--vx-fillHover-cyan` → `fill`. Dependency-free, because
|
|
4
|
+
* three sides must agree on it byte for byte: the generator that writes `BASALT_VX_FAMILIES`, the
|
|
5
|
+
* guard that judges every name inside one of those families, and the token factories that refuse a
|
|
6
|
+
* consumer name inside one — the refusal is what makes the guard's judgement honest.
|
|
7
|
+
*/
|
|
8
|
+
export function vxFamilyOf(name: string): string {
|
|
9
|
+
return /^--vx-([a-z]*)/.exec(name)?.[1] ?? ''
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Families a consumer extends without any factory: the theme emits `--vx-on-<color>` for EVERY
|
|
14
|
+
* `theme.colors` entry, a consumer's own colors included, so `on` is never basalt's alone.
|
|
15
|
+
*/
|
|
16
|
+
const CONSUMER_EXTENSIBLE_FAMILIES: ReadonlySet<string> = new Set(['on'])
|
|
17
|
+
|
|
18
|
+
/** The basalt-owned families among `names` — run over every name basalt emits (`collectVxNames`). */
|
|
19
|
+
export function basaltVxFamilies(names: readonly string[]): string[] {
|
|
20
|
+
const families = new Set(names.map(vxFamilyOf))
|
|
21
|
+
return [...families].filter((f) => f !== '' && !CONSUMER_EXTENSIBLE_FAMILIES.has(f)).toSorted()
|
|
22
|
+
}
|
package/src/guard/index.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* checkSource: pure (text, relPath, cfg) → Finding[]. No FS, no walk, no console.
|
|
6
6
|
*/
|
|
7
7
|
import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types'
|
|
8
|
+
import { vxFamilyOf } from '../common/vx-family'
|
|
9
|
+
import { BASALT_VX_FAMILIES } from '../common/vx-families'
|
|
8
10
|
import { BASALT_VX_NAMES } from './vx-names'
|
|
9
11
|
|
|
10
12
|
export type { Finding, GuardConfig, GuardKind, GuardSeverity }
|
|
@@ -327,21 +329,16 @@ const REMOVED_VX_NAMES: Readonly<Record<string, string | null>> = {
|
|
|
327
329
|
const KNOWN_VX_NAMES: ReadonlySet<string> = new Set(BASALT_VX_NAMES)
|
|
328
330
|
|
|
329
331
|
/**
|
|
330
|
-
* The
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* `groupTokens`. It is the one shape of unknown name basalt can judge WITHOUT seeing the consumer's
|
|
334
|
-
* palette.
|
|
332
|
+
* The families basalt owns (`vxFamilyOf`: `surface`, `space`, `ink`, `keyboard`, …), which the
|
|
333
|
+
* generator reads off every name basalt emits. Every name inside one is judged against the exact
|
|
334
|
+
* emitted set, so `--vx-surface-2` and a bare root like `--vx-space` are both findings.
|
|
335
335
|
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
336
|
+
* That judgement is honest only because no consumer ref may point into these families:
|
|
337
|
+
* `groupTokens`/`seriesTokens` warn on a name inside one (and throw once this kind promotes). A name in any OTHER family (argo's
|
|
338
|
+
* `--vx-activity-*`, rb's `--vx-confidence-*`) may be a consumer group declared in a file this scan
|
|
339
|
+
* cannot see, and is never judged.
|
|
339
340
|
*/
|
|
340
|
-
const
|
|
341
|
-
BASALT_VX_NAMES.map((n) => /^(--vx-[A-Za-z0-9]+)-/.exec(n)?.[1]).filter(
|
|
342
|
-
(root): root is string => root !== undefined && !KNOWN_VX_NAMES.has(root),
|
|
343
|
-
),
|
|
344
|
-
)
|
|
341
|
+
const BASALT_VX_FAMILY_SET: ReadonlySet<string> = new Set(BASALT_VX_FAMILIES)
|
|
345
342
|
|
|
346
343
|
// Raw Mantine ramp step used for surface color — gray/dark + a step digit.
|
|
347
344
|
const OFF_SYSTEM_SURFACE_VAR = /var\(--mantine-color-(gray|dark)-\d/g
|
|
@@ -919,13 +916,13 @@ export type GraceEntry = { since: string; promote: string; why: string }
|
|
|
919
916
|
*/
|
|
920
917
|
export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
|
|
921
918
|
'unknown-vx-token': {
|
|
922
|
-
since: '1.
|
|
923
|
-
promote: '1.
|
|
919
|
+
since: '1.34.0',
|
|
920
|
+
promote: '1.35.0',
|
|
924
921
|
why:
|
|
925
|
-
'New kind (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
|
|
926
|
-
'`var(--vx-space-touch-target)` that nothing reported.
|
|
927
|
-
'
|
|
928
|
-
'
|
|
922
|
+
'New kind at 1.33.0 (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
|
|
923
|
+
'`var(--vx-space-touch-target)` that nothing reported. WIDENED at 1.34.0 (image-gen r4: ' +
|
|
924
|
+
'`var(--vx-surface-2)` rendered transparent, unreported) to every unemitted name inside a ' +
|
|
925
|
+
'basalt-owned family, so the grace restarts: warn through 1.34.x, error at 1.35.0.',
|
|
929
926
|
},
|
|
930
927
|
}
|
|
931
928
|
|
|
@@ -2105,7 +2102,7 @@ export const GUARD_RULES = {
|
|
|
2105
2102
|
// JSX-tag-shaped (`<Card withBorder>`) — never appears in CSS text.
|
|
2106
2103
|
appliesTo: (relPath) => !relPath.endsWith('.css'),
|
|
2107
2104
|
message:
|
|
2108
|
-
'withBorder on a Card/Paper double-draws the edge — card depth is --vx-shadow-card, which already bakes a 1px ring into the shadow. Drop the prop (docs/DESIGN-SPEC.md doctrine inversion #1).',
|
|
2105
|
+
'withBorder on a Card/Paper double-draws the edge — card depth is --vx-shadow-card, which already bakes a 1px ring into the shadow. Drop the prop (docs/DESIGN-SPEC.md doctrine inversion #1) — and fix the inset in the same edit: an explicit p/padding or radius on that Card/Paper is the other half of the idiom, which only the oxlint lane sees (basalt/card-inset); leave both to the theme or use py="xs" px="sm".',
|
|
2109
2106
|
},
|
|
2110
2107
|
'off-system-surface-var': {
|
|
2111
2108
|
kind: 'off-system-surface-var',
|
|
@@ -2257,14 +2254,14 @@ export const GUARD_RULES = {
|
|
|
2257
2254
|
},
|
|
2258
2255
|
'unknown-vx-token': {
|
|
2259
2256
|
kind: 'unknown-vx-token',
|
|
2260
|
-
pattern: VX_VAR_REF, // handled inline (
|
|
2261
|
-
message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere.
|
|
2257
|
+
pattern: VX_VAR_REF, // handled inline (basalt-owned families); entry keeps the registry complete
|
|
2258
|
+
message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Judged: every name inside a basalt-owned family (${[...BASALT_VX_FAMILY_SET].join(', ')}) — pick the token from basalt-ui/tokens.css; your own series belong in a family of their own (groupTokens warns on one inside basalt's). Removed names and their replacements: ${Object.entries(
|
|
2262
2259
|
REMOVED_VX_NAMES,
|
|
2263
2260
|
)
|
|
2264
2261
|
.map(([from, to]) => `${from} → ${to ?? '(deleted, no replacement)'}`)
|
|
2265
2262
|
.join(
|
|
2266
2263
|
'; ',
|
|
2267
|
-
)}
|
|
2264
|
+
)}. A name in any other family is never judged, because groupTokens may declare it in a file this scan cannot see.`,
|
|
2268
2265
|
},
|
|
2269
2266
|
} as const satisfies Record<GuardKind, GuardRule>
|
|
2270
2267
|
|
|
@@ -2445,13 +2442,13 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
|
|
|
2445
2442
|
[...codeText.matchAll(VX_LOCAL_DECLARATION)].map((m) => m[1] ?? m[3] ?? m[4] ?? ''),
|
|
2446
2443
|
)
|
|
2447
2444
|
|
|
2448
|
-
// unknown-vx-token:
|
|
2445
|
+
// unknown-vx-token: any unemitted name inside a basalt-owned family — see BASALT_VX_FAMILY_SET.
|
|
2449
2446
|
// Scanned over the whole comment-stripped text rather than per line, so a formatter-wrapped
|
|
2450
2447
|
// `var(\n --vx-x\n)` is seen; the finding lands on the line the NAME is on.
|
|
2451
2448
|
for (const m of codeText.matchAll(VX_VAR_REF)) {
|
|
2452
2449
|
const name = m[1] ?? ''
|
|
2453
2450
|
if (locallyDeclaredVx.has(name)) continue
|
|
2454
|
-
if (
|
|
2451
|
+
if (KNOWN_VX_NAMES.has(name) || !BASALT_VX_FAMILY_SET.has(vxFamilyOf(name))) continue
|
|
2455
2452
|
const at = (m.index ?? 0) + m[0].length - name.length
|
|
2456
2453
|
push('unknown-vx-token', lineStarts.findLastIndex((start) => start <= at) + 1, name)
|
|
2457
2454
|
}
|