basalt-ui 1.21.0 → 1.23.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 (85) hide show
  1. package/AGENTS.md +11 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +205 -9
  4. package/README.md +63 -16
  5. package/agent/rules/basalt-app.md +39 -9
  6. package/agent/rules/basalt-charts.md +41 -6
  7. package/agent/rules/basalt-router.md +1 -1
  8. package/agent/rules/basalt-state.md +4 -4
  9. package/agent/rules/basalt-tokens.md +25 -4
  10. package/agent/skills/basalt-app/SKILL.md +4 -3
  11. package/agent/skills/basalt-charts/SKILL.md +23 -8
  12. package/agent/templates/DESIGN.md.tpl +3 -1
  13. package/configs/oxlint-plugin.js +86 -9
  14. package/configs/oxlint-plugin.test.ts +173 -0
  15. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  16. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  17. package/dist/charts/hooks/useBandPlot.js +127 -0
  18. package/dist/charts/index.d.ts +4 -0
  19. package/dist/charts/index.d.ts.map +1 -1
  20. package/dist/charts/index.js +14 -0
  21. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  22. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  23. package/dist/charts/kinds/BandStrip.js +218 -0
  24. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/Bars.d.ts +5 -1
  27. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  28. package/dist/charts/kinds/Bars.js +2 -0
  29. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  31. package/dist/charts/kinds/MirroredBars.js +315 -0
  32. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  34. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  35. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  36. package/dist/charts/kinds/MultiLine.js +2 -0
  37. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  38. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  39. package/dist/charts/kinds/StackedArea.js +2 -0
  40. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  41. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  42. package/dist/charts/kinds/ZonedLine.js +2 -0
  43. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  44. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  45. package/dist/charts/primitives/CartesianChart.js +5 -2
  46. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  47. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  48. package/dist/charts/primitives/HatchPattern.js +31 -0
  49. package/dist/cli/index.d.ts +9 -1
  50. package/dist/cli/index.d.ts.map +1 -1
  51. package/dist/cli/index.js +275 -48
  52. package/dist/cli/shipped-versions.test.d.ts +2 -0
  53. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  54. package/dist/guard/index.d.ts +67 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +70 -11
  57. package/dist/vite.d.ts +44 -2
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +71 -26
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +200 -0
  65. package/src/charts/kinds/BandStrip.tsx +364 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +193 -0
  69. package/src/charts/kinds/MirroredBars.tsx +455 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/check-theme.test.ts +35 -0
  77. package/src/cli/help.test.ts +5 -1
  78. package/src/cli/index.ts +552 -79
  79. package/src/cli/shipped-versions.test.ts +83 -0
  80. package/src/cli/toolchain-wiring.test.ts +362 -6
  81. package/src/cli/waiver-audit.test.ts +88 -4
  82. package/src/guard/check-source.test.ts +293 -0
  83. package/src/guard/index.ts +209 -20
  84. package/src/vite.test.ts +112 -0
  85. package/src/vite.ts +147 -31
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,9 +37,11 @@ import {
36
37
  checkSource,
37
38
  DEFAULT_GUARD_CONFIG,
38
39
  GENERATED_HEADER_LINE,
40
+ findAllowAnnotations,
39
41
  guardKindRemedy,
40
42
  GUARD_RULES,
41
43
  guardWaiverHint,
44
+ neutralizeAllowAnnotation,
42
45
  TOKENS_ONLY_DISABLED_KINDS,
43
46
  unmatchedExemptPatterns,
44
47
  } from '../guard'
@@ -508,6 +511,34 @@ type ProjectResolution = {
508
511
  ambiguous: string[] | null
509
512
  }
510
513
 
