feat: add Vue support - #66
Conversation
Conflict resolution after rebasing 6 weeks of main onto this PR: - src/extraction/tree-sitter.ts: main moved Liquid/Svelte/Dfm extractors out of this file into per-extractor modules. Did the same for VueExtractor — now lives in src/extraction/vue-extractor.ts. Added the import + dispatch line was already present in the PR's diff. - src/types.ts: main converted Language to a runtime-iterable `as const` array (LANGUAGES) for parser/registry use. Kept that shape and inserted 'vue' between 'svelte' and 'liquid' so the contributor's Vue language work continues to typecheck. - __tests__/extraction.test.ts: kept both the PR's Vue Extraction describe block AND main's new Instantiates+Decorates describe block; they don't overlap. - Removed Sentry's captureException call in the extracted VueExtractor — sentry was removed from main, so the import would have broken the build. Verified live on real Vue codebases: - vuejs/pinia (36 .vue files): 1,019 nodes, 1,033 edges, no errors - vuejs/router (111 .vue files): 3,095 nodes, 2,899 edges, no errors Component nodes, script-block extraction, and containment edges all produce correct output. Full test suite: 423/423 pass (was 416, +7 Vue tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Reviewed, rebased, and merging — thanks for the contribution, Abhijeet! Pushed a follow-up commit to your branch resolving the conflicts that accumulated over 6 weeks:
Validated live on two popular Vue codebases:
Component nodes, script-block extraction, and containment edges all produce correct output. Full test suite: 423/423 pass (was 416 + your 7 Vue tests). |
There was a problem hiding this comment.
Pull request overview
Adds first-class Vue Single-File Component (SFC) support to the extraction + resolution pipeline so .vue files can participate in the code relationship graph similarly to other frontend component formats (e.g., Svelte).
Changes:
- Introduces a
VueExtractorthat delegates<script>/<script setup>parsing to the existing JS/TS tree-sitter extractor and adds component containment edges. - Extends language detection / supported-language utilities to recognize
.vueasvueand includes.vuein default include globs. - Registers a new
vueResolverframework resolver (Vue/Nuxt conventions) and adds Vue extraction tests.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types.ts | Adds vue to supported languages and includes **/*.vue in default config globs. |
| src/resolution/frameworks/vue.ts | New Vue/Nuxt framework resolver for macro/auto-import handling and Nuxt route/middleware heuristics. |
| src/resolution/frameworks/index.ts | Registers and re-exports the new vueResolver. |
| src/extraction/vue-extractor.ts | New Vue SFC extractor delegating script parsing to the existing tree-sitter extractor and emitting containment edges. |
| src/extraction/tree-sitter.ts | Routes .vue files to VueExtractor in extractFromSource. |
| src/extraction/grammars.ts | Maps .vue to vue and marks vue as supported via custom extractor path. |
| tests/extraction.test.ts | Adds Vue extraction tests for detection, node creation, and containment edges. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Detect Nuxt page routes (pages/ directory) | ||
| const pagesIndex = normalized.indexOf('/pages/'); | ||
| if (pagesIndex !== -1 && normalized.endsWith('.vue')) { | ||
| const routePath = filePathToNuxtRoute(normalized, pagesIndex + '/pages/'.length); | ||
| if (routePath !== null) { |
| const allFiles = context.getAllFiles(); | ||
| const vueFiles = allFiles.filter((f) => f.endsWith('.vue')); | ||
|
|
||
| // Check for exact name match (Button -> Button.vue) | ||
| for (const file of vueFiles) { | ||
| const fileName = file.split(/[/\\]/).pop() || ''; | ||
| const componentName = fileName.replace(/\.vue$/, ''); | ||
| if (componentName === name) { | ||
| const nodes = context.getNodesInFile(file); | ||
| const component = nodes.find((n) => n.kind === 'component' && n.name === name); | ||
| if (component) { | ||
| return component.id; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Check same directory first for better specificity | ||
| const fromDir = fromFile.substring(0, fromFile.lastIndexOf('/')); | ||
| for (const file of vueFiles) { | ||
| if (file.startsWith(fromDir)) { | ||
| const fileName = file.split(/[/\\]/).pop() || ''; | ||
| const componentName = fileName.replace(/\.vue$/, ''); | ||
| if (componentName === name) { | ||
| const nodes = context.getNodesInFile(file); | ||
| const component = nodes.find((n) => n.kind === 'component'); | ||
| if (component) { | ||
| return component.id; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return null; |
| // Delegate to TreeSitterExtractor | ||
| const extractor = new TreeSitterExtractor(this.filePath, block.content, scriptLanguage); | ||
| const result = extractor.extract(); | ||
|
|
||
| // Offset line numbers from script block back to .vue file positions | ||
| for (const node of result.nodes) { | ||
| node.startLine += block.startLine; | ||
| node.endLine += block.startLine; | ||
| node.language = 'vue'; // Mark as vue, not TS/JS | ||
|
|
||
| this.nodes.push(node); | ||
|
|
||
| // Add containment edge from component to this node | ||
| this.edges.push({ | ||
| source: componentNodeId, | ||
| target: node.id, | ||
| kind: 'contains', | ||
| }); | ||
| } |
| } catch (error) { | ||
| this.errors.push({ | ||
| message: `Vue extraction error: ${error instanceof Error ? error.message : String(error)}`, | ||
| severity: 'error', |
Conflict resolution after rebasing main onto this PR: - src/extraction/tree-sitter.ts: main added VueExtractor (new file src/extraction/vue-extractor.ts via colbymchenry#66). The PR's restructured if/else chain in extractFromSource gets a new vue branch alongside svelte/liquid/dfm so the framework-extract pipeline runs uniformly for vue files too. - src/resolution/frameworks/vue.ts: vue resolver still used the dead extractNodes(): Node[] interface that this PR replaced. Migrated to extract(): { nodes, references } matching the other 13 resolvers — Vue's nuxt route detection (pages/, server/api/, middleware/) keeps working, just emits no references (matches react.ts shape). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Followup to #66 — Vue support shipped but the README languages table was never updated. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Colby McHenry <me@colbymchenry.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
) Followup to colbymchenry#66 — Vue support shipped but the README languages table was never updated. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on.rs The TS extractor keeps a python call's receiver text as a qualifier when the receiver is not a plain identifier — an attribute chain (`self.data.append`), a subscript (`d[k].append`) or a call chain (`d.setdefault(k, []).append`) — so a bare `append` can never exact-match an unrelated project function of that name (colbymchenry#66). `codegraph-kernel/src/python.rs` still collapsed all three to the bare method name. Python is in the kernel's DEFAULT_ROUTED set and every published bundle ships the .node, so the TS-only fix never ran where it mattered: on the installed 1.5.0 build, `self.data.append(...)` and `rows["k"].append(2)` both fabricated a `calls` edge onto an unrelated module-level `append`, while the real `ledger.append(row)` was missing. A from-source checkout has no .node, so the existing coverage silently exercised the wasm arm and stayed green. Mirrored the branch, with a `collapse_js_whitespace` helper rather than `char::is_whitespace`: the sets differ (U+0085 in one, U+FEFF in the other), and the parity sweep compares the two arms byte for byte. torture.py gains the subscript and call-chain shapes; the attribute-chain shape (`self.registry.lookup`) was already there and is what makes kernel-tsjs-parity fail without this commit. The new test asserts the end-to-end property on the kernel arm specifically, and skips when no .node is staged, like the parity suites. Verified: kernel-tsjs-parity 17/17 with the mirror, 2 failures without it (rebuilt both ways); the new suite passes against a freshly built kernel.
…on.rs The TS extractor keeps a python call's receiver text as a qualifier when the receiver is not a plain identifier — an attribute chain (`self.data.append`), a subscript (`d[k].append`) or a call chain (`d.setdefault(k, []).append`) — so a bare `append` can never exact-match an unrelated project function of that name (colbymchenry#66). `codegraph-kernel/src/python.rs` still collapsed all three to the bare method name. Python is in the kernel's DEFAULT_ROUTED set and every published bundle ships the .node, so the TS-only fix never ran where it mattered: on the installed 1.5.0 build, `self.data.append(...)` and `rows["k"].append(2)` both fabricated a `calls` edge onto an unrelated module-level `append`, while the real `ledger.append(row)` was missing. A from-source checkout has no .node, so the existing coverage silently exercised the wasm arm and stayed green. Mirrored the branch, with a `collapse_js_whitespace` helper rather than `char::is_whitespace`: the sets differ (U+0085 in one, U+FEFF in the other), and the parity sweep compares the two arms byte for byte. torture.py gains the subscript and call-chain shapes; the attribute-chain shape (`self.registry.lookup`) was already there and is what makes kernel-tsjs-parity fail without this commit. The new test asserts the end-to-end property on the kernel arm specifically, and skips when no .node is staged, like the parity suites. Verified: kernel-tsjs-parity 17/17 with the mirror, 2 failures without it (rebuilt both ways); the new suite passes against a freshly built kernel.
…odule The escape added for colbymchenry#66 asked only whether SOME import bound the receiver's local name. Every import produces a mapping — stdlib and PyPI included — so it was also true for `os`, `requests`, `np`. That opened the built-in-method filter for them; `resolveViaImport` then found no project file, resolution fell through to the bare-name strategy, and the call bound to whatever project method happened to share the name. The escape hatch reintroduced the exact fabrication class the filter exists to prevent. Verified before the fix, on a project with `Store.remove` / `Store.get`: import os; import requests cleanup -> Store.remove refName "os.remove" cleanup -> Store.get refName "requests.get" Two wrong edges where 1.6.0 produced none. The escape now resolves the import specifier and opens only when it names a file in this project — the same question `resolveViaImport` asks next, so a receiver that passes is one the qualified path can actually serve. `from . import mod` and `import pkg.mod as m` are both handled; anything else stays a silent miss rather than a wrong edge. `__tests__/python-import-gate.test.ts` pins both directions, and fails on the first without this change (`['remove@store.py','get@store.py']` vs `[]`). resolution + extraction + frameworks + kernel parity: 855 tests, all pass.
…odule The escape added for colbymchenry#66 asked only whether SOME import bound the receiver's local name. Every import produces a mapping — stdlib and PyPI included — so it was also true for `os`, `requests`, `np`. That opened the built-in-method filter for them; `resolveViaImport` then found no project file, resolution fell through to the bare-name strategy, and the call bound to whatever project method happened to share the name. The escape hatch reintroduced the exact fabrication class the filter exists to prevent. Verified before the fix, on a project with `Store.remove` / `Store.get`: import os; import requests cleanup -> Store.remove refName "os.remove" cleanup -> Store.get refName "requests.get" Two wrong edges where 1.6.0 produced none. The escape now resolves the import specifier and opens only when it names a file in this project — the same question `resolveViaImport` asks next, so a receiver that passes is one the qualified path can actually serve. `from . import mod` and `import pkg.mod as m` are both handled; anything else stays a silent miss rather than a wrong edge. `__tests__/python-import-gate.test.ts` pins both directions, and fails on the first without this change (`['remove@store.py','get@store.py']` vs `[]`). resolution + extraction + frameworks + kernel parity: 855 tests, all pass.
The colbymchenry#66 escape opened the built-in-method filter for ANY imported local name, stdlib and PyPI included, so `os.remove(p)` bound to a project method named `remove` — the fabrication class the filter exists to prevent, arriving through its own escape. Verified: 2 wrong edges where 1.6.0 produced none. Found by code review of the em.2 build.
Since colbymchenry#66 kept the receiver's text, `self.data.append(1)` reaches the resolver as `self.data.append` — which `matchMethodCall`'s dotMatch splits into receiver `self.data` + method `append`, and the bare-name strategies then bound it to any project method of that name. The comment on colbymchenry#66 claimed the qualifier prevented exactly this. It did not; only receivers with non-word characters (subscript, call chain) got the promised silent miss. This is the discipline Go (colbymchenry#1276), Rust (colbymchenry#1585) and PHP's `this->prop.method` already have in this same function: a dotted python receiver resolves through validated inference or not at all. **It costs recall, and the cost is measured rather than waved at.** Indexing the tracked .py of four real projects: three unchanged (278 / 643 / 301 call edges), and a 249-file one 2605 -> 2564. Of the 41 dropped, 38 were fabrications — 21 x a dict `.update` bound to a service's `update`, 16 x application code bound to a `get` defined in a TEST file, and `self._model.transcribe` on an external Whisper model bound to the file's own `transcribe` — and 3 were genuine `self._capture.stop()` hops onto the class the constructor assigns. Those 3 are recoverable: python names an attribute's type in the class body (`self.x: T`, `self.x = T()`, a typed `__init__` parameter, a class-level annotation, a base class). A first attempt read those with regexes over the class's source lines and review killed it — with only `#` stripped it took a type out of a DOCSTRING and turned a correct edge into a wrong one, read a nested class's `__init__` as the outer class's, and stripped the package off `requests.Session()` to bind an external object to a project class. Doing it right needs the AST, and it is its own change. Until then this shape is a silent miss, which is the trade this file makes everywhere else. The three negative tests each use a DISTRACTOR — a second project symbol with the same method name. Without one the old fallback found the right target by single-candidate luck and the test passed on both arms, proving nothing; that is how the first version of this suite was vacuous. Verified: all three fail against the pre-change resolver, and the two boundary guards (a single-segment inferable receiver, a module-qualified call) pass on both arms. resolution + extraction + frameworks + kernel parity: 860 tests, all pass. Plan: ~/.claude/plans/codegraph-python-attribute-chain-receiver.md
The colbymchenry#66 extractor change kept the receiver's text but the resolver still split it and fell through to the bare-name strategies, so `self.data.append(1)` bound to an unrelated class's `append`. Now validated-inference-or-nothing, the rule Go, Rust and PHP already follow in that function. Measured: 38 fabrications dropped, 3 genuine edges lost, on a 249-file python repo. Attribute type inference would recover the 3 and needs the AST — a regex version was reviewed and rejected for reading types out of docstrings.
Since colbymchenry#66 kept the receiver's text, `self.data.append(1)` reaches the resolver as `self.data.append` — which `matchMethodCall`'s dotMatch splits into receiver `self.data` + method `append`, and the bare-name strategies then bound it to any project method of that name. The comment on colbymchenry#66 claimed the qualifier prevented exactly this. It did not; only receivers with non-word characters (subscript, call chain) got the promised silent miss. This is the discipline Go (colbymchenry#1276), Rust (colbymchenry#1585) and PHP's `this->prop.method` already have in this same function: a dotted python receiver resolves through validated inference or not at all. **It costs recall, and the cost is measured rather than waved at.** Indexing the tracked .py of four real projects: three unchanged (278 / 643 / 301 call edges), and a 249-file one 2605 -> 2564. Of the 41 dropped, 38 were fabrications — 21 x a dict `.update` bound to a service's `update`, 16 x application code bound to a `get` defined in a TEST file, and `self._model.transcribe` on an external Whisper model bound to the file's own `transcribe` — and 3 were genuine `self._capture.stop()` hops onto the class the constructor assigns. Those 3 are recoverable: python names an attribute's type in the class body (`self.x: T`, `self.x = T()`, a typed `__init__` parameter, a class-level annotation, a base class). A first attempt read those with regexes over the class's source lines and review killed it — with only `#` stripped it took a type out of a DOCSTRING and turned a correct edge into a wrong one, read a nested class's `__init__` as the outer class's, and stripped the package off `requests.Session()` to bind an external object to a project class. Doing it right needs the AST, and it is its own change. Until then this shape is a silent miss, which is the trade this file makes everywhere else. The three negative tests each use a DISTRACTOR — a second project symbol with the same method name. Without one the old fallback found the right target by single-candidate luck and the test passed on both arms, proving nothing; that is how the first version of this suite was vacuous. Verified: all three fail against the pre-change resolver, and the two boundary guards (a single-segment inferable receiver, a module-qualified call) pass on both arms. resolution + extraction + frameworks + kernel parity: 860 tests, all pass. Plan: ~/.claude/plans/codegraph-python-attribute-chain-receiver.md
This pull request adds comprehensive support for Vue Single-File Components (SFCs) to the code extraction and analysis pipeline. It introduces a new
VueExtractorclass that can parse.vuefiles, extract component and function nodes from both standard and<script setup>blocks, and integrate them into the existing code relationship graph. Additionally, the PR updates language detection, grammar support, and framework resolution to recognize and process Vue files throughout the system. Extensive tests are included to verify correct extraction behavior for various Vue SFC patterns.The most important changes are:
Vue Extraction Support
VueExtractorclass to parse.vuefiles, extract component nodes, delegate script parsing to the TypeScript/JavaScript extractor, and create containment edges between components and their script nodes. This enables accurate modeling of Vue SFCs in the code graph.extractFromSourceto use the newVueExtractorwhen a.vuefile is detected, ensuring all Vue files are processed with the appropriate logic.Language and Grammar Integration
.vuefiles as thevuelanguage, and ensured all relevant utility functions (e.g.,isLanguageSupported,isGrammarLoaded,getSupportedLanguages,getLanguageDisplayName) includevue. [1] [2] [3] [4] [5]Framework Resolution
vueResolverto the framework resolution system and included it in the list of available framework resolvers, enabling Vue-specific relationship resolution. [1] [2] [3]Testing
PS: This is my first contribution to the project. Please let me know if any part of this PR deviates from your established standards—I’m happy to make any necessary adjustments to align with the codebase.
CC: @colbymchenry
Related issue: #51