فهرست منبع

feat(resolution): Fabric / Codegen view components — close JSX → native flow (Phase 6)

Two-part design closes the React Native Fabric architecture flow:

1. Framework extractor (src/resolution/frameworks/fabric.ts) parses TS/TSX
   spec files for `codegenNativeComponent<Props>('Name', ...)`
   declarations and emits:
   - One `component` node per declaration (named after the JS-visible
     component name; this is what the existing reactJsxChildEdges JSX
     synthesizer matches against to wire `<MyComponent>` JSX tags).
   - One `property` node per field of the `NativeProps` interface —
     surfacing JSX-callable props (onTap, onAppear, color, …) as
     discoverable graph nodes.

2. Synthesizer (`fabricNativeImplEdges` in callback-synthesizer.ts)
   walks every `fabric-component:*` node and looks for a native class
   matching its name with one of RN's convention suffixes
   (empty / View / ViewManager / ComponentView / Manager). Emits a
   `calls` edge per match with metadata.synthesizedBy:
   'fabric-native-impl'.

Combined with reactJsxChildEdges, this closes the full
JSX → native flow: consumer-app
`<MyView prop=v/>` → Fabric `component` node `MyView` → native
class `MyViewView` / `MyViewManager` / `MyViewComponentView` / etc.

Validated on react-native-screens (the corpus repo that was entirely
Fabric and showed 0 bridges previously):
- 27 Fabric component nodes extracted from 54 codegenNativeComponent
  declarations (the .web.ts variants are filtered out by the spec
  validity check).
- 272 prop nodes from the NativeProps interfaces.
- 68 fabric-native-impl bridge edges, sample:
    RNSFullWindowOverlay → RNSFullWindowOverlay (ObjC, exact match)
    RNSFullWindowOverlay → RNSFullWindowOverlayManager (suffix: Manager)
    RNSModalScreen → RNSModalScreenManager (Manager)
    RNSScreenContainer → RNSScreenContainerView (View)

Four tests in __tests__/fabric-view.test.ts cover the extractor (with
NativeProps + bare-component variants) + an end-to-end fixture proving:
  App (TSX with <MyView color="red"/>) → MyView (fabric-component)
    → MyViewView (ObjC class)
end-to-end after indexing.

Detect simplification: initially gated extraction on a file-scan signal
`codegenNativeComponent` marker, but on big repos (RNScreens has
~1500 source files; fabric specs sit alphabetically after FabricExample/
beyond a reasonable scan budget) the marker check timed out and
produced false negatives. Detect now uses package.json's
react-native dep alone — extract() per-file decides the per-spec
validity, which is essentially free on non-spec files.

928/930 existing tests still pass (2 skipped); +4 new Fabric tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 4 هفته پیش
والد
کامیت
b6e1b72d89

+ 144 - 0
__tests__/fabric-view.test.ts

