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/src/cli/index.ts CHANGED
@@ -36,8 +36,11 @@ import {
36
36
  checkSource,
37
37
  DEFAULT_GUARD_CONFIG,
38
38
  GENERATED_HEADER_LINE,
39
+ guardKindRemedy,
39
40
  GUARD_RULES,
41
+ guardWaiverHint,
40
42
  TOKENS_ONLY_DISABLED_KINDS,
43
+ unmatchedExemptPatterns,
41
44
  } from '../guard'
42
45
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from '../guard'
43
46
  import { evaluateGuardHook } from '../guard/guard-hook'
@@ -56,7 +59,8 @@ export type BasaltConfig = {
56
59
  * off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
57
60
  * factory, leaving the color and typography kinds live. Default: `'mantine'`.
58
61
  *
59
- * Deliberately not inferred from a missing `@mantine/core`: silencing 16 rules is a decision, and
62
+ * Deliberately not inferred from a missing `@mantine/core`: silencing 17 rules (the live count is
63
+ * `TOKENS_ONLY_DISABLED_KINDS.size`, which the check-theme banner prints) is a decision, and
60
64
  * a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
61
65
  * without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
62
66
  */
@@ -183,15 +187,27 @@ export type BasaltConfig = {
183
187
  /**
184
188
  * Per-rule, per-path exemptions — complements whole-file `exempt` (which skips ALL rules for a
185
189
  * file) and the hardcoded per-kind `appliesTo` scoping (e.g. `raw-visx-axis` → chart files
186
- * only). Each value is a list of path-segment patterns matched against a finding's relative
187
- * path: a pattern matches when the path split on `/` includes it as a WHOLE segment (`'agent'`
188
- * matches `src/agent/x.tsx` but not `src/agenting.ts`; a trailing `/` is stripped, so `'agent'`
189
- * and `'agent/'` are equivalent). Default: `{}` (no exemptions).
190
+ * only). Each value is a list of patterns matched against a finding's relative path. A pattern
191
+ * may be a whole path segment (`'agent'` matches `src/agent/x.tsx`, not `src/agenting.ts`), a
192
+ * relative path (`'public/site.webmanifest'`), a directory prefix, or a glob — `*` stops at `/`,
193
+ * `**` does not, and a slash-free glob also matches the basename, so `'*.module.css'` works.
194
+ * A trailing `/` is stripped. An entry that suppresses nothing is reported, and
195
+ * `check-theme --audit-allows` exits 1 on it. Default: `{}` (no exemptions).
196
+ *
197
+ * Two forms per kind. The bare array is paths only. The object form adds the REASON, which is
198
+ * the half a `theme-allow` carries and this key could not: JSON has no comments, so a
199
+ * `.webmanifest` / `.json` finding's only escape is this key, and it was un-reviewable by
200
+ * construction — the rationale ended up in a CLAUDE.md paragraph nobody reads next to the diff.
201
+ * `check-theme --audit-allows` prints the reason (or names its absence) beside what the entry
202
+ * still suppresses. The array form stays supported and unchanged; nothing here is required.
190
203
  *
191
204
  * @example
192
205
  * { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
206
+ * @example
207
+ * { exemptRules: { 'raw-hex': { paths: ['site.webmanifest'],
208
+ * reason: 'a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var' } } }
193
209
  */
194
- exemptRules?: Partial<Record<GuardKind, string[]>>
210
+ exemptRules?: Partial<Record<GuardKind, ExemptRuleEntry>>
195
211
  /**
196
212
  * Declares an intentional `ai` package major-version skew across workspace packages, exempting
197
213
  * `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
@@ -213,6 +229,38 @@ export type BasaltConfig = {
213
229
  aiMajorSkewReason?: string
214
230
  }
215
231
 
232
+ /**
233
+ * One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
234
+ * it exists. See `BasaltConfig.exemptRules`.
235
+ */
236
+ type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
237
+
238
+ /** The paths half of an exemption entry, whichever form it was written in. */
239
+ function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
240
+ if (entry === undefined) return []
241
+ return Array.isArray(entry) ? entry : entry.paths
242
+ }
243
+
244
+ /** The recorded reason for an exemption entry, or null for the bare-array form. */
245
+ function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
246
+ if (entry === undefined || Array.isArray(entry)) return null
247
+ return entry.reason.trim().length > 0 ? entry.reason.trim() : null
248
+ }
249
+
250
+ /**
251
+ * The guard-shaped `exemptRules` — paths only. The reason the object form carries never reaches
252
+ * the guard: it is accountability for a human reading a diff and for `--audit-allows`, and giving
253
+ * the scan a second shape to understand would only be a way for the two to disagree.
254
+ */
255
+ function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
256
+ if (cfg.exemptRules === undefined) return DEFAULT_GUARD_CONFIG.exemptRules
257
+ const out: Partial<Record<GuardKind, string[]>> = {}
258
+ for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
259
+ out[kind as GuardKind] = exemptRulePaths(entry)
260
+ }
261
+ return out
262
+ }
263
+
216
264
  const DEFAULT_ROOT = 'src'
217
265
  const DEFAULT_ROOTS = [DEFAULT_ROOT]
218
266
 
@@ -350,57 +398,6 @@ function walkSourceFiles(dir: string, out: string[] = []): string[] {
350
398
  return out
351
399
  }
352
400
 
353
- /**
354
- * One-line fix guidance per guard kind, printed in the check-theme "Fix:" epilogue — only for the
355
- * kinds actually present in a given run's findings, so the epilogue never dumps all 16+ rules on a
356
- * single-kind violation. Kinds without an entry here (e.g. `localstorage-theme`,
357
- * `chart-missing-aria-label`) fall back to the generic `theme-allow` closer only.
358
- */
359
- const GUARD_KIND_EXPLANATIONS: Partial<Record<GuardKind, string>> = {
360
- 'raw-hex':
361
- 'raw-hex: route color through VX.* / the Mantine theme instead of a hardcoded hex literal.',
362
- 'raw-color-fn':
363
- 'raw-color-fn: route color through VX.* / the Mantine theme instead of a hardcoded rgb()/hsl() literal.',
364
- 'off-identity-accent':
365
- 'off-identity-accent: use blue/gray or a status hue (red/green/orange/yellow) instead.',
366
- 'raw-spacing': 'raw-spacing: use the spacing scale token instead of a raw literal.',
367
- 'raw-radius': 'raw-radius: use the radius scale token instead of a raw literal.',
368
- 'raw-surface':
369
- 'raw-surface: use `VX.surface.*` / a `radius` token / `var(--vx-radius-card)` instead of an ' +
370
- 'inline border/radius/shadow.',
371
- 'card-with-border':
372
- 'card-with-border: `withBorder` on a Card/Paper double-draws the edge — card depth is ' +
373
- '`--vx-shadow-card`, which already bakes a 1px ring into the shadow. Drop the prop.',
374
- 'off-system-surface-var':
375
- 'off-system-surface-var: raw Mantine ramp step bypasses the basalt surface tokens — use ' +
376
- 'VX.surface.* / --vx-surface-* (border/panel/subtle/bg) instead.',
377
- 'raw-html-layout':
378
- 'raw-html-layout: raw HTML element with inline layout/surface styling — use a Mantine layout ' +
379
- 'primitive (Box/Flex/Grid/Stack/Group).',
380
- 'inline-spacing':
381
- 'inline-spacing: inline spacing literal — use the Mantine spacing prop/token (p/m/gap with xs..xl).',
382
- 'inline-display':
383
- 'inline-display: use <Flex>/<Grid>/<Group> instead of an inline display:flex/grid.',
384
- 'raw-visx-axis':
385
- 'raw-visx-axis: raw <AxisLeft>/<AxisBottom>/<AxisRight> in a chart file — use ' +
386
- 'AxisLeftNumeric / AxisBottomDate / AxisRightNumeric from the charts primitives.',
387
- 'raw-motion-value':
388
- 'raw-motion-value: hardcoded duration/spring/ease in `transition={{...}}` — use ' +
389
- 'MOTION_DURATION / MOTION_SPRING / MOTION_EASE_STANDARD instead.',
390
- 'unframed-chart':
391
- 'unframed-chart: hand-rolled ChartLegend built from an inline array literal — pass a derived ' +
392
- 'legend (deriveLegend(series)), or compose ChartFrame, which derives it for you.',
393
- 'raw-form-control':
394
- 'raw-form-control: raw <input>/<select>/<textarea> bypasses the entire theme — use ' +
395
- 'TextInput / NumberInput / Select / Textarea from @mantine/core.',
396
- 'sub-16-input-font':
397
- 'sub-16-input-font: fontSize below 16 on a form control is dead code against the `!important` ' +
398
- 'iOS floor in styles.css — drop the override or be honest about 16px.',
399
- 'raw-font-family':
400
- 'raw-font-family: route the font stack through createBasaltTheme(overrides, { fonts }) / the ' +
401
- '--basalt-font-sans|head|mono vars instead of a hardcoded fontFamily literal.',
402
- }
403
-
404
401
  // ──────────────────────────────────────────────────────────────────────────────
405
402
  // Resolution — where basalt is installed, and where the consumer's basalt config lives
406
403
  // ──────────────────────────────────────────────────────────────────────────────
@@ -702,7 +699,7 @@ export function checkTheme(
702
699
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
703
700
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
704
701
  allowComment: 'theme-allow',
705
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
702
+ exemptRules: resolveExemptRules(cfg),
706
703
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
707
704
  ...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
708
705
  }
@@ -732,6 +729,25 @@ export function checkTheme(
732
729
  return 1
733
730
  }
734
731
 
