@octanejs/mcp-server 0.2.2 → 0.2.5

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/src/bridge.js CHANGED
@@ -7,47 +7,128 @@ import { join, resolve } from 'node:path';
7
7
  export const KNOWN_BINDINGS = {
8
8
  zustand: '@octanejs/zustand',
9
9
  jotai: '@octanejs/jotai',
10
+ '@apollo/client': '@octanejs/apollo-client',
11
+ '@tanstack/ai-react': '@octanejs/tanstack-ai',
12
+ '@tanstack/react-devtools': '@octanejs/tanstack-devtools',
13
+ '@tanstack/react-form': '@octanejs/tanstack-form',
10
14
  '@tanstack/react-query': '@octanejs/tanstack-query',
15
+ '@tanstack/react-router': '@octanejs/tanstack-router',
16
+ '@tanstack/react-store': '@octanejs/tanstack-store',
11
17
  '@tanstack/react-table': '@octanejs/tanstack-table',
12
18
  '@tanstack/react-virtual': '@octanejs/tanstack-virtual',
13
19
  'framer-motion': '@octanejs/motion',
14
20
  motion: '@octanejs/motion',
15
21
  '@stylexjs/stylex': '@octanejs/stylex',
16
- 'react-router': '@octanejs/tanstack-router',
17
- 'react-router-dom': '@octanejs/tanstack-router',
22
+ 'styled-components': '@octanejs/styled-components',
23
+ 'react-router': '@octanejs/remix-router',
24
+ 'react-router-dom': '@octanejs/remix-router',
18
25
  '@lexical/react': '@octanejs/lexical',
26
+ '@tiptap/react': '@octanejs/tiptap',
27
+ 'lucide-react': '@octanejs/lucide',
19
28
  '@floating-ui/react': '@octanejs/floating-ui',
29
+ 'react-aria': '@octanejs/aria',
30
+ 'react-aria-components': '@octanejs/aria',
31
+ 'react-stately': '@octanejs/aria',
20
32
  'radix-ui': '@octanejs/radix',
21
33
  'react-hook-form': '@octanejs/hook-form',
22
34
  '@base-ui-components/react': '@octanejs/base-ui',
35
+ '@dnd-kit/react': '@octanejs/dnd-kit',
36
+ sonner: '@octanejs/sonner',
23
37
  recharts: '@octanejs/recharts',
38
+ '@react-three/fiber': '@octanejs/three',
39
+ '@visx/visx': '@octanejs/visx',
40
+ '@visx/a11y': '@octanejs/visx',
41
+ '@visx/a11y/react': '@octanejs/visx',
42
+ '@visx/a11y/server': '@octanejs/visx',
43
+ '@visx/annotation': '@octanejs/visx',
44
+ '@visx/axis': '@octanejs/visx',
45
+ '@visx/axis/react': '@octanejs/visx',
46
+ '@visx/bounds': '@octanejs/visx',
47
+ '@visx/brush': '@octanejs/visx',
48
+ '@visx/chart': '@octanejs/visx',
49
+ '@visx/chord': '@octanejs/visx',
50
+ '@visx/clip-path': '@octanejs/visx',
51
+ '@visx/curve': '@octanejs/visx',
52
+ '@visx/delaunay': '@octanejs/visx',
53
+ '@visx/drag': '@octanejs/visx',
54
+ '@visx/event': '@octanejs/visx',
55
+ '@visx/geo': '@octanejs/visx',
56
+ '@visx/glyph': '@octanejs/visx',
57
+ '@visx/gradient': '@octanejs/visx',
58
+ '@visx/grid': '@octanejs/visx',
59
+ '@visx/group': '@octanejs/visx',
60
+ '@visx/heatmap': '@octanejs/visx',
61
+ '@visx/hierarchy': '@octanejs/visx',
62
+ '@visx/kernel': '@octanejs/visx',
63
+ '@visx/legend': '@octanejs/visx',
64
+ '@visx/marker': '@octanejs/visx',
65
+ '@visx/mock-data': '@octanejs/visx',
66
+ '@visx/network': '@octanejs/visx',
67
+ '@visx/pattern': '@octanejs/visx',
68
+ '@visx/point': '@octanejs/visx',
69
+ '@visx/react-spring': '@octanejs/visx',
70
+ '@visx/responsive': '@octanejs/visx',
71
+ '@visx/sankey': '@octanejs/visx',
72
+ '@visx/scale': '@octanejs/visx',
73
+ '@visx/scale/react': '@octanejs/visx',
74
+ '@visx/shape': '@octanejs/visx',
75
+ '@visx/shape/react': '@octanejs/visx',
76
+ '@visx/stats': '@octanejs/visx',
77
+ '@visx/text': '@octanejs/visx',
78
+ '@visx/theme': '@octanejs/visx',
79
+ '@visx/theme/react': '@octanejs/visx',
80
+ '@visx/threshold': '@octanejs/visx',
81
+ '@visx/tooltip': '@octanejs/visx',
82
+ '@visx/tooltip/floating': '@octanejs/visx',
83
+ '@visx/voronoi': '@octanejs/visx',
84
+ '@visx/voronoi/react': '@octanejs/visx',
85
+ '@visx/wordcloud': '@octanejs/visx',
86
+ '@visx/xychart': '@octanejs/visx',
87
+ '@visx/zoom': '@octanejs/visx',
24
88
  'react-redux': '@octanejs/redux',
89
+ '@reduxjs/toolkit': '@octanejs/redux-toolkit',
25
90
  '@testing-library/react': '@octanejs/testing-library',
91
+ 'react-i18next': '@octanejs/i18next',
26
92
  '@mdx-js/react': '@octanejs/mdx',
93
+ 'dexie-react-hooks': '@octanejs/dexie',
27
94
  };
28
95
 
96
+ // Workspace directory names for the maintained bindings. Keep this derived
97
+ // from KNOWN_BINDINGS so repository path routing cannot drift from the public
98
+ // binding list (aliases such as `motion` intentionally collapse to one dir).
99
+ export const KNOWN_BINDING_PACKAGE_DIRS = new Set(
100
+ Object.values(KNOWN_BINDINGS).map((name) => name.slice('@octanejs/'.length)),
101
+ );
102
+
29
103
  export const KNOWN_VANILLA_CORES = {
104
+ '@apollo/client': '@apollo/client',
30
105
  '@tanstack/react-query': '@tanstack/query-core',
31
106
  '@tanstack/react-table': '@tanstack/table-core',
32
107
  '@tanstack/react-virtual': '@tanstack/virtual-core',
33
108
  '@tanstack/react-form': '@tanstack/form-core',
34
109
  '@floating-ui/react': '@floating-ui/dom',
110
+ '@dnd-kit/react': '@dnd-kit/dom',
35
111
  '@xstate/react': 'xstate',
36
112
  'react-redux': 'redux',
37
113
  '@reduxjs/toolkit': 'redux',
114
+ 'react-i18next': 'i18next',
38
115
  'react-hook-form': null,
39
116
  zustand: 'zustand/vanilla',
40
117
  valtio: 'valtio/vanilla',
41
118
  jotai: 'jotai/vanilla',
42
119
  '@lexical/react': 'lexical',
120
+ '@tiptap/react': '@tiptap/core',
43
121
  };
44
122
 
