From 5b73d0fd46725413e4602e17b85b7fee9f9a085d Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 6 Oct 2026 05:54:08 +0000 Subject: [PATCH] fix(js,ts): find a store exported on a later line when its name contains $ (#2366) TreeSitterExtractor.isExportedLater put the binding name into its regex unescaped. A `$` in the name (`items$`, `$store`, `a$b`) became an end-of-line anchor, so `const items$ = create(...)` + `export default items$;` never matched and the store's actions (e.g. `function inc`) were not extracted, while the kernel mirror escapes the name with regex::escape and extracts them. The same anchor also matched where it should not: a store named `items$` read as exported when a multi-line `export { ... }` ended a line with `items`. The kernel routes TS/TSX/JS/JSX by default, so the wasm path is what files with parse errors (deferred to wasm), Svelte/Vue/Astro script blocks and runs without the kernel get. Escape the name the way regex::escape does; the guard limits names to [A-Za-z0-9_$], so only `$` changes. `\b` stays as is on both sides: next to a leading or trailing `$` it never matches (`export { items$ }`), the same in both engines. Adds a kernel/wasm parity case (ts/tsx/js/jsx) for `items$`, `$store` and `a$b` plus the false-positive shape; all 16 fail with the name unescaped and pass with the fix. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/kernel-tsjs-parity.test.ts | 27 +++++++++++++++++++++++++++ src/extraction/tree-sitter.ts | 6 +++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e066c8f6..b7b80ad26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, the functions written inside a named object literal are now symbols of their own even when the object isn't exported: a plain `const api = { load() {…} }`, an object declared inside an IIFE or a function, and a namespace hung on the page or on another object, like `window.App = { init() {…} }`, `App.utils = {…}` or `dw_page = {…}`. Calls such as `api.load()`, `window.App.init()`, `App.utils.pad()`, a sibling's `this.render()`, and `App.init()` from another script on the page now reach them, the calls made inside a member belong to that member instead of the object, and a bare `init()` is no longer taken for `App.init`, so script-tag apps no longer lose most of their code from `callers` and impact. A member's qualified name now carries its object, exported or not (`api::load`, `window.App::init`), so asking for `App.init` finds it. Re-index JavaScript and TypeScript projects after upgrading. Thanks @tkhoaaa for the report and @danusha2345. (#2300) - In JavaScript and TypeScript files with Windows (CRLF) line endings, as a Windows checkout usually has them, a store exported on a later line, like `const useStore = create(…)` followed by `export default useStore;`, now has its actions indexed, as it already did with Unix line endings. Before, those actions were missing from the graph on Windows, so their callers and impact came back empty. Re-index JavaScript and TypeScript projects after upgrading. - In VB.NET, a name that begins with a keyword, like `SharedCache`, `Dimension`, `PublicKey` or `FriendlyName`, is now indexed whole, and a method like `Sub NewItem()` is no longer taken for a constructor. Before, the keyword was cut off (`Cache`, `ension`, `Key`) or the declaration was lost, and a line in a method that starts with such a name, like `Constants.Reload()` or `ConstVBV.Value = False`, was misread as a declaration, so reads of and calls to these names didn't link. A `Private Protected` member now counts as private instead of public. Re-index VB.NET projects after upgrading. +- In JavaScript and TypeScript, a store whose name contains a `$`, like `const items$ = create(…)` exported on a later line with `export default items$;`, now has its actions indexed. Before, they could be missing from the graph, for example in a file that also had a syntax error. Re-index JavaScript and TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/kernel-tsjs-parity.test.ts b/__tests__/kernel-tsjs-parity.test.ts index 3cf974fce..9bf906f14 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -151,6 +151,33 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { }); }); + describe.each([ + ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], + ] as const)('a store whose name has a `$`, exported by a later statement: %s', (ext, language) => { + // The later-export check builds a regex from the name. Its `$` must match + // the character, as the kernel's escaped pattern does, not a line end. + const store = (name: string, ...exportLines: string[]) => [ + 'import { create } from "zustand";', + `const ${name} = create((set) => ({ inc: () => set({}) }));`, + ...exportLines, + '', + ].join('\n'); + + it.each([ + ['items$', 'export default items$;'], + ['$store', 'export default $store;'], + ['a$b', 'export { a$b };'], + ])('keeps its actions (%s)', (name, exportLine) => { + const result = assertParity(`store.${ext}`, store(name, exportLine), language); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'inc')).toBe(true); + }); + + it('is not exported by a different name at a line end', () => { + const result = assertParity(`store.${ext}`, store('items$', 'const items = 1;', 'export {', ' items', '};'), language); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'inc')).toBe(false); + }); + }); + it.each([ ['ts', 'typescript'], ['tsx', 'tsx'], ['js', 'javascript'], ['jsx', 'jsx'], ] as const)('same-line accessors retain distinct identities after Unicode: %s (#1349)', (ext, language) => { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 031f200cf..4964f396c 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2803,8 +2803,12 @@ export class TreeSitterExtractor { */ private isExportedLater(name: string): boolean { if (!/^[A-Za-z_$][\w$]*$/.test(name)) return false; + // Unescaped, a `$` in the name (`items$`, `$store`) is a line-end anchor, + // not the character. Escaped as the kernel's `regex::escape` does, so + // both paths decide the same. + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp( - `^[ \\t]*export\\s+(?:default\\s+${name}\\s*;?[ \\t]*$|\\{[^}]*\\b${name}\\b[^}]*\\})`, + `^[ \\t]*export\\s+(?:default\\s+${n}\\s*;?[ \\t]*$|\\{[^}]*\\b${n}\\b[^}]*\\})`, 'm' ); return re.test(this.source);