732
+ // The audit is a REPORT over the same scan, not a second scan with different rules — it runs
733
+ // after the 0-files gate so "every waiver is dead" can never mean "nothing was read".
734
+ if (flags.includes('--audit-allows')) return auditAllows(cwd, cfg, guardCfg, scanned)
735
+
736
+ // An exemption that matched nothing is reported, never silently honoured. A WARNING rather than a
737
+ // failure: the entry enforced exactly nothing before this line existed, so failing on it would
738
+ // break a green build at upgrade time over config that was already inert — `--audit-allows` is
739
+ // the lane that exits non-zero on a dead waiver, for a consumer who opts into that gate.
740
+ for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
741
+ console.error(
742
+ `⚠ basalt-ui check-theme: "basalt.exemptRules" entry ${unmatched.kind}: ` +
743
+ `"${unmatched.pattern}" ` +
744
+ (unmatched.reason === 'unknown-kind'
745
+ ? 'names no guard kind — it exempts nothing at all (a typo, or a kind that was renamed).'
746
+ : 'matched none of the scanned files — it suppresses nothing, and reads as coverage in a ' +
747
+ 'config review while enforcing as much as an empty object.'),
748
+ )
749
+ }
750
+
735
751
  if (findings.length === 0) {
736
752
  console.log('✓ Theme guard: no off-palette colors.')
737
753
  return 0
@@ -774,16 +790,14 @@ export function checkTheme(
774
790
  report(errors, `✖ Theme guard: ${errors.length} off-palette / off-identity violation(s)\n`)
775
791
  }
776
792
 
777
- const presentKinds = new Set(findings.map((f) => f.kind))
778
- const explanations = (Object.keys(GUARD_KIND_EXPLANATIONS) as GuardKind[])
779
- .filter((kind) => presentKinds.has(kind))
780
- .map((kind) => GUARD_KIND_EXPLANATIONS[kind])
781
- const fixLine = [
782
- 'Fix:',
783
- ...explanations,
784
- 'Add a `theme-allow` comment for a deliberate exception.',
785
- ]
786
- console.error(fixLine.join(' '))
793
+ // Remedies come from the guard's own rule registry, never from a copy kept here: the local table
794
+ // this replaced had no entry for any of the five kinds 1.20.0 added, so exactly the new findings
795
+ // — the ones whose whole argument is "this looks correct and is not" — printed no argument.
796
+ const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted()
797
+ // And the closer is per FILE CLASS: prescribing a `theme-allow` comment to a .webmanifest (which
798
+ // has no comment syntax) is what pushed two consumers into a blanket exemption instead.
799
+ const waiverHints = [...new Set(findings.map((f) => guardWaiverHint(f.relPath)))].toSorted()
800
+ console.error(['Fix:', ...presentKinds.map(guardKindRemedy), ...waiverHints].join(' '))
787
801
 
788
802
  // A violation in a file that looks like the palette source itself is likely intentional — point
789
803
  // at the exempt escape hatch instead of leaving the author to search for it.
@@ -798,6 +812,201 @@ export function checkTheme(
798
812
  return errors.length > 0 ? 1 : 0
799
813
  }
800
814
 
815
+ // ── check-theme --audit-allows — every waiver, and whether it still suppresses anything ─────────
816
+
817
+ /** The token an audit probe substitutes for `theme-allow`. Contains no substring of it, on purpose. */
818
+ const AUDIT_NEUTRALIZED_TOKEN = 'basalt-audit-neutralized'
819
+
820
+ /** `relPath:line:kind` — a finding's identity for set arithmetic across two runs of the same file. */
821
+ function findingKey(f: Finding): string {
822
+ return `${f.relPath}:${f.line}:${f.kind}`
823
+ }
824
+
825
+ /**
826
+ * Everything allowed between a comment opener (or the start of the line) and the token, for it to
827
+ * be an ANNOTATION rather than prose mentioning one.
828
+ *
829
+ * Mirrors the guard's `ANNOTATION_PREFIX`, which is module-private. It decides only which lines
830
+ * this REPORT lists — what each one suppresses is answered by re-running `checkSource`, so the two
831
+ * cannot disagree about enforcement, only about whether a doc sentence gets a line in the audit.
832
+ * Worth an export from the guard if it ever gains a third caller.
833
+ */
834
+ const AUDIT_ANNOTATION_PREFIX = /(?:^|\/\/|\/\*|<!--|^\s*\*)\s*$/
835
+
836
+ /**
837
+ * The rule ids an annotation NAMES, as written. Classification only — what it actually waives is
838
+ * decided by re-running the guard.
839
+ *
840
+ * It exists for one distinction the behavioural probe cannot make: an annotation scoped to an
841
+ * oxlint PLUGIN rule (`hand-rolled-plot`, `raw-scroll-container`, …) suppresses nothing `checkSource`
842
+ * can see, because those rules live in the plugin. Calling it dead would tell someone to delete a
843
+ * live waiver — the exact failure this command exists to prevent, pointed the other way.
844
+ */
845
+ function annotationRuleIds(rest: string): string[] {
846
+ const head = (rest.replace(/^-file\b/, '').split(/—|–|:|\s-\s/)[0] ?? '').trim()
847
+ return head
848
+ .split(',')
849
+ .map((word) => word.trim().replace(/^basalt\//, ''))
850
+ .filter((word) => /^[a-z][a-z0-9-]*$/.test(word))
851
+ }
852
+
853
+ /** True when the token on this line opens an annotation — comment form, or the JSON member form. */
854
+ function isAllowAnnotationLine(line: string, token: string): boolean {
855
+ const at = line.indexOf(token)
856
+ if (at === -1) return false
857
+ if (line.includes(`"basalt:${token}`)) return true
858
+ return AUDIT_ANNOTATION_PREFIX.test(line.slice(0, at))
859
+ }
860
+
861
+ /**
862
+ * `check-theme --audit-allows` — list every active waiver and prove, per waiver, whether it still
863
+ * suppresses anything.
864
+ *
865
+ * The accountability release shipped `theme-allow-unscoped`, which reports a waiver that names no
866
+ * rule — but a waiver that is perfectly well written and covers a finding that no longer exists is
867
+ * invisible to it, and to everything else. Two consumers found five of those by hand, by editing
868
+ * the token out and re-running the guard. This is that method, automated: for each annotation the
869
+ * file is re-checked with THAT occurrence neutralized, and the findings that appear are exactly
870
+ * what it suppresses. Nothing is inferred from the annotation's text, so the audit cannot disagree
871
+ * with the scan — it IS the scan, run twice.
872
+ *
873
+ * `basalt.exemptRules` entries are audited the same way, one pattern at a time. That also answers
874
+ * the question the key could never answer for itself: a pattern that matches no scanned file (a
875
+ * real relative path, say, where the matcher takes whole path SEGMENTS) suppresses nothing and is
876
+ * reported as dead rather than passing silently.
877
+ *
878
+ * Exit 1 when anything is dead, so it can be wired into CI as a gate. A waiver nobody re-checks is
879
+ * how a rule quietly stops applying to the file that needed the exception most.
880
+ */
881
+ function auditAllows(
882
+ cwd: string,
883
+ cfg: BasaltConfig,
884
+ guardCfg: GuardConfig,
885
+ scanned: string[],
886
+ ): number {
887
+ const token = guardCfg.allowComment
888
+ const lines: string[] = [`\nbasalt-ui check-theme --audit-allows — ${cwd}\n`]
889
+ let dead = 0
890
+ let live = 0
891
+ let outOfReach = 0
892
+ let unaccountable = 0
893
+
894
+ // ── theme-allow annotations ─────────────────────────────────────────────────
895
+ const waiverLines: string[] = []
896
+ const sources = new Map<string, string>()
897
+ for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), 'utf8'))
898
+
899
+ for (const rel of scanned) {
900
+ const text = sources.get(rel) ?? ''
901
+ if (!text.includes(token)) continue
902
+ const baseline = checkSource(text, rel, guardCfg)
903
+ const baselineKeys = new Set(
904
+ baseline.filter((f) => f.kind !== 'theme-allow-unscoped').map(findingKey),
905
+ )
906
+ const unscopedAt = new Map(
907
+ baseline.filter((f) => f.kind === 'theme-allow-unscoped').map((f) => [f.line, f.token]),
908
+ )
909
+ const fileLines = text.split('\n')
910
+ for (const [index, line] of fileLines.entries()) {
911
+ if (!isAllowAnnotationLine(line ?? '', token)) continue
912
+ const probe = [...fileLines]
913
+ probe[index] = (line ?? '').replaceAll(token, AUDIT_NEUTRALIZED_TOKEN)
914
+ const revealed = checkSource(probe.join('\n'), rel, guardCfg)
915
+ .filter((f) => f.kind !== 'theme-allow-unscoped' && !baselineKeys.has(findingKey(f)))
916
+ .map((f) => `${f.kind}@${f.line}`)
917
+ const note = unscopedAt.get(index + 1)
918
+ if (note !== undefined) unaccountable++
919
+ const site = ` ${rel}:${index + 1}`.padEnd(52)
920
+ const suffix = note === undefined ? '' : ` [${note}]`
921
+ const ids = annotationRuleIds((line ?? '').slice((line ?? '').indexOf(token) + token.length))
922
+ if (revealed.length > 0) {
923
+ live++
924
+ waiverLines.push(`${site} suppresses ${[...new Set(revealed)].join(', ')}${suffix}`)
925
+ } else if (ids.length > 0 && !ids.some((id) => Object.hasOwn(GUARD_RULES, id))) {
926
+ outOfReach++
927
+ waiverLines.push(
928
+ `${site} scoped to ${ids.join(', ')} — not a check-theme kind, so this audit cannot ` +
929
+ `judge it (an oxlint plugin rule, or a typo theme-allow-unscoped would report)${suffix}`,
930
+ )
931
+ } else {
932
+ dead++
933
+ waiverLines.push(`${site} SUPPRESSES NOTHING — dead, delete it${suffix}`)
934
+ }
935
+ }
936
+ }
937
+
938
+ lines.push(`${token} annotations (${waiverLines.length}):`)
939
+ lines.push(...(waiverLines.length === 0 ? [' (none)'] : waiverLines))
940
+
941
+ // ── basalt.exemptRules entries ──────────────────────────────────────────────
942
+ const exemptLines: string[] = []
943
+ let exemptCount = 0
944
+ const declared = Object.entries(cfg.exemptRules ?? {}) as [GuardKind, ExemptRuleEntry][]
945
+ if (declared.length > 0) {
946
+ const baselineKeys = new Set<string>()
947
+ for (const rel of scanned) {
948
+ for (const f of checkSource(sources.get(rel) ?? '', rel, guardCfg))
949
+ baselineKeys.add(findingKey(f))
950
+ }
951
+ for (const [kind, entry] of declared) {
952
+ const reason = exemptRuleReason(entry)
953
+ for (const pattern of exemptRulePaths(entry)) {
954
+ exemptCount++
955
+ const probeCfg: GuardConfig = {
956
+ ...guardCfg,
957
+ exemptRules: {
958
+ ...guardCfg.exemptRules,
959
+ [kind]: exemptRulePaths(entry).filter((p) => p !== pattern),
960
+ },
961
+ }
962
+ const revealed = new Set<string>()
963
+ for (const rel of scanned) {
964
+ for (const f of checkSource(sources.get(rel) ?? '', rel, probeCfg)) {
965
+ if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath)
966
+ }
967
+ }
968
+ const site = ` ${kind}: "${pattern}"`.padEnd(52)
969
+ const why = reason === null ? ' [no reason recorded]' : ` — ${reason}`
970
+ if (revealed.size === 0) {
971
+ dead++
972
+ const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
973
+ (u) => u.kind === kind && u.pattern === pattern,
974
+ )
975
+ exemptLines.push(
976
+ `${site} SUPPRESSES NOTHING — ` +
977
+ (unmatched === undefined
978
+ ? 'the files it matches have no such finding; delete it'
979
+ : unmatched.reason === 'unknown-kind'
980
+ ? 'it names no guard kind (a typo, or a renamed kind)'
981
+ : 'it matches no scanned file at all') +
982
+ why,
983
+ )
984
+ } else {
985
+ live++
986
+ exemptLines.push(
987
+ `${site} suppresses findings in ${[...revealed].toSorted().join(', ')}${why}`,
988
+ )
989
+ }
990
+ }
991
+ }
992
+ }
993
+ lines.push(`\nbasalt.exemptRules entries (${exemptCount}):`)
994
+ lines.push(...(exemptLines.length === 0 ? [' (none)'] : exemptLines))
995
+
996
+ lines.push(
997
+ `\n${live} live, ${dead} dead, ${outOfReach} outside check-theme's reach, ${unaccountable} ` +
998
+ 'unaccountable (reported as theme-allow-unscoped by a normal run).',
999
+ )
1000
+ if (dead > 0) {
1001
+ lines.push(
1002
+ 'A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently ' +
1003
+ 'cover the next real finding on that line.',
1004
+ )
1005
+ }
1006
+ console.log(lines.join('\n'))
1007
+ return dead > 0 ? 1 : 0
1008
+ }
1009
+
801
1010
  // ──────────────────────────────────────────────────────────────────────────────
