mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-16 20:53:57 +08:00
652c36b72a
* fix(dart): index const constructors and redirecting factories; a call through a type stays on that type Both Dart extractors (the TS walker and the native kernel) skipped `const` constructors and redirecting factories (`const Foo.bar(...)`, `const factory Foo.bar() = _Bar;`), which parse as signature kinds of their own. A call such as flutter_bloc's `BlocProvider.value(...)` then found no method on BlocProvider, and the resolver guessed another type's `value`: on felangel/bloc, 89 calls linked to `RepositoryProvider::value`. Extraction (TS + kernel, at parity): - Named const constructors and redirecting factories are methods named by the constructor, returning their class, with a parameters-only signature; the unnamed ones stay unindexed. The name is read only from before the parameter list, so a redirect target (`= _Impl.named`) never names it. - A redirecting factory references the class it redirects to, not the target constructor's name or an import prefix. - A constructor called with type arguments keeps its type: `BlocProvider<CounterCubit>.value(...)` is `BlocProvider.value`, as a statement and as an expression (`constructor_invocation`, which produced no call at all before). Resolver: - A Dart call through a type's name reaches only that type's own constructor, static member or static constant, or nothing: no guess by name, no inherited lookup, no same-file look-alike, no instance member of the same name. SDK calls like `Uri.parse` no longer land on a project `parse` past an `extension on Uri`. - A named constructor is never reached by a bare name, the last link of a chain, a function value or an annotation (riverpod's 210 `@internal` annotations). Validated on felangel/bloc @b9be1e2 and rrousselGit/riverpod @4ba1be2 with the kernel loaded: bloc 18,338 -> 18,495 edges, riverpod 85,606 -> 85,854, every removed edge triaged. Afterwards every edge into a named constructor is written `Owner.ctor`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dart): read a type's static members one way for calls and chains - A call through a type's name now finds its member with dartStaticMember, the lookup a chain's static link uses since #2376, so both pick the same member: the call site's own file first, then the nearest copy. - isDartStaticMember's constructor test is isDartConstructor, anchored at the declaration: an instance getter declared just above a same-named factory (`Object? get error` over `const factory AsyncValue.error(...)`) read the factory from the next lines and counted as static. - clearNameMatcherMemos drops DART_CONSTRUCTORS with the other source-derived Dart memos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
/**
|
|
* Dart `const` constructors and redirecting factories are methods, and a call
|
|
* through a type's name is to that type's own member or to nothing.
|
|
*
|
|
* `const Foo.bar(…)` and `const factory Foo.bar() = _Bar;` parse as signature
|
|
* kinds of their own, which neither extractor indexed. So flutter_bloc's
|
|
* `BlocProvider.value(…)` found no `value` on BlocProvider, and the resolver
|
|
* guessed the one other `value` — RepositoryProvider's non-const constructor —
|
|
* for all 89 calls in felangel/bloc.
|
|
*
|
|
* Dart inherits no static member and no constructor, so a call through a type
|
|
* the project declares never means another type's method: not a same-named
|
|
* one guessed by name, not a supertype's, not a same-file look-alike's. And
|
|
* the SDK's `Uri.parse(…)` is not a project `parse` because the project has an
|
|
* `extension on Uri`.
|
|
*
|
|
* A constructor is only ever called through its class. Written with type
|
|
* arguments — `BlocProvider<CounterCubit>.value(…)`, most of bloc's tests —
|
|
* the call still names the type, and a bare name or a chain's last link
|
|
* (`box.inner.value(1)`) never means a constructor.
|
|
*
|
|
* Runs against the native kernel (when built) and the wasm extractor, which
|
|
* must agree.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import type { Node } from '../src/types';
|
|
|
|
const FILES: Record<string, string> = {
|
|
'pubspec.yaml': 'name: app\nenvironment:\n sdk: ">=3.0.0 <4.0.0"\n',
|
|
'lib/bloc_provider.dart': `class BlocProvider<T> {
|
|
const BlocProvider({required T Function() create}) : _create = create, _value = null;
|
|
|
|
const BlocProvider.value({required T value}) : this._(value: value);
|
|
|
|
const BlocProvider._({T? value}) : _create = null, _value = value;
|
|
|
|
final T Function()? _create;
|
|
final T? _value;
|
|
|
|
void handle(Object value) => accept(value);
|
|
}
|
|
|
|
void accept(Object o) {}
|
|
`,
|
|
'lib/repository_provider.dart': `class RepositoryProvider<T> {
|
|
RepositoryProvider({required T Function() create});
|
|
|
|
RepositoryProvider.value({required T value});
|
|
|
|
static T of<T>(Object context) => throw 0;
|
|
}
|
|
`,
|
|
'lib/new_car_state.dart': `class NewCarState {
|
|
const NewCarState._({this.brands = const []});
|
|
|
|
const NewCarState.initial() : this._();
|
|
|
|
const factory NewCarState.loading() = _Loading;
|
|
|
|
const factory NewCarState() = _Loading.named;
|
|
|
|
const factory NewCarState.fromJson(Map<String, Object?> json) = _Loading.fromJson;
|
|
|
|
final List<String> brands;
|
|
}
|
|
|
|
class _Loading extends NewCarState {
|
|
const _Loading() : super._();
|
|
|
|
const _Loading.named() : super._();
|
|
|
|
const _Loading.fromJson(Map<String, Object?> json) : super._();
|
|
}
|
|
`,
|
|
'lib/profile_wizard_state.dart': `class ProfileWizardState {
|
|
ProfileWizardState.initial() : step = 0;
|
|
|
|
final int step;
|
|
|
|
Object fromJson(Object json) => json;
|
|
}
|
|
`,
|
|
// A look-alike declared first in the same file.
|
|
'lib/family.dart': `class EmptyFamily extends Family {
|
|
EmptyFamily._() : super._();
|
|
}
|
|
|
|
class Family {
|
|
const Family._();
|
|
|
|
static Family make() => Family._();
|
|
}
|
|
`,
|
|
'lib/sub.dart': `abstract class Base {
|
|
const Base();
|
|
|
|
factory Base.named() => const _BaseImpl();
|
|
}
|
|
|
|
class _BaseImpl extends Base {
|
|
const _BaseImpl();
|
|
}
|
|
|
|
class Sub extends Base {
|
|
const Sub();
|
|
|
|
const factory Sub.named() = _SubImpl;
|
|
}
|
|
|
|
class _SubImpl extends Sub {
|
|
const _SubImpl();
|
|
}
|
|
`,
|
|
'lib/analysis_options.dart': `class AnalysisOptions {
|
|
const AnalysisOptions(this.path);
|
|
|
|
factory AnalysisOptions.parse(String path) => AnalysisOptions(path);
|
|
|
|
final String path;
|
|
}
|
|
`,
|
|
'lib/linter.dart': `extension on Uri {
|
|
String get canonicalizedPath => path;
|
|
}
|
|
`,
|
|
// An instance getter declared before a constructor of the same name.
|
|
'lib/async_value.dart': `sealed class AsyncValue<T> {
|
|
const AsyncValue._();
|
|
|
|
Object? get error => null;
|
|
|
|
const factory AsyncValue.error(Object error) = AsyncError<T>;
|
|
}
|
|
|
|
class AsyncError<T> extends AsyncValue<T> {
|
|
const AsyncError(this.error) : super._();
|
|
|
|
@override
|
|
final Object error;
|
|
}
|
|
|
|
Object fail(Object e) => AsyncValue.error(e);
|
|
`,
|
|
'lib/change_notifier_provider.dart': `class ChangeNotifierProvider {
|
|
const ChangeNotifierProvider.internal();
|
|
}
|
|
`,
|
|
// package:meta's `internal` annotation is a constant outside the project.
|
|
'lib/scope.dart': `import 'package:meta/meta.dart';
|
|
|
|
@internal
|
|
Object guarded() => 0;
|
|
`,
|
|
'lib/app.dart': `import 'bloc_provider.dart';
|
|
import 'repository_provider.dart';
|
|
import 'new_car_state.dart';
|
|
import 'sub.dart';
|
|
|
|
Object build(Object counter) => BlocProvider.value(value: counter);
|
|
Object typed(Object counter) => BlocProvider<Object>.value(value: counter);
|
|
Object typedRepository(Object repository) => RepositoryProvider<Object>.value(value: repository);
|
|
NewCarState start() => NewCarState.initial();
|
|
NewCarState load() => NewCarState.loading();
|
|
Sub sub() => Sub.named();
|
|
Object find(Object context) => BlocProvider.of(context);
|
|
Uri link() => Uri.parse('https://example.com');
|
|
Object tail(dynamic box) => box.inner.value(1);
|
|
`,
|
|
};
|
|
|
|
describe('Dart const constructors and redirecting factories', () => {
|
|
let root = '';
|
|
let cg: CodeGraph | undefined;
|
|
let kernel: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-dart-const-ctors-'));
|
|
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'])('are indexed and called through their own type (%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 find = (file: string, qualifiedName: string): Node | undefined =>
|
|
graph.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName);
|
|
const node = (file: string, qualifiedName: string): Node => {
|
|
const found = find(file, qualifiedName);
|
|
expect(found, `${file} ${qualifiedName}`).toBeDefined();
|
|
return found!;
|
|
};
|
|
const targets = (from: Node, kind: string): string[] =>
|
|
[...new Set(graph
|
|
.getOutgoingEdgesFrom([from.id])
|
|
.filter((e) => e.kind === kind)
|
|
.map((e) => graph.getNode(e.target)!.qualifiedName))]
|
|
.sort();
|
|
const sources = (to: Node): string[] =>
|
|
[...new Set(graph
|
|
.getIncomingEdgesTo([to.id])
|
|
.filter((e) => e.kind !== 'contains')
|
|
.map((e) => `${e.kind} ${graph.getNode(e.source)!.qualifiedName}`))]
|
|
.sort();
|
|
|
|
// Named `const` constructors and redirecting factories are methods named
|
|
// by the constructor, returning their class; the unnamed ones stay
|
|
// unindexed, as the unnamed generative constructor is, and a redirecting
|
|
// factory's target (`= _Loading.named`) names nothing.
|
|
const value = node('lib/bloc_provider.dart', 'BlocProvider::value');
|
|
expect(value.kind).toBe('method');
|
|
expect(value.returnType).toBe('BlocProvider');
|
|
expect(value.signature).toBe('({required T value})');
|
|
node('lib/bloc_provider.dart', 'BlocProvider::_');
|
|
expect(find('lib/bloc_provider.dart', 'BlocProvider::BlocProvider')).toBeUndefined();
|
|
node('lib/new_car_state.dart', 'NewCarState::initial');
|
|
const loading = node('lib/new_car_state.dart', 'NewCarState::loading');
|
|
expect(loading.signature).toBe('()');
|
|
expect(find('lib/new_car_state.dart', 'NewCarState::NewCarState')).toBeUndefined();
|
|
expect(find('lib/new_car_state.dart', 'NewCarState::named')).toBeUndefined();
|
|
node('lib/new_car_state.dart', '_Loading::named');
|
|
node('lib/sub.dart', 'Sub::named');
|
|
// A redirecting factory refers to the class it constructs, and not to
|
|
// that class's constructor as if it were a type (`= _Loading.fromJson`).
|
|
expect(targets(loading, 'references')).toEqual(['_Loading']);
|
|
expect(targets(node('lib/new_car_state.dart', 'NewCarState::fromJson'), 'references')).toEqual(['_Loading']);
|
|
expect(sources(node('lib/profile_wizard_state.dart', 'ProfileWizardState::fromJson'))).toEqual([]);
|
|
|
|
// Each call reaches the constructor of the type it names, type arguments
|
|
// or not...
|
|
expect(targets(node('lib/app.dart', 'build'), 'calls')).toEqual(['BlocProvider::value']);
|
|
expect(targets(node('lib/app.dart', 'typed'), 'calls')).toEqual(['BlocProvider::value']);
|
|
expect(targets(node('lib/app.dart', 'typedRepository'), 'calls')).toEqual(['RepositoryProvider::value']);
|
|
expect(targets(node('lib/app.dart', 'start'), 'calls')).toEqual(['NewCarState::initial']);
|
|
expect(targets(node('lib/app.dart', 'load'), 'calls')).toEqual(['NewCarState::loading']);
|
|
expect(targets(node('lib/app.dart', 'sub'), 'calls')).toEqual(['Sub::named']);
|
|
expect(targets(node('lib/family.dart', 'Family::make'), 'calls')).toEqual(['Family::_']);
|
|
// A constructor, not the instance getter that shares its name.
|
|
const errorFactory = graph.getNodesInFile('lib/async_value.dart')
|
|
.find((n) => n.qualifiedName === 'AsyncValue::error' && n.signature === '(Object error)');
|
|
expect(errorFactory).toBeDefined();
|
|
expect(graph.getOutgoingEdgesFrom([node('lib/async_value.dart', 'fail').id])
|
|
.filter((e) => e.kind === 'calls').map((e) => e.target)).toEqual([errorFactory!.id]);
|
|
// ...and never another type's member of that name.
|
|
expect(sources(node('lib/repository_provider.dart', 'RepositoryProvider::value'))).toEqual(['calls typedRepository']);
|
|
expect(sources(node('lib/profile_wizard_state.dart', 'ProfileWizardState::initial'))).toEqual([]);
|
|
expect(sources(node('lib/family.dart', 'EmptyFamily::_'))).toEqual([]);
|
|
expect(sources(node('lib/sub.dart', 'Base::named'))).toEqual([]);
|
|
// A member the named type does not declare is outside the project.
|
|
expect(targets(node('lib/app.dart', 'find'), 'calls')).toEqual([]);
|
|
expect(sources(node('lib/repository_provider.dart', 'RepositoryProvider::of'))).toEqual([]);
|
|
expect(targets(node('lib/app.dart', 'link'), 'calls')).toEqual([]);
|
|
expect(sources(node('lib/analysis_options.dart', 'AnalysisOptions::parse'))).toEqual([]);
|
|
|
|
// A constructor is only ever reached through its class: a parameter that
|
|
// shares its name, the last link of a chain, or an annotation is not it.
|
|
expect(targets(node('lib/app.dart', 'tail'), 'calls')).toEqual([]);
|
|
expect(sources(value)).toEqual(['calls build', 'calls typed']);
|
|
expect(sources(node('lib/change_notifier_provider.dart', 'ChangeNotifierProvider::internal'))).toEqual([]);
|
|
});
|
|
});
|