basalt-ui 1.20.0 → 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.
Files changed (87) hide show
  1. package/AGENTS.md +9 -5
  2. package/CHANGELOG.md +19 -0
  3. package/MIGRATING.md +147 -49
  4. package/README.md +42 -8
  5. package/agent/rules/basalt-app.md +41 -0
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +73 -16
  11. package/agent/skills/basalt-app/SKILL.md +4 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +181 -68
  16. package/configs/oxlint-plugin.test.ts +215 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +48 -14
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +328 -66
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +47 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +176 -31
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +24 -2
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +54 -13
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/index.ts +607 -128
  71. package/src/cli/init-sync.test.ts +2 -0
  72. package/src/cli/template-ledger.test.ts +1 -1
  73. package/src/cli/toolchain-wiring.test.ts +162 -0
  74. package/src/cli/waiver-audit.test.ts +131 -0
  75. package/src/guard/check-source.test.ts +441 -1
  76. package/src/guard/index.ts +430 -60
  77. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  78. package/src/router-tanstack/search-param-store.test.ts +135 -0
  79. package/src/router-tanstack/search-param-store.ts +90 -8
  80. package/src/shell/app-mobile-nav.tsx +1 -1
  81. package/src/shell/index.test.tsx +52 -0
  82. package/src/shell/index.tsx +64 -13
  83. package/src/tokens/index.ts +24 -1
  84. package/src/tokens/palette.ts +2 -2
  85. package/src/tokens/text-scale.test.ts +51 -0
  86. package/src/vite.test.ts +125 -6
  87. package/src/vite.ts +148 -20
package/dist/cli/index.js CHANGED
@@ -14,12 +14,31 @@ import {
14
14
  checkSource,
15
15
  DEFAULT_GUARD_CONFIG,
16
16
  GENERATED_HEADER_LINE,
17
+ guardKindRemedy,
17
18
  GUARD_RULES,
18
- TOKENS_ONLY_DISABLED_KINDS
19
+ guardWaiverHint,
20
+ TOKENS_ONLY_DISABLED_KINDS,
21
+ unmatchedExemptPatterns
19
22
  } from "../guard/index.js";
20
23
  import { evaluateGuardHook } from "../guard/guard-hook.js";
21
24
  import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from "../surfaces.js";
22
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
+ }
23
42
  const DEFAULT_ROOT = "src";
24
43
  const DEFAULT_ROOTS = [DEFAULT_ROOT];
25
44
  function resolveRoots(cfg) {
@@ -89,25 +108,6 @@ function walkSourceFiles(dir, out = []) {
89
108
  }
90
109
  return out;
91
110
  }
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
111
  function relativePosix(fromDir, toPath) {
112
112
  const rel = relative(fromDir, toPath).replace(/\\/g, "/");
113
113
  if (rel === "") return ".";
@@ -265,7 +265,7 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
265
265
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
266
266
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
267
267
  allowComment: "theme-allow",
268
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
268
+ exemptRules: resolveExemptRules(cfg),
269
269
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
270
270
  ...profile === "tokens-only" ? { profile: "tokens-only" } : {}
271
271
  };
@@ -286,6 +286,12 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
286
286
  }
287
287
  return 1;
288
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
+ }
289
295
  if (findings.length === 0) {
290
296
  console.log("\u2713 Theme guard: no off-palette colors.");
291
297
  return 0;
@@ -322,14 +328,9 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
322
328
  report(errors, `\u2716 Theme guard: ${errors.length} off-palette / off-identity violation(s)
323
329
  `);
324
330
  }
