fix(extraction): a file-based router reads only its own app in a monorepo (#2129)

create-t3-turbo, tamagui's starter and react-native-reusables keep an Expo
app beside a Next.js app. Expo Router read the Next app's `app/` folder as
its own, so `layout.tsx` became a `/layout` screen, `page.tsx` a `/page`
one, `_components/posts.tsx` and `api/auth/[...all]/route.ts` screens too,
and a shared `packages/app/` library's files became screens as well.

A resolver can now name `appDependencies`; its extractor runs on a file
only when that file's package.json or an enclosing one declares one of
them. When no manifest in the project declares any, detection found the
framework by other evidence and it runs everywhere, as before. Expo Router
names `expo-router`, Next.js `next`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 06:32:33 +00:00
committed by GitHub
parent 1ef1eb886c
commit 92e14eda40
6 changed files with 170 additions and 4 deletions
+69 -4
View File
@@ -32,7 +32,8 @@ import { isCodeGraphDataDir } from '../directory';
import { logDebug, logWarn } from '../errors';
import { validatePathWithinRoot, normalizePath } from '../utils';
import ignore, { Ignore } from 'ignore';
import { detectFrameworks } from '../resolution/frameworks';
import { detectFrameworks, getFrameworkResolver } from '../resolution/frameworks';
import { declaredDependencies } from '../resolution/frameworks/package-deps';
import type { ResolutionContext } from '../resolution/types';
import { createYielder, type MaybeYield } from '../resolution/cooperative-yield';
import { MAX_SOURCE_FILE_SIZE_BYTES, oversizeStamp, readBoundedSource, readBoundedSourceSync } from '../file-limits';
@@ -1908,9 +1909,72 @@ export class ExtractionOrchestrator {
const fileList = files ?? scanDirectory(this.rootDir);
const context = this.buildDetectionContext(fileList);
this.detectedFrameworkNames = detectFrameworks(context).map((r) => r.name);
const declared = declaredDependencies(context);
this.gatedFrameworks = new Map();
for (const name of this.detectedFrameworkNames) {
const deps = getFrameworkResolver(name)?.appDependencies;
// Gated only when some manifest names the framework: otherwise detection
// found it by other evidence and every file is its app's.
if (deps && deps.some((d) => declared.has(d))) this.gatedFrameworks.set(name, deps);
}
this.appFrameworkMemo.clear();
this.manifestDependencies.clear();
return this.detectedFrameworkNames;
}
/** Detected frameworks whose extractors run only inside their own apps, with the packages that mark one. */
private gatedFrameworks = new Map<string, readonly string[]>();
/** `<dir>|<framework>` → does the package.json at or above `dir` declare the framework. */
private appFrameworkMemo = new Map<string, boolean>();
/** Directory → the dependency names its package.json declares, null without one. */
private manifestDependencies = new Map<string, Set<string> | null>();
/**
* The detected frameworks whose extractors apply to `filePath`: all of them,
* except one with `appDependencies` when neither the file's package.json nor
* an enclosing one declares any of them.
*/
private frameworksForFile(filePath: string, names: string[]): string[] {
if (this.gatedFrameworks.size === 0) return names;
const slash = filePath.lastIndexOf('/');
const dir = slash < 0 ? '' : filePath.slice(0, slash);
const kept = names.filter((name) => this.frameworkAppliesIn(dir, name));
return kept.length === names.length ? names : kept;
}
private frameworkAppliesIn(dir: string, name: string): boolean {
const deps = this.gatedFrameworks.get(name);
if (!deps) return true;
const key = `${dir}|${name}`;
const memo = this.appFrameworkMemo.get(key);
if (memo !== undefined) return memo;
let applies = false;
for (let d: string | null = dir; d !== null; d = d === '' ? null : d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') {
const declared = this.dependenciesDeclaredIn(d);
if (declared && deps.some((dep) => declared.has(dep))) {
applies = true;
break;
}
}
this.appFrameworkMemo.set(key, applies);
return applies;
}
private dependenciesDeclaredIn(dir: string): Set<string> | null {
if (this.manifestDependencies.has(dir)) return this.manifestDependencies.get(dir)!;
let declared: Set<string> | null = null;
try {
const pkg = JSON.parse(fs.readFileSync(path.join(this.rootDir, dir, 'package.json'), 'utf8')) as Record<string, unknown>;
declared = new Set();
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
const group = pkg[field];
if (group && typeof group === 'object') for (const name of Object.keys(group)) declared.add(name);
}
} catch { /* no or unreadable manifest */ }
this.manifestDependencies.set(dir, declared);
return declared;
}
/**
* Index all files in the project
*/
@@ -2099,8 +2163,9 @@ export class ExtractionOrchestrator {
*/
const parseFile = (filePath: string, content: string): Promise<ExtractionResult> => {
const language = detectLanguage(filePath, content, overrides);
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, frameworkNames));
return pool.requestParse({ filePath, content, language, frameworkNames });
const names = this.frameworksForFile(filePath, frameworkNames);
if (!pool) return Promise.resolve(extractFromSource(filePath, content, language, names));
return pool.requestParse({ filePath, content, language, frameworkNames: names });
};
// --- Bounded rolling-window dispatch, ordered commit ---
@@ -2722,7 +2787,7 @@ export class ExtractionOrchestrator {
// Extract from source. Use cached framework names if indexAll has run,
// otherwise detect on the spot so single-file re-index paths still emit
// route nodes / middleware / etc.
const frameworkNames = this.ensureDetectedFrameworks();
const frameworkNames = this.frameworksForFile(relativePath, this.ensureDetectedFrameworks());
const result = extractFromSource(relativePath, content, language, frameworkNames);
// Store in database
+1
View File
@@ -713,6 +713,7 @@ function scoreMatch(href: string[], route: string[]): number | null {
export const expoRouterResolver: FrameworkResolver = {
name: 'expo-router',
languages: [...ROUTE_LANGUAGES],
appDependencies: ['expo-router'],
detect(context: ResolutionContext): boolean {
if (dependsOn(context, 'expo-router')) return true;
+1
View File
@@ -222,6 +222,7 @@ export function nextNavVerb(name: string): string | null {
export const nextjsResolver: FrameworkResolver = {
name: 'nextjs',
languages: [...ROUTE_LANGUAGES],
appDependencies: ['next'],
detect(context: ResolutionContext): boolean {
if (dependsOn(context, 'next')) return true;
+10
View File
@@ -239,6 +239,16 @@ export interface FrameworkResolver {
name: string;
/** Languages this framework applies to. If omitted, applies to all languages. */
languages?: Language[];
/**
* Packages an app declares when it is built on this framework. When set,
* `extract()` runs only on files of an app whose package.json — the file's
* own or an enclosing one — declares one of them: in a monorepo with an Expo
* app beside a Next.js app, Expo Router must not read the Next app's
* `app/layout.tsx` as a `/layout` screen. When no manifest in the project
* declares any, detection found the framework by other evidence and the
* extractor runs on every file, as before.
*/
appDependencies?: readonly string[];
/** Detect if project uses this framework (project-level, called once at startup) */
detect(context: ResolutionContext): boolean;
/** Resolve a reference using framework-specific patterns */