Treat .mts/.cts (TypeScript module extensions) as TypeScript
`.mts` (ESM) and `.cts` (CommonJS) are the TypeScript counterparts of the already-supported `.mjs`/`.cjs`, but graphify handled them in neither the code-extension detection nor the TypeScript grammar path — so they were either skipped entirely (classified as non-code) or, once detected, parsed with the plain JavaScript grammar, which silently drops every `type`/`interface`/type-alias declaration. Detection — add `.mts`/`.cts` alongside their siblings in: - detect.CODE_EXTENSIONS (the code / non-code gate) - analyze._LANG_FAMILY (cross-language edge family → "js") - extract._JS_RESOLVE_EXTS (import target resolution) - extract._JS_CACHE_BYPASS_SUFFIXES - build.py per-node language map (→ "js") TypeScript grammar — route `.mts`/`.cts` to the plain TypeScript grammar (like `.ts`; neither is JSX), so their TS-only syntax is captured: - extract_js() (grammar selector: .mts/.cts → _TS_CONFIG) - extract._DISPATCH (.mts/.cts → extract_js) - the use_ts tree-sitter selector - the typescript_member_calls resolver frozenset Result: a `.mts`/`.cts` file now extracts identically to the equivalent `.ts`. Verified on a real 47 KB `.mts` source — 36 nodes for `.mts`/`.cts`, matching `.ts` exactly (was 22 with the JS grammar; the 14 dropped nodes were TS type/interface declarations). No new dependency — same tree-sitter-typescript grammar as `.ts`. Tests: tests/test_typescript_module_extensions.py — membership regression locks for the extension sets, dispatch routing, and end-to-end parity asserting `.mts`/`.cts` capture the TS `type`/`interface` nodes and match the `.ts` node set. 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
32ff6d6fb3
commit
1226c34731
+1
-1
@@ -23,7 +23,7 @@ _BUILTIN_NOISE_LABELS = frozenset({
|
||||
# Language families — extensions sharing a runtime can legitimately call each other
|
||||
_LANG_FAMILY: dict[str, str] = {
|
||||
**{e: "python" for e in (".py", ".pyw")},
|
||||
**{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".vue", ".svelte")},
|
||||
**{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte")},
|
||||
**{e: "go" for e in (".go",)},
|
||||
**{e: "rust" for e in (".rs",)},
|
||||
**{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")},
|
||||
|
||||
+1
-1
@@ -577,7 +577,7 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
|
||||
_LANG_FAMILY: dict[str, str] = {
|
||||
".py": "py", ".pyi": "py",
|
||||
".js": "js", ".mjs": "js", ".cjs": "js", ".jsx": "js",
|
||||
".ts": "js", ".tsx": "js",
|
||||
".ts": "js", ".tsx": "js", ".mts": "js", ".cts": "js",
|
||||
".go": "go", ".rs": "rs",
|
||||
".java": "jvm", ".kt": "jvm", ".scala": "jvm", ".groovy": "jvm",
|
||||
# C, C++, and ObjC interoperate within one compilation unit: a method
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class FileType(str, Enum):
|
||||
|
||||
_MANIFEST_PATH = str(out_path("manifest.json"))
|
||||
|
||||
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
|
||||
CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'}
|
||||
DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'}
|
||||
PAPER_EXTENSIONS = {'.pdf'}
|
||||
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}
|
||||
|
||||
+8
-6
@@ -89,8 +89,8 @@ def _csharp_namespace_id(dotted_name: str) -> str:
|
||||
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {}
|
||||
_WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {}
|
||||
_WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json")
|
||||
_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".vue", ".svelte"}
|
||||
_JS_RESOLVE_EXTS = (".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs")
|
||||
_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte"}
|
||||
_JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs")
|
||||
_JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs")
|
||||
|
||||
|
||||
@@ -5355,10 +5355,10 @@ def extract_python(path: Path) -> dict:
|
||||
|
||||
|
||||
def extract_js(path: Path) -> dict:
|
||||
"""Extract classes, functions, arrow functions, and imports from a .js/.ts/.tsx file."""
|
||||
"""Extract classes, functions, arrow functions, and imports from a .js/.ts/.tsx/.mts/.cts file."""
|
||||
if path.suffix == ".tsx":
|
||||
config = _TSX_CONFIG
|
||||
elif path.suffix == ".ts":
|
||||
elif path.suffix in (".ts", ".mts", ".cts"):
|
||||
config = _TS_CONFIG
|
||||
else:
|
||||
config = _JS_CONFIG
|
||||
@@ -9646,7 +9646,7 @@ def _parse_js_tree(path: Path):
|
||||
source = masked.encode("utf-8")
|
||||
else:
|
||||
source = path.read_bytes()
|
||||
use_ts = path.suffix in (".ts", ".tsx") or (
|
||||
use_ts = path.suffix in (".ts", ".tsx", ".mts", ".cts") or (
|
||||
path.suffix == ".vue" and vue_lang not in ("js", "jsx")
|
||||
)
|
||||
if use_ts:
|
||||
@@ -11523,7 +11523,7 @@ register_language_resolver(
|
||||
LanguageResolver("ruby_member_calls", frozenset({".rb"}), resolve_ruby_member_calls)
|
||||
)
|
||||
register_language_resolver(
|
||||
LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".js", ".jsx"}), _resolve_typescript_member_calls)
|
||||
LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"}), _resolve_typescript_member_calls)
|
||||
)
|
||||
# C++ (#1547) and ObjC (#1556) receiver-typed member-call resolution. `.h` is in
|
||||
# both suffix sets because it routes to extract_cpp or extract_objc by content; the
|
||||
@@ -15047,6 +15047,8 @@ _DISPATCH: dict[str, Any] = {
|
||||
".mjs": extract_js,
|
||||
".ts": extract_js,
|
||||
".tsx": extract_js,
|
||||
".mts": extract_js,
|
||||
".cts": extract_js,
|
||||
".go": extract_go,
|
||||
".rs": extract_rust,
|
||||
".java": extract_java,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""TypeScript module extensions (`.mts` / `.cts`) are treated as code.
|
||||
|
||||
`.mts` (ESM) and `.cts` (CommonJS) are the TypeScript counterparts of `.mjs` /
|
||||
`.cjs`. They were missing from the code-extension detection set and the JS/TS
|
||||
language maps, so `.mts` / `.cts` sources were silently skipped during a build
|
||||
(detected as non-code and never handed to the JS/TS extractor). These are
|
||||
regression locks for the four extension sets plus an end-to-end extraction proving
|
||||
the files route to the same grammar as `.ts`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _labels(r):
|
||||
return [n["label"] for n in r["nodes"]]
|
||||
|
||||
|
||||
def test_mts_cts_registered_as_code():
|
||||
from graphify.detect import CODE_EXTENSIONS
|
||||
assert ".mts" in CODE_EXTENSIONS
|
||||
assert ".cts" in CODE_EXTENSIONS
|
||||
|
||||
|
||||
def test_mts_cts_in_js_language_family():
|
||||
from graphify.analyze import _LANG_FAMILY
|
||||
assert _LANG_FAMILY.get(".mts") == "js"
|
||||
assert _LANG_FAMILY.get(".cts") == "js"
|
||||
|
||||
|
||||
def test_mts_cts_in_js_resolution_sets():
|
||||
from graphify.extract import _JS_CACHE_BYPASS_SUFFIXES, _JS_RESOLVE_EXTS
|
||||
assert ".mts" in _JS_RESOLVE_EXTS
|
||||
assert ".cts" in _JS_RESOLVE_EXTS
|
||||
assert ".mts" in _JS_CACHE_BYPASS_SUFFIXES
|
||||
assert ".cts" in _JS_CACHE_BYPASS_SUFFIXES
|
||||
|
||||
|
||||
# A source with TypeScript-only syntax (a `type` alias + an `interface`) — these
|
||||
# only parse under the TypeScript grammar, so they prove `.mts`/`.cts` route to it
|
||||
# (the plain JS grammar would silently drop them).
|
||||
_TS_SOURCE = (
|
||||
"export type Mode = 'a' | 'b';\n"
|
||||
"export interface Options { mode: Mode; retries: number; }\n"
|
||||
"export function greet(name: string): string { return `hi ${name}`; }\n"
|
||||
"export class Widget { render(): void {} }\n"
|
||||
)
|
||||
|
||||
|
||||
def _extract(tmp_path: Path, ext: str):
|
||||
from graphify.extract import extract_js
|
||||
f = tmp_path / f"widget{ext}"
|
||||
f.write_text(_TS_SOURCE, encoding="utf-8")
|
||||
return extract_js(f)
|
||||
|
||||
|
||||
def test_mts_uses_the_typescript_grammar(tmp_path):
|
||||
# `.mts` must parse identically to `.ts` — same node set, including the TS-only
|
||||
# `type`/`interface` declarations that the plain JS grammar cannot see.
|
||||
mts = set(_labels(_extract(tmp_path, ".mts")))
|
||||
ts = set(_labels(_extract(tmp_path, ".ts")))
|
||||
assert "error" not in _extract(tmp_path, ".mts")
|
||||
assert any("Mode" in label for label in mts), "TS `type` alias missing → .mts fell back to the JS grammar"
|
||||
assert any("Options" in label for label in mts), "TS `interface` missing → .mts fell back to the JS grammar"
|
||||
# Full parity with the .ts equivalent (module the differing file-node label).
|
||||
assert {label for label in mts if not label.endswith(".mts")} == {
|
||||
label for label in ts if not label.endswith(".ts")
|
||||
}
|
||||
|
||||
|
||||
def test_cts_uses_the_typescript_grammar(tmp_path):
|
||||
cts = set(_labels(_extract(tmp_path, ".cts")))
|
||||
ts = set(_labels(_extract(tmp_path, ".ts")))
|
||||
assert any("Mode" in label for label in cts), "TS `type` alias missing → .cts fell back to the JS grammar"
|
||||
assert any("Options" in label for label in cts), "TS `interface` missing → .cts fell back to the JS grammar"
|
||||
assert {label for label in cts if not label.endswith(".cts")} == {
|
||||
label for label in ts if not label.endswith(".ts")
|
||||
}
|
||||
|
||||
|
||||
def test_mts_cts_route_to_extract_js():
|
||||
from graphify.extract import _DISPATCH, extract_js
|
||||
assert _DISPATCH.get(".mts") is extract_js
|
||||
assert _DISPATCH.get(".cts") is extract_js
|
||||
Reference in New Issue
Block a user