@@ -0,0 +1,144 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { CodeGraph } from '../src';
+import { fabricViewResolver } from '../src/resolution/frameworks/fabric';
+
+describe('Fabric view component extractor (codegenNativeComponent specs)', () => {
+  it('extracts a component node + prop nodes from a Native*.ts spec', () => {
+    const source = `
+'use client';
+import { codegenNativeComponent } from 'react-native';
+import type { ViewProps, CodegenTypes as CT, ColorValue } from 'react-native';
+
+type TapEvent = Readonly<{ x: number; y: number }>;
+
+export interface NativeProps extends ViewProps {
+  color?: ColorValue;
+  onTap?: CT.DirectEventHandler<TapEvent>;
+  caption?: string;
+}
+
+export default codegenNativeComponent<NativeProps>('MyView', {});
+`;
+    const result = fabricViewResolver.extract?.('src/MyViewNativeComponent.ts', source);
+    expect(result).toBeDefined();
+    const componentNodes = result!.nodes.filter((n) => n.kind === 'component');
+    const propNodes = result!.nodes.filter((n) => n.kind === 'property');
+    expect(componentNodes).toHaveLength(1);
+    expect(componentNodes[0]?.name).toBe('MyView');
+    expect(propNodes.map((n) => n.name).sort()).toEqual(['caption', 'color', 'onTap']);
+  });
+
+  it('returns nothing for a file without codegenNativeComponent', () => {
+    const source = `export const x = 1;`;
+    const result = fabricViewResolver.extract?.('plain.ts', source);
+    expect(result?.nodes).toHaveLength(0);
+  });
+
+  it('handles a spec with no NativeProps interface (rare but valid)', () => {
+    const source = `
+import { codegenNativeComponent } from 'react-native';
+export default codegenNativeComponent('BareComponent');
+`;
+    const result = fabricViewResolver.extract?.('Bare.ts', source);
+    // Component node exists; no prop nodes.
+    const components = result!.nodes.filter((n) => n.kind === 'component');
+    const props = result!.nodes.filter((n) => n.kind === 'property');
+    expect(components).toHaveLength(1);
+    expect(components[0]?.name).toBe('BareComponent');
+    expect(props).toHaveLength(0);
+  });
+});
+
+describe('Fabric end-to-end: JSX consumer → Fabric component → native class', () => {
+  let dir: string;
+
+  beforeEach(() => {
+    dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-fixture-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(dir, { recursive: true, force: true });
+  });
+
+  it('connects <MyView/> JSX to the native ObjC class via Fabric synthesizer', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      '{"dependencies":{"react-native":"^0.73"}}'
+    );
+    // Fabric spec.
+    fs.mkdirSync(path.join(dir, 'spec'));
+    fs.writeFileSync(
+      path.join(dir, 'spec', 'MyViewNativeComponent.ts'),
+      `import { codegenNativeComponent } from 'react-native';
+import type { ViewProps } from 'react-native';
+export interface NativeProps extends ViewProps { color?: string; }
+export default codegenNativeComponent<NativeProps>('MyView');`
+    );
+    // Native iOS implementation — class named with the `View` suffix
+    // convention.
+    fs.mkdirSync(path.join(dir, 'ios'));
+    fs.writeFileSync(
+      path.join(dir, 'ios', 'MyView.mm'),
+      `@interface MyViewView : UIView
+@end
+@implementation MyViewView
+- (void)setColor:(NSString *)c { /* … */ }
+@end`
+    );
+    // JSX consumer.
+    fs.mkdirSync(path.join(dir, 'src'));
+    fs.writeFileSync(
+      path.join(dir, 'src', 'App.tsx'),
+      `import React from 'react';
+import MyView from '../spec/MyViewNativeComponent';
+export function App() {
+  return <MyView color="red"/>;
+}`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // 1. The Fabric component node exists.
+    const componentRows = db
+      .prepare("SELECT id, name, kind FROM nodes WHERE id LIKE 'fabric-component:%' AND name='MyView'")
+      .all();
+    expect(componentRows).toHaveLength(1);
+
+    // 2. The native class node exists.
+    const nativeRows = db
+      .prepare("SELECT id, name FROM nodes WHERE kind='class' AND language='objc' AND name='MyViewView'")
+      .all();
+    expect(nativeRows).toHaveLength(1);
+
+    // 3. Fabric synthesizer bridges component → native class.
+    const bridgeRows = db
+      .prepare(
+        `SELECT s.name comp, t.name native FROM edges e
+         JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy')='fabric-native-impl'
+           AND s.name='MyView' AND t.name='MyViewView'`
+      )
+      .all();
+    expect(bridgeRows).toHaveLength(1);
+
+    // 4. JSX synthesizer links the App function → the Fabric component
+    //    (jsx-render edge keyed on the tag name 'MyView').
+    const jsxRows = db
+      .prepare(
+        `SELECT s.name caller, t.name comp FROM edges e
+         JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+         WHERE json_extract(e.metadata,'$.synthesizedBy')='jsx-render'
+           AND t.id LIKE 'fabric-component:%' AND t.name='MyView'`
+      )
+      .all();
+    cg.close?.();
+    expect(jsxRows.length).toBeGreaterThanOrEqual(1);
+    expect(jsxRows[0].caller).toBe('App');
+    // The full flow: App (TSX) → MyView (fabric-component) → MyViewView (ObjC native class)
+  });
+});

+ 51 - 1
docs/design/mixed-ios-and-react-native-bridging.md

