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'])); + }); +});