Skip to content

Commit aa767cc

Browse files
committed
Filter JSX tag reference false positives
1 parent f0c88af commit aa767cc

8 files changed

Lines changed: 290 additions & 23 deletions

File tree

crates/graph-sitter-engine/src/lib.rs

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3168,6 +3168,86 @@ fn collect_typescript_identifier_candidates(
31683168
}
31693169
return;
31703170
}
3171+
"jsx_element" => {
3172+
if let Some(open_tag) = node.child_by_field_name("open_tag") {
3173+
collect_typescript_jsx_tag_candidates(
3174+
file_id,
3175+
source,
3176+
open_tag,
3177+
symbol_ranges,
3178+
local_bindings_by_symbol_id,
3179+
local_binding_scopes,
3180+
excluded_ranges,
3181+
indexed_local_symbols,
3182+
out,
3183+
);
3184+
}
3185+
let open_range = node
3186+
.child_by_field_name("open_tag")
3187+
.map(|tag| SourceRange::from(tag.range()));
3188+
let close_range = node
3189+
.child_by_field_name("close_tag")
3190+
.map(|tag| SourceRange::from(tag.range()));
3191+
let mut cursor = node.walk();
3192+
for child in node.named_children(&mut cursor) {
3193+
let child_range = SourceRange::from(child.range());
3194+
if open_range.is_some_and(|range| range == child_range)
3195+
|| close_range.is_some_and(|range| range == child_range)
3196+
{
3197+
continue;
3198+
}
3199+
collect_typescript_identifier_candidates(
3200+
file_id,
3201+
source,
3202+
child,
3203+
symbol_ranges,
3204+
local_bindings_by_symbol_id,
3205+
local_binding_scopes,
3206+
excluded_ranges,
3207+
indexed_local_symbols,
3208+
out,
3209+
);
3210+
}
3211+
return;
3212+
}
3213+
"jsx_opening_element" | "jsx_self_closing_element" => {
3214+
collect_typescript_jsx_tag_candidates(
3215+
file_id,
3216+
source,
3217+
node,
3218+
symbol_ranges,
3219+
local_bindings_by_symbol_id,
3220+
local_binding_scopes,
3221+
excluded_ranges,
3222+
indexed_local_symbols,
3223+
out,
3224+
);
3225+
return;
3226+
}
3227+
"jsx_closing_element" => return,
3228+
"jsx_attribute" => {
3229+
let mut cursor = node.walk();
3230+
for child in node.named_children(&mut cursor) {
3231+
if matches!(
3232+
child.kind(),
3233+
"property_identifier" | "jsx_namespace_name" | "string"
3234+
) {
3235+
continue;
3236+
}
3237+
collect_typescript_identifier_candidates(
3238+
file_id,
3239+
source,
3240+
child,
3241+
symbol_ranges,
3242+
local_bindings_by_symbol_id,
3243+
local_binding_scopes,
3244+
excluded_ranges,
3245+
indexed_local_symbols,
3246+
out,
3247+
);
3248+
}
3249+
return;
3250+
}
31713251
"member_expression" => {
31723252
if let (Some(object), Some(property)) = (
31733253
node.child_by_field_name("object"),
@@ -3262,6 +3342,92 @@ fn collect_typescript_identifier_candidates(
32623342
}
32633343
}
32643344

3345+
fn collect_typescript_jsx_tag_candidates(
3346+
file_id: u32,
3347+
source: &str,
3348+
tag_node: Node<'_>,
3349+
symbol_ranges: &[(u32, SourceRange)],
3350+
local_bindings_by_symbol_id: &HashMap<u32, HashSet<String>>,
3351+
local_binding_scopes: &[LocalBindingScope],
3352+
excluded_ranges: &[SourceRange],
3353+
indexed_local_symbols: &IndexedLocalSymbols,
3354+
out: &mut Vec<ReferenceCandidate>,
3355+
) {
3356+
let Some(name_node) = tag_node.child_by_field_name("name") else {
3357+
return;
3358+
};
3359+
3360+
match name_node.kind() {
3361+
"identifier" => {
3362+
let range = SourceRange::from(name_node.range());
3363+
if !range_matches_any(range, excluded_ranges) {
3364+
if let Ok(name) = name_node.utf8_text(source.as_bytes()) {
3365+
if typescript_jsx_tag_identifier_can_reference(name) {
3366+
let source_symbol_id = innermost_symbol_for_range(symbol_ranges, range);
3367+
if !typescript_reference_is_shadowed(
3368+
source_symbol_id,
3369+
name,
3370+
range,
3371+
local_bindings_by_symbol_id,
3372+
local_binding_scopes,
3373+
indexed_local_symbols,
3374+
) {
3375+
out.push(ReferenceCandidate {
3376+
source_file_id: file_id,
3377+
source_symbol_id,
3378+
name: name.to_owned(),
3379+
qualifier: None,
3380+
range,
3381+
is_subclass: false,
3382+
call_range: None,
3383+
});
3384+
}
3385+
}
3386+
}
3387+
}
3388+
}
3389+
"member_expression" => {
3390+
collect_typescript_identifier_candidates(
3391+
file_id,
3392+
source,
3393+
name_node,
3394+
symbol_ranges,
3395+
local_bindings_by_symbol_id,
3396+
local_binding_scopes,
3397+
excluded_ranges,
3398+
indexed_local_symbols,
3399+
out,
3400+
);
3401+
}
3402+
_ => {}
3403+
}
3404+
3405+
let name_range = SourceRange::from(name_node.range());
3406+
let mut cursor = tag_node.walk();
3407+
for child in tag_node.named_children(&mut cursor) {
3408+
if SourceRange::from(child.range()) == name_range || child.kind() == "type_arguments" {
3409+
continue;
3410+
}
3411+
collect_typescript_identifier_candidates(
3412+
file_id,
3413+
source,
3414+
child,
3415+
symbol_ranges,
3416+
local_bindings_by_symbol_id,
3417+
local_binding_scopes,
3418+
excluded_ranges,
3419+
indexed_local_symbols,
3420+
out,
3421+
);
3422+
}
3423+
}
3424+
3425+
fn typescript_jsx_tag_identifier_can_reference(name: &str) -> bool {
3426+
name.chars()
3427+
.next()
3428+
.is_some_and(|first| first == '_' || first == '$' || first.is_ascii_uppercase())
3429+
}
3430+
32653431
fn collect_typescript_call_function_operands(
32663432
file_id: u32,
32673433
source: &str,
@@ -8641,6 +8807,101 @@ export function run() {\n local(helper(1));\n return run();\n}\n",
86418807
}));
86428808
}
86438809

