diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index d3dc816257..ede8bc9067 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -115,7 +115,23 @@ export type RelationshipType = | 'HANDLES_TOOL' | 'ENTRY_POINT_OF' | 'WRAPS' - | 'QUERIES'; + | 'QUERIES' + /** Vue component event system: a handler function in a parent component is + * bound to an event emitted by a child component (`@event="handlerFn"`). + * Source = handler Function/Method node in the parent. + * Target = the child component's File node. + * `reason` encodes the event name: `vue-event: @`. + * Complements `EMITS_EVENT`; together they enable Cypher queries that + * trace which handlers receive which component's emitted events. */ + | 'BINDS_EVENT_HANDLER' + /** Vue component event system: a component calls `emit('eventName', ...)` + * or `this.$emit('eventName', ...)`, advertising that it can emit that event. + * Source = the component's own File node (self-referential annotation). + * Target = the same File node. + * `reason` encodes the event name: `vue-emit: `. + * Complements `BINDS_EVENT_HANDLER`; a Cypher query joining on the + * component File node reveals all (emitter, handler) pairs. */ + | 'EMITS_EVENT'; export interface GraphNode { id: string; diff --git a/gitnexus/scripts/run-parity.ts b/gitnexus/scripts/run-parity.ts index f56db11bc3..6fbf35dc60 100644 --- a/gitnexus/scripts/run-parity.ts +++ b/gitnexus/scripts/run-parity.ts @@ -41,8 +41,15 @@ function envVarName(slug: string): string { return `REGISTRY_PRIMARY_${slug.toUpperCase().replace(/-/g, '_')}`; } -function testFilePath(slug: string): string { - return `test/integration/resolvers/${slug}.test.ts`; +function testFilePaths(slug: string): string[] { + const resolverDir = path.resolve(ROOT, 'test/integration/resolvers'); + const files = fs.readdirSync(resolverDir); + const direct = `${slug}.test.ts`; + const prefixed = `${slug}-`; + return files + .filter((name) => name === direct || (name.startsWith(prefixed) && name.endsWith('.test.ts'))) + .sort() + .map((name) => `test/integration/resolvers/${name}`); } function runVitest(testFile: string, env: Record): boolean { @@ -73,12 +80,12 @@ const languages = singleLang ? [singleLang] : [...MIGRATED_LANGUAGES].map(String // Verify test files exist before running const missingFiles: string[] = []; +const filesByLanguage = new Map(); for (const lang of languages) { - const file = path.resolve(ROOT, testFilePath(lang)); - try { - fs.accessSync(file); - } catch { - missingFiles.push(`${testFilePath(lang)} (${lang})`); + const files = testFilePaths(lang); + filesByLanguage.set(lang, files); + if (files.length === 0) { + missingFiles.push(`test/integration/resolvers/${lang}*.test.ts (${lang})`); } } @@ -94,22 +101,26 @@ console.log(`Languages: ${languages.join(', ')}\n`); const failures: ParityFailure[] = []; for (const lang of languages) { - const file = testFilePath(lang); + const files = filesByLanguage.get(lang) ?? []; const envVar = envVarName(lang); console.log(`\n── ${lang} — legacy DAG (${envVar}=0) ──`); - if (!runVitest(file, { [envVar]: '0' })) { - failures.push({ lang, mode: 'legacy' }); + for (const file of files) { + if (!runVitest(file, { [envVar]: '0' })) { + failures.push({ lang, mode: 'legacy' }); + } } console.log(`\n── ${lang} — registry-primary (${envVar}=1) ──`); - if (!runVitest(file, { [envVar]: '1' })) { - failures.push({ lang, mode: 'registry-primary' }); + for (const file of files) { + if (!runVitest(file, { [envVar]: '1' })) { + failures.push({ lang, mode: 'registry-primary' }); + } } } // Summary -const total = languages.length * 2; +const total = [...filesByLanguage.values()].reduce((sum, files) => sum + files.length * 2, 0); const passed = total - failures.length; console.log('\n═══════════════════════════════════════'); diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 058710b2b3..9b68478017 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -426,6 +426,19 @@ interface LanguageProviderConfig { * MUST trigger a fresh parse. */ cachedTree?: unknown, + /** + * Optional metadata about how `sourceText` was produced. + * + * Most providers ignore this and treat `sourceText` as full file content. + * Vue uses it to distinguish: + * - `full-file`: full `.vue` SFC source + * - `pre-extracted-script`: worker-preprocessed bare ` diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue new file mode 100644 index 0000000000..5334b754e4 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/PostList.vue @@ -0,0 +1,33 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue new file mode 100644 index 0000000000..208dd5ed5f --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/UserProfile.vue @@ -0,0 +1,43 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts new file mode 100644 index 0000000000..6489105072 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/api.ts @@ -0,0 +1,18 @@ +import type { User, Post } from './types'; + +export async function fetchUser(id: number): Promise { + const response = await fetch(`/api/users/${id}`); + return response.json() as Promise; +} + +export async function fetchPosts(userId: number): Promise { + const response = await fetch(`/api/users/${userId}/posts`); + return response.json() as Promise; +} + +export function saveUser(user: User): Promise { + return fetch('/api/users', { + method: 'POST', + body: JSON.stringify(user), + }).then((r) => r.json() as Promise); +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts new file mode 100644 index 0000000000..7661c225dd --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-composition-api/src/types.ts @@ -0,0 +1,19 @@ +export interface User { + id: number; + name: string; + email: string; +} + +export interface Post { + id: number; + title: string; + authorId: number; +} + +export function formatUser(user: User): string { + return `${user.name} <${user.email}>`; +} + +export function formatPost(post: Post): string { + return `[${post.id}] ${post.title}`; +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue new file mode 100644 index 0000000000..483e5a79ad --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/App.vue @@ -0,0 +1,20 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue new file mode 100644 index 0000000000..63d82445f6 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/PostCard.vue @@ -0,0 +1,22 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue new file mode 100644 index 0000000000..3a783113ad --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/components/UserCard.vue @@ -0,0 +1,23 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts new file mode 100644 index 0000000000..85b336948b --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/usePost.ts @@ -0,0 +1,18 @@ +import { ref } from 'vue'; +import { PostModel } from '../models'; + +export function usePost() { + const post = ref(null); + + function loadPost(id: number): PostModel { + const p = new PostModel(id, 'Hello World', 'Content here', 1); + post.value = p; + return p; + } + + function getSummary(): string { + return post.value?.summary() ?? ''; + } + + return { post, loadPost, getSummary }; +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts new file mode 100644 index 0000000000..fb50ac126a --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/composables/useUser.ts @@ -0,0 +1,34 @@ +import { ref, computed } from 'vue'; +import type { Ref } from 'vue'; +import { UserModel } from '../models'; + +export function useUser(initialId: number) { + const user = ref(null); + const loading = ref(false); + + const isAdmin = computed(() => user.value?.isAdmin() ?? false); + + async function loadUser(id: number): Promise { + loading.value = true; + const u = new UserModel(id, 'Alice', 'admin'); + user.value = u; + loading.value = false; + return u; + } + + function getDisplayName(): string { + return user.value?.displayName() ?? 'Unknown'; + } + + return { user, loading, isAdmin, loadUser, getDisplayName }; +} + +export function useUserList(): { users: Ref; addUser: (u: UserModel) => void } { + const users = ref([]); + + function addUser(u: UserModel) { + users.value.push(u); + } + + return { users, addUser }; +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts new file mode 100644 index 0000000000..d3f3ae17a8 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-cross-file/src/models.ts @@ -0,0 +1,32 @@ +export class UserModel { + constructor( + public id: number, + public name: string, + public role: 'admin' | 'user', + ) {} + + isAdmin(): boolean { + return this.role === 'admin'; + } + + displayName(): string { + return `${this.name} (${this.role})`; + } +} + +export class PostModel { + constructor( + public id: number, + public title: string, + public content: string, + public authorId: number, + ) {} + + summary(): string { + return this.title.substring(0, 100); + } + + wordCount(): number { + return this.content.split(' ').length; + } +} diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue new file mode 100644 index 0000000000..449772d021 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/App.vue @@ -0,0 +1,17 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue new file mode 100644 index 0000000000..edc0184244 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/Counter.vue @@ -0,0 +1,42 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue new file mode 100644 index 0000000000..cca9ce1d86 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/TodoList.vue @@ -0,0 +1,51 @@ + + + diff --git a/gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts new file mode 100644 index 0000000000..19b5bcd1a6 --- /dev/null +++ b/gitnexus/test/fixtures/vue-scope/vue-options-api/src/utils.ts @@ -0,0 +1,21 @@ +export interface Todo { + id: number; + text: string; + done: boolean; +} + +export function createTodo(text: string): Todo { + return { id: Date.now(), text, done: false }; +} + +export function toggleTodo(todo: Todo): Todo { + return { ...todo, done: !todo.done }; +} + +export function filterDone(todos: Todo[]): Todo[] { + return todos.filter((t) => t.done); +} + +export function filterPending(todos: Todo[]): Todo[] { + return todos.filter((t) => !t.done); +} diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index ae07af5012..09aa997eb0 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -160,6 +160,55 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly variable_declarator > call_expression > arguments + // > arrow_function`. Scope-resolver-only correctness wins; backporting the + // HOC-wrapping traversal to the legacy DAG is out of scope. + 'React.forwardRef: Button → cn and Button → helper (member-expression callee)', + 'memo (bare identifier): Card → cn and Card → helper', + 'useCallback: handleClick → doStuff and handleClick → fmt', + 'useCallback: handleSubmit → doStuff (sibling const, separate caller)', + 'useMemo: computed → doStuff (returns-a-value variant)', + 'observer (MobX): Item → helper', + 'debounce: debouncedSearch → doStuff (utility-HOC form)', + 'bare statement-level HOC calls do not produce phantom Functions', + 'handleClick and handleSubmit do not cross-attribute (no first-sibling-wins)', + 'nested HOCs: helper() call inside the deepest arrow does NOT source from Function:Wrapped', + 'export default HOC: calls attribute to the file-derived function name', + // HOF-callback CALLS edges (typescript-hof-callbacks.test.ts). + // The legacy DAG attributed calls inside pair-arrow / executor / .map + // callbacks to the outermost module scope instead of the named arrow + // function. The scope-resolver uses `pass2AttachDeclarations` to place + // the Function def on the inner arrow, correctly attributing inner calls. + // Scope-resolver-only correctness wins; backporting the pair-arrow / HOF + // attribution fix to the legacy DAG is out of scope. + 'control: direct (x) => transform(x) emits direct → transform', + 'Promise.all(map(...)) emits fanOut → transform (call inside .map callback)', + 'new Promise((resolve) => { ... }) emits wrap → transform (call inside executor)', + 'useQuery({ queryFn: () => fetchData() }) emits queryFn → fetchData (call inside named pair-arrow)', + 'useQuery({ queryFn: () => fetchData() }) emits useFeature → useQuery (direct call in body)', + 'Zustand module-level calls source from the File node (not a sibling Function)', + 'transform is reachable from at least 3 of {direct, fanOut, wrap}', + 'multi-action store: addItem → doA (calls inside addItem attribute to addItem, not first sibling)', + 'multi-action store: removeItem → doB (NOT addItem → doB)', + 'multi-action store: fetchData → doC (third action also attributes correctly)', + 'multi-action store: each action attributes calls to itself (no cross-sibling leakage)', + // JSX-as-call CALLS edges (typescript-jsx-as-call.test.ts). + // The legacy DAG had no `jsx_*` patterns in the TS scope query, so + // `` / `...` produced no CALLS edges. The scope-resolver + // added `jsx_self_closing_element` and `jsx_opening_element` captures. + // Scope-resolver-only correctness wins; backporting JSX capture to the + // legacy DAG query is out of scope. + 'self-closing emits useFoo → Foo', + 'paired ... emits useBar → Bar (closing tag does NOT double-count)', + 'nested emits both useNested → Outer AND useNested → Inner', + 'combined HOF + JSX: const Wrapped = () => emits exactly one Wrapped → Foo', ]), javascript: new Set([ // Mirrors the TypeScript class-instance and factory-pattern singleton @@ -334,6 +383,30 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ + // Template-derived edges are emitted via `emitPostResolutionEdges` on the + // registry-primary path. The legacy resolver never runs this hook, so + // these edges are absent on the REGISTRY_PRIMARY_VUE=0 path. + 'emits CALLS edge from @click="handleSave" in UserProfile.vue template', + 'emits CALLS edge from @keyup.enter="addTodo" in TodoList.vue template', + 'emits ACCESSES edge for :userId="currentUserId" in App.vue template', + 'emits ACCESSES edge for :posts="allPosts" in App.vue template', + // Component event-system edges (BINDS_EVENT_HANDLER / EMITS_EVENT) are + // registry-primary-only — the legacy resolver has no equivalent. + 'emits BINDS_EVENT_HANDLER from onPostSelected to PostList (component event)', + 'emits BINDS_EVENT_HANDLER from onUserLoaded to UserCard (component event)', + 'emits EMITS_EVENT from PostList.vue for emit("select")', + 'emits EMITS_EVENT from UserCard.vue for emit("loaded")', + // Legacy DAG over-resolves this via import/global fallback from the + // composable return object; registry-primary keeps this unresolved. + 'does not currently emit CALLS edge to addUser returned from useUserList', + // `, + ].join('\n'); + fs.writeFileSync(path.join(srcDir, `${name}.vue`), content); + } + + // App.vue — imports and renders all components + const imports = Array.from( + { length: componentCount }, + (_, i) => `import Comp${i + 1} from './Comp${i + 1}.vue';`, + ).join('\n'); + const template = Array.from( + { length: componentCount }, + (_, i) => ` `, + ).join('\n'); + const appContent = [ + ``, + ``, + ``, + ].join('\n'); + fs.writeFileSync(path.join(srcDir, 'App.vue'), appContent); + + return dir; +} + +async function runBenchmark(componentCount: number, budgetMs: number): Promise { + const dir = generateVueFixture(componentCount); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => + reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${componentCount} components`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount: componentCount + 2, // N components + utils.ts + App.vue + componentCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(results: BenchResult[]) { + console.log('\nVue SFC Pipeline Benchmark'); + console.log('┌────────────┬──────────┬───────────┬──────────┬───────┬───────┐'); + console.log('│ Components │ Files │ Time (ms) │ Heap MB │ Nodes │ Edges │'); + console.log('├────────────┼──────────┼───────────┼──────────┼───────┼───────┤'); + for (const r of results) { + console.log( + `│ ${String(r.componentCount).padStart(10)} │ ${String(r.fileCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log('└────────────┴──────────┴───────────┴──────────┴───────┴───────┘'); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / component_ratio):'); + for (let i = 1; i < results.length; i++) { + const compRatio = results[i].componentCount / results[i - 1].componentCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / compRatio; + console.log( + ` ${results[i - 1].componentCount} → ${results[i].componentCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('Vue pipeline benchmark', () => { + it('scales with component count', async () => { + const scales = [10, 25, 50, 100]; + const results: BenchResult[] = []; + + for (const componentCount of scales) { + const result = await runBenchmark(componentCount, 120_000); + results.push(result); + console.log( + ` ${componentCount} components: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults(results); + + for (let i = 1; i < results.length; i++) { + const compRatio = results[i].componentCount / results[i - 1].componentCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + // Wall-clock is noisy; allow a generous upper bound. + expect(timeRatio / compRatio).toBeLessThan(4); + + // Node count grows linearly with component count (each component + // contributes a constant number of nodes: File + Function nodes + + // scope nodes). A large ratio here indicates accidental O(n²) growth + // (e.g. every component importing from every other component). + const nodeRatio = results[i].nodeCount / results[i - 1].nodeCount; + expect(nodeRatio / compRatio).toBeLessThan(1.5); + } + }, 600_000); +}); diff --git a/gitnexus/test/unit/registry-primary-flag.test.ts b/gitnexus/test/unit/registry-primary-flag.test.ts deleted file mode 100644 index 67d692baf2..0000000000 --- a/gitnexus/test/unit/registry-primary-flag.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Unit tests for `registry-primary-flag` (RFC #909 Ring 2 PKG #924). - * - * Flag is `REGISTRY_PRIMARY_`. Each test manipulates - * `process.env` directly and restores it in `afterEach` — there is no - * per-process cache to invalidate, so isolation is lexical. - */ - -import { describe, it, expect, afterEach, beforeEach } from 'vitest'; -import { SupportedLanguages } from 'gitnexus-shared'; -import { - envVarNameFor, - isRegistryPrimary, - primaryLanguages, - MIGRATED_LANGUAGES, -} from '../../src/core/ingestion/registry-primary-flag.js'; - -// ─── Test isolation ───────────────────────────────────────────────────────── -// -// Scrub every `REGISTRY_PRIMARY_*` env var before + after each test so -// parallel vitest runs on the same process don't bleed state. - -function clearAllRegistryPrimaryVars(): void { - for (const key of Object.keys(process.env)) { - if (key.startsWith('REGISTRY_PRIMARY_')) delete process.env[key]; - } -} - -beforeEach(clearAllRegistryPrimaryVars); -afterEach(clearAllRegistryPrimaryVars); - -// ─── envVarNameFor ───────────────────────────────────────────────────────── - -describe('envVarNameFor', () => { - it('produces upper-cased env-var names from the enum value', () => { - expect(envVarNameFor(SupportedLanguages.Python)).toBe('REGISTRY_PRIMARY_PYTHON'); - expect(envVarNameFor(SupportedLanguages.TypeScript)).toBe('REGISTRY_PRIMARY_TYPESCRIPT'); - expect(envVarNameFor(SupportedLanguages.JavaScript)).toBe('REGISTRY_PRIMARY_JAVASCRIPT'); - }); - - it('uses the enum VALUE, not the key, for languages whose key differs from the value', () => { - // Key 'CPlusPlus' → value 'cpp' → env var 'REGISTRY_PRIMARY_CPP'. - // Users see the language by its canonical name, not its TS symbol. - expect(envVarNameFor(SupportedLanguages.CPlusPlus)).toBe('REGISTRY_PRIMARY_CPP'); - expect(envVarNameFor(SupportedLanguages.CSharp)).toBe('REGISTRY_PRIMARY_CSHARP'); - }); - - it('covers every member of SupportedLanguages', () => { - // Build env-var names for every language and assert no duplicates — - // catches a future enum-value collision or accidental renaming. - const names = new Set(); - for (const lang of Object.values(SupportedLanguages)) { - names.add(envVarNameFor(lang)); - } - expect(names.size).toBe(Object.values(SupportedLanguages).length); - }); -}); - -// ─── isRegistryPrimary ───────────────────────────────────────────────────── - -describe('isRegistryPrimary', () => { - it('returns MIGRATED_LANGUAGES membership by default (no env var set)', () => { - // Ring 3: languages in MIGRATED_LANGUAGES are registry-primary by - // default — operators don't need to set an env var for the rolled-out - // migration to take effect. Unmigrated languages default to false. - for (const lang of Object.values(SupportedLanguages)) { - expect(isRegistryPrimary(lang)).toBe(MIGRATED_LANGUAGES.has(lang)); - } - }); - - it("returns true when the env var is 'true' (lowercase)", () => { - process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - }); - - it("returns true when the env var is '1'", () => { - process.env['REGISTRY_PRIMARY_PYTHON'] = '1'; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - }); - - it("returns true when the env var is 'yes'", () => { - process.env['REGISTRY_PRIMARY_PYTHON'] = 'yes'; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - }); - - it('accepts mixed-case and whitespace-padded truthy values', () => { - process.env['REGISTRY_PRIMARY_PYTHON'] = ' TRUE '; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - process.env['REGISTRY_PRIMARY_PYTHON'] = 'Yes'; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - }); - - it("returns false for falsy-looking values ('false', '0', empty, 'off')", () => { - for (const value of ['false', '0', '', 'off', 'no', 'disabled']) { - process.env['REGISTRY_PRIMARY_PYTHON'] = value; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); - } - }); - - it('returns false for unrecognized tokens (fail-safe on typos)', () => { - // User meant to type 'true' but fat-fingered — conservative: treat as off. - for (const value of ['ture', 'tru', 'yeah', 'enable', 'y']) { - process.env['REGISTRY_PRIMARY_PYTHON'] = value; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false); - } - }); - - it('isolates flags per-language (one on does not affect others)', () => { - process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true); - // Vue is not in MIGRATED_LANGUAGES — default false stays - // false regardless of Python's flag. - expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false); - }); - - it('respects a mid-process env-var mutation (no stale cache)', () => { - // Use Vue — not in MIGRATED_LANGUAGES — so the unset default is - // deterministically `false`, independent of which languages have - // been flipped to registry-primary. - expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false); - process.env['REGISTRY_PRIMARY_VUE'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(true); - delete process.env['REGISTRY_PRIMARY_VUE']; - expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false); - }); - - it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => { - process.env['REGISTRY_PRIMARY_CPP'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true); - // Negative: the TS-key-style name is NOT read. CPlusPlus is now in - // MIGRATED_LANGUAGES, so we must explicitly opt it out via the - // canonical env var to verify the wrong-name var has no effect. - process.env['REGISTRY_PRIMARY_CPP'] = 'false'; - process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true'; - expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false); - }); -}); - -// ─── primaryLanguages ────────────────────────────────────────────────────── - -describe('primaryLanguages', () => { - it('returns MIGRATED_LANGUAGES when no flags are set', () => { - // Default-on for migrated languages (Ring 3); unmigrated stay off. - const enabled = primaryLanguages(); - expect(enabled.size).toBe(MIGRATED_LANGUAGES.size); - for (const lang of MIGRATED_LANGUAGES) { - expect(enabled.has(lang)).toBe(true); - } - }); - - it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => { - // Migrated languages are default-on; each must be opted out here when - // testing explicit env overrides. Ruby (unmigrated) opts in. - // Opt out every member of MIGRATED_LANGUAGES dynamically so this test - // does not have to be updated each time a new language ships its - // Ring 3 migration (C++ and PHP joined the set in their respective - // Ring 3 migrations; future Ring 3 additions land here without test churn). - for (const lang of MIGRATED_LANGUAGES) { - process.env[envVarNameFor(lang)] = 'false'; - } - process.env['REGISTRY_PRIMARY_RUBY'] = '1'; - const enabled = primaryLanguages(); - expect(enabled.has(SupportedLanguages.Python)).toBe(false); - expect(enabled.has(SupportedLanguages.CSharp)).toBe(false); - expect(enabled.has(SupportedLanguages.Go)).toBe(false); - expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false); - expect(enabled.has(SupportedLanguages.PHP)).toBe(false); - expect(enabled.has(SupportedLanguages.Ruby)).toBe(true); - // Only Ruby is on: migrated defaults overridden off, Ruby explicitly on. - expect(enabled.size).toBe(1); - }); - - it('returns a plain Set (not a frozen proxy) — consistent shape', () => { - process.env['REGISTRY_PRIMARY_PYTHON'] = 'true'; - const enabled = primaryLanguages(); - expect(enabled).toBeInstanceOf(Set); - }); -}); diff --git a/gitnexus/test/unit/vue-sfc-extractor.test.ts b/gitnexus/test/unit/vue-sfc-extractor.test.ts index d26f2b9164..62ff990e18 100644 --- a/gitnexus/test/unit/vue-sfc-extractor.test.ts +++ b/gitnexus/test/unit/vue-sfc-extractor.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { extractVueScript, extractTemplateComponents, + extractScriptEmitCalls, + extractComponentEventBindings, + extractNativeElementEventHandlers, } from '../../src/core/ingestion/vue-sfc-extractor.js'; describe('extractVueScript', () => { @@ -185,6 +188,140 @@ const x = 1; const components = extractTemplateComponents(vue); expect(components).toEqual(['MyComponent']); }); + + it('treats kebab-case component tags as component candidates', () => { + const vue = ``; + const components = extractTemplateComponents(vue); + expect(components).toContain('PostList'); + expect(components).toContain('UserCard'); + }); +}); + +describe('extractScriptEmitCalls', () => { + it('extracts bare emit() event names', () => { + const vue = ``; + expect(extractScriptEmitCalls(vue).map((c) => c.eventName)).toEqual(['select']); + }); + + it('ignores property emits and commented/string emit text', () => { + const vue = ``; + expect(extractScriptEmitCalls(vue).map((c) => c.eventName)).toEqual(['actual']); + }); +}); + +describe('extractComponentEventBindings', () => { + it('captures kebab-case component event bindings', () => { + const vue = ``; + expect(extractComponentEventBindings(vue)).toEqual([ + { componentName: 'PostList', eventName: 'select', handlerName: 'onPostSelected' }, + ]); + }); + + it('captures hyphenated event names (@user-loaded)', () => { + const vue = ``; + const bindings = extractComponentEventBindings(vue); + expect(bindings).toContainEqual({ + componentName: 'UserCard', + eventName: 'user-loaded', + handlerName: 'onUserLoaded', + }); + }); + + it('captures update:model-value style event names', () => { + const vue = ``; + const bindings = extractComponentEventBindings(vue); + expect(bindings).toContainEqual({ + componentName: 'MyInput', + eventName: 'update:model-value', + handlerName: 'onChange', + }); + }); +}); + +describe('extractNativeElementEventHandlers', () => { + it('captures handlers from native elements', () => { + const vue = ``; + const handlers = extractNativeElementEventHandlers(vue); + expect(handlers).toContain('handleSave'); + expect(handlers).toContain('onSubmit'); + }); + + it('does not emit handlers for kebab-case component tags', () => { + // is a Vue component, not a native element. + // The NATIVE_TAG_RE negative lookahead must prevent matching `post` as a native tag. + const vue = ``; + const handlers = extractNativeElementEventHandlers(vue); + expect(handlers).not.toContain('onSelect'); + expect(handlers).toContain('handleClick'); + }); +}); + +describe('extractScriptEmitCalls — Options API this.$emit', () => { + it('captures this.$emit() in Options API components', () => { + const vue = ``; + const events = extractScriptEmitCalls(vue).map((c) => c.eventName); + expect(events).toContain('save'); + expect(events).toContain('update:modelValue'); + }); + + it('does NOT capture socket.emit() or eventBus.emit() as component events', () => { + const vue = ``; + const events = extractScriptEmitCalls(vue).map((c) => c.eventName); + expect(events).toEqual(['actual']); + expect(events).not.toContain('message'); + expect(events).not.toContain('data'); + }); + + it('captures update:modelValue style event names with colon', () => { + const vue = ``; + const events = extractScriptEmitCalls(vue).map((c) => c.eventName); + expect(events).toContain('update:modelValue'); + expect(events).toContain('user-loaded'); + }); }); // ---------------------------------------------------------------------------