Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions src/frameworks/python/python-wizard-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { FrameworkConfig } from '@lib/framework-config';
import { PYTHON_PACKAGE_INSTALLATION } from '@lib/framework-config';
import { detectPythonPackageManagers } from '@lib/detection/package-manager';
import { Integration } from '@lib/constants';
import fg from 'fast-glob';
import { DETECTION_IGNORE_PATTERNS, globWithAbort } from '@lib/detection/glob';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
Expand Down Expand Up @@ -40,10 +40,10 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig<PythonContext> = {
getInstalledVersion: (options: WizardRunOptions) =>
Promise.resolve(getPythonVersion(options)),
detect: async (options) => {
const { installDir } = options;
const { installDir, signal } = options;

// Look for Python package management files
const pythonConfigFiles = await fg(
const pythonConfigFiles = await globWithAbort(
[
'**/requirements*.txt',
'**/pyproject.toml',
Expand All @@ -52,7 +52,8 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig<PythonContext> = {
],
{
cwd: installDir,
ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'],
ignore: DETECTION_IGNORE_PATTERNS,
signal,
},
);

Expand All @@ -62,9 +63,10 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig<PythonContext> = {

// Make sure this isn't Django or Flask (those should be detected first)
// Check for Django
const managePyMatches = await fg('**/manage.py', {
const managePyMatches = await globWithAbort('**/manage.py', {
cwd: installDir,
ignore: ['**/venv/**', '**/.venv/**', '**/env/**', '**/.env/**'],
ignore: DETECTION_IGNORE_PATTERNS,
signal,
});

for (const match of managePyMatches) {
Expand Down Expand Up @@ -102,17 +104,12 @@ export const PYTHON_AGENT_CONFIG: FrameworkConfig<PythonContext> = {
}
}

const pyFiles = await fg(
const pyFiles = await globWithAbort(
['**/app.py', '**/wsgi.py', '**/application.py', '**/__init__.py'],
{
cwd: installDir,
ignore: [
'**/venv/**',
'**/.venv/**',
'**/env/**',
'**/.env/**',
'**/__pycache__/**',
],
ignore: DETECTION_IGNORE_PATTERNS,
signal,
},
);

Expand Down
17 changes: 7 additions & 10 deletions src/frameworks/swift/swift-wizard-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import type { WizardRunOptions } from '@utils/types';
import type { FrameworkConfig } from '@lib/framework-config';
import { swiftPackageManager } from '@lib/detection/package-manager';
import { Integration } from '@lib/constants';
import { globWithAbort } from '@lib/detection/glob';
import fg from 'fast-glob';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
detectSwiftProjectType,
getSwiftProjectTypeName,
SWIFT_SOURCE_IGNORE_PATTERNS,
SwiftProjectType,
} from './utils';

Expand All @@ -35,10 +37,10 @@ export const SWIFT_AGENT_CONFIG: FrameworkConfig<SwiftContext> = {
usesPackageJson: false,
getVersion: () => undefined,
detect: async (options) => {
const { installDir } = options;
const { installDir, signal } = options;

// Xcode project/workspace, or an XcodeGen `project.yml` (the generated
// .xcodeproj is often uncommitted).
// .xcodeproj is often uncommitted). Non-recursive, so no deep walk.
const xcodeProjects = await fg('*.{xcodeproj,xcworkspace}', {
cwd: installDir,
onlyDirectories: true,
Expand All @@ -49,15 +51,10 @@ export const SWIFT_AGENT_CONFIG: FrameworkConfig<SwiftContext> = {

if (xcodeProjects.length > 0 || hasXcodeGenSpec) {
// Verify it contains Swift source files
const swiftFiles = await fg('**/*.swift', {
const swiftFiles = await globWithAbort('**/*.swift', {
cwd: installDir,
ignore: [
'**/.build/**',
'**/DerivedData/**',
'**/build/**',
'**/*.xcodeproj/**',
'**/Pods/**',
],
ignore: SWIFT_SOURCE_IGNORE_PATTERNS,
signal,
});
if (swiftFiles.length > 0) {
return true;
Expand Down
22 changes: 15 additions & 7 deletions src/frameworks/swift/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,22 @@
import type { WizardRunOptions } from '@utils/types';
import { DETECTION_IGNORE_PATTERNS } from '@lib/detection/glob';
import fg from 'fast-glob';
import * as fs from 'node:fs';
import * as path from 'node:path';

/**
* Ignore list for recursive Swift source walks: the shared heavy-tree patterns
* (node_modules, .git, build, …) plus Swift/Xcode build artifact directories.
* Without node_modules/.git these globs OOM on large or hybrid repos.
*/
export const SWIFT_SOURCE_IGNORE_PATTERNS = [
...DETECTION_IGNORE_PATTERNS,
'**/.build/**',
'**/DerivedData/**',
'**/*.xcodeproj/**',
'**/Pods/**',
];

export enum SwiftProjectType {
SWIFTUI = 'swiftui',
UIKIT = 'uikit',
Expand Down Expand Up @@ -42,13 +56,7 @@ export async function detectSwiftProjectType(
// Check Swift source files for SwiftUI vs UIKit imports
const swiftFiles = await fg('**/*.swift', {
cwd: installDir,
ignore: [
'**/.build/**',
'**/DerivedData/**',
'**/build/**',
'**/*.xcodeproj/**',
'**/Pods/**',
],
ignore: SWIFT_SOURCE_IGNORE_PATTERNS,
});

let hasSwiftUI = false;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/cloudflare-detection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import fg from 'fast-glob';
import { logToFile } from '@utils/debug';
import { tryGetPackageJson } from '@utils/setup-utils';
import { DETECTION_IGNORE_PATTERNS } from '@lib/detection/glob';

const CLOUDFLARE_PACKAGES = [
'@react-router/cloudflare',
Expand All @@ -22,10 +23,13 @@ const CLOUDFLARE_PACKAGES = [
export async function detectCloudflareTarget(
installDir: string,
): Promise<boolean> {
// Check for wrangler config files
// Check for wrangler config files. This pattern is root-only (no `**`), but
// we still pass the shared ignore list so it stays consistent with the other
// detectors and never descends into node_modules/.git if it's ever widened.
const wranglerFiles = await fg('wrangler.@(toml|jsonc|json)', {
cwd: installDir,
dot: true,
ignore: DETECTION_IGNORE_PATTERNS,
});
if (wranglerFiles.length > 0) {
logToFile(
Expand Down
104 changes: 104 additions & 0 deletions src/lib/detection/__tests__/glob.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { DETECTION_IGNORE_PATTERNS, globWithAbort } from '@lib/detection/glob';
import { PYTHON_AGENT_CONFIG } from '../../../frameworks/python/python-wizard-agent';
import { SWIFT_AGENT_CONFIG } from '../../../frameworks/swift/swift-wizard-agent';

const tmpDirs: string[] = [];
function project(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-glob-'));
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
tmpDirs.push(dir);
return dir;
}

afterAll(() => {
for (const dir of tmpDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

describe('DETECTION_IGNORE_PATTERNS', () => {
test('ignores the heavy trees that OOM recursive detection globs', () => {
expect(DETECTION_IGNORE_PATTERNS).toContain('**/node_modules/**');
expect(DETECTION_IGNORE_PATTERNS).toContain('**/.git/**');
});
});

describe('globWithAbort', () => {
test('behaves like fast-glob when no signal is provided', async () => {
const dir = project({ 'a.txt': '', 'sub/b.txt': '' });
const matches = await globWithAbort('**/*.txt', { cwd: dir });
expect(matches.sort()).toEqual(['a.txt', 'sub/b.txt']);
});

test('honours the ignore list', async () => {
const dir = project({
'keep.txt': '',
'node_modules/dep/skip.txt': '',
});
const matches = await globWithAbort('**/*.txt', {
cwd: dir,
ignore: DETECTION_IGNORE_PATTERNS,
});
expect(matches).toEqual(['keep.txt']);
});

test('resolves immediately with no matches when the signal is already aborted', async () => {
const dir = project({ 'a.txt': '' });
const controller = new AbortController();
controller.abort();
await expect(
globWithAbort('**/*.txt', { cwd: dir, signal: controller.signal }),
).resolves.toEqual([]);
});

test('resolves (does not hang or throw) when aborted mid-walk', async () => {
const dir = project({ 'a.txt': '', 'sub/b.txt': '' });
const controller = new AbortController();
const promise = globWithAbort('**/*.txt', {
cwd: dir,
signal: controller.signal,
});
controller.abort();
await expect(promise).resolves.toBeInstanceOf(Array);
});
});

describe('detectors ignore node_modules (regression: framework-detection OOM)', () => {
test('Python is not detected from config files buried in node_modules', async () => {
// A JS project with a Python-based dependency vendored in node_modules must
// not be mistaken for a Python project — and, more importantly, detection
// must never walk into node_modules to find out.
const dir = project({
'package.json': JSON.stringify({ dependencies: {} }),
'node_modules/some-dep/requirements.txt': 'requests==2.0.0',
'node_modules/some-dep/setup.py': 'from setuptools import setup',
});
await expect(
PYTHON_AGENT_CONFIG.detection.detect({ installDir: dir }),
).resolves.toBe(false);
});

test('Python is still detected from real project config files', async () => {
const dir = project({ 'requirements.txt': 'requests==2.0.0' });
await expect(
PYTHON_AGENT_CONFIG.detection.detect({ installDir: dir }),
).resolves.toBe(true);
});

test('Swift is not detected from .swift files buried in node_modules', async () => {
const dir = project({
'App.xcodeproj/project.pbxproj': '// pbxproj',
'node_modules/some-dep/Vendored.swift': 'import Foundation',
});
await expect(
SWIFT_AGENT_CONFIG.detection.detect({ installDir: dir }),
).resolves.toBe(false);
});
});
25 changes: 21 additions & 4 deletions src/lib/detection/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,35 @@ export async function detectFramework(
): Promise<Integration | undefined> {
for (const integration of Object.values(Integration)) {
const config = FRAMEWORK_REGISTRY[integration];
// Abort the detector's filesystem walk on timeout. Without this the
// Promise.race below just stops awaiting a slow detector — the glob keeps
// walking and buffering in the background, which on huge repos climbs until
// the heap dies. Aborting stops the walk instead of leaking it.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DETECTION_TIMEOUT_MS);
try {
const detected = await Promise.race([
config.detection.detect({ installDir }),
new Promise<false>((resolve) =>
setTimeout(() => resolve(false), DETECTION_TIMEOUT_MS),
),
config.detection.detect({ installDir, signal: controller.signal }),
new Promise<false>((resolve) => {
if (controller.signal.aborted) {
resolve(false);
return;
}
controller.signal.addEventListener('abort', () => resolve(false), {
once: true,
});
}),
]);
if (detected) {
return integration;
}
} catch {
// Skip frameworks whose detection throws
} finally {
clearTimeout(timeout);
// Stop any still-running walk before moving to the next framework
// (covers both the timeout and the detected-early paths).
controller.abort();
}
}
}
Loading
Loading