514
+ /**
515
+ * Basalt projects in `cwd`'s immediate subtree — the fallback for a repo that declares no
516
+ * `workspaces` field at all.
517
+ *
518
+ * {@link collectWorkspacePackages} is the primary source and it is exact, but it answers `[]` for
519
+ * every repo whose root `package.json` carries no `workspaces` key. linewatch is that shape: the
520
+ * entire basalt consumer lives in `web/`, with no workspace declaration anywhere. Measured at its
521
+ * repo root, all three project-scoped commands got that wrong in a different way — `check-theme`
522
+ * printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred `tokens-only` for
523
+ * a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as the remedy, which is
524
+ * the one command that WOULD have written a competing install at the root. A gate that reports
525
+ * green having looked at nothing is the failure this resolver exists to prevent, so the search
526
+ * falls back to the layout when there is no declaration to read.
527
+ *
528
+ * Bounded to two levels (`web/`, `apps/admin/`), dot-directories and `node_modules` skipped, and it
529
+ * never descends INTO a project it found — a consumer's own nested package is not a rival candidate.
530
+ */
531
+ function descendantProjects(cwd: string, depth = 2): string[] {
532
+ if (depth === 0) return []
533
+ const out: string[] = []
534
+ for (const name of subdirNames(cwd, { skipDotDirs: true })) {
535
+ const dir = resolve(cwd, name)
536
+ if (hasBasaltProject(dir)) out.push(dir)
537
+ else out.push(...descendantProjects(dir, depth - 1))
538
+ }
539
+ return out
540
+ }
541
+
511
542
  function resolveProjectDir(cwd: string): ProjectResolution {
512
543
  const override = process.env['BASALT_CWD']
513
544
  if (override !== undefined && override.length > 0) {
@@ -516,9 +547,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
516
547
  }
517
548
  if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
518
549
 
519
- const candidates = collectWorkspacePackages(cwd)
550
+ const declared = collectWorkspacePackages(cwd)
520
551
  .packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
521
552
  .map((pkg) => pkg.dir)
553
+ // The workspace declaration wins whenever there is one — it is exact, and a repo that declares
554
+ // its packages has already said where they are. The layout scan only answers for the repos that
555
+ // declare nothing; see descendantProjects.
556
+ const candidates = declared.length > 0 ? declared : descendantProjects(cwd)
522
557
  if (candidates.length === 1) {
523
558
  return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
524
559
  }
@@ -636,6 +671,37 @@ function appShellFiles(rootAbs: string): string[] {
636
671
  return out
637
672
  }
638
673
 
674
+ /**
675
+ * A path in the JSON family that is NOT a manifest — the sentinel that gets `guardWaiverHint`'s
676
+ * plain-JSON closer. Used only by {@link waiverHintFor}; see its doc for why.
677
+ */
678
+ const PLAIN_JSON_HINT_PATH = 'manifest.json'
679
+
680
+ /**
681
+ * The waiver closer for a file, gated on the profile.
682
+ *
683
+ * `guardWaiverHint` keys off the file class, and for a `.webmanifest` it leads with
684
+ * `basaltAppPlugin` — right for a Mantine app, since a hex in a manifest can never be *right* and
685
+ * the plugin removes the hand-copy entirely. A `profile: tokens-only` consumer has definitionally
686
+ * opted out of that layer: rollhook's Astro site has no `index.html` for the plugin to transform,
687
+ * imports no basalt JavaScript at all, and owns maskable icons the plugin does not emit. Leading
688
+ * with the plugin there is advice the consumer cannot take, in the one release that finally gave
689
+ * that file class a remedy.
690
+ *
691
+ * So under tokens-only a manifest is treated as what it is to that consumer — plain JSON, with the
692
+ * member as the whole answer — plus one sentence saying which remedy is being withheld and why.
693
+ * The remedy text still comes from the guard's own registry; only the class mapping is decided here.
694
+ */
695
+ function waiverHintFor(relPath: string, profile: DoctorProfile): string {
696
+ if (profile !== 'tokens-only' || !relPath.endsWith('.webmanifest')) {
697
+ return guardWaiverHint(relPath)
698
+ }
699
+ return (
700
+ `${guardWaiverHint(PLAIN_JSON_HINT_PATH)} (basaltAppPlugin would emit the file instead, but ` +
701
+ "it needs basalt's Vite/React layer, which a tokens-only consumer has opted out of.)"
702
+ )
703
+ }
704
+
639
705
  /**
640
706
  * Theme guard — thin FS walker over the headless `../guard` core. Reads BasaltConfig, builds a
641
707
  * GuardConfig, walks roots, calls checkSource per file, collects Finding[], groups/reports, returns
@@ -658,7 +724,7 @@ export function checkTheme(
658
724
  if (project.ambiguous !== null) {
659
725
  console.error(
660
726
  `✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
661
- `workspace packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
727
+ `packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
662
728
  'run it from one of them, or set BASALT_CWD to pick.',
663
729
  )
664
730
  return 1
@@ -796,7 +862,9 @@ export function checkTheme(
796
862
  const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted()
797
863
  // And the closer is per FILE CLASS: prescribing a `theme-allow` comment to a .webmanifest (which
798
864
  // 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()
865
+ const waiverHints = [
866
+ ...new Set(findings.map((f) => waiverHintFor(f.relPath, profile))),
867
+ ].toSorted()
800
868
  console.error(['Fix:', ...presentKinds.map(guardKindRemedy), ...waiverHints].join(' '))
801
869
 
802
870
  // A violation in a file that looks like the palette source itself is likely intentional — point
@@ -814,48 +882,148 @@ export function checkTheme(
814
882
 
815
883
  // ── check-theme --audit-allows — every waiver, and whether it still suppresses anything ─────────
816
884
 
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
885
  /** `relPath:line:kind` — a finding's identity for set arithmetic across two runs of the same file. */
821
886
  function findingKey(f: Finding): string {
822
887
  return `${f.relPath}:${f.line}:${f.kind}`
823
888
  }
824
889
 
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*$/
890
+ /** One annotation the guard cannot judge, queued for the oxlint half of the audit. */
891
+ type PluginProbe = {
892
+ /** Index into the report's line array, so the verdict lands where the placeholder sits. */
893
+ readonly slot: number
894
+ readonly rel: string
895
+ /** 1-based line of the annotation, as {@link findAllowAnnotations} reports it. */
896
+ readonly line: number
897
+ readonly ids: readonly string[]
898
+ readonly site: string
899
+ readonly suffix: string
900
+ }
901
+
902
+ /** One oxlint diagnostic, as `--format=json` renders it — only the fields the audit reads. */
903
+ type OxlintDiagnostic = {
904
+ code?: unknown
905
+ filename?: unknown
906
+ labels?: { span?: { line?: unknown } }[]
907
+ }
908
+
909
+ /** The nearest `node_modules/.bin/<name>` walking up from `dir`, else `name` if it is on PATH. */
910
+ function resolveToolBin(dir: string, name: string): string | null {
911
+ let current = dir
912
+ for (;;) {
913
+ const candidate = resolve(current, 'node_modules/.bin', name)
914
+ if (existsSync(candidate)) return candidate
915
+ const parent = resolve(current, '..')
916
+ if (parent === current) break
917
+ current = parent
918
+ }
919
+ const probe = spawnSync(name, ['--version'], { stdio: 'ignore', timeout: 10_000 })
920
+ return probe.error === undefined && probe.status === 0 ? name : null
921
+ }
922
+
923
+ /** `line:rule` counts for one file's diagnostics — a multiset, so two findings on a line differ. */
924
+ function diagnosticCounts(
925
+ diagnostics: readonly OxlintDiagnostic[],
926
+ filename: string,
927
+ ): Map<string, number> {
928
+ const counts = new Map<string, number>()
929
+ for (const d of diagnostics) {
930
+ if (d.filename !== filename) continue
931
+ const code = typeof d.code === 'string' ? d.code : ''
932
+ const rule = /^basalt\((.+)\)$/.exec(code)?.[1]
933
+ if (rule === undefined) continue
934
+ const line = d.labels?.[0]?.span?.line
935
+ const key = `${typeof line === 'number' ? line : 0}:${rule}`
936
+ counts.set(key, (counts.get(key) ?? 0) + 1)
937
+ }
938
+ return counts
939
+ }
835
940
 
836
941
  /**
837
- * The rule ids an annotation NAMES, as written. Classification only — what it actually waives is
838
- * decided by re-running the guard.
942
+ * The oxlint half of the audit: what each plugin-rule annotation still suppresses.
943
+ *
944
+ * The `check-theme` half proves a verdict by re-running `checkSource` with one occurrence
945
+ * neutralized. The `error`-severity design rules do not live there — they live in the oxlint
946
+ * plugin — so the audit declined to judge them and printed "not a check-theme kind". In a
947
+ * chart-heavy consumer that was 8 of 8 and 11 of 14: a CI gate that exited 0 having inspected
948
+ * nothing. The method generalizes exactly, one level over: neutralize the annotation, re-run
949
+ * oxlint over that file, and the findings that appear are what it covers.
839
950
  *
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.
951
+ * oxlint has no stdin mode, so a probe has to be a real file. It is written BESIDE the original —
952
+ * same directory, so the same `.oxlintrc.json`, the same `charts/` path segment, the same nearest
953
+ * `package.json` — and removed in a `finally`. Returns null when oxlint cannot be run at all, which
954
+ * is a "cannot judge", never a "dead".
844
955
  */
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
- }
956
+ function probePluginRules(
957
+ cwd: string,
958
+ guardCfg: GuardConfig,
959
+ probes: readonly PluginProbe[],
960
+ sources: ReadonlyMap<string, string>,
961
+ ): Map<number, string[]> | null {
962
+ if (probes.length === 0) return new Map()
963
+ const bin = resolveToolBin(cwd, 'oxlint')
964
+ if (bin === null) return null
965
+
966
+ const written: string[] = []
967
+ const probePaths = new Map<number, string>()
968
+ try {
969
+ for (const probe of probes) {
970
+ // Neutralized by the guard's own helper, so the oxlint probe and the checkSource probe can
971
+ // never differ by a substitution detail — a verdict that disagrees between the two halves of
972
+ // one audit is worse than no verdict.
973
+ const probed = neutralizeAllowAnnotation(sources.get(probe.rel) ?? '', probe.line, guardCfg)
974
+ const dot = probe.rel.lastIndexOf('.')
975
+ const rel =
976
+ dot === -1
977
+ ? `${probe.rel}.basalt-audit-${probe.slot}`
978
+ : `${probe.rel.slice(0, dot)}.basalt-audit-${probe.slot}${probe.rel.slice(dot)}`
979
+ const abs = resolve(cwd, rel)
980
+ if (existsSync(abs)) continue
981
+ writeFileSync(abs, probed, 'utf8')
982
+ written.push(abs)
983
+ probePaths.set(probe.slot, rel)
984
+ }
985
+ if (probePaths.size === 0) return null
852
986
 
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))
987
+ const targets = [...new Set(probes.map((p) => p.rel)), ...probePaths.values()]
988
+ const run = spawnSync(bin, ['--format=json', '--no-error-on-unmatched-pattern', ...targets], {
989
+ cwd,
990
+ encoding: 'utf8',
991
+ timeout: 120_000,
992
+ stdio: ['ignore', 'pipe', 'ignore'],
993
+ })
994
+ if (run.error !== undefined || typeof run.stdout !== 'string') return null
995
+ let diagnostics: OxlintDiagnostic[]
996
+ try {
997
+ diagnostics =
998
+ (JSON.parse(run.stdout) as { diagnostics?: OxlintDiagnostic[] }).diagnostics ?? []
999
+ } catch {
1000
+ return null
1001
+ }
1002
+
1003
+ const verdicts = new Map<number, string[]>()
1004
+ for (const probe of probes) {
1005
+ const probeRel = probePaths.get(probe.slot)
1006
+ if (probeRel === undefined) continue
1007
+ const before = diagnosticCounts(diagnostics, probe.rel)
1008
+ const after = diagnosticCounts(diagnostics, probeRel)
1009
+ const revealed: string[] = []
1010
+ for (const [key, count] of after) {
1011
+ if (count <= (before.get(key) ?? 0)) continue
1012
+ const [line = '', rule = ''] = key.split(':')
1013
+ revealed.push(`${rule}@${line}`)
1014
+ }
1015
+ verdicts.set(probe.slot, revealed.toSorted())
1016
+ }
1017
+ return verdicts
1018
+ } finally {
1019
+ for (const abs of written) {
1020
+ try {
1021
+ unlinkSync(abs)
1022
+ } catch {
1023
+ // The probe is a temp file basalt owns; a failed unlink must not mask the audit's verdict.
1024
+ }
1025
+ }
1026
+ }
859
1027
  }
