basalt-ui 1.20.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +11 -5
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +231 -50
- package/README.md +57 -10
- package/agent/rules/basalt-app.md +49 -2
- package/agent/rules/basalt-charts.md +15 -12
- package/agent/rules/basalt-content.md +4 -0
- package/agent/rules/basalt-router.md +57 -5
- package/agent/rules/basalt-state.md +21 -3
- package/agent/rules/basalt-tokens.md +78 -16
- package/agent/skills/basalt-app/SKILL.md +5 -1
- package/agent/skills/basalt-charts/SKILL.md +5 -3
- package/agent/templates/CLAUDE-block.md.tpl +11 -5
- package/configs/lefthook.yml +48 -15
- package/configs/oxlint-plugin.js +218 -69
- package/configs/oxlint-plugin.test.ts +331 -9
- 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/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/cli/index.d.ts +55 -15
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +521 -71
- package/dist/cli/waiver-audit.test.d.ts +2 -0
- package/dist/cli/waiver-audit.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +114 -2
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +216 -33
- 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/index.d.ts +12 -5
- package/dist/shell/index.d.ts.map +1 -1
- package/dist/shell/index.js +24 -6
- package/dist/tokens/index.d.ts +3 -1
- package/dist/tokens/index.d.ts.map +1 -1
- package/dist/tokens/index.js +5 -1
- package/dist/tokens/palette.d.ts +2 -2
- package/dist/tokens/palette.js +2 -2
- 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 +4 -2
- package/dist/vite.d.ts +28 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/Bars.tsx +1 -1
- package/src/charts/kinds/Donut.tsx +1 -1
- 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/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +978 -133
- package/src/cli/init-sync.test.ts +2 -0
- package/src/cli/template-ledger.test.ts +1 -1
- package/src/cli/toolchain-wiring.test.ts +352 -1
- package/src/cli/waiver-audit.test.ts +211 -0
- package/src/guard/check-source.test.ts +638 -1
- package/src/guard/index.ts +556 -63
- 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.tsx +1 -1
- package/src/shell/index.test.tsx +52 -0
- package/src/shell/index.tsx +64 -13
- package/src/tokens/index.ts +24 -1
- package/src/tokens/palette.ts +2 -2
- package/src/tokens/text-scale.test.ts +51 -0
- package/src/vite.test.ts +158 -6
- package/src/vite.ts +176 -34
package/dist/cli/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
existsSync,
|
|
@@ -14,12 +15,33 @@ import {
|
|
|
14
15
|
checkSource,
|
|
15
16
|
DEFAULT_GUARD_CONFIG,
|
|
16
17
|
GENERATED_HEADER_LINE,
|
|
18
|
+
findAllowAnnotations,
|
|
19
|
+
guardKindRemedy,
|
|
17
20
|
GUARD_RULES,
|
|
18
|
-
|
|
21
|
+
guardWaiverHint,
|
|
22
|
+
neutralizeAllowAnnotation,
|
|
23
|
+
TOKENS_ONLY_DISABLED_KINDS,
|
|
24
|
+
unmatchedExemptPatterns
|
|
19
25
|
} from "../guard/index.js";
|
|
20
26
|
import { evaluateGuardHook } from "../guard/guard-hook.js";
|
|
21
27
|
import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
|
|
22
28
|
import { buildPaletteCss, deriveSpacing } from "../tokens/index.js";
|
|
29
|
+
function exemptRulePaths(entry) {
|
|
30
|
+
if (entry === void 0) return [];
|
|
31
|
+
return Array.isArray(entry) ? entry : entry.paths;
|
|
32
|
+
}
|
|
33
|
+
function exemptRuleReason(entry) {
|
|
34
|
+
if (entry === void 0 || Array.isArray(entry)) return null;
|
|
35
|
+
return entry.reason.trim().length > 0 ? entry.reason.trim() : null;
|
|
36
|
+
}
|
|
37
|
+
function resolveExemptRules(cfg) {
|
|
38
|
+
if (cfg.exemptRules === void 0) return DEFAULT_GUARD_CONFIG.exemptRules;
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
|
|
41
|
+
out[kind] = exemptRulePaths(entry);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
23
45
|
const DEFAULT_ROOT = "src";
|
|
24
46
|
const DEFAULT_ROOTS = [DEFAULT_ROOT];
|
|
25
47
|
function resolveRoots(cfg) {
|
|
@@ -89,25 +111,6 @@ function walkSourceFiles(dir, out = []) {
|
|
|
89
111
|
}
|
|
90
112
|
return out;
|
|
91
113
|
}
|
|
92
|
-
const GUARD_KIND_EXPLANATIONS = {
|
|
93
|
-
"raw-hex": "raw-hex: route color through VX.* / the Mantine theme instead of a hardcoded hex literal.",
|
|
94
|
-
"raw-color-fn": "raw-color-fn: route color through VX.* / the Mantine theme instead of a hardcoded rgb()/hsl() literal.",
|
|
95
|
-
"off-identity-accent": "off-identity-accent: use blue/gray or a status hue (red/green/orange/yellow) instead.",
|
|
96
|
-
"raw-spacing": "raw-spacing: use the spacing scale token instead of a raw literal.",
|
|
97
|
-
"raw-radius": "raw-radius: use the radius scale token instead of a raw literal.",
|
|
98
|
-
"raw-surface": "raw-surface: use `VX.surface.*` / a `radius` token / `var(--vx-radius-card)` instead of an inline border/radius/shadow.",
|
|
99
|
-
"card-with-border": "card-with-border: `withBorder` on a Card/Paper double-draws the edge \u2014 card depth is `--vx-shadow-card`, which already bakes a 1px ring into the shadow. Drop the prop.",
|
|
100
|
-
"off-system-surface-var": "off-system-surface-var: raw Mantine ramp step bypasses the basalt surface tokens \u2014 use VX.surface.* / --vx-surface-* (border/panel/subtle/bg) instead.",
|
|
101
|
-
"raw-html-layout": "raw-html-layout: raw HTML element with inline layout/surface styling \u2014 use a Mantine layout primitive (Box/Flex/Grid/Stack/Group).",
|
|
102
|
-
"inline-spacing": "inline-spacing: inline spacing literal \u2014 use the Mantine spacing prop/token (p/m/gap with xs..xl).",
|
|
103
|
-
"inline-display": "inline-display: use <Flex>/<Grid>/<Group> instead of an inline display:flex/grid.",
|
|
104
|
-
"raw-visx-axis": "raw-visx-axis: raw <AxisLeft>/<AxisBottom>/<AxisRight> in a chart file \u2014 use AxisLeftNumeric / AxisBottomDate / AxisRightNumeric from the charts primitives.",
|
|
105
|
-
"raw-motion-value": "raw-motion-value: hardcoded duration/spring/ease in `transition={{...}}` \u2014 use MOTION_DURATION / MOTION_SPRING / MOTION_EASE_STANDARD instead.",
|
|
106
|
-
"unframed-chart": "unframed-chart: hand-rolled ChartLegend built from an inline array literal \u2014 pass a derived legend (deriveLegend(series)), or compose ChartFrame, which derives it for you.",
|
|
107
|
-
"raw-form-control": "raw-form-control: raw <input>/<select>/<textarea> bypasses the entire theme \u2014 use TextInput / NumberInput / Select / Textarea from @mantine/core.",
|
|
108
|
-
"sub-16-input-font": "sub-16-input-font: fontSize below 16 on a form control is dead code against the `!important` iOS floor in styles.css \u2014 drop the override or be honest about 16px.",
|
|
109
|
-
"raw-font-family": "raw-font-family: route the font stack through createBasaltTheme(overrides, { fonts }) / the --basalt-font-sans|head|mono vars instead of a hardcoded fontFamily literal."
|
|
110
|
-
};
|
|
111
114
|
function relativePosix(fromDir, toPath) {
|
|
112
115
|
const rel = relative(fromDir, toPath).replace(/\\/g, "/");
|
|
113
116
|
if (rel === "") return ".";
|
|
@@ -219,6 +222,13 @@ function appShellFiles(rootAbs) {
|
|
|
219
222
|
out.push(...walkSourceFiles(resolve(appDir, "public")));
|
|
220
223
|
return out;
|
|
221
224
|
}
|
|
225
|
+
const PLAIN_JSON_HINT_PATH = "manifest.json";
|
|
226
|
+
function waiverHintFor(relPath, profile) {
|
|
227
|
+
if (profile !== "tokens-only" || !relPath.endsWith(".webmanifest")) {
|
|
228
|
+
return guardWaiverHint(relPath);
|
|
229
|
+
}
|
|
230
|
+
return `${guardWaiverHint(PLAIN_JSON_HINT_PATH)} (basaltAppPlugin would emit the file instead, but it needs basalt's Vite/React layer, which a tokens-only consumer has opted out of.)`;
|
|
231
|
+
}
|
|
222
232
|
function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
223
233
|
if (conflictingProfileFlags(flags)) {
|
|
224
234
|
console.error(
|
|
@@ -265,7 +275,7 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
|
265
275
|
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
266
276
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
267
277
|
allowComment: "theme-allow",
|
|
268
|
-
exemptRules: cfg
|
|
278
|
+
exemptRules: resolveExemptRules(cfg),
|
|
269
279
|
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
270
280
|
...profile === "tokens-only" ? { profile: "tokens-only" } : {}
|
|
271
281
|
};
|
|
@@ -286,6 +296,12 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
|
286
296
|
}
|
|
287
297
|
return 1;
|
|
288
298
|
}
|
|
299
|
+
if (flags.includes("--audit-allows")) return auditAllows(cwd, cfg, guardCfg, scanned);
|
|
300
|
+
for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
|
|
301
|
+
console.error(
|
|
302
|
+
`\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.")
|
|
303
|
+
);
|
|
304
|
+
}
|
|
289
305
|
if (findings.length === 0) {
|
|
290
306
|
console.log("\u2713 Theme guard: no off-palette colors.");
|
|
291
307
|
return 0;
|
|
@@ -322,14 +338,11 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
|
322
338
|
report(errors, `\u2716 Theme guard: ${errors.length} off-palette / off-identity violation(s)
|
|
323
339
|
`);
|
|
324
340
|
}
|
|
325
|
-
const presentKinds = new Set(findings.map((f) => f.kind));
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
"Add a `theme-allow` comment for a deliberate exception."
|
|
331
|
-
];
|
|
332
|
-
console.error(fixLine.join(" "));
|
|
341
|
+
const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted();
|
|
342
|
+
const waiverHints = [
|
|
343
|
+
...new Set(findings.map((f) => waiverHintFor(f.relPath, profile)))
|
|
344
|
+
].toSorted();
|
|
345
|
+
console.error(["Fix:", ...presentKinds.map(guardKindRemedy), ...waiverHints].join(" "));
|
|
333
346
|
if (findings.some((f) => SERIES_MODULE_HINT_RE.test(f.relPath))) {
|
|
334
347
|
console.error(
|
|
335
348
|
'Hint: if a flagged file IS your palette/series source, exempt it via "basalt": { "exempt": ["<path>"] } in package.json.'
|
|
@@ -337,6 +350,232 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
|
|
|
337
350
|
}
|
|
338
351
|
return errors.length > 0 ? 1 : 0;
|
|
339
352
|
}
|
|
353
|
+
function findingKey(f) {
|
|
354
|
+
return `${f.relPath}:${f.line}:${f.kind}`;
|
|
355
|
+
}
|
|
356
|
+
function resolveToolBin(dir, name) {
|
|
357
|
+
let current = dir;
|
|
358
|
+
for (; ; ) {
|
|
359
|
+
const candidate = resolve(current, "node_modules/.bin", name);
|
|
360
|
+
if (existsSync(candidate)) return candidate;
|
|
361
|
+
const parent = resolve(current, "..");
|
|
362
|
+
if (parent === current) break;
|
|
363
|
+
current = parent;
|
|
364
|
+
}
|
|
365
|
+
const probe = spawnSync(name, ["--version"], { stdio: "ignore", timeout: 1e4 });
|
|
366
|
+
return probe.error === void 0 && probe.status === 0 ? name : null;
|
|
367
|
+
}
|
|
368
|
+
function diagnosticCounts(diagnostics, filename) {
|
|
369
|
+
const counts = /* @__PURE__ */ new Map();
|
|
370
|
+
for (const d of diagnostics) {
|
|
371
|
+
if (d.filename !== filename) continue;
|
|
372
|
+
const code = typeof d.code === "string" ? d.code : "";
|
|
373
|
+
const rule = /^basalt\((.+)\)$/.exec(code)?.[1];
|
|
374
|
+
if (rule === void 0) continue;
|
|
375
|
+
const line = d.labels?.[0]?.span?.line;
|
|
376
|
+
const key = `${typeof line === "number" ? line : 0}:${rule}`;
|
|
377
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
378
|
+
}
|
|
379
|
+
return counts;
|
|
380
|
+
}
|
|
381
|
+
function probePluginRules(cwd, guardCfg, probes, sources) {
|
|
382
|
+
if (probes.length === 0) return /* @__PURE__ */ new Map();
|
|
383
|
+
const bin = resolveToolBin(cwd, "oxlint");
|
|
384
|
+
if (bin === null) return null;
|
|
385
|
+
const written = [];
|
|
386
|
+
const probePaths = /* @__PURE__ */ new Map();
|
|
387
|
+
try {
|
|
388
|
+
for (const probe of probes) {
|
|
389
|
+
const probed = neutralizeAllowAnnotation(sources.get(probe.rel) ?? "", probe.line, guardCfg);
|
|
390
|
+
const dot = probe.rel.lastIndexOf(".");
|
|
391
|
+
const rel = dot === -1 ? `${probe.rel}.basalt-audit-${probe.slot}` : `${probe.rel.slice(0, dot)}.basalt-audit-${probe.slot}${probe.rel.slice(dot)}`;
|
|
392
|
+
const abs = resolve(cwd, rel);
|
|
393
|
+
if (existsSync(abs)) continue;
|
|
394
|
+
writeFileSync(abs, probed, "utf8");
|
|
395
|
+
written.push(abs);
|
|
396
|
+
probePaths.set(probe.slot, rel);
|
|
397
|
+
}
|
|
398
|
+
if (probePaths.size === 0) return null;
|
|
399
|
+
const targets = [...new Set(probes.map((p) => p.rel)), ...probePaths.values()];
|
|
400
|
+
const run2 = spawnSync(bin, ["--format=json", "--no-error-on-unmatched-pattern", ...targets], {
|
|
401
|
+
cwd,
|
|
402
|
+
encoding: "utf8",
|
|
403
|
+
timeout: 12e4,
|
|
404
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
405
|
+
});
|
|
406
|
+
if (run2.error !== void 0 || typeof run2.stdout !== "string") return null;
|
|
407
|
+
let diagnostics;
|
|
408
|
+
try {
|
|
409
|
+
diagnostics = JSON.parse(run2.stdout).diagnostics ?? [];
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const verdicts = /* @__PURE__ */ new Map();
|
|
414
|
+
for (const probe of probes) {
|
|
415
|
+
const probeRel = probePaths.get(probe.slot);
|
|
416
|
+
if (probeRel === void 0) continue;
|
|
417
|
+
const before = diagnosticCounts(diagnostics, probe.rel);
|
|
418
|
+
const after = diagnosticCounts(diagnostics, probeRel);
|
|
419
|
+
const revealed = [];
|
|
420
|
+
for (const [key, count] of after) {
|
|
421
|
+
if (count <= (before.get(key) ?? 0)) continue;
|
|
422
|
+
const [line = "", rule = ""] = key.split(":");
|
|
423
|
+
revealed.push(`${rule}@${line}`);
|
|
424
|
+
}
|
|
425
|
+
verdicts.set(probe.slot, revealed.toSorted());
|
|
426
|
+
}
|
|
427
|
+
return verdicts;
|
|
428
|
+
} finally {
|
|
429
|
+
for (const abs of written) {
|
|
430
|
+
try {
|
|
431
|
+
unlinkSync(abs);
|
|
432
|
+
} catch {
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function auditAllows(cwd, cfg, guardCfg, scanned) {
|
|
438
|
+
const token = guardCfg.allowComment;
|
|
439
|
+
const lines = [`
|
|
440
|
+
basalt-ui check-theme --audit-allows \u2014 ${cwd}
|
|
441
|
+
`];
|
|
442
|
+
let dead = 0;
|
|
443
|
+
let live = 0;
|
|
444
|
+
let outOfReach = 0;
|
|
445
|
+
let unaccountable = 0;
|
|
446
|
+
const waiverLines = [];
|
|
447
|
+
const pluginProbes = [];
|
|
448
|
+
const sources = /* @__PURE__ */ new Map();
|
|
449
|
+
for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), "utf8"));
|
|
450
|
+
for (const rel of scanned) {
|
|
451
|
+
const text = sources.get(rel) ?? "";
|
|
452
|
+
if (!text.includes(token)) continue;
|
|
453
|
+
const baseline = checkSource(text, rel, guardCfg);
|
|
454
|
+
const baselineKeys = new Set(
|
|
455
|
+
baseline.filter((f) => f.kind !== "theme-allow-unscoped").map(findingKey)
|
|
456
|
+
);
|
|
457
|
+
const unscopedAt = new Map(
|
|
458
|
+
baseline.filter((f) => f.kind === "theme-allow-unscoped").map((f) => [f.line, f.token])
|
|
459
|
+
);
|
|
460
|
+
for (const site of findAllowAnnotations(text, rel, guardCfg)) {
|
|
461
|
+
const revealed = checkSource(
|
|
462
|
+
neutralizeAllowAnnotation(text, site.line, guardCfg),
|
|
463
|
+
rel,
|
|
464
|
+
guardCfg
|
|
465
|
+
).filter((f) => f.kind !== "theme-allow-unscoped" && !baselineKeys.has(findingKey(f))).map((f) => `${f.kind}@${f.line}`);
|
|
466
|
+
const note = unscopedAt.get(site.line);
|
|
467
|
+
if (note !== void 0) unaccountable++;
|
|
468
|
+
const label = ` ${rel}:${site.line}`.padEnd(52);
|
|
469
|
+
const suffix = note === void 0 ? "" : ` [${note}]`;
|
|
470
|
+
if (revealed.length > 0) {
|
|
471
|
+
live++;
|
|
472
|
+
waiverLines.push(`${label} suppresses ${[...new Set(revealed)].join(", ")}${suffix}`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (site.pluginRules.length > 0 && site.guardKinds.length === 0) {
|
|
476
|
+
pluginProbes.push({
|
|
477
|
+
slot: waiverLines.length,
|
|
478
|
+
rel,
|
|
479
|
+
line: site.line,
|
|
480
|
+
ids: site.pluginRules,
|
|
481
|
+
site: label,
|
|
482
|
+
suffix
|
|
483
|
+
});
|
|
484
|
+
waiverLines.push("");
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (site.guardKinds.length === 0 && site.unknownRules.length > 0) {
|
|
488
|
+
outOfReach++;
|
|
489
|
+
waiverLines.push(
|
|
490
|
+
`${label} scoped to ${site.unknownRules.join(", ")} \u2014 names no guard kind and no plugin rule, so this audit cannot judge it (most likely a typo, which theme-allow-unscoped reports)${suffix}`
|
|
491
|
+
);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
dead++;
|
|
495
|
+
waiverLines.push(`${label} SUPPRESSES NOTHING \u2014 dead, delete it${suffix}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const pluginVerdicts = probePluginRules(cwd, guardCfg, pluginProbes, sources);
|
|
499
|
+
for (const probe of pluginProbes) {
|
|
500
|
+
const revealed = pluginVerdicts?.get(probe.slot);
|
|
501
|
+
if (revealed === void 0) {
|
|
502
|
+
outOfReach++;
|
|
503
|
+
waiverLines[probe.slot] = `${probe.site} scoped to ${probe.ids.join(", ")} \u2014 an oxlint plugin rule, and oxlint could not be run here, so this audit cannot judge it${probe.suffix}`;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (revealed.length > 0) {
|
|
507
|
+
live++;
|
|
508
|
+
waiverLines[probe.slot] = `${probe.site} suppresses ${revealed.join(", ")} (oxlint)${probe.suffix}`;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
dead++;
|
|
512
|
+
waiverLines[probe.slot] = `${probe.site} SUPPRESSES NOTHING \u2014 dead, delete it (proved by re-running oxlint over the file with this annotation neutralized)${probe.suffix}`;
|
|
513
|
+
}
|
|
514
|
+
lines.push(`${token} annotations (${waiverLines.length}):`);
|
|
515
|
+
lines.push(...waiverLines.length === 0 ? [" (none)"] : waiverLines);
|
|
516
|
+
const exemptLines = [];
|
|
517
|
+
let exemptCount = 0;
|
|
518
|
+
const declared = Object.entries(cfg.exemptRules ?? {});
|
|
519
|
+
if (declared.length > 0) {
|
|
520
|
+
const baselineKeys = /* @__PURE__ */ new Set();
|
|
521
|
+
for (const rel of scanned) {
|
|
522
|
+
for (const f of checkSource(sources.get(rel) ?? "", rel, guardCfg))
|
|
523
|
+
baselineKeys.add(findingKey(f));
|
|
524
|
+
}
|
|
525
|
+
for (const [kind, entry] of declared) {
|
|
526
|
+
const reason = exemptRuleReason(entry);
|
|
527
|
+
for (const pattern of exemptRulePaths(entry)) {
|
|
528
|
+
exemptCount++;
|
|
529
|
+
const probeCfg = {
|
|
530
|
+
...guardCfg,
|
|
531
|
+
exemptRules: {
|
|
532
|
+
...guardCfg.exemptRules,
|
|
533
|
+
[kind]: exemptRulePaths(entry).filter((p) => p !== pattern)
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
const revealed = /* @__PURE__ */ new Set();
|
|
537
|
+
for (const rel of scanned) {
|
|
538
|
+
for (const f of checkSource(sources.get(rel) ?? "", rel, probeCfg)) {
|
|
539
|
+
if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
const site = ` ${kind}: "${pattern}"`.padEnd(52);
|
|
543
|
+
const why = reason === null ? " [no reason recorded]" : ` \u2014 ${reason}`;
|
|
544
|
+
if (revealed.size === 0) {
|
|
545
|
+
dead++;
|
|
546
|
+
const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
|
|
547
|
+
(u) => u.kind === kind && u.pattern === pattern
|
|
548
|
+
);
|
|
549
|
+
exemptLines.push(
|
|
550
|
+
`${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
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
live++;
|
|
554
|
+
exemptLines.push(
|
|
555
|
+
`${site} suppresses findings in ${[...revealed].toSorted().join(", ")}${why}`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
lines.push(`
|
|
562
|
+
basalt.exemptRules entries (${exemptCount}):`);
|
|
563
|
+
lines.push(...exemptLines.length === 0 ? [" (none)"] : exemptLines);
|
|
564
|
+
lines.push(
|
|
565
|
+
`
|
|
566
|
+
${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} unaccountable (reported as theme-allow-unscoped by a normal run).`
|
|
567
|
+
);
|
|
568
|
+
lines.push(
|
|
569
|
+
`Scope: the ${scanned.length} file(s) check-theme scans under basalt.roots (${resolveRoots(cfg).join(", ")}). A waiver outside them is not audited \u2014 widen roots to cover it, or accept that nothing polices that file.`
|
|
570
|
+
);
|
|
571
|
+
if (dead > 0) {
|
|
572
|
+
lines.push(
|
|
573
|
+
"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."
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
console.log(lines.join("\n"));
|
|
577
|
+
return dead > 0 ? 1 : 0;
|
|
578
|
+
}
|
|
340
579
|
const BLOCK_BEGIN_PREFIX = "<!-- basalt:begin";
|
|
341
580
|
const BLOCK_END = "<!-- basalt:end -->";
|
|
342
581
|
const MANIFEST_PATH = ".basalt/manifest.json";
|
|
@@ -531,9 +770,24 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
|
|
|
531
770
|
# The path is RESOLVED from where basalt-ui actually installed, not assumed at the
|
|
532
771
|
# repo root \u2014 under an isolated linker it lives beside the package that depends on it.
|
|
533
772
|
# Run \`lefthook install\` once: the file is inert until the git hooks are written.
|
|
773
|
+
#
|
|
774
|
+
# NOTE: an extends target WINS on a colliding key \u2014 a \`run:\`/\`glob:\` you write under one of
|
|
775
|
+
# the preset's command names is discarded silently. \`env:\`, \`exclude:\`, \`skip:\` and your own
|
|
776
|
+
# command names merge. See the preset itself for the sanctioned override seams.
|
|
534
777
|
extends:
|
|
535
778
|
- ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
|
|
536
|
-
`
|
|
779
|
+
` + // The one seam that has to be RENDERED rather than documented: the preset is read in place
|
|
780
|
+
// out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
|
|
781
|
+
// default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
|
|
782
|
+
// copy; this pins it to the local one whenever basalt actually resolved.
|
|
783
|
+
(ctx.vars.BASALT_BIN.startsWith("bunx ") ? "" : `
|
|
784
|
+
# The theme guard runs the LOCAL bin, not a copy \`bunx\` fetches from npm.
|
|
785
|
+
pre-commit:
|
|
786
|
+
commands:
|
|
787
|
+
check-theme:
|
|
788
|
+
env:
|
|
789
|
+
BASALT_BIN: ${ctx.vars.BASALT_BIN}
|
|
790
|
+
`)
|
|
537
791
|
};
|
|
538
792
|
const ci = {
|
|
539
793
|
dest: ".github/workflows/check.yml",
|
|
@@ -690,6 +944,60 @@ function detectRoots(cwd) {
|
|
|
690
944
|
}
|
|
691
945
|
return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
|
|
692
946
|
}
|
|
947
|
+
function rootCovers(root, detected) {
|
|
948
|
+
const r = root.replace(/\/+$/, "");
|
|
949
|
+
return detected === r || detected.startsWith(`${r}/`);
|
|
950
|
+
}
|
|
951
|
+
function reconcileRoots(cwd, opts) {
|
|
952
|
+
const declared = readBasaltConfig(cwd).roots;
|
|
953
|
+
const detected = detectRoots(cwd);
|
|
954
|
+
if (declared !== void 0) {
|
|
955
|
+
const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)));
|
|
956
|
+
return { kind: "declared", declared, unscanned };
|
|
957
|
+
}
|
|
958
|
+
if (detected.length === 0) return { kind: "undetectable" };
|
|
959
|
+
if (!opts.write) return { kind: "declared", declared: [], unscanned: detected };
|
|
960
|
+
const wrote = patchPackageJson(cwd, (pkg) => {
|
|
961
|
+
const basalt = pkg["basalt"] ?? {};
|
|
962
|
+
if (basalt["roots"] !== void 0) return false;
|
|
963
|
+
pkg["basalt"] = { ...basalt, roots: detected };
|
|
964
|
+
return true;
|
|
965
|
+
});
|
|
966
|
+
return wrote ? { kind: "wrote", roots: detected } : { kind: "undetectable" };
|
|
967
|
+
}
|
|
968
|
+
function rootsNotices(state) {
|
|
969
|
+
if (state.kind === "wrote") {
|
|
970
|
+
return {
|
|
971
|
+
log: [
|
|
972
|
+
`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.`
|
|
973
|
+
],
|
|
974
|
+
error: []
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (state.kind === "undetectable") {
|
|
978
|
+
return {
|
|
979
|
+
log: [],
|
|
980
|
+
error: [
|
|
981
|
+
'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".'
|
|
982
|
+
]
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
if (state.unscanned.length === 0) return { log: [], error: [] };
|
|
986
|
+
if (state.declared.length === 0) {
|
|
987
|
+
return {
|
|
988
|
+
log: [],
|
|
989
|
+
error: [
|
|
990
|
+
`"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.`
|
|
991
|
+
]
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
log: [
|
|
996
|
+
`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.`
|
|
997
|
+
],
|
|
998
|
+
error: []
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
693
1001
|
function stripJsonc(text) {
|
|
694
1002
|
let out = "";
|
|
695
1003
|
let inString = false;
|
|
@@ -733,10 +1041,79 @@ function parseJsonc(text) {
|
|
|
733
1041
|
return null;
|
|
734
1042
|
}
|
|
735
1043
|
}
|
|
736
|
-
function
|
|
737
|
-
|
|
1044
|
+
function basaltPresetEntry(entries) {
|
|
1045
|
+
if (!Array.isArray(entries)) return null;
|
|
1046
|
+
const match = entries.find(
|
|
738
1047
|
(entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
|
|
739
1048
|
);
|
|
1049
|
+
return match ?? null;
|
|
1050
|
+
}
|
|
1051
|
+
const LEFTHOOK_CONFIG_NAMES = ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"];
|
|
1052
|
+
function findLefthookConfig(dir) {
|
|
1053
|
+
return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null;
|
|
1054
|
+
}
|
|
1055
|
+
function yamlUnquote(raw) {
|
|
1056
|
+
return raw.trim().replace(/^['"]|['"]$/g, "");
|
|
1057
|
+
}
|
|
1058
|
+
function yamlExtendsEntries(text) {
|
|
1059
|
+
const lines = text.split("\n");
|
|
1060
|
+
const out = [];
|
|
1061
|
+
for (const [index, line] of lines.entries()) {
|
|
1062
|
+
const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? "");
|
|
1063
|
+
if (inline !== null) {
|
|
1064
|
+
out.push(...(inline[1] ?? "").split(",").map(yamlUnquote).filter(Boolean));
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (!/^extends:\s*(#.*)?$/.test(line ?? "")) continue;
|
|
1068
|
+
for (const next of lines.slice(index + 1)) {
|
|
1069
|
+
if (/^\s*(#.*)?$/.test(next ?? "")) continue;
|
|
1070
|
+
const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? "");
|
|
1071
|
+
if (item === null) break;
|
|
1072
|
+
out.push(yamlUnquote(item[1] ?? ""));
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return out;
|
|
1076
|
+
}
|
|
1077
|
+
function inspectLefthookSeam(repoRoot) {
|
|
1078
|
+
const file = findLefthookConfig(repoRoot);
|
|
1079
|
+
if (file === null) return { kind: "no-file" };
|
|
1080
|
+
const raw = readIfExists(resolve(repoRoot, file));
|
|
1081
|
+
if (raw === null) return { kind: "no-file" };
|
|
1082
|
+
const entry = yamlExtendsEntries(raw).find((e) => e.endsWith("basalt-ui/configs/lefthook.yml"));
|
|
1083
|
+
if (entry === void 0) return { kind: "unwired", file };
|
|
1084
|
+
return existsSync(resolve(repoRoot, entry)) ? { kind: "ok", file, entry } : { kind: "broken", file, entry };
|
|
1085
|
+
}
|
|
1086
|
+
const LEFTHOOK_GUARD_RUN = /^\s*run:\s*.*\bcheck-theme\b/m;
|
|
1087
|
+
function lefthookDump(repoRoot) {
|
|
1088
|
+
const localBin = resolve(repoRoot, "node_modules/.bin/lefthook");
|
|
1089
|
+
const bin = existsSync(localBin) ? localBin : "lefthook";
|
|
1090
|
+
try {
|
|
1091
|
+
const result = spawnSync(bin, ["dump"], {
|
|
1092
|
+
cwd: repoRoot,
|
|
1093
|
+
encoding: "utf8",
|
|
1094
|
+
timeout: 1e4,
|
|
1095
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1096
|
+
});
|
|
1097
|
+
if (result.error !== void 0 || result.status !== 0) return null;
|
|
1098
|
+
return result.stdout;
|
|
1099
|
+
} catch {
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
function inspectLefthookGate(repoRoot) {
|
|
1104
|
+
const seam = inspectLefthookSeam(repoRoot);
|
|
1105
|
+
if (seam.kind === "no-file") return { kind: "no-file" };
|
|
1106
|
+
if (seam.kind === "ok") return { kind: "extends-ok", file: seam.file, entry: seam.entry };
|
|
1107
|
+
if (seam.kind === "broken") {
|
|
1108
|
+
return { kind: "extends-broken", file: seam.file, entry: seam.entry };
|
|
1109
|
+
}
|
|
1110
|
+
const dump = lefthookDump(repoRoot);
|
|
1111
|
+
if (dump !== null) {
|
|
1112
|
+
return LEFTHOOK_GUARD_RUN.test(dump) ? { kind: "wired", file: seam.file, proof: "dump" } : { kind: "absent", file: seam.file };
|
|
1113
|
+
}
|
|
1114
|
+
const raw = readIfExists(resolve(repoRoot, seam.file)) ?? "";
|
|
1115
|
+
if (LEFTHOOK_GUARD_RUN.test(raw)) return { kind: "wired", file: seam.file, proof: "config" };
|
|
1116
|
+
return { kind: "advisory", file: seam.file };
|
|
740
1117
|
}
|
|
741
1118
|
function mergeOxlintExtends(cwd, presetPath) {
|
|
742
1119
|
const abs = resolve(cwd, ".oxlintrc.json");
|
|
@@ -744,7 +1121,7 @@ function mergeOxlintExtends(cwd, presetPath) {
|
|
|
744
1121
|
if (raw === null) return "absent";
|
|
745
1122
|
const cfg = parseJsonc(raw);
|
|
746
1123
|
if (cfg === null) return "unreadable";
|
|
747
|
-
if (
|
|
1124
|
+
if (basaltPresetEntry(cfg["extends"]) !== null) return "already";
|
|
748
1125
|
if (stripJsonc(raw) !== raw) return "has-comments";
|
|
749
1126
|
const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
|
|
750
1127
|
const merged = "extends" in cfg ? {} : { extends: [presetPath] };
|
|
@@ -769,21 +1146,7 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
|
|
|
769
1146
|
`\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.`
|
|
770
1147
|
);
|
|
771
1148
|
}
|
|
772
|
-
|
|
773
|
-
let rootsUndetectable = false;
|
|
774
|
-
if (readBasaltConfig(cwd).roots === void 0) {
|
|
775
|
-
const detected = detectRoots(cwd);
|
|
776
|
-
if (detected.length === 0) {
|
|
777
|
-
rootsUndetectable = true;
|
|
778
|
-
} else if (patchPackageJson(cwd, (pkg) => {
|
|
779
|
-
const basalt = pkg["basalt"] ?? {};
|
|
780
|
-
if (basalt["roots"] !== void 0) return false;
|
|
781
|
-
pkg["basalt"] = { ...basalt, roots: detected };
|
|
782
|
-
return true;
|
|
783
|
-
})) {
|
|
784
|
-
seededRoots = detected;
|
|
785
|
-
}
|
|
786
|
-
}
|
|
1149
|
+
const rootsState = reconcileRoots(cwd, { write: true });
|
|
787
1150
|
const ctx = renderContext(cwd);
|
|
788
1151
|
const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
|
|
789
1152
|
const seededLintScript = patchPackageJson(cwd, (pkg) => {
|
|
@@ -847,16 +1210,9 @@ Kept (already present \u2014 basalt did not touch them):`);
|
|
|
847
1210
|
};
|
|
848
1211
|
console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
|
|
849
1212
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
);
|
|
854
|
-
}
|
|
855
|
-
if (rootsUndetectable) {
|
|
856
|
-
console.error(
|
|
857
|
-
'\u26A0 basalt-ui init: 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".'
|
|
858
|
-
);
|
|
859
|
-
}
|
|
1213
|
+
const rootsMessages = rootsNotices(rootsState);
|
|
1214
|
+
for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`);
|
|
1215
|
+
for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui init: ${line}`);
|
|
860
1216
|
if (seededLintScript) {
|
|
861
1217
|
console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
|
|
862
1218
|
}
|
|
@@ -921,7 +1277,38 @@ function readOxlintPresetPlugins(pkgRoot) {
|
|
|
921
1277
|
return [];
|
|
922
1278
|
}
|
|
923
1279
|
}
|
|
924
|
-
function
|
|
1280
|
+
function findManifestAbove(dir) {
|
|
1281
|
+
let current = resolve(dir, "..");
|
|
1282
|
+
for (; ; ) {
|
|
1283
|
+
if (existsSync(resolve(current, MANIFEST_PATH))) return current;
|
|
1284
|
+
const parent = resolve(current, "..");
|
|
1285
|
+
if (parent === current) return null;
|
|
1286
|
+
current = parent;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
function sync(opts = {}, invocationCwd = process.cwd()) {
|
|
1290
|
+
const project = resolveProjectDir(invocationCwd);
|
|
1291
|
+
if (project.ambiguous !== null) {
|
|
1292
|
+
console.error(
|
|
1293
|
+
`\u2716 basalt-ui sync: 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.`
|
|
1294
|
+
);
|
|
1295
|
+
return 1;
|
|
1296
|
+
}
|
|
1297
|
+
const cwd = project.dir;
|
|
1298
|
+
if (project.relocatedFrom !== null) {
|
|
1299
|
+
console.log(
|
|
1300
|
+
`basalt-ui sync: no basalt config at ${project.relocatedFrom} \u2014 running in ${relativePosix(project.relocatedFrom, cwd)}, where it lives.`
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
if (!existsSync(resolve(cwd, MANIFEST_PATH))) {
|
|
1304
|
+
const elsewhere = findManifestAbove(cwd);
|
|
1305
|
+
const wouldWrite = managedFiles(resolvePeerFlags(cwd, {}), resolvePlacement(cwd)).length;
|
|
1306
|
+
console.error(
|
|
1307
|
+
`\u2716 basalt-ui sync: no ${MANIFEST_PATH} at ${cwd} \u2014 refusing to scaffold. \`sync\` refreshes an EXISTING basalt install; creating one is \`basalt-ui init\`'s decision, and continuing here would write ${wouldWrite} managed file(s) into this directory.` + (elsewhere === null ? " No manifest exists at any ancestor either \u2014 run `basalt-ui init` here if this package really is meant to be a consumer." : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) \u2014 run sync there, or set BASALT_CWD to it.`)
|
|
1308
|
+
);
|
|
1309
|
+
return 1;
|
|
1310
|
+
}
|
|
1311
|
+
const rootsState = reconcileRoots(cwd, { write: opts.check !== true });
|
|
925
1312
|
const ctx = renderContext(cwd);
|
|
926
1313
|
const manifest = readManifest(cwd);
|
|
927
1314
|
if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
|
|
@@ -929,6 +1316,7 @@ function sync(opts = {}, cwd = process.cwd()) {
|
|
|
929
1316
|
const placement = resolvePlacement(cwd);
|
|
930
1317
|
const files = managedFiles(peers, placement);
|
|
931
1318
|
let updated = 0;
|
|
1319
|
+
let created = 0;
|
|
932
1320
|
let recreated = 0;
|
|
933
1321
|
let skippedDrift = 0;
|
|
934
1322
|
let staleForCheck = 0;
|
|
@@ -940,10 +1328,12 @@ function sync(opts = {}, cwd = process.cwd()) {
|
|
|
940
1328
|
missingSources.push(file.source);
|
|
941
1329
|
continue;
|
|
942
1330
|
}
|
|
1331
|
+
const tracked = manifest.files[file.dest] !== void 0;
|
|
943
1332
|
if (file.mode === "seed") {
|
|
944
1333
|
if (state.current === null && !opts.check) {
|
|
945
1334
|
manifest.files[file.dest] = writeUnit(file, cwd, state.desired);
|
|
946
|
-
recreated++;
|
|
1335
|
+
if (tracked) recreated++;
|
|
1336
|
+
else created++;
|
|
947
1337
|
}
|
|
948
1338
|
continue;
|
|
949
1339
|
}
|
|
@@ -961,8 +1351,9 @@ function sync(opts = {}, cwd = process.cwd()) {
|
|
|
961
1351
|
staleForCheck++;
|
|
962
1352
|
if (opts.check) continue;
|
|
963
1353
|
manifest.files[file.dest] = writeUnit(file, cwd, state.desired);
|
|
964
|
-
if (kind
|
|
965
|
-
else
|
|
1354
|
+
if (kind !== "missing") updated++;
|
|
1355
|
+
else if (tracked) recreated++;
|
|
1356
|
+
else created++;
|
|
966
1357
|
}
|
|
967
1358
|
if (!placement.isPackageRepoRoot) {
|
|
968
1359
|
console.log(
|
|
@@ -977,6 +1368,23 @@ function sync(opts = {}, cwd = process.cwd()) {
|
|
|
977
1368
|
`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.`
|
|
978
1369
|
);
|
|
979
1370
|
}
|
|
1371
|
+
const rootsMessages = rootsNotices(rootsState);
|
|
1372
|
+
for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`);
|
|
1373
|
+
for (const line of rootsMessages.error) console.error(`\u26A0 basalt-ui sync: ${line}`);
|
|
1374
|
+
const install = findBasaltInstall(cwd);
|
|
1375
|
+
const lefthookSeam = inspectLefthookSeam(placement.repoRoot);
|
|
1376
|
+
if (lefthookSeam.kind === "broken") {
|
|
1377
|
+
console.error(
|
|
1378
|
+
`\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")}".`
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
const oxlintrcRaw = readIfExists(resolve(cwd, ".oxlintrc.json"));
|
|
1382
|
+
const oxlintEntry = oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.["extends"]);
|
|
1383
|
+
if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
|
|
1384
|
+
console.error(
|
|
1385
|
+
`\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")}".`
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
980
1388
|
if (opts.check) {
|
|
981
1389
|
if (driftLines.length > 0) {
|
|
982
1390
|
console.error("basalt-ui sync --check: locally-drifted managed files:");
|
|
@@ -994,7 +1402,7 @@ function sync(opts = {}, cwd = process.cwd()) {
|
|
|
994
1402
|
writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}
|
|
995
1403
|
`);
|
|
996
1404
|
console.log(
|
|
997
|
-
`basalt-ui sync: ${updated} updated, ${recreated} recreated, ${skippedDrift} skipped (drift).`
|
|
1405
|
+
`basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ${skippedDrift} skipped (drift).`
|
|
998
1406
|
);
|
|
999
1407
|
if (driftLines.length > 0) {
|
|
1000
1408
|
console.log("Locally-edited files were skipped (run with --force to overwrite):");
|
|
@@ -1380,15 +1788,53 @@ basalt-ui doctor \u2014 ${cwd}
|
|
|
1380
1788
|
skip(
|
|
1381
1789
|
".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
|
|
1382
1790
|
);
|
|
1383
|
-
} else if (extendsBasaltPreset(parsed["extends"])) {
|
|
1384
|
-
pass(".oxlintrc.json extends the shipped basalt-ui oxlint preset");
|
|
1385
1791
|
} else {
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
)
|
|
1792
|
+
const entry = basaltPresetEntry(parsed["extends"]);
|
|
1793
|
+
const correct = shippedAssetPath(install, cwd, "configs/oxlint.json");
|
|
1794
|
+
if (entry === null) {
|
|
1795
|
+
fail(
|
|
1796
|
+
`.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.`
|
|
1797
|
+
);
|
|
1798
|
+
} else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
|
|
1799
|
+
fail(
|
|
1800
|
+
`.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.`
|
|
1801
|
+
);
|
|
1802
|
+
} else if (!existsSync(resolve(cwd, entry))) {
|
|
1803
|
+
fail(
|
|
1804
|
+
`.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.`
|
|
1805
|
+
);
|
|
1806
|
+
} else {
|
|
1807
|
+
pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`);
|
|
1808
|
+
}
|
|
1389
1809
|
}
|
|
1390
1810
|
}
|
|
1391
1811
|
}
|
|
1812
|
+
const repoRoot = findRepoRoot(cwd);
|
|
1813
|
+
const lefthookGate = profile === "tokens-only" ? { kind: "n/a" } : inspectLefthookGate(repoRoot);
|
|
1814
|
+
const lefthookCorrect = shippedAssetPath(install, repoRoot, "configs/lefthook.yml");
|
|
1815
|
+
if (lefthookGate.kind === "n/a") {
|
|
1816
|
+
pass("lefthook-preset: n/a \u2014 a tokens-only consumer wires its own hooks");
|
|
1817
|
+
} else if (lefthookGate.kind === "no-file") {
|
|
1818
|
+
pass(`lefthook-preset: n/a \u2014 no lefthook config at ${repoRoot}`);
|
|
1819
|
+
} else if (lefthookGate.kind === "extends-ok") {
|
|
1820
|
+
pass(`${lefthookGate.file} extends the shipped lefthook preset (${lefthookGate.entry})`);
|
|
1821
|
+
} else if (lefthookGate.kind === "wired") {
|
|
1822
|
+
pass(
|
|
1823
|
+
`lefthook-preset: ${lefthookGate.file} does not extend the shipped preset, but a pre-commit command runs check-theme directly \u2014 the gate exists (read from ` + (lefthookGate.proof === "dump" ? "`lefthook dump`, so extends/include/root: are all resolved)." : "the config text; `lefthook dump` was not runnable here, so an `include:` or a remote config was not consulted).")
|
|
1824
|
+
);
|
|
1825
|
+
} else if (lefthookGate.kind === "extends-broken") {
|
|
1826
|
+
fail(
|
|
1827
|
+
`${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist (${resolve(repoRoot, lefthookGate.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}".`
|
|
1828
|
+
);
|
|
1829
|
+
} else if (lefthookGate.kind === "absent") {
|
|
1830
|
+
warn(
|
|
1831
|
+
`${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit command runs check-theme \u2014 the theme guard is not gating commits. Either add "extends: [${lefthookCorrect}]", or add your own command running \`bunx basalt-ui check-theme\` (with \`root:\` if it must run in a subdirectory).`
|
|
1832
|
+
);
|
|
1833
|
+
} else {
|
|
1834
|
+
warn(
|
|
1835
|
+
`${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so this check cannot tell a missing gate from one wired through an \`include:\` or a remote config it never read \u2014 treat it as advisory. If the guard really is unwired, add "extends: [${lefthookCorrect}]" or your own command running \`bunx basalt-ui check-theme\`.`
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1392
1838
|
const publicDir = resolve(cwd, "public");
|
|
1393
1839
|
if (profile === "framework" && existsSync(publicDir)) {
|
|
1394
1840
|
const iconFiles = [
|
|
@@ -1541,10 +1987,14 @@ function generatedHeader(version, command, flags) {
|
|
|
1541
1987
|
}
|
|
1542
1988
|
function normalizeColorFunctions(css) {
|
|
1543
1989
|
return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
|
|
1544
|
-
const parts = args.split(",").map((part) => part.trim());
|
|
1990
|
+
const parts = args.split(",").map((part) => normalizeCssNumber(part.trim()));
|
|
1545
1991
|
return `${fn}(${parts.join(", ")})`;
|
|
1546
1992
|
});
|
|
1547
1993
|
}
|
|
1994
|
+
function normalizeCssNumber(arg) {
|
|
1995
|
+
if (!/^\d+\.\d+$/.test(arg)) return arg;
|
|
1996
|
+
return arg.replace(/\.?0+$/, "");
|
|
1997
|
+
}
|
|
1548
1998
|
const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
|
|
1549
1999
|
function emitGeneratedCss(content, flags, cwd, command) {
|
|
1550
2000
|
const out = flagValue(flags, "--out");
|
|
@@ -1719,7 +2169,7 @@ async function guardHook(cwd = process.cwd()) {
|
|
|
1719
2169
|
rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
|
|
1720
2170
|
sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
|
|
1721
2171
|
allowComment: "theme-allow",
|
|
1722
|
-
exemptRules: cfg
|
|
2172
|
+
exemptRules: resolveExemptRules(cfg),
|
|
1723
2173
|
severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
|
|
1724
2174
|
// Same detection check-theme uses: the hook must never block an edit over advice the app
|
|
1725
2175
|
// cannot take ("use @mantine/core's Select") in a repo with no Mantine.
|
|
@@ -1755,7 +2205,7 @@ async function guardHook(cwd = process.cwd()) {
|
|
|
1755
2205
|
}
|
|
1756
2206
|
return 0;
|
|
1757
2207
|
}
|
|
1758
|
-
const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n check-theme | check-coverage | info [--json]
|
|
2208
|
+
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.";
|
|
1759
2209
|
function run(argv, cwd = process.cwd()) {
|
|
1760
2210
|
const [cmd, ...flags] = argv;
|
|
1761
2211
|
if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {
|