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
+1
View File
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes
- In a monorepo with an Expo app next to a Next.js app, like create-t3-turbo or tamagui's starter, each router now reads only its own app. Expo Router used to read the Next.js app's files too, so `layout.tsx`, `page.tsx`, a `_components` folder and an API route showed up as screens named `/layout`, `/page` or `/_components/posts`, and a shared package's files became screens as well. Re-index such monorepos after upgrading.
- Calls in Vue, Svelte and Astro components now follow the same rules as JavaScript and TypeScript. A `t('…')` from `useI18n()` or a `ref(…)` from `vue` used to link to an interface property or a local variable of the same name elsewhere in the project, so on halo two unrelated symbols had over a thousand made-up callers each. A call imported from a package is also no longer mistaken for a project symbol when the package is declared in a nested app's `package.json` or a Deno import map. An import through an alias CodeGraph can't follow, like SvelteKit's `$lib/…` or a nested Nuxt app's `~/…`, is still treated as project code. Re-index Vue, Svelte, Astro and JavaScript/TypeScript projects after upgrading.
- A bare Java call like `verify(mock)`, `assertThat(x)` or `hashCode()` now links only to a method of the class it's in, of that class's parents, or one the file imports statically. It used to link to any class's method of that name: in halo, Mockito's `verify` and `eq` gave one service's `verify` over a thousand made-up callers, and in retrofit Truth's `assertThat` did the same to a test helper. Calls to inherited methods and static imports from the project now link to the right one, like jsoup's `attr(…)` through `LeafNode` or halo's `and(…)` / `equal(…)` through `import static …Queries.*`. Re-index Java projects after upgrading.
- Python calls like `User.objects.get(…)`, `self.client.login(…)` or `request.POST.get(…)` no longer link to whichever class in the project has a method of that name. Such a call now links only to a member of what its receiver names, like `helpers.slugify()` to `slugify` in `helpers.py`, and a bare `get(1)` never links to a method. A name the file imports from a package outside the project, like `from django.shortcuts import render`, no longer links to a same-named project function. In Django projects these made-up links had given single test or serializer methods thousands of callers, for example netbox's `.all()` calls on one `UserConfig.all`. Re-index Python projects after upgrading.
+88
View File
@@ -0,0 +1,88 @@
/**
* In a monorepo, a file-based router reads only its own app's files.
*
* 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: `app/layout.tsx` became a `/layout` screen, `app/page.tsx` a
* `/page` one, a `_components/posts.tsx` a `/_components/posts` screen, and
* `app/api/auth/[...all]/route.ts` a screen named after the file.
*/
import { describe, it, expect, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
const roots: string[] = [];
afterAll(() => {
for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true });
});
async function project(files: Record<string, string>): Promise<CodeGraph> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-app-frameworks-'));
roots.push(root);
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
return CodeGraph.init(root, { index: true });
}
const routesIn = (cg: CodeGraph, dir: string): string[] =>
cg
.getNodesByKind('route')
.filter((n) => n.filePath.startsWith(dir))
.map((n) => n.name)
.sort();
describe('file-based routers in a monorepo', () => {
it('Expo Router reads the Expo app, Next.js the Next app', async () => {
const cg = await project({
'package.json': JSON.stringify({ name: 'mono', private: true, workspaces: ['apps/*'] }),
'apps/mobile/package.json': JSON.stringify({ name: 'mobile', dependencies: { expo: '*', 'expo-router': '*', react: '*' } }),
'apps/mobile/app/_layout.tsx': `export default function Layout() { return null; }
`,
'apps/mobile/app/index.tsx': `export default function Home() { return null; }
`,
'apps/mobile/app/user/[id].tsx': `export default function User() { return null; }
`,
'apps/web/package.json': JSON.stringify({ name: 'web', dependencies: { next: '*', react: '*' } }),
'apps/web/app/layout.tsx': `export default function RootLayout({ children }: { children: unknown }) { return children; }
`,
'apps/web/app/page.tsx': `export default function Page() { return null; }
`,
'apps/web/app/user/[id]/page.tsx': `export default function UserPage() { return null; }
`,
'apps/web/app/_components/posts.tsx': `export default function Posts() { return null; }
`,
'apps/web/app/api/auth/[...all]/route.ts': `export async function GET() { return new Response('ok'); }
`,
});
try {
expect(routesIn(cg, 'apps/mobile/')).toEqual(['/', '/user/[id]']);
const web = routesIn(cg, 'apps/web/');
expect(web).toContain('/');
expect(web).toContain('/user/:id');
for (const bogus of ['/layout', '/page', '/_components/posts', '/api/auth/[...all]/route', '/user/[id]/page']) {
expect(web).not.toContain(bogus);
}
} finally {
cg.close();
}
});
it('a single Expo app at the repository root keeps its routes', async () => {
const cg = await project({
'package.json': JSON.stringify({ name: 'demo', dependencies: { expo: '*', 'expo-router': '*' } }),
'app/_layout.tsx': `export default function Layout() { return null; }
`,
'app/settings.tsx': `export default function Settings() { return null; }
`,
});
try {
expect(routesIn(cg, 'app/')).toEqual(['/settings']);
} finally {
cg.close();
}
});
});
+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 */