From 68c42009df8ce4dd0f59042cbbfede9aa3784096 Mon Sep 17 00:00:00 2001 From: Sumiteshwark <127643476+Sumiteshwark@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:35:09 +0530 Subject: [PATCH] =?UTF-8?q?fix(dart):=20anchor=20@name=20so=20a=20construc?= =?UTF-8?q?tor=20initializer=20stops=20minting=20a=20=E2=80=A6=20(#3224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dart): anchor @name so a constructor initializer stops minting a phantom A Dart declaration whose value is a constructor call parses the callee as a SECOND (identifier) sibling of the declared name: final TextEditingController _title = TextEditingController(); -> initialized_identifier[ identifier "_title", identifier "TextEditingController", selector ] The five graph-node rules that capture fields and top-level variables matched `(identifier) @name` without the first-child anchor, so @name bound to both siblings and the query minted a phantom Property/Variable named after the TYPE alongside the real declaration. On dart-flutter-conduit that produced a `Property TextEditingController` next to the genuine `_title` / `_body` in editor_screen.dart and login_screen.dart. static_final_declaration has the same shape, so class statics and top-level final/const were affected too, as were top-level `var`/`final` variables. All five rules now anchor @name with `.`, matching the mirror rules in languages/dart/query.ts which already anchored. Verified against the vendored grammar: the phantoms disappear and every real declaration is still captured (_title, _body, nullable field, static final, uninitialized field, top-level final, top-level var). End to end on dart-flutter-conduit: Property nodes 108 -> 106, type-shaped names 2 -> 0, real fields unchanged. The new test loads the grammar via createParserForLanguage rather than loadLanguage: loadLanguage resolves to void, so the surrounding `if (!(await loadDartOrSkip())) return;` idiom is always falsy and skips the body. Confirmed as a negative control -- reverting only the query change makes the new test fail on the exact phantom. * chore(autofix): apply prettier + eslint fixes via /autofix command * Address PR review feedback (#3224) Correct the RHS_ONLY_TYPES comments so they state the capture invariant instead of claiming those names appear only as constructor callees. Co-authored-by: Cursor * Address PR review feedback (#3224) Build the Dart query with Parser.Query and parser.getLanguage() so the test no longer casts the tree (or Query/captures) through any. Co-authored-by: Cursor --------- Co-authored-by: Gergő Magyar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .../src/core/ingestion/tree-sitter-queries.ts | 18 ++- .../dart-field-initializer-phantom.test.ts | 108 ++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/unit/dart-field-initializer-phantom.test.ts diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a930eb69a..c71f9eb7d 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -2243,18 +2243,26 @@ export const DART_QUERIES = ` (identifier) @name . (formal_parameter_list))) @definition.constructor ; ── Field declarations (String name = '', Address address = Address()) ────── +; @name is ANCHORED to the first named child. An initialized_identifier whose +; value is a constructor call parses the callee as a SECOND (identifier) sibling +; of the name — \`final TextEditingController _t = TextEditingController();\` is +; initialized_identifier[identifier "_t", identifier "TextEditingController", +; selector]. Unanchored, \`(identifier) @name\` matched both and minted a phantom +; Property named after the TYPE alongside the real field. The same shape applies +; to static_final_declaration (class static and top-level final/const), so every +; rule below anchors. languages/dart/query.ts already anchors its mirror rules. (declaration (type_identifier) (initialized_identifier_list (initialized_identifier - (identifier) @name))) @definition.property + . (identifier) @name))) @definition.property ; ── Nullable field declarations (String? name) ────────────────────────────── (declaration (nullable_type) (initialized_identifier_list (initialized_identifier - (identifier) @name))) @definition.property + . (identifier) @name))) @definition.property ; ── static const / static final / const class fields ──────────────────────── ; A "static const a = 1;" / "static final String b = ..., c = ...;" field parses @@ -2267,7 +2275,7 @@ export const DART_QUERIES = ` (declaration (static_final_declaration_list (static_final_declaration - (identifier) @name))) @definition.property + . (identifier) @name))) @definition.property ; ── Getters ────────────────────────────────────────────────────────────────── (method_signature @@ -2290,7 +2298,7 @@ export const DART_QUERIES = ` (program (initialized_identifier_list (initialized_identifier - (identifier) @name)) @definition.variable) + . (identifier) @name)) @definition.variable) ; Closure bindings: \`var f = (x) => x;\` binds a CALLABLE, so it emits Function ; rather than Variable, matching TS/JS. Overlap with the pattern above is ; collapsed by the parse-worker dedup (#2687). Since #2693 this node is also @@ -2336,7 +2344,7 @@ export const DART_QUERIES = ` (program (static_final_declaration_list (static_final_declaration - (identifier) @name)) @definition.variable) + . (identifier) @name)) @definition.variable) ; ── Imports ────────────────────────────────────────────────────────────────── (import_or_export diff --git a/gitnexus/test/unit/dart-field-initializer-phantom.test.ts b/gitnexus/test/unit/dart-field-initializer-phantom.test.ts new file mode 100644 index 000000000..9c7e11894 --- /dev/null +++ b/gitnexus/test/unit/dart-field-initializer-phantom.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import Parser from 'tree-sitter'; +import { SupportedLanguages } from '../../src/config/supported-languages.js'; +import { createParserForLanguage } from '../../src/core/tree-sitter/parser-loader.js'; +import { DART_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js'; + +/** + * A Dart field whose value is a constructor call parses the callee as a SECOND + * (identifier) sibling of the field name: + * + * final TextEditingController _title = TextEditingController(); + * → initialized_identifier[ identifier "_title", + * identifier "TextEditingController", selector ] + * + * Unanchored, `(identifier) @name` matched both siblings and minted a phantom + * Property/Variable named after the TYPE next to the real field. The same shape + * applies to static_final_declaration (class statics and top-level final/const), + * so all five graph-node rules anchor @name to the first named child. + * + * Regression guard: constructor-callee type names must never be captured as + * declarations, and every real declared name must still be captured. + */ + +const CODE = `class S { + final TextEditingController _title = TextEditingController(); + final TextEditingController _body = TextEditingController(); + Foo? nullableField = Foo(); + static final Bar staticField = Bar(); + final ArticleApi _noInitializer; +} +final Baz topLevelFinal = Baz(); +var topLevelVar = Qux(); +`; + +/** Constructor callee names that must never be captured as declaration names. */ +const RHS_ONLY_TYPES = ['TextEditingController', 'Foo', 'Bar', 'Baz', 'Qux']; + +/** Every name actually declared in CODE. */ +const DECLARED = [ + '_title', + '_body', + 'nullableField', + 'staticField', + '_noInitializer', + 'topLevelFinal', + 'topLevelVar', +]; + +describe('Dart field/variable declarations with constructor initializers', () => { + let parser: Parser | null = null; + let unavailable: string | null = null; + + beforeAll(async () => { + // NB: loadLanguage() resolves to void, so the `if (!(await loadDartOrSkip()))` + // idiom used elsewhere in this suite is always truthy-false and skips the + // whole test body. createParserForLanguage returns the Parser, so a genuine + // load failure is distinguishable from a successful load. + try { + parser = await createParserForLanguage(SupportedLanguages.Dart); + } catch (error) { + unavailable = error instanceof Error ? error.message : String(error); + } + }); + + function capturedNames(): { property: string[]; variable: string[] } { + if (!parser) throw new Error('parser unavailable'); + const tree = parser.parse(CODE); + const query = new Parser.Query(parser.getLanguage(), DART_QUERIES); + const property: string[] = []; + const variable: string[] = []; + for (const match of query.matches(tree.rootNode)) { + const name = match.captures.find((c) => c.name === 'name'); + const def = match.captures.find((c) => c.name.startsWith('definition.')); + if (!name || !def) continue; + if (def.name === 'definition.property') property.push(name.node.text); + if (def.name === 'definition.variable') variable.push(name.node.text); + } + return { property: [...new Set(property)], variable: [...new Set(variable)] }; + } + + it('does not mint a phantom named after the initializer type', (ctx) => { + if (!parser) return ctx.skip(`dart grammar unavailable: ${unavailable}`); + const { property, variable } = capturedNames(); + const all = [...property, ...variable]; + expect(all.length, 'query produced no captures at all').toBeGreaterThan(0); + for (const type of RHS_ONLY_TYPES) { + expect(all, `phantom captured for constructor callee "${type}"`).not.toContain(type); + } + }); + + it('still captures every declared field and top-level variable', (ctx) => { + if (!parser) return ctx.skip(`dart grammar unavailable: ${unavailable}`); + const { property, variable } = capturedNames(); + const all = [...property, ...variable]; + for (const declared of DECLARED) { + expect(all, `lost real declaration "${declared}"`).toContain(declared); + } + }); + + it('classifies class members as property and top-level names as variable', (ctx) => { + if (!parser) return ctx.skip(`dart grammar unavailable: ${unavailable}`); + const { property, variable } = capturedNames(); + expect(property).toEqual( + expect.arrayContaining(['_title', '_body', 'nullableField', 'staticField', '_noInitializer']), + ); + expect(variable).toEqual(expect.arrayContaining(['topLevelFinal', 'topLevelVar'])); + }); +});