860
1028
 
861
1029
  /**
@@ -893,6 +1061,7 @@ function auditAllows(
893
1061
 
894
1062
  // ── theme-allow annotations ─────────────────────────────────────────────────
895
1063
  const waiverLines: string[] = []
1064
+ const pluginProbes: PluginProbe[] = []
896
1065
  const sources = new Map<string, string>()
897
1066
  for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), 'utf8'))
898
1067
 
@@ -906,35 +1075,77 @@ function auditAllows(
906
1075
  const unscopedAt = new Map(
907
1076
  baseline.filter((f) => f.kind === 'theme-allow-unscoped').map((f) => [f.line, f.token]),
908
1077
  )
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)
1078
+ // Enumerated by the guard, never by a regex kept here: `findAllowAnnotations` shares its
1079
+ // collector with `checkSource`, so the audit lists exactly the annotations the scan honours.
1080
+ // The private mirror this replaced was already one comment shape behind.
1081
+ for (const site of findAllowAnnotations(text, rel, guardCfg)) {
1082
+ const revealed = checkSource(
1083
+ neutralizeAllowAnnotation(text, site.line, guardCfg),
1084
+ rel,
1085
+ guardCfg,
1086
+ )
915
1087
  .filter((f) => f.kind !== 'theme-allow-unscoped' && !baselineKeys.has(findingKey(f)))
916
1088
  .map((f) => `${f.kind}@${f.line}`)
917
- const note = unscopedAt.get(index + 1)
1089
+ const note = unscopedAt.get(site.line)
918
1090
  if (note !== undefined) unaccountable++
919
- const site = ` ${rel}:${index + 1}`.padEnd(52)
1091
+ const label = ` ${rel}:${site.line}`.padEnd(52)
920
1092
  const suffix = note === undefined ? '' : ` [${note}]`
921
- const ids = annotationRuleIds((line ?? '').slice((line ?? '').indexOf(token) + token.length))
922
1093
  if (revealed.length > 0) {
923
1094
  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))) {
1095
+ waiverLines.push(`${label} suppresses ${[...new Set(revealed)].join(', ')}${suffix}`)
1096
+ continue
1097
+ }
1098
+ // Nothing in check-theme's reach moved. If the annotation names a PLUGIN rule, the oxlint
1099
+ // half decides it — a placeholder holds the slot so the report stays in file/line order.
1100
+ if (site.pluginRules.length > 0 && site.guardKinds.length === 0) {
1101
+ pluginProbes.push({
1102
+ slot: waiverLines.length,
1103
+ rel,
1104
+ line: site.line,
1105
+ ids: site.pluginRules,
1106
+ site: label,
1107
+ suffix,
1108
+ })
1109
+ waiverLines.push('')
1110
+ continue
1111
+ }
1112
+ if (site.guardKinds.length === 0 && site.unknownRules.length > 0) {
926
1113
  outOfReach++
927
1114
  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}`,
1115
+ `${label} scoped to ${site.unknownRules.join(', ')} — names no guard kind and no ` +
1116
+ 'plugin rule, so this audit cannot judge it (most likely a typo, which ' +
1117
+ `theme-allow-unscoped reports)${suffix}`,
930
1118
  )
931
- } else {
932
- dead++
933
- waiverLines.push(`${site} SUPPRESSES NOTHING — dead, delete it${suffix}`)
1119
+ continue
934
1120
  }
1121
+ dead++
1122
+ waiverLines.push(`${label} SUPPRESSES NOTHING — dead, delete it${suffix}`)
935
1123
  }
936
1124
  }
937
1125
 
1126
+ // ── the oxlint half — plugin-rule annotations, judged the same way ──────────
1127
+ const pluginVerdicts = probePluginRules(cwd, guardCfg, pluginProbes, sources)
1128
+ for (const probe of pluginProbes) {
1129
+ const revealed = pluginVerdicts?.get(probe.slot)
1130
+ if (revealed === undefined) {
1131
+ outOfReach++
1132
+ waiverLines[probe.slot] =
1133
+ `${probe.site} scoped to ${probe.ids.join(', ')} — an oxlint plugin rule, and oxlint ` +
1134
+ `could not be run here, so this audit cannot judge it${probe.suffix}`
1135
+ continue
1136
+ }
1137
+ if (revealed.length > 0) {
1138
+ live++
1139
+ waiverLines[probe.slot] =
1140
+ `${probe.site} suppresses ${revealed.join(', ')} (oxlint)${probe.suffix}`
1141
+ continue
1142
+ }
1143
+ dead++
1144
+ waiverLines[probe.slot] =
1145
+ `${probe.site} SUPPRESSES NOTHING — dead, delete it (proved by re-running oxlint over the ` +
1146
+ `file with this annotation neutralized)${probe.suffix}`
1147
+ }
1148
+
938
1149
  lines.push(`${token} annotations (${waiverLines.length}):`)
939
1150
  lines.push(...(waiverLines.length === 0 ? [' (none)'] : waiverLines))
940
1151
 
@@ -994,9 +1205,25 @@ function auditAllows(
994
1205
  lines.push(...(exemptLines.length === 0 ? [' (none)'] : exemptLines))
995
1206
 
996
1207
  lines.push(
997
- `\n${live} live, ${dead} dead, ${outOfReach} outside check-theme's reach, ${unaccountable} ` +
1208
+ `\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
998
1209
  'unaccountable (reported as theme-allow-unscoped by a normal run).',
999
1210
  )
1211
+ // The audit reads exactly what `check-theme` reads, and that is `scannableFiles` — which is
1212
+ // WIDER than `basalt.roots`: it also takes each root's sibling `index.html` and `public/` tree
1213
+ // (see appShellFiles) and anything named in `basalt.include`. Two consumers reported a live
1214
+ // `public/site.webmanifest` waiver under a scope line saying files outside `roots` are not
1215
+ // audited; the line was wrong, not the scan. Saying the scope out loud is the difference between
1216
+ // "0 dead" and "0 dead, over these files" — so it has to name every class actually reached.
1217
+ const includes = cfg.include ?? []
1218
+ lines.push(
1219
+ `Scope: the ${scanned.length} file(s) check-theme scans — everything under basalt.roots ` +
1220
+ `(${resolveRoots(cfg).join(', ')}), plus each root's sibling index.html and public/ tree, ` +
1221
+ (includes.length === 0
1222
+ ? 'plus anything named in basalt.include (none). '
1223
+ : `plus basalt.include (${includes.join(', ')}). `) +
1224
+ 'A waiver in a file none of those reach is not audited — widen roots, or name it in ' +
1225
+ 'basalt.include, or accept that nothing polices that file.',
1226
+ )
1000
1227
  if (dead > 0) {
1001
1228
  lines.push(
1002
1229
  'A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently ' +
@@ -1556,6 +1783,34 @@ function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): v
1556
1783
  if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
1557
1784
  }
1558
1785
 
1786
+ /** The frozen `> Managed by basalt-ui (1.9.0).` opener every pre-fix DESIGN.md seed carries. */
1787
+ const DESIGN_VERSION_LINE = /^(> Managed by basalt-ui) \(\d+\.\d+\.\d+\)\./m
1788
+
1789
+ /**
1790
+ * Strip the frozen version out of an already-seeded `DESIGN.md` opener.
1791
+ *
1792
+ * `DESIGN.md` is a `seed`: written once, then consumer-owned and never reconciled. It opened with
1793
+ * `> Managed by basalt-ui ({{BASALT_VERSION}})`, which is accurate for exactly as long as it takes
1794
+ * to run one upgrade — measured across the seven consumers the same line read 1.0.0, 1.9.0, 1.21.0
1795
+ * and 1.22.0 under an identical 1.22.0 install, and three rounds of reports treated that as four
1796
+ * separate doc bugs. It was one: a version number in a file nothing ever rewrites.
1797
+ *
1798
+ * Dropping it from the template fixes new scaffolds only — every existing consumer would carry its
1799
+ * stale number forever, since sync does not touch a seed. So sync deletes the parenthetical in
1800
+ * place, and only that: not a re-seed, not a three-way merge. Everything else in the file is left
1801
+ * exactly as the consumer wrote it, and a DESIGN.md whose opener was already rewritten by hand
1802
+ * does not match at all.
1803
+ */
1804
+ function migrateDesignVersionLine(cwd: string): void {
1805
+ const dest = resolve(cwd, 'DESIGN.md')
1806
+ const onDisk = readIfExists(dest)
1807
+ if (onDisk === null || !DESIGN_VERSION_LINE.test(onDisk)) return
1808
+ writeFileEnsuringDir(
1809
+ dest,
1810
+ onDisk.replace(DESIGN_VERSION_LINE, '$1 — run `basalt-ui doctor` for the version.'),
1811
+ )
1812
+ }
1813
+
1559
1814
  function writeFileEnsuringDir(abs: string, content: string): void {
1560
1815
  mkdirSync(dirname(abs), { recursive: true })
1561
1816
  writeFileSync(abs, content)
@@ -2004,6 +2259,83 @@ function inspectLefthookSeam(repoRoot: string): ExtendsSeam {
2004
2259
  : { kind: 'broken', file, entry }
2005
2260
  }
2006
2261
 
2262
+ /** A `run:` value that invokes the theme guard — the one command that IS the pre-commit gate. */
2263
+ const LEFTHOOK_GUARD_RUN = /^\s*run:\s*.*\bcheck-theme\b/m
2264
+
2265
+ /**
2266
+ * The merged lefthook config as `lefthook dump` renders it, or null when it cannot be obtained.
2267
+ *
2268
+ * Authoritative where the YAML text is not: `dump` resolves `extends`, `include`, remote configs
2269
+ * and per-command `root:`, so it answers "does a gate exist" for a repo that wired the jobs its own
2270
+ * way. Best-effort by design — lefthook is a consumer tool basalt neither installs nor requires, so
2271
+ * an absent binary downgrades the verdict to advisory rather than inventing one.
2272
+ */
2273
+ function lefthookDump(repoRoot: string): string | null {
2274
+ const localBin = resolve(repoRoot, 'node_modules/.bin/lefthook')
2275
+ const bin = existsSync(localBin) ? localBin : 'lefthook'
2276
+ try {
2277
+ const result = spawnSync(bin, ['dump'], {
2278
+ cwd: repoRoot,
2279
+ encoding: 'utf8',
2280
+ timeout: 10_000,
2281
+ stdio: ['ignore', 'pipe', 'ignore'],
2282
+ })
2283
+ if (result.error !== undefined || result.status !== 0) return null
2284
+ return result.stdout
2285
+ } catch {
2286
+ return null
2287
+ }
2288
+ }
2289
+
2290
+ /**
2291
+ * Whether the repo's pre-commit hook genuinely runs basalt's gates — not whether it `extends` the
2292
+ * shipped preset.
2293
+ *
2294
+ * The 1.21.0 check tested for the extends STRING, and a consumer whose root config spells the same
2295
+ * three jobs out with a per-command `root:` (because `extends` merges commands WITHOUT their
2296
+ * working directory, which would run `bunx oxlint` where there is no `.oxlintrc.json`) was warned
2297
+ * at, and told to make a change that would have broken it. A check that is confidently wrong is
2298
+ * worse than one that admits its limit, so this one asks the question three ways and says which
2299
+ * answer it got:
2300
+ *
2301
+ * - `lefthook dump` — authoritative, resolves extends/include/remote/`root:` the same way lefthook
2302
+ * itself does.
2303
+ * - the config text — enough to see a gate spelled out in the root file, which is the shape that
2304
+ * produced the false positive.
2305
+ * - neither — `advisory`, reported as a warning that names what it could not see.
2306
+ */
2307
+ type LefthookGate =
2308
+ | { readonly kind: 'no-file' }
2309
+ | { readonly kind: 'extends-ok'; readonly file: string; readonly entry: string }
2310
+ | { readonly kind: 'extends-broken'; readonly file: string; readonly entry: string }
2311
+ /** A command runs the guard directly. `proof` names what established it. */
2312
+ | { readonly kind: 'wired'; readonly file: string; readonly proof: 'dump' | 'config' }
2313
+ /** `lefthook dump` merged everything and no command runs the guard — provably no gate. */
2314
+ | { readonly kind: 'absent'; readonly file: string }
2315
+ /** No dump, no extends, no guard command in the text — unknown, not absent. */
2316
+ | { readonly kind: 'advisory'; readonly file: string }
2317
+
2318
+ function inspectLefthookGate(repoRoot: string): LefthookGate {
2319
+ const seam = inspectLefthookSeam(repoRoot)
2320
+ if (seam.kind === 'no-file') return { kind: 'no-file' }
2321
+ // A resolvable extends is the wiring basalt seeds; report it as itself rather than as a generic
2322
+ // "a command exists", since the remedy for a BROKEN one is specific and worth keeping.
2323
+ if (seam.kind === 'ok') return { kind: 'extends-ok', file: seam.file, entry: seam.entry }
2324
+ if (seam.kind === 'broken') {
2325
+ return { kind: 'extends-broken', file: seam.file, entry: seam.entry }
2326
+ }
2327
+
2328
+ const dump = lefthookDump(repoRoot)
2329
+ if (dump !== null) {
2330
+ return LEFTHOOK_GUARD_RUN.test(dump)
2331
+ ? { kind: 'wired', file: seam.file, proof: 'dump' }
2332
+ : { kind: 'absent', file: seam.file }
2333
+ }
2334
+ const raw = readIfExists(resolve(repoRoot, seam.file)) ?? ''
2335
+ if (LEFTHOOK_GUARD_RUN.test(raw)) return { kind: 'wired', file: seam.file, proof: 'config' }
2336
+ return { kind: 'advisory', file: seam.file }
2337
+ }
2338
+
2007
2339
  /**
2008
2340
  * Splice the shipped preset into an EXISTING `.oxlintrc.json` (`init --merge-lint`). Opt-in, not
2009
2341
  * automatic: turning the framework on adds real lint debt to previously-clean code (see the
@@ -2255,7 +2587,31 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
2255
2587
  }
2256
2588
  }
2257
2589
 
2258
- type SyncOptions = { force?: boolean; check?: boolean }
2590
+ type SyncOptions = {
2591
+ force?: boolean
2592
+ check?: boolean
2593
+ /** The raw flag list, read for `--tokens-only` / `--framework` exactly as check-theme reads it. */
2594
+ flags?: readonly string[]
2595
+ }
2596
+
2597
+ /**
2598
+ * The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
2599
+ *
2600
+ * `resolveProjectDir` relocates DOWNWARD — a root-invoked hook finding the one workspace package
2601
+ * that holds the config. The opposite direction is the shape that produced this function: a
2602
+ * consumer standing in `apps/dashboard`, the package that actually depends on basalt-ui, whose
2603
+ * install lives at the repo root above it. Nothing walks up, so `sync` read "no manifest here" as
2604
+ * "nothing scaffolded yet" and wrote a complete second consumer beside the real one.
2605
+ */
2606
+ function findManifestAbove(dir: string): string | null {
2607
+ let current = resolve(dir, '..')
2608
+ for (;;) {
2609
+ if (existsSync(resolve(current, MANIFEST_PATH))) return current
2610
+ const parent = resolve(current, '..')
2611
+ if (parent === current) return null
2612
+ current = parent
2613
+ }
2614
+ }
2259
2615
 