802
1011
  // init / sync — managed-files engine
803
1012
  // ──────────────────────────────────────────────────────────────────────────────
@@ -1189,8 +1398,24 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
1189
1398
  '# The path is RESOLVED from where basalt-ui actually installed, not assumed at the\n' +
1190
1399
  '# repo root — under an isolated linker it lives beside the package that depends on it.\n' +
1191
1400
  '# Run `lefthook install` once: the file is inert until the git hooks are written.\n' +
1401
+ '#\n' +
1402
+ '# NOTE: an extends target WINS on a colliding key — a `run:`/`glob:` you write under one of\n' +
1403
+ "# the preset's command names is discarded silently. `env:`, `exclude:`, `skip:` and your own\n" +
1404
+ '# command names merge. See the preset itself for the sanctioned override seams.\n' +
1192
1405
  'extends:\n' +
1193
- ` - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, '')}\n`,
1406
+ ` - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, '')}\n` +
1407
+ // The one seam that has to be RENDERED rather than documented: the preset is read in place
1408
+ // out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
1409
+ // default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
1410
+ // copy; this pins it to the local one whenever basalt actually resolved.
1411
+ (ctx.vars.BASALT_BIN.startsWith('bunx ')
1412
+ ? ''
1413
+ : '\n# The theme guard runs the LOCAL bin, not a copy `bunx` fetches from npm.\n' +
1414
+ 'pre-commit:\n' +
1415
+ ' commands:\n' +
1416
+ ' check-theme:\n' +
1417
+ ' env:\n' +
1418
+ ` BASALT_BIN: ${ctx.vars.BASALT_BIN}\n`),
1194
1419
  }
1195
1420
 
1196
1421
  // CI is inherently repo-shaped (a monorepo's check.yml needs its own scripts), and GitHub Actions
@@ -1537,6 +1762,96 @@ function detectRoots(cwd: string): string[] {
1537
1762
  return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : []
1538
1763
  }
1539
1764
 
