GitNexus/gitnexus/test/unit/registry-primary-flag.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

178 lines
8.2 KiB
TypeScript

/**
* Unit tests for `registry-primary-flag` (RFC #909 Ring 2 PKG #924).
*
* Flag is `REGISTRY_PRIMARY_<UPPER(lang)>`. Each test manipulates
* `process.env` directly and restores it in `afterEach` — there is no
* per-process cache to invalidate, so isolation is lexical.
*/
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { SupportedLanguages } from 'gitnexus-shared';
import {
envVarNameFor,
isRegistryPrimary,
primaryLanguages,
MIGRATED_LANGUAGES,
} from '../../src/core/ingestion/registry-primary-flag.js';
// ─── Test isolation ─────────────────────────────────────────────────────────
//
// Scrub every `REGISTRY_PRIMARY_*` env var before + after each test so
// parallel vitest runs on the same process don't bleed state.
function clearAllRegistryPrimaryVars(): void {
for (const key of Object.keys(process.env)) {
if (key.startsWith('REGISTRY_PRIMARY_')) delete process.env[key];
}
}
beforeEach(clearAllRegistryPrimaryVars);
afterEach(clearAllRegistryPrimaryVars);
// ─── envVarNameFor ─────────────────────────────────────────────────────────
describe('envVarNameFor', () => {
it('produces upper-cased env-var names from the enum value', () => {
expect(envVarNameFor(SupportedLanguages.Python)).toBe('REGISTRY_PRIMARY_PYTHON');
expect(envVarNameFor(SupportedLanguages.TypeScript)).toBe('REGISTRY_PRIMARY_TYPESCRIPT');
expect(envVarNameFor(SupportedLanguages.JavaScript)).toBe('REGISTRY_PRIMARY_JAVASCRIPT');
});
it('uses the enum VALUE, not the key, for languages whose key differs from the value', () => {
// Key 'CPlusPlus' → value 'cpp' → env var 'REGISTRY_PRIMARY_CPP'.
// Users see the language by its canonical name, not its TS symbol.
expect(envVarNameFor(SupportedLanguages.CPlusPlus)).toBe('REGISTRY_PRIMARY_CPP');
expect(envVarNameFor(SupportedLanguages.CSharp)).toBe('REGISTRY_PRIMARY_CSHARP');
});
it('covers every member of SupportedLanguages', () => {
// Build env-var names for every language and assert no duplicates —
// catches a future enum-value collision or accidental renaming.
const names = new Set<string>();
for (const lang of Object.values(SupportedLanguages)) {
names.add(envVarNameFor(lang));
}
expect(names.size).toBe(Object.values(SupportedLanguages).length);
});
});
// ─── isRegistryPrimary ─────────────────────────────────────────────────────
describe('isRegistryPrimary', () => {
it('returns MIGRATED_LANGUAGES membership by default (no env var set)', () => {
// Ring 3: languages in MIGRATED_LANGUAGES are registry-primary by
// default — operators don't need to set an env var for the rolled-out
// migration to take effect. Unmigrated languages default to false.
for (const lang of Object.values(SupportedLanguages)) {
expect(isRegistryPrimary(lang)).toBe(MIGRATED_LANGUAGES.has(lang));
}
});
it("returns true when the env var is 'true' (lowercase)", () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
});
it("returns true when the env var is '1'", () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = '1';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
});
it("returns true when the env var is 'yes'", () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = 'yes';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
});
it('accepts mixed-case and whitespace-padded truthy values', () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = ' TRUE ';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
process.env['REGISTRY_PRIMARY_PYTHON'] = 'Yes';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
});
it("returns false for falsy-looking values ('false', '0', empty, 'off')", () => {
for (const value of ['false', '0', '', 'off', 'no', 'disabled']) {
process.env['REGISTRY_PRIMARY_PYTHON'] = value;
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false);
}
});
it('returns false for unrecognized tokens (fail-safe on typos)', () => {
// User meant to type 'true' but fat-fingered — conservative: treat as off.
for (const value of ['ture', 'tru', 'yeah', 'enable', 'y']) {
process.env['REGISTRY_PRIMARY_PYTHON'] = value;
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(false);
}
});
it('isolates flags per-language (one on does not affect others)', () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Python)).toBe(true);
// Vue is not in MIGRATED_LANGUAGES — default false stays
// false regardless of Python's flag.
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false);
});
it('respects a mid-process env-var mutation (no stale cache)', () => {
// Use Vue — not in MIGRATED_LANGUAGES — so the unset default is
// deterministically `false`, independent of which languages have
// been flipped to registry-primary.
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false);
process.env['REGISTRY_PRIMARY_VUE'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(true);
delete process.env['REGISTRY_PRIMARY_VUE'];
expect(isRegistryPrimary(SupportedLanguages.Vue)).toBe(false);
});
it('handles the CPlusPlus → REGISTRY_PRIMARY_CPP mapping correctly', () => {
process.env['REGISTRY_PRIMARY_CPP'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(true);
// Negative: the TS-key-style name is NOT read. CPlusPlus is now in
// MIGRATED_LANGUAGES, so we must explicitly opt it out via the
// canonical env var to verify the wrong-name var has no effect.
process.env['REGISTRY_PRIMARY_CPP'] = 'false';
process.env['REGISTRY_PRIMARY_CPLUSPLUS'] = 'true';
expect(isRegistryPrimary(SupportedLanguages.CPlusPlus)).toBe(false);
});
});
// ─── primaryLanguages ──────────────────────────────────────────────────────
describe('primaryLanguages', () => {
it('returns MIGRATED_LANGUAGES when no flags are set', () => {
// Default-on for migrated languages (Ring 3); unmigrated stay off.
const enabled = primaryLanguages();
expect(enabled.size).toBe(MIGRATED_LANGUAGES.size);
for (const lang of MIGRATED_LANGUAGES) {
expect(enabled.has(lang)).toBe(true);
}
});
it('returns exactly the flipped languages (env opts in unmigrated, opts out migrated)', () => {
// Migrated languages are default-on; each must be opted out here when
// testing explicit env overrides. Ruby (unmigrated) opts in.
// Opt out every member of MIGRATED_LANGUAGES dynamically so this test
// does not have to be updated each time a new language ships its
// Ring 3 migration (C++ and PHP joined the set in their respective
// Ring 3 migrations; future Ring 3 additions land here without test churn).
for (const lang of MIGRATED_LANGUAGES) {
process.env[envVarNameFor(lang)] = 'false';
}
process.env['REGISTRY_PRIMARY_RUBY'] = '1';
const enabled = primaryLanguages();
expect(enabled.has(SupportedLanguages.Python)).toBe(false);
expect(enabled.has(SupportedLanguages.CSharp)).toBe(false);
expect(enabled.has(SupportedLanguages.Go)).toBe(false);
expect(enabled.has(SupportedLanguages.CPlusPlus)).toBe(false);
expect(enabled.has(SupportedLanguages.PHP)).toBe(false);
expect(enabled.has(SupportedLanguages.Ruby)).toBe(true);
// Only Ruby is on: migrated defaults overridden off, Ruby explicitly on.
expect(enabled.size).toBe(1);
});
it('returns a plain Set (not a frozen proxy) — consistent shape', () => {
process.env['REGISTRY_PRIMARY_PYTHON'] = 'true';
const enabled = primaryLanguages();
expect(enabled).toBeInstanceOf(Set);
});
});