@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/README.md +57 -18
- package/package.json +6 -2
- package/skills/bridge-react-package.md +40 -18
- 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 +44 -17
- package/src/bridge.js +232 -11
- package/src/bridge.test.js +130 -1
- package/src/index.js +197 -27
- package/src/index.test.js +137 -0
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
|
-
'
|
|
17
|
-
'react-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: '
|
|
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: {
|
|
101
|
-
|
|
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: '
|
|
123
|
-
note: '
|
|
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: '
|
|
127
|
-
note: '
|
|
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: '
|
|
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) {
|
package/src/bridge.test.js
CHANGED
|
@@ -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 {
|
|
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
|
});
|