1765
+ /** What `reconcileRoots` found — the one roots decision `init` and `sync` both report from. */
1766
+ type RootsState =
1767
+ /** The key was absent and has just been written. */
1768
+ | { readonly kind: 'wrote'; readonly roots: string[] }
1769
+ /** The key is absent and nothing plausible could be inferred. */
1770
+ | { readonly kind: 'undetectable' }
1771
+ /** The key is already declared. `unscanned` names inferable roots it does not cover. */
1772
+ | { readonly kind: 'declared'; readonly declared: string[]; readonly unscanned: string[] }
1773
+
1774
+ /** True when `root` is `detected` itself or an ancestor of it. */
1775
+ function rootCovers(root: string, detected: string): boolean {
1776
+ const r = root.replace(/\/+$/, '')
1777
+ return detected === r || detected.startsWith(`${r}/`)
1778
+ }
1779
+
1780
+ /**
1781
+ * Read — and when absent and `write` is set, seed — `basalt.roots`.
1782
+ *
1783
+ * Shared by `init` and `sync` because the key going missing is the SAME false green in both: the
1784
+ * built-in `src` default happens to resolve from a package cwd, so `guard-scan` passes, `check-theme`
1785
+ * scans something, and the repo is silently guarding a subset of itself. `init` wrote it and `sync`
1786
+ * did not — which meant the fix reached new scaffolds only, while every existing consumer (the ones
1787
+ * who by definition upgrade rather than scaffold) stayed on the undeclared default.
1788
+ *
1789
+ * A DECLARED value is never overwritten, not even when the layout disagrees with it. `roots` is a
1790
+ * subset by construction in a monorepo — a React-free package deliberately left out is the normal
1791
+ * case, not drift — so a disagreement is reported as one line naming what is not covered, and the
1792
+ * consumer decides. Silently widening someone's deliberate scope would turn an upgrade into an
1793
+ * unplanned lint debt.
1794
+ */
1795
+ function reconcileRoots(cwd: string, opts: { write: boolean }): RootsState {
1796
+ const declared = readBasaltConfig(cwd).roots
1797
+ const detected = detectRoots(cwd)
1798
+ if (declared !== undefined) {
1799
+ const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)))
1800
+ return { kind: 'declared', declared, unscanned }
1801
+ }
1802
+ if (detected.length === 0) return { kind: 'undetectable' }
1803
+ if (!opts.write) return { kind: 'declared', declared: [], unscanned: detected }
1804
+ const wrote = patchPackageJson(cwd, (pkg) => {
1805
+ const basalt = (pkg['basalt'] ?? {}) as Record<string, unknown>
1806
+ if (basalt['roots'] !== undefined) return false
1807
+ pkg['basalt'] = { ...basalt, roots: detected }
1808
+ return true
1809
+ })
1810
+ return wrote ? { kind: 'wrote', roots: detected } : { kind: 'undetectable' }
1811
+ }
1812
+
1813
+ /** The lines `init`/`sync` print for a roots reconciliation. Empty when there is nothing to say. */
1814
+ function rootsNotices(state: RootsState): { log: string[]; error: string[] } {
1815
+ if (state.kind === 'wrote') {
1816
+ return {
1817
+ log: [
1818
+ `wrote "basalt": { "roots": ${JSON.stringify(state.roots)} } to package.json — ` +
1819
+ 'check-theme, the CI oxfmt globs and the default scan exemption all derive from it. ' +
1820
+ 'Correct it if your sources live elsewhere.',
1821
+ ],
1822
+ error: [],
1823
+ }
1824
+ }
1825
+ if (state.kind === 'undetectable') {
1826
+ return {
1827
+ log: [],
1828
+ error: [
1829
+ 'could not infer "basalt.roots" — no src/ here and no workspace package with one. Set ' +
1830
+ '"basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with ' +
1831
+ '"0 files scanned".',
1832
+ ],
1833
+ }
1834
+ }
1835
+ if (state.unscanned.length === 0) return { log: [], error: [] }
1836
+ if (state.declared.length === 0) {
1837
+ return {
1838
+ log: [],
1839
+ error: [
1840
+ `"basalt.roots" is not set — the guard is running on the built-in default ("${DEFAULT_ROOT}"). ` +
1841
+ `This layout suggests ${JSON.stringify(state.unscanned)}; declare it in package.json.`,
1842
+ ],
1843
+ }
1844
+ }
1845
+ return {
1846
+ log: [
1847
+ `keeping your "basalt.roots" (${JSON.stringify(state.declared)}) — never overwritten. The ` +
1848
+ `layout also has ${JSON.stringify(state.unscanned)}, which nothing scans; add it if it ` +
1849
+ 'should be guarded, or leave it if the exclusion is deliberate.',
1850
+ ],
1851
+ error: [],
1852
+ }
1853
+ }
1854
+
1540
1855
  /**
1541
1856
  * Strip JSONC comments (and trailing commas) so `.oxlintrc.json` can be READ. oxlint accepts JSONC
1542
1857
  * and real consumer configs use it — a plain `JSON.parse` there reported "not valid JSON" on a
@@ -1595,14 +1910,98 @@ function parseJsonc(text: string): Record<string, unknown> | null {
1595
1910
  /** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
1596
1911
  type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
1597
1912
 
1598
- /** True when an `extends` entry points at the shipped preset, wherever basalt resolved from. */
1599
- function extendsBasaltPreset(entries: unknown): boolean {
1600
- return (
1601
- Array.isArray(entries) &&
1602
- entries.some(
1603
- (entry) => typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
1604
- )
1913
+ /**
1914
+ * The `extends` entry pointing at the shipped oxlint preset, or null when none does.
1915
+ *
1916
+ * Returns the ENTRY rather than a boolean because the string alone proves nothing: a consumer that
1917
+ * upgraded (or moved to an isolated linker) keeps a perfectly well-shaped
1918
+ * `./node_modules/basalt-ui/configs/oxlint.json` that resolves to nothing, and oxlint then refuses
1919
+ * to start with `NotFound` while a check built to prove the framework is ON reports green. The
1920
+ * caller resolves this against the config's own directory — see `extendsSeam`.
1921
+ */
1922
+ function basaltPresetEntry(entries: unknown): string | null {
1923
+ if (!Array.isArray(entries)) return null
1924
+ const match = entries.find(
1925
+ (entry): entry is string =>
1926
+ typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
1605
1927
  )
1928
+ return match ?? null
1929
+ }
1930
+
1931
+ // ── The two `extends` seams init writes once and nothing ever revisits ───────────────────────────
1932
+ //
1933
+ // `.oxlintrc.json` and `lefthook.yml` are consumer-owned after the first write: `sync` never
1934
+ // touches either. So 1.20.0's path-resolution fix reached NEW scaffolds only — every existing
1935
+ // monorepo consumer kept a root-relative `extends` that resolves to nothing. oxlint at least
1936
+ // hard-errors (`NotFound`). lefthook does not: a missing extends target merges to ZERO commands and
1937
+ // exits 0, so `lefthook dump` is clean and the repo silently has no pre-commit gate at all.
1938
+ //
1939
+ // Both seams therefore need a checker that resolves the path rather than pattern-matching the
1940
+ // string, and both `doctor` (the gate) and `sync` (the command an upgrade actually runs) report
1941
+ // through it.
1942
+
1943
+ const LEFTHOOK_CONFIG_NAMES = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml']
1944
+
1945
+ /** The lefthook config present at `dir` (repo-root-relative name), or null when there is none. */
1946
+ function findLefthookConfig(dir: string): string | null {
1947
+ return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null
1948
+ }
1949
+
1950
+ /**
1951
+ * The `extends:` entries of a lefthook YAML — the block-sequence form init seeds and the inline
1952
+ * array form a consumer may write instead.
1953
+ *
1954
+ * A three-line reader rather than a YAML dependency: this parses ONE top-level key whose value is a
1955
+ * list of plain scalars, and a wrong answer costs a diagnostic, never a write.
1956
+ */
1957
+ function yamlUnquote(raw: string): string {
1958
+ return raw.trim().replace(/^['"]|['"]$/g, '')
1959
+ }
1960
+
1961
+ function yamlExtendsEntries(text: string): string[] {
1962
+ const lines = text.split('\n')
1963
+ const out: string[] = []
1964
+ for (const [index, line] of lines.entries()) {
1965
+ const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? '')
1966
+ if (inline !== null) {
1967
+ out.push(...(inline[1] ?? '').split(',').map(yamlUnquote).filter(Boolean))
1968
+ continue
1969
+ }
1970
+ if (!/^extends:\s*(#.*)?$/.test(line ?? '')) continue
1971
+ for (const next of lines.slice(index + 1)) {
1972
+ if (/^\s*(#.*)?$/.test(next ?? '')) continue
1973
+ const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? '')
1974
+ if (item === null) break
1975
+ out.push(yamlUnquote(item[1] ?? ''))
1976
+ }
1977
+ }
1978
+ return out
1979
+ }
1980
+
1981
+ /** The state of one shipped-preset `extends` seam in a consumer-owned config. */
1982
+ type ExtendsSeam =
1983
+ /** No such config file — nothing is wired, and that is a legitimate consumer choice. */
1984
+ | { readonly kind: 'no-file' }
1985
+ /** The file exists but names no basalt preset. */
1986
+ | { readonly kind: 'unwired'; readonly file: string }
1987
+ /** It names one, and the path resolves to nothing — the silent-gate case. */
1988
+ | { readonly kind: 'broken'; readonly file: string; readonly entry: string }
1989
+ | { readonly kind: 'ok'; readonly file: string; readonly entry: string }
1990
+
1991
+ /**
1992
+ * Resolve a lefthook config's basalt `extends` entry against the directory the config lives in —
1993
+ * which is how lefthook itself resolves it.
1994
+ */
1995
+ function inspectLefthookSeam(repoRoot: string): ExtendsSeam {
1996
+ const file = findLefthookConfig(repoRoot)
1997
+ if (file === null) return { kind: 'no-file' }
1998
+ const raw = readIfExists(resolve(repoRoot, file))
1999
+ if (raw === null) return { kind: 'no-file' }
2000
+ const entry = yamlExtendsEntries(raw).find((e) => e.endsWith('basalt-ui/configs/lefthook.yml'))
2001
+ if (entry === undefined) return { kind: 'unwired', file }
2002
+ return existsSync(resolve(repoRoot, entry))
2003
+ ? { kind: 'ok', file, entry }
2004
+ : { kind: 'broken', file, entry }
1606
2005
  }
1607
2006
 
1608
2007
  /**
@@ -1617,7 +2016,7 @@ function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
1617
2016
  if (raw === null) return 'absent'
1618
2017
  const cfg = parseJsonc(raw)
1619
2018
  if (cfg === null) return 'unreadable'
1620
- if (extendsBasaltPreset(cfg['extends'])) return 'already'
2019
+ if (basaltPresetEntry(cfg['extends']) !== null) return 'already'
1621
2020
  // Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
1622
2021
  // which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
1623
2022
  if (stripJsonc(raw) !== raw) return 'has-comments'
@@ -1669,23 +2068,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
1669
2068
  )
1670
2069
  }
1671
2070
 
1672
- let seededRoots: string[] | null = null
1673
- let rootsUndetectable = false
1674
- if (readBasaltConfig(cwd).roots === undefined) {
1675
- const detected = detectRoots(cwd)
1676
- if (detected.length === 0) {
1677
- rootsUndetectable = true
1678
- } else if (
1679
- patchPackageJson(cwd, (pkg) => {
1680
- const basalt = (pkg['basalt'] ?? {}) as Record<string, unknown>
1681
- if (basalt['roots'] !== undefined) return false
1682
- pkg['basalt'] = { ...basalt, roots: detected }
1683
- return true
1684
- })
1685
- ) {
1686
- seededRoots = detected
1687
- }
1688
- }
2071
+ const rootsState = reconcileRoots(cwd, { write: true })
1689
2072
 
1690
2073
  // Built AFTER the roots patch — every roots-derived template variable reads the key just written.
1691
2074
  const ctx = renderContext(cwd)
@@ -1774,20 +2157,9 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
1774
2157
  }
1775
2158
  console.log(`basalt-ui init: --merge-lint — ${mergeMessage[mergeLint]}`)
1776
2159
  }
1777
- if (seededRoots !== null) {
1778
- console.log(
1779
- `basalt-ui init: wrote "basalt": { "roots": ${JSON.stringify(seededRoots)} } to package.json ` +
1780
- '— check-theme, the CI oxfmt globs and the default scan exemption all derive from it. ' +
1781
- 'Correct it if your sources live elsewhere.',
1782
- )
1783
- }
1784
- if (rootsUndetectable) {
1785
- console.error(
1786
- '⚠ basalt-ui init: could not infer "basalt.roots" — no src/ here and no workspace package ' +
1787
- 'with one. Set "basalt": { "roots": [...] } in package.json by hand, or check-theme will ' +
1788
- 'fail with "0 files scanned".',
1789
- )
1790
- }
2160
+ const rootsMessages = rootsNotices(rootsState)
2161
+ for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`)
2162
+ for (const line of rootsMessages.error) console.error(`⚠ basalt-ui init: ${line}`)
1791
2163
  if (seededLintScript) {
1792
2164
  console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
1793
2165
  }
@@ -1893,6 +2265,8 @@ type SyncOptions = { force?: boolean; check?: boolean }
1893
2265
  * - `--force` overwrites locally-drifted files instead of skipping them.
1894
2266
  */
1895
2267
  export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): number {
2268
+ // Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
2269
+ const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
1896
2270
  const ctx = renderContext(cwd)
1897
2271
  const manifest = readManifest(cwd)
1898
2272
  if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
@@ -1970,6 +2344,35 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1970
2344
  )
1971
2345
  }
1972
2346
 
2347
+ const rootsMessages = rootsNotices(rootsState)
2348
+ for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`)
2349
+ for (const line of rootsMessages.error) console.error(`⚠ basalt-ui sync: ${line}`)
2350
+
2351
+ // The two `extends` seams sync does NOT own. Reported here because sync is the command an
2352
+ // UPGRADE runs, and an upgrade is exactly when a resolved path goes stale — 1.20.0 fixed the
2353
+ // paths `init` renders and reached no existing consumer at all. Informational: these are
2354
+ // consumer-owned files, and `doctor` is the gate that fails on them.
2355
+ const install = findBasaltInstall(cwd)
2356
+ const lefthookSeam = inspectLefthookSeam(placement.repoRoot)
2357
+ if (lefthookSeam.kind === 'broken') {
2358
+ console.error(
2359
+ `⚠ basalt-ui sync: ${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not ` +
2360
+ 'exist — lefthook merges a missing extends target into ZERO commands and still exits 0, ' +
2361
+ 'so there is no pre-commit gate. Repoint it at ' +
2362
+ `"${shippedAssetPath(install, placement.repoRoot, 'configs/lefthook.yml')}".`,
2363
+ )
2364
+ }
2365
+ const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
2366
+ const oxlintEntry =
2367
+ oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.['extends'])
2368
+ if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
2369
+ console.error(
2370
+ `⚠ basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist — oxlint ` +
2371
+ 'refuses to start on a missing extends target (`NotFound`). Repoint it at ' +
2372
+ `"${shippedAssetPath(install, cwd, 'configs/oxlint.json')}".`,
2373
+ )
2374
+ }
2375
+
1973
2376
  if (opts.check) {
1974
2377
  if (driftLines.length > 0) {
1975
2378
  console.error('basalt-ui sync --check: locally-drifted managed files:')
@@ -2430,10 +2833,17 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
2430
2833
  * It is also the moment `bunx basalt-ui` stops using the pinned copy.
2431
2834
  * 6. `guard-scan`: `check-theme` would scan MORE than zero files. `check-theme` already exits 1
2432
2835
  * on "0 files scanned"; doctor disagreeing with it in the same repo is the bug.
2433
- * 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset.
2434
- * `init` keeps an existing config, so the framework's whole lint half can be off with nothing
2435
- * to say so — one repo carried six real violations invisibly across five minors that way.
2436
- * 9. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
2836
+ * 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset AND
2837
+ * that path RESOLVES. `init` keeps an existing config, so the framework's whole lint half can
2838
+ * be off with nothing to say so — one repo carried six real violations invisibly across five
2839
+ * minors that way. Matching the string alone reproduced the same false green one layer in: a
2840
+ * well-shaped entry pointing at a path that no longer exists passed this check in the very
2841
+ * tree where oxlint refused to start with `NotFound`.
2842
+ * 8. `lefthook-preset`: the repo-root lefthook config's `extends` target resolves. lefthook
2843
+ * merges a MISSING target into zero commands and exits 0 — no error, a clean `lefthook dump`,
2844
+ * and no pre-commit gate. Nothing but a check can see that. Not-extending-at-all is a warning
2845
+ * (a consumer may run their own hooks); extending a path that isn't there is a hard failure.
2846
+ * 10. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
2437
2847
  * major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
2438
2848
  * a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
2439
2849
  * ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
@@ -2454,9 +2864,9 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
2454
2864
  * demonstrably not the installed package — see the check).
2455
2865
  * 5. The running CLI's own version matches the installed basalt-ui (catches a stale
2456
2866
  * `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
2457
- * 8. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
2867
+ * 9. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
2458
2868
  * a public/ dir exists at all — skipped otherwise).
2459
- * 9 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
2869
+ * 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
2460
2870
  * exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
2461
2871
  * skew slips through unnoticed.
2462
2872
  *
@@ -2713,19 +3123,71 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
2713
3123
  skip(
2714
3124
  '.oxlintrc.json does not parse as JSON/JSONC — cannot tell whether it extends the preset',
2715
3125
  )
2716
- } else if (extendsBasaltPreset(parsed['extends'])) {
2717
- pass('.oxlintrc.json extends the shipped basalt-ui oxlint preset')
2718
3126
  } else {
2719
- fail(
2720
- '.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
2721
- 'and oxlint reports green without them. Add ' +
2722
- `"extends": ["${shippedAssetPath(install, cwd, 'configs/oxlint.json')}"], or re-run ` +
2723
- '`basalt-ui init --merge-lint` to splice it in.',
2724
- )
3127
+ const entry = basaltPresetEntry(parsed['extends'])
3128
+ const correct = shippedAssetPath(install, cwd, 'configs/oxlint.json')
3129
+ if (entry === null) {
3130
+ fail(
3131
+ '.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
3132
+ `and oxlint reports green without them. Add "extends": ["${correct}"], or re-run ` +
3133
+ '`basalt-ui init --merge-lint` to splice it in.',
3134
+ )
3135
+ } else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
3136
+ // The path is the one basalt resolves from and the asset still isn't there — a partial
3137
+ // or corrupted install, not a stale path. Saying "repoint it at <the same string>" would
3138
+ // be the unhelpful shape of a correct diagnosis.
3139
+ fail(
3140
+ `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
3141
+ 'basalt-ui resolves there but ships no `configs/oxlint.json` at that path, so oxlint ' +
3142
+ 'refuses to start (`NotFound`) and nothing is linted. Reinstall basalt-ui.',
3143
+ )
3144
+ } else if (!existsSync(resolve(cwd, entry))) {
3145
+ // The string is right and the file is not there. oxlint refuses to start at all
3146
+ // (`invalid config file … NotFound`), so a green line here is a check that proves the
3147
+ // framework is ON while the linter is dead — the same false-green class this check was
3148
+ // added to close, one layer in. `install` already computed the true path in this run.
3149
+ fail(
3150
+ `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
3151
+ 'oxlint refuses to start on a missing extends target (`NotFound`), so nothing is ' +
3152
+ `linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`,
3153
+ )
3154
+ } else {
3155
+ pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`)
3156
+ }
2725
3157
  }
2726
3158
  }
2727
3159
  }
2728
3160
 
3161
+ // ── Hard check 8: the lefthook preset's extends target actually exists ─────
3162
+ // The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
3163
+ // zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
3164
+ // exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
3165
+ // Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
3166
+ // remedy line someone reads after a failure.
3167
+ const repoRoot = findRepoRoot(cwd)
3168
+ const lefthookSeam =
3169
+ profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookSeam(repoRoot)
3170
+ const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
3171
+ if (lefthookSeam.kind === 'n/a') {
3172
+ pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
3173
+ } else if (lefthookSeam.kind === 'no-file') {
3174
+ pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
3175
+ } else if (lefthookSeam.kind === 'unwired') {
3176
+ warn(
3177
+ `${lefthookSeam.file} does not extend the shipped lefthook preset — the oxlint / oxfmt / ` +
3178
+ `check-theme pre-commit jobs are not wired. Add "extends: [${lefthookCorrect}]" to it.`,
3179
+ )
3180
+ } else if (lefthookSeam.kind === 'broken') {
3181
+ fail(
3182
+ `${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist ` +
3183
+ `(${resolve(repoRoot, lefthookSeam.entry)}) — lefthook merges a missing extends ` +
3184
+ 'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
3185
+ `\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
3186
+ )
3187
+ } else {
3188
+ pass(`${lefthookSeam.file} extends the shipped lefthook preset (${lefthookSeam.entry})`)
3189
+ }
3190
+
2729
3191
  // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
2730
3192
  // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
2731
3193
  // default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
@@ -2978,8 +3440,8 @@ function generatedHeader(version: string, command: string, flags: string[]): str
2978
3440
  }
2979
3441
 
2980
3442
  /**
2981
- * Space-normalize the argument list of a legacy color function so the emitted file survives a
2982
- * normal repo's formatter.
3443
+ * Space- and number-normalize the argument list of a legacy color function so the emitted file
3444
+ * survives a normal repo's formatter.
2983
3445
  *
2984
3446
  * This is FORMATTING ONLY and deliberately the CLI's single deviation from "print exactly what
2985
3447
  * `buildPaletteCss` returned": `rgba(255,255,255,0.6)` and `rgba(255, 255, 255, 0.6)` are the same
@@ -2987,15 +3449,27 @@ function generatedHeader(version: string, command: string, flags: string[]): str
2987
3449
  * side, and a consumer committing the output ate a lint-ignore entry for the difference. The token
2988
3450
  * VALUES are untouched — nothing here can change what basalt's tokens are.
2989
3451
  *
3452
+ * Trailing zeros go the same way and for the same reason: prettier rewrites `0.10` → `0.1`, so the
3453
+ * two `rgba(28, 25, 23, 0.10)` alphas in the light shadow tokens were the whole reason the one
3454
+ * framework-free consumer still could not lint its committed sheet — `--fix` and `tokens:css
3455
+ * --check` disagreed forever, and the lint-ignore entry survived two releases. Only a bare decimal
3456
+ * literal is touched (never a `%`, a `var()` or a unit), and `0.10`/`0.1` are the same number.
3457
+ *
2990
3458
  * Exported for tests, so the expectation can be computed rather than restated.
2991
3459
  */
2992
3460
  export function normalizeColorFunctions(css: string): string {
2993
3461
  return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn: string, args: string) => {
2994
- const parts = args.split(',').map((part) => part.trim())
3462
+ const parts = args.split(',').map((part) => normalizeCssNumber(part.trim()))
2995
3463
  return `${fn}(${parts.join(', ')})`
2996
3464
  })
2997
3465
  }
