From 1cb56d285b9fe4819ec9fe325ddb1138123b8b5d Mon Sep 17 00:00:00 2001 From: Kookwater Date: Wed, 15 Jul 2026 10:42:35 +0200 Subject: [PATCH] fix: treat .cjs (explicit CommonJS) as a code extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .cjs was half-registered: the language maps in build.py and extract.py already routed it to the JS grammar, but it was missing from CODE_EXTENSIONS, the extractor _DISPATCH, _LANG_FAMILY, and the JS resolution/cache suffix sets — so .cjs sources (Electron main/preload scripts, CommonJS escape hatches in "type": "module" packages) were silently skipped during a build: classified as non-code and never handed to the JS extractor. Add .cjs alongside .mjs in the six lists that gate/route JS files (detect.py, extract.py _DISPATCH, analyze.py _LANG_FAMILY, extractors/models.py cache-bypass, extractors/resolution.py resolve exts, cli.py _HOOK_SOURCE_EXTS), with regression locks mirroring the .mts/.cts fix (#1607). Real-world impact: an Electron app whose ~2000-line main.cjs backend was invisible went from 201 to 294 nodes and 256 to 441 edges on rebuild. Co-Authored-By: Claude Opus 4.8 --- graphify/analyze.py | 2 +- graphify/cli.py | 2 +- graphify/detect.py | 2 +- graphify/extract.py | 1 + graphify/extractors/models.py | 2 +- graphify/extractors/resolution.py | 2 +- tests/test_cjs_module_extension.py | 90 ++++++++++++++++++++++++++++++ 7 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 tests/test_cjs_module_extension.py diff --git a/graphify/analyze.py b/graphify/analyze.py index 7f3eb72ff..cb6c76484 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -23,7 +23,7 @@ # 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", ".mts", ".cts", ".vue", ".svelte")}, + **{e: "js" for e in (".js", ".jsx", ".mjs", ".cjs", ".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")}, diff --git a/graphify/cli.py b/graphify/cli.py index 3c1ae0e51..c094c8f6c 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -38,7 +38,7 @@ } }, ensure_ascii=False, separators=(",", ":")) + "\n" _HOOK_SOURCE_EXTS = ( - '.py', '.js', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go', + '.py', '.js', '.cjs', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go', '.rs', '.java', '.rb', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.kt', '.swift', '.php', '.scala', '.lua', '.sh', '.md', '.rst', '.txt', '.mdx', ) diff --git a/graphify/detect.py b/graphify/detect.py index bc73fbde7..9f16791ea 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -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', '.rake', '.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', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.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', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 0343a5673..12a4f108e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3838,6 +3838,7 @@ def add_existing_edge(edge: dict) -> None: ".js": extract_js, ".jsx": extract_js, ".mjs": extract_js, + ".cjs": extract_js, ".ts": extract_js, ".tsx": extract_js, ".mts": extract_js, diff --git a/graphify/extractors/models.py b/graphify/extractors/models.py index 56dbbc0dd..e6da5173e 100644 --- a/graphify/extractors/models.py +++ b/graphify/extractors/models.py @@ -8,7 +8,7 @@ _WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {} -_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte"} +_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte"} @dataclass class LanguageConfig: diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index d46b4cbcc..9736cccef 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -21,7 +21,7 @@ _WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json") -_JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs") +_JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs") _JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs") diff --git a/tests/test_cjs_module_extension.py b/tests/test_cjs_module_extension.py new file mode 100644 index 000000000..f94f962ff --- /dev/null +++ b/tests/test_cjs_module_extension.py @@ -0,0 +1,90 @@ +"""CommonJS module extension (`.cjs`) is treated as code. + +`.cjs` is the explicit-CommonJS counterpart of `.mjs` (used pervasively in +Electron main/preload scripts and in `"type": "module"` packages that need a +CommonJS escape hatch). The language maps in `build.py` and `extract.py` +already routed `.cjs` to the JS grammar, but the extension was missing from +`CODE_EXTENSIONS`, the extractor `_DISPATCH`, `_LANG_FAMILY`, and the JS +resolution/cache sets — so `.cjs` sources were silently skipped during a build +(detected as non-code and never handed to the JS extractor). These are +regression locks for the extension sets plus an end-to-end extraction proving +`.cjs` parses identically to `.js`. + +Same shape of gap (and fix) as `.mts`/`.cts` in +tests/test_typescript_module_extensions.py. +""" +from __future__ import annotations + +from pathlib import Path + + +def _labels(r): + return [n["label"] for n in r["nodes"]] + + +def test_cjs_registered_as_code(): + from graphify.detect import CODE_EXTENSIONS + assert ".cjs" in CODE_EXTENSIONS + + +def test_cjs_in_extractor_dispatch(): + from graphify.extract import _DISPATCH, extract_js + assert _DISPATCH.get(".cjs") is extract_js + + +def test_cjs_in_js_language_family(): + from graphify.analyze import _LANG_FAMILY + assert _LANG_FAMILY.get(".cjs") == "js" + + +def test_cjs_in_js_resolution_sets(): + from graphify.extract import _JS_CACHE_BYPASS_SUFFIXES, _JS_RESOLVE_EXTS + assert ".cjs" in _JS_RESOLVE_EXTS + assert ".cjs" in _JS_CACHE_BYPASS_SUFFIXES + + +def test_cjs_in_hook_source_exts(): + from graphify.cli import _HOOK_SOURCE_EXTS + assert ".cjs" in _HOOK_SOURCE_EXTS + + +# A representative CommonJS source: require() imports, a class, a function, and +# module.exports — the shape of an Electron main-process script. +_CJS_SOURCE = ( + "const path = require('path');\n" + "const { app, BrowserWindow } = require('electron');\n" + "class WindowManager {\n" + " open() { return new BrowserWindow(); }\n" + "}\n" + "function createWindow() {\n" + " const manager = new WindowManager();\n" + " return manager.open();\n" + "}\n" + "module.exports = { createWindow };\n" +) + + +def _extract(tmp_path: Path, ext: str): + from graphify.extract import extract_js + f = tmp_path / f"main{ext}" + f.write_text(_CJS_SOURCE, encoding="utf-8") + return extract_js(f) + + +def test_cjs_extracts_like_js(tmp_path): + # `.cjs` must parse identically to the same source saved as `.js` — same + # node set (require() imports, class, function) modulo the file-node label. + cjs = _extract(tmp_path, ".cjs") + js = _extract(tmp_path, ".js") + assert "error" not in cjs + cjs_labels = set(_labels(cjs)) + js_labels = set(_labels(js)) + assert any("WindowManager" in label for label in cjs_labels), ( + ".cjs class declaration missing — file was not parsed as JS" + ) + assert any("createWindow" in label for label in cjs_labels), ( + ".cjs function declaration missing — file was not parsed as JS" + ) + assert {label for label in cjs_labels if not label.endswith(".cjs")} == { + label for label in js_labels if not label.endswith(".js") + }