GitNexus/gitnexus/test/unit/sequential-language-availability.test.ts
Gergő Magyar de0248c5db
refactor(ingestion): migrate Dart to registry-primary call resolution (#939) (#1970)
* feat(scope-resolution): migrate Dart to registry-primary call resolution (#939)

Add a Dart scope-resolution module (languages/dart/) mirroring the Swift
template and flip Dart to registry-primary. Resolution edges
(CALLS/IMPORTS/ACCESSES/EXTENDS/IMPLEMENTS/METHOD_IMPLEMENTS) now route
through the shared registry pipeline with byte-for-byte parity against the
legacy DAG: test/integration/resolvers/dart.test.ts passes 53/53 under both
REGISTRY_PRIMARY_DART=0 and =1 (scripts/run-parity.ts --language dart: 2/2).

Dart-specific handling:
- Function scopes are synthesized to span signature..body (tree-sitter
  function_signature/function_body are siblings, not parent/child).
- extends rides @reference.inherits (EXTENDS via the generic pre-pass);
  implements/with are carried as __heritage__ side-effect imports and
  emitted as IMPLEMENTS, since Dart `implements <class>` must be IMPLEMENTS
  regardless of the target's symbol kind.
- imports are wildcard (whole-library) with expandsWildcardTo so imported
  return types propagate cross-file (var u = getUser(); u.save()).
- getInnerSignature now self-returns a bare signature node so top-level
  function params/return/name extract (legacy-safe: legacy only ever passes
  method_signature/declaration wrappers).

Also: add Dart scope-capture bench coverage (linear ~0.99 scaling); update
two tests that used Dart as a non-migrated control (Vue / forced legacy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): close Dart registry-primary parity gaps from review

Adversarial review of #1970 surfaced real divergences from the legacy DAG on
constructs the 10 fixtures don't exercise. All fixed; parity gate still 2/2
(now 55/55 each mode):

- Implicit-constructor construction (`Foo()` with no explicit ctor): the
  legacy DAG emits `caller -> Foo` (Class) but registry emitted nothing
  (callee tagged @reference.call.free never reaches constructorCallTargetsClass).
  Re-tag UpperCamelCase free-callees to @reference.call.constructor (Dart types
  are UpperCamelCase) so they link to the Class. Locked in with a regression
  fixture + test that passes in BOTH modes.
- Cascade calls (`list..add(1)..sort()`) were dropped — cascade_section has no
  `selector` wrapper, so the reference walk never saw them while legacy emitted
  them as free calls. Add a cascade_section handler.
- BUILT_INS (setState/then/push/pop/listen/...) were not suppressed on the
  registry path, so a user symbol shadowing one produced a spurious CALLS edge
  the legacy DAG suppresses. Skip built-in-named call refs at capture time
  (extract the set to a leaf module shared with the provider).
- Enhanced-enum methods mis-parented to Module (no enum scope). Add
  `(enum_declaration) @scope.class` so enum members are owned by the enum.

Re-baseline the Dart scope-capture fingerprint (linear ~0.95).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scope-resolution): apply issue #1926 F24/F25 findings to the Dart scope path

Issue #1926 catalogs Dart parsing-layer coverage gaps. Apply the two that the
registry-primary scope-resolution path owns (call edges + call attribution),
registered as legacy-expected-failures since they are scope-resolver-only wins.

- F24: the scope path's unified tree-walk already captures member calls
  (obj.method()) in return / list-literal / named-argument / arrow-body
  contexts — the legacy DAG only captures them under expression_statement /
  initialized_variable_definition. Lock it with the dart-member-call-contexts
  fixture + tests.
- F25 (constructor portion): a constructor's body is a sibling of the WRAPPING
  method_signature (class_body > method_signature > constructor_signature, then
  function_body), so findFunctionBody now walks up to the method_signature
  wrapper. Constructor bodies get a Function scope and their body-calls
  attribute to the Constructor (a valid caller anchor) instead of the class.
  Add the dart-constructor-body fixture + test.

Switch dart.test.ts to createResolverParityIt('dart') and add the dart entry to
LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES (5 wins). Both modes pass:
run-parity --language dart → 2/2 (registry 60/60; legacy 55 pass + 5 skipped).

Not applicable to the scope path (structure-phase / shared-pipeline, tracked by
#1926's legacy fix): F25 getter/setter (Property is not a caller anchor) and
operator (no Method node emitted by the structure phase) bodies; F26 (static
field Property nodes); F27 (no generic_type reference in the scope module);
F28/F29 (typedef/variable node extraction). Re-baseline the Dart scope-capture
fingerprint (linear ~1.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): fix Dart named-constructor file-drop + container-name mis-binding (tri-review)

Multi-engine tri-review (GitNexus + CE personas + Codex gpt-5.5) of #1970
found a P0 the parity gate missed plus a P2 wrong-edge:

- P0 (file drop): a named constructor with a body (`class A { A.named() {…} }`,
  idiomatic Dart) parses as ONE constructor_signature carrying multiple `name:`
  fields, so the scope query matched it more than once and synthesized two
  identical-range @scope.function captures → ScopeTreeInvariantError(duplicate-
  scope-id) → extractParsedFile swallowed it → the WHOLE file was dropped from
  registry-primary resolution (CALLS=0 vs legacy CALLS=2). Introduced by the
  #1926 F25 findFunctionBody change that started giving constructors body
  scopes. Fix: dedup function-like declarations by their statement node so each
  is emitted once. Add dart-named-constructor-body fixture + a parity guard test
  (both modes) that fails if the file is dropped, plus the named-ctor F25
  attribution win (registry-only).

- P2 (wrong edge): normalizeDartType's Future<X>/List<X> unwrap is unreachable
  (generic args are stripped upstream to a bare `Future`/`List`), so a return/
  field type binding to the bare container name let a same-named user class
  (`class Stream {…}`) capture the receiver — a wrong CALLS edge legacy didn't
  emit. Suppress type bindings that normalize to a bare container name (leaving
  the call unresolved, matching legacy) instead of binding to the container.

Both modes still pass: run-parity --language dart → 2/2 (registry 62/62; legacy
56 + 6 skipped). Re-baseline the Dart scope-capture fingerprint. Also: refresh
the captures.ts module doc (constructors get scopes; cascade calls).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scope-resolution): address Dart tri-review follow-ups (heritage collision + polish)

- P2 heritage cross-file name collision: emitDartHeritageEdges resolved both
  child and base by a global last-write-wins simple-name map, so two files each
  declaring `class Logger` (one `implements Logger`) produced a wrong-file
  IMPLEMENTS edge. Resolve with same-file affinity (prefer a same-file class,
  then a workspace-unique match, else refuse to guess) — the #1951 file-affinity
  pattern. Add dart-heritage-name-collision fixture + a parity test (both modes
  resolve same-file). Also reason-qualify the dedup key so `implements X` + `with X`
  keep distinct edges.
- Polish: buildDartMro uses Sets instead of Array.includes-in-loop; merge-bindings
  uses named tier constants matching swift; drop the dead no-op stripQuotes in
  import-target (targetRaw already arrives quote-stripped).

Both modes pass: run-parity --language dart → 2/2 (registry 63/63; legacy 57 + 6
skipped). Re-baseline the Dart scope-capture fingerprint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:54:00 +01:00

269 lines
9.4 KiB
TypeScript

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({
loadParser: vi.fn(async () => ({
parse: vi.fn(),
getLanguage: vi.fn(),
})),
loadLanguage: vi.fn(async () => undefined),
isLanguageAvailable: vi.fn(() => true),
}));
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { processImports } from '../../src/core/ingestion/import-processor.js';
import { processCalls } from '../../src/core/ingestion/call-processor.js';
import { processHeritage } from '../../src/core/ingestion/heritage-processor.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js';
import { _captureLogger } from '../../src/core/logger.js';
import type { LoggerCapture } from '../../src/core/logger.js';
describe('sequential native parser availability', () => {
// Hoisted so a stray live capture from a failed warn test can always be
// torn down in afterEach — otherwise a single assertion failure cascades
// into `_captureLogger: a previous capture is still active` (logger.ts).
let cap: LoggerCapture | undefined;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cap?.restore();
cap = undefined;
});
it('skips Swift files in processImports when the native parser is unavailable', async () => {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await expect(
processImports(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'import Foundation' }],
createASTCache(),
createResolutionContext(),
undefined,
'/tmp/repo',
['App.swift'],
),
).resolves.toBeUndefined();
expect(parserLoader.loadLanguage).not.toHaveBeenCalled();
});
it('warns when processImports skips files in verbose mode', async () => {
cap = _captureLogger();
const previous = process.env.GITNEXUS_VERBOSE;
process.env.GITNEXUS_VERBOSE = '1';
try {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await processImports(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'import Foundation' }],
createASTCache(),
createResolutionContext(),
undefined,
'/tmp/repo',
['App.swift'],
);
expect(
cap
.records()
.some(
(r) =>
r.msg ===
'[ingestion] Skipped 1 swift file(s) in import processing — swift parser not available.',
),
).toBe(true);
} finally {
if (previous === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = previous;
}
}
});
it('skips Swift files in processCalls (registry-primary: scope-resolution owns call resolution)', async () => {
// Swift is registry-primary, so processCalls skips it via the
// isRegistryPrimary gate (call-processor.ts) BEFORE the parser-availability
// check — the registry-primary scope-resolution path owns its call edges
// (#1951). The unavailable-parser mock is therefore moot: the file is skipped
// (no loadLanguage) regardless. The legacy availability-skip path itself is
// exercised by the Dart verbose test below (Dart is not registry-primary).
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await expect(
processCalls(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'func demo() {}' }],
createASTCache(),
createResolutionContext(),
),
).resolves.toEqual([]);
expect(parserLoader.loadLanguage).not.toHaveBeenCalled();
});
it('warns when processCalls skips files in verbose mode', async () => {
cap = _captureLogger();
const previous = process.env.GITNEXUS_VERBOSE;
const previousDart = process.env.REGISTRY_PRIMARY_DART;
process.env.GITNEXUS_VERBOSE = '1';
// call-processor gates registry-primary languages (Swift, Dart, etc.) via
// the isRegistryPrimary gate BEFORE the parser-availability skip counter.
// Dart is now in MIGRATED_LANGUAGES, so force it onto the legacy call path
// (REGISTRY_PRIMARY_DART=0) to exercise the skip/warn branch this test
// covers — without disturbing any other language's mode.
process.env.REGISTRY_PRIMARY_DART = '0';
try {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await processCalls(
createKnowledgeGraph(),
[{ path: 'App.dart', content: 'void demo() {}' }],
createASTCache(),
createResolutionContext(),
);
expect(
cap
.records()
.some(
(r) =>
r.msg ===
'[ingestion] Skipped 1 dart file(s) in call processing — dart parser not available.',
),
).toBe(true);
} finally {
if (previous === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = previous;
}
if (previousDart === undefined) {
delete process.env.REGISTRY_PRIMARY_DART;
} else {
process.env.REGISTRY_PRIMARY_DART = previousDart;
}
}
});
it('skips Swift files in processHeritage (registry-primary: scope-resolution owns heritage)', async () => {
// Swift is registry-primary, so processHeritage skips it via the
// isRegistryPrimary gate (heritage-processor.ts) BEFORE the parser-availability
// check — scope-resolution (#1951) owns its EXTENDS/IMPLEMENTS edges. The
// unavailable-parser mock is therefore moot: the file is skipped (no
// loadLanguage) regardless. The legacy availability-skip path itself is
// exercised by the Dart verbose test below (Dart is not registry-primary).
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await expect(
processHeritage(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'class AppViewController: UIViewController {}' }],
createASTCache(),
createResolutionContext(),
),
).resolves.toBeUndefined();
expect(parserLoader.loadLanguage).not.toHaveBeenCalled();
});
it('warns when processHeritage skips files in verbose mode', async () => {
cap = _captureLogger();
const previous = process.env.GITNEXUS_VERBOSE;
const previousDart = process.env.REGISTRY_PRIMARY_DART;
process.env.GITNEXUS_VERBOSE = '1';
// processHeritage skips registry-primary languages (Swift, Dart, etc.) via
// the isRegistryPrimary gate — scope-based resolution owns their
// inheritance (#1951) — BEFORE the legacy parser-availability skip this
// test exercises. Dart is now in MIGRATED_LANGUAGES, so force it onto the
// legacy heritage path (REGISTRY_PRIMARY_DART=0) to fire the skip/warn
// branch, without disturbing any other language's mode.
process.env.REGISTRY_PRIMARY_DART = '0';
try {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await processHeritage(
createKnowledgeGraph(),
[{ path: 'App.dart', content: 'class Widget extends StatelessWidget {}' }],
createASTCache(),
createResolutionContext(),
);
expect(
cap
.records()
.some(
(r) =>
r.msg ===
'[ingestion] Skipped 1 dart file(s) in heritage processing — dart parser not available.',
),
).toBe(true);
} finally {
if (previous === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = previous;
}
if (previousDart === undefined) {
delete process.env.REGISTRY_PRIMARY_DART;
} else {
process.env.REGISTRY_PRIMARY_DART = previousDart;
}
}
});
it('skips Swift files in processParsing when the native parser is unavailable', async () => {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await expect(
processParsing(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'class AppViewController: UIViewController {}' }],
createSymbolTable(),
createASTCache(),
),
).resolves.toBeNull();
expect(parserLoader.loadLanguage).not.toHaveBeenCalled();
});
it('warns when processParsing skips files in verbose mode', async () => {
cap = _captureLogger();
const previous = process.env.GITNEXUS_VERBOSE;
process.env.GITNEXUS_VERBOSE = '1';
try {
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
await processParsing(
createKnowledgeGraph(),
[{ path: 'App.swift', content: 'class AppViewController: UIViewController {}' }],
createSymbolTable(),
createASTCache(),
);
expect(
cap
.records()
.some(
(r) =>
r.msg ===
'[ingestion] Skipped 1 swift file(s) in parsing processing — swift parser not available.',
),
).toBe(true);
} finally {
if (previous === undefined) {
delete process.env.GITNEXUS_VERBOSE;
} else {
process.env.GITNEXUS_VERBOSE = previous;
}
}
});
});