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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-06 05:54:08 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent fe7542b6f6
commit 5b73d0fd46
3 changed files with 33 additions and 1 deletions
+1
View File
@@ -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
+27
View File
@@ -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) => {
+5 -1
View File
@@ -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);