@@ -372,7 +372,7 @@ otherwise closed but a Fabric flow still breaks.
 | JavaScript × native | React Native TurboModules | spec interface ↔ impl | R (spec as ground truth) | ✅ partial — name-match path lands (§8b) |
 | Objective-C/Java/Kotlin → JavaScript | React Native event emitters | `[self sendEventWithName:]` → `addListener` | S (cross-lang channel) | ✅ Phase 3 (§8e) |
 | JavaScript × Swift/Kotlin | Expo Modules | `requireNativeModule('X').fn(...)` → `Function("fn") { }` | R (extract synthesizes method nodes) | ✅ Phase 4 (§8f) |
-| JavaScript × native | React Native Fabric views | `<MyView p=v/>` → `RCT_EXPORT_VIEW_PROPERTY` / Codegen view spec | R + JSX | ⬜ Phase 6 (defer) |
+| JavaScript × native | React Native Fabric views | `<MyView p=v/>` → Codegen spec component + NativeProps | R (extract) + S (native-impl) + JSX | ✅ Phase 6 (§8g) |
 
 ### 8a. Phase 1 measurements — Swift ↔ ObjC
 
@@ -460,6 +460,56 @@ Five tests cover the extractor + an end-to-end fixture:
 to the native impl node` — confirms the resolver-free bridge path
 works when names aren't shadowed.
 
+### 8g. Phase 6 measurements — Fabric / Codegen view components
+
+Two-part design:
+
+1. **Framework extractor** (`src/resolution/frameworks/fabric.ts`) — parses
+   TS / TSX spec files for `codegenNativeComponent<Props>('Name', ...)`
+   declarations. Emits:
+   - One `component` node per declaration (named after the JS-visible
+     component name; matches the JSX synthesizer's name+kind filter).
+   - One `property` node per declared field of the `NativeProps`
+     interface — surfacing JSX-callable props like `onTap`,
+     `nativeContainerBackgroundColor` as discoverable graph nodes.
+
+2. **Synthesizer** (`fabricNativeImplEdges` in `callback-synthesizer.ts`) —
+   walks every `fabric-component:*` node and looks for a native class
+   matching its name with one of RN's convention suffixes (empty / `View`
+   / `ViewManager` / `ComponentView` / `Manager`). Emits a `calls` edge
+   with `metadata.synthesizedBy:'fabric-native-impl'` from the component
+   to each match. The convention is precise enough that there's no name
+   collision in well-formed RN libraries.
+
+Combined with the existing `reactJsxChildEdges` JSX synthesizer, this
+closes the full JSX → native flow: consumer-app JSX `<MyView prop=v/>`
+→ Fabric `component` node `MyView` → native class `MyViewView`
+(or `MyViewManager` / `MyViewComponentView` / …).
+
+Re-validated on **react-native-screens** (the corpus repo that was
+entirely Fabric and showed 0 bridges in Phase 2):
+
+| Metric | Count |
+|---|---|
+| `codegenNativeComponent` spec declarations | 54 |
+| Fabric component nodes extracted | 27 (one per non-web spec; the `*.web.ts` variants are filtered out by spec validity) |
+| Fabric prop nodes extracted | 272 (the full NativeProps interface surface across all components) |
+| `fabric-native-impl` bridge edges | 68 |
+
+Sample bridge edges:
+
+| JS component | Native class | Suffix |
+|---|---|---|
+| `RNSFullWindowOverlay` | `RNSFullWindowOverlay` (ObjC) | (exact) |
+| `RNSFullWindowOverlay` | `RNSFullWindowOverlayManager` (ObjC) | `Manager` |
+| `RNSModalScreen` | `RNSModalScreenManager` (ObjC) | `Manager` |
+| `RNSScreenContainer` | `RNSScreenContainerView` (ObjC) | `View` |
+
+Four tests cover the extractor + a full end-to-end fixture
+(`App (TSX) → MyView (fabric-component) → MyViewView (ObjC class)`)
+that asserts the JSX→component edge AND the
+component→native-class edge both exist after indexing.
+
 ---
 
 ## 9. Open questions to settle in Phase 1

+ 77 - 3
src/resolution/callback-synthesizer.ts

@@ -669,11 +669,83 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] {
   return edges;
 }
 
+/**
+ * Phase 6 — React Native Fabric/Codegen view component bridge.
+ *
+ * The Fabric framework extractor (`frameworks/fabric.ts`) emits
+ * `component` nodes named after the JS-visible component (e.g.
+ * `RNSScreenStack`) from each `codegenNativeComponent<Props>('Name')`
+ * spec declaration. The native implementation lives in an ObjC++/.mm or
+ * Kotlin/Java class whose name follows one of RN's conventions:
+ *
+ *   - Exact: `RNSScreenStack`
+ *   - With suffix: `RNSScreenStackView`, `RNSScreenStackViewManager`,
+ *     `RNSScreenStackComponentView`, `RNSScreenStackManager`
+ *
+ * This synthesizer walks every Fabric component node and looks for a
+ * native class matching one of those names; when found, emits a
+ * `calls` edge `component → native class` (provenance `'heuristic'`,
+ * `synthesizedBy:'fabric-native-impl'`) so trace from JSX usage of the
+ * component continues into native.
+ *
+ * The convention-based suffix lookup is precise: there's no name
+ * collision in RN view-manager codebases by design (Codegen output would
+ * conflict otherwise).
+ */
+const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager'];
+
+function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  // The Fabric extractor IDs are prefixed `fabric-component:` so we can
+  // filter to just those without iterating all `component` nodes.
+  const components = ctx.getNodesByKind('component').filter((n) => n.id.startsWith('fabric-component:'));
+  if (components.length === 0) return edges;
+
+  // Pre-index native classes by name for O(1) lookup.
+  const nativeClassesByName = new Map<string, Node[]>();
+  for (const n of ctx.getNodesByKind('class')) {
+    if (n.language !== 'objc' && n.language !== 'kotlin' && n.language !== 'java' && n.language !== 'cpp') continue;
+    const arr = nativeClassesByName.get(n.name);
+    if (arr) arr.push(n);
+    else nativeClassesByName.set(n.name, [n]);
+  }
+
+  for (const component of components) {
+    for (const suffix of FABRIC_NATIVE_SUFFIXES) {
+      const candidate = component.name + suffix;
+      const matches = nativeClassesByName.get(candidate);
+      if (!matches || matches.length === 0) continue;
+      // Link the component node to every matching native class (iOS +
+      // Android each have one).
+      for (const native of matches) {
+        const key = `${component.id}>${native.id}`;
+        if (seen.has(key)) continue;
+        seen.add(key);
+        edges.push({
+          source: component.id,
+          target: native.id,
+          kind: 'calls',
+          provenance: 'heuristic',
+          metadata: {
+            synthesizedBy: 'fabric-native-impl',
+            viaSuffix: suffix || '(exact)',
+            componentName: component.name,
+          },
+        });
+      }
+    }
+  }
+
+  return edges;
+}
+
 /**
  * Synthesize dispatcher→callback edges (field observers + EventEmitters +
- * React re-render + JSX children + Vue templates + RN event channel).
- * Returns the count added. Never throws into indexing — callers wrap in
- * try/catch.
+ * React re-render + JSX children + Vue templates + RN event channel +
+ * Fabric native-impl). Returns the count added. Never throws into
+ * indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
   const fieldEdges = fieldChannelEdges(queries, ctx);
@@ -685,6 +757,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const cppEdges = cppOverrideEdges(queries);
   const ifaceEdges = interfaceOverrideEdges(queries);
   const rnEventEdgesList = rnEventEdges(ctx);
+  const fabricNativeEdges = fabricNativeImplEdges(ctx);
 
   const merged: Edge[] = [];
   const seen = new Set<string>();
@@ -698,6 +771,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...cppEdges,
     ...ifaceEdges,
     ...rnEventEdgesList,
+    ...fabricNativeEdges,
   ]) {
     const key = `${e.source}>${e.target}`;
     if (seen.has(key)) continue;

+ 200 - 0
src/resolution/frameworks/fabric.ts

@@ -0,0 +1,200 @@
+/**
+ * React Native Fabric / Codegen view components — Phase 6 of the
+ * mixed-iOS/RN bridging effort.
+ *
+ * In the new RN architecture, JS-visible view components are declared via
+ * Codegen TS spec files of the shape:
+ *
+ *   // src/fabric/MyComponentNativeComponent.ts
+ *   import { codegenNativeComponent } from 'react-native';
+ *   import type { ViewProps, CodegenTypes as CT } from 'react-native';
+ *
+ *   export interface NativeProps extends ViewProps {
+ *     color?: ColorValue;
+ *     onTap?: CT.DirectEventHandler<TapEvent>;
+ *   }
+ *
+ *   export default codegenNativeComponent<NativeProps>('MyComponent');
+ *
+ * Codegen then generates a native ComponentDescriptor that wires the JS
+ * component name to a native implementation class — by RN convention,
+ * one of `MyComponent`, `MyComponentView`, `MyComponentComponentView`,
+ * `MyComponentManager`, `MyComponentViewManager`. The actual implementation
+ * lives in ObjC++ (.mm) on iOS or Kotlin/Java on Android.
+ *
+ * Without bridging, JSX `<MyComponent color="red"/>` in a consumer app has
+ * nothing in the graph to land on — the JS-visible name `MyComponent` isn't
+ * a node anywhere (only `MyComponentView` is, in the .mm), and the JSX
+ * synthesizer matches strictly by name.
+ *
+ * What this extractor does:
+ *   1. Parse the spec file's `codegenNativeComponent<Props>('Name', ...)`
+ *      literal — emit a `component` node named `Name`, attributed to the
+ *      spec file.
+ *   2. Parse the `NativeProps` interface and emit one `property` node per
+ *      prop, attributed to the spec file. Props like `onTap` /
+ *      `onFinishTransitioning` are JS-callable event-handler bindings;
+ *      surfacing them as nodes lets the agent discover the JS surface of
+ *      the component.
+ *
+ * A companion synthesizer (`fabricNativeImplEdges` in
+ * callback-synthesizer.ts) links the emitted component node to its
+ * native implementation class via the convention-based name+suffix
+ * lookup — that produces the cross-language hop the JSX synthesizer's
+ * `<MyComponent>` edges naturally chain through.
+ */
+import type { Node } from '../../types';
+import {
+  FrameworkExtractionResult,
+  FrameworkResolver,
+} from '../types';
+
+const CODEGEN_DECL_RE =
+  /codegenNativeComponent\s*(?:<[^>]+>)?\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g;
+
+/**
+ * Cheap source-level detector — must contain `codegenNativeComponent` to
+ * be worth parsing. The presence of that import is the canonical Fabric
+ * spec signal.
+ */
+function isFabricSpec(source: string): boolean {
+  return source.includes('codegenNativeComponent');
+}
+
+/**
+ * Pull the `NativeProps` interface body out of a Fabric spec source.
+ * Returns `null` when the interface isn't declared in the expected shape.
+ */
+function findNativePropsBody(source: string): string | null {
+  // Permissive: `export interface NativeProps [extends X, Y] { … }`.
+  const m = source.match(/export\s+interface\s+NativeProps\b[^{]*\{([\s\S]*?)\n\}/);
+  return m?.[1] ?? null;
+}
+
+/**
+ * Parse the NativeProps interface body and return prop names.
+ * Each prop is `name?: Type;` or `name: Type;` on its own line.
+ * We don't care about types — just the JS-visible name.
+ */
+function extractPropNames(body: string): string[] {
+  const props: string[] = [];
+  // Anchor to start-of-line (after optional whitespace), then capture an
+  // identifier, then optional `?`, then `:`. Skip lines that look like
+  // method declarations (`name(`) — those are TurboModule spec methods,
+  // not view props.
+  const regex = /^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:/gm;
+  let m: RegExpExecArray | null;
+  while ((m = regex.exec(body)) !== null) {
+    const name = m[1]!;
+    // Exclude any line that immediately turns into a function-shape (e.g.
+    // `onTap?: () => void` is fine — it's a prop, not a method body —
+    // but a literal `name(arg: T): R` is a method declaration).
+    const after = body.slice(m.index + m[0].length, m.index + m[0].length + 80);
+    if (/^\s*\(/.test(after)) continue; // method-shape, skip
+    props.push(name);
+  }
+  return props;
+}
+
+function extractFabricNodes(filePath: string, source: string): Node[] {
+  if (!isFabricSpec(source)) return [];
+
+  const now = Date.now();
+  const nodes: Node[] = [];
+
+  CODEGEN_DECL_RE.lastIndex = 0;
+  let m: RegExpExecArray | null;
+  while ((m = CODEGEN_DECL_RE.exec(source)) !== null) {
+    const componentName = m[1]!;
+    const before = source.slice(0, m.index);
+    const startLine = before.split('\n').length;
+    const startColumn = before.length - before.lastIndexOf('\n') - 1;
+
+    // The component itself — kind: 'component' so the existing
+    // reactJsxChildEdges synthesizer matches `<MyComponent>` JSX tags to
+    // it (its name+kind filter is the gate).
+    const componentId = `fabric-component:${filePath}:${componentName}:${startLine}`;
+    nodes.push({
+      id: componentId,
+      kind: 'component',
+      name: componentName,
+      qualifiedName: `${filePath}::${componentName}`,
+      filePath,
+      // The spec file is .ts or .tsx; use the file's apparent language
+      // by extension. Trim to a known Language value.
+      language: filePath.endsWith('.tsx') ? 'tsx' : 'typescript',
+      startLine,
+      endLine: startLine,
+      startColumn,
+      endColumn: startColumn + 'codegenNativeComponent'.length,
+      docstring: `Fabric/Codegen native component '${componentName}'`,
+      signature: `codegenNativeComponent<NativeProps>('${componentName}')`,
+      isExported: true,
+      updatedAt: now,
+    });
+  }
+
+  // Props from the NativeProps interface. These are not "method" semantic
+  // — they're JS-visible bindings the consumer sets via JSX attributes —
+  // so use `property` kind. (The JSX synthesizer doesn't currently
+  // produce per-attribute edges, but surfacing the prop names as nodes
+  // lets `codegraph_search('onFinishTransitioning')` discover them.)
+  const body = findNativePropsBody(source);
+  if (body) {
+    const props = extractPropNames(body);
+    for (const propName of props) {
+      const propBefore = source.indexOf(propName, source.indexOf(body));
+      const propLine =
+        propBefore >= 0 ? source.slice(0, propBefore).split('\n').length : 1;
+      nodes.push({
+        id: `fabric-prop:${filePath}:${propName}:${propLine}`,
+        kind: 'property',
+        name: propName,
+        qualifiedName: `${filePath}::NativeProps.${propName}`,
+        filePath,
+        language: filePath.endsWith('.tsx') ? 'tsx' : 'typescript',
+        startLine: propLine,
+        endLine: propLine,
+        startColumn: 0,
+        endColumn: propName.length,
+        docstring: `Fabric NativeProps prop '${propName}'`,
+        isExported: true,
+        updatedAt: now,
+      });
+    }
+  }
+
+  return nodes;
+}
+
+export const fabricViewResolver: FrameworkResolver = {
+  name: 'fabric-view',
+  languages: ['typescript', 'tsx'],
+
+  detect(context) {
+    // Detect on package.json alone: an RN project has the dep. We
+    // initially scanned for a `codegenNativeComponent` marker file too,
+    // but on big repos (RNScreens has ~1500 source files; fabric specs
+    // come alphabetically after FabricExample/ etc., past any reasonable
+    // scan budget) the marker check times out and produces false-
+    // negatives. Detect lightly, and let the per-file `extract()` decide
+    // which files actually have Fabric specs — extract() is essentially
+    // free on non-spec files (a short `includes('codegenNativeComponent')`).
+    const pkg = context.readFile('package.json');
+    return pkg ? /["']react-native["']\s*:/.test(pkg) : false;
+  },
+
+  extract(filePath, source): FrameworkExtractionResult {
+    return {
+      nodes: extractFabricNodes(filePath, source),
+      references: [],
+    };
+  },
+
+  resolve() {
+    // The companion synthesizer (`fabricNativeImplEdges`) handles
+    // cross-language edges; standard name resolution handles
+    // <MyComponent> → component-node via the JSX synthesizer.
+    return null;
+  },
+};

+ 4 - 0
src/resolution/frameworks/index.ts

@@ -24,6 +24,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 import { swiftObjcBridgeResolver } from './swift-objc';
 import { reactNativeBridgeResolver } from './react-native';
 import { expoModulesResolver } from './expo-modules';
+import { fabricViewResolver } from './fabric';
 
 /**
  * All registered framework resolvers
@@ -63,6 +64,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
   reactNativeBridgeResolver,
   // Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
   expoModulesResolver,
+  // React Native Fabric / Codegen view components — TS spec → component nodes
+  fabricViewResolver,
 ];
 
 /**
@@ -136,3 +139,4 @@ export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
 export { swiftObjcBridgeResolver } from './swift-objc';
 export { reactNativeBridgeResolver } from './react-native';
 export { expoModulesResolver } from './expo-modules';
+export { fabricViewResolver } from './fabric';