basalt-ui 1.9.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * F3 — the mount-reconcile / unmount-cleanup wedge in useAgentThreadRuns.
3
+ *
4
+ * The bug: the unmount-cleanup effect (`controllersRef.current.forEach(c => c.abort())`) aborted
5
+ * every in-flight controller but never CLEARED the map. The mount-reconcile effect treats a thread
6
+ * as "orphaned" (worth a resume attempt) only when `!controllersRef.current.has(thread.id)`. If
7
+ * this fiber's effects re-run WITHOUT the fiber itself unmounting — React 19 StrictMode's
8
+ * dev-only double-invoke, or a `<Activity>` boundary being hidden then shown again — the stale,
9
+ * now-aborted entry survives into the second reconcile pass, `orphaned` reads `false`, no new
10
+ * resume is attempted, and the one live consumer that WAS attempted already returned silently on
11
+ * its AbortError (`consumeAndFinalize`'s guard at the top of its try block). The thread is left in
12
+ * `'streaming'` forever with nothing left to resolve it.
13
+ *
14
+ * Both halves matter equally here:
15
+ *
16
+ * (a) REFUTED — a genuine unmount + remount (a fresh component instance, not a re-run of THIS
17
+ * fiber's effects) gets a brand-new `useRef` Map. There is nothing for the fix to protect
18
+ * against here; the test exists so nobody "fixes" this non-bug later.
19
+ * (b) CONFIRMED — React 19 StrictMode's dev-only double-invoke reuses the SAME fiber (and thus
20
+ * the same ref) across a simulated unmount+remount within one commit.
21
+ * (c) CONFIRMED — React 19.2's `<Activity>` does the SAME thing in PRODUCTION: hiding an
22
+ * Activity boundary destroys the subtree's effects and re-creates them on show, while state,
23
+ * DOM, and ref identity survive. This is the reachable-in-prod half of the wedge, not a
24
+ * StrictMode-only curiosity — hence testing it directly against `<Activity>` from `react`
25
+ * rather than only through Mantine's `Collapse`. At the `@mantine/core` 9.3.0 pinned in THIS
26
+ * repo, `Collapse` has no `keepMounted` DEFAULT — with the prop unset it keeps `children`
27
+ * permanently mounted (the `else` branch below), so a bare `<Collapse>` cannot wedge and a
28
+ * default-Collapse-based test would not reproduce the bug. But a consumer writing
29
+ * `<Collapse keepMounted>` on this SAME 9.3.0 does hit the `<Activity>` path — read
30
+ * the installed `@mantine/core` 9.3.0 `Collapse.mjs` source directly: it imports
31
+ * `Activity` from `react` and, when `keepMounted === true`, renders
32
+ * `jsx(Activity, { mode: isExited ? 'hidden' : 'visible', … })` on the ordinary non-zero-
33
+ * duration path (only the `duration === 0` fast path carries a `&& env !== 'test'` guard). So
34
+ * the Activity hazard is live on THIS pinned version, not only on the 9.4.1 argo resolves —
35
+ * testing `<Activity>` directly is still the right call because it is what a `keepMounted`
36
+ * consumer actually gets here today, not because 9.3.0 is exempt.
37
+ *
38
+ * Verified manually while writing this file (reported alongside the test suite, not re-asserted
39
+ * here as a runtime check): reverting the one-line `controllersRef.current.clear()` fix makes (b)
40
+ * and (c) fail — the thread's status stays `'streaming'` instead of settling — while (a) passes
41
+ * either way, exactly as the "refutation" framing above expects.
42
+ *
43
+ * Both (b) and (c) observe `resume()` invoked TWICE post-fix (once for the pass that gets aborted,
44
+ * once for the pass that survives), not once — React's whole justification for double-invoking a
45
+ * mount effect with a side effect (a network call here) is to surface exactly this
46
+ * non-idempotence; a spec that anticipated "resume() attempted exactly once" for this scenario
47
+ * does not match observed React 19.2.7 behavior. What's invariant, and what these tests assert, is
48
+ * that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is
49
+ * asserted too, but as an observed constant with the doubling explained, not as evidence of a
50
+ * single attempt.
51
+ */
52
+ import { describe, expect, test } from 'bun:test'
53
+ import { Activity, StrictMode, useState } from 'react'
54
+ import { act, render, renderHook, waitFor } from '@testing-library/react'
55
+ import { useAgentThreadRuns } from './use-agent-thread-runs'
56
+ import type { AgentThread, ThreadsStore } from './thread'
57
+ import type { AgentTransport } from './transport'
58
+ import type { AgentPart } from './parts'
59
+ import type { ChatMessage } from './history'
60
+ import type { AgentOutcome } from './outcome'
61
+
62
+ // ── test-only ThreadsStore double ────────────────────────────────────────────
63
+ //
64
+ // A plain mutable object satisfying the ThreadsStore contract — no React state, no
65
+ // localStorage/useSyncExternalStore. useAgentThreadRuns only ever reads `store.threads` and calls
66
+ // its action methods synchronously (via `storeRef.current`), so a hand-rolled mutable double gives
67
+ // the test full, synchronous, non-persisted control over what a "reload mid-stream" looked like —
68
+ // exactly what these mount-reconcile tests need to seed.
69
+ function createTestThreadsStore(initial: AgentThread<AgentPart>[] = []): ThreadsStore<AgentPart> {
70
+ let threads = initial
71
+ let activeId: string | null = null
72
+ return {
73
+ get threads() {
74
+ return threads
75
+ },
76
+ get activeId() {
77
+ return activeId
78
+ },
79
+ select(id) {
80
+ activeId = id
81
+ },
82
+ create(opts) {
83
+ const id = crypto.randomUUID()
84
+ const now = Date.now()
85
+ const thread: AgentThread<AgentPart> = {
86
+ id,
87
+ messages: [],
88
+ outcome: null,
89
+ status: 'pending',
90
+ read: false,
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ ...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
94
+ }
95
+ threads = [thread, ...threads]
96
+ return id
97
+ },
98
+ appendMessage(id, message) {
99
+ threads = threads.map((thread) =>
100
+ thread.id === id
101
+ ? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
102
+ : thread,
103
+ )
104
+ },
105
+ setOutcome(id, outcome) {
106
+ threads = threads.map((thread) =>
107
+ thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
108
+ )
109
+ },
110
+ setStatus(id, status) {
111
+ threads = threads.map((thread) =>
112
+ thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
113
+ )
114
+ },
115
+ setResumeToken(id, token) {
116
+ threads = threads.map((thread) => {
117
+ if (thread.id !== id) return thread
118
+ const { resumeToken: _resumeToken, ...rest } = thread
119
+ return {
120
+ ...rest,
121
+ ...(token !== undefined ? { resumeToken: token } : {}),
122
+ updatedAt: Date.now(),
123
+ }
124
+ })
125
+ },
126
+ markRead(id) {
127
+ threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
128
+ },
129
+ remove(id) {
130
+ threads = threads.filter((thread) => thread.id !== id)
131
+ if (activeId === id) activeId = null
132
+ },
133
+ clear() {
134
+ threads = []
135
+ activeId = null
136
+ },
137
+ }
138
+ }
139
+
140
+ function makeThread(overrides: Partial<AgentThread<AgentPart>> = {}): AgentThread<AgentPart> {
141
+ const now = Date.now()
142
+ return {
143
+ id: crypto.randomUUID(),
144
+ messages: [],
145
+ outcome: null,
146
+ status: 'pending',
147
+ read: false,
148
+ createdAt: now,
149
+ updatedAt: now,
150
+ ...overrides,
151
+ }
152
+ }
153
+
154
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
155
+ let resolve!: (value: T) => void
156
+ const promise = new Promise<T>((r) => {
157
+ resolve = r
158
+ })
159
+ return { promise, resolve }
160
+ }
161
+
162
+ const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
163
+
164
+ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', () => {
165
+ test('(a) REFUTED: a genuine unmount + remount gets a fresh controllers map — resolves to interrupted, never re-enters streaming', () => {
166
+ const store = createTestThreadsStore()
167
+ const threadId = store.create()
168
+
169
+ // No `resume` on this transport: on remount, the orphaned thread must fall straight to
170
+ // 'interrupted' with no async wait involved.
171
+ const transport: AgentTransport<AgentPart, string> = {
172
+ // oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
173
+ async *stream(_input, signal) {
174
+ await new Promise((resolve) => {
175
+ signal?.addEventListener('abort', () => resolve(undefined))
176
+ })
177
+ },
178
+ }
179
+
180
+ const { result, unmount } = renderHook(() =>
181
+ useAgentThreadRuns({ transport, store, resolveOutcome }),
182
+ )
183
+
184
+ act(() => {
185
+ result.current.start(threadId, 'hello')
186
+ })
187
+ expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('streaming')
188
+
189
+ unmount()
190
+
191
+ // A brand-new component instance against the SAME store object — a fresh fiber, fresh
192
+ // `useRef` Map. This must hold both before AND after the F3 fix.
193
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
194
+
195
+ expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('interrupted')
196
+ })
197
+
198
+ test('(b) CONFIRMED: React 19 StrictMode double-invoke resumes the orphaned thread instead of wedging it in streaming', async () => {
199
+ const userMessage: ChatMessage<AgentPart> = {
200
+ id: crypto.randomUUID(),
201
+ role: 'user',
202
+ parts: [{ type: 'text', text: 'hello' }],
203
+ createdAt: Date.now(),
204
+ }
205
+ const thread = makeThread({
206
+ status: 'streaming',
207
+ resumeToken: 'resume-token-1',
208
+ messages: [userMessage],
209
+ })
210
+ const store = createTestThreadsStore([thread])
211
+
212
+ let resumeCalls = 0
213
+ const transport: AgentTransport<AgentPart, string> = {
214
+ async *stream() {},
215
+ async *resume() {
216
+ resumeCalls++
217
+ yield { type: 'text', text: 'resumed' }
218
+ },
219
+ }
220
+
221
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }), {
222
+ wrapper: StrictMode,
223
+ })
224
+
225
+ await waitFor(() => {
226
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
227
+ })
228
+ // Observed constant for this scenario (see file doc): the aborted first pass plus the
229
+ // surviving second pass, not "exactly once".
230
+ expect(resumeCalls).toBe(2)
231
+ })
232
+
233
+ test('(c) CONFIRMED: an <Activity> hide/show cycle resumes the orphaned thread instead of wedging it in streaming', async () => {
234
+ const userMessage: ChatMessage<AgentPart> = {
235
+ id: crypto.randomUUID(),
236
+ role: 'user',
237
+ parts: [{ type: 'text', text: 'hello' }],
238
+ createdAt: Date.now(),
239
+ }
240
+ const thread = makeThread({
241
+ status: 'streaming',
242
+ resumeToken: 'resume-token-2',
243
+ messages: [userMessage],
244
+ })
245
+ const store = createTestThreadsStore([thread])
246
+
247
+ // Gates the first resume() attempt so the hide happens strictly before it settles — an
248
+ // explicit happens-before, not a scheduler coincidence a `setTimeout(…, 0)` would only
249
+ // usually win.
250
+ const gate = deferred<void>()
251
+
252
+ let resumeCalls = 0
253
+ const transport: AgentTransport<AgentPart, string> = {
254
+ async *stream() {},
255
+ async *resume() {
256
+ resumeCalls++
257
+ await gate.promise
258
+ yield { type: 'text', text: 'resumed' }
259
+ },
260
+ }
261
+
262
+ let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
263
+
264
+ function Probe(): null {
265
+ useAgentThreadRuns({ transport, store, resolveOutcome })
266
+ return null
267
+ }
268
+
269
+ function Harness(): JSX.Element {
270
+ const [mode, setMode] = useState<'visible' | 'hidden'>('visible')
271
+ externalSetMode = setMode
272
+ return (
273
+ <Activity mode={mode}>
274
+ <Probe />
275
+ </Activity>
276
+ )
277
+ }
278
+
279
+ await act(async () => {
280
+ render(<Harness />)
281
+ })
282
+
283
+ // First resume attempt is in flight (its gate hasn't been released yet) — hide now to abort
284
+ // it via the F3-fixed cleanup effect. `gate.resolve()` only runs after this completes, so the
285
+ // hide is guaranteed to land before the first attempt could ever settle.
286
+ await act(async () => {
287
+ externalSetMode?.('hidden')
288
+ })
289
+ gate.resolve()
290
+ await act(async () => {
291
+ externalSetMode?.('visible')
292
+ })
293
+
294
+ await waitFor(() => {
295
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
296
+ })
297
+ expect(resumeCalls).toBe(2)
298
+ })
299
+ })
@@ -2,11 +2,12 @@
2
2
  * Mantine-styled thread-chat components over the headless `basalt-ui/agent` layer, capped by the