2260
2616
  /**
2261
2617
  * Reconcile managed files with the shipped versions via a sha256 three-way compare.
@@ -2263,18 +2619,92 @@ type SyncOptions = { force?: boolean; check?: boolean }
2263
2619
  * - `--check` makes NO writes; exits non-zero if any managed file is out-of-date or locally drifted
2264
2620
  * (a CI freshness gate), exit 0 when all current.
2265
2621
  * - `--force` overwrites locally-drifted files instead of skipping them.
2622
+ *
2623
+ * Runs against `resolveProjectDir(cwd)`, exactly as `check-theme` and `doctor` do — and then
2624
+ * REFUSES rather than scaffolding when the resolved project has no manifest. See
2625
+ * {@link findManifestAbove} for the failure that bought this: `sync` is the drift refresh and
2626
+ * `init` is the scaffold, so placing twenty files on a cwd that was never a consumer is a decision
2627
+ * `sync` does not get to make silently.
2266
2628
  */
2267
- export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): number {
2629
+ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd()): number {
2630
+ const syncFlags = opts.flags ?? []
2631
+ if (conflictingProfileFlags(syncFlags)) {
2632
+ console.error('basalt-ui sync: --tokens-only and --framework are alternatives — pass one.')
2633
+ return 1
2634
+ }
2635
+ const project = resolveProjectDir(invocationCwd)
2636
+ if (project.ambiguous !== null) {
2637
+ console.error(
2638
+ `✖ basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
2639
+ `packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
2640
+ 'run it from one of them, or set BASALT_CWD to pick.',
2641
+ )
2642
+ return 1
2643
+ }
2644
+ const cwd = project.dir
2645
+ if (project.relocatedFrom !== null) {
2646
+ console.log(
2647
+ `basalt-ui sync: no basalt config at ${project.relocatedFrom} — running in ` +
2648
+ `${relativePosix(project.relocatedFrom, cwd)}, where it lives.`,
2649
+ )
2650
+ }
2651
+
2652
+ // Profile before the refusal, because in a tokens-only consumer the refusal's own advice is
2653
+ // wrong: it names `basalt-ui init`, and `doctor` in the same directory at the same version says
2654
+ // in so many words that init is NOT the fix there — it places a Mantine doctrine the consumer
2655
+ // has no use for. Mirrors doctor's manifest row verbatim, and exits 0 so `sync --check` is
2656
+ // wirable into a tokens-only repo's CI (the one drift gate every other consumer runs).
2657
+ //
2658
+ // DECLARED, never inferred — the same asymmetry check-theme applies, for the same reason: the
2659
+ // answer SILENCES the whole reconciliation, and inferring it from a missing @mantine/core would
2660
+ // turn a framework consumer that keeps Mantine in a sibling package into a silent no-op.
2661
+ if (declaredProfile(readBasaltConfig(cwd), syncFlags) === 'tokens-only') {
2662
+ console.log(
2663
+ `basalt-ui sync: ${MANIFEST_PATH}: n/a — a tokens-only consumer has no scaffold to ` +
2664
+ 'reconcile. The token layer is refreshed by re-running `basalt-ui tokens:css`, and ' +
2665
+ '`tokens:css --check` is its drift gate. Pass --framework to force the full profile.',
2666
+ )
2667
+ return 0
2668
+ }
2669
+
2670
+ // The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
2671
+ // key is half the damage: a second `basalt` block means check-theme scans a different tree
2672
+ // depending on which directory it was invoked from.
2673
+ if (!existsSync(resolve(cwd, MANIFEST_PATH))) {
2674
+ const elsewhere = findManifestAbove(cwd)
2675
+ const wouldWrite = managedFiles(resolvePeerFlags(cwd, {}), resolvePlacement(cwd)).length
2676
+ console.error(
2677
+ `✖ basalt-ui sync: no ${MANIFEST_PATH} at ${cwd} — refusing to scaffold. ` +
2678
+ `\`sync\` refreshes an EXISTING basalt install; creating one is \`basalt-ui init\`'s ` +
2679
+ `decision, and continuing here would write ${wouldWrite} managed file(s) into this ` +
2680
+ 'directory.' +
2681
+ (elsewhere === null
2682
+ ? ' No manifest exists at any ancestor either — run `basalt-ui init` here if this ' +
2683
+ 'package really is meant to be a consumer.'
2684
+ : ` This repo's install is at ${relativePosix(cwd, elsewhere)} (${elsewhere}) — run ` +
2685
+ 'sync there, or set BASALT_CWD to it.'),
2686
+ )
2687
+ return 1
2688
+ }
2689
+
2268
2690
  // Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
2269
2691
  const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
2270
2692
  const ctx = renderContext(cwd)
2271
2693
  const manifest = readManifest(cwd)
2272
- if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
2694
+ if (!opts.check) {
2695
+ migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
2696
+ migrateDesignVersionLine(cwd)
2697
+ }
2273
2698
  const peers = resolvePeerFlags(cwd, {})
2274
2699
  const placement = resolvePlacement(cwd)
2275
2700
  const files = managedFiles(peers, placement)
2276
2701
 
2277
2702
  let updated = 0
2703
+ // `created` and `recreated` were one counter, and `0 updated, 20 recreated` is what a full
2704
+ // scaffold printed — a word that means "it was here, it went missing, I put it back" describing
2705
+ // twenty files this directory never had. The ledger already knows which: an entry in
2706
+ // `manifest.files` is basalt saying it placed the file once.
2707
+ let created = 0
2278
2708
  let recreated = 0
2279
2709
  let skippedDrift = 0
2280
2710
  let staleForCheck = 0
@@ -2288,11 +2718,14 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2288
2718
  continue
2289
2719
  }
