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/AGENTS.md +3 -1
- package/CHANGELOG.md +14 -0
- package/MIGRATING.md +93 -10
- package/README.md +20 -7
- package/agent/rules/basalt-app.md +15 -9
- package/agent/rules/basalt-charts.md +2 -2
- package/agent/rules/basalt-router.md +1 -1
- package/agent/rules/basalt-state.md +4 -4
- package/agent/rules/basalt-tokens.md +9 -4
- package/agent/skills/basalt-app/SKILL.md +4 -3
- package/agent/skills/basalt-charts/SKILL.md +1 -1
- package/configs/oxlint-plugin.js +37 -1
- package/configs/oxlint-plugin.test.ts +116 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +230 -42
- package/dist/guard/index.d.ts +67 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +40 -2
- package/dist/vite.d.ts +4 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +21 -13
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +434 -68
- package/src/cli/toolchain-wiring.test.ts +192 -3
- package/src/cli/waiver-audit.test.ts +84 -4
- package/src/guard/check-source.test.ts +197 -0
- package/src/guard/index.ts +129 -6
- package/src/vite.test.ts +33 -0
- package/src/vite.ts +28 -14
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 = [
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
|
838
|
-
*
|
|
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
|
-
*
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
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
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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(
|
|
1057
|
+
const note = unscopedAt.get(site.line)
|
|
918
1058
|
if (note !== undefined) unaccountable++
|
|
919
|
-
const
|
|
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(`${
|
|
925
|
-
|
|
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
|
-
`${
|
|
929
|
-
|
|
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
|
-
|
|
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}
|
|
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 = {},
|
|
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
|
|
2322
|
-
else
|
|
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, ${
|
|
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
|
-
// ──
|
|
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
|
|
3169
|
-
profile === 'tokens-only' ? { kind: 'n/a' as const } :
|
|
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 (
|
|
3513
|
+
if (lefthookGate.kind === 'n/a') {
|
|
3172
3514
|
pass('lefthook-preset: n/a — a tokens-only consumer wires its own hooks')
|
|
3173
|
-
} else if (
|
|
3515
|
+
} else if (lefthookGate.kind === 'no-file') {
|
|
3174
3516
|
pass(`lefthook-preset: n/a — no lefthook config at ${repoRoot}`)
|
|
3175
|
-
} else if (
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
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 (
|
|
3532
|
+
} else if (lefthookGate.kind === 'extends-broken') {
|
|
3181
3533
|
fail(
|
|
3182
|
-
`${
|
|
3183
|
-
`(${resolve(repoRoot,
|
|
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
|
-
|
|
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/ ────
|