@octanejs/mcp-server 0.2.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/src/bridge.js ADDED
@@ -0,0 +1,314 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ export const KNOWN_BINDINGS = {
5
+ zustand: '@octanejs/zustand',
6
+ '@tanstack/react-query': '@octanejs/query',
7
+ 'framer-motion': '@octanejs/motion',
8
+ motion: '@octanejs/motion',
9
+ '@stylexjs/stylex': '@octanejs/stylex',
10
+ 'react-router': '@octanejs/router',
11
+ 'react-router-dom': '@octanejs/router',
12
+ '@lexical/react': '@octanejs/lexical',
13
+ '@floating-ui/react': '@octanejs/floating-ui',
14
+ 'radix-ui': '@octanejs/radix',
15
+ };
16
+
17
+ export const KNOWN_VANILLA_CORES = {
18
+ '@tanstack/react-query': '@tanstack/query-core',
19
+ '@tanstack/react-table': '@tanstack/table-core',
20
+ '@tanstack/react-virtual': '@tanstack/virtual-core',
21
+ '@tanstack/react-form': '@tanstack/form-core',
22
+ '@floating-ui/react': '@floating-ui/dom',
23
+ '@xstate/react': 'xstate',
24
+ 'react-redux': 'redux',
25
+ '@reduxjs/toolkit': 'redux',
26
+ 'react-hook-form': null,
27
+ zustand: 'zustand/vanilla',
28
+ valtio: 'valtio/vanilla',
29
+ jotai: 'jotai/vanilla',
30
+ '@lexical/react': 'lexical',
31
+ };
32
+
33
+ export const REACT_API_MAP = {
34
+ useState: {
35
+ status: 'same',
36
+ note: 'Identical, including lazy initializer and functional updates.',
37
+ },
38
+ useReducer: { status: 'same', note: 'Identical, including lazy init third argument.' },
39
+ useEffect: { status: 'same', note: 'Identical deps/cleanup semantics.' },
40
+ useLayoutEffect: { status: 'same', note: 'Identical: synchronous after DOM mutation.' },
41
+ useInsertionEffect: { status: 'same', note: 'Supported.' },
42
+ useMemo: { status: 'same', note: 'Identical.' },
43
+ useCallback: { status: 'same', note: 'Identical.' },
44
+ useRef: { status: 'same', note: 'Identical.' },
45
+ useContext: { status: 'same', note: 'Identical.' },
46
+ useId: { status: 'same', note: 'Identical, hydration-stable.' },
47
+ useImperativeHandle: {
48
+ status: 'same',
49
+ note: 'Supported; combine with refs-as-props instead of forwardRef.',
50
+ },
51
+ useSyncExternalStore: {
52
+ status: 'same',
53
+ note: 'Full React 19 shape including getServerSnapshot; tearing-tested.',
54
+ },
55
+ useDeferredValue: { status: 'same', note: 'Supported.' },
56
+ useTransition: { status: 'same', note: 'Supported.' },
57
+ useActionState: { status: 'same', note: 'Supported.' },
58
+ useOptimistic: { status: 'same', note: 'Supported.' },
59
+ useFormStatus: { status: 'same', note: 'Supported (import from octane, not react-dom).' },
60
+ useEffectEvent: { status: 'same', note: 'Supported.' },
61
+ use: { status: 'same', note: 'Supported for promises and context.' },
62
+ startTransition: { status: 'same', note: 'Supported.' },
63
+ memo: { status: 'same', note: 'Supported.' },
64
+ createContext: { status: 'same', note: 'Supported.' },
65
+ createPortal: { status: 'same', note: 'Supported (import from octane, not react-dom).' },
66
+ flushSync: { status: 'same', note: 'Supported (import from octane, not react-dom).' },
67
+ createElement: {
68
+ status: 'partial',
69
+ note: 'Returns a flat descriptor consumed by compiled templates; not a VDOM tree. Component trees must be authored in .tsrx, not built with nested createElement calls.',
70
+ },
71
+ cloneElement: { status: 'partial', note: 'Works on Octane element descriptors only.' },
72
+ isValidElement: { status: 'same', note: 'Supported for Octane descriptors.' },
73
+ Children: {
74
+ status: 'partial',
75
+ note: 'Supported for Octane descriptors; React.Children traversal idioms over arbitrary VDOM do not apply.',
76
+ },
77
+ Fragment: { status: 'same', note: 'Supported.' },
78
+ Suspense: {
79
+ status: 'same',
80
+ note: 'Supported (also available as the @try/@pending directive in .tsrx).',
81
+ },
82
+ createRoot: { status: 'same', note: 'Supported (import from octane, not react-dom/client).' },
83
+ hydrateRoot: { status: 'same', note: 'Supported (import from octane, not react-dom/client).' },
84
+ forwardRef: {
85
+ status: 'rewrite',
86
+ note: 'No forwardRef. Rewrite to React 19 refs-as-props: accept ref as a normal prop.',
87
+ },
88
+ useDebugValue: { status: 'rewrite', note: 'Not present. Shim as a no-op.' },
89
+ lazy: { status: 'rewrite', note: 'Not present. Use dynamic import plus use()/Suspense.' },
90
+ Component: {
91
+ status: 'unsupported',
92
+ note: 'No class components. Rewrite as a function component.',
93
+ },
94
+ PureComponent: {
95
+ status: 'unsupported',
96
+ note: 'No class components. Rewrite as a function component with memo.',
97
+ },
98
+ StrictMode: {
99
+ status: 'rewrite',
100
+ note: 'Not present, and there is no double-invoke. Drop the wrapper.',
101
+ },
102
+ Profiler: { status: 'unsupported', note: 'Not present.' },
103
+ SuspenseList: { status: 'unsupported', note: 'Not present.' },
104
+ findDOMNode: { status: 'unsupported', note: 'Removed in React 19 too. Use refs.' },
105
+ renderToString: {
106
+ status: 'rewrite',
107
+ note: 'Use renderToString() from octane/server (sync) or prerender() from octane/static (async, awaits Suspense); both return { html, css }.',
108
+ },
109
+ renderToPipeableStream: {
110
+ status: 'unsupported',
111
+ note: 'No streaming SSR yet. Use render() from octane/server.',
112
+ },
113
+ renderToReadableStream: {
114
+ status: 'unsupported',
115
+ note: 'No streaming SSR yet. Use render() from octane/server.',
116
+ },
117
+ onChange: {
118
+ status: 'rewrite',
119
+ note: 'Events are native and delegated; there is no synthetic onChange firing per keystroke. Use onInput for text inputs.',
120
+ },
121
+ defaultProps: {
122
+ status: 'rewrite',
123
+ note: 'Use default parameter values / destructuring defaults.',
124
+ },
125
+ };
126
+
127
+ const IMPORT_SOURCES = [
128
+ 'react',
129
+ 'react-dom',
130
+ 'react-dom/client',
131
+ 'react-dom/server',
132
+ 'react/jsx-runtime',
133
+ 'react/jsx-dev-runtime',
134
+ ];
135
+
136
+ const SCANNABLE = /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts)$/;
137
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__tests__', '__mocks__', 'test', 'tests']);
138
+ const MAX_FILES = 400;
139
+
140
+ export async function collectSourceFiles(root, out = [], depth = 0) {
141
+ if (depth > 6 || out.length >= MAX_FILES) return out;
142
+ let entries;
143
+ try {
144
+ entries = await readdir(root, { withFileTypes: true });
145
+ } catch {
146
+ return out;
147
+ }
148
+ for (const entry of entries) {
149
+ if (out.length >= MAX_FILES) break;
150
+ if (entry.isDirectory()) {
151
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
152
+ await collectSourceFiles(join(root, entry.name), out, depth + 1);
153
+ }
154
+ } else if (SCANNABLE.test(entry.name) && !entry.name.endsWith('.d.ts')) {
155
+ out.push(join(root, entry.name));
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ export function scanSource(source) {
162
+ const apis = new Map();
163
+ for (const name of Object.keys(REACT_API_MAP)) {
164
+ const matches = source.match(new RegExp(`\\b${name}\\b`, 'g'));
165
+ if (matches) apis.set(name, matches.length);
166
+ }
167
+ const imports = new Set();
168
+ for (const spec of IMPORT_SOURCES) {
169
+ if (
170
+ source.includes(`'${spec}'`) ||
171
+ source.includes(`"${spec}"`) ||
172
+ source.includes(`require('${spec}')`) ||
173
+ source.includes(`require("${spec}")`)
174
+ ) {
175
+ imports.add(spec);
176
+ }
177
+ }
178
+ const classComponent = /\bextends\s+(React\.)?(Pure)?Component\b/.test(source);
179
+ return { apis, imports, classComponent };
180
+ }
181
+
182
+ export async function scanPath(root) {
183
+ const files = await collectSourceFiles(resolve(root));
184
+ const totals = new Map();
185
+ const imports = new Set();
186
+ let classComponents = false;
187
+ for (const file of files) {
188
+ let source;
189
+ try {
190
+ source = await readFile(file, 'utf8');
191
+ } catch {
192
+ continue;
193
+ }
194
+ const result = scanSource(source);
195
+ for (const [name, count] of result.apis) {
196
+ totals.set(name, (totals.get(name) ?? 0) + count);
197
+ }
198
+ for (const spec of result.imports) imports.add(spec);
199
+ classComponents ||= result.classComponent;
200
+ }
201
+ return { filesScanned: files.length, totals, imports, classComponents };
202
+ }
203
+
204
+ function apiRows(totals) {
205
+ return [...totals.entries()]
206
+ .map(([name, count]) => ({ name, count, ...REACT_API_MAP[name] }))
207
+ .sort((a, b) => b.count - a.count);
208
+ }
209
+
210
+ function verdictFor(rows, classComponents) {
211
+ if (classComponents || rows.some((row) => row.status === 'unsupported')) return 'needs-rework';
212
+ if (rows.some((row) => row.status === 'rewrite' || row.status === 'partial')) {
213
+ return 'bridgeable-with-rewrites';
214
+ }
215
+ return 'bridgeable';
216
+ }
217
+
218
+ export async function readPackageJson(dir) {
219
+ try {
220
+ return JSON.parse(await readFile(join(dir, 'package.json'), 'utf8'));
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ export function detectVanillaCore(packageName, packageJson) {
227
+ if (packageName in KNOWN_VANILLA_CORES) {
228
+ return KNOWN_VANILLA_CORES[packageName];
229
+ }
230
+ if (packageJson?.exports && typeof packageJson.exports === 'object') {
231
+ for (const key of Object.keys(packageJson.exports)) {
232
+ if (key.includes('vanilla') || key.includes('core')) return `${packageName}${key.slice(1)}`;
233
+ }
234
+ }
235
+ const deps = Object.keys(packageJson?.dependencies ?? {});
236
+ const core = deps.find((dep) => /(^|\/|-)core$/.test(dep) && !dep.startsWith('@babel'));
237
+ return core ?? null;
238
+ }
239
+
240
+ export async function bridgeReport({ packageName, path, projectRoot }) {
241
+ const report = {
242
+ target: packageName ?? path,
243
+ existingBinding: packageName ? (KNOWN_BINDINGS[packageName] ?? null) : null,
244
+ };
245
+
246
+ let scanRoot = path;
247
+ let packageJson = null;
248
+ if (packageName) {
249
+ const base = resolve(projectRoot ?? process.cwd());
250
+ const dir = join(base, 'node_modules', ...packageName.split('/'));
251
+ packageJson = await readPackageJson(dir);
252
+ if (!packageJson) {
253
+ return {
254
+ ...report,
255
+ error: `Package '${packageName}' not found under ${join(base, 'node_modules')}. Install it first or pass 'path' pointing at its source.`,
256
+ };
257
+ }
258
+ report.version = packageJson.version ?? null;
259
+ scanRoot = dir;
260
+ report.vanillaCore = detectVanillaCore(packageName, packageJson);
261
+ report.peerDependsOnReact = Boolean(
262
+ packageJson.peerDependencies?.react ?? packageJson.dependencies?.react,
263
+ );
264
+ }
265
+
266
+ const scan = await scanPath(scanRoot);
267
+ const rows = apiRows(scan.totals);
268
+ report.filesScanned = scan.filesScanned;
269
+ report.reactImports = [...scan.imports];
270
+ report.classComponents = scan.classComponents;
271
+ report.apis = rows;
272
+ report.verdict = verdictFor(rows, scan.classComponents);
273
+ report.plan = planFor(report);
274
+ return report;
275
+ }
276
+
277
+ function planFor(report) {
278
+ const steps = [];
279
+ if (report.existingBinding) {
280
+ steps.push(
281
+ `An official Octane binding already exists: ${report.existingBinding}. Prefer installing it over bridging by hand.`,
282
+ );
283
+ }
284
+ if (report.vanillaCore) {
285
+ steps.push(
286
+ `Reuse the framework-agnostic core '${report.vanillaCore}' unchanged; it has no React imports and runs on Octane as-is.`,
287
+ );
288
+ }
289
+ steps.push(
290
+ 'Re-implement the React binding layer (the hooks/components that import react) against Octane hooks of the same names. Most store bindings reduce to useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot).',
291
+ );
292
+ const rewrites = (report.apis ?? []).filter(
293
+ (row) => row.status === 'rewrite' || row.status === 'partial',
294
+ );
295
+ for (const row of rewrites) {
296
+ steps.push(`${row.name} (${row.count}x): ${row.note}`);
297
+ }
298
+ if (report.classComponents) {
299
+ steps.push(
300
+ 'Class components detected: rewrite each as a function component; error boundaries become the @try/@catch directive or the ErrorBoundary component.',
301
+ );
302
+ }
303
+ const unsupported = (report.apis ?? []).filter((row) => row.status === 'unsupported');
304
+ for (const row of unsupported) {
305
+ steps.push(`${row.name} (${row.count}x): ${row.note}`);
306
+ }
307
+ steps.push(
308
+ 'Re-author any JSX components shipped by the package in .tsrx: compiled React JSX output cannot run on Octane, and hooks called from non-compiled files need compiler slotting (see the bridge-react-package skill for the subSlot pattern).',
309
+ );
310
+ steps.push(
311
+ 'Validate with tests that drive real DOM events and compare behavior against the React original where possible.',
312
+ );
313
+ return steps;
314
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { bridgeReport, detectVanillaCore, scanSource, KNOWN_BINDINGS } from './bridge.js';
6
+
7
+ describe('scanSource', () => {
8
+ it('collects React API usage counts and import specifiers', () => {
9
+ const source = `
10
+ import { forwardRef, useState, useEffect } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ export const X = forwardRef((props, ref) => {
13
+ const [n, setN] = useState(0);
14
+ useEffect(() => {}, [n]);
15
+ return createPortal(null, document.body);
16
+ });
17
+ `;
18
+ const { apis, imports, classComponent } = scanSource(source);
19
+ expect(apis.get('forwardRef')).toBe(2);
20
+ expect(apis.get('useState')).toBe(2);
21
+ expect(apis.get('useEffect')).toBe(2);
22
+ expect(apis.get('createPortal')).toBe(2);
23
+ expect(imports.has('react')).toBe(true);
24
+ expect(imports.has('react-dom')).toBe(true);
25
+ expect(classComponent).toBe(false);
26
+ });
27
+
28
+ it('detects class components', () => {
29
+ expect(scanSource('class Boundary extends React.Component {}').classComponent).toBe(true);
30
+ expect(scanSource('class Memoish extends PureComponent {}').classComponent).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe('detectVanillaCore', () => {
35
+ it('prefers the known-core table', () => {
36
+ expect(detectVanillaCore('@tanstack/react-query', {})).toBe('@tanstack/query-core');
37
+ expect(detectVanillaCore('zustand', {})).toBe('zustand/vanilla');
38
+ });
39
+
40
+ it('finds a vanilla export subpath', () => {
41
+ expect(
42
+ detectVanillaCore('somelib', { exports: { '.': './index.js', './vanilla': './vanilla.js' } }),
43
+ ).toBe('somelib/vanilla');
44
+ });
45
+
46
+ it('falls back to a -core dependency', () => {
47
+ expect(detectVanillaCore('somelib', { dependencies: { '@somelib/core': '1.0.0' } })).toBe(
48
+ '@somelib/core',
49
+ );
50
+ expect(detectVanillaCore('somelib', { dependencies: { '@babel/core': '7.0.0' } })).toBe(null);
51
+ });
52
+ });
53
+
54
+ describe('bridgeReport', () => {
55
+ async function writeFakePackage(root, name, files, packageJson = {}) {
56
+ const dir = join(root, 'node_modules', ...name.split('/'));
57
+ await mkdir(dir, { recursive: true });
58
+ await writeFile(
59
+ join(dir, 'package.json'),
60
+ JSON.stringify({ name, version: '1.2.3', ...packageJson }),
61
+ );
62
+ for (const [file, content] of Object.entries(files)) {
63
+ await writeFile(join(dir, file), content);
64
+ }
65
+ return dir;
66
+ }
67
+
68
+ it('reports a same-name-hooks package as bridgeable', async () => {
69
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
70
+ await writeFakePackage(root, 'tiny-store', {
71
+ 'index.js': `
72
+ import { useSyncExternalStore } from 'react';
73
+ export function useStore(api, selector) {
74
+ return useSyncExternalStore(api.subscribe, () => selector(api.getState()));
75
+ }
76
+ `,
77
+ });
78
+ const report = await bridgeReport({ packageName: 'tiny-store', projectRoot: root });
79
+ expect(report.version).toBe('1.2.3');
80
+ expect(report.filesScanned).toBe(1);
81
+ expect(report.verdict).toBe('bridgeable');
82
+ expect(report.apis.find((row) => row.name === 'useSyncExternalStore').status).toBe('same');
83
+ expect(report.plan.length).toBeGreaterThan(0);
84
+ });
85
+
86
+ it('reports forwardRef usage as bridgeable-with-rewrites', async () => {
87
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
88
+ await writeFakePackage(root, 'ref-lib', {
89
+ 'index.js': `
90
+ import { forwardRef } from 'react';
91
+ export const Thing = forwardRef((props, ref) => null);
92
+ `,
93
+ });
94
+ const report = await bridgeReport({ packageName: 'ref-lib', projectRoot: root });
95
+ expect(report.verdict).toBe('bridgeable-with-rewrites');
96
+ expect(report.plan.join('\n')).toContain('forwardRef');
97
+ });
98
+
99
+ it('reports class components as needs-rework', async () => {
100
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
101
+ await writeFakePackage(root, 'classy', {
102
+ 'index.js': `
103
+ import React from 'react';
104
+ export class Panel extends React.Component { render() { return null; } }
105
+ `,
106
+ });
107
+ const report = await bridgeReport({ packageName: 'classy', projectRoot: root });
108
+ expect(report.classComponents).toBe(true);
109
+ expect(report.verdict).toBe('needs-rework');
110
+ });
111
+
112
+ it('surfaces an existing official binding', async () => {
113
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
114
+ await writeFakePackage(root, 'zustand', { 'index.js': `export {};` });
115
+ const report = await bridgeReport({ packageName: 'zustand', projectRoot: root });
116
+ expect(report.existingBinding).toBe('@octanejs/zustand');
117
+ expect(report.plan[0]).toContain('@octanejs/zustand');
118
+ });
119
+
120
+ it('errors clearly when the package is not installed', async () => {
121
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
122
+ const report = await bridgeReport({ packageName: 'missing-lib', projectRoot: root });
123
+ expect(report.error).toContain('missing-lib');
124
+ });
125
+
126
+ it('scans a bare path without a package name', async () => {
127
+ const root = await mkdtemp(join(tmpdir(), 'octane-bridge-'));
128
+ await writeFile(
129
+ join(root, 'component.jsx'),
130
+ `import { useState } from 'react';
131
+ export function C() { const [n] = useState(0); return n; }`,
132
+ );
133
+ const report = await bridgeReport({ path: root });
134
+ expect(report.filesScanned).toBe(1);
135
+ expect(report.verdict).toBe('bridgeable');
136
+ });
137
+ });
138
+
139
+ describe('KNOWN_BINDINGS', () => {
140
+ it('covers every published @octanejs binding', () => {
141
+ expect(new Set(Object.values(KNOWN_BINDINGS))).toEqual(
142
+ new Set([
143
+ '@octanejs/zustand',
144
+ '@octanejs/query',
145
+ '@octanejs/motion',
146
+ '@octanejs/stylex',
147
+ '@octanejs/router',
148
+ '@octanejs/lexical',
149
+ '@octanejs/floating-ui',
150
+ '@octanejs/radix',
151
+ ]),
152
+ );
153
+ });
154
+ });