fix(dart): extract extension type symbols (#2539)

* fix(dart): extract extension type symbols

* test(dart): update extension type benchmark baseline

* fix(dart): emit extension type implements heritage

* fix(dart): handle generic extension type implements

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
azizur100389 2026-07-18 22:57:26 +01:00 • committed by GitHub
parent d75e20654d
commit 28a70fc1df
7 changed files with 274 additions and 5 deletions

View file

@ -79,8 +79,10 @@
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget."
},
"dart": {
"fingerprint": "66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3",
"fingerprint": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
"scaling_budget": 1.5,
"_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.",
"_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Dart function-tearoff and selector callable flow facts, split signature/body lexical ownership, and invocation-result suppression. Prior 94bf2c26e1ba96f4211634aa572c0a989b503e717e75dfc5df04f66c417de80f -> 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8; scaling 0.906 < 1.5.",
"_added": "#939: dart added to the scope-capture bench with the registry-primary migration. Heritage-bearing scale source (Entity extends Base implements Marker) gates the @reference.inherits synth + the postfix-chain reference walk at scale. emitDartScopeCaptures threads tree-sitter captured nodes (no findNodeAtRange root-walk), so it is linear (~1.0).",

View file

@ -45,6 +45,7 @@ import {
dartArityCompatibility,
} from './dart/index.js';
import { DART_BUILT_INS } from './dart/built-ins.js';
import { preprocessDartExtensionTypes } from './dart/extension-type-preprocess.js';
/**
* Resolve the enclosing function from a `function_body` node by looking at its
@ -119,6 +120,7 @@ export const dartProvider = defineLanguage({
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: DART_QUERIES,
preprocessSource: preprocessDartExtensionTypes,
typeConfig: dartConfig,
exportChecker: dartExportChecker,
importResolver: createImportResolver(dartImportConfig),

View file

@ -45,6 +45,7 @@ import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { encodeMarker } from '../../utils/heritage-marker.js';
import { DART_BUILT_INS } from './built-ins.js';
import { synthesizeCallableFlowCaptures } from '../../utils/callable-flow-captures.js';
import { preprocessDartExtensionTypes } from './extension-type-preprocess.js';
const FUNCTION_DECL_TAGS = [
'@declaration.function',
@ -85,13 +86,14 @@ export function emitDartScopeCaptures(
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
const parseText = preprocessDartExtensionTypes(sourceText);
let tree: Parser.Tree;
if (cachedTree !== undefined && cachedTree !== null) {
tree = cachedTree as Parser.Tree;
recordCacheHit();
} else {
tree = parseSourceSafe(getDartParser(), sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
tree = parseSourceSafe(getDartParser(), parseText, undefined, {
bufferSize: getTreeSitterBufferSize(parseText),
});
recordCacheMiss();
}
@ -186,6 +188,10 @@ export function emitDartScopeCaptures(
emitHeritage(node, out);
return;
}
if (node.type === 'extension_declaration') {
emitExtensionImplementsHeritage(node, out);
return;
}
});
out.push(...synthesizeCallableFlowCaptures(root, DART_CALLABLE_CAPTURE_OPTIONS));
@ -522,6 +528,50 @@ function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void {
}
}
function emitExtensionImplementsHeritage(extensionNode: SyntaxNode, out: CaptureMatch[]): void {
const nameNode = extensionNode.childForFieldName('name');
if (nameNode === null) return;
const bodyStart = extensionNode.text.indexOf('{');
const header = bodyStart === -1 ? extensionNode.text : extensionNode.text.slice(0, bodyStart);
const implementsIndex = header.indexOf('implements');
if (implementsIndex === -1) return;
const className = nameNode.text;
const interfaces = header.slice(implementsIndex + 'implements'.length);
for (const rawInterface of splitTopLevelCommaList(interfaces)) {
const target = /^[ \t]*([A-Za-z_$][A-Za-z0-9_$]*)/.exec(rawInterface)?.[1];
if (target === undefined) continue;
const payload = encodeMarker('heritage', ['implements', target, className]);
out.push({ '@import.heritage': syntheticCapture('@import.heritage', nameNode, payload) });
}
}
function splitTopLevelCommaList(text: string): string[] {
const parts: string[] = [];
let start = 0;
let angleDepth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === '<') {
angleDepth++;
continue;
}
if (ch === '>' && angleDepth > 0) {
angleDepth--;
continue;
}
if (ch === ',' && angleDepth === 0) {
parts.push(text.slice(start, i));
start = i + 1;
}
}
parts.push(text.slice(start));
return parts;
}
function emitHeritageMarkers(
container: SyntaxNode,
kind: 'implements' | 'with',

View file

@ -0,0 +1,46 @@
const DART_EXTENSION_TYPE_HEADER =
/\b(extension)([ \t]+type[ \t]+(?:const[ \t]+)?)([A-Za-z_$][A-Za-z0-9_$]*)([ \t]*<[^()\r\n]*>)?([ \t]*)\(([^()\r\n]*)\)/g;
function representationType(representation: string): string | null {
const trimmed = representation.trim();
const match = /^(.*\S)\s+[A-Za-z_$][A-Za-z0-9_$]*$/.exec(trimmed);
const typeName = match?.[1]?.trim();
return typeName && typeName.length > 0 ? typeName : null;
}
/**
* Rewrites Dart 3.3 `extension type Name(Type value)` headers into ordinary
* extension headers before tree-sitter sees the source. The vendored grammar
* currently recovers these declarations through an ERROR subtree, while the
* existing Dart ingestion path already handles `extension Name on Type`.
*/
export function preprocessDartExtensionTypes(sourceText: string): string {
return sourceText.replace(
DART_EXTENSION_TYPE_HEADER,
(
match,
extensionKeyword: string,
extensionTypeGap: string,
name: string,
typeParameters: string | undefined,
beforeRepresentation: string,
representation: string,
) => {
const typeName = representationType(representation);
if (typeName === null) return match;
const representationSpanLength = beforeRepresentation.length + representation.length + 2;
const rewrittenRepresentation = ` on ${typeName}`;
if (rewrittenRepresentation.length > representationSpanLength) return match;
return (
extensionKeyword +
' '.repeat(extensionTypeGap.length) +
name +
(typeParameters ?? '') +
rewrittenRepresentation +
' '.repeat(representationSpanLength - rewrittenRepresentation.length)
);
},
);
}

View file

@ -249,7 +249,7 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
record_declaration: 'Record',
protocol_declaration: 'Interface',
mixin_declaration: 'Mixin',
extension_declaration: 'Extension',
extension_declaration: 'Class',
class: 'Class',
// Ruby `module` declarations map to `Trait` so they participate in the
// class-like type registry used by `lookupClassByName` / inheritance

View file

@ -0,0 +1,28 @@
class Identifiable {}
class SequenceLike<T> {}
class Comparator<A, B> {}
extension type const UserId(String value) implements Identifiable {
String describe() => value;
}
extension type const EmptyId(String value) {}
extension type Celsius(double degrees) {
double toFahrenheit() => degrees * 9 / 5 + 32;
}
extension type Box<T>(List<T> value) implements SequenceLike<T> {
T first() => value.first;
}
extension type Pair(String value) implements Comparator<String, int> {
String describePair() => value;
}
extension Fancy on String {
int get doubledLength => length * 2;
String shout() => toUpperCase();
}

View file

@ -18,7 +18,15 @@
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { emitDartScopeCaptures } from '../../../src/core/ingestion/languages/dart/captures.js';
import { FIXTURES, getNodesByLabel, runPipelineFromRepo, type PipelineResult } from './helpers.js';
import {
FIXTURES,
edgeSet,
findDanglingEdges,
getNodesByLabel,
getRelationships,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
import {
isLanguageAvailable,
loadParser,
@ -26,6 +34,7 @@ import {
} from '../../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import type { CaptureMatch } from 'gitnexus-shared';
import { preprocessDartExtensionTypes } from '../../../src/core/ingestion/languages/dart/extension-type-preprocess.js';
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
if (dartAvailable) {
@ -43,6 +52,35 @@ typedef Pred = bool Function(int);
typedef Mapper<T> = T Function(T);
typedef int _Internal(int);`;
const EXTENSION_TYPES = `class Identifiable {}
class SequenceLike<T> {}
class Comparator<A, B> {}
extension type const UserId(String value) implements Identifiable {
String describe() => value;
}
extension type const EmptyId(String value) {}
extension type Celsius(double degrees) {
double toFahrenheit() => degrees * 9 / 5 + 32;
}
extension type Box<T>(List<T> value) implements SequenceLike<T> {
T first() => value.first;
}
extension type Pair(String value) implements Comparator<String, int> {
String describePair() => value;
}
extension Fancy on String {
int get doubledLength => length * 2;
String shout() => toUpperCase();
}`;
/** All @declaration.type_alias matches, as (name) tuples. */
function typeAliasNames(src: string): string[] {
const matches = emitDartScopeCaptures(src, 'test.dart') as CaptureMatch[];
@ -52,6 +90,21 @@ function typeAliasNames(src: string): string[] {
.filter((n): n is string => Boolean(n));
}
/** All class-like Dart declaration matches, as names. */
function classDeclarationNames(src: string): string[] {
const matches = emitDartScopeCaptures(src, 'test.dart') as CaptureMatch[];
return matches
.filter((m) => m['@declaration.class'] !== undefined)
.map((m) => m['@declaration.name']?.text)
.filter((n): n is string => Boolean(n));
}
/** Synthetic Dart implements markers, as marker payloads. */
function heritageImports(src: string): string[] {
const matches = emitDartScopeCaptures(src, 'test.dart') as CaptureMatch[];
return matches.map((m) => m['@import.heritage']?.text).filter((n): n is string => Boolean(n));
}
// ---------------------------------------------------------------------------
// F28 — typedef capture (scope layer)
// ---------------------------------------------------------------------------
@ -88,6 +141,45 @@ describe.skipIf(!dartAvailable)('F28 — Dart typedef capture (scope layer)', ()
});
});
// ---------------------------------------------------------------------------
// #2538 — extension type declarations (scope layer)
// ---------------------------------------------------------------------------
describe.skipIf(!dartAvailable)('Dart extension type declarations (scope layer)', () => {
it('rewrites extension type headers without changing source length or line count', () => {
const rewritten = preprocessDartExtensionTypes(EXTENSION_TYPES);
expect(rewritten).toHaveLength(EXTENSION_TYPES.length);
expect(rewritten.split('\n')).toHaveLength(EXTENSION_TYPES.split('\n').length);
for (const name of ['UserId', 'EmptyId', 'Celsius', 'Box', 'Pair']) {
expect(rewritten.indexOf(name)).toBe(EXTENSION_TYPES.indexOf(name));
}
expect(rewritten).toContain('UserId on String');
expect(rewritten).toContain('EmptyId on String');
expect(rewritten).toContain('Celsius on double');
expect(rewritten).toContain('Box<T> on List<T>');
expect(rewritten).toContain('Pair on String');
});
it('captures extension types as class-like declarations', () => {
const names = classDeclarationNames(EXTENSION_TYPES);
expect(names).toEqual(
expect.arrayContaining(['UserId', 'EmptyId', 'Celsius', 'Box', 'Pair', 'Fancy']),
);
});
it('captures implements clauses on extension types as heritage markers', () => {
const imports = heritageImports(EXTENSION_TYPES);
expect(imports).toEqual(
expect.arrayContaining([
'__heritage__:implements:Identifiable:UserId',
'__heritage__:implements:SequenceLike:Box',
'__heritage__:implements:Comparator:Pair',
]),
);
expect(imports).not.toContain('__heritage__:implements:int:Pair');
});
});
// ---------------------------------------------------------------------------
// F28 — typedef symbols exist end-to-end (structure phase)
// ---------------------------------------------------------------------------
@ -116,3 +208,52 @@ describe.skipIf(!dartAvailable)('F28 — Dart typedef symbols (end-to-end)', ()
expect(fromFixture.sort()).toEqual(['Cmp', 'Cmp2', 'Mapper', 'Pred', '_Internal'].sort());
});
});
// ---------------------------------------------------------------------------
// #2538 — extension type symbols exist end-to-end (structure phase)
// ---------------------------------------------------------------------------
describe.skipIf(!dartAvailable)('Dart extension type symbols (end-to-end)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'dart-extension-types'), () => {});
}, 60000);
it('creates Class nodes for extension type declarations', () => {
const classes = getNodesByLabel(result, 'Class');
expect(classes).toEqual(
expect.arrayContaining(['UserId', 'EmptyId', 'Celsius', 'Box', 'Pair', 'Fancy']),
);
});
it('emits IMPLEMENTS edges for extension type implements clauses', () => {
const implementsEdges = edgeSet(getRelationships(result, 'IMPLEMENTS'));
expect(implementsEdges).toEqual(
expect.arrayContaining(['Box → SequenceLike', 'Pair → Comparator', 'UserId → Identifiable']),
);
expect(implementsEdges).not.toContain('Pair → int');
});
it('keeps extension type methods owned by their extension type symbol', () => {
const methods = getNodesByLabel(result, 'Method');
expect(methods).toEqual(
expect.arrayContaining(['describe', 'toFahrenheit', 'first', 'describePair', 'shout']),
);
const hasMethod = edgeSet(getRelationships(result, 'HAS_METHOD'));
expect(hasMethod).toEqual(
expect.arrayContaining([
'UserId → describe',
'Celsius → toFahrenheit',
'Box → first',
'Pair → describePair',
'Fancy → shout',
]),
);
});
it('does not leave dangling method ownership edges', () => {
expect(findDanglingEdges(result, ['HAS_METHOD', 'IMPLEMENTS'])).toEqual([]);
});
});