@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/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: { status: 'rewrite', note: 'Not present. Shim as a no-op.' },
121
- lazy: { status: 'rewrite', note: 'Not present. Use dynamic import plus use()/Suspense.' },
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: 'unsupported',
143
- note: 'No streaming SSR yet. Use render() from octane/server.',
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: 'unsupported',
147
- note: 'No streaming SSR yet. Use render() from octane/server.',
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: 'Events are native and delegated; there is no synthetic onChange firing per keystroke. Use onInput for text inputs.',
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) {
@@ -1,10 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { mkdir, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises';
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('covers every published @octanejs binding (derived from workspace manifests)', async () => {
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 bindings = [];
165
- const bindingDirectories = [];
166
- for (const entry of await readdir(packagesRoot, { withFileTypes: true })) {
167
- if (!entry.isDirectory()) continue;
168
- let manifest;
169
- try {
170
- manifest = JSON.parse(
171
- await readFile(join(packagesRoot, entry.name, 'package.json'), 'utf8'),
172
- );
173
- } catch {
174
- continue; // not a package dir
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(new Set(bindings));
182
- expect(KNOWN_BINDING_PACKAGE_DIRS).toEqual(new Set(bindingDirectories));
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
  });