mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
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:
+69
-4
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user