325
- const presentKinds = new Set(findings.map((f) => f.kind));
326
- const explanations = Object.keys(GUARD_KIND_EXPLANATIONS).filter((kind) => presentKinds.has(kind)).map((kind) => GUARD_KIND_EXPLANATIONS[kind]);
327
- const fixLine = [
328
- "Fix:",
329
- ...explanations,
330
- "Add a `theme-allow` comment for a deliberate exception."
331
- ];
332
- 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(" "));
333
334
  if (findings.some((f) => SERIES_MODULE_HINT_RE.test(f.relPath))) {
334
335
  console.error(
335
336
  'Hint: if a flagged file IS your palette/series source, exempt it via "basalt": { "exempt": ["<path>"] } in package.json.'
@@ -337,6 +338,130 @@ function checkTheme(invocationCwd = process.cwd(), flags = []) {
337
338
  }
338
339
  return errors.length > 0 ? 1 : 0;
339
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
+ }
340
465
  const BLOCK_BEGIN_PREFIX = "<!-- basalt:begin";
341
466
  const BLOCK_END = "<!-- basalt:end -->";
342
467
  const MANIFEST_PATH = ".basalt/manifest.json";
@@ -531,9 +656,24 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
531
656
  # The path is RESOLVED from where basalt-ui actually installed, not assumed at the
532
657
  # repo root \u2014 under an isolated linker it lives beside the package that depends on it.
533
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.
534
663
  extends:
535
664
  - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, "")}
536
- `
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
+ `)
537
677
  };
538
678
  const ci = {
539
679
  dest: ".github/workflows/check.yml",
@@ -690,6 +830,60 @@ function detectRoots(cwd) {
690
830
  }
691
831
  return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : [];
692
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
+ }
693
887
  function stripJsonc(text) {
694
888
  let out = "";
695
889
  let inString = false;
@@ -733,10 +927,47 @@ function parseJsonc(text) {
733
927
  return null;
734
928
  }
735
929
  }
736
- function extendsBasaltPreset(entries) {
737
- return Array.isArray(entries) && entries.some(
930
+ function basaltPresetEntry(entries) {
931
+ if (!Array.isArray(entries)) return null;
932
+ const match = entries.find(
738
933
  (entry) => typeof entry === "string" && entry.endsWith("basalt-ui/configs/oxlint.json")
739
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 };
740
971
  }
741
972
  function mergeOxlintExtends(cwd, presetPath) {
742
973
  const abs = resolve(cwd, ".oxlintrc.json");
@@ -744,7 +975,7 @@ function mergeOxlintExtends(cwd, presetPath) {
744
975
  if (raw === null) return "absent";
745
976
  const cfg = parseJsonc(raw);
746
977
  if (cfg === null) return "unreadable";
747
- if (extendsBasaltPreset(cfg["extends"])) return "already";
978
+ if (basaltPresetEntry(cfg["extends"]) !== null) return "already";
748
979
  if (stripJsonc(raw) !== raw) return "has-comments";
749
980
  const existing = Array.isArray(cfg["extends"]) ? cfg["extends"].filter((entry) => typeof entry === "string") : [];
750
981
  const merged = "extends" in cfg ? {} : { extends: [presetPath] };
@@ -769,21 +1000,7 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
769
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.`
770
1001
  );
771
1002
  }
772
- let seededRoots = null;
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
- }
1003
+ const rootsState = reconcileRoots(cwd, { write: true });
787
1004
  const ctx = renderContext(cwd);
788
1005
  const lintScript = `oxlint . && ${ctx.vars.BASALT_BIN} check-theme`;
789
1006
  const seededLintScript = patchPackageJson(cwd, (pkg) => {
@@ -847,16 +1064,9 @@ Kept (already present \u2014 basalt did not touch them):`);
847
1064
  };
848
1065
  console.log(`basalt-ui init: --merge-lint \u2014 ${mergeMessage[mergeLint]}`);
849
1066
  }
850
- if (seededRoots !== null) {
851
- console.log(
852
- `basalt-ui init: wrote "basalt": { "roots": ${JSON.stringify(seededRoots)} } 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.`
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
- }
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}`);
860
1070
  if (seededLintScript) {
861
1071
  console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`);
862
1072
  }
