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 */