8810+
#[test]
8811+
fn resolves_jsx_component_tag_references_without_intrinsic_or_prop_false_edges() {
8812+
let repo = temp_repo_path("resolve-typescript-jsx-tag-references");
8813+
fs::create_dir_all(repo.join("src")).unwrap();
8814+
fs::write(
8815+
repo.join("src/components.tsx"),
8816+
"export function Button() { return null; }\n\
8817+
export function div() { return null; }\n\
8818+
export namespace UI { export function Card() { return null; } }\n",
8819+
)
8820+
.unwrap();
8821+
fs::write(
8822+
repo.join("src/app.tsx"),
8823+
"import { Button, UI, div } from './components';\n\n\
8824+
export function App() {\n\
8825+
const local = Button;\n\
8826+
return <main Button={Button}><Button /><UI.Card /><div /></main>;\n\
8827+
}\n",
8828+
)
8829+
.unwrap();
8830+
8831+
let index = index_typescript_path(&repo).unwrap();
8832+
fs::remove_dir_all(&repo).unwrap();
8833+
8834+
let app_file_id = index
8835+
.files
8836+
.iter()
8837+
.find(|file| file.path == "src/app.tsx")
8838+
.unwrap()
8839+
.id;
8840+
let components_file_id = index
8841+
.files
8842+
.iter()
8843+
.find(|file| file.path == "src/components.tsx")
8844+
.unwrap()
8845+
.id;
8846+
let app_symbol_id = index
8847+
.symbols
8848+
.iter()
8849+
.find(|symbol| symbol.file_id == app_file_id && symbol.name == "App")
8850+
.unwrap()
8851+
.id;
8852+
let button_symbol_id = index
8853+
.symbols
8854+
.iter()
8855+
.find(|symbol| symbol.file_id == components_file_id && symbol.name == "Button")
8856+
.unwrap()
8857+
.id;
8858+
let div_symbol_id = index
8859+
.symbols
8860+
.iter()
8861+
.find(|symbol| symbol.file_id == components_file_id && symbol.name == "div")
8862+
.unwrap()
8863+
.id;
8864+
let ui_symbol_id = index
8865+
.symbols
8866+
.iter()
8867+
.find(|symbol| symbol.file_id == components_file_id && symbol.name == "UI")
8868+
.unwrap()
8869+
.id;
8870+
let card_symbol_id = index
8871+
.symbols
8872+
.iter()
8873+
.find(|symbol| symbol.file_id == components_file_id && symbol.name == "Card")
8874+
.unwrap()
8875+
.id;
8876+
8877+
let button_references = index
8878+
.references
8879+
.iter()
8880+
.filter(|reference| {
8881+
reference.source_symbol_id == Some(app_symbol_id)
8882+
&& reference.target_symbol_id == button_symbol_id
8883+
})
8884+
.count();
8885+
assert_eq!(
8886+
button_references, 3,
8887+
"local assignment, prop expression, and component tag should resolve without counting the prop name"
8888+
);
8889+
assert!(index.references.iter().any(|reference| {
8890+
reference.source_symbol_id == Some(app_symbol_id)
8891+
&& reference.target_symbol_id == ui_symbol_id
8892+
&& reference.name == "UI"
8893+
}));
8894+
assert!(index.references.iter().any(|reference| {
8895+
reference.source_symbol_id == Some(app_symbol_id)
8896+
&& reference.target_symbol_id == card_symbol_id
8897+
&& reference.name == "Card"
8898+
}));
8899+
assert!(!index.references.iter().any(|reference| {
8900+
reference.source_symbol_id == Some(app_symbol_id)
8901+
&& reference.target_symbol_id == div_symbol_id
8902+
}));
8903+
}
8904+
86448905
#[test]
86458906
fn extracts_typescript_promise_chain_records() {
86468907
let repo = temp_repo_path("typescript-promise-chain-records");

docs/benchmarks/large-repos.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ mode, the old eager Python graph is blocked after the compact Rust index builds.
3232
| Repository | Python wall | Python max RSS | Rust wall | Rust max RSS | Wall improvement | RSS improvement |
3333
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
3434
| Apache Airflow `2.10.5` | 18.940s | 3469.5 MB | 4.085s | 266.2 MB | 4.637x | 13.031x |
35-
| Next.js `v15.0.0` | 24.959s | 3100.1 MB | 10.861s | 439.8 MB | 2.298x | 7.049x |
35+
| Next.js `v15.0.0` | 24.959s | 3100.1 MB | 10.771s | 435.2 MB | 2.317x | 7.123x |
3636

3737
The Airflow row exercises compact Python files, symbols, imports, import
3838
resolution, references, dependencies, and Python compatibility handles. The
3939
Next.js row exercises TypeScript/JavaScript files, symbols, imports, exports,
40-
relative and tsconfig path resolution, references, dependencies, subclass edges,
41-
read-only function call records, and read-only Promise-chain records.
40+
relative and tsconfig path resolution, JSX component tag references,
41+
dependencies, subclass edges, read-only function call records, and read-only
42+
Promise-chain records.
4243

4344
## Installed-Wheel uvx Proof
4445

rust-rewrite/benchmarks.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,9 @@ uv run python rust-rewrite/tools/check_pinned_typescript_codebase.py \
257257

258258
| Input | Rust `Codebase` wall | Rust `Codebase` max RSS | Files | Symbols | Imports | Exports | Import resolutions | References | Dependencies | Function calls | Promise chains | Python graph blocked |
259259
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
260-
| Next.js `v15.0.0` (`51bfe3c1863b191f4b039bc230e8ed5c57b0baf3`) | 10.861s | 439.8 MB | 13688 | 44871 | 28210 | 16027 | 13462 | 114465 | 49287 | 197581 | 878 | yes |
260+
| Next.js `v15.0.0` (`51bfe3c1863b191f4b039bc230e8ed5c57b0baf3`) | 10.771s | 435.2 MB | 13688 | 44871 | 28210 | 16027 | 13462 | 113809 | 49285 | 197581 | 878 | yes |
261261

262-
Compared with the Python TypeScript parse/object-materialization baseline above, the current Rust `Codebase` TypeScript shell is about 2.298x faster and about 7.049x lower max RSS while exposing compact export, call, and Promise-chain handles and keeping the eager Python graph unbuilt. The pinned proof also validates a real `packages/next/src/cli/next-lint.ts` file/symbol lookup for 27 file-local call records, 16 `nextLint` symbol call records, and one `.then/.catch` Promise chain without materializing the full call or chain caches. A parser fallback now tries the TS grammar for `.ts`/`.js` files and keeps the lower-error parse, reducing pinned Next.js parser-error files from 114 to 113 by recovering `test/integration/typescript/components/angle-bracket-type-assertions.ts`.
262+
Compared with the Python TypeScript parse/object-materialization baseline above, the current Rust `Codebase` TypeScript shell is about 2.317x faster and about 7.123x lower max RSS while exposing compact export, call, and Promise-chain handles and keeping the eager Python graph unbuilt. The pinned proof also validates a real `packages/next/src/cli/next-lint.ts` file/symbol lookup for 27 file-local call records, 16 `nextLint` symbol call records, and one `.then/.catch` Promise chain without materializing the full call or chain caches. JSX traversal now records component tag references while skipping lowercase intrinsic tags and prop-name false positives. A parser fallback tries the TS grammar for `.ts`/`.js` files and keeps the lower-error parse, reducing pinned Next.js parser-error files from 114 to 113 by recovering `test/integration/typescript/components/angle-bracket-type-assertions.ts`.
263263

264264
The same proof is now available as an opt-in test gate:
265265

@@ -269,7 +269,7 @@ uv run python rust-rewrite/tools/check_pinned_typescript_codebase.py \
269269
--skip-fetch
270270
```
271271

272-
On 2026-06-19, that checker validated exact pinned Next.js `Codebase` handle counts plus compact function-call and Promise-chain counts, confirmed the Python graph stayed blocked, and measured 10.861s wall / 439.8 MB max RSS. Against the recorded Python TypeScript parse/object-materialization baseline above, that is 2.298x faster wall time and 7.049x lower max RSS with conservative CI-style ceilings.
272+
On 2026-06-19, the full pinned large-repo gate validated exact pinned Next.js `Codebase` handle counts plus compact function-call and Promise-chain counts, confirmed the Python graph stayed blocked, and measured 10.771s wall / 435.2 MB max RSS. Against the recorded Python TypeScript parse/object-materialization baseline above, that is 2.317x faster wall time and 7.123x lower max RSS with conservative CI-style ceilings.
273273

274274
## Installed-Wheel `uvx` Airflow Evidence
275275

rust-rewrite/golden/next.js-v15.0.0-rust-compact-typescript.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"graphs": {
33
"dependencies": {
4-
"count": 49287,
4+
"count": 49285,
55
"samples": [
66
{
77
"reference_count": 2,
@@ -210,7 +210,7 @@
210210
"target_symbol": ".github/actions/next-integration-stat/src/index.ts:type_alias:Octokit@284"
211211
}
212212
],
213-
"sha256": "2b4504dada98ee8d1c67f44555c126db3afb6995cbe9d3a3fd8ef98a90486da3"
213+
"sha256": "72d6c7c2ee1ec459b47b109e4bfdef8b935a0446ea0ca5c8c3990e088185da2d"
214214
},
215215
"exports": {
216216
"count": 16027,
@@ -905,7 +905,7 @@
905905
"sha256": "75176bcfeb2ad12c09a7dfddc5de0f357b3f760e0b16b6cd05c9cf8260113b33"
906906
},
907907
"external_references": {
908-
"count": 25317,
908+
"count": 22427,
909909
"samples": [
910910
{
911911
"import": ".github/actions/needs-triage/src/index.ts:namespace_import:@actions/github:*:github@7",
@@ -1208,7 +1208,7 @@
12081208
"source_symbol": ".github/actions/next-integration-stat/src/index.ts:function:getTestResultDiffBase@10615"
12091209
}
12101210
],
1211-
"sha256": "30bdfc999d06a7b426261e850bdbec296db2b5dac2ad482c73fd87f06181579f"
1211+
"sha256": "911fa8cb79987fc29b94fd6d6fe19b3d188af26dc834a37faaf685690db8a768"
12121212
},
12131213
"files": {
12141214
"count": 13688,
@@ -1969,7 +1969,7 @@
19691969
"sha256": "701c5aeb3c9738bd1051ddb1c7a97afe67422e303b25dc50e4c2d028f739856f"
19701970
},
19711971
"references": {
1972-
"count": 114465,
1972+
"count": 113809,
19731973
"samples": [
19741974
{
19751975
"import": null,
@@ -2292,7 +2292,7 @@
22922292
"target_symbol": ".github/actions/next-integration-stat/src/index.ts:function:fetchJobLogsFromWorkflow@2452"
22932293
}
22942294
],
2295-
"sha256": "51ecfc2eab6601e947f7f64944f162c63e304c0e00c256c2ac497fc28dd95202"
2295+
"sha256": "273b2c2b48d09aa7fabdc027ba0bc6f9d2124ce1003ff0b359f31209a00fc4ca"
22962296
},
22972297
"subclass_edges": {
22982298
"count": 160,
@@ -2991,18 +2991,18 @@
29912991
"summary": {
29922992
"bytes": 25421217,
29932993
"classes": 502,
2994-
"dependencies": 49287,
2994+
"dependencies": 49285,
29952995
"exports": 16027,
29962996
"external_modules": 13525,
2997-
"external_references": 25317,
2997+
"external_references": 22427,
29982998
"files": 13688,
29992999
"files_with_errors": 113,
30003000
"functions": 13497,
30013001
"global_variables": 28742,
30023002
"import_resolutions": 13462,
30033003
"imports": 28210,
30043004
"lines": 634891,
3005-
"references": 114465,
3005+
"references": 113809,
30063006
"subclass_edges": 160,
30073007
"symbols": 44871
30083008
}

0 commit comments

Comments
 (0)