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