basalt-ui 1.8.0 → 1.10.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 +1 -0
- package/CHANGELOG.md +25 -0
- package/README.md +64 -34
- package/agent/rules/basalt-agent.md +49 -6
- package/agent/rules/basalt-charts.md +74 -5
- package/configs/oxlint-plugin.js +279 -0
- package/configs/oxlint-plugin.test.ts +216 -1
- package/configs/oxlint.json +5 -2
- package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
- package/dist/agent/use-agent-thread-runs.js +1 -0
- package/dist/agent-chat/index.d.ts +4 -3
- package/dist/agent-chat/index.d.ts.map +1 -1
- package/dist/charts/hooks/useHoverSync.d.ts +18 -2
- package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
- package/dist/charts/hooks/useHoverSync.js +12 -7
- package/dist/charts/index.d.ts +1 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +6 -0
- package/dist/charts/kinds/Bars.d.ts +2 -0
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +3 -1
- package/dist/charts/kinds/Donut.d.ts +2 -0
- package/dist/charts/kinds/Donut.d.ts.map +1 -1
- package/dist/charts/kinds/Donut.js +8 -4
- package/dist/charts/kinds/DualPanel.d.ts +2 -0
- package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
- package/dist/charts/kinds/DualPanel.js +2 -1
- package/dist/charts/kinds/Heatmap.d.ts +8 -0
- package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
- package/dist/charts/kinds/Heatmap.js +7 -3
- package/dist/charts/kinds/MultiLine.d.ts +2 -0
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -1
- package/dist/charts/kinds/StackedArea.d.ts +2 -0
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -1
- package/dist/charts/kinds/ZonedLine.d.ts +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -1
- package/dist/charts/primitives/Axes.d.ts +6 -1
- package/dist/charts/primitives/Axes.d.ts.map +1 -1
- package/dist/charts/primitives/Axes.js +3 -2
- package/dist/charts/primitives/ChartFrame.d.ts +8 -1
- package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
- package/dist/charts/primitives/ChartFrame.js +8 -4
- package/dist/charts/primitives/ChartPending.d.ts +34 -0
- package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
- package/dist/charts/primitives/ChartPending.js +23 -0
- package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
- package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
- package/dist/charts/primitives/ChartTooltip.js +6 -1
- package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +4 -2
- package/dist/cli/index.d.ts +55 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +217 -5
- package/dist/dashboard/stat-card.d.ts.map +1 -1
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +13 -5
- package/dist/surfaces.d.ts +10 -0
- package/dist/surfaces.d.ts.map +1 -1
- package/dist/surfaces.js +21 -0
- package/llms.txt +9 -2
- package/package.json +8 -5
- package/src/agent/ai-sdk-transport.test.ts +269 -0
- package/src/agent/parts.test.ts +261 -0
- package/src/agent/thread.test.ts +147 -0
- package/src/agent/use-agent-stream.test.tsx +231 -0
- package/src/agent/use-agent-thread-runs.resume.test.tsx +261 -0
- package/src/agent/use-agent-thread-runs.test.tsx +398 -0
- package/src/agent/use-agent-thread-runs.ts +7 -1
- package/src/agent/use-agent-thread-runs.wedge.test.tsx +299 -0
- package/src/agent-chat/index.ts +4 -3
- package/src/charts/hooks/useHoverSync.ts +43 -7
- package/src/charts/index.ts +6 -0
- package/src/charts/kinds/Bars.tsx +4 -0
- package/src/charts/kinds/Donut.tsx +10 -4
- package/src/charts/kinds/DualPanel.tsx +5 -1
- package/src/charts/kinds/Heatmap.tsx +19 -2
- package/src/charts/kinds/MultiLine.tsx +4 -1
- package/src/charts/kinds/StackedArea.tsx +4 -1
- package/src/charts/kinds/ZonedLine.tsx +4 -1
- package/src/charts/primitives/Axes.tsx +7 -1
- package/src/charts/primitives/ChartFrame.test.tsx +67 -0
- package/src/charts/primitives/ChartFrame.tsx +17 -4
- package/src/charts/primitives/ChartPending.tsx +59 -0
- package/src/charts/primitives/ChartTooltip.tsx +15 -3
- package/src/charts/primitives/HoverOverlay.tsx +12 -6
- package/src/cli/index.ts +439 -5
- package/src/cli/placement-engine.test.ts +104 -0
- package/src/dashboard/stat-card.test.tsx +5 -3
- package/src/dashboard/stat-card.tsx +12 -0
- package/src/guard/check-source.test.ts +20 -0
- package/src/guard/index.ts +20 -9
- package/src/provider/index.test.tsx +6 -5
- package/src/surfaces.ts +22 -0
- package/src/theme/use-basalt-spacing.test.tsx +7 -4
- package/src/tokens/build-fonts-css.test.ts +3 -2
package/src/cli/index.ts
CHANGED
|
@@ -165,6 +165,25 @@ export type BasaltConfig = {
|
|
|
165
165
|
* { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
|
|
166
166
|
*/
|
|
167
167
|
exemptRules?: Partial<Record<GuardKind, string[]>>
|
|
168
|
+
/**
|
|
169
|
+
* Declares an intentional `ai` package major-version skew across workspace packages, exempting
|
|
170
|
+
* `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
|
|
171
|
+
* because the whole point of this key is that the pairing is written down, not just switched off.
|
|
172
|
+
* A written declaration is the pin `basalt/ai-sdk-major`'s own comment notes is otherwise missing.
|
|
173
|
+
*
|
|
174
|
+
* Semantics: absent (or present but not a non-empty string) → the skew still hard-fails exactly as
|
|
175
|
+
* without this key. A non-empty string with a skew present → `doctor` passes, echoing both the
|
|
176
|
+
* skew and this reason. A non-empty string with NO skew present → `doctor` warns that the
|
|
177
|
+
* exemption is stale and can be deleted (an exemption nobody re-checks is how a real, later skew
|
|
178
|
+
* slips through unnoticed).
|
|
179
|
+
*
|
|
180
|
+
* @example
|
|
181
|
+
* // apps/api streams on ai@5; apps/dashboard parses on ai@7; a producer-side TransformStream
|
|
182
|
+
* // neutralizes the one enum value that differs between the two majors.
|
|
183
|
+
* { aiMajorSkewReason: 'apps/api pinned to ai@5, apps/dashboard on ai@7 — the skew is neutralized '
|
|
184
|
+
* + 'by a producer-side TransformStream in apps/api/src/stream-transform.ts' }
|
|
185
|
+
*/
|
|
186
|
+
aiMajorSkewReason?: string
|
|
168
187
|
}
|
|
169
188
|
|
|
170
189
|
const DEFAULT_ROOT = 'src'
|
|
@@ -675,8 +694,105 @@ function resolvePeerFlags(cwd: string, flags: ScaffoldFlags): PeerFlags {
|
|
|
675
694
|
}
|
|
676
695
|
}
|
|
677
696
|
|
|
697
|
+
/**
|
|
698
|
+
* Where the seed engine is running relative to the consumer's git repo — decides which
|
|
699
|
+
* repo-root-shaped seeds are safe to write. `init`/`sync` compute it once per run via
|
|
700
|
+
* `resolvePlacement` and print the matching skip notices themselves; `managedFiles` only reads it.
|
|
701
|
+
*/
|
|
702
|
+
type PlacementFlags = {
|
|
703
|
+
/** True when the package directory IS the detected repo root, or no `.git` exists anywhere above
|
|
704
|
+
* it (a consumer not using git keeps today's behaviour exactly). False means the package lives
|
|
705
|
+
* in a subdirectory of the repo (a monorepo's `web/`, say) — lefthook and GitHub Actions both
|
|
706
|
+
* only read config at the repo root, so seeding either one there is worse than not seeding at
|
|
707
|
+
* all. */
|
|
708
|
+
isPackageRepoRoot: boolean
|
|
709
|
+
/** Absolute path of the detected repo root. Meaningful only when `isPackageRepoRoot` is false —
|
|
710
|
+
* otherwise it equals the package dir and is never read. */
|
|
711
|
+
repoRoot: string
|
|
712
|
+
/** Absolute path of a `query-client.ts` already present elsewhere under the package's `src/` (the
|
|
713
|
+
* consumer relocated it — e.g. to `src/lib/query-client.ts`), or null. The seed's OWN
|
|
714
|
+
* destination is deliberately excluded here — that exact-path case is handled by the ordinary
|
|
715
|
+
* skip-if-exists logic every seed file already goes through, unchanged. */
|
|
716
|
+
relocatedQueryClient: string | null
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** The default placement — today's single-package-at-repo-root behaviour, unconditionally. */
|
|
720
|
+
const ROOT_PLACEMENT: PlacementFlags = {
|
|
721
|
+
isPackageRepoRoot: true,
|
|
722
|
+
repoRoot: '',
|
|
723
|
+
relocatedQueryClient: null,
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Walks up from `dir` looking for a `.git` entry — ordinarily a directory, but a git worktree or
|
|
728
|
+
* submodule points it at a FILE instead, and both still mean "this directory is the repo root".
|
|
729
|
+
* Stops at the filesystem root. Returns `dir` itself when no `.git` is found anywhere, so a
|
|
730
|
+
* consumer not using git falls back to today's behaviour exactly.
|
|
731
|
+
*/
|
|
732
|
+
function findRepoRoot(dir: string): string {
|
|
733
|
+
let current = dir
|
|
734
|
+
for (;;) {
|
|
735
|
+
if (existsSync(resolve(current, '.git'))) return current
|
|
736
|
+
const parent = dirname(current)
|
|
737
|
+
if (parent === current) return dir
|
|
738
|
+
current = parent
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* First path under `dir` (recursive) whose basename is `filename`, or null. Skips dependency/build
|
|
744
|
+
* dirs, mirroring `walkSourceFiles`'s scoping.
|
|
745
|
+
*/
|
|
746
|
+
function findFileNamed(dir: string, filename: string): string | null {
|
|
747
|
+
let entries: string[]
|
|
748
|
+
try {
|
|
749
|
+
entries = readdirSync(dir)
|
|
750
|
+
} catch {
|
|
751
|
+
return null
|
|
752
|
+
}
|
|
753
|
+
for (const name of entries) {
|
|
754
|
+
if (name === 'node_modules' || name === '.git' || name === 'dist') continue
|
|
755
|
+
const abs = resolve(dir, name)
|
|
756
|
+
let isDir: boolean
|
|
757
|
+
try {
|
|
758
|
+
isDir = statSync(abs).isDirectory()
|
|
759
|
+
} catch {
|
|
760
|
+
continue
|
|
761
|
+
}
|
|
762
|
+
if (isDir) {
|
|
763
|
+
const found = findFileNamed(abs, filename)
|
|
764
|
+
if (found !== null) return found
|
|
765
|
+
} else if (name === filename) {
|
|
766
|
+
return abs
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return null
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Absolute path of a `query-client.ts` living elsewhere under the package's `src/`, or null. The
|
|
774
|
+
* seed's own destination is excluded — reseeding on top of a RELOCATED client (the failure this
|
|
775
|
+
* exists to prevent) is a different case from the seed's own path already being occupied, which the
|
|
776
|
+
* ordinary skip-if-exists logic handles unchanged.
|
|
777
|
+
*/
|
|
778
|
+
function findRelocatedQueryClient(cwd: string): string | null {
|
|
779
|
+
const seedAbs = resolve(cwd, 'src/query-client.ts')
|
|
780
|
+
const found = findFileNamed(resolve(cwd, 'src'), 'query-client.ts')
|
|
781
|
+
return found !== null && found !== seedAbs ? found : null
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Resolve a run's placement flags once, from the consumer cwd. */
|
|
785
|
+
function resolvePlacement(cwd: string): PlacementFlags {
|
|
786
|
+
const repoRoot = findRepoRoot(cwd)
|
|
787
|
+
return {
|
|
788
|
+
isPackageRepoRoot: repoRoot === cwd,
|
|
789
|
+
repoRoot,
|
|
790
|
+
relocatedQueryClient: findRelocatedQueryClient(cwd),
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
678
794
|
/** The full managed-file manifest. Stable, declarative — the single source of truth for init/sync. */
|
|
679
|
-
function managedFiles(peers: PeerFlags): ManagedFile[] {
|
|
795
|
+
function managedFiles(peers: PeerFlags, placement: PlacementFlags = ROOT_PLACEMENT): ManagedFile[] {
|
|
680
796
|
// ── managed: exactly what Claude reads ──────────────────────────────────────
|
|
681
797
|
const rules: ManagedFile[] = RULE_NAMES.map((name) => ({
|
|
682
798
|
dest: `.claude/rules/basalt-${name}.md`,
|
|
@@ -804,10 +920,16 @@ function managedFiles(peers: PeerFlags): ManagedFile[] {
|
|
|
804
920
|
// type) — both are documented optional peers, so seeding either scaffold without its peer(s)
|
|
805
921
|
// installed would ship a file with an unresolved import.
|
|
806
922
|
const scaffolds: ManagedFile[] = []
|
|
807
|
-
if (peers.hasQuery) scaffolds.push(queryClient)
|
|
923
|
+
if (peers.hasQuery && placement.relocatedQueryClient === null) scaffolds.push(queryClient)
|
|
808
924
|
if (peers.hasRouter && peers.hasQuery) scaffolds.push(rootRoute)
|
|
809
925
|
|
|
810
|
-
|
|
926
|
+
// lefthook.yml / .github/workflows/check.yml are repo-root-shaped: lefthook and GitHub Actions
|
|
927
|
+
// only ever read config at the repo root, so a package living in a subdirectory (a monorepo's
|
|
928
|
+
// `web/`) skips both rather than relocating them — the repo root very often already has its own
|
|
929
|
+
// lefthook/CI config, and writing over or beside it is worse than not writing at all.
|
|
930
|
+
const rootTooling: ManagedFile[] = placement.isPackageRepoRoot ? [lefthook, ci] : []
|
|
931
|
+
|
|
932
|
+
return [...rules, ...skills, claudeBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds]
|
|
811
933
|
}
|
|
812
934
|
|
|
813
935
|
type Manifest = {
|
|
@@ -1041,7 +1163,8 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1041
1163
|
const manifest = readManifest(cwd)
|
|
1042
1164
|
migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
1043
1165
|
const peers = resolvePeerFlags(cwd, scaffoldFlags)
|
|
1044
|
-
const
|
|
1166
|
+
const placement = resolvePlacement(cwd)
|
|
1167
|
+
const files = managedFiles(peers, placement)
|
|
1045
1168
|
|
|
1046
1169
|
let written = 0
|
|
1047
1170
|
let skipped = 0
|
|
@@ -1082,6 +1205,28 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
1082
1205
|
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
|
|
1083
1206
|
)
|
|
1084
1207
|
}
|
|
1208
|
+
// lefthook.yml / .github/workflows/check.yml are repo-root-shaped — neither lefthook nor GitHub
|
|
1209
|
+
// Actions reads config from anywhere but the repo root, so a package living in a subdirectory
|
|
1210
|
+
// skips both rather than relocating them into a spot nothing reads.
|
|
1211
|
+
if (!placement.isPackageRepoRoot) {
|
|
1212
|
+
console.log(
|
|
1213
|
+
`basalt-ui init: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
1214
|
+
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
1215
|
+
'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
|
|
1216
|
+
)
|
|
1217
|
+
console.log(
|
|
1218
|
+
`basalt-ui init: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
1219
|
+
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
1220
|
+
'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
|
|
1221
|
+
)
|
|
1222
|
+
}
|
|
1223
|
+
if (placement.relocatedQueryClient !== null) {
|
|
1224
|
+
console.log(
|
|
1225
|
+
`basalt-ui init: skipped src/query-client.ts (found an existing query client at ` +
|
|
1226
|
+
`${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
|
|
1227
|
+
're-seeding at the original path.',
|
|
1228
|
+
)
|
|
1229
|
+
}
|
|
1085
1230
|
// query-client.ts / __root.tsx reference the optional TanStack peers directly — seeding them
|
|
1086
1231
|
// without the peer installed would ship an unresolved import. Hint how to opt in instead.
|
|
1087
1232
|
if (!peers.hasQuery) {
|
|
@@ -1132,7 +1277,8 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
|
|
|
1132
1277
|
const manifest = readManifest(cwd)
|
|
1133
1278
|
if (!opts.check) migrateLegacyOxfmt(cwd, ctx.pkgRoot, manifest)
|
|
1134
1279
|
const peers = resolvePeerFlags(cwd, {})
|
|
1135
|
-
const
|
|
1280
|
+
const placement = resolvePlacement(cwd)
|
|
1281
|
+
const files = managedFiles(peers, placement)
|
|
1136
1282
|
|
|
1137
1283
|
let updated = 0
|
|
1138
1284
|
let recreated = 0
|
|
@@ -1182,6 +1328,28 @@ export function sync(opts: SyncOptions = {}, cwd: string = process.cwd()): numbe
|
|
|
1182
1328
|
else updated++
|
|
1183
1329
|
}
|
|
1184
1330
|
|
|
1331
|
+
// Placement notices — informational, never affect the exit code (a skipped tooling seed or a
|
|
1332
|
+
// relocated scaffold is a legitimate consumer choice, not a sync failure).
|
|
1333
|
+
if (!placement.isPackageRepoRoot) {
|
|
1334
|
+
console.log(
|
|
1335
|
+
`basalt-ui sync: skipped lefthook.yml (this package is not the repo root — repo root ` +
|
|
1336
|
+
`detected at ${placement.repoRoot}) — lefthook only reads config at the repo root; extend ` +
|
|
1337
|
+
'node_modules/basalt-ui/configs/lefthook.yml from your root lefthook.yml instead.',
|
|
1338
|
+
)
|
|
1339
|
+
console.log(
|
|
1340
|
+
`basalt-ui sync: skipped .github/workflows/check.yml (this package is not the repo root — ` +
|
|
1341
|
+
`repo root detected at ${placement.repoRoot}) — GitHub Actions only reads .github/ at the ` +
|
|
1342
|
+
'repo root; extend node_modules/basalt-ui/configs/check.yml from your root CI workflow instead.',
|
|
1343
|
+
)
|
|
1344
|
+
}
|
|
1345
|
+
if (placement.relocatedQueryClient !== null) {
|
|
1346
|
+
console.log(
|
|
1347
|
+
`basalt-ui sync: skipped src/query-client.ts (found an existing query client at ` +
|
|
1348
|
+
`${relative(cwd, placement.relocatedQueryClient)}) — import it from there instead of ` +
|
|
1349
|
+
're-seeding at the original path.',
|
|
1350
|
+
)
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1185
1353
|
if (opts.check) {
|
|
1186
1354
|
if (driftLines.length > 0) {
|
|
1187
1355
|
console.error('basalt-ui sync --check: locally-drifted managed files:')
|
|
@@ -1411,6 +1579,202 @@ type DoctorResult = {
|
|
|
1411
1579
|
warnings: number
|
|
1412
1580
|
}
|
|
1413
1581
|
|
|
1582
|
+
type WorkspacePackage = { readonly name: string; readonly dir: string }
|
|
1583
|
+
|
|
1584
|
+
/** {@link collectWorkspacePackages}'s return: the walked packages plus whether a `workspaces`
|
|
1585
|
+
* field was present at all (distinguishes "nothing to compare" from "not a workspace root"). */
|
|
1586
|
+
type WorkspaceWalk = {
|
|
1587
|
+
readonly packages: readonly WorkspacePackage[]
|
|
1588
|
+
readonly hasWorkspacesField: boolean
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Every subdirectory of `base` (name only), or `[]` if `base` doesn't exist / isn't a directory.
|
|
1593
|
+
* `node_modules` is always excluded — a wildcard workspaces pattern must never descend into a
|
|
1594
|
+
* dependency tree. `skipDotDirs` additionally excludes dot-directories; it is only set by the
|
|
1595
|
+
* `*`/`**` wildcard descent in {@link expandPatternSegments}, never for an explicitly named
|
|
1596
|
+
* literal segment, so a pattern like `.internal/*` still resolves.
|
|
1597
|
+
*/
|
|
1598
|
+
function subdirNames(base: string, options: { skipDotDirs?: boolean } = {}): string[] {
|
|
1599
|
+
let entries: string[]
|
|
1600
|
+
try {
|
|
1601
|
+
entries = readdirSync(base)
|
|
1602
|
+
} catch {
|
|
1603
|
+
return []
|
|
1604
|
+
}
|
|
1605
|
+
return entries.filter((name) => {
|
|
1606
|
+
if (name === 'node_modules') return false
|
|
1607
|
+
if (options.skipDotDirs && name.startsWith('.')) return false
|
|
1608
|
+
try {
|
|
1609
|
+
return statSync(resolve(base, name)).isDirectory()
|
|
1610
|
+
} catch {
|
|
1611
|
+
return false
|
|
1612
|
+
}
|
|
1613
|
+
})
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/**
|
|
1617
|
+
* Expands one glob PATTERN's segments against the filesystem, starting from `baseDir`. Supports
|
|
1618
|
+
* three segment kinds: a literal name, a single `*` wildcard (any one directory), and a `**`
|
|
1619
|
+
* wildcard (zero or more directory levels, i.e. arbitrary depth) — covering a trailing single
|
|
1620
|
+
* wildcard, an interior wildcard between literal segments, and a trailing double-wildcard alike,
|
|
1621
|
+
* not just one trailing single-wildcard segment.
|
|
1622
|
+
*/
|
|
1623
|
+
function expandPatternSegments(baseDir: string, segments: readonly string[]): string[] {
|
|
1624
|
+
if (segments.length === 0) return [baseDir]
|
|
1625
|
+
const [head, ...rest] = segments
|
|
1626
|
+
|
|
1627
|
+
if (head === '**') {
|
|
1628
|
+
// Zero levels: `**` consumes nothing and the rest of the pattern matches right here.
|
|
1629
|
+
const matches = expandPatternSegments(baseDir, rest)
|
|
1630
|
+
// One-or-more levels: descend into every subdirectory, `**` still active.
|
|
1631
|
+
for (const name of subdirNames(baseDir, { skipDotDirs: true })) {
|
|
1632
|
+
matches.push(...expandPatternSegments(resolve(baseDir, name), segments))
|
|
1633
|
+
}
|
|
1634
|
+
return matches
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const names =
|
|
1638
|
+
head === '*'
|
|
1639
|
+
? subdirNames(baseDir, { skipDotDirs: true })
|
|
1640
|
+
: subdirNames(baseDir).filter((n) => n === head)
|
|
1641
|
+
return names.flatMap((name) => expandPatternSegments(resolve(baseDir, name), rest))
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* Expands a root `package.json`'s `workspaces` patterns into concrete package directories.
|
|
1646
|
+
* Supports literal segments, a single-wildcard segment, and a double-wildcard segment, both
|
|
1647
|
+
* interior and trailing (e.g. `packages` followed by a double wildcard, or an interior single
|
|
1648
|
+
* wildcard between two literal segments) — not just one trailing single-wildcard segment. A
|
|
1649
|
+
* candidate directory that has no `package.json` of its own is skipped, not reported — an empty
|
|
1650
|
+
* scaffold directory is not a workspace package. Also honours npm/bun/yarn `!`-prefixed exclusion
|
|
1651
|
+
* entries (e.g. `["packages/*", "!packages/legacy"]`) — both the included and excluded patterns
|
|
1652
|
+
* are expanded through the same machinery, then the excluded set is subtracted.
|
|
1653
|
+
*/
|
|
1654
|
+
function expandWorkspaceGlobs(cwd: string, patterns: readonly string[]): string[] {
|
|
1655
|
+
const expand = (pattern: string): string[] =>
|
|
1656
|
+
expandPatternSegments(
|
|
1657
|
+
cwd,
|
|
1658
|
+
pattern.split('/').filter((s) => s.length > 0),
|
|
1659
|
+
)
|
|
1660
|
+
|
|
1661
|
+
const excluded = new Set(
|
|
1662
|
+
patterns
|
|
1663
|
+
.filter((pattern) => pattern.startsWith('!'))
|
|
1664
|
+
.flatMap((pattern) => expand(pattern.replace(/^!+/, ''))),
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
const dirs: string[] = []
|
|
1668
|
+
for (const pattern of patterns) {
|
|
1669
|
+
if (pattern.startsWith('!')) continue
|
|
1670
|
+
for (const abs of expand(pattern)) {
|
|
1671
|
+
if (excluded.has(abs)) continue
|
|
1672
|
+
if (existsSync(resolve(abs, 'package.json'))) dirs.push(abs)
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return dirs
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Every workspace package declared by `cwd`'s root `package.json` (npm/bun/yarn `workspaces`
|
|
1680
|
+
* field, array or `{ packages: [...] }` object form), named + directory-resolved — PLUS the root
|
|
1681
|
+
* manifest itself, labelled `"{name} (root)"`. The root is walked because it commonly carries a
|
|
1682
|
+
* hoisted `ai` dev dependency that can itself skew against a workspace package's own major — that
|
|
1683
|
+
* is the producer/consumer shape the whole check exists to catch, and it is invisible if only the
|
|
1684
|
+
* expanded workspace dirs are inspected. `hasWorkspacesField` is `false` (and `packages` empty)
|
|
1685
|
+
* when `cwd` has no `package.json`, no `workspaces` field, or the field is a shape this doesn't
|
|
1686
|
+
* recognize — a repo-wide walk is a bonus check, not something that should ever throw doctor over
|
|
1687
|
+
* an unrecognized `workspaces` shape.
|
|
1688
|
+
*/
|
|
1689
|
+
function collectWorkspacePackages(cwd: string): WorkspaceWalk {
|
|
1690
|
+
const raw = readIfExists(resolve(cwd, 'package.json'))
|
|
1691
|
+
if (raw === null) return { packages: [], hasWorkspacesField: false }
|
|
1692
|
+
|
|
1693
|
+
let parsed: { workspaces?: unknown; name?: string }
|
|
1694
|
+
try {
|
|
1695
|
+
parsed = JSON.parse(raw) as { workspaces?: unknown; name?: string }
|
|
1696
|
+
} catch {
|
|
1697
|
+
return { packages: [], hasWorkspacesField: false }
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const patterns = isStringArray(parsed.workspaces)
|
|
1701
|
+
? parsed.workspaces
|
|
1702
|
+
: parsed.workspaces !== null &&
|
|
1703
|
+
typeof parsed.workspaces === 'object' &&
|
|
1704
|
+
isStringArray((parsed.workspaces as { packages?: unknown }).packages)
|
|
1705
|
+
? (parsed.workspaces as { packages: string[] }).packages
|
|
1706
|
+
: null
|
|
1707
|
+
if (patterns === null) return { packages: [], hasWorkspacesField: false }
|
|
1708
|
+
|
|
1709
|
+
const rootName = parsed.name && parsed.name.length > 0 ? `${parsed.name} (root)` : '(root)'
|
|
1710
|
+
const workspacePackages = expandWorkspaceGlobs(cwd, patterns).map((dir) => ({
|
|
1711
|
+
name: readPackageName(dir),
|
|
1712
|
+
dir,
|
|
1713
|
+
}))
|
|
1714
|
+
|
|
1715
|
+
return {
|
|
1716
|
+
packages: [{ name: rootName, dir: cwd }, ...workspacePackages],
|
|
1717
|
+
hasWorkspacesField: true,
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Narrows to a string array — shared by `collectWorkspacePackages`'s two `workspaces` shapes. */
|
|
1722
|
+
function isStringArray(value: unknown): value is string[] {
|
|
1723
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* First run of digits in a semver range string (`^7.0.15` → `7`), or null if there is none.
|
|
1728
|
+
*
|
|
1729
|
+
* Duplicated verbatim in `configs/oxlint-plugin.js` — that file must stay import-free from this
|
|
1730
|
+
* package (it loads via `jsPlugins` out of a consumer's node_modules, before this package's own
|
|
1731
|
+
* code is necessarily resolvable), so the duplication is structural, not an oversight. Keep both
|
|
1732
|
+
* copies in sync by hand; a future parsing fix (pre-release suffixes, say) must land in both.
|
|
1733
|
+
*/
|
|
1734
|
+
function majorOf(range: string | undefined): number | null {
|
|
1735
|
+
if (typeof range !== 'string') return null
|
|
1736
|
+
const match = range.match(/\d+/)
|
|
1737
|
+
return match === null ? null : Number(match[0])
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/** The `ai` package's declared major at `dir`'s own `package.json`, or null if undeclared/unreadable. */
|
|
1741
|
+
function aiMajorAt(dir: string): number | null {
|
|
1742
|
+
const raw = readIfExists(resolve(dir, 'package.json'))
|
|
1743
|
+
if (raw === null) return null
|
|
1744
|
+
try {
|
|
1745
|
+
const pkg = JSON.parse(raw) as {
|
|
1746
|
+
dependencies?: Record<string, string>
|
|
1747
|
+
devDependencies?: Record<string, string>
|
|
1748
|
+
peerDependencies?: Record<string, string>
|
|
1749
|
+
}
|
|
1750
|
+
return majorOf(
|
|
1751
|
+
pkg.dependencies?.['ai'] ?? pkg.devDependencies?.['ai'] ?? pkg.peerDependencies?.['ai'],
|
|
1752
|
+
)
|
|
1753
|
+
} catch {
|
|
1754
|
+
return null
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Splits `basalt.aiMajorSkewReason` into a validated reason (or null) plus whether the key was
|
|
1760
|
+
* present at all but failed validation. A present-but-invalid value (anything other than a
|
|
1761
|
+
* non-empty string — including a bare `true`) is treated as absent for the pass/fail decision, same
|
|
1762
|
+
* as a missing key, but doctor's failure message still calls out that the key exists and is
|
|
1763
|
+
* malformed rather than silently falling back to the plain "no exemption" message — a forgotten key
|
|
1764
|
+
* and a broken one are different mistakes and deserve different guidance.
|
|
1765
|
+
*/
|
|
1766
|
+
function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
1767
|
+
reason: string | null
|
|
1768
|
+
presentButInvalid: boolean
|
|
1769
|
+
} {
|
|
1770
|
+
const raw = cfg.aiMajorSkewReason
|
|
1771
|
+
if (raw === undefined) return { reason: null, presentButInvalid: false }
|
|
1772
|
+
if (typeof raw === 'string' && raw.trim().length > 0) {
|
|
1773
|
+
return { reason: raw, presentButInvalid: false }
|
|
1774
|
+
}
|
|
1775
|
+
return { reason: null, presentButInvalid: true }
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1414
1778
|
/**
|
|
1415
1779
|
* Check a consumer repo's basalt integration and print a pass/warn report.
|
|
1416
1780
|
*
|
|
@@ -1421,6 +1785,17 @@ type DoctorResult = {
|
|
|
1421
1785
|
*
|
|
1422
1786
|
* Hard failures (exit non-zero):
|
|
1423
1787
|
* 1. .basalt/manifest.json exists (init was run).
|
|
1788
|
+
* 6. `ai-major-parity`: every workspace package that declares the `ai` package agrees on its
|
|
1789
|
+
* major version. `basalt/ai-sdk-major` (the oxlint plugin rule) cannot catch this — it checks
|
|
1790
|
+
* a linted file's NEAREST package.json, so a lint run scoped to one workspace package only
|
|
1791
|
+
* ever sees that package's own `ai` major and is perfectly happy; the cross-package skew (one
|
|
1792
|
+
* package streaming on `ai@5`, another parsing it on `ai@7`) only a repo-wide manifest walk
|
|
1793
|
+
* can see. Skipped entirely when `cwd` has no `workspaces` field or none of its packages
|
|
1794
|
+
* declare `ai`. A detected skew is exempt-able via `basalt.aiMajorSkewReason` (a non-empty
|
|
1795
|
+
* reason string, not a bare `true`) in the consumer's package.json — an intentional
|
|
1796
|
+
* producer/consumer split neutralized by a transform is a locked decision, not a defect, and
|
|
1797
|
+
* the written reason IS the pin that a permanently-failing guard would otherwise lack. Missing
|
|
1798
|
+
* or invalid (empty/non-string) still hard-fails exactly as if the key were absent.
|
|
1424
1799
|
*
|
|
1425
1800
|
* Warnings (non-fatal):
|
|
1426
1801
|
* 2. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
|
|
@@ -1432,10 +1807,14 @@ type DoctorResult = {
|
|
|
1432
1807
|
* `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
|
|
1433
1808
|
* 5. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
|
|
1434
1809
|
* a public/ dir exists at all — skipped otherwise).
|
|
1810
|
+
* 7. A declared `basalt.aiMajorSkewReason` when the ai majors currently AGREE — the exemption is
|
|
1811
|
+
* stale and can be deleted; an exemption nobody revisits is how a real, later skew slips
|
|
1812
|
+
* through unnoticed.
|
|
1435
1813
|
*
|
|
1436
1814
|
* Returns the exit code: 0 = all good, 1 = one or more hard failures.
|
|
1437
1815
|
*/
|
|
1438
1816
|
export function doctor(cwd: string = process.cwd()): number {
|
|
1817
|
+
const cfg = readBasaltConfig(cwd)
|
|
1439
1818
|
const result: DoctorResult = { hardFailures: 0, warnings: 0 }
|
|
1440
1819
|
const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
|
|
1441
1820
|
|
|
@@ -1579,6 +1958,61 @@ export function doctor(cwd: string = process.cwd()): number {
|
|
|
1579
1958
|
}
|
|
1580
1959
|
}
|
|
1581
1960
|
|
|
1961
|
+
// ── Hard check 6: ai package major version parity across workspace packages ─
|
|
1962
|
+
// basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
|
|
1963
|
+
// run scoped to one workspace package only ever sees that package's own `ai` major and is
|
|
1964
|
+
// perfectly happy. The real defect is CROSS-package (argo defect 1: apps/api on ai@5 producing a
|
|
1965
|
+
// stream apps/dashboard's ai@7 client can't parse) and only a repo-wide manifest walk sees it —
|
|
1966
|
+
// this is that walk. Hard failure, not a warning: a skewed pair throws at runtime, it doesn't
|
|
1967
|
+
// just look different. `basalt.aiMajorSkewReason` exempts an INTENTIONAL skew (e.g. a
|
|
1968
|
+
// producer-side TransformStream neutralizing the divergent enum value) — see the BasaltConfig
|
|
1969
|
+
// JSDoc for its exact semantics; a stale declaration (skew resolved, reason still present) warns
|
|
1970
|
+
// instead of passing silently.
|
|
1971
|
+
const workspaceWalk = collectWorkspacePackages(cwd)
|
|
1972
|
+
const aiMajors = workspaceWalk.packages
|
|
1973
|
+
.map((pkg) => ({ name: pkg.name, major: aiMajorAt(pkg.dir) }))
|
|
1974
|
+
.filter((entry): entry is { name: string; major: number } => entry.major !== null)
|
|
1975
|
+
const { reason: aiMajorSkewReason, presentButInvalid: aiMajorSkewReasonInvalid } =
|
|
1976
|
+
resolveAiMajorSkewReason(cfg)
|
|
1977
|
+
if (aiMajors.length > 0) {
|
|
1978
|
+
const distinctMajors = new Set(aiMajors.map((entry) => entry.major))
|
|
1979
|
+
if (distinctMajors.size > 1) {
|
|
1980
|
+
const summary = aiMajors.map((entry) => `${entry.name}@ai${entry.major}`).join(', ')
|
|
1981
|
+
if (aiMajorSkewReason !== null) {
|
|
1982
|
+
pass(
|
|
1983
|
+
`ai package major version mismatch across workspace packages: ${summary} — exempted via ` +
|
|
1984
|
+
`basalt.aiMajorSkewReason: "${aiMajorSkewReason}"`,
|
|
1985
|
+
)
|
|
1986
|
+
} else if (aiMajorSkewReasonInvalid) {
|
|
1987
|
+
fail(
|
|
1988
|
+
`ai package major version mismatch across workspace packages: ${summary} — ` +
|
|
1989
|
+
'basalt.aiMajorSkewReason is present but is not a non-empty string (a bare `true` is ' +
|
|
1990
|
+
'not accepted); the exemption must carry a written reason, e.g. ' +
|
|
1991
|
+
'`aiMajorSkewReason: "apps/api on ai@5, apps/dashboard on ai@7, neutralized by a ' +
|
|
1992
|
+
'producer-side TransformStream"`.',
|
|
1993
|
+
)
|
|
1994
|
+
} else {
|
|
1995
|
+
fail(`ai package major version mismatch across workspace packages: ${summary}`)
|
|
1996
|
+
}
|
|
1997
|
+
} else {
|
|
1998
|
+
const [major] = distinctMajors
|
|
1999
|
+
if (aiMajorSkewReason !== null) {
|
|
2000
|
+
warn(
|
|
2001
|
+
`basalt.aiMajorSkewReason ("${aiMajorSkewReason}") is declared but the ai package major ` +
|
|
2002
|
+
`already matches across all ${aiMajors.length} workspace package(s) declaring it ` +
|
|
2003
|
+
`(ai@${major}) — the exemption is no longer needed and can be deleted.`,
|
|
2004
|
+
)
|
|
2005
|
+
} else {
|
|
2006
|
+
pass(
|
|
2007
|
+
`ai package major matches across ${aiMajors.length} workspace package(s) declaring it (ai@${major})`,
|
|
2008
|
+
)
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
} else if (workspaceWalk.hasWorkspacesField) {
|
|
2012
|
+
// A hard check that can silently not run is worse than one that is absent — say so.
|
|
2013
|
+
pass('ai-major-parity: no workspace package declares the ai dependency — nothing to compare')
|
|
2014
|
+
}
|
|
2015
|
+
|
|
1582
2016
|
// ── Report ──────────────────────────────────────────────────────────────────
|
|
1583
2017
|
lines.push('')
|
|
1584
2018
|
if (result.hardFailures === 0 && result.warnings === 0) {
|
|
@@ -81,6 +81,25 @@ function silenced<T>(fn: () => T): T {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Captures both console.log and console.error — init()/sync() interleave the two. */
|
|
85
|
+
function capture(fn: () => number): { code: number; log: string } {
|
|
86
|
+
const originalLog = console.log
|
|
87
|
+
const originalError = console.error
|
|
88
|
+
let log = ''
|
|
89
|
+
console.log = (...args: unknown[]) => {
|
|
90
|
+
log += `${args.join(' ')}\n`
|
|
91
|
+
}
|
|
92
|
+
console.error = (...args: unknown[]) => {
|
|
93
|
+
log += `${args.join(' ')}\n`
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return { code: fn(), log }
|
|
97
|
+
} finally {
|
|
98
|
+
console.log = originalLog
|
|
99
|
+
console.error = originalError
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
describe('managed vs seed placement', () => {
|
|
85
104
|
it('init places rules AND skills as managed files, plus the toolchain seeds', () => {
|
|
86
105
|
silenced(() => init(dir))
|
|
@@ -192,3 +211,88 @@ describe('--check performs zero writes', () => {
|
|
|
192
211
|
expect(snapshotDir()).toEqual(drifted)
|
|
193
212
|
})
|
|
194
213
|
})
|
|
214
|
+
|
|
215
|
+
// A real consumer's repo root (an API + collector) had its dashboard package in `web/`, one level
|
|
216
|
+
// below the repo `.git`. `init`/`sync` used to seed lefthook.yml, .github/workflows/check.yml, and
|
|
217
|
+
// src/query-client.ts relative to the PACKAGE dir regardless — none of which anything reads from
|
|
218
|
+
// there, and re-created on every sync even after the consumer relocated them by hand.
|
|
219
|
+
describe('repo-root-shaped seeds (lefthook.yml / check.yml / query-client.ts)', () => {
|
|
220
|
+
it('package IS the repo root (a bare .git dir present): writes lefthook.yml and check.yml, unchanged', () => {
|
|
221
|
+
mkdirSync(join(dir, '.git'))
|
|
222
|
+
silenced(() => init(dir))
|
|
223
|
+
expect(existsSync(join(dir, 'lefthook.yml'))).toBe(true)
|
|
224
|
+
expect(existsSync(join(dir, '.github/workflows/check.yml'))).toBe(true)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('no .git anywhere in the tree: falls back to writing lefthook.yml and check.yml, unchanged', () => {
|
|
228
|
+
silenced(() => init(dir))
|
|
229
|
+
expect(existsSync(join(dir, 'lefthook.yml'))).toBe(true)
|
|
230
|
+
expect(existsSync(join(dir, '.github/workflows/check.yml'))).toBe(true)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('package lives in a subdirectory of the repo: init skips both tooling seeds, notes the repo root, exits 0', () => {
|
|
234
|
+
mkdirSync(join(dir, '.git'))
|
|
235
|
+
const webDir = join(dir, 'web')
|
|
236
|
+
mkdirSync(webDir, { recursive: true })
|
|
237
|
+
writeFileSync(join(webDir, 'package.json'), JSON.stringify({ name: 'web' }))
|
|
238
|
+
|
|
239
|
+
const { code, log } = capture(() => init(webDir))
|
|
240
|
+
|
|
241
|
+
expect(code).toBe(0)
|
|
242
|
+
expect(existsSync(join(webDir, 'lefthook.yml'))).toBe(false)
|
|
243
|
+
expect(existsSync(join(webDir, '.github/workflows/check.yml'))).toBe(false)
|
|
244
|
+
expect(log).toContain('lefthook.yml')
|
|
245
|
+
expect(log).toContain('.github/workflows/check.yml')
|
|
246
|
+
expect(log).toContain(dir)
|
|
247
|
+
// Repo-root-shaped seeds are skipped; everything else still seeds normally.
|
|
248
|
+
expect(existsSync(join(webDir, 'DESIGN.md'))).toBe(true)
|
|
249
|
+
expect(existsSync(join(webDir, '.oxlintrc.json'))).toBe(true)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('package lives in a subdirectory of the repo: sync ALSO skips both tooling seeds and notes it, exits 0', () => {
|
|
253
|
+
mkdirSync(join(dir, '.git'))
|
|
254
|
+
const webDir = join(dir, 'web')
|
|
255
|
+
mkdirSync(webDir, { recursive: true })
|
|
256
|
+
writeFileSync(join(webDir, 'package.json'), JSON.stringify({ name: 'web' }))
|
|
257
|
+
silenced(() => init(webDir))
|
|
258
|
+
|
|
259
|
+
const { code, log } = capture(() => sync({}, webDir))
|
|
260
|
+
|
|
261
|
+
expect(code).toBe(0)
|
|
262
|
+
expect(existsSync(join(webDir, 'lefthook.yml'))).toBe(false)
|
|
263
|
+
expect(existsSync(join(webDir, '.github/workflows/check.yml'))).toBe(false)
|
|
264
|
+
expect(log).toContain('lefthook.yml')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('an existing query-client.ts elsewhere under src/ suppresses the seed and names the found file', () => {
|
|
268
|
+
writeFileSync(
|
|
269
|
+
join(dir, 'package.json'),
|
|
270
|
+
JSON.stringify({ name: 'fixture', dependencies: { '@tanstack/react-query': '^5.101.0' } }),
|
|
271
|
+
)
|
|
272
|
+
write('src/lib/query-client.ts', 'export const queryClient = 1\n')
|
|
273
|
+
|
|
274
|
+
const { code, log } = capture(() => init(dir))
|
|
275
|
+
|
|
276
|
+
expect(code).toBe(0)
|
|
277
|
+
expect(existsSync(join(dir, 'src/query-client.ts'))).toBe(false)
|
|
278
|
+
expect(read('src/lib/query-client.ts')).toBe('export const queryClient = 1\n')
|
|
279
|
+
expect(log).toContain('src/lib/query-client.ts')
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('an existing query-client.ts elsewhere under src/ keeps suppressing the seed on sync', () => {
|
|
283
|
+
writeFileSync(
|
|
284
|
+
join(dir, 'package.json'),
|
|
285
|
+
JSON.stringify({ name: 'fixture', dependencies: { '@tanstack/react-query': '^5.101.0' } }),
|
|
286
|
+
)
|
|
287
|
+
silenced(() => init(dir))
|
|
288
|
+
expect(existsSync(join(dir, 'src/query-client.ts'))).toBe(true)
|
|
289
|
+
rmSync(join(dir, 'src/query-client.ts'))
|
|
290
|
+
write('src/lib/query-client.ts', 'export const queryClient = 1\n')
|
|
291
|
+
|
|
292
|
+
const { code, log } = capture(() => sync({}, dir))
|
|
293
|
+
|
|
294
|
+
expect(code).toBe(0)
|
|
295
|
+
expect(existsSync(join(dir, 'src/query-client.ts'))).toBe(false)
|
|
296
|
+
expect(log).toContain('src/lib/query-client.ts')
|
|
297
|
+
})
|
|
298
|
+
})
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* leaving the prop off, so the absence case asserts no rail and no verdict text at all — which a
|
|
14
14
|
* future `tone = tone ?? 'good'` style default would immediately fail.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
* `renderToStaticMarkup` inside a real `MantineProvider`
|
|
18
|
-
* style on server-rendered markup.
|
|
16
|
+
* A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`; see
|
|
17
|
+
* `theme/use-basalt-spacing.test.tsx`'s doc) — `renderToStaticMarkup` inside a real `MantineProvider`
|
|
18
|
+
* is used deliberately here instead, since the rail is an inline style on server-rendered markup.
|
|
19
|
+
* Converting to the DOM harness would only be worth it if a future assertion here needed live DOM
|
|
20
|
+
* behavior (e.g. computed styles, focus/hover interaction) rather than the static markup string.
|
|
19
21
|
*/
|
|
20
22
|
import { MantineProvider } from '@mantine/core'
|
|
21
23
|
import { describe, expect, test } from 'bun:test'
|
|
@@ -38,6 +38,18 @@
|
|
|
38
38
|
* @example
|
|
39
39
|
* // The earned zero — a measured value worth asserting, not a card that merely has no reading.
|
|
40
40
|
* <StatCard label="Downtime · last 24h" value="0 min" tone="good" />
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* // The honest gate. A zero can be synthesized as well as measured — a downtime figure derived
|
|
44
|
+
* // from outage rows reads 0 both when probes ran clean AND when no probe cycle was ingested at
|
|
45
|
+
* // all, because a dead collector opens no outage row either. `tone` is a positive assertion
|
|
46
|
+
* // about a reading, so the gate belongs on whether a reading exists, not on whether the number
|
|
47
|
+
* // happens to be defined — those are separate facts. Gate on measured coverage instead:
|
|
48
|
+
* <StatCard
|
|
49
|
+
* label="Downtime · last 24h"
|
|
50
|
+
* value={`${downtimeMinutes} min`}
|
|
51
|
+
* tone={hasCoverage ? (downtimeMinutes === 0 ? 'good' : 'bad') : undefined}
|
|
52
|
+
* />
|
|
41
53
|
*/
|
|
42
54
|
import { Box, Card, Group, VisuallyHidden } from '@mantine/core'
|
|
43
55
|
import type { ReactNode } from 'react'
|