Skip to content
Open
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
42 changes: 42 additions & 0 deletions packages/core/src/analyzer/tool-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { detectTools } from './tool-detector.js';

let root: string;

beforeEach(() => {
root = join(tmpdir(), `tool-detector-test-${Date.now()}`);
mkdirSync(root, { recursive: true });
});

afterEach(() => rmSync(root, { recursive: true, force: true }));

describe('Rails detection', () => {
it('detects via Gemfile containing rails', () => {
writeFileSync(join(root, 'Gemfile'), `gem "rails", "~> 7.1"`);
const tools = detectTools(root);
expect(tools.find(t => t.id === 'rails')).toBeDefined();
});

it('does not detect rails from a non-rails Gemfile', () => {
writeFileSync(join(root, 'Gemfile'), `gem "sinatra"`);
const tools = detectTools(root);
expect(tools.find(t => t.id === 'rails')).toBeUndefined();
});

it('detects via config/application.rb with Rails::Application', () => {
mkdirSync(join(root, 'config'), { recursive: true });
writeFileSync(join(root, 'config/application.rb'), `class Application < Rails::Application\nend`);
const tools = detectTools(root);
expect(tools.find(t => t.id === 'rails')).toBeDefined();
});

it('detects via bin/rails existence alone', () => {
mkdirSync(join(root, 'bin'), { recursive: true });
writeFileSync(join(root, 'bin/rails'), '#!/usr/bin/env ruby');
const tools = detectTools(root);
expect(tools.find(t => t.id === 'rails')).toBeDefined();
});
});
24 changes: 21 additions & 3 deletions packages/core/src/analyzer/tool-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface ToolSignature {
versionFrom?: 'package.json' | 'config';
/** Package name in package.json (for version extraction) */
packageName?: string;
/** If a signal file matches an entry here, its contents must match the pattern */
fileContains?: Array<{ file: string; pattern: RegExp }>;
/** Context hint template */
contextHint: string;
}
Expand Down Expand Up @@ -63,6 +65,16 @@ const TOOL_SIGNATURES: ToolSignature[] = [
contextHint: 'Supabase: PostgreSQL + Auth + Storage + Realtime, config in supabase/',
},
// Frameworks
{
id: 'rails', name: 'Ruby on Rails', category: 'framework',
signals: ['Gemfile', 'config/application.rb', 'bin/rails'],
fileContains: [
{ file: 'Gemfile', pattern: /gem\s+['"]rails['"]/i },
{ file: 'config/application.rb', pattern: /Rails::Application/ },
],
filePatterns: ['app/controllers/**', 'app/models/**', 'app/views/**', 'config/**', 'db/**', 'Gemfile'],
contextHint: 'Ruby on Rails: MVC framework, app/ has models/views/controllers, db/ has migrations, config/routes.rb defines routes',
},
{
id: 'nextjs', name: 'Next.js', category: 'framework',
signals: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
Expand Down Expand Up @@ -209,10 +221,16 @@ export function detectTools(repoRoot: string, scanResult?: ScanEntry): DetectedT
// Check file signals
for (const signal of sig.signals) {
const fullPath = join(repoRoot, signal);
if (existsSync(fullPath)) {
configPath = signal;
break;
if (!existsSync(fullPath)) continue;
const contentCheck = sig.fileContains?.find(c => c.file === signal);
if (contentCheck) {
try {
const contents = readFileSync(fullPath, 'utf-8');
if (!contentCheck.pattern.test(contents)) continue;
} catch { continue; }
}
configPath = signal;
break;
}

// Check package.json deps (for tools without config files, like Express)
Expand Down