basalt-ui 1.22.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 +9 -1
- package/CHANGELOG.md +19 -0
- package/MIGRATING.md +125 -12
- package/README.md +45 -11
- package/agent/rules/basalt-app.md +26 -2
- package/agent/rules/basalt-charts.md +40 -5
- package/agent/rules/basalt-tokens.md +17 -1
- package/agent/skills/basalt-charts/SKILL.md +23 -8
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +49 -8
- package/configs/oxlint-plugin.test.ts +88 -31
- 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 +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +47 -8
- 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.map +1 -1
- package/dist/guard/index.js +32 -11
- package/dist/vite.d.ts +42 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +67 -30
- 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/index.ts +125 -18
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/toolchain-wiring.test.ts +171 -4
- package/src/cli/waiver-audit.test.ts +7 -3
- package/src/guard/check-source.test.ts +138 -42
- package/src/guard/index.ts +83 -17
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
|
@@ -151,8 +151,24 @@ export type CartesianChartProps<T> = {
|
|
|
151
151
|
/** Passing `y2` is what makes the chart dual-axis: it draws the right axis and widens the
|
|
152
152
|
* right margin by measurement. Series opt in with `axis: 'right'`. */
|
|
153
153
|
y2?: AxisConfig<T>
|
|
154
|
-
/** Exact number of x ticks. Default: as many as fit (`smartTicks`).
|
|
154
|
+
/** Exact number of x ticks. Default: as many as fit (`smartTicks`). Ignored when
|
|
155
|
+
* `xTickValues` is set. */
|
|
155
156
|
xTicks?: number
|
|
157
|
+
/**
|
|
158
|
+
* Which domain keys get a tick, chosen from the full key list and the resolved plot width.
|
|
159
|
+
* Takes precedence over `xTicks`; omit both for `smartTicks`.
|
|
160
|
+
*
|
|
161
|
+
* A COUNT is not always enough. `smartTicks`/`smartTicksEvery` append the final key
|
|
162
|
+
* unconditionally, so when the step does not land on the last index that appended tick sits a
|
|
163
|
+
* partial step from its neighbour — measured on a 24h window, two `DD.MM HH:MM` labels printed
|
|
164
|
+
* on top of each other at the right edge, at every tick count. The only fix a count can express
|
|
165
|
+
* is thinning the whole axis to avoid one crowded pair. A consumer whose labels are richer than
|
|
166
|
+
* the default `DD.MM` needs to pick the VALUES, which until now meant measuring the container
|
|
167
|
+
* itself with `useChartSize` to derive a count — the chart already knows its own width.
|
|
168
|
+
*
|
|
169
|
+
* `BandStrip`/`MirroredBars` take the identical prop, so the seam does not fork by kind.
|
|
170
|
+
*/
|
|
171
|
+
xTickValues?: (keys: readonly string[], xMax: number) => readonly string[]
|
|
156
172
|
/** X tick label formatter. Default `fmtAxisDate` (DD.MM). */
|
|
157
173
|
formatX?: (key: string) => string
|
|
158
174
|
/** Value-range bands on the left scale, drawn behind the marks. */
|
|
@@ -267,6 +283,7 @@ export function CartesianChart<T>({
|
|
|
267
283
|
y,
|
|
268
284
|
y2,
|
|
269
285
|
xTicks,
|
|
286
|
+
xTickValues,
|
|
270
287
|
formatX = fmtAxisDate,
|
|
271
288
|
zones,
|
|
272
289
|
xZones,
|
|
@@ -305,6 +322,7 @@ export function CartesianChart<T>({
|
|
|
305
322
|
{...(y !== undefined && { y })}
|
|
306
323
|
{...(y2 !== undefined && { y2 })}
|
|
307
324
|
{...(xTicks !== undefined && { xTicks })}
|
|
325
|
+
{...(xTickValues !== undefined && { xTickValues })}
|
|
308
326
|
formatX={formatX}
|
|
309
327
|
{...(zones !== undefined && { zones })}
|
|
310
328
|
{...(xZones !== undefined && { xZones })}
|
|
@@ -343,6 +361,7 @@ function CartesianPlot<T>({
|
|
|
343
361
|
y,
|
|
344
362
|
y2,
|
|
345
363
|
xTicks,
|
|
364
|
+
xTickValues,
|
|
346
365
|
formatX = fmtAxisDate,
|
|
347
366
|
zones,
|
|
348
367
|
xZones,
|
|
@@ -459,9 +478,15 @@ function CartesianPlot<T>({
|
|
|
459
478
|
[rightDomain, yMax, y2?.nice],
|
|
460
479
|
)
|
|
461
480
|
|
|
481
|
+
// Resolution order: explicit VALUES win, then an explicit COUNT, then as many as fit.
|
|
462
482
|
const tickValues = useMemo(
|
|
463
|
-
() =>
|
|
464
|
-
|
|
483
|
+
() =>
|
|
484
|
+
xTickValues !== undefined
|
|
485
|
+
? [...xTickValues(keys, xMax)]
|
|
486
|
+
: xTicks === undefined
|
|
487
|
+
? smartTicks(keys, xMax)
|
|
488
|
+
: smartTicksEvery(keys, xTicks),
|
|
489
|
+
[keys, xMax, xTicks, xTickValues],
|
|
465
490
|
)
|
|
466
491
|
|
|
467
492
|
const cursor = useChartCursor<T>({
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { alpha } from '../../tokens'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A diagonal-hatch `<pattern>` — the one fill that reads as **absence** rather than as a weak
|
|
6
|
+
* measurement.
|
|
7
|
+
*
|
|
8
|
+
* A faint solid tint cannot do this job: it sits on the same perceptual axis as every other
|
|
9
|
+
* intensity of the same hue, so "nothing was measured here" and "everything measured here was
|
|
10
|
+
* fine" collapse into neighbouring steps of one ramp. Hatching is off that axis entirely and
|
|
11
|
+
* survives being placed beside a colour ramp of the same hue.
|
|
12
|
+
*
|
|
13
|
+
* `id` must be unique per document — `BandStrip` / `MirroredBars` namespace theirs with `chartId`.
|
|
14
|
+
* `opacity` is applied through `alpha()` on the stroke rather than as an `opacity` attribute, so
|
|
15
|
+
* the hue keeps resolving per colour scheme.
|
|
16
|
+
*/
|
|
17
|
+
export function HatchPattern({
|
|
18
|
+
id,
|
|
19
|
+
color,
|
|
20
|
+
opacity = 0.55,
|
|
21
|
+
size = 6,
|
|
22
|
+
}: {
|
|
23
|
+
id: string
|
|
24
|
+
color: string
|
|
25
|
+
opacity?: number
|
|
26
|
+
size?: number
|
|
27
|
+
}): ReactNode {
|
|
28
|
+
return (
|
|
29
|
+
<pattern
|
|
30
|
+
id={id}
|
|
31
|
+
width={size}
|
|
32
|
+
height={size}
|
|
33
|
+
patternUnits="userSpaceOnUse"
|
|
34
|
+
patternTransform="rotate(45)"
|
|
35
|
+
>
|
|
36
|
+
<line x1={0} y1={0} x2={0} y2={size} stroke={alpha(color, opacity)} strokeWidth={size / 3} />
|
|
37
|
+
</pattern>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `fill` value referencing a {@link HatchPattern} by id. */
|
|
42
|
+
export function hatchFill(id: string): string {
|
|
43
|
+
return `url(#${id})`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pattern's own repeat, shrunk to fit the band it textures.
|
|
48
|
+
*
|
|
49
|
+
* Left at a fixed 6px a sub-pixel band draws less than one diagonal rule, so a fill/hatch split
|
|
50
|
+
* inside one band antialiases into a single smudge no reader can tell from a fully-measured band.
|
|
51
|
+
* Floored at 2 — below that the stroke has nothing to render on.
|
|
52
|
+
*/
|
|
53
|
+
export function hatchSizeFor(bandWidth: number): number {
|
|
54
|
+
return Math.max(2, Math.min(6, Math.round(bandWidth)))
|
|
55
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -511,6 +511,34 @@ type ProjectResolution = {
|
|
|
511
511
|
ambiguous: string[] | null
|
|
512
512
|
}
|
|
513
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
|
+
|
|
514
542
|
function resolveProjectDir(cwd: string): ProjectResolution {
|
|
515
543
|
const override = process.env['BASALT_CWD']
|
|
516
544
|
if (override !== undefined && override.length > 0) {
|
|
@@ -519,9 +547,13 @@ function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
519
547
|
}
|
|
520
548
|
if (hasBasaltProject(cwd)) return { dir: cwd, relocatedFrom: null, ambiguous: null }
|
|
521
549
|
|
|
522
|
-
const
|
|
550
|
+
const declared = collectWorkspacePackages(cwd)
|
|
523
551
|
.packages.filter((pkg) => pkg.dir !== cwd && hasBasaltProject(pkg.dir))
|
|
524
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)
|
|
525
557
|
if (candidates.length === 1) {
|
|
526
558
|
return { dir: candidates[0] as string, relocatedFrom: cwd, ambiguous: null }
|
|
527
559
|
}
|
|
@@ -692,7 +724,7 @@ export function checkTheme(
|
|
|
692
724
|
if (project.ambiguous !== null) {
|
|
693
725
|
console.error(
|
|
694
726
|
`✖ basalt-ui check-theme: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
695
|
-
`
|
|
727
|
+
`packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
696
728
|
'run it from one of them, or set BASALT_CWD to pick.',
|
|
697
729
|
)
|
|
698
730
|
return 1
|
|
@@ -1176,13 +1208,21 @@ function auditAllows(
|
|
|
1176
1208
|
`\n${live} live, ${dead} dead, ${outOfReach} unjudgeable, ${unaccountable} ` +
|
|
1177
1209
|
'unaccountable (reported as theme-allow-unscoped by a normal run).',
|
|
1178
1210
|
)
|
|
1179
|
-
// The audit reads exactly what `check-theme` reads, and that is `
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
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 ?? []
|
|
1182
1218
|
lines.push(
|
|
1183
|
-
`Scope: the ${scanned.length} file(s) check-theme scans under basalt.roots ` +
|
|
1184
|
-
`(${resolveRoots(cfg).join(', ')})
|
|
1185
|
-
|
|
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.',
|
|
1186
1226
|
)
|
|
1187
1227
|
if (dead > 0) {
|
|
1188
1228
|
lines.push(
|
|
@@ -1743,6 +1783,34 @@ function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): v
|
|
|
1743
1783
|
if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
|
|
1744
1784
|
}
|
|
1745
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
|
+
|
|
1746
1814
|
function writeFileEnsuringDir(abs: string, content: string): void {
|
|
1747
1815
|
mkdirSync(dirname(abs), { recursive: true })
|
|
1748
1816
|
writeFileSync(abs, content)
|
|
@@ -2519,7 +2587,12 @@ function readOxlintPresetPlugins(pkgRoot: string): string[] {
|
|
|
2519
2587
|
}
|
|
2520
2588
|
}
|
|
2521
2589
|
|
|
2522
|
-
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
|
+
}
|
|
2523
2596
|
|
|
2524
2597
|
/**
|
|
2525
2598
|
* The nearest ANCESTOR of `dir` (exclusive) carrying a basalt manifest, or null.
|
|
@@ -2554,11 +2627,16 @@ function findManifestAbove(dir: string): string | null {
|
|
|
2554
2627
|
* `sync` does not get to make silently.
|
|
2555
2628
|
*/
|
|
2556
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
|
+
}
|
|
2557
2635
|
const project = resolveProjectDir(invocationCwd)
|
|
2558
2636
|
if (project.ambiguous !== null) {
|
|
2559
2637
|
console.error(
|
|
2560
2638
|
`✖ basalt-ui sync: no basalt config at ${invocationCwd}, and ${project.ambiguous.length} ` +
|
|
2561
|
-
`
|
|
2639
|
+
`packages below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
2562
2640
|
'run it from one of them, or set BASALT_CWD to pick.',
|
|
2563
2641
|
)
|
|
2564
2642
|
return 1
|
|
@@ -2571,6 +2649,24 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2571
2649
|
)
|
|
2572
2650
|
}
|
|
2573
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
|
+
|
|
2574
2670
|
// The refusal runs BEFORE reconcileRoots, which WRITES `basalt.roots` into package.json — that
|
|
2575
2671
|
// key is half the damage: a second `basalt` block means check-theme scans a different tree
|
|
2576
2672
|
// depending on which directory it was invoked from.
|
|
@@ -2595,7 +2691,10 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
2595
2691
|
const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
|
|
2596
2692
|
const ctx = renderContext(cwd)
|
|
2597
2693
|
const manifest = readManifest(cwd)
|
|
2598
|
-
if (!opts.check)
|
|
2694
|
+
if (!opts.check) {
|
|
2695
|
+
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
2696
|
+
migrateDesignVersionLine(cwd)
|
|
2697
|
+
}
|
|
2599
2698
|
const peers = resolvePeerFlags(cwd, {})
|
|
2600
2699
|
const placement = resolvePlacement(cwd)
|
|
2601
2700
|
const files = managedFiles(peers, placement)
|
|
@@ -3228,8 +3327,8 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
3228
3327
|
if (project.ambiguous !== null) {
|
|
3229
3328
|
console.error(
|
|
3230
3329
|
`\nbasalt-ui doctor — ${invocationCwd}\n\n` +
|
|
3231
|
-
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length}
|
|
3232
|
-
`
|
|
3330
|
+
` ✖ no basalt config at ${invocationCwd}, and ${project.ambiguous.length} packages ` +
|
|
3331
|
+
`below it carry one (${project.ambiguous.map((d) => relativePosix(invocationCwd, d)).join(', ')}) — ` +
|
|
3233
3332
|
'run doctor from one of them, or set BASALT_CWD to pick.\n\n' +
|
|
3234
3333
|
'1 hard failure(s), 0 warning(s).\n',
|
|
3235
3334
|
)
|
|
@@ -4120,7 +4219,8 @@ export async function guardHook(cwd: string = process.cwd()): Promise<number> {
|
|
|
4120
4219
|
|
|
4121
4220
|
/** The one usage string — printed by `basalt help` / `--help` / `-h` AND the unknown-command fallback. */
|
|
4122
4221
|
const USAGE =
|
|
4123
|
-
'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' +
|
|
4124
4224
|
' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
|
|
4125
4225
|
' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
|
|
4126
4226
|
'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
|
|
@@ -4143,9 +4243,13 @@ const USAGE =
|
|
|
4143
4243
|
'doctor [--tokens-only|--framework]\n' +
|
|
4144
4244
|
' Auto-detects a tokens-only consumer (no manifest + no @mantine/core) and checks only what\n' +
|
|
4145
4245
|
' applies. A check that cannot RUN is reported as SKIPPED and exits non-zero.\n\n' +
|
|
4146
|
-
'
|
|
4147
|
-
'
|
|
4148
|
-
'
|
|
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' +
|
|
4149
4253
|
'Every subcommand accepts --help / -h to print this message and exit without running.'
|
|
4150
4254
|
|
|
4151
4255
|
/**
|
|
@@ -4179,7 +4283,10 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
|
|
|
4179
4283
|
mergeLint: flags.includes('--merge-lint'),
|
|
4180
4284
|
})
|
|
4181
4285
|
case 'sync':
|
|
4182
|
-
return sync(
|
|
4286
|
+
return sync(
|
|
4287
|
+
{ force: flags.includes('--force'), check: flags.includes('--check'), flags },
|
|
4288
|
+
cwd,
|
|
4289
|
+
)
|
|
4183
4290
|
case 'check-theme':
|
|
4184
4291
|
return checkTheme(cwd, flags)
|
|
4185
4292
|
case 'check-coverage':
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No shipped artifact may name a basalt version npm never served.
|
|
3
|
+
*
|
|
4
|
+
* Three rounds running, the newest `MIGRATING.md` heading named a phantom — `1.20.1` (shipped as
|
|
5
|
+
* 1.21.0), then `1.21.1` (shipped as 1.22.0) — because the docs pass runs BEFORE semantic-release
|
|
6
|
+
* computes the number, so whatever is written is a guess. That would be a docs problem alone if it
|
|
7
|
+
* stayed in docs. It does not: `sync` copies `agent/rules/**` and `agent/skills/**` verbatim into
|
|
8
|
+
* every consumer, so "Since 1.21.1" is currently shipped, as fact, into seven repos.
|
|
9
|
+
*
|
|
10
|
+
* This is the gate that ends it, and it is mechanical rather than editorial: every basalt-shaped
|
|
11
|
+
* version literal in a file a consumer receives must appear as a release heading in CHANGELOG.md —
|
|
12
|
+
* the one file `semantic-release` writes AFTER the number exists. A guessed version fails here
|
|
13
|
+
* before it can ship.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately NOT a ban on version literals: `agent/rules/**` is release-history prose and
|
|
16
|
+
* "widened at 1.21.0, promoted at 1.22.0" is exactly the sentence a consumer needs. What it may
|
|
17
|
+
* not do is name a release that does not exist.
|
|
18
|
+
*
|
|
19
|
+
* The complementary half — no version stamped into a file nothing ever rewrites — lives in
|
|
20
|
+
* `toolchain-wiring.test.ts` ("DESIGN.md carries no version").
|
|
21
|
+
*
|
|
22
|
+
* Run: bun test packages/basalt-ui/src/cli/shipped-versions.test.ts
|
|
23
|
+
*/
|
|
24
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
25
|
+
import { join, relative, resolve } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { describe, expect, it } from 'bun:test'
|
|
28
|
+
|
|
29
|
+
const PKG_ROOT = fileURLToPath(new URL('../../', import.meta.url))
|
|
30
|
+
|
|
31
|
+
/** Every version CHANGELOG.md records, plus the one package.json currently declares. */
|
|
32
|
+
const RELEASED = new Set<string>([
|
|
33
|
+
...(
|
|
34
|
+
readFileSync(resolve(PKG_ROOT, 'CHANGELOG.md'), 'utf8').match(/^#+\s*\[?(\d+\.\d+\.\d+)/gm) ??
|
|
35
|
+
[]
|
|
36
|
+
).map((h) => h.replace(/^#+\s*\[?/, '')),
|
|
37
|
+
(JSON.parse(readFileSync(resolve(PKG_ROOT, 'package.json'), 'utf8')) as { version: string })
|
|
38
|
+
.version,
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* basalt's own version line is 0.x / 1.x, so a literal with any other major belongs to a peer
|
|
43
|
+
* (visx 4.0.0, motion 12.42.0, the fontsource 5.2.x trio) and is none of this gate's business. A
|
|
44
|
+
* `^`/`~`/`@`-prefixed literal is a dependency RANGE for the same reason — `vite-plugin-pwa
|
|
45
|
+
* (^1.3.0)` shares a major with basalt and is still not a claim about basalt.
|
|
46
|
+
*/
|
|
47
|
+
const BASALT_VERSION_LITERAL = /(^|[^-~^@\w.])([01]\.\d+\.\d+)(?![\w.])/g
|
|
48
|
+
|
|
49
|
+
function versionsIn(content: string): string[] {
|
|
50
|
+
return [...content.matchAll(BASALT_VERSION_LITERAL)].map((m) => m[2] as string)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Every file under `dir`, recursively. */
|
|
54
|
+
function filesUnder(dir: string): string[] {
|
|
55
|
+
return readdirSync(dir).flatMap((name) => {
|
|
56
|
+
const abs = join(dir, name)
|
|
57
|
+
return statSync(abs).isDirectory() ? filesUnder(abs) : [abs]
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Everything `init`/`sync` place in a consumer repo, plus the presets they `extends` into.
|
|
62
|
+
const SHIPPED = [
|
|
63
|
+
...filesUnder(resolve(PKG_ROOT, 'agent')),
|
|
64
|
+
resolve(PKG_ROOT, 'configs/check.yml'),
|
|
65
|
+
resolve(PKG_ROOT, 'configs/lefthook.yml'),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
describe('shipped agent assets name only versions that exist', () => {
|
|
69
|
+
it.each(SHIPPED.map((abs) => [relative(PKG_ROOT, abs), abs] as const))(
|
|
70
|
+
'%s',
|
|
71
|
+
(_rel, abs: string) => {
|
|
72
|
+
const unreleased = [...new Set(versionsIn(readFileSync(abs, 'utf8')))].filter(
|
|
73
|
+
(v) => !RELEASED.has(v),
|
|
74
|
+
)
|
|
75
|
+
expect(unreleased).toEqual([])
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
it('reads a real changelog, so an empty RELEASED set can never make this vacuous', () => {
|
|
80
|
+
expect(RELEASED.size).toBeGreaterThan(20)
|
|
81
|
+
expect(RELEASED.has('1.21.1')).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -344,7 +344,7 @@ describe('check-theme — finds the config a root-invoked hook cannot see', () =
|
|
|
344
344
|
it('reports ambiguity rather than guessing when two packages carry a config', () => {
|
|
345
345
|
ambiguousWorkspace()
|
|
346
346
|
const { code, log } = capture(() => checkTheme(dir))
|
|
347
|
-
expect(log).toContain('2
|
|
347
|
+
expect(log).toContain('2 packages below it carry one')
|
|
348
348
|
expect(log).toContain('BASALT_CWD')
|
|
349
349
|
expect(code).toBe(1)
|
|
350
350
|
})
|
|
@@ -363,7 +363,7 @@ describe('doctor — an ambiguous project is terminal, exactly as it is for chec
|
|
|
363
363
|
it('short-circuits instead of running every check against the wrong root', () => {
|
|
364
364
|
ambiguousWorkspace()
|
|
365
365
|
const { code, log } = capture(() => doctor(dir))
|
|
366
|
-
expect(log).toContain('2
|
|
366
|
+
expect(log).toContain('2 packages below it carry one')
|
|
367
367
|
expect(log).toContain('BASALT_CWD')
|
|
368
368
|
expect(code).toBe(1)
|
|
369
369
|
// The bug this pins shut: falling back to the invocation cwd ran the remaining checks against
|
|
@@ -381,7 +381,7 @@ describe('doctor — an ambiguous project is terminal, exactly as it is for chec
|
|
|
381
381
|
it('still runs normally once BASALT_CWD picks a package', () => {
|
|
382
382
|
ambiguousWorkspace()
|
|
383
383
|
const { code, log } = capture(() => doctor(join(dir, 'apps/a')))
|
|
384
|
-
expect(log).not.toContain('
|
|
384
|
+
expect(log).not.toContain('packages below it carry one')
|
|
385
385
|
expect(code).toBe(1) // no install / no manifest there — but the checks RAN
|
|
386
386
|
expect(log).toContain('guard-scan')
|
|
387
387
|
})
|
|
@@ -945,7 +945,7 @@ describe('sync — a scaffold is init’s decision, never a drift refresh’s',
|
|
|
945
945
|
ambiguousWorkspace()
|
|
946
946
|
const { code, log } = capture(() => sync({}, dir))
|
|
947
947
|
expect(code).toBe(1)
|
|
948
|
-
expect(log).toContain('2
|
|
948
|
+
expect(log).toContain('2 packages below it carry one')
|
|
949
949
|
expect(log).toContain('BASALT_CWD')
|
|
950
950
|
})
|
|
951
951
|
|
|
@@ -1043,3 +1043,170 @@ describe('doctor — exit status per outcome, not per printed string', () => {
|
|
|
1043
1043
|
expect(capture(() => doctor(dir, ['--tokens-only', '--framework'])).code).toBe(1)
|
|
1044
1044
|
})
|
|
1045
1045
|
})
|
|
1046
|
+
|
|
1047
|
+
// ── the project resolver, for a repo that declares no workspaces ───────────────────────────────
|
|
1048
|
+
//
|
|
1049
|
+
// linewatch keeps its whole basalt consumer in `web/` and declares no `workspaces` field anywhere.
|
|
1050
|
+
// Measured at its repo root, all three project-scoped commands got that wrong in a different way:
|
|
1051
|
+
// `check-theme` printed `✓ no off-palette colors` having scanned ZERO files, `doctor` inferred
|
|
1052
|
+
// `tokens-only` for a full Mantine consumer, and `sync` refused while naming `basalt-ui init` as
|
|
1053
|
+
// the remedy — the one command that WOULD have written a competing install at the root. The
|
|
1054
|
+
// workspace-declaration path already handled image-share's `apps/admin`; nothing handled this.
|
|
1055
|
+
|
|
1056
|
+
/** linewatch's shape: no `workspaces` field, the whole consumer one directory down. */
|
|
1057
|
+
function undeclaredChild(child = 'web'): void {
|
|
1058
|
+
write('package.json', JSON.stringify({ name: 'root' }))
|
|
1059
|
+
write(`${child}/package.json`, JSON.stringify({ name: child, basalt: { roots: ['src'] } }))
|
|
1060
|
+
write(`${child}/src/app.tsx`, "export const c = '#ff0000'\n")
|
|
1061
|
+
write(join(child, MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
describe('resolveProjectDir — a repo that declares no workspaces still has a layout', () => {
|
|
1065
|
+
it('sync relocates into the undeclared child instead of refusing with init as the advice', () => {
|
|
1066
|
+
undeclaredChild()
|
|
1067
|
+
const { log } = capture(() => sync({ check: true }, dir))
|
|
1068
|
+
expect(log).toContain('running in ./web, where it lives')
|
|
1069
|
+
expect(log).not.toContain('basalt-ui init')
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
it('sync writes NOTHING at the root it was invoked from', () => {
|
|
1073
|
+
undeclaredChild()
|
|
1074
|
+
capture(() => sync({}, dir))
|
|
1075
|
+
expect(readdirSync(dir).toSorted()).toEqual(['package.json', 'web'])
|
|
1076
|
+
expect(readPkg()['basalt']).toBeUndefined()
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
it('check-theme scans the child rather than reporting green over zero files', () => {
|
|
1080
|
+
undeclaredChild()
|
|
1081
|
+
const { code, log } = capture(() => checkTheme(dir, []))
|
|
1082
|
+
expect(log).toContain('running in ./web, where it lives')
|
|
1083
|
+
expect(log).not.toContain('no off-palette colors')
|
|
1084
|
+
expect(code).toBe(1)
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
it('doctor reports on the child, so it cannot infer tokens-only from the empty root', () => {
|
|
1088
|
+
undeclaredChild()
|
|
1089
|
+
const { log } = capture(() => doctor(dir, []))
|
|
1090
|
+
expect(log).toContain(join(dir, 'web'))
|
|
1091
|
+
expect(log).not.toContain('profile: tokens-only')
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
it('two undeclared children are ambiguous, named, and never guessed between', () => {
|
|
1095
|
+
undeclaredChild('web')
|
|
1096
|
+
undeclaredChild('admin')
|
|
1097
|
+
const { code, log } = capture(() => sync({}, dir))
|
|
1098
|
+
expect(code).toBe(1)
|
|
1099
|
+
expect(log).toContain('2 packages below it carry one')
|
|
1100
|
+
expect(log).toContain('./admin')
|
|
1101
|
+
expect(log).toContain('./web')
|
|
1102
|
+
expect(readdirSync(dir)).not.toContain('DESIGN.md')
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
it('a declared workspaces field still wins — the layout scan is a fallback, not a union', () => {
|
|
1106
|
+
// `stray/` carries a basalt key but is not a workspace package. Treating both as candidates
|
|
1107
|
+
// would turn a working single-candidate repo into an ambiguity error on upgrade.
|
|
1108
|
+
write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }))
|
|
1109
|
+
write('apps/web/package.json', JSON.stringify({ name: 'web', basalt: { roots: ['src'] } }))
|
|
1110
|
+
write('apps/web/src/app.tsx', 'export const App = () => null\n')
|
|
1111
|
+
write(join('apps/web', MANIFEST_PATH), JSON.stringify({ version: 1, files: {} }))
|
|
1112
|
+
write('stray/package.json', JSON.stringify({ name: 'stray', basalt: { roots: ['src'] } }))
|
|
1113
|
+
const { log } = capture(() => sync({ check: true }, dir))
|
|
1114
|
+
expect(log).toContain('running in ./apps/web, where it lives')
|
|
1115
|
+
})
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
// ── sync honours the profile doctor already reads ──────────────────────────────────────────────
|
|
1119
|
+
//
|
|
1120
|
+
// rollhook is tokens-only in both apps and declares it. `sync` told it to run `basalt-ui init` —
|
|
1121
|
+
// the exact advice `doctor`, in the same directory at the same version, exists to prevent — and
|
|
1122
|
+
// exited 1, which also put `sync --check` out of reach of the one CI drift gate every other
|
|
1123
|
+
// consumer runs.
|
|
1124
|
+
|
|
1125
|
+
describe('sync — tokens-only is a pass, not a refusal', () => {
|
|
1126
|
+
function tokensOnly(): void {
|
|
1127
|
+
write(
|
|
1128
|
+
'package.json',
|
|
1129
|
+
JSON.stringify({ name: 'marketing', basalt: { roots: ['src'], profile: 'tokens-only' } }),
|
|
1130
|
+
)
|
|
1131
|
+
write('src/app.ts', 'export const c = 1\n')
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
it('reports n/a and exits 0, mirroring doctor’s manifest row', () => {
|
|
1135
|
+
tokensOnly()
|
|
1136
|
+
const { code, log } = capture(() => sync({}, dir))
|
|
1137
|
+
expect(code).toBe(0)
|
|
1138
|
+
expect(log).toContain('n/a — a tokens-only consumer has no scaffold to reconcile')
|
|
1139
|
+
expect(log).not.toContain('basalt-ui init')
|
|
1140
|
+
})
|
|
1141
|
+
|
|
1142
|
+
it('is wirable into CI: --check exits 0 instead of failing on a scaffold it must not have', () => {
|
|
1143
|
+
tokensOnly()
|
|
1144
|
+
expect(capture(() => sync({ check: true, flags: [] }, dir)).code).toBe(0)
|
|
1145
|
+
})
|
|
1146
|
+
|
|
1147
|
+
it('writes nothing — no manifest, no DESIGN.md, no basalt.roots backfill', () => {
|
|
1148
|
+
tokensOnly()
|
|
1149
|
+
capture(() => sync({}, dir))
|
|
1150
|
+
expect(readdirSync(dir).toSorted()).toEqual(['package.json', 'src'])
|
|
1151
|
+
})
|
|
1152
|
+
|
|
1153
|
+
it('--framework forces the full profile back on', () => {
|
|
1154
|
+
tokensOnly()
|
|
1155
|
+
const { code, log } = capture(() => sync({ flags: ['--framework'] }, dir))
|
|
1156
|
+
expect(code).toBe(1)
|
|
1157
|
+
expect(log).toContain('refusing to scaffold')
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
it('contradictory profile flags exit 1, exactly as they do for check-theme and doctor', () => {
|
|
1161
|
+
tokensOnly()
|
|
1162
|
+
expect(capture(() => sync({ flags: ['--tokens-only', '--framework'] }, dir)).code).toBe(1)
|
|
1163
|
+
})
|
|
1164
|
+
})
|
|
1165
|
+
|
|
1166
|
+
// ── no shipped artifact carries a version number that goes stale ───────────────────────────────
|
|
1167
|
+
//
|
|
1168
|
+
// `DESIGN.md` is a seed: written once at init, then consumer-owned and never reconciled. Its
|
|
1169
|
+
// opener stamped `{{BASALT_VERSION}}`, so across the seven consumers the same line read 1.0.0,
|
|
1170
|
+
// 1.9.0, 1.21.0 and 1.22.0 under an identical 1.22.0 install — reported three rounds running as
|
|
1171
|
+
// four separate doc bugs. It was one bug: a version number in a file nothing ever rewrites.
|
|
1172
|
+
|
|
1173
|
+
describe('DESIGN.md carries no version, and sync heals the ones already written', () => {
|
|
1174
|
+
function seeded(): void {
|
|
1175
|
+
write('package.json', JSON.stringify({ name: 'fixture', basalt: { roots: ['src'] } }))
|
|
1176
|
+
write('src/app.tsx', 'export const App = () => null\n')
|
|
1177
|
+
capture(() => init(dir))
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
it('a fresh scaffold stamps no version at all', () => {
|
|
1181
|
+
seeded()
|
|
1182
|
+
expect(read('DESIGN.md')).not.toMatch(/Managed by basalt-ui \(\d+\.\d+\.\d+\)/)
|
|
1183
|
+
expect(read('DESIGN.md')).toContain('run `basalt-ui doctor` for the version')
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
it('sync rewrites a stale opener in place and leaves the rest of the file alone', () => {
|
|
1187
|
+
seeded()
|
|
1188
|
+
const consumerOwned = '\n## Series\n\nOurs: hrv, rhr.\n'
|
|
1189
|
+
write('DESIGN.md', `# app — Design\n\n> Managed by basalt-ui (1.9.0). Thin.\n${consumerOwned}`)
|
|
1190
|
+
capture(() => sync({}, dir))
|
|
1191
|
+
expect(read('DESIGN.md')).toContain(
|
|
1192
|
+
'> Managed by basalt-ui — run `basalt-ui doctor` for the version. Thin.',
|
|
1193
|
+
)
|
|
1194
|
+
expect(read('DESIGN.md')).toContain(consumerOwned)
|
|
1195
|
+
})
|
|
1196
|
+
|
|
1197
|
+
it('--check writes nothing, including the heal', () => {
|
|
1198
|
+
seeded()
|
|
1199
|
+
const stale = '# app — Design\n\n> Managed by basalt-ui (1.9.0). Thin.\n'
|
|
1200
|
+
write('DESIGN.md', stale)
|
|
1201
|
+
capture(() => sync({ check: true }, dir))
|
|
1202
|
+
expect(read('DESIGN.md')).toBe(stale)
|
|
1203
|
+
})
|
|
1204
|
+
|
|
1205
|
+
it('leaves an opener the consumer already rewrote by hand untouched', () => {
|
|
1206
|
+
seeded()
|
|
1207
|
+
const owned = '# app — Design\n\n> Our own words entirely.\n'
|
|
1208
|
+
write('DESIGN.md', owned)
|
|
1209
|
+
capture(() => sync({}, dir))
|
|
1210
|
+
expect(read('DESIGN.md')).toBe(owned)
|
|
1211
|
+
})
|
|
1212
|
+
})
|