2290
2720
 
2721
+ const tracked = manifest.files[file.dest] !== undefined
2722
+
2291
2723
  // `seed` is written once, then owned by the consumer — never reconciled, never reported.
2292
2724
  if (file.mode === 'seed') {
2293
2725
  if (state.current === null && !opts.check) {
2294
2726
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
2295
- recreated++
2727
+ if (tracked) recreated++
2728
+ else created++
2296
2729
  }
2297
2730
  continue
2298
2731
  }
@@ -2318,8 +2751,9 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2318
2751
  staleForCheck++
2319
2752
  if (opts.check) continue
2320
2753
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
2321
- if (kind === 'missing') recreated++
2322
- else updated++
2754
+ if (kind !== 'missing') updated++
2755
+ else if (tracked) recreated++
2756
+ else created++
2323
2757
  }
2324
2758
 
2325
2759
  // Placement notices — informational, never affect the exit code (a skipped tooling seed or a
@@ -2391,7 +2825,8 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2391
2825
  writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
2392
2826
 
2393
2827
  console.log(
2394
- `basalt-ui sync: ${updated} updated, ${recreated} recreated, ${skippedDrift} skipped (drift).`,
2828
+ `basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ` +
2829
+ `${skippedDrift} skipped (drift).`,
2395
2830
  )
