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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
75a5e6d5ff
commit
349465b8af
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user