@@ -922,6 +1132,7 @@ function readOxlintPresetPlugins(pkgRoot) {
922
1132
  }
923
1133
  }
924
1134
  function sync(opts = {}, cwd = process.cwd()) {
1135
+ const rootsState = reconcileRoots(cwd, { write: opts.check !== true });
925
1136
  const ctx = renderContext(cwd);
926
1137
  const manifest = readManifest(cwd);
927
1138
  if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest);
@@ -977,6 +1188,23 @@ function sync(opts = {}, cwd = process.cwd()) {
977
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.`
978
1189
  );
979
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
+ }
980
1208
  if (opts.check) {
981
1209
  if (driftLines.length > 0) {
982
1210
  console.error("basalt-ui sync --check: locally-drifted managed files:");
@@ -1380,15 +1608,45 @@ basalt-ui doctor \u2014 ${cwd}
1380
1608
  skip(
1381
1609
  ".oxlintrc.json does not parse as JSON/JSONC \u2014 cannot tell whether it extends the preset"
1382
1610
  );
1383
- } else if (extendsBasaltPreset(parsed["extends"])) {
1384
- pass(".oxlintrc.json extends the shipped basalt-ui oxlint preset");
1385
1611
  } else {
1386
- fail(
1387
- `.oxlintrc.json does NOT extend the shipped preset \u2014 every basalt/* design rule is off and oxlint reports green without them. Add "extends": ["${shippedAssetPath(install, cwd, "configs/oxlint.json")}"], or re-run \`basalt-ui init --merge-lint\` to splice it in.`
1388
- );
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
+ }
1389
1629
  }
1390
1630
  }
1391
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
+ }
1392
1650
  const publicDir = resolve(cwd, "public");
1393
1651
  if (profile === "framework" && existsSync(publicDir)) {
1394
1652
  const iconFiles = [
@@ -1541,10 +1799,14 @@ function generatedHeader(version, command, flags) {
1541
1799
  }
1542
1800
  function normalizeColorFunctions(css) {
1543
1801
  return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn, args) => {
1544
- const parts = args.split(",").map((part) => part.trim());
1802
+ const parts = args.split(",").map((part) => normalizeCssNumber(part.trim()));
1545
1803
  return `${fn}(${parts.join(", ")})`;
1546
1804
  });
1547
1805
  }
1806
+ function normalizeCssNumber(arg) {
1807
+ if (!/^\d+\.\d+$/.test(arg)) return arg;
1808
+ return arg.replace(/\.?0+$/, "");
1809
+ }
1548
1810
  const SCHEME_CLASS_SENTINEL = "data-basalt-scheme-class";
1549
1811
  function emitGeneratedCss(content, flags, cwd, command) {
1550
1812
  const out = flagValue(flags, "--out");
@@ -1719,7 +1981,7 @@ async function guardHook(cwd = process.cwd()) {
1719
1981
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
1720
1982
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
1721
1983
  allowComment: "theme-allow",
1722
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
1984
+ exemptRules: resolveExemptRules(cfg),
1723
1985
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
1724
1986
  // Same detection check-theme uses: the hook must never block an edit over advice the app
1725
1987
  // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
@@ -1755,7 +2017,7 @@ async function guardHook(cwd = process.cwd()) {
1755
2017
  }
1756
2018
  return 0;
1757
2019
  }
1758
- const USAGE = "Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n check-theme | check-coverage | info [--json] | doctor [--tokens-only|--framework] |\n guard-hook | tokens:css | fonts:css | help>\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.";
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.";
1759
2021
  function run(argv, cwd = process.cwd()) {
1760
2022
  const [cmd, ...flags] = argv;
1761
2023
  if (cmd === "help" || cmd === "--help" || cmd === "-h" || flags.includes("--help") || flags.includes("-h")) {
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=waiver-audit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver-audit.test.d.ts","sourceRoot":"","sources":["../../src/cli/waiver-audit.test.ts"],"names":[],"mappings":""}
@@ -8,6 +8,26 @@ import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types.js'
8
8
  export type { Finding, GuardConfig, GuardKind, GuardSeverity };
9
9
  /** Shared default config — CLI and tests import this to avoid duplication. */
10
10
  export declare const DEFAULT_GUARD_CONFIG: GuardConfig;
11
+ /** One `exemptRules` entry that suppressed nothing, and why — see {@link unmatchedExemptPatterns}. */
12
+ export type UnmatchedExemptPattern = {
13
+ readonly kind: string;
14
+ readonly pattern: string;
15
+ readonly reason: 'unknown-kind' | 'no-match';
16
+ };
17
+ /**
18
+ * Every `exemptRules` entry that matched no scanned file, so a run can say so instead of passing.
19
+ *
20
+ * An exemption is a claim ("this rule does not apply here"), and a claim that resolves to nothing is
21
+ * not harmless — it reads as coverage in a config review while enforcing exactly as much as an empty
22
+ * object. rollhook's `["public/site.webmanifest"]` was correct, matched nothing under the
23
+ * segments-only matcher, and reported no diagnostic; the finding it was written for kept failing the
24
+ * build with the config sitting right there looking like the answer. A silent no-op exemption is the
25
+ * same false-green family as a guard that reports green while enforcing nothing.
26
+ *
27
+ * Called once per run by the CLI with every relative path it scanned. Kind keys are checked too: a
28
+ * typo'd kind name is an exemption for a rule that does not exist.
29
+ */
30
+ export declare function unmatchedExemptPatterns(cfg: GuardConfig, scannedRelPaths: readonly string[]): UnmatchedExemptPattern[];
11
31
  /**
12
32
  * The exact first line of every stylesheet the CLI emits — the contract between `basalt-ui
13
33
  * tokens:css` / `fonts:css` and this scanner, and the single source of truth for it (`src/cli`
@@ -178,13 +198,13 @@ export declare const GUARD_RULES: {
178
198
  readonly 'theme-allow-unscoped': {
179
199
  readonly kind: "theme-allow-unscoped";
180
200
  readonly pattern: RegExp;
181
- readonly message: "theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing.";
201
+ readonly message: "theme-allow without a usable rule id and a reason. Write `theme-allow <rule-id> — <why>`: the id scopes the exception to that one kind (a bare comment waives EVERY kind on the line, including ones added later), and the reason is what makes it reviewable in a diff. An id that names no rule is a typo, not a blanket waiver — it suppresses nothing. `theme-allow-file <rule-id> — <why>` declares the whole file, and must name its ids: a bare one waives nothing.";
182
202
  };
183
203
  readonly 'surface-shadow-override': {
184
204
  readonly kind: "surface-shadow-override";
185
205
  readonly pattern: RegExp;
186
206
  readonly enabled: (cfg: GuardConfig) => boolean;
187
- readonly message: "Token-composed boxShadow that REPLACES card depth — a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with it (`${VX.shadowCard}, <your ring>`) rather than instead of it.";
207
+ readonly message: "Token-composed boxShadow that REPLACES surface depth — a card whose shadow drops --vx-shadow-card is the one card in the app with no depth. Compose with the depth token for its tier (`${VX.shadowCard}, <your ring>`; a fixed/floating surface wants --vx-shadow-overlay) rather than instead of it.";
188
208
  };
189
209
  readonly 'css-raw-surface': {
190
210
  readonly kind: "css-raw-surface";
@@ -206,6 +226,31 @@ export declare const GUARD_RULES: {
206
226
  readonly message: "Raw font-size literal — route through VX.text.* (numbers) / --vx-text-* (CSS), or the Mantine fz token. Same rule the oxlint plugin enforces as basalt/no-raw-font-size; this is the half a check-theme-only CI can see.";
207
227
  };
208
228
  };
229
+ /**
230
+ * The one-line remedy for `kind`, as a report reads it: `raw-hex: Route color through VX.* / …`.
231
+ *
232
+ * The registry has always carried a remedy per kind; `check-theme`'s `Fix:` epilogue read a
233
+ * hand-duplicated subset instead, and the subset was missing every kind added at 1.20.0. Since all
234
+ * five of those ship `warn` under grace, the findings whose whole argument is "this looks correct
235
+ * and is not" arrived with no argument at all — just "add a `theme-allow`", which reads as advice to
236
+ * waive. Sourcing the epilogue from here retires the duplicate rather than extending it.
237
+ */
238
+ export declare function guardKindRemedy(kind: GuardKind): string;
239
+ /**
240
+ * How to waive a finding **in this file's syntax** — the closer a report prints under `Fix:`.
241
+ *
242
+ * `check-theme` printed one closer for every file class, and from 1.20.0 (when the scan reached
243
+ * `.webmanifest` / `.json`) it prescribed a `theme-allow` comment to files that cannot hold a
244
+ * comment. Two consumers read that, found no route, and fell back to a blanket `exemptRules` entry —
245
+ * the un-reviewable shape the release was spent retiring.
246
+ *
247
+ * A manifest gets the sharper answer first, because a hex there can never be *right*: a manifest
248
+ * cannot reference a CSS custom property, so the value is a hand-copy of a token that drifts the day
249
+ * the palette moves (which is exactly what both consumers found — two dead colours). The remedy is
250
+ * to stop hand-writing the file: `basaltAppPlugin` emits it from `SURFACE.bg`. The annotation is the
251
+ * fallback for a manifest basalt does not generate.
252
+ */
253
+ export declare function guardWaiverHint(relPath: string): string;
209
254
  /**
210
255
  * Scan ONE file's text for theme-guard violations. Pure: same (text, relPath, cfg) → same Finding[].
211
256
  * No FS, no walk, no console. The 3 dynamic regexes (forbiddenAccent, spacing, radius) are derived
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/guard/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE7E,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,CAAA;AAuW9D,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,EAAE,WAoBlC,CAAA;AAuVD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,gGACwD,CAAA;AA8F1F;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,SAAS,CAqB5D,CAAA;AA2NF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;gCA0BL,WAAW;;;;;;;gCAcX,WAAW;;;;;gCAMX,WAAW;;;;;;;gCAaX,WAAW;;;;;;;gCAQX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCAaX,WAAW;;;;;;gCAMX,WAAW;;;;;;;gCAaX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;;;;;;gCAgBX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;;;;CAWmB,CAAA;AAUjD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,EAAE,CAyVtF"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/guard/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAE7E,YAAY,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,aAAa,EAAE,CAAA;AAyb9D,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,EAAE,WAoBlC,CAAA;AAmJD,sGAAsG;AACtG,MAAM,MAAM,sBAAsB,GAAG;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,UAAU,CAAA;CAC7C,CAAA;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,WAAW,EAChB,eAAe,EAAE,SAAS,MAAM,EAAE,GACjC,sBAAsB,EAAE,CAc1B;AA8WD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,gGACwD,CAAA;AA8F1F;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,SAAS,CAqB5D,CAAA;AA2NF;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;;;;;;;;;gCA0BL,WAAW;;;;;;;gCAcX,WAAW;;;;;gCAMX,WAAW;;;;;;;gCAaX,WAAW;;;;;;;gCAQX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCAaX,WAAW;;;;;;gCAMX,WAAW;;;;;;;gCAaX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCASX,WAAW;;;;;;;gCAUX,WAAW;;;;;;;;;;;;gCAgBX,WAAW;;;;;;gCAOX,WAAW;;;;;;;gCASX,WAAW;;;;;;;;;CAWmB,CAAA;AAEjD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAEvD;AAKD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAgBvD;AAUD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,OAAO,EAAE,CA0XtF"}