2396
2831
  if (driftLines.length > 0) {
2397
2832
  console.log('Locally-edited files were skipped (run with --force to overwrite):')
@@ -2892,8 +3327,8 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
2892
3327
  if (project.ambiguous !== null) {
2893
3328
  console.error(
2894
3329
  `\nbasalt-ui doctor — ${invocationCwd}\n\n` +
2895
- ` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} workspace ` +
2896
- `packages carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
3330
+ ` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
3331
+ `below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
2897
3332
  'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
2898
3333
  '1 hard failure(s), 0 warning(s).\n',
2899
3334
  )
@@ -3158,34 +3593,64 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3158
3593
  }
3159
3594
  }
3160
3595
 
3161
- // ── Hard check 8: the lefthook preset's extends target actually exists ─────
3596
+ // ── Check 8: the pre-commit hook actually runs the guard ───────────────────
3162
3597
  // The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
3163
3598
  // zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
3164
3599
  // exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
3165
3600
  // Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
3166
3601
  // remedy line someone reads after a failure.
3602
+ //
3603
+ // But a BROKEN extends is the only half of this that can be asserted from the string. Whether a
3604
+ // gate EXISTS is a different question, and 1.21.0 answered it by testing for the extends target
3605
+ // — warning at a correctly-wired repo and prescribing a change that would have broken it. See
3606
+ // {@link inspectLefthookGate}: `lefthook dump` where it runs, the config text where it doesn't,
3607
+ // and an advisory that names the gap where neither settles it.
3167
3608
  const repoRoot = findRepoRoot(cwd)
3168
- const lefthookSeam =
3169
- profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookSeam(repoRoot)
3609
+ const lefthookGate =
3610
+ profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookGate(repoRoot)
3170
3611
  const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
3171
- if (lefthookSeam.kind === 'n/a') {
3612
+ if (lefthookGate.kind === 'n/a') {
3172
3613
  pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
3173
- } else if (lefthookSeam.kind === 'no-file') {
3614
+ } else if (lefthookGate.kind === 'no-file') {
3174
3615
  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.`,
3616
+ } else if (lefthookGate.kind === 'extends-ok') {
3617
+ pass(`${lefthookGate.file} extends the shipped lefthook preset (${lefthookGate.entry})`)
3618
+ } else if (lefthookGate.kind === 'wired') {
3619
+ // Extending the preset is basalt's recommendation, not the contract. The contract is that a
3620
+ // commit cannot land without the guard running, and a repo spelling the jobs out with a
3621
+ // per-command `root:` satisfies it — `extends` merges commands without their working
3622
+ // directory, so for that repo the preset is the thing that would NOT work.
3623
+ pass(
3624
+ `lefthook-preset: ${lefthookGate.file} does not extend the shipped preset, but a pre-commit ` +
3625
+ `command runs check-theme directly — the gate exists (read from ` +
3626
+ (lefthookGate.proof === 'dump'
3627
+ ? '`lefthook dump`, so extends/include/root: are all resolved).'
3628
+ : 'the config text; `lefthook dump` was not runnable here, so an `include:` or a ' +
3629
+ 'remote config was not consulted).'),
3179
3630
  )
3180
- } else if (lefthookSeam.kind === 'broken') {
3631
+ } else if (lefthookGate.kind === 'extends-broken') {
3181
3632
  fail(
3182
- `${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist ` +
3183
- `(${resolve(repoRoot, lefthookSeam.entry)}) — lefthook merges a missing extends ` +
3633
+ `${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist ` +
3634
+ `(${resolve(repoRoot, lefthookGate.entry)}) — lefthook merges a missing extends ` +
3184
3635
  'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
3185
3636
  `\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
3186
3637
  )
3638
+ } else if (lefthookGate.kind === 'absent') {
3639
+ // Warn, not fail — same tier the string-matching version used. A consumer may deliberately
3640
+ // gate in CI only, and doctor does not get to promote its own advice to a build break.
3641
+ warn(
3642
+ `${lefthookGate.file}: \`lefthook dump\` resolves the merged config and NO pre-commit ` +
3643
+ 'command runs check-theme — the theme guard is not gating commits. Either add ' +
3644
+ `"extends: [${lefthookCorrect}]", or add your own command running ` +
3645
+ '`bunx basalt-ui check-theme` (with `root:` if it must run in a subdirectory).',
3646
+ )
3187
3647
  } else {
3188
- pass(`${lefthookSeam.file} extends the shipped lefthook preset (${lefthookSeam.entry})`)
3648
+ warn(
3649
+ `${lefthookGate.file} names no basalt gate and \`lefthook dump\` could not be run here, so ` +
3650
+ 'this check cannot tell a missing gate from one wired through an `include:` or a remote ' +
3651
+ 'config it never read — treat it as advisory. If the guard really is unwired, add ' +
3652
+ `"extends: [${lefthookCorrect}]" or your own command running \`bunx basalt-ui check-theme\`.`,
3653
+ )
3189
3654
  }
3190
3655
 
3191
3656
  // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────
@@ -3754,7 +4219,8 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
3754
4219
 
3755
4220
  /** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
3756
4221
  const USAGE =
3757
- 'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] | sync [--force] [--check] |\n' +
4222
+ 'Usage: basalt-ui <init [--with-router] [--with-query] [--merge-lint] |\n' +
4223
+ ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
3758
4224
  ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
3759
4225
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
3760
4226
  'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
@@ -3777,9 +4243,13 @@ const USAGE =
3777
4243
  'doctor [--tokens-only|--framework]\n' +
3778
4244
  ' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
3779
4245
  ' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
3780
- 'check-theme / doctor honour BASALT_CWD, and relocate to the single workspace package carrying a\n' +
3781
- 'basalt config when invoked from a repo root that has none — several carrying one is ambiguous\n' +
3782
- 'and exits 1 in BOTH. --tokens-only and --framework are alternatives; passing both is an error.\n\n' +
4246
+ 'sync [--force] [--check] [--tokens-only|--framework]\n' +
4247
+ ' Refreshes an EXISTING install; it never creates one (that is init). A tokens-only consumer\n' +
4248
+ ' has no scaffold to reconcile, so sync reports n/a and exits 0 there — as doctor does.\n\n' +
4249
+ 'check-theme / doctor / sync all resolve their project the same way: they honour BASALT_CWD, and\n' +
4250
+ 'relocate to the single package below the cwd carrying a basalt config when invoked from a repo\n' +
4251
+ 'root that has none — several carrying one is ambiguous and exits 1 in ALL THREE.\n' +
4252
+ '--tokens-only and --framework are alternatives; passing both is an error.\n\n' +
3783
4253
  'Every subcommand accepts --help / -h to print this message and exit without running.'
3784
4254
 
3785
4255
  /**
@@ -3813,7 +4283,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
3813
4283
  mergeLint: flags.includes('--merge-lint'),
3814
4284
  })
3815
4285
  case 'sync':
3816
- return sync({ force: flags.includes('--force'), check: flags.includes('--check') }, cwd)
4286
+ return sync(
4287
+ { force: flags.includes('--force'), check: flags.includes('--check'), flags },
4288
+ cwd,
4289
+ )
3817
4290
  case 'check-theme':
3818
4291
  return checkTheme(cwd, flags)
3819
4292
  case 'check-coverage':