basalt-ui 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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'
@@ -636,6 +639,37 @@ function appShellFiles(rootAbs: string): string[] {
636
639
  return out
637
640
  }
638
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
+
639
673
  /**
640
674
  * Theme guard — thin FS walker over the headless `../guard` core. Reads BasaltConfig, builds a
641
675
  * GuardConfig, walks roots, calls checkSource per file, collects Finding[], groups/reports, returns
@@ -796,7 +830,9 @@ export function checkTheme(
796
830
  const presentKinds = [...new Set(findings.map((f) => f.kind))].toSorted()
797
831
  // And the closer is per FILE CLASS: prescribing a `theme-allow` comment to a .webmanifest (which
798
832
  // 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()
833
+ const waiverHints = [
834
+ ...new Set(findings.map((f) => waiverHintFor(f.relPath, profile))),
835
+ ].toSorted()
800
836
  console.error(['Fix:', ...presentKinds.map(guardKindRemedy), ...waiverHints].join(' '))
801
837
 
802
838
  // A violation in a file that looks like the palette source itself is likely intentional — point
@@ -814,48 +850,148 @@ export function checkTheme(
814
850
 
815
851
  // ── check-theme --audit-allows — every waiver, and whether it still suppresses anything ─────────
816
852
 
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
853
  /** `relPath:line:kind` — a finding's identity for set arithmetic across two runs of the same file. */
821
854
  function findingKey(f: Finding): string {
822
855
  return `${f.relPath}:${f.line}:${f.kind}`
823
856
  }
824
857
 
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*$/
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
+ }
835
908
 
836
909
  /**
837
- * The rule ids an annotation NAMES, as written. Classification only — what it actually waives is
838
- * decided by re-running the guard.
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.
839
918
  *
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.
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".
844
923
  */
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
- }
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
+ }
852
970
 
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))
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
+ }
859
995
  }
860
996
 
861
997
  /**
@@ -893,6 +1029,7 @@ function auditAllows(
893
1029
 
894
1030
  // ── theme-allow annotations ─────────────────────────────────────────────────
895
1031
  const waiverLines: string[] = []
1032
+ const pluginProbes: PluginProbe[] = []
896
1033
  const sources = new Map<string, string>()
897
1034
  for (const rel of scanned) sources.set(rel, readFileSync(resolve(cwd, rel), 'utf8'))
898
1035
 
@@ -906,33 +1043,75 @@ function auditAllows(
906
1043
  const unscopedAt = new Map(
907
1044
  baseline.filter((f) => f.kind === 'theme-allow-unscoped').map((f) => [f.line, f.token]),
908
1045
  )
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)
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
+ )
915
1055
  .filter((f) => f.kind !== 'theme-allow-unscoped' && !baselineKeys.has(findingKey(f)))
916
1056
  .map((f) => `${f.kind}@${f.line}`)
917
- const note = unscopedAt.get(index + 1)
1057
+ const note = unscopedAt.get(site.line)
918
1058
  if (note !== undefined) unaccountable++
919
- const site = ` ${rel}:${index + 1}`.padEnd(52)
1059
+ const label = ` ${rel}:${site.line}`.padEnd(52)
920
1060
  const suffix = note === undefined ? '' : ` [${note}]`
921
- const ids = annotationRuleIds((line ?? '').slice((line ?? '').indexOf(token) + token.length))
922
1061
  if (revealed.length > 0) {
923
1062
  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))) {
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) {
926
1081
  outOfReach++
927
1082
  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}`,
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}`,
930
1086
  )
931
- } else {
932
- dead++
933
- waiverLines.push(`${site} SUPPRESSES NOTHING — dead, delete it${suffix}`)
1087
+ continue
934
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
935
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}`
936
1115
  }
937
1116
 
938
1117
  lines.push(`${token} annotations (${waiverLines.length}):`)
@@ -994,9 +1173,17 @@ function auditAllows(
994
1173
  lines.push(...(exemptLines.length === 0 ? [' (none)'] : exemptLines))
995
1174
 
996
1175
  lines.push(
997
- `\n${live} live, ${dead} dead, ${outOfReach} outside check-theme's reach, ${unaccountable} ` +
1176
+ `\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
998
1177
  'unaccountable (reported as theme-allow-unscoped by a normal run).',
999
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
+ )
1000
1187
  if (dead > 0) {
1001
1188
  lines.push(
1002
1189
  'A dead waiver is not harmless: it is an exception nobody re-checked, and it will silently ' +
@@ -2004,6 +2191,83 @@ function inspectLefthookSeam(repoRoot: string): ExtendsSeam {
2004
2191
  : { kind: 'broken', file, entry }
2005
2192
  }
2006
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 }
2269
+ }
2270
+
2007
2271
  /**
2008
2272
  * Splice the shipped preset into an EXISTING `.oxlintrc.json` (`init --merge-lint`). Opt-in, not
2009
2273
  * automatic: turning the framework on adds real lint debt to previously-clean code (see the
@@ -2257,14 +2521,76 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
2257
2521
 
2258
2522
  type SyncOptions = { force?: boolean; check?: boolean }
2259
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
+
2260
2543
  /**
2261
2544
  * Reconcile managed files with the shipped versions via a sha256 three-way compare.
2262
2545
  *
2263
2546
  * - `--check` makes NO writes; exits non-zero if any managed file is out-of-date or locally drifted
2264
2547
  * (a CI freshness gate), exit 0 when all current.
2265
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.
2266
2555
  */
