diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index d040361de..943c7ef6d 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -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).", diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index a95571dc2..79d75eb71 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index b9733c54f..2a47dbf83 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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', diff --git a/gitnexus/src/core/ingestion/languages/dart/extension-type-preprocess.ts b/gitnexus/src/core/ingestion/languages/dart/extension-type-preprocess.ts new file mode 100644 index 000000000..66b3e661f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/dart/extension-type-preprocess.ts @@ -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) + ); + }, + ); +} diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 4be34749a..3f2cef4d8 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -249,7 +249,7 @@ export const CONTAINER_TYPE_TO_LABEL: Record = { 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 diff --git a/gitnexus/test/fixtures/lang-resolution/dart-extension-types/models.dart b/gitnexus/test/fixtures/lang-resolution/dart-extension-types/models.dart new file mode 100644 index 000000000..2e602e481 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/dart-extension-types/models.dart @@ -0,0 +1,28 @@ +class Identifiable {} + +class SequenceLike {} + +class Comparator {} + +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(List value) implements SequenceLike { + T first() => value.first; +} + +extension type Pair(String value) implements Comparator { + String describePair() => value; +} + +extension Fancy on String { + int get doubledLength => length * 2; + String shout() => toUpperCase(); +} diff --git a/gitnexus/test/integration/resolvers/dart-coverage.test.ts b/gitnexus/test/integration/resolvers/dart-coverage.test.ts index d7c0113ec..9b144525d 100644 --- a/gitnexus/test/integration/resolvers/dart-coverage.test.ts +++ b/gitnexus/test/integration/resolvers/dart-coverage.test.ts @@ -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 Function(T); typedef int _Internal(int);`; +const EXTENSION_TYPES = `class Identifiable {} + +class SequenceLike {} + +class Comparator {} + +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(List value) implements SequenceLike { + T first() => value.first; +} + +extension type Pair(String value) implements Comparator { + 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 on List'); + 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([]); + }); +});