basalt-ui 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  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 +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -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 +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -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 +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  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 +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  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 +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  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/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
package/src/cli/index.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * safe to import under plain Node. Config is read from the consuming package.json `"basalt"` key;
20
20
  * argo's hardcoded values are the DEFAULTS.
21
21
  */
22
+ import { spawnSync } from 'node:child_process'
22
23
  import { createHash } from 'node:crypto'
23
24
  import {
24
25
  existsSync,
@@ -36,8 +37,13 @@ import {
36
37
  checkSource,
37
38
  DEFAULT_GUARD_CONFIG,
38
39
  GENERATED_HEADER_LINE,
40
+ findAllowAnnotations,
41
+ guardKindRemedy,
39
42
  GUARD_RULES,
43
+ guardWaiverHint,
44
+ neutralizeAllowAnnotation,
40
45
  TOKENS_ONLY_DISABLED_KINDS,
46
+ unmatchedExemptPatterns,
41
47
  } from '../guard'
42
48
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from '../guard'
43
49
  import { evaluateGuardHook } from '../guard/guard-hook'
@@ -56,7 +62,8 @@ export type BasaltConfig = {
56
62
  * off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
57
63
  * factory, leaving the color and typography kinds live. Default: `'mantine'`.
58
64
  *
59
- * Deliberately not inferred from a missing `@mantine/core`: silencing 16 rules is a decision, and
65
+ * Deliberately not inferred from a missing `@mantine/core`: silencing 17 rules (the live count is
66
+ * `TOKENS_ONLY_DISABLED_KINDS.size`, which the check-theme banner prints) is a decision, and
60
67
  * a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
61
68
  * without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
62
69
  */
@@ -183,15 +190,27 @@ export type BasaltConfig = {
183
190
  /**
184
191
  * Per-rule, per-path exemptions — complements whole-file `exempt` (which skips ALL rules for a
185
192
  * 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).
193
+ * only). Each value is a list of patterns matched against a finding's relative path. A pattern
194
+ * may be a whole path segment (`'agent'` matches `src/agent/x.tsx`, not `src/agenting.ts`), a
195
+ * relative path (`'public/site.webmanifest'`), a directory prefix, or a glob — `*` stops at `/`,
196
+ * `**` does not, and a slash-free glob also matches the basename, so `'*.module.css'` works.
197
+ * A trailing `/` is stripped. An entry that suppresses nothing is reported, and
198
+ * `check-theme --audit-allows` exits 1 on it. Default: `{}` (no exemptions).
199
+ *
200
+ * Two forms per kind. The bare array is paths only. The object form adds the REASON, which is
201
+ * the half a `theme-allow` carries and this key could not: JSON has no comments, so a
202
+ * `.webmanifest` / `.json` finding's only escape is this key, and it was un-reviewable by
203
+ * construction — the rationale ended up in a CLAUDE.md paragraph nobody reads next to the diff.
204
+ * `check-theme --audit-allows` prints the reason (or names its absence) beside what the entry
205
+ * still suppresses. The array form stays supported and unchanged; nothing here is required.
190
206
  *
191
207
  * @example
192
208
  * { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
209
+ * @example
210
+ * { exemptRules: { 'raw-hex': { paths: ['site.webmanifest'],
211
+ * reason: 'a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var' } } }
193
212
  */
194
- exemptRules?: Partial<Record<GuardKind, string[]>>
213
+ exemptRules?: Partial<Record<GuardKind, ExemptRuleEntry>>
195
214
  /**
196
215
  * Declares an intentional `ai` package major-version skew across workspace packages, exempting
197
216
  * `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
@@ -213,6 +232,38 @@ export type BasaltConfig = {
213
232
  aiMajorSkewReason?: string
214
233
  }
215
234
 
235
+ /**
236
+ * One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
237
+ * it exists. See `BasaltConfig.exemptRules`.
238
+ */
239
+ type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
240
+
241
+ /** The paths half of an exemption entry, whichever form it was written in. */
242
+ function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
243
+ if (entry === undefined) return []
244
+ return Array.isArray(entry) ? entry : entry.paths
245
+ }
246
+
247
+ /** The recorded reason for an exemption entry, or null for the bare-array form. */
248
+ function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
249
+ if (entry === undefined || Array.isArray(entry)) return null
250
+ return entry.reason.trim().length > 0 ? entry.reason.trim() : null
251
+ }
252
+
253
+ /**
254
+ * The guard-shaped `exemptRules` — paths only. The reason the object form carries never reaches
255
+ * the guard: it is accountability for a human reading a diff and for `--audit-allows`, and giving
256
+ * the scan a second shape to understand would only be a way for the two to disagree.
257
+ */
258
+ function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'] {
259
+ if (cfg.exemptRules === undefined) return DEFAULT_GUARD_CONFIG.exemptRules
260
+ const out: Partial<Record<GuardKind, string[]>> = {}
261
+ for (const [kind, entry] of Object.entries(cfg.exemptRules)) {
262
+ out[kind as GuardKind] = exemptRulePaths(entry)
263
+ }
264
+ return out
265
+ }
266
+
216
267
  const DEFAULT_ROOT = 'src'
217
268
  const DEFAULT_ROOTS = [DEFAULT_ROOT]
218
269
 
@@ -350,57 +401,6 @@ function walkSourceFiles(dir: string, out: string[] = []): string[] {
350
401
  return out
351
402
  }
352
403
 
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
404
  // ──────────────────────────────────────────────────────────────────────────────
405
405
  // Resolution — where basalt is installed, and where the consumer's basalt config lives
406
406
  // ──────────────────────────────────────────────────────────────────────────────
@@ -639,6 +639,37 @@ function appShellFiles(rootAbs: string): string[] {
639
639
  return out
640
640
  }
641
641
 
