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.
- package/AGENTS.md +1 -0
- package/CHANGELOG.md +8 -0
- package/README.md +64 -34
- package/agent/rules/basalt-agent.md +49 -6
- 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/cli/index.d.ts +33 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +131 -0
- 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 +6 -1
- 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/primitives/ChartFrame.test.tsx +8 -5
- package/src/cli/index.ts +285 -0
- package/src/dashboard/stat-card.test.tsx +5 -3
- 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
|
@@ -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
|
+
})
|
package/src/agent-chat/index.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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'
|