From 92e14eda40e98dbdf43ea1c69f089afaa99e783b Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 06:32:33 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/monorepo-app-frameworks.test.ts | 88 +++++++++++++++++++++++ src/extraction/index.ts | 73 +++++++++++++++++-- src/resolution/frameworks/expo-router.ts | 1 + src/resolution/frameworks/nextjs.ts | 1 + src/resolution/types.ts | 10 +++ 6 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 __tests__/monorepo-app-frameworks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f85c40329..c549985a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/monorepo-app-frameworks.test.ts b/__tests__/monorepo-app-frameworks.test.ts new file mode 100644 index 000000000..f7efbf8ca --- /dev/null +++ b/__tests__/monorepo-app-frameworks.test.ts @@ -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): Promise { + 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(); + } + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index a4fec2ac3..706bc0bd1 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -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(); + /** `|` → does the package.json at or above `dir` declare the framework. */ + private appFrameworkMemo = new Map(); + /** Directory → the dependency names its package.json declares, null without one. */ + private manifestDependencies = new Map | 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 | null { + if (this.manifestDependencies.has(dir)) return this.manifestDependencies.get(dir)!; + let declared: Set | null = null; + try { + const pkg = JSON.parse(fs.readFileSync(path.join(this.rootDir, dir, 'package.json'), 'utf8')) as Record; + 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 => { 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 diff --git a/src/resolution/frameworks/expo-router.ts b/src/resolution/frameworks/expo-router.ts index 2f513e3fa..5d82b94e2 100644 --- a/src/resolution/frameworks/expo-router.ts +++ b/src/resolution/frameworks/expo-router.ts @@ -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; diff --git a/src/resolution/frameworks/nextjs.ts b/src/resolution/frameworks/nextjs.ts index 7eb543dee..22b85f746 100644 --- a/src/resolution/frameworks/nextjs.ts +++ b/src/resolution/frameworks/nextjs.ts @@ -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; diff --git a/src/resolution/types.ts b/src/resolution/types.ts index 7b76bb324..65fd98caf 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -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 */