642
+ /**
643
+ * A path in the JSON family that is NOT a manifest — the sentinel that gets `guardWaiverHint`'s
644
+ * plain-JSON closer. Used only by {@link waiverHintFor}; see its doc for why.
645
+ */
646
+ const PLAIN_JSON_HINT_PATH = 'manifest.json'
647
+
648
+ /**
649
+ * The waiver closer for a file, gated on the profile.
650
+ *
651
+ * `guardWaiverHint` keys off the file class, and for a `.webmanifest` it leads with
652
+ * `basaltAppPlugin` — right for a Mantine app, since a hex in a manifest can never be *right* and
653
+ * the plugin removes the hand-copy entirely. A `profile: tokens-only` consumer has definitionally
654
+ * opted out of that layer: rollhook's Astro site has no `index.html` for the plugin to transform,
655
+ * imports no basalt JavaScript at all, and owns maskable icons the plugin does not emit. Leading
656
+ * with the plugin there is advice the consumer cannot take, in the one release that finally gave
657
+ * that file class a remedy.
658
+ *
659
+ * So under tokens-only a manifest is treated as what it is to that consumer — plain JSON, with the
660
+ * member as the whole answer — plus one sentence saying which remedy is being withheld and why.
661
+ * The remedy text still comes from the guard's own registry; only the class mapping is decided here.
662
+ */
663
+ function waiverHintFor(relPath: string, profile: DoctorProfile): string {
664
+ if (profile !== 'tokens-only' || !relPath.endsWith('.webmanifest')) {
665
+ return guardWaiverHint(relPath)
666
+ }
667
+ return (
668
+ `${guardWaiverHint(PLAIN_JSON_HINT_PATH)} (basaltAppPlugin would emit the file instead, but ` +
669
+ "it needs basalt's Vite/React layer, which a tokens-only consumer has opted out of.)"
670
+ )
671
+ }
672
+
642
673
  /**
643
674
  * Theme guard — thin FS walker over the headless `../guard` core. Reads BasaltConfig, builds a
644
675
  * GuardConfig, walks roots, calls checkSource per file, collects Finding[], groups/reports, returns
@@ -702,7 +733,7 @@ export function checkTheme(
702
733
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
703
734
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
704
735
  allowComment: 'theme-allow',
705
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
736
+ exemptRules: resolveExemptRules(cfg),
706
737
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
707
738
  ...(profile === 'tokens-only' ? { profile: 'tokens-only' as const } : {}),
708
739
  }
@@ -732,6 +763,25 @@ export function checkTheme(
732
763
  return 1
733
764
  }
734
765
 
766
+ // The audit is a REPORT over the same scan, not a second scan with different rules — it runs
767
+ // after the 0-files gate so "every waiver is dead" can never mean "nothing was read".
768
+ if (flags.includes('--audit-allows')) return auditAllows(cwd, cfg, guardCfg, scanned)
769
+
770
+ // An exemption that matched nothing is reported, never silently honoured. A WARNING rather than a
771
+ // failure: the entry enforced exactly nothing before this line existed, so failing on it would
772
+ // break a green build at upgrade time over config that was already inert — `--audit-allows` is
773
+ // the lane that exits non-zero on a dead waiver, for a consumer who opts into that gate.
774
+ for (const unmatched of unmatchedExemptPatterns(guardCfg, scanned)) {
775
+ console.error(
776
+ `⚠ basalt-ui check-theme: "basalt.exemptRules" entry ${unmatched.kind}: ` +
777
+ `"${unmatched.pattern}" ` +
778
+ (unmatched.reason === 'unknown-kind'
779
+ ? 'names no guard kind — it exempts nothing at all (a typo, or a kind that was renamed).'
780
+ : 'matched none of the scanned files — it suppresses nothing, and reads as coverage in a ' +
781
+ 'config review while enforcing as much as an empty object.'),
782
+ )
783
+ }
784
+
735
785
  if (findings.length === 0) {
736
786
  console.log('✓ Theme guard: no off-palette colors.')
737
787
  return 0
@@ -774,16 +824,16 @@ export function checkTheme(
774
824
  report(errors, `✖ Theme guard: ${errors.length} off-palette / off-identity violation(s)\n`)
775
825
  }
776
826
 
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(' '))
827
+ // Remedies come from the guard's own rule registry, never from a copy kept here: the local table
828
+ // this replaced had no entry for any of the five kinds 1.20.0 added, so exactly the new findings
829
+ // — the ones whose whole argument is "this looks correct and is not" — printed no argument.
830
+ const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted()
831
+ // And the closer is per FILE CLASS: prescribing a `theme-allow` comment to a .webmanifest (which
832
+ // has no comment syntax) is what pushed two consumers into a blanket exemption instead.
833
+ const waiverHints = [
834
+ ...new Set(findings.map((f) => waiverHintFor(f.relPath, profile))),
835
+ ].toSorted()
836
+ console.error(['Fix:', ...presentKinds.map(guardKindRemedy), ...waiverHints].join(' '))
787
837
 
788
838
  // A violation in a file that looks like the palette source itself is likely intentional — point
789
839
  // at the exempt escape hatch instead of leaving the author to search for it.
@@ -798,6 +848,352 @@ export function checkTheme(
798
848
  return errors.length > 0 ? 1 : 0
799
849
  }
800
850
 
851
+ // ── check-theme --audit-allows — every waiver, and whether it still suppresses anything ─────────
852
+
853
+ /** `relPath:line:kind` — a finding's identity for set arithmetic across two runs of the same file. */
854
+ function findingKey(f: Finding): string {
855
+ return `${f.relPath}:${f.line}:${f.kind}`
856
+ }
857
+
858
+ /** One annotation the guard cannot judge, queued for the oxlint half of the audit. */
859
+ type PluginProbe = {
860
+ /** Index into the report's line array, so the verdict lands where the placeholder sits. */
861
+ readonly slot: number
862
+ readonly rel: string
863
+ /** 1-based line of the annotation, as {@link findAllowAnnotations} reports it. */
864
+ readonly line: number
865
+ readonly ids: readonly string[]
866
+ readonly site: string
867
+ readonly suffix: string
868
+ }
869
+
870
+ /** One oxlint diagnostic, as `--format=json` renders it — only the fields the audit reads. */
871
+ type OxlintDiagnostic = {
872
+ code?: unknown
873
+ filename?: unknown
874
+ labels?: { span?: { line?: unknown } }[]
875
+ }
876
+
877
+ /** The nearest `node_modules/.bin/<name>` walking up from `dir`, else `name` if it is on PATH. */
878
+ function resolveToolBin(dir: string, name: string): string | null {
879
+ let current = dir
880
+ for (;;) {
881
+ const candidate = resolve(current, 'node_modules/.bin', name)
882
+ if (existsSync(candidate)) return candidate
883
+ const parent = resolve(current, '..')
884
+ if (parent === current) break
885
+ current = parent
886
+ }
887
+ const probe = spawnSync(name, ['--version'], { stdio: 'ignore', timeout: 10_000 })
888
+ return probe.error === undefined && probe.status === 0 ? name : null
889
+ }
890
+
891
+ /** `line:rule` counts for one file's diagnostics — a multiset, so two findings on a line differ. */
892
+ function diagnosticCounts(
893
+ diagnostics: readonly OxlintDiagnostic[],
894
+ filename: string,
895
+ ): Map<string, number> {
896
+ const counts = new Map<string, number>()
897
+ for (const d of diagnostics) {
898
+ if (d.filename !== filename) continue
899
+ const code = typeof d.code === 'string' ? d.code : ''
900
+ const rule = /^basalt\((.+)\)$/.exec(code)?.[1]
901
+ if (rule === undefined) continue
902
+ const line = d.labels?.[0]?.span?.line
903
+ const key = `${typeof line === 'number' ? line : 0}:${rule}`
904
+ counts.set(key, (counts.get(key) ?? 0) + 1)
905
+ }
906
+ return counts
907
+ }
908
+
909
+ /**
910
+ * The oxlint half of the audit: what each plugin-rule annotation still suppresses.
911
+ *
912
+ * The `check-theme` half proves a verdict by re-running `checkSource` with one occurrence
913
+ * neutralized. The `error`-severity design rules do not live there — they live in the oxlint
914
+ * plugin — so the audit declined to judge them and printed "not a check-theme kind". In a
915
+ * chart-heavy consumer that was 8 of 8 and 11 of 14: a CI gate that exited 0 having inspected
916
+ * nothing. The method generalizes exactly, one level over: neutralize the annotation, re-run
917
+ * oxlint over that file, and the findings that appear are what it covers.
918
+ *
919
+ * oxlint has no stdin mode, so a probe has to be a real file. It is written BESIDE the original —
920
+ * same directory, so the same `.oxlintrc.json`, the same `charts/` path segment, the same nearest
921
+ * `package.json` — and removed in a `finally`. Returns null when oxlint cannot be run at all, which
922
+ * is a "cannot judge", never a "dead".
923
+ */
924
+ function probePluginRules(
925
+ cwd: string,
926
+ guardCfg: GuardConfig,
927
+ probes: readonly PluginProbe[],
928
+ sources: ReadonlyMap<string, string>,
929
+ ): Map<number, string[]> | null {
930
+ if (probes.length === 0) return new Map()
931
+ const bin = resolveToolBin(cwd, 'oxlint')
932
+ if (bin === null) return null
933
+
934
+ const written: string[] = []
935
+ const probePaths = new Map<number, string>()
936
+ try {
937
+ for (const probe of probes) {
938
+ // Neutralized by the guard's own helper, so the oxlint probe and the checkSource probe can
939
+ // never differ by a substitution detail — a verdict that disagrees between the two halves of
940
+ // one audit is worse than no verdict.
941
+ const probed = neutralizeAllowAnnotation(sources.get(probe.rel) ?? '', probe.line, guardCfg)
942
+ const dot = probe.rel.lastIndexOf('.')
943
+ const rel =
944
+ dot === -1
945
+ ? `${probe.rel}.basalt-audit-${probe.slot}`
946
+ : `${probe.rel.slice(0, dot)}.basalt-audit-${probe.slot}${probe.rel.slice(dot)}`
947
+ const abs = resolve(cwd, rel)
948
+ if (existsSync(abs)) continue
949
+ writeFileSync(abs, probed, 'utf8')
950
+ written.push(abs)
951
+ probePaths.set(probe.slot, rel)
952
+ }
953
+ if (probePaths.size === 0) return null
954
+
955
+ const targets = [...new Set(probes.map((p) => p.rel)), ...probePaths.values()]
956
+ const run = spawnSync(bin, ['--format=json', '--no-error-on-unmatched-pattern', ...targets], {
957
+ cwd,
958
+ encoding: 'utf8',
959
+ timeout: 120_000,
960
+ stdio: ['ignore', 'pipe', 'ignore'],
961
+ })
962
+ if (run.error !== undefined || typeof run.stdout !== 'string') return null
963
+ let diagnostics: OxlintDiagnostic[]
964
+ try {
965
+ diagnostics =
966
+ (JSON.parse(run.stdout) as { diagnostics?: OxlintDiagnostic[] }).diagnostics ?? []
967
+ } catch {
968
+ return null
969
+ }
970
+
971
+ const verdicts = new Map<number, string[]>()
972
+ for (const probe of probes) {
973
+ const probeRel = probePaths.get(probe.slot)
974
+ if (probeRel === undefined) continue
975
+ const before = diagnosticCounts(diagnostics, probe.rel)
976
+ const after = diagnosticCounts(diagnostics, probeRel)
977
+ const revealed: string[] = []
978
+ for (const [key, count] of after) {
979
+ if (count <= (before.get(key) ?? 0)) continue
980
+ const [line = '', rule = ''] = key.split(':')
981
+ revealed.push(`${rule}@${line}`)
982
+ }
983
+ verdicts.set(probe.slot, revealed.toSorted())
984
+ }
985
+ return verdicts
986
+ } finally {
987
+ for (const abs of written) {
988
+ try {
989
+ unlinkSync(abs)
990
+ } catch {
991
+ // The probe is a temp file basalt owns; a failed unlink must not mask the audit's verdict.
992
+ }
993
+ }
994
+ }
995
+ }
996
+
997
+ /**
998
+ * `check-theme --audit-allows` — list every active waiver and prove, per waiver, whether it still
999
+ * suppresses anything.
1000
+ *
1001
+ * The accountability release shipped `theme-allow-unscoped`, which reports a waiver that names no
1002
+ * rule — but a waiver that is perfectly well written and covers a finding that no longer exists is
1003
+ * invisible to it, and to everything else. Two consumers found five of those by hand, by editing
1004
+ * the token out and re-running the guard. This is that method, automated: for each annotation the
1005
+ * file is re-checked with THAT occurrence neutralized, and the findings that appear are exactly
1006
+ * what it suppresses. Nothing is inferred from the annotation's text, so the audit cannot disagree
1007
+ * with the scan — it IS the scan, run twice.
1008
+ *
1009
+ * `basalt.exemptRules` entries are audited the same way, one pattern at a time. That also answers
1010
+ * the question the key could never answer for itself: a pattern that matches no scanned file (a
1011
+ * real relative path, say, where the matcher takes whole path SEGMENTS) suppresses nothing and is
1012
+ * reported as dead rather than passing silently.
1013
+ *
1014
+ * Exit 1 when anything is dead, so it can be wired into CI as a gate. A waiver nobody re-checks is
1015
+ * how a rule quietly stops applying to the file that needed the exception most.
1016
+ */
1017
+ function auditAllows(
1018
+ cwd: string,
1019
+ cfg: BasaltConfig,
1020
+ guardCfg: GuardConfig,
1021
+ scanned: string[],
1022
+ ): number {
1023
+ const token = guardCfg.allowComment
1024
+ const lines: string[] = [`\nbasalt-ui check-theme --audit-allows — ${cwd}\n`]
1025
+ let dead = 0
1026
+ let live = 0
1027
+ let outOfReach = 0
1028
+ let unaccountable = 0
1029
+
1030
+ // ── theme-allow annotations ─────────────────────────────────────────────────
1031
+ const waiverLines: string[] = []
1032
+ const pluginProbes: PluginProbe[] = []
1033
+ const sources = new Map<string, string>()
1034
+ for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), 'utf8'))
1035
+
1036
+ for (const rel of scanned) {
1037
+ const text = sources.get(rel) ?? ''
1038
+ if (!text.includes(token)) continue
1039
+ const baseline = checkSource(text, rel, guardCfg)
1040
+ const baselineKeys = new Set(
1041
+ baseline.filter((f) => f.kind !== 'theme-allow-unscoped').map(findingKey),
1042
+ )
1043
+ const unscopedAt = new Map(
1044
+ baseline.filter((f) => f.kind === 'theme-allow-unscoped').map((f) => [f.line, f.token]),
1045
+ )
1046
+ // Enumerated by the guard, never by a regex kept here: `findAllowAnnotations` shares its
1047
+ // collector with `checkSource`, so the audit lists exactly the annotations the scan honours.
1048
+ // The private mirror this replaced was already one comment shape behind.
1049
+ for (const site of findAllowAnnotations(text, rel, guardCfg)) {
1050
+ const revealed = checkSource(
1051
+ neutralizeAllowAnnotation(text, site.line, guardCfg),
1052
+ rel,
1053
+ guardCfg,
1054
+ )
1055
+ .filter((f) => f.kind !== 'theme-allow-unscoped' && !baselineKeys.has(findingKey(f)))
1056
+ .map((f) => `${f.kind}@${f.line}`)
1057
+ const note = unscopedAt.get(site.line)
1058
+ if (note !== undefined) unaccountable++
1059
+ const label = ` ${rel}:${site.line}`.padEnd(52)
1060
+ const suffix = note === undefined ? '' : ` [${note}]`
1061
+ if (revealed.length > 0) {
1062
+ live++
1063
+ waiverLines.push(`${label} suppresses ${[...new Set(revealed)].join(', ')}${suffix}`)
1064
+ continue
1065
+ }
1066
+ // Nothing in check-theme's reach moved. If the annotation names a PLUGIN rule, the oxlint
1067
+ // half decides it — a placeholder holds the slot so the report stays in file/line order.
1068
+ if (site.pluginRules.length > 0 && site.guardKinds.length === 0) {
1069
+ pluginProbes.push({
1070
+ slot: waiverLines.length,
1071
+ rel,
1072
+ line: site.line,
1073
+ ids: site.pluginRules,
1074
+ site: label,
1075
+ suffix,
1076
+ })
1077
+ waiverLines.push('')
1078
+ continue
1079
+ }
1080
+ if (site.guardKinds.length === 0 && site.unknownRules.length > 0) {
1081
+ outOfReach++
1082
+ waiverLines.push(
1083
+ `${label} scoped to ${site.unknownRules.join(', ')} — names no guard kind and no ` +
1084
+ 'plugin rule, so this audit cannot judge it (most likely a typo, which ' +
1085
+ `theme-allow-unscoped reports)${suffix}`,
1086
+ )
1087
+ continue
1088
+ }
1089
+ dead++
1090
+ waiverLines.push(`${label} SUPPRESSES NOTHING — dead, delete it${suffix}`)
1091
+ }
1092
+ }
1093
+
1094
+ // ── the oxlint half — plugin-rule annotations, judged the same way ──────────
1095
+ const pluginVerdicts = probePluginRules(cwd, guardCfg, pluginProbes, sources)
1096
+ for (const probe of pluginProbes) {
1097
+ const revealed = pluginVerdicts?.get(probe.slot)
1098
+ if (revealed === undefined) {
1099
+ outOfReach++
1100
+ waiverLines[probe.slot] =
1101
+ `${probe.site} scoped to ${probe.ids.join(', ')} — an oxlint plugin rule, and oxlint ` +
1102
+ `could not be run here, so this audit cannot judge it${probe.suffix}`
1103
+ continue
1104
+ }
1105
+ if (revealed.length > 0) {
1106
+ live++
1107
+ waiverLines[probe.slot] =
1108
+ `${probe.site} suppresses ${revealed.join(', ')} (oxlint)${probe.suffix}`
1109
+ continue
1110
+ }
1111
+ dead++
1112
+ waiverLines[probe.slot] =
1113
+ `${probe.site} SUPPRESSES NOTHING — dead, delete it (proved by re-running oxlint over the ` +
1114
+ `file with this annotation neutralized)${probe.suffix}`
1115
+ }
1116
+
1117
+ lines.push(`${token} annotations (${waiverLines.length}):`)
1118
+ lines.push(...(waiverLines.length === 0 ? [' (none)'] : waiverLines))
1119
+
1120
+ // ── basalt.exemptRules entries ──────────────────────────────────────────────
1121
+ const exemptLines: string[] = []
1122
+ let exemptCount = 0
1123
+ const declared = Object.entries(cfg.exemptRules ?? {}) as [GuardKind, ExemptRuleEntry][]
1124
+ if (declared.length > 0) {
1125
+ const baselineKeys = new Set<string>()
1126
+ for (const rel of scanned) {
1127
+ for (const f of checkSource(sources.get(rel) ?? '', rel, guardCfg))
1128
+ baselineKeys.add(findingKey(f))
1129
+ }
1130
+ for (const [kind, entry] of declared) {
1131
+ const reason = exemptRuleReason(entry)
1132
+ for (const pattern of exemptRulePaths(entry)) {
1133
+ exemptCount++
1134
+ const probeCfg: GuardConfig = {
1135
+ ...guardCfg,
1136
+ exemptRules: {
1137
+ ...guardCfg.exemptRules,
1138
+ [kind]: exemptRulePaths(entry).filter((p) => p !== pattern),
1139
+ },
1140
+ }
1141
+ const revealed = new Set<string>()
1142
+ for (const rel of scanned) {
1143
+ for (const f of checkSource(sources.get(rel) ?? '', rel, probeCfg)) {
1144
+ if (!baselineKeys.has(findingKey(f))) revealed.add(f.relPath)
1145
+ }
1146
+ }
1147
+ const site = ` ${kind}: "${pattern}"`.padEnd(52)
1148
+ const why = reason === null ? ' [no reason recorded]' : ` — ${reason}`
1149
+ if (revealed.size === 0) {
1150
+ dead++
1151
+ const unmatched = unmatchedExemptPatterns(guardCfg, scanned).find(
1152
+ (u) => u.kind === kind && u.pattern === pattern,
1153
+ )
1154
+ exemptLines.push(
1155
+ `${site} SUPPRESSES NOTHING — ` +
1156
+ (unmatched === undefined
1157
+ ? 'the files it matches have no such finding; delete it'
1158
+ : unmatched.reason === 'unknown-kind'
1159
+ ? 'it names no guard kind (a typo, or a renamed kind)'
1160
+ : 'it matches no scanned file at all') +
1161
+ why,
1162
+ )
1163
+ } else {
1164
+ live++
1165
+ exemptLines.push(
1166
+ `${site} suppresses findings in ${[...revealed].toSorted().join(', ')}${why}`,
1167
+ )
1168
+ }
1169
+ }
1170
+ }
1171
+ }
1172
+ lines.push(`\nbasalt.exemptRules entries (${exemptCount}):`)
1173
+ lines.push(...(exemptLines.length === 0 ? [' (none)'] : exemptLines))
1174
+
1175
+ lines.push(
1176
+ `\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
1177
+ 'unaccountable (reported as theme-allow-unscoped by a normal run).',
1178
+ )
1179
+ // The audit reads exactly what `check-theme` reads, and that is `basalt.roots`. A waiver in a
1180
+ // file outside them is invisible here — not because it is fine, but because nothing scanned it.
1181
+ // Saying the scope out loud is the difference between "0 dead" and "0 dead, over these files".
1182
+ lines.push(
1183
+ `Scope: the ${scanned.length} file(s) check-theme scans under basalt.roots ` +
1184
+ `(${resolveRoots(cfg).join(', ')}). A waiver outside them is not audited — widen roots to ` +
1185
+ 'cover it, or accept that nothing polices that file.',
1186
+ )
1187
+ if (dead > 0) {
1188
+ lines.push(
1189
+ 'A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently ' +
1190
+ 'cover the next real finding on that line.',
1191
+ )
1192
+ }
1193
+ console.log(lines.join('\n'))
1194
+ return dead > 0 ? 1 : 0
1195
+ }
1196
+
801
1197
  // ──────────────────────────────────────────────────────────────────────────────
802
1198
  // init / sync — managed-files engine
803
1199
  // ──────────────────────────────────────────────────────────────────────────────
@@ -1189,8 +1585,24 @@ function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEME
1189
1585
  '# The path is RESOLVED from where basalt-ui actually installed, not assumed at the\n' +
1190
1586
  '# repo root — under an isolated linker it lives beside the package that depends on it.\n' +
1191
1587
  '# Run `lefthook install` once: the file is inert until the git hooks are written.\n' +
1588
+ '#\n' +
1589
+ '# NOTE: an extends target WINS on a colliding key — a `run:`/`glob:` you write under one of\n' +
1590
+ "# the preset's command names is discarded silently. `env:`, `exclude:`, `skip:` and your own\n" +
1591
+ '# command names merge. See the preset itself for the sanctioned override seams.\n' +
1192
1592
  'extends:\n' +
1193
- ` - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, '')}\n`,
1593
+ ` - ${ctx.vars.LEFTHOOK_PRESET_PATH.replace(/^\.\//, '')}\n` +
1594
+ // The one seam that has to be RENDERED rather than documented: the preset is read in place
1595
+ // out of node_modules, so it cannot template the resolved bin the way check.yml does. Its
1596
+ // default (`bunx --no-install basalt-ui`) fails loudly rather than fetching a stranger's
1597
+ // copy; this pins it to the local one whenever basalt actually resolved.
1598
+ (ctx.vars.BASALT_BIN.startsWith('bunx ')
1599
+ ? ''
1600
+ : '\n# The theme guard runs the LOCAL bin, not a copy `bunx` fetches from npm.\n' +
1601
+ 'pre-commit:\n' +
1602
+ ' commands:\n' +
1603
+ ' check-theme:\n' +
1604
+ ' env:\n' +
1605
+ ` BASALT_BIN: ${ctx.vars.BASALT_BIN}\n`),
1194
1606
  }
1195
1607
 
1196
1608
  // CI is inherently repo-shaped (a monorepo's check.yml needs its own scripts), and GitHub Actions
@@ -1537,6 +1949,96 @@ function detectRoots(cwd: string): string[] {
1537
1949
  return existsSync(resolve(cwd, DEFAULT_ROOT)) ? [DEFAULT_ROOT] : []
1538
1950
  }
1539
1951
 
1952
+ /** What `reconcileRoots` found — the one roots decision `init` and `sync` both report from. */
1953
+ type RootsState =
1954
+ /** The key was absent and has just been written. */
1955
+ | { readonly kind: 'wrote'; readonly roots: string[] }
1956
+ /** The key is absent and nothing plausible could be inferred. */
1957
+ | { readonly kind: 'undetectable' }
1958
+ /** The key is already declared. `unscanned` names inferable roots it does not cover. */
1959
+ | { readonly kind: 'declared'; readonly declared: string[]; readonly unscanned: string[] }
1960
+
1961
+ /** True when `root` is `detected` itself or an ancestor of it. */
1962
+ function rootCovers(root: string, detected: string): boolean {
1963
+ const r = root.replace(/\/+$/, '')
1964
+ return detected === r || detected.startsWith(`${r}/`)
1965
+ }
1966
+
1967
+ /**
1968
+ * Read — and when absent and `write` is set, seed — `basalt.roots`.
1969
+ *
1970
+ * Shared by `init` and `sync` because the key going missing is the SAME false green in both: the
1971
+ * built-in `src` default happens to resolve from a package cwd, so `guard-scan` passes, `check-theme`
1972
+ * scans something, and the repo is silently guarding a subset of itself. `init` wrote it and `sync`
1973
+ * did not — which meant the fix reached new scaffolds only, while every existing consumer (the ones
1974
+ * who by definition upgrade rather than scaffold) stayed on the undeclared default.
1975
+ *
1976
+ * A DECLARED value is never overwritten, not even when the layout disagrees with it. `roots` is a
1977
+ * subset by construction in a monorepo — a React-free package deliberately left out is the normal
1978
+ * case, not drift — so a disagreement is reported as one line naming what is not covered, and the
1979
+ * consumer decides. Silently widening someone's deliberate scope would turn an upgrade into an
1980
+ * unplanned lint debt.
1981
+ */
1982
+ function reconcileRoots(cwd: string, opts: { write: boolean }): RootsState {
1983
+ const declared = readBasaltConfig(cwd).roots
1984
+ const detected = detectRoots(cwd)
1985
+ if (declared !== undefined) {
1986
+ const unscanned = detected.filter((d) => !declared.some((r) => rootCovers(r, d)))
1987
+ return { kind: 'declared', declared, unscanned }
1988
+ }
1989
+ if (detected.length === 0) return { kind: 'undetectable' }
1990
+ if (!opts.write) return { kind: 'declared', declared: [], unscanned: detected }
1991
+ const wrote = patchPackageJson(cwd, (pkg) => {
1992
+ const basalt = (pkg['basalt'] ?? {}) as Record<string, unknown>
1993
+ if (basalt['roots'] !== undefined) return false
1994
+ pkg['basalt'] = { ...basalt, roots: detected }
1995
+ return true
1996
+ })
1997
+ return wrote ? { kind: 'wrote', roots: detected } : { kind: 'undetectable' }
1998
+ }
1999
+
2000
+ /** The lines `init`/`sync` print for a roots reconciliation. Empty when there is nothing to say. */
2001
+ function rootsNotices(state: RootsState): { log: string[]; error: string[] } {
2002
+ if (state.kind === 'wrote') {
2003
+ return {
2004
+ log: [
2005
+ `wrote "basalt": { "roots": ${JSON.stringify(state.roots)} } to package.json — ` +
2006
+ 'check-theme, the CI oxfmt globs and the default scan exemption all derive from it. ' +
2007
+ 'Correct it if your sources live elsewhere.',
2008
+ ],
2009
+ error: [],
2010
+ }
2011
+ }
2012
+ if (state.kind === 'undetectable') {
2013
+ return {
2014
+ log: [],
2015
+ error: [
2016
+ 'could not infer "basalt.roots" — no src/ here and no workspace package with one. Set ' +
2017
+ '"basalt": { "roots": [...] } in package.json by hand, or check-theme will fail with ' +
2018
+ '"0 files scanned".',
2019
+ ],
2020
+ }
2021
+ }
2022
+ if (state.unscanned.length === 0) return { log: [], error: [] }
2023
+ if (state.declared.length === 0) {
2024
+ return {
2025
+ log: [],
2026
+ error: [
2027
+ `"basalt.roots" is not set — the guard is running on the built-in default ("${DEFAULT_ROOT}"). ` +
2028
+ `This layout suggests ${JSON.stringify(state.unscanned)}; declare it in package.json.`,
2029
+ ],
2030
+ }
2031
+ }
2032
+ return {
2033
+ log: [
2034
+ `keeping your "basalt.roots" (${JSON.stringify(state.declared)}) — never overwritten. The ` +
2035
+ `layout also has ${JSON.stringify(state.unscanned)}, which nothing scans; add it if it ` +
2036
+ 'should be guarded, or leave it if the exclusion is deliberate.',
2037
+ ],
2038
+ error: [],
2039
+ }
2040
+ }
2041
+
1540
2042
  /**
1541
2043
  * Strip JSONC comments (and trailing commas) so `.oxlintrc.json` can be READ. oxlint accepts JSONC
1542
2044
  * and real consumer configs use it — a plain `JSON.parse` there reported "not valid JSON" on a
@@ -1595,14 +2097,175 @@ function parseJsonc(text: string): Record<string, unknown> | null {
1595
2097
  /** Outcome of splicing the shipped preset into an `.oxlintrc.json` the consumer already had. */
1596
2098
  type MergeLintResult = 'added' | 'already' | 'absent' | 'unreadable' | 'has-comments'
1597
2099
 
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
- )
2100
+ /**
2101
+ * The `extends` entry pointing at the shipped oxlint preset, or null when none does.
2102
+ *
2103
+ * Returns the ENTRY rather than a boolean because the string alone proves nothing: a consumer that
2104
+ * upgraded (or moved to an isolated linker) keeps a perfectly well-shaped
2105
+ * `./node_modules/basalt-ui/configs/oxlint.json` that resolves to nothing, and oxlint then refuses
2106
+ * to start with `NotFound` while a check built to prove the framework is ON reports green. The
2107
+ * caller resolves this against the config's own directory — see `extendsSeam`.
2108
+ */
2109
+ function basaltPresetEntry(entries: unknown): string | null {
2110
+ if (!Array.isArray(entries)) return null
2111
+ const match = entries.find(
2112
+ (entry): entry is string =>
2113
+ typeof entry === 'string' && entry.endsWith('basalt-ui/configs/oxlint.json'),
1605
2114
  )
2115
+ return match ?? null
2116
+ }
2117
+
2118
+ // ── The two `extends` seams init writes once and nothing ever revisits ───────────────────────────
2119
+ //
2120
+ // `.oxlintrc.json` and `lefthook.yml` are consumer-owned after the first write: `sync` never
2121
+ // touches either. So 1.20.0's path-resolution fix reached NEW scaffolds only — every existing
2122
+ // monorepo consumer kept a root-relative `extends` that resolves to nothing. oxlint at least
2123
+ // hard-errors (`NotFound`). lefthook does not: a missing extends target merges to ZERO commands and
2124
+ // exits 0, so `lefthook dump` is clean and the repo silently has no pre-commit gate at all.
2125
+ //
2126
+ // Both seams therefore need a checker that resolves the path rather than pattern-matching the
2127
+ // string, and both `doctor` (the gate) and `sync` (the command an upgrade actually runs) report
2128
+ // through it.
2129
+
2130
+ const LEFTHOOK_CONFIG_NAMES = ['lefthook.yml', 'lefthook.yaml', '.lefthook.yml', '.lefthook.yaml']
2131
+
2132
+ /** The lefthook config present at `dir` (repo-root-relative name), or null when there is none. */
2133
+ function findLefthookConfig(dir: string): string | null {
2134
+ return LEFTHOOK_CONFIG_NAMES.find((name) => existsSync(resolve(dir, name))) ?? null
2135
+ }
2136
+
2137
+ /**
2138
+ * The `extends:` entries of a lefthook YAML — the block-sequence form init seeds and the inline
2139
+ * array form a consumer may write instead.
2140
+ *
2141
+ * A three-line reader rather than a YAML dependency: this parses ONE top-level key whose value is a
2142
+ * list of plain scalars, and a wrong answer costs a diagnostic, never a write.
2143
+ */
2144
+ function yamlUnquote(raw: string): string {
2145
+ return raw.trim().replace(/^['"]|['"]$/g, '')
2146
+ }
2147
+
2148
+ function yamlExtendsEntries(text: string): string[] {
2149
+ const lines = text.split('\n')
2150
+ const out: string[] = []
2151
+ for (const [index, line] of lines.entries()) {
2152
+ const inline = /^extends:\s*\[(.*)\]\s*$/.exec(line ?? '')
2153
+ if (inline !== null) {
2154
+ out.push(...(inline[1] ?? '').split(',').map(yamlUnquote).filter(Boolean))
2155
+ continue
2156
+ }
2157
+ if (!/^extends:\s*(#.*)?$/.test(line ?? '')) continue
2158
+ for (const next of lines.slice(index + 1)) {
2159
+ if (/^\s*(#.*)?$/.test(next ?? '')) continue
2160
+ const item = /^\s*-\s*(.+?)\s*$/.exec(next ?? '')
2161
+ if (item === null) break
2162
+ out.push(yamlUnquote(item[1] ?? ''))
2163
+ }
2164
+ }
2165
+ return out
2166
+ }
2167
+
2168
+ /** The state of one shipped-preset `extends` seam in a consumer-owned config. */
2169
+ type ExtendsSeam =
2170
+ /** No such config file — nothing is wired, and that is a legitimate consumer choice. */
2171
+ | { readonly kind: 'no-file' }
2172
+ /** The file exists but names no basalt preset. */
2173
+ | { readonly kind: 'unwired'; readonly file: string }
2174
+ /** It names one, and the path resolves to nothing — the silent-gate case. */
2175
+ | { readonly kind: 'broken'; readonly file: string; readonly entry: string }
2176
+ | { readonly kind: 'ok'; readonly file: string; readonly entry: string }
2177
+
2178
+ /**
2179
+ * Resolve a lefthook config's basalt `extends` entry against the directory the config lives in —
2180
+ * which is how lefthook itself resolves it.
2181
+ */
2182
+ function inspectLefthookSeam(repoRoot: string): ExtendsSeam {
2183
+ const file = findLefthookConfig(repoRoot)
2184
+ if (file === null) return { kind: 'no-file' }
2185
+ const raw = readIfExists(resolve(repoRoot, file))
2186
+ if (raw === null) return { kind: 'no-file' }
2187
+ const entry = yamlExtendsEntries(raw).find((e) => e.endsWith('basalt-ui/configs/lefthook.yml'))
2188
+ if (entry === undefined) return { kind: 'unwired', file }
2189
+ return existsSync(resolve(repoRoot, entry))
2190
+ ? { kind: 'ok', file, entry }
2191
+ : { kind: 'broken', file, entry }
2192
+ }
2193
+
2194
+ /** A `run:` value that invokes the theme guard — the one command that IS the pre-commit gate. */
2195
+ const LEFTHOOK_GUARD_RUN = /^\s*run:\s*.*\bcheck-theme\b/m
2196
+
2197
+ /**
2198
+ * The merged lefthook config as `lefthook dump` renders it, or null when it cannot be obtained.
2199
+ *
2200
+ * Authoritative where the YAML text is not: `dump` resolves `extends`, `include`, remote configs
2201
+ * and per-command `root:`, so it answers "does a gate exist" for a repo that wired the jobs its own
2202
+ * way. Best-effort by design — lefthook is a consumer tool basalt neither installs nor requires, so
2203
+ * an absent binary downgrades the verdict to advisory rather than inventing one.
2204
+ */
2205
+ function lefthookDump(repoRoot: string): string | null {
2206
+ const localBin = resolve(repoRoot, 'node_modules/.bin/lefthook')
2207
+ const bin = existsSync(localBin) ? localBin : 'lefthook'
2208
+ try {
2209
+ const result = spawnSync(bin, ['dump'], {
2210
+ cwd: repoRoot,
2211
+ encoding: 'utf8',
2212
+ timeout: 10_000,
2213
+ stdio: ['ignore', 'pipe', 'ignore'],
2214
+ })
2215
+ if (result.error !== undefined || result.status !== 0) return null
2216
+ return result.stdout
2217
+ } catch {
2218
+ return null
2219
+ }
2220
+ }
2221
+
2222
+ /**
2223
+ * Whether the repo's pre-commit hook genuinely runs basalt's gates — not whether it `extends` the
2224
+ * shipped preset.
2225
+ *
2226
+ * The 1.21.0 check tested for the extends STRING, and a consumer whose root config spells the same
2227
+ * three jobs out with a per-command `root:` (because `extends` merges commands WITHOUT their
2228
+ * working directory, which would run `bunx oxlint` where there is no `.oxlintrc.json`) was warned
2229
+ * at, and told to make a change that would have broken it. A check that is confidently wrong is
2230
+ * worse than one that admits its limit, so this one asks the question three ways and says which
2231
+ * answer it got:
2232
+ *
2233
+ * - `lefthook dump` — authoritative, resolves extends/include/remote/`root:` the same way lefthook
2234
+ * itself does.
2235
+ * - the config text — enough to see a gate spelled out in the root file, which is the shape that
2236
+ * produced the false positive.
2237
+ * - neither — `advisory`, reported as a warning that names what it could not see.
2238
+ */
2239
+ type LefthookGate =
2240
+ | { readonly kind: 'no-file' }
2241
+ | { readonly kind: 'extends-ok'; readonly file: string; readonly entry: string }
2242
+ | { readonly kind: 'extends-broken'; readonly file: string; readonly entry: string }
2243
+ /** A command runs the guard directly. `proof` names what established it. */
2244
+ | { readonly kind: 'wired'; readonly file: string; readonly proof: 'dump' | 'config' }
2245
+ /** `lefthook dump` merged everything and no command runs the guard — provably no gate. */
2246
+ | { readonly kind: 'absent'; readonly file: string }
2247
+ /** No dump, no extends, no guard command in the text — unknown, not absent. */
2248
+ | { readonly kind: 'advisory'; readonly file: string }
2249
+
2250
+ function inspectLefthookGate(repoRoot: string): LefthookGate {
2251
+ const seam = inspectLefthookSeam(repoRoot)
2252
+ if (seam.kind === 'no-file') return { kind: 'no-file' }
2253
+ // A resolvable extends is the wiring basalt seeds; report it as itself rather than as a generic
2254
+ // "a command exists", since the remedy for a BROKEN one is specific and worth keeping.
2255
+ if (seam.kind === 'ok') return { kind: 'extends-ok', file: seam.file, entry: seam.entry }
2256
+ if (seam.kind === 'broken') {
2257
+ return { kind: 'extends-broken', file: seam.file, entry: seam.entry }
2258
+ }
2259
+
2260
+ const dump = lefthookDump(repoRoot)
2261
+ if (dump !== null) {
2262
+ return LEFTHOOK_GUARD_RUN.test(dump)
2263
+ ? { kind: 'wired', file: seam.file, proof: 'dump' }
2264
+ : { kind: 'absent', file: seam.file }
2265
+ }
2266
+ const raw = readIfExists(resolve(repoRoot, seam.file)) ?? ''
2267
+ if (LEFTHOOK_GUARD_RUN.test(raw)) return { kind: 'wired', file: seam.file, proof: 'config' }
2268
+ return { kind: 'advisory', file: seam.file }
1606
2269
  }
1607
2270
 
1608
2271
  /**
@@ -1617,7 +2280,7 @@ function mergeOxlintExtends(cwd: string, presetPath: string): MergeLintResult {
1617
2280
  if (raw === null) return 'absent'
1618
2281
  const cfg = parseJsonc(raw)
1619
2282
  if (cfg === null) return 'unreadable'
1620
- if (extendsBasaltPreset(cfg['extends'])) return 'already'
2283
+ if (basaltPresetEntry(cfg['extends']) !== null) return 'already'
1621
2284
  // Rewriting a JSONC config through JSON.stringify would silently delete the consumer's comments,
1622
2285
  // which in a lint config are usually the WHY of every disabled rule. Refuse and say so.
1623
2286
  if (stripJsonc(raw) !== raw) return 'has-comments'
@@ -1669,23 +2332,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
1669
2332
  )
1670
2333
  }
1671
2334
 
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
- }
2335
+ const rootsState = reconcileRoots(cwd, { write: true })
1689
2336
 
1690
2337
  // Built AFTER the roots patch — every roots-derived template variable reads the key just written.
1691
2338
  const ctx = renderContext(cwd)
@@ -1774,20 +2421,9 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
1774
2421
  }
1775
2422
  console.log(`basalt-ui init: --merge-lint — ${mergeMessage[mergeLint]}`)
1776
2423
  }
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
- }
2424
+ const rootsMessages = rootsNotices(rootsState)
2425
+ for (const line of rootsMessages.log) console.log(`basalt-ui init: ${line}`)
2426
+ for (const line of rootsMessages.error) console.error(`⚠ basalt-ui init: ${line}`)
1791
2427
  if (seededLintScript) {
1792
2428
  console.log(`basalt-ui init: added the "lint:basalt" script (${lintScript}) to package.json.`)
1793
2429
  }
@@ -1885,14 +2521,78 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
1885
2521
 
1886
2522
  type SyncOptions = { force?: boolean; check?: boolean }
1887
2523
 
2524
+ /**
2525
+ * The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
2526
+ *
2527
+ * `resolveProjectDir` relocates DOWNWARD — a root-invoked hook finding the one workspace package
2528
+ * that holds the config. The opposite direction is the shape that produced this function: a
2529
+ * consumer standing in `apps/dashboard`, the package that actually depends on basalt-ui, whose
2530
+ * install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
2531
+ * "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
2532
+ */
2533
+ function findManifestAbove(dir: string): string | null {
2534
+ let current = resolve(dir, '..')
2535
+ for (;;) {
2536
+ if (existsSync(resolve(current, MANIFEST_PATH))) return current
2537
+ const parent = resolve(current, '..')
2538
+ if (parent === current) return null
2539
+ current = parent
2540
+ }
2541
+ }
2542
+
1888
2543
  /**
1889
2544
  * Reconcile managed files with the shipped versions via a sha256 three-way compare.
1890
2545
  *
1891
2546
  * - `--check` makes NO writes; exits non-zero if any managed file is out-of-date or locally drifted
1892
2547
  * (a CI freshness gate), exit 0 when all current.
1893
2548
  * - `--force` overwrites locally-drifted files instead of skipping them.
2549
+ *
2550
+ * Runs against `resolveProjectDir(cwd)`, exactly as `check-theme` and `doctor` do — and then
2551
+ * REFUSES rather than scaffolding when the resolved project has no manifest. See
2552
+ * {@link findManifestAbove} for the failure that bought this: `sync` is the drift refresh and
2553
+ * `init` is the scaffold, so placing twenty files on a cwd that was never a consumer is a decision
2554
+ * `sync` does not get to make silently.
1894
2555
  */
1895
- export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): number {
2556
+ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
2557
+ const project = resolveProjectDir(invocationCwd)
2558
+ if (project.ambiguous !== null) {
2559
+ console.error(
2560
+ `✖ basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
2561
+ `workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
2562
+ 'run it from one of them, or set BASALT_CWD to pick.',
2563
+ )
2564
+ return 1
2565
+ }
2566
+ const cwd = project.dir
2567
+ if (project.relocatedFrom !== null) {
2568
+ console.log(
2569
+ `basalt-ui sync: no basalt config at ${project.relocatedFrom} — running in ` +
2570
+ `${relativePosix(project.relocatedFrom, cwd)}, where it lives.`,
2571
+ )
2572
+ }
2573
+
2574
+ // The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
2575
+ // key is half the damage: a second `basalt` block means check-theme scans a different tree
2576
+ // depending on which directory it was invoked from.
2577
+ if (!existsSync(resolve(cwd, MANIFEST_PATH))) {
2578
+ const elsewhere = findManifestAbove(cwd)
2579
+ const wouldWrite = managedFiles(resolvePeerFlags(cwd, {}), resolvePlacement(cwd)).length
2580
+ console.error(
2581
+ `✖ basalt-ui sync: no ${MANIFEST_PATH} at ${cwd} — refusing to scaffold. ` +
2582
+ `\`sync\` refreshes an EXISTING basalt install; creating one is \`basalt-ui init\`'s ` +
2583
+ `decision, and continuing here would write ${wouldWrite} managed file(s) into this ` +
2584
+ 'directory.' +
2585
+ (elsewhere === null
2586
+ ? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
2587
+ 'package really is meant to be a consumer.'
2588
+ : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) — run ` +
2589
+ 'sync there, or set BASALT_CWD to it.'),
2590
+ )
2591
+ return 1
2592
+ }
2593
+
2594
+ // Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
2595
+ const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
1896
2596
  const ctx = renderContext(cwd)
1897
2597
  const manifest = readManifest(cwd)
1898
2598
  if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
@@ -1901,6 +2601,11 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1901
2601
  const files = managedFiles(peers, placement)
1902
2602
 
1903
2603
  let updated = 0
2604
+ // `created` and `recreated` were one counter, and `0 updated, 20 recreated` is what a full
2605
+ // scaffold printed — a word that means "it was here, it went missing, I put it back" describing
2606
+ // twenty files this directory never had. The ledger already knows which: an entry in
2607
+ // `manifest.files` is basalt saying it placed the file once.
2608
+ let created = 0
1904
2609
  let recreated = 0
1905
2610
  let skippedDrift = 0
1906
2611
  let staleForCheck = 0
@@ -1914,11 +2619,14 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1914
2619
  continue
1915
2620
  }
1916
2621
 
2622
+ const tracked = manifest.files[file.dest] !== undefined
2623
+
1917
2624
  // `seed` is written once, then owned by the consumer — never reconciled, never reported.
1918
2625
  if (file.mode === 'seed') {
1919
2626
  if (state.current === null && !opts.check) {
1920
2627
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
1921
- recreated++
2628
+ if (tracked) recreated++
2629
+ else created++
1922
2630
  }
1923
2631
  continue
1924
2632
  }
@@ -1944,8 +2652,9 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1944
2652
  staleForCheck++
1945
2653
  if (opts.check) continue
1946
2654
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
1947
- if (kind === 'missing') recreated++
1948
- else updated++
2655
+ if (kind !== 'missing') updated++
2656
+ else if (tracked) recreated++
2657
+ else created++
1949
2658
  }
1950
2659
 
1951
2660
  // Placement notices — informational, never affect the exit code (a skipped tooling seed or a
@@ -1970,6 +2679,35 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1970
2679
  )
1971
2680
  }
1972
2681
 
2682
+ const rootsMessages = rootsNotices(rootsState)
2683
+ for (const line of rootsMessages.log) console.log(`basalt-ui sync: ${line}`)
2684
+ for (const line of rootsMessages.error) console.error(`⚠ basalt-ui sync: ${line}`)
2685
+
2686
+ // The two `extends` seams sync does NOT own. Reported here because sync is the command an
2687
+ // UPGRADE runs, and an upgrade is exactly when a resolved path goes stale — 1.20.0 fixed the
2688
+ // paths `init` renders and reached no existing consumer at all. Informational: these are
2689
+ // consumer-owned files, and `doctor` is the gate that fails on them.
2690
+ const install = findBasaltInstall(cwd)
2691
+ const lefthookSeam = inspectLefthookSeam(placement.repoRoot)
2692
+ if (lefthookSeam.kind === 'broken') {
2693
+ console.error(
2694
+ `⚠ basalt-ui sync: ${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not ` +
2695
+ 'exist — lefthook merges a missing extends target into ZERO commands and still exits 0, ' +
2696
+ 'so there is no pre-commit gate. Repoint it at ' +
2697
+ `"${shippedAssetPath(install, placement.repoRoot, 'configs/lefthook.yml')}".`,
2698
+ )
2699
+ }
2700
+ const oxlintrcRaw = readIfExists(resolve(cwd, '.oxlintrc.json'))
2701
+ const oxlintEntry =
2702
+ oxlintrcRaw === null ? null : basaltPresetEntry(parseJsonc(oxlintrcRaw)?.['extends'])
2703
+ if (oxlintEntry !== null && !existsSync(resolve(cwd, oxlintEntry))) {
2704
+ console.error(
2705
+ `⚠ basalt-ui sync: .oxlintrc.json extends "${oxlintEntry}", which does not exist — oxlint ` +
2706
+ 'refuses to start on a missing extends target (`NotFound`). Repoint it at ' +
2707
+ `"${shippedAssetPath(install, cwd, 'configs/oxlint.json')}".`,
2708
+ )
2709
+ }
2710
+
1973
2711
  if (opts.check) {
1974
2712
  if (driftLines.length > 0) {
1975
2713
  console.error('basalt-ui sync --check: locally-drifted managed files:')
@@ -1988,7 +2726,8 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
1988
2726
  writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
1989
2727
 
1990
2728
  console.log(
1991
- `basalt-ui sync: ${updated} updated, ${recreated} recreated, ${skippedDrift} skipped (drift).`,
2729
+ `basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ` +
2730
+ `${skippedDrift} skipped (drift).`,
1992
2731
  )
1993
2732
  if (driftLines.length > 0) {
1994
2733
  console.log('Locally-edited files were skipped (run with --force to overwrite):')
@@ -2430,10 +3169,17 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
2430
3169
  * It is also the moment `bunx basalt-ui` stops using the pinned copy.
2431
3170
  * 6. `guard-scan`: `check-theme` would scan MORE than zero files. `check-theme` already exits 1
2432
3171
  * 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
3172
+ * 7. `oxlint-preset`: the consumer's `.oxlintrc.json` actually `extends` the shipped preset AND
3173
+ * that path RESOLVES. `init` keeps an existing config, so the framework's whole lint half can
3174
+ * be off with nothing to say so — one repo carried six real violations invisibly across five
3175
+ * minors that way. Matching the string alone reproduced the same false green one layer in: a
3176
+ * well-shaped entry pointing at a path that no longer exists passed this check in the very
3177
+ * tree where oxlint refused to start with `NotFound`.
3178
+ * 8. `lefthook-preset`: the repo-root lefthook config's `extends` target resolves. lefthook
3179
+ * merges a MISSING target into zero commands and exits 0 — no error, a clean `lefthook dump`,
3180
+ * and no pre-commit gate. Nothing but a check can see that. Not-extending-at-all is a warning
3181
+ * (a consumer may run their own hooks); extending a path that isn't there is a hard failure.
3182
+ * 10. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
2437
3183
  * major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
2438
3184
  * a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
2439
3185
  * ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
@@ -2454,9 +3200,9 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
2454
3200
  * demonstrably not the installed package — see the check).
2455
3201
  * 5. The running CLI's own version matches the installed basalt-ui (catches a stale
2456
3202
  * `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
3203
+ * 9. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
2458
3204
  * a public/ dir exists at all — skipped otherwise).
2459
- * 9 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
3205
+ * 10 (second half). A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the
2460
3206
  * exemption is stale and can be deleted; an exemption nobody revisits is how a real, later
2461
3207
  * skew slips through unnoticed.
2462
3208
  *
@@ -2713,19 +3459,101 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
2713
3459
  skip(
2714
3460
  '.oxlintrc.json does not parse as JSON/JSONC — cannot tell whether it extends the preset',
2715
3461
  )
2716
- } else if (extendsBasaltPreset(parsed['extends'])) {
2717
- pass('.oxlintrc.json extends the shipped basalt-ui oxlint preset')
2718
3462
  } 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
- )
3463
+ const entry = basaltPresetEntry(parsed['extends'])
3464
+ const correct = shippedAssetPath(install, cwd, 'configs/oxlint.json')
3465
+ if (entry === null) {
3466
+ fail(
3467
+ '.oxlintrc.json does NOT extend the shipped preset — every basalt/* design rule is off ' +
3468
+ `and oxlint reports green without them. Add "extends": ["${correct}"], or re-run ` +
3469
+ '`basalt-ui init --merge-lint` to splice it in.',
3470
+ )
3471
+ } else if (!existsSync(resolve(cwd, entry)) && entry === correct && install.dir !== null) {
3472
+ // The path is the one basalt resolves from and the asset still isn't there — a partial
3473
+ // or corrupted install, not a stale path. Saying "repoint it at <the same string>" would
3474
+ // be the unhelpful shape of a correct diagnosis.
3475
+ fail(
3476
+ `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
3477
+ 'basalt-ui resolves there but ships no `configs/oxlint.json` at that path, so oxlint ' +
3478
+ 'refuses to start (`NotFound`) and nothing is linted. Reinstall basalt-ui.',
3479
+ )
3480
+ } else if (!existsSync(resolve(cwd, entry))) {
3481
+ // The string is right and the file is not there. oxlint refuses to start at all
3482
+ // (`invalid config file … NotFound`), so a green line here is a check that proves the
3483
+ // framework is ON while the linter is dead — the same false-green class this check was
3484
+ // added to close, one layer in. `install` already computed the true path in this run.
3485
+ fail(
3486
+ `.oxlintrc.json extends "${entry}", which does not exist (${resolve(cwd, entry)}) — ` +
3487
+ 'oxlint refuses to start on a missing extends target (`NotFound`), so nothing is ' +
3488
+ `linted at all. Repoint it at "${correct}", where basalt-ui actually resolves.`,
3489
+ )
3490
+ } else {
3491
+ pass(`.oxlintrc.json extends the shipped basalt-ui oxlint preset (${entry})`)
3492
+ }
2725
3493
  }
2726
3494
  }
2727
3495
  }
2728
3496
 
3497
+ // ── Check 8: the pre-commit hook actually runs the guard ───────────────────
3498
+ // The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
3499
+ // zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
3500
+ // exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
3501
+ // Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
3502
+ // remedy line someone reads after a failure.
3503
+ //
3504
+ // But a BROKEN extends is the only half of this that can be asserted from the string. Whether a
3505
+ // gate EXISTS is a different question, and 1.21.0 answered it by testing for the extends target
3506
+ // — warning at a correctly-wired repo and prescribing a change that would have broken it. See
3507
+ // {@link inspectLefthookGate}: `lefthook dump` where it runs, the config text where it doesn't,
3508
+ // and an advisory that names the gap where neither settles it.
3509
+ const repoRoot = findRepoRoot(cwd)
3510
+ const lefthookGate =
3511
+ profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookGate(repoRoot)
3512
+ const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
3513
+ if (lefthookGate.kind === 'n/a') {
3514
+ pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
3515
+ } else if (lefthookGate.kind === 'no-file') {
3516
+ pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
3517
+ } else if (lefthookGate.kind === 'extends-ok') {
3518
+ pass(`${lefthookGate.file} extends the shipped lefthook preset (${lefthookGate.entry})`)
3519
+ } else if (lefthookGate.kind === 'wired') {
3520
+ // Extending the preset is basalt's recommendation, not the contract. The contract is that a
3521
+ // commit cannot land without the guard running, and a repo spelling the jobs out with a
3522
+ // per-command `root:` satisfies it — `extends` merges commands without their working
3523
+ // directory, so for that repo the preset is the thing that would NOT work.
3524
+ pass(
3525
+ `lefthook-preset: ${lefthookGate.file} does not extend the shipped preset, but a pre-commit ` +
3526
+ `command runs check-theme directly — the gate exists (read from ` +
3527
+ (lefthookGate.proof === 'dump'
3528
+ ? '`lefthook dump`, so extends/include/root: are all resolved).'
3529
+ : 'the config text; `lefthook dump` was not runnable here, so an `include:` or a ' +
3530
+ 'remote config was not consulted).'),
3531
+ )
3532
+ } else if (lefthookGate.kind === 'extends-broken') {
3533
+ fail(
3534
+ `${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist ` +
3535
+ `(${resolve(repoRoot, lefthookGate.entry)}) — lefthook merges a missing extends ` +
3536
+ 'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
3537
+ `\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
3538
+ )
3539
+ } else if (lefthookGate.kind === 'absent') {
3540
+ // Warn, not fail — same tier the string-matching version used. A consumer may deliberately
3541
+ // gate in CI only, and doctor does not get to promote its own advice to a build break.
3542
+ warn(
3543
+ `${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
3544
+ 'command runs check-theme — the theme guard is not gating commits. Either add ' +
3545
+ `"extends: [${lefthookCorrect}]", or add your own command running ` +
3546
+ '`bunx basalt-ui check-theme` (with `root:` if it must run in a subdirectory).',
3547
+ )
3548
+ } else {
3549
+ warn(
3550
+ `${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
3551
+ 'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
3552
+ 'config it never read — treat it as advisory. If the guard really is unwired, add ' +
3553
+ `"extends: [${lefthookCorrect}]" or your own command running \`bunx basalt-ui check-theme\`.`,
3554
+ )
3555
+ }
3556
+
2729
3557
  // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
2730
3558
  // basalt-ui/vite's basaltAppPlugin (head/manifest metadata) references these filenames by
2731
3559
  // default (the realfavicongenerator convention). Only runs when a public/ dir exists at all —
@@ -2978,8 +3806,8 @@ function generatedHeader(version: string, command: string, flags: string[]): str
2978
3806
  }
2979
3807
 
2980
3808
  /**
2981
- * Space-normalize the argument list of a legacy color function so the emitted file survives a
2982
- * normal repo's formatter.
3809
+ * Space- and number-normalize the argument list of a legacy color function so the emitted file
3810
+ * survives a normal repo's formatter.
2983
3811
  *
2984
3812
  * This is FORMATTING ONLY and deliberately the CLI's single deviation from "print exactly what
2985
3813
  * `buildPaletteCss` returned": `rgba(255,255,255,0.6)` and `rgba(255, 255, 255, 0.6)` are the same
@@ -2987,15 +3815,27 @@ function generatedHeader(version: string, command: string, flags: string[]): str
2987
3815
  * side, and a consumer committing the output ate a lint-ignore entry for the difference. The token
2988
3816
  * VALUES are untouched — nothing here can change what basalt's tokens are.
2989
3817
  *
3818
+ * Trailing zeros go the same way and for the same reason: prettier rewrites `0.10` → `0.1`, so the
3819
+ * two `rgba(28, 25, 23, 0.10)` alphas in the light shadow tokens were the whole reason the one
3820
+ * framework-free consumer still could not lint its committed sheet — `--fix` and `tokens:css
3821
+ * --check` disagreed forever, and the lint-ignore entry survived two releases. Only a bare decimal
3822
+ * literal is touched (never a `%`, a `var()` or a unit), and `0.10`/`0.1` are the same number.
3823
+ *
2990
3824
  * Exported for tests, so the expectation can be computed rather than restated.
2991
3825
  */
2992
3826
  export function normalizeColorFunctions(css: string): string {
2993
3827
  return css.replace(/\b(rgba?|hsla?)\(([^()]*)\)/g, (_match, fn: string, args: string) => {
2994
- const parts = args.split(',').map((part) => part.trim())
3828
+ const parts = args.split(',').map((part) => normalizeCssNumber(part.trim()))
2995
3829
  return `${fn}(${parts.join(', ')})`
2996
3830
  })
2997
3831
  }
2998
3832
 
3833
+ /** `0.10` → `0.1`, `1.0` → `1`; anything that is not a bare decimal literal is returned verbatim. */
3834
+ function normalizeCssNumber(arg: string): string {
3835
+ if (!/^\d+\.\d+$/.test(arg)) return arg
3836
+ return arg.replace(/\.?0+$/, '')
3837
+ }
3838
+
2999
3839
  /** A scheme-class emission uses this sentinel attribute, then rewrites it to a class selector. */
3000
3840
  const SCHEME_CLASS_SENTINEL = 'data-basalt-scheme-class'
3001
3841
 
@@ -3236,7 +4076,7 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
3236
4076
  rawFormControl: cfg.rawFormControl ?? DEFAULT_GUARD_CONFIG.rawFormControl,
3237
4077
  sub16InputFont: cfg.sub16InputFont ?? DEFAULT_GUARD_CONFIG.sub16InputFont,
3238
4078
  allowComment: 'theme-allow',
3239
- exemptRules: cfg.exemptRules ?? DEFAULT_GUARD_CONFIG.exemptRules,
4079
+ exemptRules: resolveExemptRules(cfg),
3240
4080
  severity: cfg.severity ?? DEFAULT_GUARD_CONFIG.severity,
3241
4081
  // Same detection check-theme uses: the hook must never block an edit over advice the app
3242
4082
  // cannot take ("use @mantine/core's Select") in a repo with no Mantine.
@@ -3281,8 +4121,13 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
3281
4121
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
3282
4122
  const USAGE =
3283
4123
  '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' +
4124
+ ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
4125
+ ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
4126
+ 'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
4127
+ ' --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n' +
4128
+ ' `basalt.exemptRules` entry, with what each one still suppresses — proved by re-running the\n' +
4129
+ ' guard with that one waiver neutralized, not by reading its text. Exits 1 on a waiver that\n' +
4130
+ ' suppresses nothing (dead), which is what makes it usable as a CI gate.\n\n' +
3286
4131
  'tokens:css [--out <path>] [--check] [--selector-attribute <attr> | --selector-class <class>]\n' +
3287
4132
  ' [--light-class <class>] [--dark-value <v>] [--light-value <v>]\n' +
3288
4133
  ' [--default-scheme <dark|light|none>] [--media-fallback] [--only <core|all>]\n' +