From bf6f1fa67e7a69c2371ed2f765e21d0749cfa993 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 21 Oct 2026 20:46:20 +0000 Subject: [PATCH] fix(dart): a typedef references the types it aliases (#2763) In class Report { final int score; const Report(this.score); } typedef ReportCallback = void Function(Report r); `codegraph callers Report` did not list ReportCallback. Both extractors minted the typedef's node and nothing else. tree-sitter-dart gives `type_alias` no fields, so extractTypeAlias found no `value` to read and had no Dart branch, and the kernel's extract_type_alias mirrored that. Impact on a model type missed every callback or collection type built on it. The legacy form with a return type, `typedef Report LegacyMaker(int n);`, was also named after that return type: with no `name` field the name fell back to the first type name in the declaration, so a `type_alias Report` (or `Widget`, `Future`, `bool`) stood beside the real type and took references meant for it. Both extractors now name a typedef by the last type name before its `=`, its type parameters or its parameter list, and add a `references` ref from the typedef, on each type's name, for every type its aliased type names: a function type's return and parameter types, generic arguments, record fields, a type parameter's bound, and the legacy form's return and parameter types. Annotations are not part of it, an import prefix names no type, and a type parameter (the typedef's own, or one a generic function type or an old-style generic function-typed parameter declares) is skipped unless written through a prefix. Kernel and wasm output are identical; a new parity fixture, TortureTypedefs.dart, pins every form in LF and CRLF. Validation, kernel path, arms built from dbb6aa9b and from this change, each with its own kernel: - felangel/bloc b9be1e25: 17,875 -> 17,884 edges (+9, 0 removed). - rrousselGit/riverpod 4ded4f01: 84,726 -> 84,761 (+35, 0 removed). - jonataslaw/getx e3c8fa04: 8,436 -> 8,456 (+20, 0 removed). - alibaba/flutter-go a41b2124 (9 legacy typedefs, 6 with a return type): 7,994 -> 7,968; 39 wrong links removed (34 `Widget` and 5 `Future` uses that had gone to typedefs misnamed `Widget` and `Future`), 13 added (4 from typedefs, 9 fields typed by the renamed ones). - peng8350/flutter_pulltorefresh 0be096c1 (8 legacy, 5 with a return type): 3,186 -> 2,976; 221 wrong `Widget` links removed, 11 added. All 88 added non-contains edges were checked against the source; every removed edge pointed at a typedef misnamed after its return type. The other new typedef refs name SDK or Flutter types and stay unresolved. gin and zustand dumps are byte-identical across the arms, kernel and wasm dumps are byte-identical on bloc, riverpod and pulltorefresh, and the parity sweep over the five Dart repos is 2,330/2,633 files at parity, 0 diffs, 303 deferred on both arms. Index time is unchanged within noise (median of 5 interleaved runs: riverpod 12.6 s vs 11.9 s, bloc 5.0 s vs 5.0 s, getx 2.8 s vs 2.8 s), and the DB grows by 8 to 24 KB (pulltorefresh shrinks by 64 KB). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/dart-typedef-refs.test.ts | 312 ++++++++++++++++++ .../kernel-parity/TortureTypedefs.dart | 78 +++++ __tests__/kernel-dart-parity.test.ts | 11 +- codegraph-kernel/src/dart.rs | 95 +++++- docs/design/dart-kernel-port-checklist.md | 14 +- src/extraction/languages/dart.ts | 79 +++++ src/extraction/tree-sitter.ts | 8 +- 8 files changed, 588 insertions(+), 10 deletions(-) create mode 100644 __tests__/dart-typedef-refs.test.ts create mode 100644 __tests__/fixtures/kernel-parity/TortureTypedefs.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index dd3960443..5f93154ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In COBOL, asking `codegraph_explore` (or `codegraph explore` and `codegraph context`) about a copybook by name, such as `CVACT01Y` or a member brought in with `EXEC SQL INCLUDE`, now answers with the copybook's own source when it is indexed and every `COPY` or `EXEC SQL INCLUDE` statement that includes it, and says so when the copybook's source isn't part of the project. Before, it answered "No relevant code found", or showed an unrelated program whose name was a letter or two away. Thanks @popolusiak for the report. (#2342) - In Dart, reading a getter such as `box.area` or `status.label` now counts as calling it, so `codegraph callers` and impact list the code that reads it — whether the getter is declared on the class, inherited, or added by an extension — and a method an extension adds to an enum, like `shape.shout()`, now links as well. A plain field, a getter of a type from outside your project, or a value whose type isn't written down still links nothing. Re-index Dart projects after upgrading. Thanks @brookly255-lv for the report. (#2338) - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) +- In Dart, a `typedef` now links to the types it is built from, like `Report` in `typedef ReportCallback = void Function(Report r);` or `typedef Reports = List;`, so `callers` and impact for a model class also reach the callback and collection types defined on it. An old-style typedef with a return type, like `typedef Widget ItemBuilder(BuildContext context);`, is now indexed under its own name instead of its return type's, so code that only uses `Widget` no longer links to it. Thanks @mg-mg-mg for the report. (#2327) - In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) - In Go, a call made through a type assertion, like `srv.(KVServer).Range(ctx, in)` in generated gRPC code, `c.Reader.(*pipe).Close()` or `v.(storage.Store).Fetch(key)`, now links to that method of the asserted type, including one the type gets from a struct or interface it embeds, and to nothing when the type comes from outside your project. Before, it linked to whichever method shared the name, so every gRPC handler pointed at the `Unimplemented…Server` stub instead of the server interface. Re-index Go projects after upgrading. - In Go, a call through a variable that holds the result of a type assertion, like `st.Fetch(key)` after `st := v.(storage.Store)` or `rc.Read(p)` after `rc, ok := v.(ReadCloser)`, now links to that method of the asserted type, as a call made through the assertion itself does, including one the type gets from a struct or interface it embeds, and to nothing when the type comes from outside your project. Before, such a call linked to whichever method shared the name or to nothing, and a variable named like a standard-library package, like grpc-go's `parser, ok := builder.(balancer.ConfigParser)`, was taken for that package. Re-index Go projects after upgrading. diff --git a/__tests__/dart-typedef-refs.test.ts b/__tests__/dart-typedef-refs.test.ts new file mode 100644 index 000000000..6b8c7eeb9 --- /dev/null +++ b/__tests__/dart-typedef-refs.test.ts @@ -0,0 +1,312 @@ +/** + * A Dart typedef references the types it aliases (#2327). + * + * In + * + * class Report { final int score; const Report(this.score); } + * typedef ReportCallback = void Function(Report r); + * + * `callers Report` did not list `ReportCallback`. Both extractors minted the + * typedef's node and nothing else: the grammar gives `type_alias` no fields, + * so the aliased type was never read, and impact on a model type missed every + * callback type built on it. The legacy form with a return type, `typedef + * Report LegacyMaker(int n);`, was named after that return type, the first + * type name in it, so a `type_alias Report` stood beside the class. + * + * A typedef now references every type its aliased type names, from the + * typedef, on each type's name: a function type's return and parameter types, + * generic arguments, record fields, a type parameter's bound, and the legacy + * form's return and parameter types. A type parameter, the typedef's own or a + * generic function type's, means that parameter and never a project type of + * its name; an import prefix names no type; and the legacy form is named by + * its own name. + * + * Runs against the native kernel (when built) and the wasm extractor, which + * must agree. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult, Node } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +/** Every typedef shape: new style and legacy, generic, prefixed, annotated, wrapped. */ +const SOURCE = `import 'model.dart' as m; + +class Report { + final int score; + const Report(this.score); +} + +typedef ReportCallback = void Function(Report r); +typedef ReportMaker = Report Function(); +typedef Reports = List; +typedef ReportsByName = Map; +typedef Results = Future?> Function(int page); +typedef void LegacyCallback(Report r); +typedef Report LegacyMaker(int n); +typedef List LegacyLoader([Report? seed]); +typedef m.Report PrefixedLegacy(m.Report r); +typedef Mapper = T Function(Report r); +typedef Picker = T Function(T a, Report r); +typedef T LegacyPicker(T a, Report r); +typedef Bounded = List; +typedef Sdk = Future; +typedef Dynamic = dynamic Function(int n); +typedef Prefixed = m.Report; +typedef Named = void Function({required Report report, int? n}); +typedef Pair = (Report, {m.Report other}); +typedef Nested = void Function(Report Function(int) make); +/// Documented. +@Deprecated('Use ReportCallback') +typedef OldCallback = void Function(Report r); +typedef Formatted = + Map Function( + Report first, + Report second, + ); +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe('a Dart typedef references the types it aliases (#2327)', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['dart']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource('lib/typedefs.dart', source, 'dart'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract('lib/typedefs.dart', source, 'dart'); + expect(result, 'kernel extraction').not.toBeNull(); + return result!; + } + + /** ` ` for every `references` ref out of a typedef, sorted. */ + function typedefRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && byId.get(r.fromNodeId)?.kind === 'type_alias') + .map((r) => `${byId.get(r.fromNodeId)!.name} ${r.referenceName}`) + .sort(); + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`a legacy typedef is named by its own name, not its return type: %s${label}`, (backend) => { + const result = extract(backend, eol(SOURCE)); + expect(result.nodes.filter((n) => n.kind === 'type_alias').map((n) => n.name)).toEqual([ + 'ReportCallback', + 'ReportMaker', + 'Reports', + 'ReportsByName', + 'Results', + 'LegacyCallback', + 'LegacyMaker', + 'LegacyLoader', + 'PrefixedLegacy', + 'Mapper', + 'Picker', + 'LegacyPicker', + 'Bounded', + 'Sdk', + 'Dynamic', + 'Prefixed', + 'Named', + 'Pair', + 'Nested', + 'OldCallback', + 'Formatted', + ]); + // An annotated typedef keeps the dartdoc above its annotation. + expect(result.nodes.find((n) => n.name === 'OldCallback')!.docstring).toBe('Documented.'); + }); + + it.each(backends)(`every type the alias names is referenced from the typedef: %s${label}`, (backend) => { + const source = eol(SOURCE); + const result = extract(backend, source); + // Built-ins (`void`, `int`, `String`, `dynamic`) are no references, an + // import prefix (`m`) is none, and neither is a type parameter (`T`, + // `X`), declared by the typedef or by a generic function type. + expect(typedefRefs(result)).toEqual([ + 'Bounded List', + 'Bounded Report', + 'Formatted Map', + 'Formatted Report', + 'Formatted Report', + 'Formatted Report', + 'LegacyCallback Report', + 'LegacyLoader List', + 'LegacyLoader Report', + 'LegacyLoader Report', + 'LegacyMaker Report', + 'LegacyPicker Report', + 'Mapper Report', + 'Named Report', + 'Nested Report', + 'OldCallback Report', + 'Pair Report', + 'Pair Report', + 'Picker Report', + 'Prefixed Report', + 'PrefixedLegacy Report', + 'PrefixedLegacy Report', + 'ReportCallback Report', + 'ReportMaker Report', + 'Reports List', + 'Reports Report', + 'ReportsByName Map', + 'ReportsByName Report', + 'Results Future', + 'Results List', + 'Results Report', + 'Sdk Future', + ]); + // Each sits on the type's name, where resolution reads an import prefix back. + const lines = source.split(/\r?\n/); + const byId = new Map(result.nodes.map((n) => [n.id, n])); + for (const r of result.unresolvedReferences.filter((x) => byId.get(x.fromNodeId)?.kind === 'type_alias')) { + expect(lines[r.line - 1]!.startsWith(r.referenceName, r.column), `${r.referenceName} at ${r.line}:${r.column}`).toBe(true); + } + // The file takes none of them: no type is credited twice. + const file = result.nodes.find((n) => n.kind === 'file')!; + expect(result.unresolvedReferences.filter((r) => r.fromNodeId === file.id && r.referenceKind === 'references')).toEqual([]); + }); + } +}); + +describe('an indexed Dart library links a typedef to the types it aliases (#2327)', () => { + const FILES: Record = { + 'pubspec.yaml': 'name: repro\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n', + // The issue's repro. + 'lib/report.dart': 'class Report {\n final int score;\n const Report(this.score);\n}\n', + 'lib/callback.dart': "import 'report.dart';\n\ntypedef ReportCallback = void Function(Report r);\n", + // A namesake reached only through the `m.` prefix. + 'lib/model.dart': 'class Report {}\n', + // Project types named like the type parameters, imported where those are declared. + 'lib/params.dart': 'class T {}\n\nclass X {}\n', + // A project `Future` the typedefs' library does not import. + 'lib/async.dart': 'class Future {}\n', + 'lib/typedefs.dart': `import 'model.dart' as m; +import 'params.dart'; +import 'report.dart'; + +typedef ReportMaker = Report Function(); +typedef Reports = List; +typedef Report LegacyMaker(int n); +typedef Mapper = T Function(Report r); +typedef Picker = T Function(T a, Report r); +typedef Bounded = List; +typedef Sdk = Future; +typedef Prefixed = m.Report; +typedef Pair = (Report, {m.Report other}); +`, + }; + + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-typedef-refs-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + kernel = process.env.CODEGRAPH_KERNEL; + }); + + afterEach(() => { + cg?.close(); + cg = undefined; + fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + it.each(['default', 'wasm'])('callers of a type list the typedefs built on it (%s)', async (backend) => { + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + cg = await CodeGraph.init(root, { index: true }); + const graph = cg; + const node = (file: string, name: string): Node => { + const found = graph.getNodesInFile(file).filter((n) => n.name === name && n.kind !== 'import'); + expect(found, `${name} in ${file}`).toHaveLength(1); + return found[0]!; + }; + /** `kind file:qualifiedName` of each `references` target of the typedef `name`, sorted. */ + const referenced = (name: string): string[] => + [...new Set(graph + .getOutgoingEdgesFrom([node('lib/typedefs.dart', name).id], ['references']) + .map((e) => graph.getNode(e.target)!) + .map((n) => `${n.kind} ${n.filePath}:${n.qualifiedName}`))] + .sort(); + + // `callers Report` lists every typedef that names it, the issue's first. + const callers = graph.getCallers(node('lib/report.dart', 'Report').id).map(({ node: n }) => `${n.kind} ${n.filePath}:${n.name}`); + expect(callers.filter((c) => c.startsWith('type_alias')).sort()).toEqual([ + 'type_alias lib/callback.dart:ReportCallback', + 'type_alias lib/typedefs.dart:Bounded', + 'type_alias lib/typedefs.dart:LegacyMaker', + 'type_alias lib/typedefs.dart:Mapper', + 'type_alias lib/typedefs.dart:Pair', + 'type_alias lib/typedefs.dart:Picker', + 'type_alias lib/typedefs.dart:ReportMaker', + 'type_alias lib/typedefs.dart:Reports', + ]); + + // A prefixed type is the one its import names. + expect(referenced('Prefixed')).toEqual(['class lib/model.dart:Report']); + expect(referenced('Pair')).toEqual(['class lib/model.dart:Report', 'class lib/report.dart:Report']); + // A type parameter is not the project's `T` or `X`, though both are imported. + expect(referenced('Mapper')).toEqual(['class lib/report.dart:Report']); + expect(referenced('Picker')).toEqual(['class lib/report.dart:Report']); + expect(referenced('Bounded')).toEqual(['class lib/report.dart:Report']); + expect(graph.getCallers(node('lib/params.dart', 'T').id)).toEqual([]); + expect(graph.getCallers(node('lib/params.dart', 'X').id)).toEqual([]); + // The SDK's Future is not a project class the library does not import. + expect(referenced('Sdk')).toEqual([]); + expect(graph.getCallers(node('lib/async.dart', 'Future').id)).toEqual([]); + // The legacy typedef is named LegacyMaker; nothing else is named Report there. + expect(graph.getNodesInFile('lib/typedefs.dart').filter((n) => n.name === 'Report')).toEqual([]); + expect(referenced('LegacyMaker')).toEqual(['class lib/report.dart:Report']); + }, 60_000); +}); diff --git a/__tests__/fixtures/kernel-parity/TortureTypedefs.dart b/__tests__/fixtures/kernel-parity/TortureTypedefs.dart new file mode 100644 index 000000000..f152ad8f3 --- /dev/null +++ b/__tests__/fixtures/kernel-parity/TortureTypedefs.dart @@ -0,0 +1,78 @@ +// Typedefs reference the types they alias (#2327): every new-style and legacy +// shape, type parameters (the typedef's own and a generic function type's), +// import prefixes, annotations, comments and wrapped declarations. +import 'package:meta/meta.dart'; +import 'model.dart' as m; +import 'model.dart' as M; + +class Report { + final int score; + const Report(this.score); +} + +class T {} + +void tag(Object o) {} + +// New style. +typedef ReportCallback = void Function(Report r); +typedef ReportMaker = Report Function(); +typedef Reports = List; +typedef ReportsByName = Map; +typedef Results = Future?> Function(int page); +typedef Opt = Report?; +typedef MaybeMaker = Report Function()?; +typedef Pair = (Report, int); +typedef NamedPair = ({Report first, m.Report second}); +typedef Named = void Function({required Report report, int? n}); +typedef Positional = void Function([Report? report]); +typedef Unnamed = void Function(Report, m.Report); +typedef Nested = void Function(void Function(Report) inner); +typedef Maker = void Function(Report Function() make); +typedef Dynamic = dynamic Function(Object? o); +typedef Void = void; +typedef Fn = Function; +typedef Prefixed = m.Report; +typedef UpperPrefixed = M.Report; +typedef PrefixedGeneric = m.Box; + +// Type parameters: the typedef's own, a bound, a generic function type's. +typedef Mapper = T Function(Report r); +typedef Bounded = List; +typedef SelfBounded> = int Function(C a, C b); +typedef Picker = T Function(T a, Report r); +typedef Outer = S Function(U u, S s, T t); +typedef Shadow = m.T Function(T t); +typedef TwoParams = Map>; + +// Legacy. +typedef void LegacyCallback(Report r); +typedef Report LegacyMaker(int n); +typedef List LegacyLoader([Report? seed]); +typedef m.Report PrefixedLegacy(m.Report r); +typedef Report? LegacyOptional({Report? fallback}); +typedef void Function(Report) LegacyFnReturn(int n); +typedef T LegacyPicker(T a, Report r); +typedef void LegacyFnParam(Report make(int n)); +typedef void LegacyGenericFnParam(S make(S s, Report r)); +typedef void LegacyCovariant(covariant Report r, final Report s); + +// Annotated, documented, commented, wrapped. +/// Documented. +@Deprecated('Use ReportCallback') +typedef OldCallback = void Function(Report r); +@visibleForTesting +@Tag(tag) +typedef Tagged = Report Function(/* the report */ Report r); +typedef Commented = // why + Report Function(Report r); +typedef Wrapped = + Map Function( + Report first, + Report second, + ); + +class Tag { + const Tag(this.f); + final Object f; +} diff --git a/__tests__/kernel-dart-parity.test.ts b/__tests__/kernel-dart-parity.test.ts index 487fcfd17..c1f868a1b 100644 --- a/__tests__/kernel-dart-parity.test.ts +++ b/__tests__/kernel-dart-parity.test.ts @@ -37,8 +37,10 @@ * TortureAnnotatedComments.dart: the comments between annotations and their * declaration, stepped over by the decorator scan; TortureDirectives.dart / * TorturePartOfName.dart: `part` as an import of the part's file, `part of` - * as nothing, and the arguments of annotated directives) and their CRLF - * variants (derived in-memory — #1329), plus defer and generated-file pins. + * as nothing, and the arguments of annotated directives; + * TortureTypedefs.dart: the types a typedef aliases, in every form) and + * their CRLF variants (derived in-memory — #1329), plus defer and + * generated-file pins. * * The full-repo sweeps live in scripts/kernel-parity.mjs (shelf/bloc/flutter * with --max-deferral 0.3); this suite keeps the invariant alive in @@ -167,6 +169,11 @@ describe.skipIf(!kernelBuilt)('kernel Dart extraction parity', () => { ['TortureDirectives.dart', 20], // `part of` a library named by its library name records nothing. ['TorturePartOfName.dart', 3], + // Typedefs reference the types they alias (#2327), new style and legacy + // (named by its own name, not its return type), with type parameters — + // the typedef's own and a generic function type's — skipped, prefixed, + // annotated, commented and wrapped declarations. + ['TortureTypedefs.dart', 40], ] as const; for (const [file, minNodes] of FIXTURES) { diff --git a/codegraph-kernel/src/dart.rs b/codegraph-kernel/src/dart.rs index 78bc8a5db..7dcc5ef1b 100644 --- a/codegraph-kernel/src/dart.rs +++ b/codegraph-kernel/src/dart.rs @@ -156,6 +156,23 @@ fn next_named<'t>(node: Node<'t>) -> Option> { next } +/// dartTypeAliasName (dart.ts) — the name a typedef declares: the last type +/// name before the `=`, the type parameters or the parameter list. The +/// legacy form writes its return type first (`typedef Report LegacyMaker(int +/// n);`), so the first type name is not always it. +fn type_alias_name<'t>(node: Node<'t>) -> Option> { + let mut name = None; + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + match child.kind() { + "=" | "type_parameters" | "formal_parameter_list" => break, + "type_identifier" => name = Some(child), + _ => {} + } + } + name +} + struct Scope { row: u32, kind: &'static str, @@ -668,6 +685,14 @@ impl<'t> Walker<'t> { /// name field → the method_signature inner unwrap → identifier-ish /// child → `` (operators land here). fn extract_name(&self, node: Node<'t>) -> String { + // resolveName hook (dart.ts): a typedef has no `name` field, and a + // legacy one writes its return type before its name (`typedef Report + // LegacyMaker(int n);`). + if node.kind() == "type_alias" { + if let Some(name) = type_alias_name(node) { + return self.text(name).to_string(); + } + } // resolveName hook (dart.ts:244-260): named ctor/factory → ctor name. if let Some((class_name, ctor_name)) = self.ctor_info(node) { if ctor_name != class_name { @@ -1034,12 +1059,76 @@ impl<'t> Walker<'t> { return false; } let docstring = self.docstring_of(node); - // `value` field is null (type_alias has no fields) → no refs from - // the aliased type; returns false → children re-visited. - self.create_node("type_alias", &name, node, Extra { docstring, ..Default::default() }); + // type_alias has no fields (no `value`): the dart branch reads the + // aliased type from the rest of the declaration (#2327). Returns + // false → children re-visited (they match nothing type-related). + let row = self.create_node("type_alias", &name, node, Extra { docstring, ..Default::default() }); + if let Some(row) = row { + self.type_alias_refs(node, row); + } false } + /// pushDartTypeAliasRefs (dart.ts) — a `references` ref from the typedef + /// for every type its aliased type names; its annotations are not its + /// type, and a type parameter is no type of the project's. + fn type_alias_refs(&mut self, node: Node<'t>, from_row: u32) { + let name = type_alias_name(node); + let scope = self.type_parameter_scope(node, &[]).unwrap_or_default(); + let mut cursor = node.walk(); + let kids: Vec> = node.named_children(&mut cursor).collect(); + for child in kids { + if child.kind() == "annotation" || Some(child) == name { + continue; + } + self.aliased_type_refs(child, from_row, &scope); + } + } + + /// dartTypeParameterScope (dart.ts) — `scope` and the type parameters + /// `node` declares (`Mapper`, `T Function(T a)`), or None when it + /// declares none. + fn type_parameter_scope(&self, node: Node<'t>, scope: &[&'t str]) -> Option> { + let mut cursor = node.walk(); + let parameters = node.named_children(&mut cursor).find(|c| c.kind() == "type_parameters")?; + let mut inner = scope.to_vec(); + let mut pc = parameters.walk(); + for parameter in parameters.named_children(&mut pc) { + if parameter.kind() != "type_parameter" { + continue; + } + let mut ic = parameter.walk(); + let name = parameter.named_children(&mut ic).find(|c| c.kind() == "type_identifier"); + if let Some(name) = name { + inner.push(self.text(name)); + } + } + Some(inner) + } + + /// pushDartAliasedTypeRefs (dart.ts) — type_refs_dart for a typedef's + /// aliased type, where a type parameter in `scope` is no type of its own + /// (but `p.T` is the type an import names). + fn aliased_type_refs(&mut self, node: Node<'t>, from_row: u32, scope: &[&'t str]) { + stack_guard!(); + if node.kind() == "type_identifier" { + let text = self.text(node); + let parameter = + scope.contains(&text) && !node.prev_sibling().map(|s| s.kind() == ".").unwrap_or(false); + if !parameter && self.is_type_name_dart(node) { + self.push_ref_at(from_row, text, "references", node); + } + return; + } + let inner = self.type_parameter_scope(node, scope); + let scope = inner.as_deref().unwrap_or(scope); + let mut cursor = node.walk(); + let kids: Vec> = node.named_children(&mut cursor).collect(); + for child in kids { + self.aliased_type_refs(child, from_row, scope); + } + } + // --- extractImport (:3170; hook dart.ts extractImport) ----------------- fn extract_import(&mut self, node: Node<'t>) { diff --git a/docs/design/dart-kernel-port-checklist.md b/docs/design/dart-kernel-port-checklist.md index 0386b419a..d130ad112 100644 --- a/docs/design/dart-kernel-port-checklist.md +++ b/docs/design/dart-kernel-port-checklist.md @@ -226,7 +226,12 @@ Grammar-shape facts the config leans on (all probed, `mini-cst.txt` / keyword node, identifier, on-types as bare type_identifiers, class_body). `enum_declaration`: `name:` + `body:` (enum_body); `enum_constant.name` ✓. `type_alias` has NO fields (both modern `typedef X = T` and legacy - `typedef void X(int)` are node `type_alias`). + `typedef void X(int)` are node `type_alias`). Its name is the last direct + `type_identifier` before the `=`, `type_parameters` or + `formal_parameter_list` child: a legacy return type comes first (`typedef + Report X(int n)`), and until 2026-10-21 the first-identifier fallback + named such a typedef after its return type (`dartTypeAliasName` / + `type_alias_name` now, via resolveName). `initialized_variable_definition`: `name:` + one-or-more `value:` fields (the RHS postfix chain is FLATTENED into multiple value-fielded siblings). - **In-class member shapes:** methods/getters/setters/operators/factories @@ -472,7 +477,7 @@ Registration: `EXTRACTORS.dart` (languages/index.ts:56), `FN_REF_SPECS.dart` | `mixin_declaration`, `extension_declaration` | extraClassNodeTypes:1022 | extractClass(node) → kind **`class`** | | `method_signature`, `constructor_signature` | methodTypes:1027 | **NOT class-gated at the ladder** — extractMethod:1737 runs anywhere; its own gate :1747 (not class-like, no methodsAreTopLevel, no receiver) falls back: parent `object`/`object_expression`? (never in dart) else **extractFunction** — this is how extension-TYPE members and any stray non-class method_signature become plain `function` nodes (pinned: `extension type MetersT` → function `km`, bare QN) | | `enum_declaration` | enumTypes:1064 → extractEnum:1914 | §Class family | -| `type_alias` | typeAliasTypes:1071 → extractTypeAlias:2890 | plain type_alias node; `getChildByField(node,'value')` → **null** (no fields) → **NO refs from the aliased type** (`typedef MapAlias = Map` emits nothing); returns false → children re-visited (function_type/formal_parameter_list children match nothing) | +| `type_alias` | typeAliasTypes:1071 → extractTypeAlias:2890 | type_alias node named by the resolveName hook (§Extractor config); `getChildByField(node,'value')` → **null** (no fields), so the dart branch — `pushDartTypeAliasRefs` / `type_alias_refs`, since 2026-10-21 (#2327; nothing before) — emits a `references` ref from the node for each type named in every named child but the name and `annotation`s, through the isDartTypeName gate (`typedef MapAlias = Map` → `Map`, `WidgetT`). A type parameter in scope is skipped unless written `p.T`: the typedef's own, and those of any node that declares `type_parameters` (a generic `function_type`, an old-style generic function-typed `formal_parameter`) within it. Returns false → children re-visited (they match nothing type-related; an annotated typedef's arguments are fn-ref capture points) | | `import_or_export`, `part_directive` | importTypes:1209 → extractImport:3170 | §Extractor config. No skipChildren: the directive's children are still recursed, so an annotated directive's arguments are fn-ref capture points (`@Tag(f) import 'a.dart';` captures `f`). The kernel arm recurses too — since 2026-10-06; before, its `import_or_export` arm returned and missed those (TortureDirectives.dart pins both) | | `new_expression` | INSTANTIATION_KINDS:1255 (`new_expression` ∈ :354-361) | extractInstantiation:4610 → ctor field lookups null → namedChild(0) = type_identifier → `instantiates` ref from stack top; `<`-strip + last-`.`-segment apply (`new p.Foo()` → `Foo`). findAnonymousClassBody → always null for dart. Children still recursed | | `function_body` (sibling of a consumed signature) | **no branch** | recursed → THE DOUBLE-WALK (§below) | @@ -1299,8 +1304,9 @@ NO unwrap, NO ungatedModes, NO addressOfOnly. Pins: sealed/base modifiers (plain classes); enum (simple + enhanced: ctor'd constants at constant positions, args unwalked, `with` GAP vs `implements` ✓, members after `;`); typedef ×3 (modern/legacy/generic — - zero refs); extension type (container invisible, **members leak as bare - `function`s**); the FULL call matrix (§Calls rows verbatim — incl. + zero refs until 2026-10-21, the aliased types since; every form is in + TortureTypedefs.dart); extension type (container invisible, **members + leak as bare `function`s**); the FULL call matrix (§Calls rows verbatim — incl. cascade invisibility, `?.`, const-object args, chain re-encodes, interpolation calls, `$x` vs `${x}`); static reads (`X.member`, `X.method()` double-emission, 3-segment nothing, case-pattern GAP); diff --git a/src/extraction/languages/dart.ts b/src/extraction/languages/dart.ts index 6f68fb234..6fe24dccd 100644 --- a/src/extraction/languages/dart.ts +++ b/src/extraction/languages/dart.ts @@ -43,6 +43,82 @@ export function pushDartTypeRefs( for (const child of node.namedChildren) pushDartTypeRefs(child, fromNodeId, push); } +/** + * The name a typedef declares. The grammar gives `type_alias` no fields, and + * the first type name is not always it: the legacy form writes its return + * type first (`typedef Report LegacyMaker(int n);`). The name is the last type + * name before the `=`, the type parameters or the parameter list. + */ +export function dartTypeAliasName(node: SyntaxNode): SyntaxNode | undefined { + let name: SyntaxNode | undefined; + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (!child || child.type === '=' || child.type === 'type_parameters' || child.type === 'formal_parameter_list') break; + if (child.type === 'type_identifier') name = child; + } + return name; +} + +/** `scope` and the type parameters `node` declares, when it declares any (`Mapper`, `T Function(T a)`). */ +function dartTypeParameterScope(node: SyntaxNode, scope: ReadonlySet): ReadonlySet { + const parameters = node.namedChildren.find((c: SyntaxNode) => c.type === 'type_parameters'); + if (!parameters) return scope; + const inner = new Set(scope); + for (const parameter of parameters.namedChildren) { + const name = parameter.type === 'type_parameter' + ? parameter.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier') + : undefined; + if (name) inner.add(name.text); + } + return inner; +} + +/** pushDartTypeRefs for a typedef's aliased type: a type parameter in `scope` is no type of its own. */ +function pushDartAliasedTypeRefs( + node: SyntaxNode, + fromNodeId: string, + scope: ReadonlySet, + push: (ref: UnresolvedReference) => void, +): void { + if (node.type === 'type_identifier') { + // `p.T` is the type an import names, whatever is declared as `T` here. + const parameter = scope.has(node.text) && node.previousSibling?.type !== '.'; + if (!parameter && isDartTypeName(node)) { + push({ + fromNodeId, + referenceName: node.text, + referenceKind: 'references', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + return; + } + const inner = dartTypeParameterScope(node, scope); + for (const child of node.namedChildren) pushDartAliasedTypeRefs(child, fromNodeId, inner, push); +} + +/** + * A `references` ref from the typedef `node` for every type its aliased type + * names (#2327): a function type's return and parameter types, generic + * arguments, record fields, a type parameter's bound, and the legacy form's + * return and parameter types. A type parameter, the typedef's own or a + * generic function type's (`T Function(T a)`), means that parameter and no + * type of the project's. The typedef's annotations are not its type. + */ +export function pushDartTypeAliasRefs( + node: SyntaxNode, + fromNodeId: string, + push: (ref: UnresolvedReference) => void, +): void { + const name = dartTypeAliasName(node); + const scope = dartTypeParameterScope(node, new Set()); + for (const child of node.namedChildren) { + if (child.type === 'annotation' || (name && child.equals(name))) continue; + pushDartAliasedTypeRefs(child, fromNodeId, scope, push); + } +} + /** The bodies a Dart field `declaration` can sit in: a class's or mixin's, an extension's, an enum's. */ const DART_MEMBER_BODIES: ReadonlySet = new Set(['class_body', 'extension_body', 'enum_body']); @@ -646,6 +722,9 @@ export const dartExtractor: LanguageExtractor = { return false; }, resolveName: (node) => { + // A typedef has no `name` field, and a legacy one writes its return type + // before its name (`typedef Report LegacyMaker(int n);`). + if (node.type === 'type_alias') return dartTypeAliasName(node)?.text; // `class A = B with C;` — a mixin application — names its class inside // the `mixin_application_class`, not in a `name` field. if (node.type === 'class_definition') { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index ee757ab08..2cffc3a49 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -26,7 +26,7 @@ import { NestedIntervals, scanCppBraceScopes, type CppBraceScopes } from './lang import { appendCppMacroMethods } from './languages/cpp-macro-methods'; import { rustImplItemOwner, rustImplTypeName } from './languages/rust'; import { goAliasTypeNames, goEmbeddedTypeName, goInterfaceMethodTypeNames, goStructFieldTypeNames } from './languages/go'; -import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; +import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeAliasRefs, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; import { SvelteExtractor } from './svelte-extractor'; @@ -4246,6 +4246,12 @@ export class TreeSitterExtractor { column: type.startPosition.column, }); } + } else if (this.language === 'dart') { + // A Dart typedef has no fields either: the type it aliases is the + // rest of the declaration, `void Function(Report r)` in `typedef + // ReportCallback = void Function(Report r);`, or a legacy one's + // return and parameter types (#2327). + pushDartTypeAliasRefs(node, typeAliasNode.id, (ref) => this.unresolvedReferences.push(ref)); } } return false;