45
123
  export const REACT_API_MAP = {
46
124
  useState: {
47
125
  status: 'same',
48
- note: 'Identical, including lazy initializer and functional updates.',
126
+ note: 'Same lazy initializer and functional-update semantics; Octane additionally exposes a stable current-state getter at tuple index 2.',
127
+ },
128
+ useReducer: {
129
+ status: 'same',
130
+ note: 'Same reducer and lazy-init semantics; Octane additionally exposes a stable current-state getter at tuple index 2.',
49
131
  },
50
- useReducer: { status: 'same', note: 'Identical, including lazy init third argument.' },
51
132
  useEffect: { status: 'same', note: 'Identical deps/cleanup semantics.' },
52
133
  useLayoutEffect: { status: 'same', note: 'Identical: synchronous after DOM mutation.' },
53
134
  useInsertionEffect: { status: 'same', note: 'Supported.' },
@@ -97,8 +178,14 @@ export const REACT_API_MAP = {
97
178
  status: 'rewrite',
98
179
  note: 'No forwardRef. Rewrite to React 19 refs-as-props: accept ref as a normal prop.',
99
180
  },
100
- useDebugValue: { status: 'rewrite', note: 'Not present. Shim as a no-op.' },
101
- lazy: { status: 'rewrite', note: 'Not present. Use dynamic import plus use()/Suspense.' },
181
+ useDebugValue: {
182
+ status: 'same',
183
+ note: 'Supported as an accepted no-op (devtools-only label; there is no DevTools integration).',
184
+ },
185
+ lazy: {
186
+ status: 'same',
187
+ note: "Supported. Accepts React's { default } module shape and additionally a bare component from the loader; wrapping Suspense or ViewTransition in lazy() is valid (nested lazy wrappers are not).",
188
+ },
102
189
  Component: {
103
190
  status: 'unsupported',
104
191
  note: 'No class components. Rewrite as a function component.',
@@ -118,17 +205,21 @@ export const REACT_API_MAP = {
118
205
  status: 'rewrite',
119
206
  note: 'Use renderToString() from octane/server (sync) or prerender() from octane/static (async, awaits Suspense); both return { html, css }.',
120
207
  },
208
+ renderToStaticMarkup: {
209
+ status: 'rewrite',
210
+ note: 'Use renderToStaticMarkup() from octane/server (clean, non-hydratable HTML; returns { html, css }).',
211
+ },
121
212
  renderToPipeableStream: {
122
- status: 'unsupported',
123
- note: 'No streaming SSR yet. Use render() from octane/server.',
213
+ status: 'rewrite',
214
+ note: 'Supported natively: import renderToPipeableStream from octane/server (Octane argument convention: component, props?, options?; returns { pipe, abort } with onShellReady/onShellError/onAllReady StreamOptions).',
124
215
  },
125
216
  renderToReadableStream: {
126
- status: 'unsupported',
127
- note: 'No streaming SSR yet. Use render() from octane/server.',
217
+ status: 'rewrite',
218
+ note: 'Supported natively: import renderToReadableStream from octane/server (Octane argument convention; resolves with a ReadableStream once the shell is ready, same StreamOptions).',
128
219
  },
129
220
  onChange: {
130
221
  status: 'rewrite',
131
- note: 'Events are native and delegated; there is no synthetic onChange firing per keystroke. Use onInput for text inputs.',
222
+ note: 'This is a standard text host using React-style per-edit onChange. Use onInput (or onInputCapture) for per-edit intent. Preserve component callbacks, select/checkable change handlers, and deliberate native text commits; mark the last case with suppressNativeChangeWarning.',
132
223
  },
133
224
  defaultProps: {
134
225
  status: 'rewrite',
@@ -149,6 +240,111 @@ const SCANNABLE = /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/;
149
240
  const SKIP_DIRS = new Set(['node_modules', '.git', '__tests__', '__mocks__', 'test', 'tests']);
150
241
  const MAX_FILES = 400;
151
242
 
243
+ const NON_TEXT_INPUT_TYPES = new Set([
244
+ 'button',
245
+ 'checkbox',
246
+ 'color',
247
+ 'date',
248
+ 'datetime-local',
249
+ 'file',
250
+ 'hidden',
251
+ 'image',
252
+ 'month',
253
+ 'radio',
254
+ 'range',
255
+ 'reset',
256
+ 'submit',
257
+ 'time',
258
+ 'week',
259
+ ]);
260
+
261
+ function booleanAttributeState(attributes, name, exactTrue = false) {
262
+ const match = attributes.match(
263
+ new RegExp(
264
+ `\\b${name}(?=\\s|=|\\/|$)(?:\\s*=\\s*(?:"([^"]*)"|'([^']*)'|\\{\\s*([^}]*)\\s*\\}))?`,
265
+ ),
266
+ );
267
+ if (!match) return 'false';
268
+ if (!match[0].includes('=')) return 'true';
269
+ const quoted = match[1] ?? match[2];
270
+ if (exactTrue && quoted !== undefined) return 'false';
271
+ if (quoted !== undefined) return quoted.length > 0 ? 'true' : 'false';
272
+ const expression = match[3]?.trim();
273
+ if (exactTrue) {
274
+ if (expression === 'true') return 'true';
275
+ if (
276
+ /^(?:false|null|undefined|void\s+0|[+-]?(?:\d+(?:\.\d+)?|\.\d+)|NaN|'[^']*'|"[^"]*")$/.test(
277
+ expression ?? '',
278
+ )
279
+ )
280
+ return 'false';
281
+ return 'dynamic';
282
+ }
283
+ if (/^(?:false|null|undefined|void\s+0|0|-0|NaN|''|"")$/.test(expression ?? '')) {
284
+ return 'false';
285
+ }
286
+ if (/^(?:true|[1-9]\d*(?:\.\d+)?|'[^']+'|"[^"]+")$/.test(expression ?? '')) {
287
+ return 'true';
288
+ }
289
+ return 'dynamic';
290
+ }
291
+
292
+ function hasPotentialEventAssignment(attributes, name) {
293
+ const match = attributes.match(new RegExp(`\\b${name}\\s*=\\s*([^\\s>]+)`));
294
+ if (!match) return false;
295
+ const value = match[1];
296
+ if (value.startsWith('"') || value.startsWith("'")) return false;
297
+ if (
298
+ /^\{\s*(?:false|true|null|undefined|void\s+0|0|-0|NaN|''|"")\s*\}/.test(
299
+ attributes.slice(match.index + match[0].indexOf(value)),
300
+ )
301
+ )
302
+ return false;
303
+ return true;
304
+ }
305
+
306
+ function countReactStyleTextChanges(source) {
307
+ let count = 0;
308
+ const hosts = source.matchAll(/<(input|textarea)\b((?:[^>]|=>)*)>/g);
309
+ for (const match of hosts) {
310
+ const host = match[1].toLowerCase();
311
+ const attrs = match[2];
312
+ const changeHandlers = ['onChange', 'onChangeCapture'].filter((name) =>
313
+ hasPotentialEventAssignment(attrs, name),
314
+ );
315
+ if (
316
+ changeHandlers.length === 0 ||
317
+ hasPotentialEventAssignment(attrs, 'onInput') ||
318
+ hasPotentialEventAssignment(attrs, 'onInputCapture')
319
+ )
320
+ continue;
321
+ // A spread can replace the handler, type, editability, or suppression.
322
+ // Leave that site for the compiler/runtime's final-props diagnostic rather
323
+ // than suggesting a source rewrite from an incomplete static scan.
324
+ if (/\{\s*\.\.\./.test(attrs)) continue;
325
+ const suppression = booleanAttributeState(attrs, 'suppressNativeChangeWarning', true);
326
+ const readOnly = booleanAttributeState(attrs, 'readOnly');
327
+ const disabled = booleanAttributeState(attrs, 'disabled');
328
+ if ([suppression, readOnly, disabled].includes('true')) continue;
329
+ if ([suppression, readOnly, disabled].includes('dynamic')) continue;
330
+ if (host === 'input') {
331
+ const literalType = attrs.match(
332
+ /\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*["']([^"']*)["']\s*\})/i,
333
+ );
334
+ if (!literalType && /\btype\s*=/.test(attrs)) continue;
335
+ const type = (
336
+ literalType?.[1] ??
337
+ literalType?.[2] ??
338
+ literalType?.[3] ??
339
+ 'text'
340
+ ).toLowerCase();
341
+ if (NON_TEXT_INPUT_TYPES.has(type)) continue;
342
+ }
343
+ count += changeHandlers.length;
344
+ }
345
+ return count;
346
+ }
347
+
152
348
  export async function collectSourceFiles(root, out = [], depth = 0) {
153
349
  if (depth > 6 || out.length >= MAX_FILES) return out;
154
350
  let entries;
@@ -173,9 +369,12 @@ export async function collectSourceFiles(root, out = [], depth = 0) {
173
369
  export function scanSource(source) {
174
370
  const apis = new Map();
175
371
  for (const name of Object.keys(REACT_API_MAP)) {
372
+ if (name === 'onChange') continue;
176
373
  const matches = source.match(new RegExp(`\\b${name}\\b`, 'g'));
177
374
  if (matches) apis.set(name, matches.length);
178
375
  }
376
+ const textChanges = countReactStyleTextChanges(source);
377
+ if (textChanges > 0) apis.set('onChange', textChanges);
179
378
  const imports = new Set();
180
379
  for (const spec of IMPORT_SOURCES) {
181
380
  if (
@@ -286,6 +485,28 @@ export async function bridgeReport({ packageName, path, projectRoot }) {
286
485
  return report;
287
486
  }
288
487
 
488
+ // Filesystem-free variant of bridgeReport for hosted/remote use: the caller
489
+ // pastes source text instead of pointing at an installed package, so there is
490
+ // no node_modules resolution, no version, and no file counting. Everything
491
+ // else (API rows, verdict, plan) matches bridgeReport.
492
+ export function bridgeReportFromSource(source, { packageName } = {}) {
493
+ const report = {
494
+ target: packageName ?? 'pasted-source',
495
+ existingBinding: packageName ? (KNOWN_BINDINGS[packageName] ?? null) : null,
496
+ };
497
+ if (packageName) {
498
+ report.vanillaCore = detectVanillaCore(packageName, null);
499
+ }
500
+ const scan = scanSource(source);
501
+ const rows = apiRows(scan.apis);
502
+ report.reactImports = [...scan.imports];
503
+ report.classComponents = scan.classComponent;
504
+ report.apis = rows;
505
+ report.verdict = verdictFor(rows, scan.classComponent);
506
+ report.plan = planFor(report);
507
+ return report;
508
+ }
509
+
289
510
  function planFor(report) {
290
511
  const steps = [];
291
512
  if (report.existingBinding) {
@@ -3,7 +3,14 @@ import { mkdir, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { bridgeReport, detectVanillaCore, scanSource, KNOWN_BINDINGS } from './bridge.js';
6
+ import {
7
+ bridgeReport,
8
+ bridgeReportFromSource,
9
+ detectVanillaCore,
10
+ scanSource,
11
+ KNOWN_BINDINGS,
12
+ KNOWN_BINDING_PACKAGE_DIRS,
13
+ } from './bridge.js';
7
14
 
8
15
  describe('scanSource', () => {
9
16
  it('collects React API usage counts and import specifiers', () => {
@@ -30,10 +37,41 @@ describe('scanSource', () => {
30
37
  expect(scanSource('class Boundary extends React.Component {}').classComponent).toBe(true);
31
38
  expect(scanSource('class Memoish extends PureComponent {}').classComponent).toBe(true);
32
39
  });
40
+
41
+ it('targets only React-style text-host onChange wiring', () => {
42
+ const source = `
43
+ function Demo(props) {
44
+ return <>
45
+ <input onChange={(event) => props.edit(event.currentTarget.value)} />
46
+ <textarea onChangeCapture={props.capture} />
47
+ <input type="checkbox" onChange={props.toggle} />
48
+ <input type={props.type} onChange={props.dynamic} />
49
+ <input {...props.field} onChange={props.dynamic} />
50
+ <input onInput={props.input} onChange={props.commit} />
51
+ <input onInput={null} onChange={props.edit} />
52
+ <input onChange={null} />
53
+ <input onChange={props.commit} suppressNativeChangeWarning />
54
+ <input onChange={props.commit} suppressNativeChangeWarning={true} />
55
+ <input onChange={props.edit} suppressNativeChangeWarning={false} />
56
+ <input onChange={props.edit} suppressNativeChangeWarning="false" />
57
+ <input onChange={props.edit} suppressNativeChangeWarning="true" />
58
+ <textarea onChange={props.commit} readOnly />
59
+ <textarea onChange={props.commit} disabled="disabled" />
60
+ <select onChange={props.select}><option>one</option></select>
61
+ <Input onChange={props.value} />
62
+ <Field onChange={props.value} />
63
+ </>;
64
+ }
65
+ `;
66
+ expect(scanSource(source).apis.get('onChange')).toBe(6);
67
+ const report = bridgeReportFromSource(source);
68
+ expect(report.plan.join('\n')).toContain('standard text host');
69
+ });
33
70
  });
34
71
 
35
72
  describe('detectVanillaCore', () => {
36
73
  it('prefers the known-core table', () => {
74
+ expect(detectVanillaCore('@apollo/client', {})).toBe('@apollo/client');
37
75
  expect(detectVanillaCore('@tanstack/react-query', {})).toBe('@tanstack/query-core');
38
76
  expect(detectVanillaCore('zustand', {})).toBe('zustand/vanilla');
39
77
  });
@@ -97,6 +135,34 @@ describe('bridgeReport', () => {
97
135
  expect(report.plan.join('\n')).toContain('forwardRef');
98
136
  });
99
137
 
138
+ it('lazy plus Suspense stays bridgeable', async () => {
139
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
140
+ await writeFakePackage(root, 'lazy-lib', {
141
+ 'index.js': `
142
+ import { lazy, Suspense } from 'react';
143
+ export const Panel = lazy(() => import('./panel.js'));
144
+ export { Suspense };
145
+ `,
146
+ });
147
+ const report = await bridgeReport({ packageName: 'lazy-lib', projectRoot: root });
148
+ expect(report.apis.find((row) => row.name === 'lazy').status).toBe('same');
149
+ expect(report.verdict).toBe('bridgeable');
150
+ });
151
+
152
+ it('routes streaming SSR entry points to octane/server as a rewrite', async () => {
153
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
154
+ await writeFakePackage(root, 'streamer', {
155
+ 'index.js': `
156
+ import { renderToPipeableStream } from 'react-dom/server';
157
+ export const ssr = (el) => renderToPipeableStream(el);
158
+ `,
159
+ });
160
+ const report = await bridgeReport({ packageName: 'streamer', projectRoot: root });
161
+ expect(report.apis.find((row) => row.name === 'renderToPipeableStream').status).toBe('rewrite');
162
+ expect(report.verdict).toBe('bridgeable-with-rewrites');
163
+ expect(report.plan.join('\n')).toContain('octane/server');
164
+ });
165
+
100
166
  it('reports class components as needs-rework', async () => {
101
167
  const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
102
168
  await writeFakePackage(root, 'classy', {
@@ -137,7 +203,64 @@ describe('bridgeReport', () => {
137
203
  });
138
204
  });
139
205
 
206
+ describe('bridgeReportFromSource', () => {
207
+ it('produces the same verdict and plan as bridgeReport without touching the filesystem', () => {
208
+ const report = bridgeReportFromSource(`
209
+ import { forwardRef, useState } from 'react';
210
+ export const Thing = forwardRef((props, ref) => {
211
+ const [n] = useState(0);
212
+ return n;
213
+ });
214
+ `);
215
+ expect(report.target).toBe('pasted-source');
216
+ expect(report.reactImports).toContain('react');
217
+ expect(report.verdict).toBe('bridgeable-with-rewrites');
218
+ expect(report.apis.find((row) => row.name === 'forwardRef').status).toBe('rewrite');
219
+ expect(report.plan.join('\n')).toContain('forwardRef');
220
+ });
221
+
222
+ it('reports class components as needs-rework', () => {
223
+ const report = bridgeReportFromSource(`
224
+ import React from 'react';
225
+ export class Panel extends React.Component { render() { return null; } }
226
+ `);
227
+ expect(report.classComponents).toBe(true);
228
+ expect(report.verdict).toBe('needs-rework');
229
+ expect(report.plan.join('\n')).toContain('function component');
230
+ });
231
+
232
+ it('surfaces the official binding and vanilla core when a package name is given', () => {
233
+ const report = bridgeReportFromSource(`export {};`, {
234
+ packageName: '@tanstack/react-query',
235
+ });
236
+ expect(report.target).toBe('@tanstack/react-query');
237
+ expect(report.existingBinding).toBe('@octanejs/tanstack-query');
238
+ expect(report.vanillaCore).toBe('@tanstack/query-core');
239
+ expect(report.plan[0]).toContain('@octanejs/tanstack-query');
240
+ });
241
+
242
+ it('same-name hook usage stays bridgeable', () => {
243
+ const report = bridgeReportFromSource(`
244
+ import { useSyncExternalStore } from 'react';
245
+ export function useStore(api, selector) {
246
+ return useSyncExternalStore(api.subscribe, () => selector(api.getState()));
247
+ }
248
+ `);
249
+ expect(report.verdict).toBe('bridgeable');
250
+ });
251
+ });
252
+
140
253
  describe('KNOWN_BINDINGS', () => {
254
+ it('maps every public Visx entry point to the aggregate Octane port', async () => {
255
+ const packagesRoot = fileURLToPath(new URL('../..', import.meta.url));
256
+ const manifest = JSON.parse(await readFile(join(packagesRoot, 'visx', 'package.json'), 'utf8'));
257
+ const upstreamPackages = Object.keys(manifest.exports).map((entry) =>
258
+ entry === '.' ? '@visx/visx' : `@visx/${entry.slice(2)}`,
259
+ );
260
+ expect(upstreamPackages).toHaveLength(49);
261
+ expect(upstreamPackages.every((name) => KNOWN_BINDINGS[name] === '@octanejs/visx')).toBe(true);
262
+ });
263
+
141
264
  it('covers every published @octanejs binding (derived from workspace manifests)', async () => {
142
265
  // The expected set is DERIVED from packages/*/package.json rather than
143
266
  // hand-maintained, so publishing a new binding without registering it in
@@ -146,12 +269,16 @@ describe('KNOWN_BINDINGS', () => {
146
269
  // excluded by name.
147
270
  const NON_BINDINGS = new Set([
148
271
  'octane',
272
+ '@octanejs/app-core',
273
+ '@octanejs/rspack-plugin',
274
+ '@octanejs/rsbuild-plugin',
149
275
  '@octanejs/vite-plugin',
150
276
  '@octanejs/adapter-vercel',
151
277
  '@octanejs/mcp-server',
152
278
  ]);
153
279
  const packagesRoot = fileURLToPath(new URL('../..', import.meta.url));
154
280
  const bindings = [];
281
+ const bindingDirectories = [];
155
282
  for (const entry of await readdir(packagesRoot, { withFileTypes: true })) {
156
283
  if (!entry.isDirectory()) continue;
157
284
  let manifest;
@@ -164,8 +291,10 @@ describe('KNOWN_BINDINGS', () => {
164
291
  }
165
292
  if (manifest.private || NON_BINDINGS.has(manifest.name)) continue;
166
293
  bindings.push(manifest.name);
294
+ bindingDirectories.push(entry.name);
167
295
  }
168
296
  expect(bindings.length).toBeGreaterThan(0);
169
297
  expect(new Set(Object.values(KNOWN_BINDINGS))).toEqual(new Set(bindings));
298
+ expect(KNOWN_BINDING_PACKAGE_DIRS).toEqual(new Set(bindingDirectories));
170
299
  });
171
300
  });