mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(vue): Vue SFC support + destructured call result tracking (#604)
* feat(vue): add Vue SFC (.vue) support for indexing
Vue Single File Components are now fully supported in the indexing pipeline.
The implementation extracts <script> / <script setup> blocks from .vue files
and parses them using the existing TypeScript tree-sitter grammar — no new
npm dependencies required.
Key changes:
- SFC script extractor: regex-based extraction of <script setup lang="ts">
blocks with correct line offset mapping back to the .vue file
- Vue language provider: reuses TypeScript queries, type config, field
extractors, and named binding extraction
- Import resolution: .vue added to EXTENSIONS so `import Foo from './Foo'`
resolves to Foo.vue; Vue import resolver delegates to TS resolver for
tsconfig path alias support
- Export detection: <script setup> top-level bindings are implicitly exported
- Template component detection: PascalCase tags in <template> emit CALLS edges
- Line offsets applied to all emitted positions (startLine, endLine, route
lineNumbers, decorator positions) in both worker and sequential paths
Validated on a 3,553-file Vue project:
Before: 24,693 nodes | 73,614 edges | 0 symbols from .vue
After: 30,495 nodes | 112,324 edges | 5,213 symbols from .vue
18,682 imports from .vue | 5,826 vue-to-vue imports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(typescript): track destructured call results in TypeEnv
Extend `extractPendingAssignment` to handle object destructuring from
function calls and await expressions:
const { isMaker } = useUserRole()
const { data } = await fetchData()
const { name } = repo.getProfile()
Previously, only `const { x } = someVariable` (identifier RHS) produced
TypeEnv bindings. Call-expression RHS was silently skipped, leaving
destructured properties untracked.
The fix emits a synthetic `callResult` item plus N `fieldAccess` items
per destructured property, which the existing fixpoint resolver processes
in 2 iterations. No changes needed to type-env.ts, PendingAssignment
types, or call-processor — the existing infrastructure handles it.
Also extracts a `collectDestructuredFields` helper to share the
object_pattern property iteration logic between the identifier and
call-expression branches.
Note: Full property-type resolution requires the callee to have a
declared returnType in the SymbolTable. Arrow-function composables
without type annotations (common in Vue/React) won't resolve property
types until return-type inference is added in a future change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(vue): address PR review issues for Vue SFC support
- Extract duplicated isVueSetupTopLevel to vue-sfc-extractor.ts shared
utility, removing identical copies from parse-worker.ts and
parsing-processor.ts
- Fix VUE_BUILT_INS to be a superset of TS BUILT_INS by importing and
spreading the TypeScript set, preventing spurious unresolved calls for
standard built-ins (Symbol, BigInt, WeakMap, array methods, etc.)
- Add Vue template component CALLS edge resolution in both sequential
and worker paths (call-processor.ts), matching PascalCase template
tags against imported .vue file basenames via the import map
- Add integration test for template PascalCase CALLS edges
(App.vue → Button.vue)
- Add integration test for isExported: false on non-setup <script>
blocks (OldStyle.vue options API)
- Add comment explaining TEMPLATE_RE greedy regex behavior for nested
template tags
- Fix stale language count comment (14 → 15) and remove dead code
branch in test
Made-with: Cursor
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e3d73a7aed
commit
dd0f5eed7d
23 changed files with 941 additions and 59 deletions
|
|
@ -41,6 +41,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
|
|||
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
|
||||
[SupportedLanguages.Swift]: ['.swift'],
|
||||
[SupportedLanguages.Dart]: ['.dart'],
|
||||
[SupportedLanguages.Vue]: ['.vue'],
|
||||
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
|
||||
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
|
||||
|
||||
|
|
@ -98,6 +99,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
|
|||
[SupportedLanguages.Kotlin]: 'kotlin',
|
||||
[SupportedLanguages.Swift]: 'swift',
|
||||
[SupportedLanguages.Dart]: 'dart',
|
||||
[SupportedLanguages.Vue]: 'typescript',
|
||||
[SupportedLanguages.Cobol]: 'cobol',
|
||||
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export enum SupportedLanguages {
|
|||
Kotlin = 'kotlin',
|
||||
Swift = 'swift',
|
||||
Dart = 'dart',
|
||||
Vue = 'vue',
|
||||
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
|
||||
Cobol = 'cobol',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
|
|||
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
||||
import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import {
|
||||
|
|
@ -37,6 +37,7 @@ import type {
|
|||
FileConstructorBindings,
|
||||
} from './workers/parse-worker.js';
|
||||
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
|
||||
import { extractTemplateComponents } from './vue-sfc-extractor.js';
|
||||
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
|
||||
import type { LiteralTypeInferrer } from './type-extractors/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
|
|
@ -962,6 +963,41 @@ export const processCalls = async (
|
|||
}
|
||||
});
|
||||
|
||||
// Vue: emit CALLS edges for PascalCase components used in <template>.
|
||||
// Template components are default-imported (not named), so we match the
|
||||
// component name against imported .vue file basenames via the import map.
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
if (templateComponents.length > 0) {
|
||||
const fileId = generateId('File', file.path);
|
||||
const importedFiles = ctx.importMap.get(file.path);
|
||||
if (importedFiles) {
|
||||
for (const componentName of templateComponents) {
|
||||
for (const importedPath of importedFiles) {
|
||||
if (!importedPath.endsWith('.vue')) continue;
|
||||
const basename = importedPath.slice(
|
||||
importedPath.lastIndexOf('/') + 1,
|
||||
importedPath.lastIndexOf('.'),
|
||||
);
|
||||
if (basename !== componentName) continue;
|
||||
const targetFileId = generateId('File', importedPath);
|
||||
if (graph.getNode(targetFileId)) {
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${fileId}:${componentName}->${targetFileId}`),
|
||||
sourceId: fileId,
|
||||
targetId: targetFileId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'vue-template-component',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearCache();
|
||||
}
|
||||
|
||||
|
|
@ -1659,7 +1695,38 @@ export const processCallsFromExtracted = async (
|
|||
widenCache,
|
||||
effectiveCall.argTypes,
|
||||
);
|
||||
if (!resolved) continue;
|
||||
if (!resolved) {
|
||||
// Vue template component fallback: match calledName against imported .vue basenames
|
||||
if (effectiveCall.filePath.endsWith('.vue') && effectiveCall.sourceId.startsWith('File:')) {
|
||||
const importedFiles = ctx.importMap.get(effectiveCall.filePath);
|
||||
if (importedFiles) {
|
||||
for (const importedPath of importedFiles) {
|
||||
if (!importedPath.endsWith('.vue')) continue;
|
||||
const basename = importedPath.slice(
|
||||
importedPath.lastIndexOf('/') + 1,
|
||||
importedPath.lastIndexOf('.'),
|
||||
);
|
||||
if (basename !== effectiveCall.calledName) continue;
|
||||
const targetFileId = generateId('File', importedPath);
|
||||
if (graph.getNode(targetFileId)) {
|
||||
graph.addRelationship({
|
||||
id: generateId(
|
||||
'CALLS',
|
||||
`${effectiveCall.sourceId}:${effectiveCall.calledName}->${targetFileId}`,
|
||||
),
|
||||
sourceId: effectiveCall.sourceId,
|
||||
targetId: targetFileId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'vue-template-component',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const relId = generateId(
|
||||
'CALLS',
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ export const ENTRY_POINT_PATTERNS = {
|
|||
/^onEvent$/, // BLoC event handler
|
||||
/^mapEventToState$/, // Legacy BLoC pattern
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
|
||||
} satisfies Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -891,6 +891,7 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
|||
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
|
||||
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const EXTENSIONS = [
|
|||
'.ts',
|
||||
'.jsx',
|
||||
'.js',
|
||||
'.vue',
|
||||
'/index.tsx',
|
||||
'/index.ts',
|
||||
'/index.jsx',
|
||||
|
|
|
|||
13
gitnexus/src/core/ingestion/import-resolvers/vue.ts
Normal file
13
gitnexus/src/core/ingestion/import-resolvers/vue.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Vue import resolver — delegates to TypeScript's standard resolver.
|
||||
*
|
||||
* Vue <script> blocks use the same import syntax as TypeScript (including
|
||||
* tsconfig path aliases like `@/`), so no custom resolution logic is needed.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { resolveStandard } from './standard.js';
|
||||
import type { ImportResolverFn } from './types.js';
|
||||
|
||||
export const resolveVueImport: ImportResolverFn = (raw, fp, ctx) =>
|
||||
resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript);
|
||||
|
|
@ -23,6 +23,7 @@ import { phpProvider } from './php.js';
|
|||
import { rubyProvider } from './ruby.js';
|
||||
import { swiftProvider } from './swift.js';
|
||||
import { dartProvider } from './dart.js';
|
||||
import { vueProvider } from './vue.js';
|
||||
import { cobolProvider } from './cobol.js';
|
||||
|
||||
export const providers = {
|
||||
|
|
@ -40,6 +41,7 @@ export const providers = {
|
|||
[SupportedLanguages.Ruby]: rubyProvider,
|
||||
[SupportedLanguages.Swift]: swiftProvider,
|
||||
[SupportedLanguages.Dart]: dartProvider,
|
||||
[SupportedLanguages.Vue]: vueProvider,
|
||||
[SupportedLanguages.Cobol]: cobolProvider,
|
||||
} satisfies Record<SupportedLanguages, LanguageProvider>;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
javascriptMethodConfig,
|
||||
} from '../method-extractors/configs/typescript-javascript.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
export const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'console',
|
||||
'log',
|
||||
'warn',
|
||||
|
|
|
|||
68
gitnexus/src/core/ingestion/languages/vue.ts
Normal file
68
gitnexus/src/core/ingestion/languages/vue.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Vue language provider.
|
||||
*
|
||||
* Vue SFCs are preprocessed by extracting the <script> / <script setup>
|
||||
* block content, which is then parsed as TypeScript. This provider reuses
|
||||
* nearly all TypeScript infrastructure — queries, type config, field
|
||||
* extraction, and named binding extraction.
|
||||
*
|
||||
* Export detection for <script setup> is handled directly in the parse
|
||||
* worker (all top-level bindings are implicitly exported). The export
|
||||
* checker here is used as fallback for non-setup <script> blocks.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
|
||||
import { tsExportChecker } from '../export-detection.js';
|
||||
import { resolveVueImport } from '../import-resolvers/vue.js';
|
||||
import { extractTsNamedBindings } from '../named-bindings/typescript.js';
|
||||
import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
|
||||
import { BUILT_INS as TS_BUILT_INS } from './typescript.js';
|
||||
|
||||
const VUE_SPECIFIC_BUILT_INS = [
|
||||
'ref',
|
||||
'reactive',
|
||||
'computed',
|
||||
'watch',
|
||||
'watchEffect',
|
||||
'onMounted',
|
||||
'onUnmounted',
|
||||
'onBeforeMount',
|
||||
'onBeforeUnmount',
|
||||
'onUpdated',
|
||||
'onBeforeUpdate',
|
||||
'nextTick',
|
||||
'defineProps',
|
||||
'defineEmits',
|
||||
'defineExpose',
|
||||
'defineOptions',
|
||||
'defineSlots',
|
||||
'defineModel',
|
||||
'withDefaults',
|
||||
'toRef',
|
||||
'toRefs',
|
||||
'unref',
|
||||
'isRef',
|
||||
'shallowRef',
|
||||
'triggerRef',
|
||||
'provide',
|
||||
'inject',
|
||||
'useSlots',
|
||||
'useAttrs',
|
||||
] as const;
|
||||
|
||||
const VUE_BUILT_INS: ReadonlySet<string> = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]);
|
||||
|
||||
export const vueProvider = defineLanguage({
|
||||
id: SupportedLanguages.Vue,
|
||||
extensions: ['.vue'],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
importResolver: resolveVueImport,
|
||||
namedBindingExtractor: extractTsNamedBindings,
|
||||
fieldExtractor: typescriptFieldExtractor,
|
||||
builtInNames: VUE_BUILT_INS,
|
||||
});
|
||||
|
|
@ -6,7 +6,8 @@ import { getProvider } from './languages/index.js';
|
|||
import { generateId } from '../../lib/utils.js';
|
||||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import {
|
||||
getDefinitionNodeFromCaptures,
|
||||
|
|
@ -280,6 +281,18 @@ const processParsingSequential = async (
|
|||
// Skip files larger than the max tree-sitter buffer (32 MB)
|
||||
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
|
||||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let lineOffset = 0;
|
||||
let isVueSetup = false;
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const extracted = extractVueScript(file.content);
|
||||
if (!extracted) continue; // skip .vue files with no script block
|
||||
parseContent = extracted.scriptContent;
|
||||
lineOffset = extracted.lineOffset;
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadLanguage(language, file.path);
|
||||
} catch {
|
||||
|
|
@ -288,8 +301,8 @@ const processParsingSequential = async (
|
|||
|
||||
let tree;
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent.length),
|
||||
});
|
||||
} catch (parseError) {
|
||||
console.warn(`Skipping unparseable file: ${file.path}`);
|
||||
|
|
@ -337,10 +350,10 @@ const processParsingSequential = async (
|
|||
|
||||
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNodeForRange
|
||||
? definitionNodeForRange.startPosition.row
|
||||
? definitionNodeForRange.startPosition.row + lineOffset
|
||||
: nameNode
|
||||
? nameNode.startPosition.row
|
||||
: 0;
|
||||
? nameNode.startPosition.row + lineOffset
|
||||
: lineOffset;
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
|
|
@ -376,14 +389,21 @@ const processParsingSequential = async (
|
|||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
|
||||
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
|
||||
startLine: definitionNodeForRange
|
||||
? definitionNodeForRange.startPosition.row + lineOffset
|
||||
: startLine,
|
||||
endLine: definitionNodeForRange
|
||||
? definitionNodeForRange.endPosition.row + lineOffset
|
||||
: startLine,
|
||||
language: language,
|
||||
isExported: cachedExportCheck(
|
||||
provider.exportChecker,
|
||||
nameNode || definitionNodeForRange,
|
||||
nodeName,
|
||||
),
|
||||
isExported:
|
||||
language === SupportedLanguages.Vue && isVueSetup
|
||||
? isVueSetupTopLevel(nameNode || definitionNodeForRange)
|
||||
: cachedExportCheck(
|
||||
provider.exportChecker,
|
||||
nameNode || definitionNodeForRange,
|
||||
nodeName,
|
||||
),
|
||||
...(frameworkHint
|
||||
? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
|
|
@ -441,7 +461,7 @@ const processParsingSequential = async (
|
|||
}
|
||||
}
|
||||
}
|
||||
// All 14 languages register a FieldExtractor — no fallback needed.
|
||||
// All 15 tree-sitter languages register a FieldExtractor — no fallback needed.
|
||||
}
|
||||
|
||||
// Apply field metadata to the graph node retroactively
|
||||
|
|
|
|||
|
|
@ -1180,5 +1180,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
|||
[SupportedLanguages.Ruby]: RUBY_QUERIES,
|
||||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
[SupportedLanguages.Dart]: DART_QUERIES,
|
||||
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
|
||||
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
|
||||
};
|
||||
|
|
|
|||
|
|
@ -480,8 +480,40 @@ const extractForLoopBinding: ForLoopExtractor = (
|
|||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/** Collect fieldAccess items from an object_pattern's destructured properties. */
|
||||
const collectDestructuredFields = (
|
||||
nameNode: SyntaxNode,
|
||||
receiver: string,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
): PendingAssignment[] => {
|
||||
const items: PendingAssignment[] = [];
|
||||
for (let j = 0; j < nameNode.namedChildCount; j++) {
|
||||
const prop = nameNode.namedChild(j);
|
||||
if (!prop) continue;
|
||||
if (prop.type === 'shorthand_property_identifier_pattern') {
|
||||
// `const { name } = obj` → shorthand: varName = fieldName
|
||||
const varName = prop.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
|
||||
}
|
||||
} else if (prop.type === 'pair_pattern') {
|
||||
// `const { address: addr } = obj` → pair_pattern: key=field, value=varName
|
||||
const keyNode = prop.childForFieldName('key');
|
||||
const valNode = prop.childForFieldName('value');
|
||||
if (keyNode && valNode) {
|
||||
const fieldName = keyNode.text;
|
||||
const varName = valNode.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
/** TS/JS: const alias = u → variable_declarator with name/value fields.
|
||||
* Also handles destructuring: `const { a, b } = obj` → N fieldAccess items. */
|
||||
* Also handles destructuring: `const { a, b } = obj` and `const { a } = fn()` → N fieldAccess items. */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
|
|
@ -490,34 +522,50 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
|||
const valueNode = child.childForFieldName('value');
|
||||
if (!nameNode || !valueNode) continue;
|
||||
|
||||
// Object destructuring: `const { address, name } = user`
|
||||
// Emits N fieldAccess items — one per destructured binding.
|
||||
// Object destructuring from identifier: `const { address, name } = user`
|
||||
if (nameNode.type === 'object_pattern' && valueNode.type === 'identifier') {
|
||||
const receiver = valueNode.text;
|
||||
const items: PendingAssignment[] = [];
|
||||
for (let j = 0; j < nameNode.namedChildCount; j++) {
|
||||
const prop = nameNode.namedChild(j);
|
||||
if (!prop) continue;
|
||||
if (prop.type === 'shorthand_property_identifier_pattern') {
|
||||
// `const { name } = user` → shorthand: varName = fieldName
|
||||
const varName = prop.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
|
||||
}
|
||||
} else if (prop.type === 'pair_pattern') {
|
||||
// `const { address: addr } = user` → pair_pattern: key=field, value=varName
|
||||
const keyNode = prop.childForFieldName('key');
|
||||
const valNode = prop.childForFieldName('value');
|
||||
if (keyNode && valNode) {
|
||||
const fieldName = keyNode.text;
|
||||
const varName = valNode.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
|
||||
const items = collectDestructuredFields(nameNode, valueNode.text, scopeEnv);
|
||||
if (items.length > 0) return items;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Object destructuring from call/await: `const { x } = fn()` or `const { x } = await fn()`
|
||||
// Emits a synthetic callResult + N fieldAccess items resolved via fixpoint iteration.
|
||||
if (nameNode.type === 'object_pattern') {
|
||||
const callNode = unwrapAwait(valueNode);
|
||||
if (callNode?.type === 'call_expression') {
|
||||
const funcNode = callNode.childForFieldName('function');
|
||||
if (funcNode) {
|
||||
let syntheticVar: string | undefined;
|
||||
let leadItem: PendingAssignment | undefined;
|
||||
|
||||
if (funcNode.type === 'identifier') {
|
||||
syntheticVar = `__destr_${funcNode.text}_${callNode.startIndex}`;
|
||||
leadItem = { kind: 'callResult', lhs: syntheticVar, callee: funcNode.text };
|
||||
} else if (funcNode.type === 'member_expression') {
|
||||
const obj = funcNode.childForFieldName('object');
|
||||
const prop = funcNode.childForFieldName('property');
|
||||
if (
|
||||
obj &&
|
||||
prop?.type === 'property_identifier' &&
|
||||
(obj.type === 'identifier' || obj.type === 'this')
|
||||
) {
|
||||
syntheticVar = `__destr_${prop.text}_${callNode.startIndex}`;
|
||||
leadItem = {
|
||||
kind: 'methodCallResult',
|
||||
lhs: syntheticVar,
|
||||
receiver: obj.text,
|
||||
method: prop.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (syntheticVar && leadItem) {
|
||||
const fieldItems = collectDestructuredFields(nameNode, syntheticVar, scopeEnv);
|
||||
if (fieldItems.length > 0) return [leadItem, ...fieldItems];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
125
gitnexus/src/core/ingestion/vue-sfc-extractor.ts
Normal file
125
gitnexus/src/core/ingestion/vue-sfc-extractor.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Vue SFC (Single File Component) script extractor.
|
||||
*
|
||||
* Extracts the <script> / <script setup> block content from .vue files
|
||||
* so it can be parsed by the TypeScript tree-sitter grammar.
|
||||
*
|
||||
* Pure function — no tree-sitter dependency, safe for worker threads.
|
||||
*/
|
||||
|
||||
export interface VueScriptExtraction {
|
||||
/** Extracted script content (TypeScript/JavaScript) */
|
||||
scriptContent: string;
|
||||
/** 0-based line number in the .vue file where the script content starts */
|
||||
lineOffset: number;
|
||||
/** true if the primary block is <script setup> */
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
||||
interface ScriptBlock {
|
||||
content: string;
|
||||
lineOffset: number;
|
||||
isSetup: boolean;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script>/g;
|
||||
const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g;
|
||||
// Greedy: matches from the first <template> to the *last* </template>.
|
||||
// This is intentional — nested <template v-slot:...> tags are valid Vue
|
||||
// syntax and we want the entire outermost template body.
|
||||
const TEMPLATE_RE = /<template(\s[^>]*)?>([^]*)<\/template>/;
|
||||
|
||||
function countNewlines(text: string): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseScriptBlock(
|
||||
attrs: string | undefined,
|
||||
content: string,
|
||||
precedingText: string,
|
||||
): ScriptBlock {
|
||||
const isSetup = attrs != null && /\bsetup\b/.test(attrs);
|
||||
const langMatch = attrs?.match(/\blang\s*=\s*["']([^"']+)["']/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
// +1 for the newline after the opening <script...> tag
|
||||
const lineOffset = countNewlines(precedingText) + 1;
|
||||
|
||||
return { content, lineOffset, isSetup, lang };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract script content from a Vue SFC.
|
||||
*
|
||||
* When both <script> and <script setup> are present, returns only the
|
||||
* <script setup> block (the dominant pattern — 94% of Vue files in real
|
||||
* projects use setup). The <script> (non-setup) block typically contains
|
||||
* only `defineOptions` or legacy option merges and is less important for
|
||||
* the knowledge graph.
|
||||
*/
|
||||
export function extractVueScript(vueContent: string): VueScriptExtraction | null {
|
||||
const blocks: ScriptBlock[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset lastIndex for reuse of the global regex
|
||||
SCRIPT_RE.lastIndex = 0;
|
||||
while ((match = SCRIPT_RE.exec(vueContent)) !== null) {
|
||||
const precedingText = vueContent.slice(0, match.index + match[0].indexOf(match[2]));
|
||||
blocks.push(parseScriptBlock(match[1], match[2], precedingText));
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
// Prefer <script setup> if present
|
||||
const setupBlock = blocks.find((b) => b.isSetup);
|
||||
const primary = setupBlock ?? blocks[0];
|
||||
|
||||
return {
|
||||
scriptContent: primary.content,
|
||||
lineOffset: primary.lineOffset,
|
||||
isSetup: primary.isSetup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue <script setup>: all top-level bindings are implicitly exported.
|
||||
* Returns true if the node (or any ancestor) has the `program` root as its
|
||||
* direct parent — i.e. the node is at the top level of the script block.
|
||||
*
|
||||
* Shared between the worker and sequential parsing paths.
|
||||
*/
|
||||
export const isVueSetupTopLevel = (
|
||||
node: { parent: { type: string; parent: unknown } | null } | null,
|
||||
): boolean => {
|
||||
if (!node) return false;
|
||||
let current: { parent: { type: string; parent: unknown } | null } | null = node;
|
||||
while (current) {
|
||||
if (current.parent?.type === 'program') return true;
|
||||
current = current.parent as typeof current;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract PascalCase component names used in <template>.
|
||||
* Returns deduplicated component names (e.g., ["MyButton", "AppHeader"]).
|
||||
*/
|
||||
export function extractTemplateComponents(vueContent: string): string[] {
|
||||
const templateMatch = TEMPLATE_RE.exec(vueContent);
|
||||
if (!templateMatch) return [];
|
||||
|
||||
const templateContent = templateMatch[2];
|
||||
const components = new Set<string>();
|
||||
let componentMatch: RegExpExecArray | null;
|
||||
|
||||
TEMPLATE_COMPONENT_RE.lastIndex = 0;
|
||||
while ((componentMatch = TEMPLATE_COMPONENT_RE.exec(templateContent)) !== null) {
|
||||
components.add(componentMatch[1]);
|
||||
}
|
||||
|
||||
return [...components];
|
||||
}
|
||||
|
|
@ -65,6 +65,11 @@ import type { ConstructorBinding } from '../type-env.js';
|
|||
import { detectFrameworkFromAST } from '../framework-detection.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { preprocessImportPath } from '../import-processor.js';
|
||||
import {
|
||||
extractVueScript,
|
||||
extractTemplateComponents,
|
||||
isVueSetupTopLevel,
|
||||
} from '../vue-sfc-extractor.js';
|
||||
import type { NamedBinding } from '../named-bindings/types.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { FieldInfo, FieldExtractorContext } from '../field-types.js';
|
||||
|
|
@ -283,6 +288,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
|
|||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
};
|
||||
|
|
@ -1212,12 +1218,24 @@ const processFileGroup = (
|
|||
// Skip files larger than the max tree-sitter buffer (32 MB)
|
||||
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
|
||||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let lineOffset = 0;
|
||||
let isVueSetup = false;
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const extracted = extractVueScript(file.content);
|
||||
if (!extracted) continue; // skip .vue files with no script block
|
||||
parseContent = extracted.scriptContent;
|
||||
lineOffset = extracted.lineOffset;
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
||||
clearCaches(); // Reset memoization before each new file
|
||||
|
||||
let tree;
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent.length),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
|
|
@ -1370,7 +1388,7 @@ const processFileGroup = (
|
|||
routePath,
|
||||
httpMethod,
|
||||
decoratorName,
|
||||
lineNumber: decoratorNode.startPosition.row,
|
||||
lineNumber: decoratorNode.startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
// MCP/RPC tool detection: @mcp.tool(), @app.tool(), @server.tool()
|
||||
|
|
@ -1392,7 +1410,7 @@ const processFileGroup = (
|
|||
result.fetchCalls.push({
|
||||
filePath: file.path,
|
||||
fetchURL: urlNode.text,
|
||||
lineNumber: captureMap['route.fetch'].startPosition.row,
|
||||
lineNumber: captureMap['route.fetch'].startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1408,7 +1426,7 @@ const processFileGroup = (
|
|||
result.fetchCalls.push({
|
||||
filePath: file.path,
|
||||
fetchURL: url,
|
||||
lineNumber: captureMap['http_client'].startPosition.row,
|
||||
lineNumber: captureMap['http_client'].startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1432,7 +1450,7 @@ const processFileGroup = (
|
|||
routePath,
|
||||
httpMethod,
|
||||
decoratorName: `express.${method}`,
|
||||
lineNumber: captureMap['express_route'].startPosition.row,
|
||||
lineNumber: captureMap['express_route'].startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
|
|
@ -1715,10 +1733,10 @@ const processFileGroup = (
|
|||
const nodeName = nameNode ? nameNode.text : 'init';
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNode
|
||||
? definitionNode.startPosition.row
|
||||
? definitionNode.startPosition.row + lineOffset
|
||||
: nameNode
|
||||
? nameNode.startPosition.row
|
||||
: 0;
|
||||
? nameNode.startPosition.row + lineOffset
|
||||
: lineOffset;
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
|
||||
const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap);
|
||||
|
|
@ -1753,7 +1771,7 @@ const processFileGroup = (
|
|||
filePath: file.path,
|
||||
toolName: nodeName,
|
||||
description: dec.arg || '',
|
||||
lineNumber: definitionNode.startPosition.row,
|
||||
lineNumber: definitionNode.startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
fileDecorators.delete(checkLine);
|
||||
|
|
@ -1865,14 +1883,13 @@ const processFileGroup = (
|
|||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: definitionNode ? definitionNode.startPosition.row : startLine,
|
||||
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
|
||||
startLine: definitionNode ? definitionNode.startPosition.row + lineOffset : startLine,
|
||||
endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine,
|
||||
language: language,
|
||||
isExported: cachedExportCheck(
|
||||
provider.exportChecker,
|
||||
nameNode || definitionNode,
|
||||
nodeName,
|
||||
),
|
||||
isExported:
|
||||
language === SupportedLanguages.Vue && isVueSetup
|
||||
? isVueSetupTopLevel(nameNode || definitionNode)
|
||||
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName),
|
||||
...(frameworkHint
|
||||
? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
|
|
@ -1964,7 +1981,20 @@ const processFileGroup = (
|
|||
}
|
||||
|
||||
// Extract ORM queries (Prisma, Supabase)
|
||||
extractORMQueries(file.path, file.content, result.ormQueries);
|
||||
extractORMQueries(file.path, parseContent, result.ormQueries);
|
||||
|
||||
// Vue: emit CALLS edges for components used in <template>
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
for (const componentName of templateComponents) {
|
||||
result.calls.push({
|
||||
filePath: file.path,
|
||||
calledName: componentName,
|
||||
sourceId: generateId('File', file.path),
|
||||
callForm: 'free',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const languageMap: Record<string, any> = {
|
|||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
};
|
||||
|
|
|
|||
30
gitnexus/test/fixtures/lang-resolution/vue-basic/App.vue
vendored
Normal file
30
gitnexus/test/fixtures/lang-resolution/vue-basic/App.vue
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<h1>{{ title }}</h1>
|
||||
<Button variant="primary" @click="onButtonClick">
|
||||
Click me
|
||||
</Button>
|
||||
<p>{{ userDisplay }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import type { User } from './types';
|
||||
import { formatUser } from './types';
|
||||
import Button from './components/Button.vue';
|
||||
|
||||
const title = ref('Hello Vue');
|
||||
|
||||
const user = ref<User>({
|
||||
id: 1,
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
});
|
||||
|
||||
const userDisplay = computed(() => formatUser(user.value));
|
||||
|
||||
function onButtonClick() {
|
||||
title.value = 'Clicked!';
|
||||
}
|
||||
</script>
|
||||
21
gitnexus/test/fixtures/lang-resolution/vue-basic/OldStyle.vue
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/vue-basic/OldStyle.vue
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div>{{ message }}</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'OldStyle',
|
||||
data() {
|
||||
return {
|
||||
message: 'Hello from options API',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
greet() {
|
||||
return this.message;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
30
gitnexus/test/fixtures/lang-resolution/vue-basic/components/Button.vue
vendored
Normal file
30
gitnexus/test/fixtures/lang-resolution/vue-basic/components/Button.vue
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<button :class="classes" @click="handleClick">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: 'primary' | 'secondary';
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const classes = computed(() => ({
|
||||
btn: true,
|
||||
[`btn-${props.variant ?? 'primary'}`]: true,
|
||||
'btn-disabled': props.disabled,
|
||||
}));
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!props.disabled) {
|
||||
emit('click', event);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
9
gitnexus/test/fixtures/lang-resolution/vue-basic/types.ts
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/vue-basic/types.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export function formatUser(user: User): string {
|
||||
return `${user.name} <${user.email}>`;
|
||||
}
|
||||
122
gitnexus/test/integration/resolvers/vue.test.ts
Normal file
122
gitnexus/test/integration/resolvers/vue.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Vue SFC: script extraction, symbol parsing, import resolution, template component edges
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
runPipelineFromRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
describe('Vue SFC support', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'vue-basic'), () => {});
|
||||
}, 60000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Symbol extraction from <script setup>
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('extracts Function nodes from <script setup> .vue files', () => {
|
||||
const functions = getNodesByLabel(result, 'Function');
|
||||
// App.vue: onButtonClick; Button.vue: handleClick; types.ts: formatUser
|
||||
// OldStyle.vue: defineComponent call not a function definition, but greet/data might be
|
||||
expect(functions).toContain('handleClick');
|
||||
expect(functions).toContain('onButtonClick');
|
||||
expect(functions).toContain('formatUser');
|
||||
});
|
||||
|
||||
it('extracts Interface nodes from .ts files used by .vue', () => {
|
||||
const interfaces = getNodesByLabel(result, 'Interface');
|
||||
expect(interfaces).toContain('User');
|
||||
});
|
||||
|
||||
it('marks <script setup> top-level bindings as exported', () => {
|
||||
const allNodes = getNodesByLabelFull(result, 'Function');
|
||||
const handleClick = allNodes.find(
|
||||
(n) => n.properties.name === 'handleClick' && n.properties.filePath.endsWith('Button.vue'),
|
||||
);
|
||||
expect(handleClick).toBeDefined();
|
||||
expect(handleClick!.properties.isExported).toBe(true);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Line offset accuracy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('reports correct startLine in the .vue file (not offset 0)', () => {
|
||||
const allNodes = getNodesByLabelFull(result, 'Function');
|
||||
const handleClick = allNodes.find(
|
||||
(n) => n.properties.name === 'handleClick' && n.properties.filePath.endsWith('Button.vue'),
|
||||
);
|
||||
expect(handleClick).toBeDefined();
|
||||
// handleClick is inside <script setup> which starts after 7 lines of template
|
||||
// The function starts several lines into the script block
|
||||
expect(handleClick!.properties.startLine).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Import resolution: .vue ↔ .ts
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('resolves imports from .vue to .ts files', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const vueToTs = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('types.ts'),
|
||||
);
|
||||
expect(vueToTs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('resolves imports between .vue files', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
const vueToVue = imports.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('Button.vue'),
|
||||
);
|
||||
expect(vueToVue.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cross-file function calls
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('resolves CALLS edges from .vue to .ts functions', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const vueToTs = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.target === 'formatUser',
|
||||
);
|
||||
expect(vueToTs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('emits CALLS edge for PascalCase component used in <template>', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
const templateCalls = calls.filter(
|
||||
(e) => e.sourceFilePath.endsWith('App.vue') && e.targetFilePath.endsWith('Button.vue'),
|
||||
);
|
||||
expect(templateCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('does not mark non-setup <script> symbols as implicitly exported', () => {
|
||||
const allNodes = getNodesByLabelFull(result, 'Function');
|
||||
const oldStyleFns = allNodes.filter((n) => n.properties.filePath.endsWith('OldStyle.vue'));
|
||||
// OldStyle.vue uses options API (no <script setup>), so any extracted
|
||||
// symbols without an explicit `export` keyword should have isExported: false.
|
||||
for (const fn of oldStyleFns) {
|
||||
expect(fn.properties.isExported).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// File nodes exist for .vue files
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('creates File nodes for .vue files', () => {
|
||||
const files = getNodesByLabel(result, 'File');
|
||||
expect(files.some((f) => f.endsWith('.vue'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1122,6 +1122,107 @@ class RepoService {
|
|||
});
|
||||
});
|
||||
|
||||
describe('destructured call results', () => {
|
||||
// Minimal mock SymbolTable for call-result return type lookup
|
||||
const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({
|
||||
lookupFuzzyCallable: (name: string) =>
|
||||
callables
|
||||
.filter((c) => c.name === name)
|
||||
.map((c) => ({
|
||||
nodeId: 'n1',
|
||||
filePath: 'src.ts',
|
||||
type: 'Function' as const,
|
||||
returnType: c.returnType,
|
||||
})),
|
||||
lookupFuzzy: () => [],
|
||||
lookupExact: () => undefined,
|
||||
lookupExactFull: () => undefined,
|
||||
add: () => {},
|
||||
getStats: () => ({ fileCount: 0, globalSymbolCount: 0 }),
|
||||
clear: () => {},
|
||||
});
|
||||
|
||||
it('emits callResult + fieldAccess items for const { x } = fn()', () => {
|
||||
const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]);
|
||||
const tree = parse('const { name } = getUser();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any });
|
||||
// callResult resolves __destr_getUser_N → User
|
||||
// fieldAccess resolves name via User's properties (no Property nodes in mock → undefined)
|
||||
// But the callResult itself IS emitted — verify constructorBindings is still empty
|
||||
expect(typeEnv.constructorBindings).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits callResult for destructured await call', () => {
|
||||
const symbolTable = makeSymbolTable([{ name: 'fetchData', returnType: 'Response' }]);
|
||||
const tree = parse(
|
||||
'async function f() { const { data } = await fetchData(); }',
|
||||
TypeScript.typescript,
|
||||
);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any });
|
||||
expect(typeEnv.constructorBindings).toEqual([]);
|
||||
});
|
||||
|
||||
it('gracefully handles no return type (composable without annotation)', () => {
|
||||
const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType
|
||||
const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any });
|
||||
// No return type → callResult unresolved → fieldAccess unresolved
|
||||
expect(flatGet(typeEnv, 'isMaker')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves destructured properties when return type has declared fields', () => {
|
||||
// importedReturnTypes provides the return type since no real SymbolTable
|
||||
const tree = parse('const { name } = getUser();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
importedReturnTypes: new Map([['getUser', 'User']]),
|
||||
});
|
||||
// callResult resolves __destr_getUser_N → User via importedReturnTypes
|
||||
// fieldAccess for 'name' on 'User' needs User's Property nodes in SymbolTable
|
||||
// Without SymbolTable, field resolution returns undefined — but callResult itself works
|
||||
// Verify the synthetic var resolved to the return type
|
||||
for (const [, scopeMap] of typeEnv.allScopes()) {
|
||||
for (const [key, val] of scopeMap) {
|
||||
if (key.startsWith('__destr_getUser')) {
|
||||
expect(val).toBe('User');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('handles destructured method call: const { x } = obj.getStuff()', () => {
|
||||
const tree = parse(
|
||||
`
|
||||
const repo: Repo = new Repo();
|
||||
const { name } = repo.getProfile();
|
||||
`,
|
||||
TypeScript.typescript,
|
||||
);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript');
|
||||
// repo is resolved to Repo via Tier 1 (constructor inference)
|
||||
expect(flatGet(typeEnv, 'repo')).toBe('Repo');
|
||||
// Destructured method call emits methodCallResult + fieldAccess
|
||||
// Without SymbolTable, method return type is unknown → name is undefined
|
||||
expect(flatGet(typeEnv, 'name')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles renamed destructuring: const { address: addr } = fn()', () => {
|
||||
const tree = parse('const { address: addr } = getUser();', TypeScript.typescript);
|
||||
const typeEnv = buildTypeEnv(tree, 'typescript', {
|
||||
importedReturnTypes: new Map([['getUser', 'User']]),
|
||||
});
|
||||
// 'addr' should be the binding, not 'address'
|
||||
expect(flatGet(typeEnv, 'address')).toBeUndefined();
|
||||
// The synthetic callResult should resolve
|
||||
for (const [, scopeMap] of typeEnv.allScopes()) {
|
||||
for (const [key, val] of scopeMap) {
|
||||
if (key.startsWith('__destr_getUser')) {
|
||||
expect(val).toBe('User');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor inference (Tier 1 fallback)', () => {
|
||||
describe('TypeScript', () => {
|
||||
it('infers type from new expression when no annotation', () => {
|
||||
|
|
|
|||
188
gitnexus/test/unit/vue-sfc-extractor.test.ts
Normal file
188
gitnexus/test/unit/vue-sfc-extractor.test.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractVueScript,
|
||||
extractTemplateComponents,
|
||||
} from '../../src/core/ingestion/vue-sfc-extractor.js';
|
||||
|
||||
describe('extractVueScript', () => {
|
||||
it('extracts <script setup lang="ts"> content', () => {
|
||||
const vue = `<template>
|
||||
<div>Hello</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const count = ref(0);
|
||||
</script>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isSetup).toBe(true);
|
||||
expect(result!.scriptContent).toContain("import { ref } from 'vue'");
|
||||
expect(result!.scriptContent).toContain('const count = ref(0)');
|
||||
// Line 0-3 is template + blank, line 4 is <script setup>, content starts at line 5
|
||||
expect(result!.lineOffset).toBe(5);
|
||||
});
|
||||
|
||||
it('extracts <script lang="ts"> (non-setup)', () => {
|
||||
const vue = `<template>
|
||||
<div>Hello</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'MyComponent',
|
||||
};
|
||||
</script>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isSetup).toBe(false);
|
||||
expect(result!.scriptContent).toContain('export default');
|
||||
});
|
||||
|
||||
it('prefers <script setup> when both blocks exist', () => {
|
||||
const vue = `<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
const name = ref('test');
|
||||
</script>
|
||||
|
||||
<template><div /></template>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isSetup).toBe(true);
|
||||
expect(result!.scriptContent).toContain("const name = ref('test')");
|
||||
expect(result!.scriptContent).not.toContain('inheritAttrs');
|
||||
});
|
||||
|
||||
it('returns null for .vue files with no <script> block', () => {
|
||||
const vue = `<template>
|
||||
<div>Hello</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div { color: red; }
|
||||
</style>
|
||||
`;
|
||||
expect(extractVueScript(vue)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles <script> without lang attribute', () => {
|
||||
const vue = `<template><div /></template>
|
||||
|
||||
<script>
|
||||
export default { name: 'NoLang' };
|
||||
</script>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.scriptContent).toContain('NoLang');
|
||||
expect(result!.isSetup).toBe(false);
|
||||
});
|
||||
|
||||
it('handles <script setup> without lang attribute', () => {
|
||||
const vue = `<template><div /></template>
|
||||
|
||||
<script setup>
|
||||
const x = 1;
|
||||
</script>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isSetup).toBe(true);
|
||||
expect(result!.scriptContent).toContain('const x = 1');
|
||||
});
|
||||
|
||||
it('computes correct lineOffset for script at top of file', () => {
|
||||
const vue = `<script setup lang="ts">
|
||||
const x = 1;
|
||||
</script>
|
||||
|
||||
<template><div /></template>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
// <script> tag is line 0, content starts at line 1
|
||||
expect(result!.lineOffset).toBe(1);
|
||||
});
|
||||
|
||||
it('handles multiline script tag attributes', () => {
|
||||
const vue = `<template><div /></template>
|
||||
|
||||
<script
|
||||
setup
|
||||
lang="ts"
|
||||
>
|
||||
import { ref } from 'vue';
|
||||
</script>
|
||||
`;
|
||||
const result = extractVueScript(vue);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.isSetup).toBe(true);
|
||||
expect(result!.scriptContent).toContain("import { ref } from 'vue'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTemplateComponents', () => {
|
||||
it('finds PascalCase component tags', () => {
|
||||
const vue = `<template>
|
||||
<div>
|
||||
<MyButton @click="doSomething" />
|
||||
<AppHeader title="hello" />
|
||||
<span>text</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ...
|
||||
</script>
|
||||
`;
|
||||
const components = extractTemplateComponents(vue);
|
||||
expect(components).toContain('MyButton');
|
||||
expect(components).toContain('AppHeader');
|
||||
expect(components).not.toContain('div');
|
||||
expect(components).not.toContain('span');
|
||||
});
|
||||
|
||||
it('returns empty array when no template', () => {
|
||||
const vue = `<script setup lang="ts">
|
||||
const x = 1;
|
||||
</script>
|
||||
`;
|
||||
expect(extractTemplateComponents(vue)).toEqual([]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated component usage', () => {
|
||||
const vue = `<template>
|
||||
<MyButton />
|
||||
<MyButton />
|
||||
<MyButton />
|
||||
</template>
|
||||
`;
|
||||
const components = extractTemplateComponents(vue);
|
||||
expect(components.filter((c) => c === 'MyButton')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores HTML elements and lowercase tags', () => {
|
||||
const vue = `<template>
|
||||
<div>
|
||||
<p>text</p>
|
||||
<router-view />
|
||||
<transition name="fade">
|
||||
<MyComponent />
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
`;
|
||||
const components = extractTemplateComponents(vue);
|
||||
expect(components).toEqual(['MyComponent']);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue