Files
codegraph/__tests__/dart-getter-reads.test.ts
Colby Mchenry 9f69e47836 fix(dart): link getter reads, enum-extension members and more type positions (#2338, #2327) (#2360)
Issue #2338: a Dart getter read (`x.area`, `s.label`) linked nothing, so
`codegraph callers area` was empty while `x.grow()` worked, and a member
an extension adds to an ENUM (`s.shout()` through `extension ShapeInfo on
Shape`) never resolved. Issue #2327: `callers Report` saw only parameter
and return types; the type an extension is `on`, field types, and types
named in bodies and initializers (`Future<Report?>.value(null)`, riverpod's
`final reportProvider = Family<Report?, String>()`) recorded nothing.

Causes:
- The Dart body walker only emitted calls (a selector with an argument
  list); a member read is a selector without one, so it produced no ref.
- For a typed receiver, resolveMethodOnType looks for `Type::member` and
  the type's supertype edges; an extension's member is `ShapeInfo::shout`.
  On a class receiver the call fell through to name matching, which found
  a unique `twice` by luck; on an enum the "type the project doesn't
  declare" guard (it only counts classes) returned null.
- Type references were taken from function and method signatures only.

Fix (TS extractor and the Rust kernel, mirrored):
- A member read `x.area` / `x?.area` / `Type.getter` whose receiver is a
  plain name, and that is not a call, is sent as a `references` ref named
  `x.area` on the member. The resolver (matchDartMemberRead, ahead of
  every other strategy) types the receiver — a static read through a
  project type, a parameter or local in scope, else a field of the
  enclosing class — and links the getter that type reaches: declared on
  it or a type it extends, mixes in or implements, else added by an
  extension on one of those (an unnamed extension only in its own
  library). The edge is `calls`: a Dart getter is a method node, reading
  it runs it, and `calls` is what callers, impact and explore's flow
  follow (as Ruby's paren-less `x.area` and VB.NET's paren-less calls
  through #2355 already are). A field read, a getter of a type outside the
  project and an untyped receiver link nothing; there is no name-only
  fallback. Kotlin/Swift/C#/TS link no instance property reads today.
- resolveMethodOnType, for Dart, falls back to a member an extension on
  the type (or a supertype) adds when no declaration of the type has it.
- `references` refs for the type an extension is `on`, a field's declared
  type and initializer (from its class — Dart fields mint no nodes), an
  enum field, a top-level variable's type (from the file), a constant's
  initializer (from the constant), and every type a function body names:
  locals, generic arguments, casts, type tests, catch clauses. Skipped: a
  `new`/`const` constructor's class (already instantiates/calls), an
  import prefix (`p` in `p.Foo`), built-ins, and lowercase names, which in
  Dart are built-ins (`num`, `dynamic`) or error-recovery artifacts around
  syntax the grammar predates (dot shorthands). A class whose field holds
  its own type does not get a self-edge.

Verification: __tests__/dart-getter-reads.test.ts (7 tests) and
__tests__/dart-type-positions.test.ts (3 tests) fail 8/10 on main (both
the TS path and main's kernel) and pass on both arms here; the 2 that pass
on main are the controls. New kernel parity fixture TortureReadsTypes.dart
(+CRLF); kernel-dart-parity passes with CODEGRAPH_KERNEL_EXPECT=1, and
scripts/kernel-parity.mjs over bloc/riverpod/flutter-samples is 0 diffs
(2,103 files byte-parity, 300 parse-error files defer as before). tsc and
build clean; the issues' repros now list c/a/b for area/label/shout and
Holder, ReportX, reportProvider and run for Report.

Validation (kernel loaded, main vs this branch): nodes unchanged, 0 edges
removed, no new self-loops.
- felangel/bloc: 16,372 -> 18,276 edges (+550 getter-read calls, +1,354
  type references)
- rrousselGit/riverpod: 76,432 -> 81,909 (+598 getter reads, +13
  extension-method calls, +4,866 type references)
- flutter/samples: 26,133 -> 26,943 (+142 getter reads, +668 type refs)
Spot-checked 16 getter reads and 16 type references against source.
Index time within noise on riverpod (median of 5 interleaved runs); reads
that link nothing are kept for re-resolution like failed calls (riverpod
db 92.9 -> 103.2 MB).

No EXTRACTION_VERSION bump: it already moved to 28 this release.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:12:24 +00:00

166 lines
5.0 KiB
TypeScript

/**
* Dart getter reads and extension members on enums (#2338).
*
* Reading a getter runs it: `x.area` calls `Box`'s `int get area`. A read
* linked to nothing, so `callers area` was empty while `x.grow()` worked. And
* a member an `extension … on` an ENUM adds (`s.shout()`) resolved nowhere,
* while the same extension on a class did.
*
* A read links only a getter the receiver's type reaches — never a field (they
* mint no nodes), a getter of a type outside the project, or a same-named
* getter guessed by name.
*/
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-getters-'));
const files: Record<string, string> = {
'pubspec.yaml': 'name: repro\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n',
// The issue's two files, verbatim.
'lib/shape.dart': `enum Shape { circle, square }
extension ShapeInfo on Shape {
String get label => name.toUpperCase();
String shout() => name.toUpperCase();
}
class Box {
const Box(this.size);
final int size;
int get area => size * size;
int grow() => size + 1;
}
extension BoxActions on Box {
int twice() => size * 2;
}
`,
'lib/use.dart': `import 'shape.dart';
String a(Shape s) => s.label; // getter, extension on enum
String b(Shape s) => s.shout(); // method, extension on enum
int c(Box x) => x.area; // getter, plain class
int d(Box x) => x.grow(); // method, plain class
int e(Box x) => x.twice(); // method, extension on class
`,
// Block bodies and class methods, through a parameter, a local and a field.
'lib/render.dart': `import 'shape.dart';
class Square extends Box {
const Square(int size) : super(size);
static Square get unit => const Square(1);
}
class Renderer {
final Box box;
final Shape shape;
Renderer(this.box, this.shape);
int paint(Box other) {
final local = Box(2);
return other.area + local.area;
}
int viaField() {
return box.area;
}
String tag() {
final text = shape.label;
return text + shape.shout();
}
}
int inherited(Square s) {
return s.area;
}
Square staticGetter() => Square.unit;
`,
// Reads that must link nothing.
'lib/other.dart': `class Panel {
int get size => 0;
int get length => 0;
}
extension on Box {
int get hidden => 0;
}
`,
'lib/negatives.dart': `import 'shape.dart';
int fieldRead(Box x) => x.size; // a field, not Panel's getter
int outside(List<int> xs) => xs.length; // a getter of a type outside the project
int untyped(dynamic d) => d.area; // nothing says what d is
int unnamed(Box x) => x.hidden; // an unnamed extension in another library
`,
};
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);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** `kind name` of each caller of the node with qualified name `qn`, sorted. */
function callersOf(qn: string): string[] {
const [target] = cg.getNodesByQualifiedName(qn);
expect(target, `no node ${qn}`).toBeDefined();
return cg.getCallers(target!.id).map(({ node, edge }) => `${edge.kind} ${node.qualifiedName}`).sort();
}
describe('Dart getter reads (#2338)', () => {
it('link a getter read on a plain class, as a call', () => {
expect(callersOf('Box::area')).toContain('calls c');
});
it('link a getter an extension on an enum adds', () => {
expect(callersOf('ShapeInfo::label')).toEqual(['calls Renderer::tag', 'calls a']);
});
it('link reads in block bodies and class methods, through parameters, locals and fields', () => {
expect(callersOf('Box::area')).toEqual([
'calls Renderer::paint',
'calls Renderer::viaField',
'calls c',
'calls inherited',
]);
});
it('link a static getter read through its class', () => {
expect(callersOf('Square::unit')).toEqual(['calls staticGetter']);
});
it('link nothing for a field, an outside type, an untyped receiver or an out-of-library unnamed extension', () => {
const ids = cg.getNodesInFile('lib/negatives.dart').map((n) => n.id);
const targets = cg.getOutgoingEdgesFrom(ids, ['calls'])
.map((edge) => cg.getNode(edge.target)!.qualifiedName);
expect(targets).toEqual([]);
expect(callersOf('Panel::size')).toEqual([]);
expect(callersOf('Panel::length')).toEqual([]);
});
});
describe('Dart extension members on an enum (#2338)', () => {
it('resolve a method an extension on an enum adds', () => {
expect(callersOf('ShapeInfo::shout')).toEqual(['calls Renderer::tag', 'calls b']);
});
it('still resolve methods on a class and its extensions', () => {
expect(callersOf('Box::grow')).toEqual(['calls d']);
expect(callersOf('BoxActions::twice')).toEqual(['calls e']);
});
});