fix(extract): parse .vue SFC <script> with the right grammar (#1468)

.vue files were dispatched to extract_js, which picks a tree-sitter grammar by
suffix. .vue is neither .ts nor .tsx, so the whole SFC -- <template> markup,
<script>, and <style> -- was fed to the JavaScript grammar, producing a top-level
ERROR node and recovering no imports, symbols, or type references.

A dedicated extract_vue masks everything outside <script> (replacing it with
spaces so symbol line numbers stay accurate) and parses just the script with the
grammar named by `lang` (ts default; tsx/js/jsx honored). .vue also joins the
cross-file symbol-resolution pass now that it parses cleanly.

Ported from PR #1468 by @papinto. Maintainer fix on top: the <script> open-tag
scan now skips over quoted attribute values, so a `>` inside one (Vue 3.3+ generic
components, e.g. generic="T extends Record<string, unknown>") no longer ends the
tag early and swallow the body. Added a regression test for that case.

(CHANGELOG also records #1470, committed just prior.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
papinto
2026-06-26 10:20:53 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 75a5e6d5ff
commit 349465b8af
3 changed files with 412 additions and 7 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## Unreleased
- Fix: `.vue` Single File Components now extract their `<script>` with the right grammar (#1468, thanks @papinto). `.vue` was dispatched to `extract_js`, which selects a tree-sitter grammar by suffix; `.vue` is neither `.ts` nor `.tsx`, so the whole SFC — `<template>` markup, `<script>`, and `<style>` — was parsed as JavaScript, producing a top-level ERROR node and recovering no imports, symbols, or type references. A dedicated `extract_vue` now masks everything outside `<script>` (replacing it with spaces so line numbers stay accurate) and parses just the script with the grammar named by `lang` (`ts` default, `tsx`/`js`/`jsx` honored). The open-tag scan tolerates `>` inside quoted attributes, so Vue 3.3+ generic components (`generic="T extends Record<string, unknown>"`) parse correctly.
- Fix: `graphify reflect --if-stale` now also checks the `.graphify_analysis.json` and `.graphify_labels.json` sidecars (and any custom `--analysis`/`--labels` paths) when deciding whether `LESSONS.md` is up to date (#1470, thanks @oleksii-tumanov). It previously only stat'd the memory docs and `graph.json`, so lessons could stay stale after community analysis or labels changed without the graph changing. A missing sidecar is treated as not-an-input, so no-cluster builds are unaffected.
- Fix: the `Read|Glob` PreToolUse hook (the "run graphify first" nudge installed for Claude Code and CodeBuddy) now matches the file's real trailing extension instead of substring-scanning the path (#1463, thanks @marketechniks). The old check asked `any(ext in path)`, which had two opposite failures: `.json` files (`package.json`, `tsconfig.json`) spuriously fired because `.js` is a substring of `.json`, and `.astro`/`.vue`/`.svelte` never fired because they weren't in the set — so on Astro/Vue/Svelte projects, where those are the primary source type, reads and globs never surfaced the graph. The hook now compares the segment after the last `/` then after the last `.` against the extension set (with `.astro`/`.vue`/`.svelte` added), so `package.json` stays silent, `data.geojson` stays silent, `**/*.astro` fires, and an extension sitting on a directory component (`my.ts/file`) correctly doesn't. The `graphify-out/` suppression and fail-open behavior are unchanged.
- Fix: make it unambiguous in the skill that graphify needs no API key, so terminal-style hosts stop looping on a missing one (#1461). Hermes (and the other AGENTS.md hosts: Codex, Aider, OpenClaw, Droid, Trae, …) run the `graphify` CLI directly and don't dispatch subagents, but the Step 3 extraction guidance framed the no-key path only as "fall through to subagent dispatch" — so on `/graphify .` those agents would spin for minutes insisting they needed an API key before eventually proceeding. Step 3 now opens with an explicit, hoisted "graphify needs no API key — never ask the user for one, never block on one" statement (code is AST-only; a code-only corpus skips semantic extraction entirely), and the fallback now spells out a non-subagent path for terminal hosts instead of assuming subagent dispatch. Applied across every generated skill body, including the aider/devin monoliths, with a regression test that pins the wording in place.
- Feat: extract WPF/XAML structure from `.xaml` files (#1460, thanks @MikeKatsoulakis). No new parser dependency (stdlib XML, with the same DOCTYPE/ENTITY and size guards as the `.csproj` extractor). Captures the root element, named controls (`x:Name`/`Name`) and their control types, `{Binding ...}` references, and `x:Class`, and bridges the view to its `.xaml.cs` code-behind by resolving event-handler attributes to the matching methods on the partial class. Event resolution is gated on the .NET handler signature `(object sender, …EventArgs e)` and skips free-form attributes (`Content`, `Text`, `Tag`, …), so a property value that merely matches a method name (e.g. `Content="Save"` next to a business method `Save()`) can't fabricate a spurious event edge.
+143 -7
View File
@@ -2343,8 +2343,15 @@ _SWIFT_CONFIG = LanguageConfig(
# ── Generic extractor ─────────────────────────────────────────────────────────
def _extract_generic(path: Path, config: LanguageConfig) -> dict:
"""Generic AST extractor driven by LanguageConfig."""
def _extract_generic(
path: Path, config: LanguageConfig, *, source_override: bytes | None = None
) -> dict:
"""Generic AST extractor driven by LanguageConfig.
``source_override`` parses the given bytes instead of reading ``path``, while
still keying nodes/edges off ``path``. Lets container formats (e.g. Vue SFCs)
mask the wrapper and parse just the embedded ``<script>``.
"""
try:
mod = importlib.import_module(config.ts_module)
from tree_sitter import Language, Parser
@@ -2371,7 +2378,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict:
try:
parser = Parser(language)
source = path.read_bytes()
source = path.read_bytes() if source_override is None else source_override
tree = parser.parse(source)
root = tree.root_node
except Exception as e:
@@ -4236,6 +4243,123 @@ def extract_astro(path: Path) -> dict:
return result
# The open-tag matcher skips over quoted attribute values so a `>` inside one
# (e.g. Vue 3.3+ generic components: `<script setup lang="ts"
# generic="T extends Record<string, unknown>">`) doesn't prematurely end the tag.
_VUE_SCRIPT_RE = re.compile(
r"""(<script\b(?:"[^"]*"|'[^']*'|[^>"'])*>)([\s\S]*?)(</script\s*>)""",
re.IGNORECASE,
)
_VUE_SCRIPT_LANG_RE = re.compile(
r"""\blang\s*=\s*['"]?([A-Za-z]+)['"]?""", re.IGNORECASE
)
def _vue_mask_non_script(src: str) -> tuple[str, str | None]:
"""Blank everything outside ``<script>`` bodies, keeping ``\\r``/``\\n``.
Replaces template/style/tags with spaces so a JS/TS grammar sees only the
script, while preserved newlines keep line numbers accurate. Returns
``(masked_source, lang)``; ``lang`` is the first block's declared ``lang``.
"""
def _blank(s: str) -> str:
return re.sub(r"[^\r\n]", " ", s)
out: list[str] = []
pos = 0
lang: str | None = None
for m in _VUE_SCRIPT_RE.finditer(src):
out.append(_blank(src[pos:m.start()])) # markup/style before this block
out.append(_blank(m.group(1))) # <script …> open tag
out.append(m.group(2)) # script body, verbatim
out.append(_blank(m.group(3))) # </script> close tag
pos = m.end()
if lang is None:
lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1))
if lang_m:
lang = lang_m.group(1).lower()
out.append(_blank(src[pos:]))
return "".join(out), lang
def extract_vue(path: Path) -> dict:
"""Extract imports, symbols, and type refs from a ``.vue`` SFC.
Masks the non-``<script>`` regions and parses the script with the grammar
its ``lang`` implies (``tsx``→TSX, ``js``/``jsx``→JS, ``ts`` or unset→TS;
TS is a superset of JS so it is a safe default). A regex pass then recovers
``import('…')`` dynamic imports the AST does not edge.
"""
try:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}
masked, lang = _vue_mask_non_script(src)
if lang == "tsx":
config = _TSX_CONFIG
elif lang in ("js", "jsx"):
config = _JS_CONFIG
else: # "ts" or unspecified — default to the TS grammar (superset of JS)
config = _TS_CONFIG
result = _extract_generic(path, config, source_override=masked.encode("utf-8"))
# Dynamic `import('…')` calls aren't edged by the AST pass; recover by regex,
# mirroring extract_svelte/extract_astro.
try:
existing_ids = {n["id"] for n in result.get("nodes", [])}
file_node_id = _make_id(str(path))
aliases = _load_tsconfig_aliases(path.parent)
for m in re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
raw = m.group(1)
if not raw:
continue
if raw.startswith("."):
resolved = Path(os.path.normpath(path.parent / raw))
resolved = _resolve_js_module_path(resolved)
node_id = _make_id(str(resolved))
stub_source_file = str(resolved)
else:
resolved_alias = None
for alias_prefix, alias_base in aliases.items():
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
rest = raw[len(alias_prefix):].lstrip("/")
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
break
if resolved_alias is not None:
resolved_alias = _resolve_js_module_path(resolved_alias)
node_id = _make_id(str(resolved_alias))
stub_source_file = str(resolved_alias)
else:
module_name = raw.split("/")[-1]
if not module_name:
continue
node_id = _make_id(module_name)
stub_source_file = raw
if node_id in existing_ids:
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
continue
result.setdefault("nodes", []).append({
"id": node_id, "label": raw,
"file_type": "code", "source_file": stub_source_file,
"confidence": "EXTRACTED",
})
result.setdefault("edges", []).append({
"source": file_node_id, "target": node_id,
"relation": "dynamic_import", "confidence": "EXTRACTED",
"source_file": str(path),
})
existing_ids.add(node_id)
except Exception:
pass
return result
def extract_java(path: Path) -> dict:
"""Extract classes, interfaces, methods, constructors, and imports from a .java file."""
return _extract_generic(path, _JAVA_CONFIG)
@@ -7823,13 +7947,25 @@ def _apply_symbol_resolution_facts(
def _parse_js_tree(path: Path):
try:
from tree_sitter import Language, Parser
if path.suffix in (".ts", ".tsx"):
# .vue embeds the script in non-JS markup; mask it out and parse the
# <script> with TS.
vue_lang: str | None = None
if path.suffix == ".vue":
masked, vue_lang = _vue_mask_non_script(
path.read_text(encoding="utf-8", errors="replace")
)
source = masked.encode("utf-8")
else:
source = path.read_bytes()
use_ts = path.suffix in (".ts", ".tsx") or (
path.suffix == ".vue" and vue_lang not in ("js", "jsx")
)
if use_ts:
import tree_sitter_typescript as tstypescript
language = Language(tstypescript.language_typescript())
else:
import tree_sitter_javascript as tsjavascript
language = Language(tsjavascript.language())
source = path.read_bytes()
parser = Parser(language)
return source, parser.parse(source).root_node
except Exception:
@@ -8174,7 +8310,7 @@ def _ts_walk_class_members(class_node, source: bytes, path: Path, class_nid: str
def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolutionFacts) -> None:
js_paths = [
path for path in paths
if path.suffix in _JS_CACHE_BYPASS_SUFFIXES and path.suffix != ".vue"
if path.suffix in _JS_CACHE_BYPASS_SUFFIXES
]
if not js_paths:
return
@@ -12243,7 +12379,7 @@ _DISPATCH: dict[str, Any] = {
".F03": extract_fortran,
".f08": extract_fortran,
".F08": extract_fortran,
".vue": extract_js,
".vue": extract_vue,
".svelte": extract_svelte,
".astro": extract_astro,
".dart": extract_dart,
+267
View File
@@ -0,0 +1,267 @@
"""Tests for ``.vue`` SFC extraction.
Feeding a whole SFC to the JS grammar produces a top-level ERROR node, dropping
imports and symbols. :func:`extract_vue` masks the non-script regions and parses
the ``<script>`` with the TypeScript grammar, recovering the full graph.
"""
from __future__ import annotations
from pathlib import Path
from graphify.detect import CODE_EXTENSIONS
from graphify.extract import (
_make_id,
_vue_mask_non_script,
extract,
extract_vue,
)
def _write(path: Path, body: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
return path
def _targets(result: dict, *, relation: str | None = None) -> set[str]:
return {
str(e.get("target") or "")
for e in result.get("edges", [])
if relation is None or e.get("relation") == relation
}
def _labels(result: dict) -> set[str]:
return {str(n.get("label") or "") for n in result.get("nodes", [])}
def test_vue_is_in_code_extensions():
assert ".vue" in CODE_EXTENSIONS
def test_mask_preserves_line_numbers_and_blanks_markup():
src = (
"<template>\n"
" <div>{{ msg }}</div>\n"
"</template>\n"
"\n"
'<script setup lang="ts">\n'
"const msg = 'hi'\n"
"</script>\n"
)
masked, lang = _vue_mask_non_script(src)
assert lang == "ts"
# Same number of lines (newlines preserved) so line numbers are stable.
assert masked.count("\n") == src.count("\n")
# Template content is gone; the script body survives verbatim.
assert "div" not in masked
assert "const msg = 'hi'" in masked
# The script body sits on the same line it does in the source (line 6).
assert masked.splitlines()[5].strip() == "const msg = 'hi'"
def test_script_setup_ts_static_imports_resolve(tmp_path):
_write(tmp_path / "Child.vue", "<template><div/></template>\n")
_write(tmp_path / "utils/helper.ts", "export function helper(){}\n")
comp = _write(
tmp_path / "Comp.vue",
"""<template>
<Child />
</template>
<script setup lang="ts">
import Child from './Child.vue'
import { helper } from './utils/helper'
helper()
</script>
""",
)
result = extract_vue(comp)
targets = _targets(result, relation="imports_from")
assert _make_id(str(tmp_path / "Child.vue")) in targets
assert _make_id(str(tmp_path / "utils/helper.ts")) in targets
def test_script_setup_extracts_symbols_with_correct_lines(tmp_path):
comp = _write(
tmp_path / "Widget.vue",
"""<template>
<button @click="onClick">x</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
function onClick(): void {
count.value += 1
}
</script>
""",
)
result = extract_vue(comp)
by_label = {n["label"]: n for n in result["nodes"]}
assert "count" in by_label
assert "onClick()" in by_label
# count is declared on line 8, onClick on line 10 of the SFC.
assert by_label["count"]["source_location"] == "L8"
assert by_label["onClick()"]["source_location"] == "L10"
def test_typed_props_reference_imported_type(tmp_path):
_write(tmp_path / "types.ts", "export interface Thing { id: number }\n")
comp = _write(
tmp_path / "Typed.vue",
"""<script setup lang="ts">
import type { Thing } from './types'
defineProps<{ item: Thing }>()
function use(x: Thing): Thing {
return x
}
</script>
<template><div/></template>
""",
)
result = extract_vue(comp)
# The imported type is referenced from the script.
assert _make_id(str(tmp_path / "types.ts")) in _targets(result, relation="imports_from")
def test_two_script_blocks_both_parsed(tmp_path):
"""Vue allows a classic ``<script>`` plus ``<script setup>``; both are TS."""
_write(tmp_path / "a.ts", "export const a = 1\n")
_write(tmp_path / "b.ts", "export const b = 2\n")
comp = _write(
tmp_path / "Dual.vue",
"""<script lang="ts">
import { a } from './a'
export default { name: 'Dual' }
</script>
<script setup lang="ts">
import { b } from './b'
</script>
<template><div/></template>
""",
)
result = extract_vue(comp)
targets = _targets(result, relation="imports_from")
assert _make_id(str(tmp_path / "a.ts")) in targets
assert _make_id(str(tmp_path / "b.ts")) in targets
def test_dynamic_import_recovered(tmp_path):
_write(tmp_path / "Lazy.vue", "<template><div/></template>\n")
comp = _write(
tmp_path / "Host.vue",
"""<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const Lazy = defineAsyncComponent(() => import('./Lazy.vue'))
</script>
<template><Lazy /></template>
""",
)
result = extract_vue(comp)
assert _make_id(str(tmp_path / "Lazy.vue")) in _targets(result, relation="dynamic_import")
def test_plain_js_script_block(tmp_path):
_write(tmp_path / "dep.js", "export const x = 1\n")
comp = _write(
tmp_path / "Legacy.vue",
"""<script>
import { x } from './dep'
export default { name: 'Legacy' }
</script>
<template><div/></template>
""",
)
result = extract_vue(comp)
assert _make_id(str(tmp_path / "dep.js")) in _targets(result, relation="imports_from")
def test_template_only_file_does_not_crash(tmp_path):
comp = _write(tmp_path / "Static.vue", "<template>\n <h1>hi</h1>\n</template>\n")
result = extract_vue(comp)
assert isinstance(result, dict)
# Only the file node; no script means no imports/symbols.
assert _targets(result, relation="imports_from") == set()
def test_whole_file_to_js_grammar_would_extract_nothing(tmp_path):
"""The SFC must not be parsed as one JS blob.
With the bug, a real SFC yields just the file node; after the fix it yields
its imports.
"""
_write(tmp_path / "dep.ts", "export const v = 1\n")
comp = _write(
tmp_path / "Guard.vue",
"""<template>
<div class="x" :data-y="z">markup that is not valid JS</div>
</template>
<script setup lang="ts">
import { v } from './dep'
const z = v
</script>
""",
)
result = extract_vue(comp)
assert _make_id(str(tmp_path / "dep.ts")) in _targets(result, relation="imports_from")
def test_vue_joins_cross_file_symbol_resolution(tmp_path):
"""A ``.vue`` calling an imported function wires to the real symbol across files.
The SFC's calls should resolve like any ``.ts`` file's would.
"""
helper = _write(tmp_path / "helper.ts", "export function helper() {}\n")
comp = _write(
tmp_path / "Caller.vue",
"""<script setup lang="ts">
import { helper } from './helper'
function go(): void {
helper()
}
</script>
<template><div @click="go" /></template>
""",
)
result = extract([comp, helper], cache_root=tmp_path)
by_label = {n["label"]: n["id"] for n in result["nodes"]}
edges = {(e["source"], e["target"], e["relation"]) for e in result["edges"]}
assert (by_label["go()"], by_label["helper()"], "calls") in edges
def test_generic_component_open_tag_with_angle_brackets(tmp_path):
"""A Vue 3.3+ generic= attribute containing '>' (e.g. Record<string, unknown>)
must not prematurely end the <script> open tag and swallow the body (#1468)."""
_write(tmp_path / "utils/helper.ts", "export function helper(){}\n")
comp = _write(
tmp_path / "Generic.vue",
"""<template><div/></template>
<script setup lang="ts" generic="T extends Record<string, unknown>">
import { helper } from './utils/helper'
const value = helper()
</script>
""",
)
result = extract_vue(comp)
# the import inside the script body is recovered (body wasn't masked away)
assert _make_id(str(tmp_path / "utils/helper.ts")) in _targets(result, relation="imports_from")
# and no stray '">' leaked from the open tag into a parse error
masked, lang = _vue_mask_non_script(comp.read_text(encoding="utf-8"))
assert lang == "ts"
assert 'generic="T extends Record' not in masked # open tag fully blanked
assert "import { helper }" in masked # body preserved