From 8fd2c49b2c178edd71e394f3f208769d073441fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 22:40:48 +0000 Subject: [PATCH] fix(frontend): index Vue/Svelte sources and tighten Next route detection Add .vue/.svelte to scan and language detection, extract script blocks for generic analysis, detect Nuxt/SvelteKit from workspace deps, and ignore SvelteKit + routes and non-Next app/pages paths. Document five-repo smoke results in docs/benchmark.md with fixture tests. --- docs/benchmark.md | 33 ++++++ src/analyzers/generic/index.ts | 34 +++++- src/analyzers/nextjs/index.ts | 26 ++++- src/core/indexer.ts | 1 + src/utils/frontend-framework-metadata.ts | 98 +++++++++++++++++ src/utils/language-detection.ts | 4 + tests/fixtures/frontend-vue-mini/Button.vue | 12 ++ tests/frontend-spa-indexing.test.ts | 115 ++++++++++++++++++++ tests/nextjs-analyzer.test.ts | 14 +++ 9 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 src/utils/frontend-framework-metadata.ts create mode 100644 tests/fixtures/frontend-vue-mini/Button.vue create mode 100644 tests/frontend-spa-indexing.test.ts diff --git a/docs/benchmark.md b/docs/benchmark.md index 73d073b..99aeb63 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,5 +1,38 @@ # What our local code-context experiments show +## Front-end repo matrix (plan) + +### Now + +- Ship `.vue` / `.svelte` indexing, Nuxt/SvelteKit framework claims, and tighter Next.js route detection (SvelteKit `+` routes, Nuxt `app/pages/*.vue`). +- Re-run the five-repo smoke matrix after each fix; keep this section honest. + +### Next + +- Dedicated Vue/Nuxt and Svelte analyzers (patterns, route conventions) instead of generic-only SPA handling. +- Path-alias coverage for Vite/Nuxt `~/` and monorepo package exports beyond TypeScript `paths`. +- Markdown-heavy repos (docs sites) as a separate benchmark series. + +### Done + +- 2026-10-30: Cloned and exercised five OSS front-end repos (see table below); fixed SPA file gaps and Next.js false positives; added fixture tests in `tests/frontend-spa-indexing.test.ts`. + +### Five-repo smoke (2026-10-30, `reindex --no-embeddings`, Node 22, this branch) + +Clones: [shadcn-ui/taxonomy](https://github.com/shadcn-ui/taxonomy) (Next.js), [nuxt/ui](https://github.com/nuxt/ui), [angular/components](https://github.com/angular/components), [sveltejs/kit](https://github.com/sveltejs/kit), [vercel/turbo](https://github.com/vercel/turbo) (pnpm/turbo monorepo with UI apps). + +| Repo | Init / reindex / status | Framework metadata | SPA sources in index | Symbol refs | Edit preflight (realistic query) | +| --- | --- | --- | --- | --- | --- | +| taxonomy (Next.js) | pass / pass / ready | Next.js detected | 94 `.tsx` indexed | `cn` useful (241 hits) | not ready — low confidence; top hit was relevant layout | +| nuxt/ui | pass / pass / ready | Nuxt detected (was missing) | **816** `.vue` indexed (was **0**) | `UButton` finds `Button.vue` imports | not ready — low confidence; stale-source hint on some queries | +| angular/components | pass / pass / ready | Angular patterns | TS/Angular only | `MatDialog` static refs OK | not ready — low confidence; dialog hits sensible | +| sveltejs/kit | pass / pass / ready | SvelteKit detected (monorepo deps) | **1011** `.svelte` indexed (was **0**) | `load` very noisy (587 lexical) | not ready — no longer mislabels SvelteKit tests as Next.js pages | +| vercel/turbo | pass / pass / ready; chunk cap warning | monorepo packages listed | UI via Next apps in `examples/` | `Button` finds UI components | not ready — low confidence; 5k chunk cap drops most of repo | + +Known limits surfaced here (not fixed in this pass): default 5k chunk cap on very large repos, lexical `load`/`Button` ambiguity, edit preflight still conservative on broad natural-language queries, one Rust-generated TS parse warning in turbo. + +--- + This report describes local evidence from five ways of giving an AI coding agent context: Raw-native, Codebase Context, jCodeMunch, Repowise, and context-mode. The evidence is split into separate families because retrieval, repeated runs, full-agent observations, and a metered replay do not answer the same question. The families are not pooled into one score. The clearest current finding is narrow: in this fixed retrieval setup, Codebase Context recovered 25.7% of expected gold files at 11.5% file precision, while fresh indexing had material local cost. These measurements do not show better completed coding tasks. diff --git a/src/analyzers/generic/index.ts b/src/analyzers/generic/index.ts index 78ed320..6fb0dd2 100644 --- a/src/analyzers/generic/index.ts +++ b/src/analyzers/generic/index.ts @@ -32,6 +32,10 @@ import { categorizeDependency } from '../../utils/dependency-detection.js'; import type { WorkspacePackageJson } from '../../utils/workspace-detection.js'; +import { + detectFrontendFrameworkFromDeps, + extractSingleFileComponentScript +} from '../../utils/frontend-framework-metadata.js'; export class GenericAnalyzer implements FrameworkAnalyzer { readonly name = 'generic'; @@ -91,7 +95,9 @@ export class GenericAnalyzer implements FrameworkAnalyzer { '.css', '.scss', '.sass', - '.less' + '.less', + '.vue', + '.svelte' ]; readonly priority = 10; // Low priority - fallback analyzer @@ -126,8 +132,23 @@ export class GenericAnalyzer implements FrameworkAnalyzer { usesTreeSitterSymbols = true; } - if (language === 'typescript' || language === 'javascript') { - const parsed = await this.parseJSTSFile(filePath, content, language); + let scriptLanguage: 'typescript' | 'javascript' | null = null; + let scriptBody: string | null = null; + if (language === 'vue' || language === 'svelte') { + const extracted = extractSingleFileComponentScript(content, language); + if (extracted) { + scriptBody = extracted.body; + scriptLanguage = extracted.language; + } + } + + const jstsLanguage = + scriptLanguage ?? + (language === 'typescript' || language === 'javascript' ? language : null); + const jstsContent = scriptBody ?? content; + + if (jstsLanguage) { + const parsed = await this.parseJSTSFile(filePath, jstsContent, jstsLanguage); imports = parsed.imports; exports = parsed.exports; @@ -247,6 +268,7 @@ export class GenericAnalyzer implements FrameworkAnalyzer { let workspaceType: string = 'single'; let workspacePackages: WorkspacePackageJson[] = []; + let frameworkClaim: CodebaseMetadata['framework']; try { workspaceType = await detectWorkspaceType(rootPath); @@ -276,6 +298,11 @@ export class GenericAnalyzer implements FrameworkAnalyzer { version: version as string, category: categorizeDependency(name) })); + + const framework = detectFrontendFrameworkFromDeps(rawDeps); + if (framework) { + frameworkClaim = framework; + } } catch (_error) { // skip } @@ -285,6 +312,7 @@ export class GenericAnalyzer implements FrameworkAnalyzer { rootPath, languages: [], dependencies, + framework: frameworkClaim, architecture: { type: 'mixed', layers: { diff --git a/src/analyzers/nextjs/index.ts b/src/analyzers/nextjs/index.ts index ea99dab..cf9e8ca 100644 --- a/src/analyzers/nextjs/index.ts +++ b/src/analyzers/nextjs/index.ts @@ -71,7 +71,19 @@ export class NextJsAnalyzer implements FrameworkAnalyzer { return false; } - if (isInAppRouter(filePath) || isInPagesRouter(filePath) || isProxyFile(filePath)) { + if (isSvelteKitRouteFile(filePath)) { + return false; + } + + if (isProxyFile(filePath)) { + return true; + } + + if (matchesNextAppRouterConvention(filePath)) { + return true; + } + + if (isInPagesRouter(filePath)) { return true; } @@ -342,6 +354,18 @@ function analyzeRouting(filePath: string, content: string): NextRoutingInfo { }; } +function isSvelteKitRouteFile(filePath: string): boolean { + const baseName = path.basename(filePath, path.extname(filePath)); + return baseName.startsWith('+'); +} + +function matchesNextAppRouterConvention(filePath: string): boolean { + if (!isInAppRouter(filePath)) { + return false; + } + return getAppRouteFileKind(path.basename(filePath)) !== null; +} + function isInAppRouter(filePath: string): boolean { const normalizedPath = filePath.replace(/\\/g, '/'); return normalizedPath.includes('/app/') || normalizedPath.includes('/src/app/'); diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 6d9ed37..397c3cb 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -279,6 +279,7 @@ export class CodebaseIndexer { }, include: [ '**/*.{ts,tsx,js,jsx,mjs,cjs,mts,cts}', + '**/*.{vue,svelte}', '**/*.{html,htm,css,scss,sass,less}', '**/*.{py,pyi,rb,php}', '**/*.{java,kt,kts,scala,swift,cs}', diff --git a/src/utils/frontend-framework-metadata.ts b/src/utils/frontend-framework-metadata.ts new file mode 100644 index 0000000..69de53b --- /dev/null +++ b/src/utils/frontend-framework-metadata.ts @@ -0,0 +1,98 @@ +import type { FrameworkInfo } from '../types/index.js'; +import { normalizeAnalyzerVersion } from '../analyzers/shared/metadata.js'; + +function hasDep(deps: Record, name: string): boolean { + return Object.prototype.hasOwnProperty.call(deps, name); +} + +/** + * Detect Vue / Nuxt / Svelte / SvelteKit from merged package dependencies. + * Uses the same MIN_INDICATORS=3 gate as indexer.selectFramework. + */ +export function detectFrontendFrameworkFromDeps( + deps: Record +): FrameworkInfo | undefined { + const indicators: string[] = []; + + if (hasDep(deps, 'nuxt')) indicators.push('dep:nuxt'); + if (hasDep(deps, '@nuxt/kit')) indicators.push('dep:@nuxt/kit'); + if (hasDep(deps, '@nuxt/schema')) indicators.push('dep:@nuxt/schema'); + if (hasDep(deps, 'vue')) indicators.push('dep:vue'); + if (hasDep(deps, '@sveltejs/kit')) indicators.push('dep:@sveltejs/kit'); + if (hasDep(deps, '@sveltejs/vite-plugin-svelte')) { + indicators.push('dep:@sveltejs/vite-plugin-svelte'); + } + if (hasDep(deps, 'svelte')) indicators.push('dep:svelte'); + if (hasDep(deps, 'vite')) indicators.push('dep:vite'); + + if (indicators.length < 3) { + return undefined; + } + + if (indicators.includes('dep:nuxt') || indicators.includes('dep:@nuxt/kit')) { + return { + name: 'Nuxt', + version: normalizeAnalyzerVersion(deps.nuxt ?? deps['@nuxt/kit'] ?? 'unknown'), + type: 'vue', + variant: 'nuxt', + uiLibraries: hasDep(deps, 'tailwindcss') ? ['Tailwind'] : undefined, + indicators + }; + } + + if (indicators.includes('dep:@sveltejs/kit')) { + return { + name: 'SvelteKit', + version: normalizeAnalyzerVersion(deps['@sveltejs/kit']), + type: 'svelte', + indicators + }; + } + + if (indicators.includes('dep:vue')) { + return { + name: 'Vue', + version: normalizeAnalyzerVersion(deps.vue), + type: 'vue', + indicators + }; + } + + if (indicators.includes('dep:svelte')) { + return { + name: 'Svelte', + version: normalizeAnalyzerVersion(deps.svelte), + type: 'svelte', + indicators + }; + } + + return undefined; +} + +export function extractSingleFileComponentScript( + content: string, + kind: 'vue' | 'svelte' +): { body: string; language: 'typescript' | 'javascript' } | null { + const tag = 'script'; + const match = content.match(new RegExp(`<${tag}([^>]*)>([\\s\\S]*?)<\\/${tag}>`, 'i')); + if (!match?.[2]) { + return null; + } + + const attrs = match[1] ?? ''; + const body = match[2].trim(); + if (!body) { + return null; + } + + const isTypeScript = + /\blang\s*=\s*['"]ts['"]/i.test(attrs) || + /\blang\s*=\s*['"]typescript['"]/i.test(attrs) || + (kind === 'svelte' && attrs.includes('lang="ts"')); + + return { + body, + language: isTypeScript ? 'typescript' : 'javascript' + }; +} diff --git a/src/utils/language-detection.ts b/src/utils/language-detection.ts index 2cc8bac..5df0fae 100644 --- a/src/utils/language-detection.ts +++ b/src/utils/language-detection.ts @@ -16,6 +16,8 @@ const extensionToLanguage: Record = { '.tsx': 'typescriptreact', '.mts': 'typescript', '.cts': 'typescript', + '.vue': 'vue', + '.svelte': 'svelte', // Web '.html': 'html', @@ -115,6 +117,8 @@ const baseCodeExtensions = new Set([ '.tsx', '.mts', '.cts', + '.vue', + '.svelte', '.html', '.htm', '.css', diff --git a/tests/fixtures/frontend-vue-mini/Button.vue b/tests/fixtures/frontend-vue-mini/Button.vue new file mode 100644 index 0000000..b14d667 --- /dev/null +++ b/tests/fixtures/frontend-vue-mini/Button.vue @@ -0,0 +1,12 @@ + + + diff --git a/tests/frontend-spa-indexing.test.ts b/tests/frontend-spa-indexing.test.ts new file mode 100644 index 0000000..ff7c63c --- /dev/null +++ b/tests/frontend-spa-indexing.test.ts @@ -0,0 +1,115 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { isCodeFile, detectLanguage } from '../src/utils/language-detection.js'; +import { GenericAnalyzer } from '../src/analyzers/generic/index.js'; +import { NextJsAnalyzer } from '../src/analyzers/nextjs/index.js'; +import { + detectFrontendFrameworkFromDeps, + extractSingleFileComponentScript +} from '../src/utils/frontend-framework-metadata.js'; + +describe('frontend single-file component support', () => { + it('treats .vue and .svelte as code files', () => { + expect(isCodeFile('src/Button.vue')).toBe(true); + expect(isCodeFile('routes/+page.svelte')).toBe(true); + expect(detectLanguage('App.vue')).toBe('vue'); + expect(detectLanguage('Page.svelte')).toBe('svelte'); + }); + + it('extracts script blocks from vue and svelte files', () => { + const vue = extractSingleFileComponentScript( + '', + 'vue' + ); + expect(vue).toEqual({ body: 'export const x = 1', language: 'typescript' }); + + const svelte = extractSingleFileComponentScript( + '', + 'svelte' + ); + expect(svelte?.language).toBe('typescript'); + }); + + it('indexes vue components through the generic analyzer', async () => { + const fixture = path.join(process.cwd(), 'tests/fixtures/frontend-vue-mini/Button.vue'); + const analyzer = new GenericAnalyzer(); + expect(analyzer.canAnalyze(fixture)).toBe(true); + + const content = await import('node:fs/promises').then((fs) => fs.readFile(fixture, 'utf-8')); + const result = await analyzer.analyze(fixture, content); + expect(result.chunks.length).toBeGreaterThan(0); + const serialized = JSON.stringify(result); + expect(serialized.includes('ButtonProps')).toBe(true); + }); + + it('does not classify SvelteKit +page files or Nuxt app/pages vue as Next.js', () => { + const analyzer = new NextJsAnalyzer(); + expect( + analyzer.canAnalyze( + '/repo/packages/kit/test/+page.server.js', + 'export async function load() { return {}; }' + ) + ).toBe(false); + expect( + analyzer.canAnalyze( + '/repo/playgrounds/nuxt/app/pages/typography.vue', + '' + ) + ).toBe(false); + expect( + analyzer.canAnalyze( + path.join(process.cwd(), 'app', 'dashboard', 'page.tsx'), + 'export default function Page() { return null; }' + ) + ).toBe(true); + }); + + it('detects Nuxt and SvelteKit framework metadata from dependencies', () => { + const nuxt = detectFrontendFrameworkFromDeps({ + nuxt: '^4.0.0', + '@nuxt/kit': 'catalog:', + vue: '^3.5.0' + }); + expect(nuxt?.type).toBe('vue'); + expect(nuxt?.name).toBe('Nuxt'); + + const kit = detectFrontendFrameworkFromDeps({ + '@sveltejs/kit': '^2.0.0', + svelte: '^5.0.0', + vite: '^6.0.0' + }); + expect(kit?.type).toBe('svelte'); + expect(kit?.name).toBe('SvelteKit'); + }); +}); + +describe('frontend fixture indexing smoke', () => { + it('writes vue files into a temp project manifest via glob include patterns', async () => { + const root = path.join(process.cwd(), 'tests/fixtures/frontend-vue-mini'); + const analyzer = new GenericAnalyzer(); + const filePath = path.join(root, 'Button.vue'); + const content = await import('node:fs/promises').then((fs) => fs.readFile(filePath, 'utf-8')); + const result = await analyzer.analyze(filePath, content); + expect(result.language).toBe('vue'); + expect(result.chunks[0]?.metadata.analyzer).toBe('generic'); + }); + + it('creates an isolated vue file for incremental tests', async () => { + const dir = path.join(process.cwd(), 'tests/fixtures/.tmp', randomUUID()); + const filePath = path.join(dir, 'Widget.vue'); + const source = + ''; + await mkdir(dir, { recursive: true }); + try { + await writeFile(filePath, source); + const analyzer = new GenericAnalyzer(); + expect(analyzer.canAnalyze(filePath)).toBe(true); + const result = await analyzer.analyze(filePath, source); + expect(result.chunks.length).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/nextjs-analyzer.test.ts b/tests/nextjs-analyzer.test.ts index 4f6d9e6..1fbb508 100644 --- a/tests/nextjs-analyzer.test.ts +++ b/tests/nextjs-analyzer.test.ts @@ -15,6 +15,20 @@ describe('NextJsAnalyzer', () => { expect(analyzer.canAnalyze('/tmp/file.tsx', 'import React from "react";')).toBe(false); }); + it('ignores SvelteKit route files and Nuxt app/pages vue files', () => { + const analyzer = new NextJsAnalyzer(); + + expect( + analyzer.canAnalyze( + '/repo/routes/+page.server.js', + 'export async function load() { return {}; }' + ) + ).toBe(false); + expect( + analyzer.canAnalyze('/repo/app/pages/home.vue', '') + ).toBe(false); + }); + it('detects app router pages, client components, and metadata exports', async () => { const analyzer = new NextJsAnalyzer(); const filePath = path.join(process.cwd(), 'app', 'settings', 'page.tsx');