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/dist/cli/index.js
CHANGED
|
@@ -10,10 +10,35 @@ import {
|
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
checkSource,
|
|
15
|
+
DEFAULT_GUARD_CONFIG,
|
|
16
|
+
GENERATED_HEADER_LINE,
|
|
17
|
+
guardKindRemedy,
|
|
18
|
+
GUARD_RULES,
|
|
19
|
+
guardWaiverHint,
|
|
20
|
+
TOKENS_ONLY_DISABLED_KINDS,
|
|
21
|
+
unmatchedExemptPatterns
|
|
22
|
+
} from "../guard/index.js";
|
|
14
23
|
import { evaluateGuardHook } from "../guard/guard-hook.js";
|
|
15
24
|
import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
|
|
16
25
|
import { buildPaletteCss, deriveSpacing } from "../tokens/index.js";
|
|
26
|
+
function exemptRulePaths(entry) {
|
|
27
|
+
if (entry === void 0) return [];
|
|
28
|
+
return Array.isArray(entry) ? entry : entry.paths;
|
|
29
|
+
}
|
|
30
|
+
function exemptRuleReason(entry) {
|
|
31
|
+
if (entry === void 0 || Array.isArray(entry)) return null;
|
|
32
|
+
return entry.reason.trim().length > 0 ? entry.reason.trim() : null;
|
|
33
|
+
}
|
|
34
|
+
function resolveExemptRules(cfg) {
|
|
35
|
+
if (cfg.exemptRules === void 0) return DEFAULT_GUARD_CONFIG.exemptRules;
|
|
36
|
+
const out = {};
|
|
37
|
+
for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
|
|
38
|
+
out[kind] = exemptRulePaths(entry);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
17
42
|
const DEFAULT_ROOT = "src";
|
|
18
43
|
const DEFAULT_ROOTS = [DEFAULT_ROOT];
|
|
19
44
|
function resolveRoots(cfg) {
|
|
@@ -61,6 +86,7 @@ function readBasaltConfig(cwd) {
|
|
|
61
86
|
return {};
|
|
62
87
|
}
|
|
63
88
|
}
|
|
89
|
+
const SCANNABLE_EXT = /\.(?:tsx?|css|html?|webmanifest)$/;
|
|
64
90
|
function walkSourceFiles(dir, out = []) {
|
|
65
91
|
let entries;
|
|
66
92
|
try {
|
|
@@ -78,33 +104,149 @@ function walkSourceFiles(dir, out = []) {
|
|
|
78
104
|
continue;
|
|
79
105
|
}
|
|
80
106
|
if (isDir) walkSourceFiles(abs, out);
|
|
81
|
-
else if (
|
|
107
|
+
else if (SCANNABLE_EXT.test(name)) out.push(abs);
|
|
82
108
|
}
|
|
83
109
|
return out;
|
|
84
110
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
function relativePosix(fromDir, toPath) {
|
|
112
|
+
const rel = relative(fromDir, toPath).replace(/\\/g, "/");
|
|
113
|
+
if (rel === "") return ".";
|
|
114
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
115
|
+
}
|
|
116
|
+
const UNRESOLVED_INSTALL = { dir: null, version: null, how: null };
|
|
117
|
+
function basaltInstallAt(dir) {
|
|
118
|
+
const installDir = resolve(dir, "node_modules", "basalt-ui");
|
|
119
|
+
const raw = readIfExists(resolve(installDir, "package.json"));
|
|
120
|
+
if (raw === null) return null;
|
|
121
|
+
try {
|
|
122
|
+
const pkg = JSON.parse(raw);
|
|
123
|
+
return { dir: installDir, version: pkg.version ?? null };
|
|
124
|
+
} catch {
|
|
125
|
+
return { dir: installDir, version: null };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function findBasaltInstall(cwd) {
|
|
129
|
+
const own = basaltInstallAt(cwd);
|
|
130
|
+
if (own !== null) return { ...own, how: "cwd" };
|
|
131
|
+
let current = dirname(cwd);
|
|
132
|
+
for (; ; ) {
|
|
133
|
+
const found = basaltInstallAt(current);
|
|
134
|
+
if (found !== null) return { ...found, how: "ancestor" };
|
|
135
|
+
const parent = dirname(current);
|
|
136
|
+
if (parent === current) break;
|
|
137
|
+
current = parent;
|
|
138
|
+
}
|
|
139
|
+
for (const pkg of collectWorkspacePackages(cwd).packages) {
|
|
140
|
+
if (pkg.dir === cwd) continue;
|
|
141
|
+
const found = basaltInstallAt(pkg.dir);
|
|
142
|
+
if (found !== null) return { ...found, how: "workspace" };
|
|
143
|
+
}
|
|
144
|
+
return UNRESOLVED_INSTALL;
|
|
145
|
+
}
|
|
146
|
+
function shippedAssetPath(install, fromDir, asset) {
|
|
147
|
+
if (install.dir === null) return `./node_modules/basalt-ui/${asset}`;
|
|
148
|
+
return `${relativePosix(fromDir, install.dir)}/${asset}`;
|
|
149
|
+
}
|
|
150
|
+
function hasBasaltProject(dir) {
|
|
151
|
+
if (existsSync(resolve(dir, MANIFEST_PATH))) return true;
|
|
152
|
+
const raw = readIfExists(resolve(dir, "package.json"));
|
|
153
|
+
if (raw === null) return false;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(raw).basalt !== void 0;
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function resolveProjectDir(cwd) {
|
|
161
|
+
const override = process.env["BASALT_CWD"];
|
|
162
|
+
if (override !== void 0 && override.length > 0) {
|
|
163
|
+
const dir = isAbsolute(override) ? override : resolve(cwd, override);
|
|
164
|
+
return { dir, relocatedFrom: dir === cwd ? null : cwd, ambiguous: null };
|
|
165
|
+
}
|
|
166
|
+
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null };
|
|
167
|
+
const candidates = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir)).map((pkg) => pkg.dir);
|
|
168
|
+
if (candidates.length === 1) {
|
|
169
|
+
return { dir: candidates[0], relocatedFrom: cwd, ambiguous: null };
|
|
170
|
+
}
|
|
171
|
+
if (candidates.length > 1) return { dir: cwd, relocatedFrom: null, ambiguous: candidates };
|
|
172
|
+
return { dir: cwd, relocatedFrom: null, ambiguous: null };
|
|
173
|
+
}
|
|
174
|
+
function declaredProfile(cfg, flags) {
|
|
175
|
+
if (flags.includes("--tokens-only")) return "tokens-only";
|
|
176
|
+
if (flags.includes("--framework")) return "framework";
|
|
177
|
+
return cfg.profile === "tokens-only" ? "tokens-only" : "framework";
|
|
178
|
+
}
|
|
179
|
+
function conflictingProfileFlags(flags) {
|
|
180
|
+
return flags.includes("--tokens-only") && flags.includes("--framework");
|
|
181
|
+
}
|
|
182
|
+
function workspaceHasMantine(cwd) {
|
|
183
|
+
if (hasDependency(cwd, "@mantine/core")) return true;
|
|
184
|
+
return collectWorkspacePackages(cwd).packages.some(
|
|
185
|
+
(pkg) => hasDependency(pkg.dir, "@mantine/core")
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
function inferredProfile(cwd, cfg, flags) {
|
|
189
|
+
if (flags.includes("--tokens-only") || flags.includes("--framework") || cfg.profile !== void 0) {
|
|
190
|
+
return declaredProfile(cfg, flags);
|
|
191
|
+
}
|
|
192
|
+
return !existsSync(resolve(cwd, MANIFEST_PATH)) && !workspaceHasMantine(cwd) ? "tokens-only" : "framework";
|
|
193
|
+
}
|
|
194
|
+
function scannableFiles(cwd, cfg) {
|
|
107
195
|
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
|
|
196
|
+
const seen = /* @__PURE__ */ new Set();
|
|
197
|
+
const out = [];
|
|
198
|
+
const add = (abs) => {
|
|
199
|
+
const rel = relative(cwd, abs).replace(/\\/g, "/");
|
|
200
|
+
if (rel.startsWith("..") || SKIP.test(rel) || exempt.has(rel) || seen.has(rel)) return;
|
|
201
|
+
seen.add(rel);
|
|
202
|
+
out.push(rel);
|
|
203
|
+
};
|
|
204
|
+
for (const root of resolveRoots(cfg)) {
|
|
205
|
+
const rootAbs = resolve(cwd, root);
|
|
206
|
+
for (const f of walkSourceFiles(rootAbs)) add(f);
|
|
207
|
+
for (const f of appShellFiles(rootAbs)) add(f);
|
|
208
|
+
}
|
|
209
|
+
for (const rel of cfg.include ?? []) add(resolve(cwd, rel));
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function appShellFiles(rootAbs) {
|
|
213
|
+
const appDir = dirname(rootAbs);
|
|
214
|
+
const out = [];
|
|
215
|
+
for (const name of ["index.html", "index.htm"]) {
|
|
216
|
+
const abs = resolve(appDir, name);
|
|
217
|
+
if (existsSync(abs)) out.push(abs);
|
|
218
|
+
}
|
|
219
|
+
out.push(...walkSourceFiles(resolve(appDir, "public")));
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
223
|
+
if (conflictingProfileFlags(flags)) {
|
|
224
|
+
console.error(
|
|
225
|
+
"basalt-ui check-theme: --tokens-only and --framework are alternatives \u2014 pass one."
|
|
226
|
+
);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
const project = resolveProjectDir(invocationCwd);
|
|
230
|
+
if (project.ambiguous !== null) {
|
|
231
|
+
console.error(
|
|
232
|
+
`\u2716 basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(", ")}) \u2014 run it from one of them, or set BASALT_CWD to pick.`
|
|
233
|
+
);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
const cwd = project.dir;
|
|
237
|
+
if (project.relocatedFrom !== null) {
|
|
238
|
+
console.log(
|
|
239
|
+
`basalt-ui check-theme: no basalt config at ${project.relocatedFrom} \u2014 running in ${relativePosix(project.relocatedFrom, cwd)}, where it lives.`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const cfg = readBasaltConfig(cwd);
|
|
243
|
+
const roots = resolveRoots(cfg);
|
|
244
|
+
const profile = declaredProfile(cfg, flags);
|
|
245
|
+
if (profile === "tokens-only") {
|
|
246
|
+
console.log(
|
|
247
|
+
`basalt-ui check-theme: tokens-only profile \u2014 ${TOKENS_ONLY_DISABLED_KINDS.size} Mantine-coupled kinds are off; the color and typography kinds still apply. Pass --framework to force the full set.`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
108
250
|
const guardCfg = {
|
|
109
251
|
spacingSteps: cfg.spacingSteps ?? DEFAULT_GUARD_CONFIG.spacingSteps,
|
|
110
252
|
rawRadius: cfg.rawRadius ?? DEFAULT_GUARD_CONFIG.rawRadius,
|
|
@@ -123,21 +265,16 @@ function checkTheme(cwd = process.cwd()) {
|
|
|
123
265
|
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
124
266
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
125
267
|
allowComment: "theme-allow",
|
|
126
|
-
exemptRules: cfg
|
|
127
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
|
|
268
|
+
exemptRules: resolveExemptRules(cfg),
|
|
269
|
+
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
270
|
+
...profile === "tokens-only" ? { profile: "tokens-only" } : {}
|
|
128
271
|
};
|
|
129
272
|
const findings = [];
|
|
130
|
-
|
|
131
|
-
for (const
|
|
132
|
-
|
|
133
|
-
const rel = relative(cwd, f).replace(/\\/g, "/");
|
|
134
|
-
if (SKIP.test(rel) || exempt.has(rel)) continue;
|
|
135
|
-
scannedCount += 1;
|
|
136
|
-
const text = readFileSync(f, "utf8");
|
|
137
|
-
findings.push(...checkSource(text, rel, guardCfg));
|
|
138
|
-
}
|
|
273
|
+
const scanned = scannableFiles(cwd, cfg);
|
|
274
|
+
for (const rel of scanned) {
|
|
275
|
+
findings.push(...checkSource(readFileSync(resolve(cwd, rel), "utf8"), rel, guardCfg));
|
|
139
276
|
}
|
|
140
|
-
if (
|
|
277
|
+
if (scanned.length === 0) {
|
|
141
278
|
if (cfg.roots === void 0) {
|
|
142
279
|
console.error(
|
|
143
280
|
`\u2716 basalt-ui check-theme: 0 files scanned \u2014 no "basalt.roots" configured in package.json, and the built-in default roots (${DEFAULT_ROOTS.join(", ")}) matched zero files. Set "basalt": { "roots": [...] } in package.json to point at your source directories.`
|
|
@@ -149,6 +286,12 @@ function checkTheme(cwd = process.cwd()) {
|
|
|
149
286
|
}
|
|
150
287
|
return 1;
|
|
151
288
|
}
|
|
289
|
+
if (flags.includes("--audit-allows")) return auditAllows(cwd, cfg, guardCfg, scanned);
|
|
290
|
+
for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
|
|
291
|
+
console.error(
|
|
292
|
+
`\u26A0 basalt-ui check-theme: "basalt.exemptRules" entry ${unmatched.kind}: "${unmatched.pattern}" ` + (unmatched.reason === "unknown-kind" ? "names no guard kind \u2014 it exempts nothing at all (a typo, or a kind that was renamed)." : "matched none of the scanned files \u2014 it suppresses nothing, and reads as coverage in a config review while enforcing as much as an empty object.")
|
|
293
|
+
);
|
|
294
|
+
}
|
|
152
295
|
if (findings.length === 0) {
|
|
153
296
|
console.log("\u2713 Theme guard: no off-palette colors.");
|
|
154
297
|
return 0;
|
|
@@ -185,14 +328,9 @@ function checkTheme(cwd = process.cwd()) {
|
|
|
185
328
|
report(errors, `\u2716 Theme guard: ${errors.length} off-palette / off-identity violation(s)
|
|
186
329
|
`);
|
|
187
330
|
}
|
|
188
|
-
const presentKinds = new Set(findings.map((f) => f.kind));
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
"Fix:",
|
|
192
|
-
...explanations,
|
|
193
|
-
"Add a `theme-allow` comment for a deliberate exception."
|
|
194
|
-
];
|
|
195
|
-
console.error(fixLine.join(" "));
|
|
331
|
+
const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted();
|
|
332
|
+
const waiverHints = [...new Set(findings.map((f) => guardWaiverHint(f.relPath)))].toSorted();
|
|
333
|
+
console.error(["Fix:", ...presentKinds.map(guardKindRemedy), ...waiverHints].join(" "));
|
|
196
334
|
if (findings.some((f) => SERIES_MODULE_HINT_RE.test(f.relPath))) {
|
|
197
335
|
console.error(
|
|
198
336
|
'Hint: if a flagged file IS your palette/series source, exempt it via "basalt": { "exempt": ["<path>"] } in package.json.'
|
|
@@ -200,6 +338,130 @@ function checkTheme(cwd = process.cwd()) {
|
|
|
200
338
|
}
|
|
201
339
|
return errors.length > 0 ? 1 : 0;
|
|
202
340
|
}
|
|
341
|
+
const AUDIT_NEUTRALIZED_TOKEN = "basalt-audit-neutralized";
|
|
342
|
+
function findingKey(f) {
|
|
343
|
+
return `${f.relPath}:${f.line}:${f.kind}`;
|
|
344
|
+
}
|
|
345
|
+
const AUDIT_ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/;
|
|
346
|
+
function annotationRuleIds(rest) {
|
|
347
|
+
const head = (rest.replace(/^-file\b/, "").split(/—|–|:|\s-\s/)[0] ?? "").trim();
|
|
348
|
+
return head.split(",").map((word) => word.trim().replace(/^basalt\//, "")).filter((word) => /^[a-z][a-z0-9-]*$/.test(word));
|
|
349
|
+
}
|
|
350
|
+
function isAllowAnnotationLine(line, token) {
|
|
351
|
+
const at = line.indexOf(token);
|
|
352
|
+
if (at === -1) return false;
|
|
353
|
+
if (line.includes(`"basalt:${token}`)) return true;
|
|
354
|
+
return AUDIT_ANNOTATION_PREFIX.test(line.slice(0, at));
|
|
355
|
+
}
|
|
356
|
+
function auditAllows(cwd, cfg, guardCfg, scanned) {
|
|
357
|
+
const token = guardCfg.allowComment;
|
|
358
|
+
const lines = [`
|
|
359
|
+
basalt-ui check-theme --audit-allows \u2014 ${cwd}
|
|
360
|
+
`];
|
|
361
|
+
let dead = 0;
|
|
362
|
+
let live = 0;
|
|
363
|
+
let outOfReach = 0;
|
|
364
|
+
let unaccountable = 0;
|
|
365
|
+
const waiverLines = [];
|
|
366
|
+
const sources = /* @__PURE__ */ new Map();
|
|
367
|
+
for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), "utf8"));
|
|
368
|
+
for (const rel of scanned) {
|
|
369
|
+
const text = sources.get(rel) ?? "";
|
|
370
|
+
if (!text.includes(token)) continue;
|
|
371
|
+
const baseline = checkSource(text, rel, guardCfg);
|
|
372
|
+
const baselineKeys = new Set(
|
|
373
|
+
baseline.filter((f) => f.kind !== "theme-allow-unscoped").map(findingKey)
|
|
374
|
+
);
|
|
375
|
+
const unscopedAt = new Map(
|
|
376
|
+
baseline.filter((f) => f.kind === "theme-allow-unscoped").map((f) => [f.line, f.token])
|
|
377
|
+
);
|
|
378
|
+
const fileLines = text.split("\n");
|
|
379
|
+
for (const [index, line] of fileLines.entries()) {
|
|
380
|
+
if (!isAllowAnnotationLine(line ?? "", token)) continue;
|
|
381
|
+
const probe = [...fileLines];
|
|
382
|
+
probe[index] = (line ?? "").replaceAll(token, AUDIT_NEUTRALIZED_TOKEN);
|
|
383
|
+
const revealed = checkSource(probe.join("\n"), rel, guardCfg).filter((f) => f.kind !== "theme-allow-unscoped" && !baselineKeys.has(findingKey(f))).map((f) => `${f.kind}@${f.line}`);
|
|
384
|
+
const note = unscopedAt.get(index + 1);
|
|
385
|
+
if (note !== void 0) unaccountable++;
|
|
386
|
+
const site = ` ${rel}:${index + 1}`.padEnd(52);
|
|
387
|
+
const suffix = note === void 0 ? "" : ` [${note}]`;
|
|
388
|
+
const ids = annotationRuleIds((line ?? "").slice((line ?? "").indexOf(token) + token.length));
|
|
389
|
+
if (revealed.length > 0) {
|
|
390
|
+
live++;
|
|
391
|
+
waiverLines.push(`${site} suppresses ${[...new Set(revealed)].join(", ")}${suffix}`);
|
|
392
|
+
} else if (ids.length > 0 && !ids.some((id) => Object.hasOwn(GUARD_RULES, id))) {
|
|
393
|
+
outOfReach++;
|
|
394
|
+
waiverLines.push(
|
|
395
|
+
`${site} scoped to ${ids.join(", ")} \u2014 not a check-theme kind, so this audit cannot judge it (an oxlint plugin rule, or a typo theme-allow-unscoped would report)${suffix}`
|
|
396
|
+
);
|
|
397
|
+
} else {
|
|
398
|
+
dead++;
|
|
399
|
+
waiverLines.push(`${site} SUPPRESSES NOTHING \u2014 dead, delete it${suffix}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
lines.push(`${token} annotations (${waiverLines.length}):`);
|
|
404
|
+
lines.push(...waiverLines.length === 0 ? [" (none)"] : waiverLines);
|
|
405
|
+
const exemptLines = [];
|
|
406
|
+
let exemptCount = 0;
|
|
407
|
+
const declared = Object.entries(cfg.exemptRules ?? {});
|
|
408
|
+
if (declared.length > 0) {
|
|
409
|
+
const baselineKeys = /* @__PURE__ */ new Set();
|
|
410
|
+
for (const rel of scanned) {
|
|
411
|
+
for (const f of checkSource(sources.get(rel) ?? "", rel, guardCfg))
|
|
412
|
+
baselineKeys.add(findingKey(f));
|
|
413
|
+
}
|
|
414
|
+
for (const [kind, entry] of declared) {
|
|
415
|
+
const reason = exemptRuleReason(entry);
|
|
416
|
+
for (const pattern of exemptRulePaths(entry)) {
|
|
417
|
+
exemptCount++;
|
|
418
|
+
const probeCfg = {
|
|
419
|
+
...guardCfg,
|
|
420
|
+
exemptRules: {
|
|
421
|
+
...guardCfg.exemptRules,
|
|
422
|
+
[kind]: exemptRulePaths(entry).filter((p) => p !== pattern)
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const revealed = /* @__PURE__ */ new Set();
|
|
426
|
+
for (const rel of scanned) {
|
|
427
|
+
for (const f of checkSource(sources.get(rel) ?? "", rel, probeCfg)) {
|
|
428
|
+
if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const site = ` ${kind}: "${pattern}"`.padEnd(52);
|
|
432
|
+
const why = reason === null ? " [no reason recorded]" : ` \u2014 ${reason}`;
|
|
433
|
+
if (revealed.size === 0) {
|
|
434
|
+
dead++;
|
|
435
|
+
const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
|
|
436
|
+
(u) => u.kind === kind && u.pattern === pattern
|
|
437
|
+
);
|
|
438
|
+
exemptLines.push(
|
|
439
|
+
`${site} SUPPRESSES NOTHING \u2014 ` + (unmatched === void 0 ? "the files it matches have no such finding; delete it" : unmatched.reason === "unknown-kind" ? "it names no guard kind (a typo, or a renamed kind)" : "it matches no scanned file at all") + why
|
|
440
|
+
);
|
|
441
|
+
} else {
|
|
442
|
+
live++;
|
|
443
|
+
exemptLines.push(
|
|
444
|
+
`${site} suppresses findings in ${[...revealed].toSorted().join(", ")}${why}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
lines.push(`
|
|
451
|
+
basalt.exemptRules entries (${exemptCount}):`);
|
|
452
|
+
lines.push(...exemptLines.length === 0 ? [" (none)"] : exemptLines);
|
|
453
|
+
lines.push(
|
|
454
|
+
`
|
|
455
|
+
${live} live, ${dead} dead, ${outOfReach} outside check-theme's reach, ${unaccountable} unaccountable (reported as theme-allow-unscoped by a normal run).`
|
|
456
|
+
);
|
|
457
|
+
if (dead > 0) {
|
|
458
|
+
lines.push(
|
|
459
|
+
"A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently cover the next real finding on that line."
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
console.log(lines.join("\n"));
|
|
463
|
+
return dead > 0 ? 1 : 0;
|
|
464
|
+
}
|
|
203
465
|
const BLOCK_BEGIN_PREFIX = "<!-- basalt:begin";
|
|
204
466
|
const BLOCK_END = "<!-- basalt:end -->";
|
|
205
467
|
const MANIFEST_PATH = ".basalt/manifest.json";
|
|
@@ -230,12 +492,17 @@ function readFrameworkVersion(pkgRoot) {
|
|
|
230
492
|
}
|
|
231
493
|
}
|
|
232
494
|
function buildTemplateVars(pkgRoot, cwd, cfg) {
|
|
495
|
+
const install = findBasaltInstall(cwd);
|
|
496
|
+
const binDir = install.dir === null ? null : resolve(install.dir, "..", ".bin");
|
|
233
497
|
return {
|
|
234
498
|
APP_NAME: readPackageName(cwd),
|
|
235
499
|
BASALT_VERSION: readFrameworkVersion(pkgRoot),
|
|
236
500
|
ACCENT_HUE: cfg.accentHue ?? "blue",
|
|
237
501
|
SERIES_MODULE_PATH: resolveSeriesModulePath(cfg),
|
|
238
|
-
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" ")
|
|
502
|
+
ROOTS_GLOBS: resolveRoots(cfg).map(toRootGlob).join(" "),
|
|
503
|
+
OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, "configs/oxlint.json"),
|
|
504
|
+
LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, "configs/lefthook.yml"),
|
|
505
|
+
BASALT_BIN: binDir === null ? "bunx basalt-ui" : `${relativePosix(cwd, binDir).replace(/^\.\//, "")}/basalt-ui`
|
|
239
506
|
};
|
|
240
507
|
}
|
|
241
508
|
function fillTemplate(tpl, vars) {
|
|
@@ -383,7 +650,30 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
|
|
|
383
650
|
dest: "lefthook.yml",
|
|
384
651
|
mode: "seed",
|
|
385
652
|
source: "configs/lefthook.yml",
|
|
386
|
-
render: () =>
|
|
653
|
+
render: (ctx) => `# Seeded by basalt-ui init \u2014 you own this file; sync never touches it again.
|
|
654
|
+
# The extends target supplies oxlint + oxfmt + check-theme pre-commit jobs and
|
|
655
|
+
# auto-updates with the basalt-ui package. Add your own commands alongside.
|
|
656
|
+
# The path is RESOLVED from where basalt-ui actually installed, not assumed at the
|
|
657
|
+
# repo root \u2014 under an isolated linker it lives beside the package that depends on it.
|
|
658
|
+
# Run \`lefthook install\` once: the file is inert until the git hooks are written.
|
|
659
|
+
#
|
|
660
|
+
# NOTE: an extends target WINS on a colliding key \u2014 a \`run:\`/\`glob:\` you write under one of
|
|
661
|
+
# the preset's command names is discarded silently. \`env:\`, \`exclude:\`, \`skip:\` and your own
|
|
662
|
+
# command names merge. See the preset itself for the sanctioned override seams.
|
|
663
|
+
extends:
|
|
664
|
+
- ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
|
|
665
|
+
` + // The one seam that has to be RENDERED rather than documented: the preset is read in place
|
|
666
|
+
// out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
|
|
667
|
+
// default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
|
|
668
|
+
// copy; this pins it to the local one whenever basalt actually resolved.
|
|
669
|
+
(ctx.vars.BASALT_BIN.startsWith("bunx ") ? "" : `
|
|
670
|
+
# The theme guard runs the LOCAL bin, not a copy \`bunx\` fetches from npm.
|
|
671
|
+
pre-commit:
|
|
672
|
+
commands:
|
|
673
|
+
check-theme:
|
|
674
|
+
env:
|
|
675
|
+
BASALT_BIN: ${ctx.vars.BASALT_BIN}
|
|
676
|
+
`)
|
|
387
677
|
};
|
|
388
678
|
const ci = {
|
|
389
679
|
dest: ".github/workflows/check.yml",
|
|
@@ -399,7 +689,10 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
|
|
|
399
689
|
dest: ".oxlintrc.json",
|
|
400
690
|
mode: "seed",
|
|
401
691
|
source: "configs/oxlint.json",
|
|
402
|
-
render: () =>
|
|
692
|
+
render: (ctx) => `{
|
|
693
|
+
"extends": ["${ctx.vars.OXLINT_PRESET_PATH}"]
|
|
694
|
+
}
|
|
695
|
+
`
|
|
403
696
|
};
|
|
404
697
|
const queryClient = {
|
|
405
698
|
dest: "src/query-client.ts",
|
|
@@ -510,15 +803,219 @@ function renderContext(cwd) {
|
|
|
510
803
|
const pkgRoot = packageRoot();
|
|
511
804
|
return { pkgRoot, vars: buildTemplateVars(pkgRoot, cwd, readBasaltConfig(cwd)) };
|
|
512
805
|
}
|
|
806
|
+
function patchPackageJson(cwd, mutate) {
|
|
807
|
+
const abs = resolve(cwd, "package.json");
|
|
808
|
+
const raw = readIfExists(abs);
|
|
809
|
+
if (raw === null) return false;
|
|
810
|
+
let pkg;
|
|
811
|
+
try {
|
|
812
|
+
pkg = JSON.parse(raw);
|
|
813
|
+
} catch {
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
if (!mutate(pkg)) return false;
|
|
817
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
|
|
818
|
+
writeFileSync(abs, JSON.stringify(pkg, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
function detectRoots(cwd) {
|
|
822
|
+
const workspacePackages = collectWorkspacePackages(cwd).packages.filter((pkg) => pkg.dir !== cwd);
|
|
823
|
+
const withSrc = workspacePackages.filter((pkg) => existsSync(resolve(pkg.dir, "src")));
|
|
824
|
+
const uiPackages = withSrc.filter(
|
|
825
|
+
(pkg) => hasDependency(pkg.dir, "basalt-ui") || hasDependency(pkg.dir, "@mantine/core")
|
|
826
|
+
);
|
|
827
|
+
const chosen = uiPackages.length > 0 ? uiPackages : withSrc;
|
|
828
|
+
if (chosen.length > 0) {
|
|
829
|
+
return chosen.map((pkg) => relativePosix(cwd, resolve(pkg.dir, "src")).replace(/^\.\//, ""));
|
|
830
|
+
}
|
|
831
|
+
return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
|
|
832
|
+
}
|
|
833
|
+
function rootCovers(root, detected) {
|
|
834
|
+
const r = root.replace(/\/+$/, "");
|
|
835
|
+
return detected === r || detected.startsWith(`${r}/`);
|
|
836
|
+
}
|
|
837
|
+
function reconcileRoots(cwd, opts) {
|
|
838
|
+
const declared = readBasaltConfig(cwd).roots;
|
|
839
|
+
const detected = detectRoots(cwd);
|
|
840
|
+
if (declared !== void 0) {
|
|
841
|
+
const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)));
|
|
842
|
+
return { kind: "declared", declared, unscanned };
|
|
843
|
+
}
|
|
844
|
+
if (detected.length === 0) return { kind: "undetectable" };
|
|
845
|
+
if (!opts.write) return { kind: "declared", declared: [], unscanned: detected };
|
|
846
|
+
const wrote = patchPackageJson(cwd, (pkg) => {
|
|
847
|
+
const basalt = pkg["basalt"] ?? {};
|
|
848
|
+
if (basalt["roots"] !== void 0) return false;
|
|
849
|
+
pkg["basalt"] = { ...basalt, roots: detected };
|
|
850
|
+
return true;
|
|
851
|
+
});
|
|
852
|
+
return wrote ? { kind: "wrote", roots: detected } : { kind: "undetectable" };
|
|
853
|
+
}
|
|
854
|
+
function rootsNotices(state) {
|
|
855
|
+
if (state.kind === "wrote") {
|
|
856
|
+
return {
|
|
857
|
+
log: [
|
|
858
|
+
`wrote "basalt": { "roots": ${JSON.stringify(state.roots)} } to package.json \u2014 check-theme, the CI oxfmt globs and the default scan exemption all derive from it. Correct it if your sources live elsewhere.`
|
|
859
|
+
],
|
|
860
|
+
error: []
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
if (state.kind === "undetectable") {
|
|
864
|
+
return {
|
|
865
|
+
log: [],
|
|
866
|
+
error: [
|
|
867
|
+
'could not infer "basalt.roots" \u2014 no src/ here and no workspace package with one. Set "basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with "0 files scanned".'
|
|
868
|
+
]
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
if (state.unscanned.length === 0) return { log: [], error: [] };
|
|
872
|
+
if (state.declared.length === 0) {
|
|
873
|
+
return {
|
|
874
|
+
log: [],
|
|
875
|
+
error: [
|
|
876
|
+
`"basalt.roots" is not set \u2014 the guard is running on the built-in default ("${DEFAULT_ROOT}"). This layout suggests ${JSON.stringify(state.unscanned)}; declare it in package.json.`
|
|
877
|
+
]
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
log: [
|
|
882
|
+
`keeping your "basalt.roots" (${JSON.stringify(state.declared)}) \u2014 never overwritten. The layout also has ${JSON.stringify(state.unscanned)}, which nothing scans; add it if it should be guarded, or leave it if the exclusion is deliberate.`
|
|
883
|
+
],
|
|
884
|
+
error: []
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function stripJsonc(text) {
|
|
888
|
+
let out = "";
|
|
889
|
+
let inString = false;
|
|
890
|
+
let escaped = false;
|
|
891
|
+
let i = 0;
|
|
892
|
+
while (i < text.length) {
|
|
893
|
+
const ch = text[i];
|
|
894
|
+
if (inString) {
|
|
895
|
+
out += ch;
|
|
896
|
+
if (escaped) escaped = false;
|
|
897
|
+
else if (ch === "\\") escaped = true;
|
|
898
|
+
else if (ch === '"') inString = false;
|
|
899
|
+
i += 1;
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (ch === '"') {
|
|
903
|
+
inString = true;
|
|
904
|
+
out += ch;
|
|
905
|
+
i += 1;
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
909
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
913
|
+
i += 2;
|
|
914
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
|
|
915
|
+
i += 2;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
out += ch;
|
|
919
|
+
i += 1;
|
|
920
|
+
}
|
|
921
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
922
|
+
}
|
|
923
|
+
function parseJsonc(text) {
|
|
924
|
+
try {
|
|
925
|
+
return JSON.parse(stripJsonc(text));
|
|
926
|
+
} catch {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
function basaltPresetEntry(entries) {
|
|
931
|
+
if (!Array.isArray(entries)) return null;
|
|
932
|
+
const match = entries.find(
|
|
933
|
+
(entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
|
|
934
|
+
);
|
|
935
|
+
return match ?? null;
|
|
936
|
+
}
|
|
937
|
+
const LEFTHOOK_CONFIG_NAMES = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"];
|
|
938
|
+
function findLefthookConfig(dir) {
|
|
939
|
+
return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null;
|
|
940
|
+
}
|
|
941
|
+
function yamlUnquote(raw) {
|
|
942
|
+
return raw.trim().replace(/^['"]|['"]$/g, "");
|
|
943
|
+
}
|
|
944
|
+
function yamlExtendsEntries(text) {
|
|
945
|
+
const lines = text.split("\n");
|
|
946
|
+
const out = [];
|
|
947
|
+
for (const [index, line] of lines.entries()) {
|
|
948
|
+
const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? "");
|
|
949
|
+
if (inline !== null) {
|
|
950
|
+
out.push(...(inline[1] ?? "").split(",").map(yamlUnquote).filter(Boolean));
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (!/^extends:\s*(#.*)?$/.test(line ?? "")) continue;
|
|
954
|
+
for (const next of lines.slice(index + 1)) {
|
|
955
|
+
if (/^\s*(#.*)?$/.test(next ?? "")) continue;
|
|
956
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? "");
|
|
957
|
+
if (item === null) break;
|
|
958
|
+
out.push(yamlUnquote(item[1] ?? ""));
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
function inspectLefthookSeam(repoRoot) {
|
|
964
|
+
const file = findLefthookConfig(repoRoot);
|
|
965
|
+
if (file === null) return { kind: "no-file" };
|
|
966
|
+
const raw = readIfExists(resolve(repoRoot, file));
|
|
967
|
+
if (raw === null) return { kind: "no-file" };
|
|
968
|
+
const entry = yamlExtendsEntries(raw).find((e) => e.endsWith("basalt-ui/configs/lefthook.yml"));
|
|
969
|
+
if (entry === void 0) return { kind: "unwired", file };
|
|
970
|
+
return existsSync(resolve(repoRoot, entry)) ? { kind: "ok", file, entry } : { kind: "broken", file, entry };
|
|
971
|
+
}
|
|
972
|
+
function mergeOxlintExtends(cwd, presetPath) {
|
|
973
|
+
const abs = resolve(cwd, ".oxlintrc.json");
|
|
974
|
+
const raw = readIfExists(abs);
|
|
975
|
+
if (raw === null) return "absent";
|
|
976
|
+
const cfg = parseJsonc(raw);
|
|
977
|
+
if (cfg === null) return "unreadable";
|
|
978
|
+
if (basaltPresetEntry(cfg["extends"]) !== null) return "already";
|
|
979
|
+
if (stripJsonc(raw) !== raw) return "has-comments";
|
|
980
|
+
const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
|
|
981
|
+
const merged = "extends" in cfg ? {} : { extends: [presetPath] };
|
|
982
|
+
for (const [key, value] of Object.entries(cfg)) {
|
|
983
|
+
merged[key] = key === "extends" ? [presetPath, ...existing] : value;
|
|
984
|
+
}
|
|
985
|
+
const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
|
|
986
|
+
writeFileSync(abs, JSON.stringify(merged, null, indent) + (raw.endsWith("\n") ? "\n" : ""));
|
|
987
|
+
return "added";
|
|
988
|
+
}
|
|
989
|
+
const KEPT_FILE_COST = {
|
|
990
|
+
".oxlintrc.json": "the shipped oxlint preset (the basalt/* design rules AND the jsx-a11y / import / promise / unicorn plugins) is NOT active \u2014 nothing else turns it on, and every gate stays green without it. Re-run with `--merge-lint` to splice the extends in, or add it by hand.",
|
|
991
|
+
"lefthook.yml": "the pre-commit oxlint / oxfmt / check-theme jobs are not wired \u2014 add `extends: [<preset>]` to your own file.",
|
|
992
|
+
".github/workflows/check.yml": "CI does not run check-theme or the sync drift gate \u2014 copy those steps into your own workflow.",
|
|
993
|
+
".oxfmtrc.json": "your existing formatter config stands; basalt formats nothing on its own.",
|
|
994
|
+
"DESIGN.md": "the design brief Claude reads is yours, not the seeded one."
|
|
995
|
+
};
|
|
513
996
|
function init(cwd = process.cwd(), scaffoldFlags = {}) {
|
|
997
|
+
const install = findBasaltInstall(cwd);
|
|
998
|
+
if (install.dir === null) {
|
|
999
|
+
console.error(
|
|
1000
|
+
`\u26A0 basalt-ui init: basalt-ui does not resolve from ${cwd} or any directory above it \u2014 the seeded \`extends\` paths and CI steps are being written against the repo root by assumption. Install basalt-ui here (or at the repo root) and re-run \`basalt-ui init\` so they resolve.`
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
const rootsState = reconcileRoots(cwd, { write: true });
|
|
514
1004
|
const ctx = renderContext(cwd);
|
|
1005
|
+
const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
|
|
1006
|
+
const seededLintScript = patchPackageJson(cwd, (pkg) => {
|
|
1007
|
+
const scripts = pkg["scripts"] ?? {};
|
|
1008
|
+
if (scripts["lint:basalt"] !== void 0) return false;
|
|
1009
|
+
pkg["scripts"] = { ...scripts, "lint:basalt": lintScript };
|
|
1010
|
+
return true;
|
|
1011
|
+
});
|
|
515
1012
|
const manifest = readManifest(cwd);
|
|
516
1013
|
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
|
|
517
1014
|
const peers = resolvePeerFlags(cwd, scaffoldFlags);
|
|
518
1015
|
const placement = resolvePlacement(cwd);
|
|
519
1016
|
const files = managedFiles(peers, placement);
|
|
520
|
-
|
|
521
|
-
|
|
1017
|
+
const writtenFiles = [];
|
|
1018
|
+
const keptFiles = [];
|
|
522
1019
|
const missingSources = [];
|
|
523
1020
|
for (const file of files) {
|
|
524
1021
|
const state = unitState(file, cwd, ctx);
|
|
@@ -529,29 +1026,56 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
|
|
|
529
1026
|
const destExists = existsSync(resolve(cwd, file.dest));
|
|
530
1027
|
if (!file.markers && destExists) {
|
|
531
1028
|
manifest.files[file.dest] = sha256(normalizeForLedger(state.desired));
|
|
532
|
-
|
|
1029
|
+
keptFiles.push(file.dest);
|
|
533
1030
|
continue;
|
|
534
1031
|
}
|
|
535
1032
|
const hash = writeUnit(file, cwd, state.desired);
|
|
536
1033
|
manifest.files[file.dest] = hash;
|
|
537
|
-
|
|
1034
|
+
writtenFiles.push(file.dest);
|
|
538
1035
|
}
|
|
1036
|
+
const mergeLint = scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null;
|
|
539
1037
|
manifest.basaltVersion = ctx.vars.BASALT_VERSION;
|
|
540
1038
|
manifest.spacingScale = { ...deriveSpacing(0).scale };
|
|
541
1039
|
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}
|
|
542
1040
|
`);
|
|
543
|
-
console.log(
|
|
1041
|
+
console.log(
|
|
1042
|
+
`basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`
|
|
1043
|
+
);
|
|
544
1044
|
if (missingSources.length > 0) {
|
|
545
1045
|
console.log(
|
|
546
1046
|
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(", ")}`
|
|
547
1047
|
);
|
|
548
1048
|
}
|
|
1049
|
+
if (keptFiles.length > 0) {
|
|
1050
|
+
console.log(`
|
|
1051
|
+
Kept (already present \u2014 basalt did not touch them):`);
|
|
1052
|
+
for (const dest of keptFiles) {
|
|
1053
|
+
const cost = KEPT_FILE_COST[dest];
|
|
1054
|
+
console.log(cost === void 0 ? ` \xB7 ${dest}` : ` \xB7 ${dest} \u2014 ${cost}`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (mergeLint !== null) {
|
|
1058
|
+
const mergeMessage = {
|
|
1059
|
+
added: `spliced "${ctx.vars.OXLINT_PRESET_PATH}" into the existing .oxlintrc.json extends`,
|
|
1060
|
+
already: ".oxlintrc.json already extends the shipped preset \u2014 nothing to do",
|
|
1061
|
+
absent: "no existing .oxlintrc.json to merge into (the seeded one already extends the preset)",
|
|
1062
|
+
unreadable: ".oxlintrc.json is not valid JSON \u2014 merge the extends entry by hand",
|
|
1063
|
+
"has-comments": `.oxlintrc.json carries comments; rewriting it would delete them (usually the reason each rule is off). Add "extends": ["${ctx.vars.OXLINT_PRESET_PATH}"] as the first entry by hand.`
|
|
1064
|
+
};
|
|
1065
|
+
console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
|
|
1066
|
+
}
|
|
1067
|
+
const rootsMessages = rootsNotices(rootsState);
|
|
1068
|
+
for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`);
|
|
1069
|
+
for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui init: ${line}`);
|
|
1070
|
+
if (seededLintScript) {
|
|
1071
|
+
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
|
|
1072
|
+
}
|
|
549
1073
|
if (!placement.isPackageRepoRoot) {
|
|
550
1074
|
console.log(
|
|
551
|
-
`basalt-ui init: skipped lefthook.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 lefthook only reads config at the repo root; extend
|
|
1075
|
+
`basalt-ui init: skipped lefthook.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 lefthook only reads config at the repo root; extend ${ctx.vars.LEFTHOOK_PRESET_PATH} from your root lefthook.yml instead, and give its check-theme command \`env: { BASALT_CWD: ${relativePosix(placement.repoRoot, cwd)} }\` so the guard runs where your basalt config actually lives.`
|
|
552
1076
|
);
|
|
553
1077
|
console.log(
|
|
554
|
-
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 GitHub Actions only reads .github/ at the repo root;
|
|
1078
|
+
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root \u2014 repo root detected at ${placement.repoRoot}) \u2014 GitHub Actions only reads .github/ at the repo root; copy the steps from ${shippedAssetPath(install, placement.repoRoot, "configs/check.yml")} into your root CI workflow instead.`
|
|
555
1079
|
);
|
|
556
1080
|
}
|
|
557
1081
|
if (placement.relocatedQueryClient !== null) {
|
|
@@ -581,12 +1105,34 @@ Theme guard hook: add to .claude/settings.json \u2192 hooks.PreToolUse to catch
|
|
|
581
1105
|
]
|
|
582
1106
|
}`
|
|
583
1107
|
);
|
|
1108
|
+
const presetPlugins = readOxlintPresetPlugins(ctx.pkgRoot);
|
|
1109
|
+
if (presetPlugins.length > 0) {
|
|
1110
|
+
console.log(
|
|
1111
|
+
`
|
|
1112
|
+
Lint debt: adopting the shipped oxlint preset on an EXISTING app is not a no-op \u2014 it turns on ${presetPlugins.length} plugins (${presetPlugins.join(", ")}) plus the basalt/* design rules, on code never linted against them. Run \`oxlint .\` now and triage the count before your next commit; turn a rule off in your own .oxlintrc.json with a written reason rather than blanket-disabling a plugin.`
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
console.log(
|
|
1116
|
+
"Activate the hooks: `lefthook install` \u2014 the seeded lefthook.yml is inert until the git hooks are written, and looks configured either way."
|
|
1117
|
+
);
|
|
584
1118
|
console.log(
|
|
585
|
-
|
|
1119
|
+
`
|
|
1120
|
+
First run: run \`${ctx.vars.BASALT_BIN} check-theme\` next (or \`bun run lint:basalt\`), then tune the per-rule \`basalt.*\` config keys in package.json for anything that fires \u2014 do not mass-\`theme-allow\` findings. Then \`basalt-ui doctor\` to confirm the wiring actually took.`
|
|
586
1121
|
);
|
|
587
1122
|
return 0;
|
|
588
1123
|
}
|
|
1124
|
+
function readOxlintPresetPlugins(pkgRoot) {
|
|
1125
|
+
const raw = readSource(pkgRoot, "configs/oxlint.json");
|
|
1126
|
+
if (raw === null) return [];
|
|
1127
|
+
try {
|
|
1128
|
+
const plugins = JSON.parse(raw).plugins;
|
|
1129
|
+
return isStringArray(plugins) ? plugins : [];
|
|
1130
|
+
} catch {
|
|
1131
|
+
return [];
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
589
1134
|
function sync(opts = {}, cwd = process.cwd()) {
|
|
1135
|
+
const rootsState = reconcileRoots(cwd, { write: opts.check !== true });
|
|
590
1136
|
const ctx = renderContext(cwd);
|
|
591
1137
|
const manifest = readManifest(cwd);
|
|
592
1138
|
if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
|
|
@@ -642,6 +1188,23 @@ function sync(opts = {}, cwd = process.cwd()) {
|
|
|
642
1188
|
`basalt-ui sync: skipped src/query-client.ts (found an existing query client at ${relative(cwd, placement.relocatedQueryClient)}) \u2014 import it from there instead of re-seeding at the original path.`
|
|
643
1189
|
);
|
|
644
1190
|
}
|
|
1191
|
+
const rootsMessages = rootsNotices(rootsState);
|
|
1192
|
+
for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`);
|
|
1193
|
+
for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui sync: ${line}`);
|
|
1194
|
+
const install = findBasaltInstall(cwd);
|
|
1195
|
+
const lefthookSeam = inspectLefthookSeam(placement.repoRoot);
|
|
1196
|
+
if (lefthookSeam.kind === "broken") {
|
|
1197
|
+
console.error(
|
|
1198
|
+
`\u26A0 basalt-ui sync: ${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist \u2014 lefthook merges a missing extends target into ZERO commands and still exits 0, so there is no pre-commit gate. Repoint it at "${shippedAssetPath(install, placement.repoRoot, "configs/lefthook.yml")}".`
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
|
|
1202
|
+
const oxlintEntry = oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.["extends"]);
|
|
1203
|
+
if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
|
|
1204
|
+
console.error(
|
|
1205
|
+
`\u26A0 basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist \u2014 oxlint refuses to start on a missing extends target (\`NotFound\`). Repoint it at "${shippedAssetPath(install, cwd, "configs/oxlint.json")}".`
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
645
1208
|
if (opts.check) {
|
|
646
1209
|
if (driftLines.length > 0) {
|
|
647
1210
|
console.error("basalt-ui sync --check: locally-drifted managed files:");
|
|
@@ -725,7 +1288,7 @@ function checkCoverage() {
|
|
|
725
1288
|
for (const exportKey of pkgExports) {
|
|
726
1289
|
if (exportKey === "." || exportKey === "./styles.css" || exportKey.startsWith("./configs/"))
|
|
727
1290
|
continue;
|
|
728
|
-
if (!(exportKey
|
|
1291
|
+
if (!Object.hasOwn(SURFACES, exportKey)) {
|
|
729
1292
|
failures.push(`package.json exports key '${exportKey}' has no matching SURFACES entry`);
|
|
730
1293
|
}
|
|
731
1294
|
}
|
|
@@ -880,9 +1443,27 @@ function resolveAiMajorSkewReason(cfg) {
|
|
|
880
1443
|
}
|
|
881
1444
|
return { reason: null, presentButInvalid: true };
|
|
882
1445
|
}
|
|
883
|
-
function doctor(
|
|
1446
|
+
function doctor(invocationCwd = process.cwd(), flags = []) {
|
|
1447
|
+
if (conflictingProfileFlags(flags)) {
|
|
1448
|
+
console.error("basalt-ui doctor: --tokens-only and --framework are alternatives \u2014 pass one.");
|
|
1449
|
+
return 1;
|
|
1450
|
+
}
|
|
1451
|
+
const project = resolveProjectDir(invocationCwd);
|
|
1452
|
+
if (project.ambiguous !== null) {
|
|
1453
|
+
console.error(
|
|
1454
|
+
`
|
|
1455
|
+
basalt-ui doctor \u2014 ${invocationCwd}
|
|
1456
|
+
|
|
1457
|
+
\u2716 no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(", ")}) \u2014 run doctor from one of them, or set BASALT_CWD to pick.
|
|
1458
|
+
|
|
1459
|
+
1 hard failure(s), 0 warning(s).
|
|
1460
|
+
`
|
|
1461
|
+
);
|
|
1462
|
+
return 1;
|
|
1463
|
+
}
|
|
1464
|
+
const cwd = project.dir;
|
|
884
1465
|
const cfg = readBasaltConfig(cwd);
|
|
885
|
-
const result = { hardFailures: 0, warnings: 0 };
|
|
1466
|
+
const result = { hardFailures: 0, warnings: 0, skipped: 0 };
|
|
886
1467
|
const lines = [`
|
|
887
1468
|
basalt-ui doctor \u2014 ${cwd}
|
|
888
1469
|
`];
|
|
@@ -893,26 +1474,59 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
893
1474
|
lines.push(` \u26A0 ${msg}`);
|
|
894
1475
|
result.warnings++;
|
|
895
1476
|
}
|
|
1477
|
+
function skip(msg) {
|
|
1478
|
+
lines.push(` \u2298 SKIPPED \u2014 ${msg}`);
|
|
1479
|
+
result.skipped++;
|
|
1480
|
+
}
|
|
896
1481
|
function fail(msg) {
|
|
897
1482
|
lines.push(` \u2716 ${msg}`);
|
|
898
1483
|
result.hardFailures++;
|
|
899
1484
|
}
|
|
1485
|
+
if (project.relocatedFrom !== null) {
|
|
1486
|
+
lines.push(
|
|
1487
|
+
` \u2192 no basalt config at ${project.relocatedFrom} \u2014 reporting on ${relativePosix(project.relocatedFrom, cwd)}, where it lives.
|
|
1488
|
+
`
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
900
1491
|
const manifestAbs = resolve(cwd, MANIFEST_PATH);
|
|
901
1492
|
const manifestExists = existsSync(manifestAbs);
|
|
902
|
-
|
|
1493
|
+
const profile = inferredProfile(cwd, cfg, flags);
|
|
1494
|
+
if (profile === "tokens-only") {
|
|
1495
|
+
lines.push(
|
|
1496
|
+
" profile: tokens-only \u2014 checking the token layer only. The scaffold checks below are not\n applicable; `basalt-ui init` is NOT the fix here, it places a Mantine doctrine you have no\n use for. Pass --framework to force the full profile.\n"
|
|
1497
|
+
);
|
|
1498
|
+
if (cfg.profile === void 0) {
|
|
1499
|
+
lines.push(
|
|
1500
|
+
' \u2192 inferred (no .basalt/manifest.json and no @mantine/core in this workspace). Write it\n down as "basalt": { "profile": "tokens-only" } in package.json \u2014 check-theme does not\n infer it, so today it still reports the Mantine-only kinds against this repo.\n'
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
if (profile === "tokens-only") {
|
|
1505
|
+
pass(`${MANIFEST_PATH}: n/a \u2014 a tokens-only consumer has no scaffold to reconcile`);
|
|
1506
|
+
} else if (manifestExists) {
|
|
903
1507
|
pass(`${MANIFEST_PATH} exists`);
|
|
904
1508
|
} else {
|
|
905
1509
|
fail(`${MANIFEST_PATH} missing \u2014 run \`basalt-ui init\` to scaffold the consumer repo`);
|
|
906
1510
|
}
|
|
907
|
-
const
|
|
908
|
-
|
|
909
|
-
if (
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1511
|
+
const install = findBasaltInstall(cwd);
|
|
1512
|
+
const installedVersion = install.version;
|
|
1513
|
+
if (install.dir === null) {
|
|
1514
|
+
fail(
|
|
1515
|
+
`basalt-ui does not resolve from ${cwd}, any directory above it, or any workspace package \u2014 so every version check below is unrunnable, \`extends: [<preset>]\` cannot resolve, and \`bunx basalt-ui\` will silently fetch a DIFFERENT copy from npm instead of your pinned one. Add basalt-ui as a dependency here (or at the repo root) and re-run.`
|
|
1516
|
+
);
|
|
1517
|
+
} else if (install.how === "cwd") {
|
|
1518
|
+
pass(
|
|
1519
|
+
`basalt-ui resolves at ./node_modules/basalt-ui (${installedVersion ?? "unknown version"})`
|
|
1520
|
+
);
|
|
1521
|
+
} else {
|
|
1522
|
+
pass(
|
|
1523
|
+
`basalt-ui resolves at ${relativePosix(cwd, install.dir)} (${installedVersion ?? "unknown version"}, found via the ${install.how}) \u2014 seeded \`extends\` paths and CI steps must use that path, not ./node_modules/basalt-ui.`
|
|
1524
|
+
);
|
|
1525
|
+
}
|
|
1526
|
+
if (profile === "framework" && manifestExists && installedVersion === null) {
|
|
1527
|
+
skip(
|
|
1528
|
+
"installed-vs-manifest version \u2014 basalt-ui did not resolve, so there is nothing to compare"
|
|
1529
|
+
);
|
|
916
1530
|
}
|
|
917
1531
|
if (manifestExists && installedVersion !== null) {
|
|
918
1532
|
const manifestVersion = readManifest(cwd).basaltVersion;
|
|
@@ -930,11 +1544,17 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
930
1544
|
}
|
|
931
1545
|
const cliVersion = readFrameworkVersion(packageRoot());
|
|
932
1546
|
const cliIsTheInstall = installedVersion === null || installedVersion === cliVersion;
|
|
933
|
-
if (
|
|
1547
|
+
if (profile === "tokens-only") {
|
|
1548
|
+
pass("spacing scale: n/a \u2014 a tokens-only consumer renders no basalt Mantine theme");
|
|
1549
|
+
} else if (!manifestExists) {
|
|
1550
|
+
skip(
|
|
1551
|
+
`spacing scale \u2014 no ${MANIFEST_PATH} records the scale this app last synced with, so there is nothing to compare against`
|
|
1552
|
+
);
|
|
1553
|
+
} else if (!cliIsTheInstall) {
|
|
934
1554
|
pass(
|
|
935
1555
|
`spacing scale not compared \u2014 this CLI (${cliVersion}) is not the installed basalt-ui (${installedVersion}), so its scale is not the one the app renders with`
|
|
936
1556
|
);
|
|
937
|
-
} else
|
|
1557
|
+
} else {
|
|
938
1558
|
const recorded = readManifest(cwd).spacingScale;
|
|
939
1559
|
const current = deriveSpacing(0).scale;
|
|
940
1560
|
if (recorded === void 0) {
|
|
@@ -951,17 +1571,84 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
951
1571
|
}
|
|
952
1572
|
}
|
|
953
1573
|
}
|
|
954
|
-
if (installedVersion
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1574
|
+
if (installedVersion === null) {
|
|
1575
|
+
skip("CLI-vs-installed version \u2014 basalt-ui did not resolve, so there is no installed version");
|
|
1576
|
+
} else if (installedVersion !== cliVersion) {
|
|
1577
|
+
warn(
|
|
1578
|
+
`running CLI version (${cliVersion}) differs from the installed basalt-ui version at ${install.dir === null ? "node_modules" : relativePosix(cwd, install.dir)} (${installedVersion}) \u2014 likely a stale \`bunx basalt-ui\` fetch from npm; run the local bin (\`node_modules/.bin/basalt-ui\`, or a package.json script) so the pinned copy is the one that runs.`
|
|
1579
|
+
);
|
|
1580
|
+
} else {
|
|
1581
|
+
pass(`CLI version (${cliVersion}) matches the installed basalt-ui`);
|
|
1582
|
+
}
|
|
1583
|
+
if (profile === "tokens-only") {
|
|
1584
|
+
pass("guard-scan: n/a \u2014 the theme guard is a Mantine-app check");
|
|
1585
|
+
} else {
|
|
1586
|
+
const scanned = scannableFiles(cwd, cfg);
|
|
1587
|
+
if (scanned.length === 0) {
|
|
1588
|
+
fail(
|
|
1589
|
+
`guard-scan: check-theme would scan 0 files \u2014 the roots (${resolveRoots(cfg).join(", ")}) match nothing under ${cwd}, so the palette guard is a no-op that still exits green in every gate that does not run it. Set "basalt": { "roots": [...] } in package.json.`
|
|
1590
|
+
);
|
|
1591
|
+
} else {
|
|
1592
|
+
pass(
|
|
1593
|
+
`guard-scan: check-theme covers ${scanned.length} file(s) under ${resolveRoots(cfg).join(", ")}`
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
if (profile === "tokens-only") {
|
|
1598
|
+
pass("oxlint-preset: n/a \u2014 the shipped preset is a Mantine/React preset");
|
|
1599
|
+
} else {
|
|
1600
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
|
|
1601
|
+
if (oxlintrcRaw === null) {
|
|
1602
|
+
fail(
|
|
1603
|
+
".oxlintrc.json missing \u2014 the shipped oxlint preset (the basalt/* design rules) is not active. Run `basalt-ui init` to seed it."
|
|
958
1604
|
);
|
|
959
1605
|
} else {
|
|
960
|
-
|
|
1606
|
+
const parsed = parseJsonc(oxlintrcRaw);
|
|
1607
|
+
if (parsed === null) {
|
|
1608
|
+
skip(
|
|
1609
|
+
".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
|
|
1610
|
+
);
|
|
1611
|
+
} else {
|
|
1612
|
+
const entry = basaltPresetEntry(parsed["extends"]);
|
|
1613
|
+
const correct = shippedAssetPath(install, cwd, "configs/oxlint.json");
|
|
1614
|
+
if (entry === null) {
|
|
1615
|
+
fail(
|
|
1616
|
+
`.oxlintrc.json does NOT extend the shipped preset \u2014 every basalt/* design rule is off and oxlint reports green without them. Add "extends": ["${correct}"], or re-run \`basalt-ui init --merge-lint\` to splice it in.`
|
|
1617
|
+
);
|
|
1618
|
+
} else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
|
|
1619
|
+
fail(
|
|
1620
|
+
`.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) \u2014 basalt-ui resolves there but ships no \`configs/oxlint.json\` at that path, so oxlint refuses to start (\`NotFound\`) and nothing is linted. Reinstall basalt-ui.`
|
|
1621
|
+
);
|
|
1622
|
+
} else if (!existsSync(resolve(cwd, entry))) {
|
|
1623
|
+
fail(
|
|
1624
|
+
`.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) \u2014 oxlint refuses to start on a missing extends target (\`NotFound\`), so nothing is linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`
|
|
1625
|
+
);
|
|
1626
|
+
} else {
|
|
1627
|
+
pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
961
1630
|
}
|
|
962
1631
|
}
|
|
1632
|
+
const repoRoot = findRepoRoot(cwd);
|
|
1633
|
+
const lefthookSeam = profile === "tokens-only" ? { kind: "n/a" } : inspectLefthookSeam(repoRoot);
|
|
1634
|
+
const lefthookCorrect = shippedAssetPath(install, repoRoot, "configs/lefthook.yml");
|
|
1635
|
+
if (lefthookSeam.kind === "n/a") {
|
|
1636
|
+
pass("lefthook-preset: n/a \u2014 a tokens-only consumer wires its own hooks");
|
|
1637
|
+
} else if (lefthookSeam.kind === "no-file") {
|
|
1638
|
+
pass(`lefthook-preset: n/a \u2014 no lefthook config at ${repoRoot}`);
|
|
1639
|
+
} else if (lefthookSeam.kind === "unwired") {
|
|
1640
|
+
warn(
|
|
1641
|
+
`${lefthookSeam.file} does not extend the shipped lefthook preset \u2014 the oxlint / oxfmt / check-theme pre-commit jobs are not wired. Add "extends: [${lefthookCorrect}]" to it.`
|
|
1642
|
+
);
|
|
1643
|
+
} else if (lefthookSeam.kind === "broken") {
|
|
1644
|
+
fail(
|
|
1645
|
+
`${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist (${resolve(repoRoot, lefthookSeam.entry)}) \u2014 lefthook merges a missing extends target into ZERO commands and still exits 0, so this repo has no pre-commit gate and \`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`
|
|
1646
|
+
);
|
|
1647
|
+
} else {
|
|
1648
|
+
pass(`${lefthookSeam.file} extends the shipped lefthook preset (${lefthookSeam.entry})`);
|
|
1649
|
+
}
|
|
963
1650
|
const publicDir = resolve(cwd, "public");
|
|
964
|
-
if (existsSync(publicDir)) {
|
|
1651
|
+
if (profile === "framework" && existsSync(publicDir)) {
|
|
965
1652
|
const iconFiles = [
|
|
966
1653
|
"favicon.ico",
|
|
967
1654
|
"favicon.svg",
|
|
@@ -1013,15 +1700,17 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
1013
1700
|
pass("ai-major-parity: no workspace package declares the ai dependency \u2014 nothing to compare");
|
|
1014
1701
|
}
|
|
1015
1702
|
lines.push("");
|
|
1016
|
-
if (result.hardFailures === 0 && result.warnings === 0) {
|
|
1703
|
+
if (result.hardFailures === 0 && result.warnings === 0 && result.skipped === 0) {
|
|
1017
1704
|
lines.push("All checks passed.");
|
|
1018
1705
|
} else {
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1706
|
+
const parts = [];
|
|
1707
|
+
if (result.hardFailures > 0) parts.push(`${result.hardFailures} hard failure(s)`);
|
|
1708
|
+
if (result.skipped > 0) parts.push(`${result.skipped} check(s) SKIPPED (could not run)`);
|
|
1709
|
+
parts.push(`${result.warnings} warning(s)`);
|
|
1710
|
+
lines.push(`${parts.join(", ")}.`);
|
|
1022
1711
|
}
|
|
1023
1712
|
lines.push("");
|
|
1024
|
-
if (result.hardFailures > 0) {
|
|
1713
|
+
if (result.hardFailures > 0 || result.skipped > 0) {
|
|
1025
1714
|
console.error(lines.join("\n"));
|
|
1026
1715
|
return 1;
|
|
1027
1716
|
}
|
|
@@ -1101,8 +1790,65 @@ function flagValue(flags, name) {
|
|
|
1101
1790
|
const value = flags[i + 1];
|
|
1102
1791
|
return value === void 0 || value.startsWith("--") ? void 0 : value;
|
|
1103
1792
|
}
|
|
1793
|
+
function generatedHeader(version, command, flags) {
|
|
1794
|
+
const shown = flags.filter((f) => f !== "--check");
|
|
1795
|
+
const invocation = shown.length === 0 ? command : `${command} ${shown.join(" ")}`;
|
|
1796
|
+
return `${GENERATED_HEADER_LINE}
|
|
1797
|
+
/* basalt-ui ${version} \u2014 \`basalt-ui ${invocation}\` */
|
|
1798
|
+
`;
|
|
1799
|
+
}
|
|
1800
|
+
function normalizeColorFunctions(css) {
|
|
1801
|
+
return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
|
|
1802
|
+
const parts = args.split(",").map((part) => normalizeCssNumber(part.trim()));
|
|
1803
|
+
return `${fn}(${parts.join(", ")})`;
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
function normalizeCssNumber(arg) {
|
|
1807
|
+
if (!/^\d+\.\d+$/.test(arg)) return arg;
|
|
1808
|
+
return arg.replace(/\.?0+$/, "");
|
|
1809
|
+
}
|
|
1810
|
+
const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
|
|
1811
|
+
function emitGeneratedCss(content, flags, cwd, command) {
|
|
1812
|
+
const out = flagValue(flags, "--out");
|
|
1813
|
+
const check = flags.includes("--check");
|
|
1814
|
+
if (out === void 0) {
|
|
1815
|
+
if (check) {
|
|
1816
|
+
console.error(
|
|
1817
|
+
`${command}: --check needs --out <path> \u2014 there is nothing to compare stdout to.`
|
|
1818
|
+
);
|
|
1819
|
+
return 1;
|
|
1820
|
+
}
|
|
1821
|
+
process.stdout.write(content);
|
|
1822
|
+
return 0;
|
|
1823
|
+
}
|
|
1824
|
+
const target = isAbsolute(out) ? out : resolve(cwd, out);
|
|
1825
|
+
const shown = relative(cwd, target) || target;
|
|
1826
|
+
if (check) {
|
|
1827
|
+
const onDisk = readIfExists(target);
|
|
1828
|
+
if (onDisk === null) {
|
|
1829
|
+
console.error(
|
|
1830
|
+
`\u2716 ${command} --check: ${shown} does not exist \u2014 run without --check to write it.`
|
|
1831
|
+
);
|
|
1832
|
+
return 1;
|
|
1833
|
+
}
|
|
1834
|
+
if (onDisk === content) {
|
|
1835
|
+
console.log(`\u2713 ${command} --check: ${shown} is up to date.`);
|
|
1836
|
+
return 0;
|
|
1837
|
+
}
|
|
1838
|
+
console.error(
|
|
1839
|
+
`\u2716 ${command} --check: ${shown} differs from what \`basalt-ui ${command}\` emits today (on disk ${onDisk.split("\n").length} lines, emitted ${content.split("\n").length}) \u2014 re-run the same command without --check and commit the result.`
|
|
1840
|
+
);
|
|
1841
|
+
return 1;
|
|
1842
|
+
}
|
|
1843
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1844
|
+
writeFileSync(target, content);
|
|
1845
|
+
console.log(`wrote ${content.split("\n").length} lines \u2192 ${shown}`);
|
|
1846
|
+
return 0;
|
|
1847
|
+
}
|
|
1104
1848
|
function tokensCss(flags, cwd = process.cwd()) {
|
|
1105
1849
|
const attribute = flagValue(flags, "--selector-attribute");
|
|
1850
|
+
const schemeClass = flagValue(flags, "--selector-class");
|
|
1851
|
+
const lightClass = flagValue(flags, "--light-class");
|
|
1106
1852
|
const darkValue = flagValue(flags, "--dark-value");
|
|
1107
1853
|
const lightValue = flagValue(flags, "--light-value");
|
|
1108
1854
|
const defaultScheme = flagValue(flags, "--default-scheme");
|
|
@@ -1117,12 +1863,31 @@ function tokensCss(flags, cwd = process.cwd()) {
|
|
|
1117
1863
|
console.error(`tokens:css: --only must be core or all (got '${only}')`);
|
|
1118
1864
|
return 1;
|
|
1119
1865
|
}
|
|
1866
|
+
if (schemeClass !== void 0 && attribute !== void 0) {
|
|
1867
|
+
console.error(
|
|
1868
|
+
"tokens:css: --selector-class and --selector-attribute are alternatives \u2014 pass one."
|
|
1869
|
+
);
|
|
1870
|
+
return 1;
|
|
1871
|
+
}
|
|
1872
|
+
if (schemeClass !== void 0 && /[^\w-]/.test(schemeClass)) {
|
|
1873
|
+
console.error(
|
|
1874
|
+
`tokens:css: --selector-class must be a plain CSS class name (got '${schemeClass}')`
|
|
1875
|
+
);
|
|
1876
|
+
return 1;
|
|
1877
|
+
}
|
|
1878
|
+
if (lightClass !== void 0 && /[^\w-]/.test(lightClass)) {
|
|
1879
|
+
console.error(`tokens:css: --light-class must be a plain CSS class name (got '${lightClass}')`);
|
|
1880
|
+
return 1;
|
|
1881
|
+
}
|
|
1882
|
+
const resolvedDarkValue = schemeClass !== void 0 ? schemeClass : darkValue;
|
|
1883
|
+
const resolvedLightValue = schemeClass !== void 0 ? lightClass ?? "light" : lightValue;
|
|
1884
|
+
const resolvedAttribute = schemeClass !== void 0 ? SCHEME_CLASS_SENTINEL : attribute;
|
|
1120
1885
|
const scheme = {
|
|
1121
|
-
...
|
|
1122
|
-
...
|
|
1123
|
-
...
|
|
1886
|
+
...resolvedAttribute === void 0 ? {} : { attribute: resolvedAttribute },
|
|
1887
|
+
...resolvedDarkValue === void 0 ? {} : { darkValue: resolvedDarkValue },
|
|
1888
|
+
...resolvedLightValue === void 0 ? {} : { lightValue: resolvedLightValue }
|
|
1124
1889
|
};
|
|
1125
|
-
|
|
1890
|
+
let css = buildPaletteCss({
|
|
1126
1891
|
...Object.keys(scheme).length === 0 ? {} : { scheme },
|
|
1127
1892
|
...defaultScheme === void 0 ? {} : { defaultScheme },
|
|
1128
1893
|
...flags.includes("--media-fallback") ? { mediaFallback: true } : {},
|
|
@@ -1133,17 +1898,44 @@ function tokensCss(flags, cwd = process.cwd()) {
|
|
|
1133
1898
|
// are, which is the one thing this command is documented never to allow.
|
|
1134
1899
|
...flags.includes("--no-legacy-aliases") ? { legacyAliases: false } : {}
|
|
1135
1900
|
});
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
`
|
|
1140
|
-
|
|
1901
|
+
if (schemeClass !== void 0) {
|
|
1902
|
+
css = css.replace(
|
|
1903
|
+
new RegExp(`\\[${SCHEME_CLASS_SENTINEL}='([\\w-]+)'\\]`, "g"),
|
|
1904
|
+
(_match, value) => `.${value}`
|
|
1905
|
+
);
|
|
1141
1906
|
}
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1907
|
+
const version = readFrameworkVersion(packageRoot());
|
|
1908
|
+
const content = `${generatedHeader(version, "tokens:css", flags)}${normalizeColorFunctions(css)}
|
|
1909
|
+
`;
|
|
1910
|
+
return emitGeneratedCss(content, flags, cwd, "tokens:css");
|
|
1911
|
+
}
|
|
1912
|
+
function readShippedFontDecls(pkgRoot) {
|
|
1913
|
+
const css = readSource(pkgRoot, "dist/styles.css") ?? readSource(pkgRoot, "src/styles.css");
|
|
1914
|
+
if (css === null) return [];
|
|
1915
|
+
const decls = [];
|
|
1916
|
+
for (const match of css.matchAll(/(--basalt-font[\w-]*)\s*:\s*([^;]+);/g)) {
|
|
1917
|
+
decls.push({
|
|
1918
|
+
name: match[1],
|
|
1919
|
+
value: match[2].replace(/\s+/g, " ").trim()
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
return decls;
|
|
1923
|
+
}
|
|
1924
|
+
function fontsCss(flags, cwd = process.cwd()) {
|
|
1925
|
+
const pkgRoot = packageRoot();
|
|
1926
|
+
const decls = readShippedFontDecls(pkgRoot);
|
|
1927
|
+
if (decls.length === 0) {
|
|
1928
|
+
console.error(
|
|
1929
|
+
"fonts:css: could not read the shipped font stacks from styles.css \u2014 this is a packaging bug in basalt-ui, not a config error."
|
|
1930
|
+
);
|
|
1931
|
+
return 1;
|
|
1932
|
+
}
|
|
1933
|
+
const body = `:root {
|
|
1934
|
+
${decls.map((d) => ` ${d.name}: ${d.value};`).join("\n")}
|
|
1935
|
+
}
|
|
1936
|
+
`;
|
|
1937
|
+
const header = generatedHeader(readFrameworkVersion(pkgRoot), "fonts:css", flags);
|
|
1938
|
+
return emitGeneratedCss(`${header}${body}`, flags, cwd, "fonts:css");
|
|
1147
1939
|
}
|
|
1148
1940
|
async function guardHook(cwd = process.cwd()) {
|
|
1149
1941
|
let raw;
|
|
@@ -1189,8 +1981,11 @@ async function guardHook(cwd = process.cwd()) {
|
|
|
1189
1981
|
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
1190
1982
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
1191
1983
|
allowComment: "theme-allow",
|
|
1192
|
-
exemptRules: cfg
|
|
1193
|
-
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity
|
|
1984
|
+
exemptRules: resolveExemptRules(cfg),
|
|
1985
|
+
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
1986
|
+
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
1987
|
+
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
1988
|
+
...declaredProfile(cfg, []) === "tokens-only" ? { profile: "tokens-only" } : {}
|
|
1194
1989
|
};
|
|
1195
1990
|
const roots = cfg.roots ?? DEFAULT_ROOTS;
|
|
1196
1991
|
const exempt = new Set(cfg.exempt ?? defaultExempt(cfg));
|
|
@@ -1222,7 +2017,7 @@ async function guardHook(cwd = process.cwd()) {
|
|
|
1222
2017
|
}
|
|
1223
2018
|
return 0;
|
|
1224
2019
|
}
|
|
1225
|
-
const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] | sync [--force] [--check]
|
|
2020
|
+
const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n check-theme [--audit-allows] | check-coverage | info [--json] |\n doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\ncheck-theme [--tokens-only|--framework] [--audit-allows]\n --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n `basalt.exemptRules` entry, with what each one still suppresses \u2014 proved by re-running the\n guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n suppresses nothing (dead), which is what makes it usable as a CI gate.\n\ntokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n [--no-legacy-aliases]\n Emit the --vx-* stylesheet (stdout unless --out). Defaults reproduce basalt-ui/tokens.css.\n --selector-class emits `:root.dark` instead of an attribute selector (the Tailwind convention).\n --check writes nothing and exits 1 when --out differs from what would be emitted (a CI gate).\n --no-legacy-aliases drops the deprecated camelCase spellings (--vx-accentFill and friends),\n which are emitted by default as aliases of the canonical kebab-case names.\n\nfonts:css [--out <path>] [--check]\n Emit the shipped --basalt-font-* stacks as plain CSS \u2014 the typeface half of the token layer,\n otherwise reachable only by importing styles.css.\n\ndoctor [--tokens-only|--framework]\n Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\ncheck-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\nbasalt config when invoked from a repo root that has none \u2014 several carrying one is ambiguous\nand exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\nEvery subcommand accepts --help / -h to print this message and exit without running.";
|
|
1226
2021
|
function run(argv, cwd = process.cwd()) {
|
|
1227
2022
|
const [cmd, ...flags] = argv;
|
|
1228
2023
|
if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {
|
|
@@ -1233,22 +2028,25 @@ function run(argv, cwd = process.cwd()) {
|
|
|
1233
2028
|
case "init":
|
|
1234
2029
|
return init(cwd, {
|
|
1235
2030
|
withRouter: flags.includes("--with-router"),
|
|
1236
|
-
withQuery: flags.includes("--with-query")
|
|
2031
|
+
withQuery: flags.includes("--with-query"),
|
|
2032
|
+
mergeLint: flags.includes("--merge-lint")
|
|
1237
2033
|
});
|
|
1238
2034
|
case "sync":
|
|
1239
2035
|
return sync({ force: flags.includes("--force"), check: flags.includes("--check") }, cwd);
|
|
1240
2036
|
case "check-theme":
|
|
1241
|
-
return checkTheme(cwd);
|
|
2037
|
+
return checkTheme(cwd, flags);
|
|
1242
2038
|
case "check-coverage":
|
|
1243
2039
|
return checkCoverage();
|
|
1244
2040
|
case "info":
|
|
1245
2041
|
return info(flags);
|
|
1246
2042
|
case "doctor":
|
|
1247
|
-
return doctor(cwd);
|
|
2043
|
+
return doctor(cwd, flags);
|
|
1248
2044
|
case "guard-hook":
|
|
1249
2045
|
return guardHook(cwd);
|
|
1250
2046
|
case "tokens:css":
|
|
1251
2047
|
return tokensCss(flags, cwd);
|
|
2048
|
+
case "fonts:css":
|
|
2049
|
+
return fontsCss(flags, cwd);
|
|
1252
2050
|
default:
|
|
1253
2051
|
console.error(USAGE);
|
|
1254
2052
|
return 1;
|
|
@@ -1261,10 +2059,12 @@ export {
|
|
|
1261
2059
|
checkCoverage,
|
|
1262
2060
|
checkTheme,
|
|
1263
2061
|
doctor,
|
|
2062
|
+
fontsCss,
|
|
1264
2063
|
guardHook,
|
|
1265
2064
|
info,
|
|
1266
2065
|
init,
|
|
1267
2066
|
managedFiles,
|
|
2067
|
+
normalizeColorFunctions,
|
|
1268
2068
|
normalizeForLedger,
|
|
1269
2069
|
run,
|
|
1270
2070
|
sync,
|