From 5f5b8db620c5aa607a022471c9a604a71715ae73 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 6 Oct 2026 15:31:37 +0000 Subject: [PATCH] fix(js,ts): find a store exported by an export { } list when its name starts or ends with $ (#2384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TreeSitterExtractor.isExportedLater and its kernel mirror is_exported_later bounded the name in the `export { ... }` branch with `\b`, which takes a `$` for a separator. It never fires beside a leading or trailing `$`, so `const items$ = create(...)` + `export { items$ };` (or `export { $items as default };` for a `$items` store) never matched, and the store's actions were not extracted on either engine. It also matched inside a longer name: `export { useStore$ }` or `export { $useStore }` counted as exporting a store named `useStore`. The clause branch now bounds the name with a character that can't continue an identifier. The regex crate has no look-around, so the bounds consume, and both sides spell the class in ASCII, since the crate's `\w` is Unicode while JS's is ASCII. Plain names decide as before apart from the two false positives. A non-ASCII letter written against the name (`export { éuseStore }`) used to split the engines (the kernel's Unicode `\b` said not exported, the wasm path exported); both now give the wasm answer. Adds kernel/wasm parity cases (ts/tsx/js/jsx) inside 'a store exported by a later statement', and the same cases in store-exported-later.test.ts, for `items$`, `$items as default` and both false-positive shapes. They fail with the unfixed kernel and with the unfixed TS on the wasm path, and pass with both fixes. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/kernel-tsjs-parity.test.ts | 20 ++++++++++++++++++++ __tests__/store-exported-later.test.ts | 14 ++++++++++++++ codegraph-kernel/src/tsjs/extractors.rs | 6 +++++- src/extraction/tree-sitter.ts | 6 +++++- 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0980e8029..5e9fa96cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a call that passes a type argument and arguments, like `ref.read(repoProvider)`, `ref.watch(counterProvider)`, `Provider((ref) => …)`, `BlocProvider.of(context)` or `RepositoryProvider(create: …)`, now links to the method or class it calls, and the type it passes counts as used there. Depending on the code around it, the parser often read such a call as two comparisons, so it linked nothing, and Riverpod providers, repository reads and bloc lookups were missing from `callers`, impact and `codegraph_explore`. A real comparison like `a < b` is still left alone. Re-index Dart projects after upgrading. - In Dart, a constructor or member written without a body, like flutter_bloc's `const BlocProvider.value(…)`, `Foo._();`, `Point.origin() : x = 0, y = 0;` or an abstract or `external` method, now keeps the `///` documentation and the annotations written above it, the way one with a body already did. Before, that documentation was lost, so searching for words in it didn't find the symbol, and an annotation like `@visibleForTesting` or `@protected` on it wasn't recorded. Re-index Dart projects after upgrading. - In Dart, a call on what a generic lookup returns, like flutter_bloc's `BlocProvider.of(context, listen: false).increment()` or Provider's `Provider.of(context, listen: false).add(item)`, now links to that method of the type in the angle brackets, whether the lookup is declared in your project or comes from a package. Before, such a call often linked nothing — for example whenever the lookup passed `listen: false` — so `callers` and impact missed those methods. A lookup that names no type, like `BlocProvider.of(context)`, still links nothing. Re-index Dart projects after upgrading. (#750) +- In JavaScript and TypeScript, a store whose name starts or ends with a `$`, like `const items$ = create(…)`, now has its actions indexed when an `export { … }` list on a later line exports it, as in `export { items$ };` or `export { $items as default };`. Before, those actions were missing from the graph, so their callers and impact came back empty. A store whose name only lacks the `$`, like `items`, is no longer treated as exported by that list. 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 9bf906f14..866d80d30 100644 --- a/__tests__/kernel-tsjs-parity.test.ts +++ b/__tests__/kernel-tsjs-parity.test.ts @@ -149,6 +149,26 @@ describe.skipIf(!kernelBuilt)('kernel TS/JS extraction parity', () => { const result = assertParity(`store.${ext}`, source, language); expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'inc')).toBe(true); }); + + it.each([ + ['items$', 'export { items$ };', true], + ['$items', 'export { $items as default };', true], + ['items', 'export { items$ };', false], + ['items', 'export { $items };', false], + ] as const)('an export clause names %s only whole: `%s`', (name, exportLine, kept) => { + // `\b` takes a `$` for a separator: it can't bound `items$` or + // `$items`, and it finds `items` inside both. `other` keeps the + // negative cases above assertParity's node floor. + const source = [ + 'import { create } from "zustand";', + `const ${name} = create((set) => ({ inc: () => set({}) }));`, + 'const other = 1;', + exportLine, + '', + ].join('\n'); + const result = assertParity(`store.${ext}`, source, language); + expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'inc')).toBe(kept); + }); }); describe.each([ diff --git a/__tests__/store-exported-later.test.ts b/__tests__/store-exported-later.test.ts index 19c124f1d..c5cf774ef 100644 --- a/__tests__/store-exported-later.test.ts +++ b/__tests__/store-exported-later.test.ts @@ -48,6 +48,20 @@ describe('store actions on a later-exported const', () => { expect(fnNames(asDefault)).toContain('bump'); }); + it.each([ + ['items$', 'export { items$ };', true], + ['$items', 'export { $items as default };', true], + ['items', 'export { items$ };', false], + ['items', 'export { $items };', false], + ] as const)('an export clause names %s only whole: `%s`', (name, exportLine, kept) => { + // A `$` belongs to the name: `items$` is exported, `items` is not. + const code = ` + const ${name} = create((set) => ({ inc: () => set({}) })) + ${exportLine} + `; + expect(fnNames(code).includes('inc')).toBe(kept); + }); + it('a const nothing exports keeps its members out of the graph', () => { const code = ` const useStore = create((set) => ({ bump: () => set({}) })) diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index ef1d3d517..461ae6d39 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -366,8 +366,12 @@ impl<'t> Walker<'t> { // `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]*$|\{{[^}}]*\b{n}\b[^}}]*\}})", + 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) { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index bf8f5f35a..561f7e643 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -2818,8 +2818,12 @@ export class TreeSitterExtractor { // not the character. Escaped as the kernel's `regex::escape` does, so // both paths decide the same. const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Inside `{ … }` the name is bounded by a character that can't continue + // an identifier, not by `\b`, which takes a `$` for a separator: it can't + // bound `items$` and finds `items` inside it. Spelled in ASCII, as the + // kernel spells it, since the kernel's `\w` is Unicode. const re = new RegExp( - `^[ \\t]*export\\s+(?:default\\s+${n}\\s*;?[ \\t]*$|\\{[^}]*\\b${n}\\b[^}]*\\})`, + `^[ \\t]*export\\s+(?:default\\s+${n}\\s*;?[ \\t]*$|\\{(?:[^}]*[^0-9A-Za-z_$}])?${n}(?:[^0-9A-Za-z_$}][^}]*)?\\})`, 'm' ); return re.test(this.source);