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.
- package/AGENTS.md +11 -1
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +205 -9
- package/README.md +63 -16
- package/agent/rules/basalt-app.md +39 -9
- package/agent/rules/basalt-charts.md +41 -6
- package/agent/rules/basalt-router.md +1 -1
- package/agent/rules/basalt-state.md +4 -4
- package/agent/rules/basalt-tokens.md +25 -4
- package/agent/skills/basalt-app/SKILL.md +4 -3
- package/agent/skills/basalt-charts/SKILL.md +23 -8
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +86 -9
- package/configs/oxlint-plugin.test.ts +173 -0
- package/dist/charts/hooks/useBandPlot.d.ts +138 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
- package/dist/charts/hooks/useBandPlot.js +127 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +14 -0
- package/dist/charts/kinds/BandStrip.d.ts +113 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +218 -0
- package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
- package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
- package/dist/charts/kinds/Bars.d.ts +5 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +2 -0
- package/dist/charts/kinds/MirroredBars.d.ts +93 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +315 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +5 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -0
- package/dist/charts/kinds/StackedArea.d.ts +5 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts +5 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -0
- package/dist/charts/primitives/CartesianChart.d.ts +18 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/HatchPattern.d.ts +31 -0
- package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
- package/dist/charts/primitives/HatchPattern.js +31 -0
- package/dist/cli/index.d.ts +9 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +275 -48
- package/dist/cli/shipped-versions.test.d.ts +2 -0
- package/dist/cli/shipped-versions.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +67 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +70 -11
- package/dist/vite.d.ts +44 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +71 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/hooks/useBandPlot.ts +302 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/kinds/BandStrip.test.tsx +200 -0
- package/src/charts/kinds/BandStrip.tsx +364 -0
- package/src/charts/kinds/Bars.tsx +7 -1
- package/src/charts/kinds/DualPanel.test.tsx +10 -4
- package/src/charts/kinds/MirroredBars.test.tsx +193 -0
- package/src/charts/kinds/MirroredBars.tsx +455 -0
- package/src/charts/kinds/MultiLine.tsx +7 -1
- package/src/charts/kinds/StackedArea.tsx +7 -1
- package/src/charts/kinds/ZonedLine.tsx +7 -1
- package/src/charts/primitives/CartesianChart.test.tsx +86 -4
- package/src/charts/primitives/CartesianChart.tsx +28 -3
- package/src/charts/primitives/HatchPattern.tsx +55 -0
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +552 -79
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/toolchain-wiring.test.ts +362 -6
- package/src/cli/waiver-audit.test.ts +88 -4
- package/src/guard/check-source.test.ts +293 -0
- package/src/guard/index.ts +209 -20
- package/src/vite.test.ts +112 -0
- 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
|
|
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
|
-
`
|
|
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 = [
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
838
|
-
*
|
|
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
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
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
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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(
|
|
1089
|
+
const note = unscopedAt.get(site.line)
|
|
918
1090
|
if (note !== undefined) unaccountable++
|
|
919
|
-
const
|
|
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(`${
|
|
925
|
-
|
|
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
|
-
`${
|
|
929
|
-
|
|
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
|
-
|
|
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}
|
|
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 = {
|
|
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 = {},
|
|
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)
|
|
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
|
|
2322
|
-
else
|
|
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, ${
|
|
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}
|
|
2896
|
-
`
|
|
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
|
-
// ──
|
|
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
|
|
3169
|
-
profile === 'tokens-only' ? { kind: 'n/a' as const } :
|
|
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 (
|
|
3612
|
+
if (lefthookGate.kind === 'n/a') {
|
|
3172
3613
|
pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
|
|
3173
|
-
} else if (
|
|
3614
|
+
} else if (lefthookGate.kind === 'no-file') {
|
|
3174
3615
|
pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
|
|
3175
|
-
} else if (
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
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 (
|
|
3631
|
+
} else if (lefthookGate.kind === 'extends-broken') {
|
|
3181
3632
|
fail(
|
|
3182
|
-
`${
|
|
3183
|
-
`(${resolve(repoRoot,
|
|
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
|
-
|
|
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]
|
|
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
|
-
'
|
|
3781
|
-
'
|
|
3782
|
-
'
|
|
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(
|
|
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':
|