From 2f85e34ff51b416c685ad7a536a9be8fe22e259a Mon Sep 17 00:00:00 2001 From: johnhldiver2 Date: Sun, 25 Oct 2026 11:28:38 +0000 Subject: [PATCH] fix: unify export-status detection and fix ambient export handling (#1721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export status — "is this name exported from this file" — was answered in three places that shared no code and disagreed: the AST predicate on the TS/JS extractors (exact for declaration forms, blind to everything else), a hand-mirrored regex that existed only because of that blindness and had one call site, and a coarser per-file check in the resolver. src/extraction/export-status.ts is now the single answer, mirrored for the native walker by codegraph-kernel/src/tsjs/export_status.rs. It closes the blind spots end to end: later `export { x }` / `export default x`, every CommonJS form (module.exports, exports.x, module.exports.x and the bracket spelling exports["x"]), and module.exports object literals. The scan is comment- and string-aware, so a commented-out or quoted export is not one. The resolver no longer treats a declaration exported only as a module's default as a named export, so `import { Widget } from './x'` stays unresolved when x.ts writes `class Widget {}` + `export default Widget`. Adds __tests__/export-status.test.ts, including a kernel<->wasm parity gate so the Rust mirror cannot drift from the source of truth. Co-authored-by: Perch Made-with: Perch --- CHANGELOG.md | 2 + __tests__/export-status.test.ts | 212 ++++++++ codegraph-kernel/src/tsjs/export_status.rs | 371 ++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 56 +-- codegraph-kernel/src/tsjs/mod.rs | 74 ++- src/extraction/export-status.ts | 543 +++++++++++++++++++++ src/extraction/languages/javascript.ts | 7 +- src/extraction/languages/typescript.ts | 11 +- src/extraction/tree-sitter.ts | 47 +- src/resolution/import-resolver.ts | 43 ++ src/resolution/name-matcher.ts | 10 +- 11 files changed, 1288 insertions(+), 88 deletions(-) create mode 100644 __tests__/export-status.test.ts create mode 100644 codegraph-kernel/src/tsjs/export_status.rs create mode 100644 src/extraction/export-status.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17022e6343..f7f8791cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -361,6 +361,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. +- In JavaScript and TypeScript, a declaration exported by a statement other than the one that declares it is now marked exported everywhere, not just for store extraction: `const x = …` followed by `export { x }` or `export default x`, and every CommonJS form — `module.exports = { x }`, `exports.x = …`, `module.exports.x = …` and the bracket spelling `exports[\"x\"] = …`. Before, a CommonJS module's exports were invisible to the graph, `export { x }` was seen only where a store's actions were extracted, and the same question was answered by three separate pieces of code that disagreed. A commented-out or quoted `export { x }` is still not an export. Re-index JavaScript and TypeScript projects after upgrading. (#1721) +- In JavaScript and TypeScript, a class or function a module exports only as its default, like `class Widget {}` followed by `export default Widget`, is no longer treated as a named export, so `import { Widget } from './x'` no longer links to it. Before, that import resolved where a full index leaves it unresolved, so a sync that dropped the module's default kept the link. (#1721) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/export-status.test.ts b/__tests__/export-status.test.ts new file mode 100644 index 0000000000..a90fdd7cea --- /dev/null +++ b/__tests__/export-status.test.ts @@ -0,0 +1,212 @@ +/** + * Export status is answered in ONE place (#1721). + * + * Three implementations used to answer "is this name exported from this file", + * none sharing code, and they disagreed: + * + * 1. `isExported` — the AST predicate on the TS/JS extractors, walking the + * parent chain for an `export_statement` ancestor. Exact for + * `export function f`, blind to everything else: `const x = …; export { + * x }` and every CommonJS form left it false. + * 2. `isExportedLater` — a regex over the file source, existing only because + * of that blindness, hand-mirrored in the Rust kernel. + * 3. A third per-file check in the resolver (`isSealedModule`). + * + * The visible damage: a CommonJS module's exports were invisible to the graph + * as exports, `export { x }` was visible only to store extraction, and the + * resolver re-derived a coarser version of the same fact from the same text. + * + * `src/extraction/export-status.ts` is now the single answer, and + * `codegraph-kernel/src/tsjs/export_status.rs` mirrors it for the native + * walker. This file covers the blind spots end to end — through extraction, on + * BOTH paths — so the mirror cannot drift from the source of truth. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import { + fileExportsAnything, + isCommonJsExportedName, + isEsModuleSource, + isExportedLaterInSource, + isSealedModuleSource, + exportedNamesInSource, + commonJsExportNameFromAssignment, + commonJsObjectExportNames, +} from '../src/extraction/export-status'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelBuilt = fs.existsSync(KERNEL_PATH); + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +/** The blind cases: every form the AST parent-walk cannot see. */ +const BLIND_CASES: [label: string, source: string, file: string, language: Language][] = [ + ['later export { x }', 'const x = 1;\nexport { x };', 'src/probe.js', 'javascript'], + ['export default x', 'const x = 1;\nexport default x;', 'src/probe.js', 'javascript'], + ['module.exports = { x }', 'const x = 1;\nmodule.exports = { x };', 'src/probe.js', 'javascript'], + ['exports.x = x', 'const x = 1;\nexports.x = x;', 'src/probe.js', 'javascript'], + ['exports["x"] = x', 'const x = 1;\nexports["x"] = x;', 'src/probe.js', 'javascript'], + ['ts: later export { x }', 'const x = 1;\nexport { x };', 'src/probe.ts', 'typescript'], + ['ts: export default x', 'const x = 1;\nexport default x;', 'src/probe.ts', 'typescript'], +]; + +/** The declaration forms the AST walk is already exact on — must stay exported. */ +const DECLARED_CASES: [label: string, source: string, file: string, language: Language][] = [ + ['export function f', 'export function f() {}', 'src/probe.js', 'javascript'], + ['export const x', 'export const x = 1;', 'src/probe.js', 'javascript'], + ['exports.fn arrow', 'exports.getItems = async (req, res) => {};', 'src/probe.js', 'javascript'], +]; + +const exportedFlags = (result: ExtractionResult) => + result.nodes.filter((n) => n.kind !== 'file').map((n) => `${n.kind}:${n.name}=${n.isExported}`); + +describe('export-status source scan', () => { + it('reads every ESM and CommonJS export form', () => { + expect(isExportedLaterInSource('const x = 1;\nexport { x };', 'javascript', 'x')).toBe(true); + expect(isExportedLaterInSource('const x = 1;\nexport default x;', 'javascript', 'x')).toBe(true); + expect(isExportedLaterInSource('const x = 1;\nexport { x as default };', 'javascript', 'x')).toBe(true); + expect(isCommonJsExportedName('const x = 1;\nexports.x = x;', 'x')).toBe(true); + expect(isCommonJsExportedName('module.exports.x = 1;', 'x')).toBe(true); + expect(isCommonJsExportedName('exports["x"] = 1;', 'x')).toBe(true); + expect(isCommonJsExportedName('const x = 1;\nmodule.exports = { x };', 'x')).toBe(true); + expect(isCommonJsExportedName('module.exports = { x: impl };', 'x')).toBe(true); + expect(isCommonJsExportedName("module.exports = { 'x': impl };", 'x')).toBe(true); + }); + + it('does not read a name that is merely mentioned', () => { + expect(isExportedLaterInSource('const x = 1;\nexport { y };', 'javascript', 'x')).toBe(false); + expect(isExportedLaterInSource('const x = 1;\nexport { items$ };', 'javascript', 'items')).toBe(false); + expect(isCommonJsExportedName('exports.y = 1;', 'x')).toBe(false); + expect(isCommonJsExportedName('module.exports = { y };', 'x')).toBe(false); + }); + + it('is not fooled by a comment or a string', () => { + expect(fileExportsAnything('// export { x }\nconst x = 1;', 'javascript')).toBe(false); + expect(fileExportsAnything("const s = 'export { x }';\nconst x = 1;", 'javascript')).toBe(false); + expect(isExportedLaterInSource('// export { x }\nconst x = 1;', 'javascript', 'x')).toBe(false); + }); + + it('answers the per-file questions the resolver asks', () => { + expect(fileExportsAnything('export const x = 1;', 'javascript')).toBe(true); + expect(fileExportsAnything('module.exports = { x };', 'javascript')).toBe(true); + expect(fileExportsAnything('declare global { interface Window {} }', 'typescript')).toBe(true); + expect(fileExportsAnything('const x = 1;', 'javascript')).toBe(false); + + // A module that imports and exports nothing is sealed; a classic script is not. + expect(isSealedModuleSource("import { a } from './a';\nconst x = 1;", 'javascript')).toBe(true); + expect(isSealedModuleSource('const x = 1;\nwindow.x = x;', 'javascript')).toBe(false); + // CommonJS is exempt — `module.exports` counts as an export. + expect(isSealedModuleSource("const a = require('./a');\nmodule.exports = { a };", 'javascript')).toBe(false); + // Other languages have no equivalent boundary. + expect(isSealedModuleSource('import os\nx = 1', 'python')).toBe(false); + + expect(isEsModuleSource('export const x = 1;', 'javascript')).toBe(true); + expect(isEsModuleSource('module.exports = { x };', 'javascript')).toBe(false); + }); + + it('reads a CommonJS assignment name from either the dot or the bracket form', () => { + expect(commonJsExportNameFromAssignment('exports.x')).toBe('x'); + expect(commonJsExportNameFromAssignment('module.exports.x')).toBe('x'); + expect(commonJsExportNameFromAssignment('exports["x"]')).toBe('x'); + expect(commonJsExportNameFromAssignment("exports['x']")).toBe('x'); + expect(commonJsExportNameFromAssignment('other.x')).toBe(null); + expect(commonJsExportNameFromAssignment('exports.x.y')).toBe(null); + }); + + it('lists object-literal export names, skipping a spread it cannot know', () => { + expect(commonJsObjectExportNames('{ x, y: impl, ...rest }')).toEqual(['x', 'y']); + expect(commonJsObjectExportNames('notAnObject')).toBe(null); + }); + + it('collects every exported name, including both sides of an alias', () => { + const names = exportedNamesInSource( + 'const a = 1;\nexport { a, b as c };\nexport const d = 2;', + 'javascript' + ); + // `b as c` contributes BOTH: `b` is the local binding and `c` the exported + // name, and a caller asking "is this name exported" may mean either. + expect([...names].sort()).toEqual(['a', 'b', 'c', 'd']); + }); +}); + +describe('export status through extraction', () => { + it.each([...DECLARED_CASES, ...BLIND_CASES])( + '%s: the declaration is exported', + (_label, source, file, language) => { + const result = extractFromSource(file, source, language); + const flags = exportedFlags(result); + expect(flags.length, `no node extracted for ${file}`).toBeGreaterThan(0); + for (const flag of flags) expect(flag, `${file}: ${flag}`).toMatch(/=true$/); + } + ); + + it('names a CommonJS function export after its property, not the file', () => { + const result = extractFromSource('src/probe.js', 'exports.getItems = async (req, res) => {};'); + const fn = result.nodes.find((n) => n.kind === 'function'); + expect(fn?.name).toBe('getItems'); + expect(fn?.isExported).toBe(true); + }); +}); + +/** + * The drift guard for the Rust mirror. The kernel answers export status from + * its own port of the same scan (codegraph-kernel/src/tsjs/export_status.rs); + * if the two disagree on any form, the graph would depend on which path + * extracted the file. Compares the whole ExtractionResult, canonicalized. + */ +describe.skipIf(!kernelBuilt)('kernel↔wasm export-status parity', () => { + const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + let savedEnv: Record; + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + const canon = (result: ExtractionResult) => ({ + nodes: result.nodes + .map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())) + .sort(), + edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(), + refs: result.unresolvedReferences.map((r) => JSON.stringify(r, Object.keys(r).sort())).sort(), + }); + + it.each([...DECLARED_CASES, ...BLIND_CASES])( + '%s: the kernel and the wasm path agree', + (_label, source, file, language) => { + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + delete process.env.CODEGRAPH_KERNEL; + const viaKernel = tryKernelExtract(file, source, language); + expect(viaKernel, `kernel extraction failed for ${file}`).not.toBeNull(); + + process.env.CODEGRAPH_KERNEL = '0'; + const viaWasm = extractFromSource(file, source, language); + delete process.env.CODEGRAPH_KERNEL; + + expect(canon(viaKernel!)).toEqual(canon(viaWasm)); + } + ); +}); diff --git a/codegraph-kernel/src/tsjs/export_status.rs b/codegraph-kernel/src/tsjs/export_status.rs new file mode 100644 index 0000000000..61769964f8 --- /dev/null +++ b/codegraph-kernel/src/tsjs/export_status.rs @@ -0,0 +1,371 @@ +//! Export status — the Rust mirror of `src/extraction/export-status.ts` (#1721). +//! +//! The TS module owns the one implementation of "is this name exported from +//! this file". This mirrors it for the native kernel, which has no syntax tree +//! at the points that ask the per-file question and decodes its own tables. +//! Every function notes the TS function it mirrors — change one side, change +//! the other, or the parity gate (`__tests__/kernel-tsjs-parity.test.ts`) fails. +//! +//! The forms a source scan recognizes, and why the AST walk is not enough, are +//! documented at length in the TS module; this file keeps the same shapes so +//! the two can be read side by side. + +/// `IDENTIFIER` in export-status.ts: a plain ASCII identifier. +pub(super) fn is_identifier(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +/// Mirrors `commentStrippedJsSource`: C-style comments blanked, string literals +/// left INTACT — what the CommonJS name scan reads, since the bracket form puts +/// the exported name inside a string (`exports["x"] = …`). +/// +/// Offsets are preserved (blanked characters are replaced by spaces, newlines +/// kept), matching the TS helper, so a match index still maps to its line. +pub(super) fn comment_stripped_js_source(source: &str) -> String { + let bytes = source.as_bytes(); + let mut out: Vec = bytes.to_vec(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i]; + let c2 = bytes.get(i + 1).copied().unwrap_or(0); + if c == b'/' && c2 == b'*' { + let start = i; + i += 2; + while i < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) { + i += 1; + } + if i < bytes.len() { + i += 2; + } + blank_range(&mut out, start, i, bytes); + continue; + } + if c == b'/' && c2 == b'/' { + let start = i; + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + blank_range(&mut out, start, i, bytes); + continue; + } + // A string literal is skipped whole (so a `//` inside it is data), but + // its characters are left as they are. + if c == b'"' || c == b'\'' || c == b'`' { + let quote = c; + i += 1; + while i < bytes.len() && bytes[i] != quote { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 2; + continue; + } + if quote != b'`' && bytes[i] == b'\n' { + break; + } + i += 1; + } + if i < bytes.len() && bytes[i] == quote { + i += 1; + } + continue; + } + i += 1; + } + // Every byte written is ASCII or a preserved UTF-8 sequence, so this is + // valid UTF-8: only ASCII bytes are ever replaced, and multi-byte + // characters contain no ASCII byte. + String::from_utf8(out).unwrap_or_else(|_| source.to_string()) +} + +/// Mirrors `maskedJsSource`: comments blanked AND string contents blanked, so a +/// regex over the result cannot match text that only looks like an export. +pub(super) fn masked_js_source(source: &str) -> String { + let stripped = comment_stripped_js_source(source); + let bytes = stripped.as_bytes(); + let mut out: Vec = bytes.to_vec(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i]; + if c == b'"' || c == b'\'' || c == b'`' { + let quote = c; + i += 1; + while i < bytes.len() && bytes[i] != quote { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + out[i] = b' '; + out[i + 1] = b' '; + i += 2; + continue; + } + if quote != b'`' && bytes[i] == b'\n' { + break; + } + if bytes[i] != b'\n' { + out[i] = b' '; + } + i += 1; + } + if i < bytes.len() && bytes[i] == quote { + i += 1; + } + continue; + } + i += 1; + } + String::from_utf8(out).unwrap_or_else(|_| stripped) +} + +/// Replace every byte in `start..end` with a space, keeping newlines. +fn blank_range(out: &mut [u8], start: usize, end: usize, src: &[u8]) { + for i in start..end.min(src.len()) { + if src[i] != b'\n' { + out[i] = b' '; + } + } +} + +/// Mirrors `isExportedLaterInSource`: whether `name` is exported by a LATER +/// statement than its declaration — `export default NAME`, `export { NAME }`, +/// `export { NAME as default }`. +/// +/// The name must be a specifier the clause exports AS ITSELF or AS THE DEFAULT: +/// `export { A as B }` exports `B`, and `A` is only the local source of it, so +/// a caller asking after `A` must not be told yes (the ambient-module case). +/// `A as default` does count — the binding is the module's default. +pub(super) fn is_exported_later_in_source(source: &str, name: &str) -> bool { + if !is_identifier(name) { + return false; + } + let n = regex::escape(name); + // `R` (CRLF mode): JS's multiline `^`/`$` treat `\r` as a line end as well + // as `\n`; `(?m)` alone sees only `\n`, and would miss `export default + // NAME;\r\n` on every Windows autocrlf checkout. + let default_form = format!(r"(?mR)^[ \t]*export\s+default\s+{n}\s*;?[ \t]*$", n = n); + if regex::Regex::new(&default_form) + .map(|re| re.is_match(source)) + .unwrap_or(false) + { + return true; + } + let Ok(clauses) = regex::Regex::new(r"(?mR)^[ \t]*export\s+(?:type\s+)?\{([^}]*)\}") else { + return false; + }; + for cap in clauses.captures_iter(source) { + let Some(body) = cap.get(1) else { continue }; + for raw in body.as_str().split(',') { + let specifier = raw.trim().trim_start_matches("type ").trim(); + if specifier.is_empty() { + continue; + } + if let Some((local, exported)) = specifier.split_once(" as ") { + let local = local.trim(); + let exported = exported.trim(); + if exported == name || (exported == "default" && local == name) { + return true; + } + continue; + } + if specifier == name { + return true; + } + } + } + false +} + +/// Mirrors `isCommonJsExportedName`: whether a name is exported by a CommonJS +/// assignment anywhere in the file — `exports.NAME = …`, +/// `module.exports.NAME = …`, `exports["NAME"] = …`, or a +/// `module.exports = { NAME }` object literal. +pub(super) fn is_common_js_exported_name(source: &str, name: &str) -> bool { + if !is_identifier(name) { + return false; + } + let n = regex::escape(name); + let assignment = format!( + r#"\b(?:module\.exports|exports)\s*(?:\.|\[)\s*['"]?{n}['"]?\s*\]?\s*="#, + n = n + ); + if regex::Regex::new(&assignment) + .map(|re| re.is_match(source)) + .unwrap_or(false) + { + return true; + } + let Ok(object) = regex::Regex::new(r"\bmodule\.exports\s*=\s*(\{[\s\S]*?\})\s*;?") else { + return false; + }; + for cap in object.captures_iter(source) { + let Some(rhs) = cap.get(1) else { continue }; + if let Some(names) = common_js_object_export_names(rhs.as_str()) { + if names.iter().any(|x| x == name) { + return true; + } + } + } + false +} + +/// Mirrors `commonJsObjectExportNames`: the names a `module.exports = { … }` +/// object literal exports, in shorthand (`{ x }`), keyed (`{ x: impl }`) and +/// string-keyed (`{ 'x': impl }`) form. None when the right-hand side is not an +/// object literal. +pub(super) fn common_js_object_export_names(right: &str) -> Option> { + let text = right.trim(); + if !text.starts_with('{') || !text.ends_with('}') { + return None; + } + let body = &text[1..text.len() - 1]; + let mut names = Vec::new(); + for raw_entry in split_top_level(body) { + let entry = raw_entry.trim(); + if entry.is_empty() { + continue; + } + // `...spread` contributes names this scan cannot know — skip it rather + // than guess one. + if entry.starts_with("...") { + continue; + } + match top_level_colon(entry) { + None => { + // Shorthand `{ x }` — the name is the binding. + let name = entry.trim_start_matches("...").trim(); + if is_identifier(name) { + names.push(name.to_string()); + } + } + Some(colon) => { + let key = entry[..colon].trim(); + if let Some(inner) = quoted_identifier(key) { + names.push(inner); + } else if is_identifier(key) { + names.push(key.to_string()); + } + } + } + } + Some(names) +} + +/// Mirrors `commonJsExportNameFromAssignment`: the property a CommonJS export +/// assignment binds a value to — `exports.NAME = …`, `module.exports.NAME = …`, +/// `exports["NAME"] = …` — or None for any other assignment. +/// +/// The bracket form's quotes must match, which this crate's regex cannot +/// express (no backreferences), so the quoting is stripped by hand. +pub(super) fn common_js_export_name_from_assignment(left: &str) -> Option { + let text = left.trim(); + let Ok(dot) = regex::Regex::new(r"^(?:module\.exports|exports)\.([A-Za-z_$][\w$]*)$") else { + return None; + }; + if let Some(cap) = dot.captures(text) { + return cap.get(1).map(|m| m.as_str().to_string()); + } + let head = ["module.exports", "exports"] + .iter() + .find_map(|prefix| text.strip_prefix(prefix))?; + let inner = head.trim_start().strip_prefix('[')?.trim_end().strip_suffix(']')?; + let inner = inner.trim(); + let unquoted = inner + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))?; + let unquoted = unquoted.trim(); + if is_identifier(unquoted) { + Some(unquoted.to_string()) + } else { + None + } +} + +/// `'x'` / `"x"` → `x`, for an object-literal key. None when not a quoted +/// identifier. +fn quoted_identifier(key: &str) -> Option { + let inner = key + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .or_else(|| key.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))?; + if is_identifier(inner) { + Some(inner.to_string()) + } else { + None + } +} + +/// Split an object-literal body on top-level commas (not inside +/// braces/brackets/parens/strings). Mirrors `splitTopLevel`. +/// +/// Byte iteration is safe here: every character this looks at is ASCII, and a +/// UTF-8 continuation byte is never one of them, so the slices land on +/// character boundaries. +fn split_top_level(body: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth: i32 = 0; + let mut start = 0usize; + let mut quote: Option = None; + let bytes = body.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i]; + if let Some(q) = quote { + if c == b'\\' { + i += 2; + continue; + } + if c == q { + quote = None; + } + i += 1; + continue; + } + match c { + b'"' | b'\'' | b'`' => quote = Some(c), + b'{' | b'[' | b'(' => depth += 1, + b'}' | b']' | b')' => depth -= 1, + b',' if depth == 0 => { + parts.push(&body[start..i]); + start = i + 1; + } + _ => {} + } + i += 1; + } + parts.push(&body[start..]); + parts +} + +/// The index of the top-level `:` in an object-literal entry, or None. +/// Mirrors `topLevelColon`. +fn top_level_colon(entry: &str) -> Option { + let mut depth: i32 = 0; + let mut quote: Option = None; + let bytes = entry.as_bytes(); + let mut i = 0usize; + while i < bytes.len() { + let c = bytes[i]; + if let Some(q) = quote { + if c == b'\\' { + i += 2; + continue; + } + if c == q { + quote = None; + } + i += 1; + continue; + } + match c { + b'"' | b'\'' | b'`' => quote = Some(c), + b'{' | b'[' | b'(' => depth += 1, + b'}' | b']' | b')' => depth -= 1, + b':' if depth == 0 => return Some(i), + _ => {} + } + i += 1; + } + None +} diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index f10c5a5073..c3230d3d94 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -141,24 +141,21 @@ impl<'t> Walker<'t> { /// `exports.NAME = ` / `module.exports.NAME = ` — or None for /// any other assignment. The node must be the assignment's whole /// right-hand side. Mirrors TreeSitterExtractor.commonJsExportName. + /// + /// The dot form (`exports.x`) is a member_expression; the bracket form + /// (`exports["x"]`) is a subscript_expression. Both are the same export and + /// both name the value — the shared helper reads the property out of + /// either (#1721). fn common_js_export_name(&self, assignment: Node<'t>, value: Node<'t>) -> Option { let right = assignment.child_by_field_name("right")?; if right.start_byte() != value.start_byte() || right.end_byte() != value.end_byte() { return None; } let left = assignment.child_by_field_name("left")?; - if left.kind() != "member_expression" { + if !matches!(left.kind(), "member_expression" | "subscript_expression") { return None; } - let object = left.child_by_field_name("object")?; - let property = left.child_by_field_name("property")?; - if property.kind() != "property_identifier" { - return None; - } - if !matches!(self.text(object), "exports" | "module.exports") { - return None; - } - Some(self.text(property).to_string()) + super::export_status::common_js_export_name_from_assignment(self.text(left)) } // --- reactComponentHoc / extractReactComponentNode (#841) -------------------- @@ -576,36 +573,6 @@ impl<'t> Walker<'t> { // --- extractVariable (TS/JS branch) ------------------------------------------------ - /// A top-level binding exported by a LATER statement rather than at its - /// declaration: `export default NAME`, `export { NAME }`, `export { NAME as - /// default }`. The declaration's own `is_exported` (an `export_statement` - /// ancestor) cannot see these. One anchored regex over the file source. - /// Mirrors TreeSitterExtractor.isExportedLater. - pub(super) fn is_exported_later(&self, name: &str) -> bool { - if name.is_empty() - || !name.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_' || c == '$').unwrap_or(false) - || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') - { - return false; - } - let n = regex::escape(name); - // `R` (CRLF mode): JS's multiline `^`/`$` treat `\r` as a line end as - // well as `\n`; `(?m)` alone sees only `\n`, and would miss `export - // default NAME;\r\n` on every Windows autocrlf checkout. - // Inside `{ … }` the name is bounded by a character that can't - // continue an identifier, not by `\b`, which takes a `$` for a - // separator. The crate has no look-around, so the bounds consume. - // ASCII classes, not `\w`: this crate's `\w` is Unicode, JS's is not. - let pattern = format!( - r"(?mR)^[ \t]*export\s+(?:default\s+{n}\s*;?[ \t]*$|\{{(?:[^}}]*[^0-9A-Za-z_$}}])?{n}(?:[^0-9A-Za-z_$}}][^}}]*)?\}})", - n = n - ); - match regex::Regex::new(&pattern) { - Ok(re) => re.is_match(self.src), - Err(_) => false, - } - } - /// Returns the initializers it walked under the symbol they declare — a /// constant's or a variable's (#693), a component wrapper's — whose fn-ref /// candidates that walk already took for the symbol (the caller's scan of @@ -711,11 +678,12 @@ impl<'t> Walker<'t> { .map(|o| self.object_has_inline_functions(o)) .unwrap_or(false); // "Exported" includes the two-statement form `const useStore = - // create(…)` … `export default useStore` (is_exported_later), the - // shape most React Native stores are written in. Mirrors - // TreeSitterExtractor.isExportedLater. + // create(…)` … `export default useStore`, which is_exported now + // sees directly (the shared export-status scan, #1721), the shape + // most React Native stores are written in. Mirrors + // TreeSitterExtractor.extractVariable. let extract_object_methods = - (is_exported || self.is_exported_later(&name)) && object_of_fns.is_some() && has_inline_fns; + is_exported && object_of_fns.is_some() && has_inline_fns; let rtk_endpoints = match value { Some(v) if v.kind() == "call_expression" => self.find_rtk_endpoints_object(v), diff --git a/codegraph-kernel/src/tsjs/mod.rs b/codegraph-kernel/src/tsjs/mod.rs index cd53f3255b..be8a8ec808 100644 --- a/codegraph-kernel/src/tsjs/mod.rs +++ b/codegraph-kernel/src/tsjs/mod.rs @@ -9,6 +9,7 @@ //! parity gate fails. Positions are emitted in UTF-16 code units (what //! web-tree-sitter reports), see util::col16. +mod export_status; mod extractors; mod fnref; use crate::textutil as util; @@ -21,6 +22,7 @@ use crate::buffers::{ use crate::ids; use crate::langs; use std::collections::{HashMap, HashSet}; +use std::cell::OnceCell; use tree_sitter::{Node, Parser}; #[derive(Clone, Copy, PartialEq, Eq)] @@ -217,6 +219,11 @@ pub struct Walker<'t> { object_path_owners: HashMap, /// ownsObjectLiterals: false for a generated or minified bundle (#2300). owns_objects: bool, + /// The comment-stripped and fully masked sources the export-status scan + /// reads (#1721), computed on first use. The TS side memoizes the same two + /// forms; a file with no blind-form declaration never pays for the mask. + masked_src: OnceCell, + stripped_src: OnceCell, } const MAX_VALUE_REF_NODES: usize = 20_000; @@ -268,6 +275,8 @@ pub fn extract(file_path: &str, source: &str, language: &str) -> Result`, endLine = @@ -1054,7 +1063,9 @@ impl<'t> Walker<'t> { } /// isExported: walk the parent chain for an export_statement, then ask - /// whether a `declare module '…'` / `declare global` body exports it. + /// whether a `declare module '…'` / `declare global` body exports it, then + /// fall back to the shared source scan for the forms the walk is blind to + /// (#1721). Mirrors typescriptExtractor/javascriptExtractor.isExported. fn is_exported(&self, node: Node) -> bool { let mut cur = node.parent(); while let Some(p) = cur { @@ -1063,7 +1074,66 @@ impl<'t> Walker<'t> { } cur = p.parent(); } - is_ambient_export(node) + if is_ambient_export(node) { + return true; + } + self.declaration_exported_by_source(node) + } + + /// Mirrors `declarationExportedBySource`: whether a declaration node is + /// exported by a form the AST walk cannot see — a later `export { … }` / + /// `export default`, or a CommonJS assignment. + /// + /// Comments and strings are blanked before the scan (see + /// `export_status::masked_js_source`): a `// export { x }` in a comment or + /// an `'export { x }'` in a string is not an export. The CommonJS bracket + /// form keeps its string (`exports["x"]`), so that scan reads the + /// comment-stripped source instead. Mirrors the TS side exactly. + fn declaration_exported_by_source(&self, node: Node) -> bool { + let names = self.declaration_names(node); + if names.is_empty() { + return false; + } + let masked = self.masked_src.get_or_init(|| export_status::masked_js_source(self.src)); + let stripped = self + .stripped_src + .get_or_init(|| export_status::comment_stripped_js_source(self.src)); + for name in names { + if export_status::is_exported_later_in_source(masked, &name) { + return true; + } + if export_status::is_common_js_exported_name(stripped, &name) { + return true; + } + } + false + } + + /// Mirrors `declarationNames`: the names a declaration node binds — one for + /// `function f` / `class C`, one per declarator for `const a = 1, b = 2`. + fn declaration_names(&self, node: Node) -> Vec { + let mut names = Vec::new(); + if matches!(node.kind(), "lexical_declaration" | "variable_declaration") { + for i in 0..node.named_child_count() { + let Some(declarator) = node.named_child(i) else { continue }; + if declarator.kind() != "variable_declarator" { + continue; + } + if let Some(name_node) = declarator.child_by_field_name("name") { + if name_node.kind() == "identifier" { + names.push(self.text(name_node).to_string()); + } + } + } + return names; + } + if let Some(name_node) = node.child_by_field_name("name") { + let text = self.text(name_node); + if export_status::is_identifier(text) { + names.push(text.to_string()); + } + } + names } fn has_keyword_child(&self, node: Node, kw: &str) -> bool { diff --git a/src/extraction/export-status.ts b/src/extraction/export-status.ts new file mode 100644 index 0000000000..092250782d --- /dev/null +++ b/src/extraction/export-status.ts @@ -0,0 +1,543 @@ +/** + * Export status — the ONE implementation of "is this name exported from this + * file" (#1721). + * + * Three places used to answer this question, none sharing code, and they + * disagreed: + * + * 1. `isExported` (the AST predicate on the TS/JS language extractors) walks + * the parent chain for an `export_statement` ancestor. Exact for + * `export function f`, blind to everything else: `const x = …; export { + * x }` and every CommonJS form left it false. + * 2. `isExportedLater` (a regex over the file source, in tree-sitter.ts and + * mirrored by hand in the Rust kernel) exists only because of that + * blindness. Its single call site was the store-extraction condition, so + * the fix it represents was scoped to Zustand stores. + * 3. A third, per-file check in the resolver (`isSealedModule`), which needs + * "does this file export anything at all" and could use neither — the AST + * predicate answers no for a CommonJS module, and the regex answers a + * per-name question. + * + * The result was that a CommonJS module's exports were invisible to the graph + * as exports, `export { x }` was visible only to store extraction, and the + * resolver re-derived a coarser version of the same fact from the same file + * text. + * + * This module is the single answer. It is deliberately SOURCE-based rather + * than AST-based, because two of the three callers (the resolver's per-file + * question, and the kernel's decoded tables, which carry no tree) have no + * syntax tree to walk — and because the forms the AST walk misses are exactly + * the ones a source scan reads cleanly. The AST walk is kept as a fast path + * for the declaration forms it is exact on (see `declaredExported`). + * + * Everything here is offset-preserving and comment/string-aware: a `// export + * { x }` in a comment or an `'export { x }'` in a string is not an export. + */ + +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { blankStringContents, stripCommentsForRegex } from '../resolution/strip-comments'; + +/** + * The export forms a source scan recognizes. Each is anchored at a line start + * (indentation allowed) so a mention mid-expression does not match. + */ + +/** `export const x`, `export function f`, `export { x }`, `export default x`, `export * from`. */ +export const ESM_EXPORT = /^[ \t]*export[\s{*]/m; + +/** `declare global` contributes names to every file, exported or not. */ +export const DECLARE_GLOBAL = /^[ \t]*declare\s+global\b/m; + +/** + * Anything the file could offer another file: an ESM export statement OR a + * `declare global` block. The two are one question at the call sites that ask + * "does this file contribute names at all" (the resolver's sealed-module and + * classic-script checks), so they share one pattern rather than each spelling + * the alternation out. + */ +export const ESM_EXPORT_OR_DECLARE_GLOBAL = /^[ \t]*export[\s{*]|^[ \t]*declare\s+global\b/m; + +/** + * A CommonJS export in either the dot or the bracket form: `module.exports`, + * `exports.x`, `exports["x"]`, `exports['x']`. + */ +export const CJS_EXPORT = /\bmodule\.exports\b|\bexports\s*[.[]/; + +/** A line-initial `import` statement — the marker that a JS/TS file is a module. */ +export const IMPORT_STATEMENT = /^[ \t]*import[\s{*'"]/m; + +/** A `require(…)` call — the other way a file joins the module system. */ +export const REQUIRE_CALL = /(?:^|[^\w$.])require\s*\(/; + +/** A JS/TS-family language: the only family these export forms describe. */ +export function isJsTsFamily(language: string): boolean { + return ( + language === 'typescript' || + language === 'javascript' || + language === 'tsx' || + language === 'jsx' || + language === 'arkts' + ); +} + +/** + * The source with comments blanked and string contents blanked, so a regex + * over it cannot match text that only *looks* like an export. Offsets are + * preserved (both helpers replace with spaces), so a match index still maps to + * the original line. + * + * Template interpolations are blanked too. A CommonJS assignment genuinely can + * execute inside one, which is why the per-file "exports nothing" verdict + * consults the RAW source for the CommonJS forms as well (see + * `fileExportsAnything`) — the conservative direction there is to call a file + * exported, never to seal it. + */ +export function maskedJsSource(source: string, language: string): string { + return blankStringContents(stripCommentsForRegex(source, jsCommentLang(language))); +} + +/** The comment syntax to strip with — JS-family languages all use C-style. */ +function jsCommentLang(language: string): 'typescript' { + // stripCommentsForRegex keys its C-style branch on 'typescript'; every + // JS-family language shares that syntax, so one key covers them. + void language; + return 'typescript'; +} + +/** + * The source with comments blanked but string literals left INTACT — what the + * CommonJS name scan reads. The bracket form puts the exported name inside a + * string (`exports["x"] = …`), so blanking string contents (as + * `maskedJsSource` does) would erase the very name being looked for. Comment + * text is not code, so it is blanked: a commented-out CommonJS assignment is + * not an export. + */ +export function commentStrippedJsSource(source: string, language: string): string { + return stripCommentsForRegex(source, jsCommentLang(language)); +} + +/** + * One-entry memos for the masked and comment-stripped forms of a source. The + * extractor asks the per-name questions once per declaration node, always with + * the same source string, so caching the last one turns a per-node O(file) + * mask into a per-file one. + */ +let lastMasked: { source: string; language: string; text: string } | null = null; +let lastStripped: { source: string; language: string; text: string } | null = null; + +/** `maskedJsSource`, memoized for the source the caller is walking. */ +function maskedFor(source: string, language: string): string { + if (lastMasked && lastMasked.source === source && lastMasked.language === language) return lastMasked.text; + lastMasked = { source, language, text: maskedJsSource(source, language) }; + return lastMasked.text; +} + +/** `commentStrippedJsSource`, memoized the same way. */ +function strippedFor(source: string, language: string): string { + if (lastStripped && lastStripped.source === source && lastStripped.language === language) return lastStripped.text; + lastStripped = { source, language, text: commentStrippedJsSource(source, language) }; + return lastStripped.text; +} + +/** + * Whether the file source contains ANY export, in any form — the per-file + * question the resolver asks (#1720's "does this file export anything at + * all"). Covers the ESM statement forms, the CommonJS forms, and + * `declare global` (which contributes names whether or not the module exports + * anything of its own). + * + * Checks the RAW source for the CommonJS forms as well as the masked source, + * because a `module.exports` assignment can execute inside a template + * interpolation that the masker blanks. Erring toward "exports something" is + * the safe direction: a file wrongly called exported keeps its cross-file + * candidates, a file wrongly called sealed loses real edges. + */ +export function fileExportsAnything(source: string, language: string): boolean { + if (!isJsTsFamily(language)) return false; + if (CJS_EXPORT.test(source)) return true; + const code = maskedJsSource(source, language); + return ESM_EXPORT.test(code) || DECLARE_GLOBAL.test(code); +} + +/** + * Whether a JS/TS file is a module that exports NOTHING — an `import` or + * `require(…)` present, no export of any form. No reference from another file + * can reach a binding in such a file, so every one of its symbols is a false + * candidate for a cross-file name match. + * + * Deliberately narrow on three axes, because each is a class this would + * otherwise resolve wrongly in the opposite direction: + * + * - **A classic script is exempt.** Requiring an `import`/`require` means a + * non-module `.js` file — concatenated globals, a browser `