3
3
  * flagship `ThreadWorkspace` composite.
4
4
  *
5
- * This module is Mantine-coupled by design and lives under the root entry (unlike `basalt-ui/agent`,
6
- * which stays headless so it can be styled by any consumer).
5
+ * This module is Mantine-coupled by design and ships its own `basalt-ui/agent-chat` subpath
6
+ * (also re-exported from the root entry, unchanged for existing consumers) — unlike
7
+ * `basalt-ui/agent`, which stays headless so it can be styled by any consumer.
7
8
  *
8
9
  * @example
9
- * import { ThreadWorkspace } from 'basalt-ui'
10
+ * import { ThreadWorkspace } from 'basalt-ui/agent-chat'
10
11
  * import { createThreadsStore, edenTransport, heuristicOutcome } from 'basalt-ui/agent'
11
12
  */
12
13
 
@@ -4,11 +4,14 @@
4
4
  * shape back on screen exactly where `ChartPending` is supposed to reserve a static, contentless
5
5
  * placeholder (see `ChartPending`'s JSDoc for the three-state "nothing to draw" rationale).
6
6
  *
7
- * No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
8
- * `renderToStaticMarkup` is enough here: `ChartFrame`/`ChartPending` live in `src/charts/**`, which
9
- * is Mantine-free, so no `MantineProvider` wrapper is needed either. `useChartSize` never measures
10
- * under SSR (no `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed
11
- * height — non-zero, which is what lets `children`/`ChartPending` render at all in this harness.
7
+ * A DOM harness now exists (`tests/setup/dom.ts`, preloaded via the root `bunfig.toml`; see
8
+ * `theme/use-basalt-spacing.test.tsx`'s doc) — `renderToStaticMarkup` is used deliberately here
9
+ * instead: `ChartFrame`/`ChartPending` live in `src/charts/**`, which is Mantine-free, so no
10
+ * `MantineProvider` wrapper is needed either. `useChartSize` never measures under SSR (no
11
+ * `ResizeObserver`), so the plot rect falls back to `minWidth` × the resolved fixed height —
12
+ * non-zero, which is what lets `children`/`ChartPending` render at all in this harness. Converting
13
+ * to the DOM harness would only be worth it if a future assertion here needed a real measured size
14
+ * (a live `ResizeObserver` reading) rather than this SSR fallback rect.
12
15
  */
13
16
  import { describe, expect, test } from 'bun:test'
14
17
  import { renderToStaticMarkup } from 'react-dom/server'
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'
@@ -1560,6 +1579,202 @@ type DoctorResult = {
1560
1579
  warnings: number
1561
1580
  }
1562
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
+
1563
1778
  /**
1564
1779
  * Check a consumer repo's basalt integration and print a pass/warn report.
1565
1780
  *
@@ -1570,6 +1785,17 @@ type DoctorResult = {
1570
1785
  *
1571
1786
  * Hard failures (exit non-zero):
1572
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.
1573
1799
  *
1574
1800
  * Warnings (non-fatal):
1575
1801
  * 2. The installed node_modules/basalt-ui version matches the manifest's basaltVersion
@@ -1581,10 +1807,14 @@ type DoctorResult = {
1581
1807
  * `bunx basalt-ui` npm fetch; best-effort, skipped if node_modules is absent).
1582
1808
  * 5. `basaltAppPlugin`'s (basalt-ui/vite) default icon filenames exist under public/ (only when
1583
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.
1584
1813
  *
1585
1814
  * Returns the exit code: 0 = all good, 1 = one or more hard failures.
1586
1815
  */
1587
1816
  export function doctor(cwd: string = process.cwd()): number {
1817
+ const cfg = readBasaltConfig(cwd)
1588
1818
  const result: DoctorResult = { hardFailures: 0, warnings: 0 }
1589
1819
  const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
1590
1820
 
@@ -1728,6 +1958,61 @@ export function doctor(cwd: string = process.cwd()): number {
1728
1958
  }
1729
1959
  }
1730
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
+
1731
2016
  // ── Report ──────────────────────────────────────────────────────────────────
1732
2017
  lines.push('')
1733
2018
  if (result.hardFailures === 0 && result.warnings === 0) {
@@ -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
- * No jsdom is configured in this repo (see `theme/use-basalt-spacing.test.tsx`'s doc) —
17
- * `renderToStaticMarkup` inside a real `MantineProvider` is enough here, since the rail is an inline
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'