2267
- 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
+
2268
2594
  // Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
2269
2595
  const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
2270
2596
  const ctx = renderContext(cwd)
@@ -2275,6 +2601,11 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2275
2601
  const files = managedFiles(peers, placement)
2276
2602
 
2277
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
2278
2609
  let recreated = 0
2279
2610
  let skippedDrift = 0
2280
2611
  let staleForCheck = 0
@@ -2288,11 +2619,14 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2288
2619
  continue
2289
2620
  }
2290
2621
 
2622
+ const tracked = manifest.files[file.dest] !== undefined
2623
+
2291
2624
  // `seed` is written once, then owned by the consumer — never reconciled, never reported.
2292
2625
  if (file.mode === 'seed') {
2293
2626
  if (state.current === null && !opts.check) {
2294
2627
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
2295
- recreated++
2628
+ if (tracked) recreated++
2629
+ else created++
2296
2630
  }
2297
2631
  continue
2298
2632
  }
@@ -2318,8 +2652,9 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2318
2652
  staleForCheck++
2319
2653
  if (opts.check) continue
2320
2654
  manifest.files[file.dest] = writeUnit(file, cwd, state.desired)
2321
- if (kind === 'missing') recreated++
2322
- else updated++
2655
+ if (kind !== 'missing') updated++
2656
+ else if (tracked) recreated++
2657
+ else created++
2323
2658
  }
2324
2659
 
2325
2660
  // Placement notices — informational, never affect the exit code (a skipped tooling seed or a
@@ -2391,7 +2726,8 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
2391
2726
  writeFileEnsuringDir(resolve(cwd, MANIFEST_PATH), `${JSON.stringify(manifest, null, 2)}\n`)
2392
2727
 
2393
2728
  console.log(
2394
- `basalt-ui sync: ${updated} updated, ${recreated} recreated, ${skippedDrift} skipped (drift).`,
2729
+ `basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ` +
2730
+ `${skippedDrift} skipped (drift).`,
2395
2731
  )
2396
2732
  if (driftLines.length > 0) {
2397
2733
  console.log('Locally-edited files were skipped (run with --force to overwrite):')
@@ -3158,34 +3494,64 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
3158
3494
  }
3159
3495
  }
3160
3496
 
3161
- // ── Hard check 8: the lefthook preset's extends target actually exists ─────
3497
+ // ── Check 8: the pre-commit hook actually runs the guard ───────────────────
3162
3498
  // The loudest silent failure in the toolchain: lefthook merges a MISSING extends target into
3163
3499
  // zero commands and exits 0 — `lefthook dump` prints the extends line, no commands, and a clean
3164
3500
  // exit — so a repo whose path went stale has no pre-commit gate and nothing anywhere says so.
3165
3501
  // Unlike oxlint there is no error to notice, which is why this has to be a check rather than a
3166
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.
3167
3509
  const repoRoot = findRepoRoot(cwd)
3168
- const lefthookSeam =
3169
- profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookSeam(repoRoot)
3510
+ const lefthookGate =
3511
+ profile === 'tokens-only' ? { kind: 'n/a' as const } : inspectLefthookGate(repoRoot)
3170
3512
  const lefthookCorrect = shippedAssetPath(install, repoRoot, 'configs/lefthook.yml')
3171
- if (lefthookSeam.kind === 'n/a') {
3513
+ if (lefthookGate.kind === 'n/a') {
3172
3514
  pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
3173
- } else if (lefthookSeam.kind === 'no-file') {
3515
+ } else if (lefthookGate.kind === 'no-file') {
3174
3516
  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.`,
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).'),
3179
3531
  )
3180
- } else if (lefthookSeam.kind === 'broken') {
3532
+ } else if (lefthookGate.kind === 'extends-broken') {
3181
3533
  fail(
3182
- `${lefthookSeam.file} extends "${lefthookSeam.entry}", which does not exist ` +
3183
- `(${resolve(repoRoot, lefthookSeam.entry)}) — lefthook merges a missing extends ` +
3534
+ `${lefthookGate.file} extends "${lefthookGate.entry}", which does not exist ` +
3535
+ `(${resolve(repoRoot, lefthookGate.entry)}) — lefthook merges a missing extends ` +
3184
3536
  'target into ZERO commands and still exits 0, so this repo has no pre-commit gate and ' +
3185
3537
  `\`lefthook dump\` looks clean. Repoint it at "${lefthookCorrect}".`,
3186
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
+ )
3187
3548
  } else {
3188
- pass(`${lefthookSeam.file} extends the shipped lefthook preset (${lefthookSeam.entry})`)
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
+ )
3189
3555
  }
3190
3556
 
3191
3557
  // ── Warn check 8: basaltAppPlugin's default icon files exist in public/ ────