2998
3466
 
3467
+ /** `0.10` → `0.1`, `1.0` → `1`; anything that is not a bare decimal literal is returned verbatim. */
3468
+ function normalizeCssNumber(arg: string): string {
3469
+ if (!/^\d+\.\d+$/.test(arg)) return arg
3470
+ return arg.replace(/\.?0+$/, '')
3471
+ }
3472
+
2999
3473
  /** A scheme-class emission uses this sentinel attribute, then rewrites it to a class selector. */
3000
3474
  const SCHEME_CLASS_SENTINEL = 'data-basalt-scheme-class'
3001
3475
 
@@ -3236,7 +3710,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
3236
3710
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
3237
3711
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
3238
3712
  allowComment: 'theme-allow',
3239
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
3713
+ exemptRules: resolveExemptRules(cfg),
3240
3714
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
3241
3715
  // Same detection check-theme uses: the hook must never block an edit over advice the app
3242
3716
  // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
@@ -3281,8 +3755,13 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
3281
3755
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
3282
3756
  const USAGE =
3283
3757
  'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n' +
3284
- ' check-theme | check-coverage | info [--json] | doctor [--tokens-only|--framework] |\n' +
3285
- ' guard-hook | tokens:css | fonts:css | help>\n\n' +
3758
+ ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
3759
+ ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
3760
+ 'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
3761
+ ' --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n' +
3762
+ ' `basalt.exemptRules` entry, with what each one still suppresses — proved by re-running the\n' +
3763
+ ' guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n' +
3764
+ ' suppresses nothing (dead), which is what makes it usable as a CI gate.\n\n' +
3286
3765
  'tokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n' +
3287
3766
  ' [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n' +
3288
3767
  ' [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n' +