mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
e27d6da6b2
* fix(scala): resolve extends/with to the trait, not its companion object The Scala trait + companion object idiom puts two same-named symbols in one file. Both were indexed as kind 'class', so an 'extends X' reference tied between the trait and its companion and the winner was arbitrary. When the companion won, every subtype was detached from the trait's inheritance chain, and impact analysis on a widely-used trait stopped at depth 1. - classify Scala object_definition as 'module' (a singleton value, not a type - 'extends' can never target it) - add 'module' to the classifyClassNode contract and handle it in the extractor dispatch - in name matching, bias extends/implements references toward actual type definitions and penalize 'module' candidates, so the trait wins the tie deterministically Regression test: trait + companion in one file, subclass in another - the extends edge must land on the trait and impact must reach the subtype through it. * fix(scala): resolve companion inheritance to type definitions (#1824) Scala objects and their companion types could tie during inheritance resolution, disconnecting subtype impact paths. Classify objects as modules in both extraction engines and reject Scala singleton inheritance targets across resolution strategies. Preserve object method lookup and inherited receiver methods, with declaration-order, impact, and native/WASM parity regressions. Co-authored-by: Hubert Tarnacki <hubert.tarnacki@comarch.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Hubert Tarnacki <hubert.tarnacki@comarch.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
9.0 KiB
TypeScript
207 lines
9.0 KiB
TypeScript
/**
|
|
* Kernel↔wasm Scala extraction parity (R7b batch 4 of the kernel migration).
|
|
*
|
|
* Asserts the native walker (codegraph-kernel/src/scala.rs) produces the SAME
|
|
* ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
|
|
* unresolved refs compared as canonicalized multisets — over the checked-in
|
|
* fixtures (torture.scala: first-segment imports, defs-as-methods with the
|
|
* top-level function fallback, curried/type-params-first signatures, the
|
|
* val/var hook with object-vs-class kinds and initializer invisibility,
|
|
* companion pairs sharing a QN namespace, the bodiless-header asymmetry,
|
|
* enum cases at case-node positions with invisible tails, extends
|
|
* with-chains, `@deprecated(args)` decorates, the #750 capitalized-chain
|
|
* re-encode, literal-receiver silence, static reads incl. the write-LHS
|
|
* emission, nested-def invisibility with body-local classes extracting
|
|
* fully; TortureDocs: scaladoc retention + the CRLF `\r` pin; TortureVref:
|
|
* value-ref targets, shadow prune, interpolation reads, the last-wins
|
|
* mis-target; TortureFnref: all five capture channels + var-init
|
|
* non-capture + eta expansion; TortureGiven/TortureExt: the anon-body and
|
|
* extension leak asymmetries — the port's likeliest regression sites;
|
|
* TortureIndent: Scala-3 indentation syntax through the external scanner;
|
|
* TortureMisc: package objects/braced packages/self-types/super-ctor args/
|
|
* unicode columns; TortureScript.sc: top-level statements from the FILE)
|
|
* and their CRLF variants (derived in-memory — #1329), plus MISSING-only and
|
|
* real-error defer pins and the #1823 grammar-bump pins.
|
|
*
|
|
* The full-repo sweeps live in scripts/kernel-parity.mjs (os-lib/cats +
|
|
* scala3 compiler/src + library/src with --max-deferral 0.3); this suite
|
|
* keeps the invariant alive in `npm test`. Skips when no kernel binary is
|
|
* staged; CODEGRAPH_KERNEL_EXPECT=1 turns that into a failure.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { extractFromSource } from '../src/extraction';
|
|
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
|
|
import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel';
|
|
import type { ExtractionResult } from '../src/types';
|
|
|
|
const KERNEL_PATH = path.join(
|
|
__dirname,
|
|
'..',
|
|
'codegraph-kernel',
|
|
'prebuilds',
|
|
`${process.platform}-${process.arch}`,
|
|
'codegraph-kernel.node'
|
|
);
|
|
const kernelBuilt = fs.existsSync(KERNEL_PATH);
|
|
|
|
const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'kernel-parity');
|
|
|
|
function canon(result: ExtractionResult): { nodes: string[]; edges: string[]; refs: string[] } {
|
|
return {
|
|
nodes: result.nodes
|
|
.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort()))
|
|
.sort(),
|
|
edges: result.edges.map((e) => JSON.stringify(e, Object.keys(e).sort())).sort(),
|
|
refs: result.unresolvedReferences
|
|
.map((r) => JSON.stringify(r, Object.keys(r).sort()))
|
|
.sort(),
|
|
};
|
|
}
|
|
|
|
const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const;
|
|
let savedEnv: Record<string, string | undefined>;
|
|
|
|
describe.skipIf(!kernelBuilt)('kernel Scala extraction parity', () => {
|
|
beforeAll(async () => {
|
|
await initGrammars();
|
|
await loadGrammarsForLanguages(['scala']);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
resetKernelForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
resetKernelForTests();
|
|
});
|
|
|
|
function assertParity(filePath: string, source: string, minNodes = 2): ExtractionResult {
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
const viaKernel = tryKernelExtract(filePath, source, 'scala');
|
|
expect(viaKernel, `kernel extraction failed for ${filePath}`).not.toBeNull();
|
|
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource(filePath, source, 'scala');
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
|
|
const k = canon(viaKernel!);
|
|
const w = canon(viaWasm);
|
|
expect(k.nodes, `${filePath}: nodes`).toEqual(w.nodes);
|
|
expect(k.edges, `${filePath}: edges`).toEqual(w.edges);
|
|
expect(k.refs, `${filePath}: refs`).toEqual(w.refs);
|
|
expect(viaWasm.nodes.length).toBeGreaterThanOrEqual(minNodes);
|
|
return viaKernel!;
|
|
}
|
|
|
|
const FIXTURES = [
|
|
['torture.scala', 40],
|
|
['ScalaCompanions.scala', 14],
|
|
['TortureDocs.scala', 3],
|
|
['TortureVref.scala', 4],
|
|
['TortureFnref.scala', 4],
|
|
['TortureGiven.scala', 3],
|
|
['TortureExt.scala', 1],
|
|
['TortureIndent.scala', 3],
|
|
['TortureMisc.scala', 4],
|
|
['TortureScript.sc', 1],
|
|
] as const;
|
|
|
|
for (const [file, minNodes] of FIXTURES) {
|
|
it(`${file}: parity`, () => {
|
|
const src = fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf8');
|
|
assertParity(`fixtures/${file}`, src, minNodes);
|
|
});
|
|
|
|
it(`${file}: CRLF parity`, () => {
|
|
const src = fs.readFileSync(path.join(FIXTURE_DIR, file), 'utf8');
|
|
const crlf = src.replace(/(?<!\r)\n/g, '\r\n');
|
|
assertParity(`fixtures/${file} (crlf)`, crlf, minNodes);
|
|
});
|
|
}
|
|
|
|
it.each(['LF', 'CRLF'])('companion kinds and method ownership (%s)', (eol) => {
|
|
let source = fs.readFileSync(path.join(FIXTURE_DIR, 'ScalaCompanions.scala'), 'utf8');
|
|
if (eol === 'CRLF') source = source.replace(/\n/g, '\r\n');
|
|
const result = assertParity('ScalaCompanions.scala', source, 14);
|
|
for (const [owner, method] of [
|
|
['ObjectFirst', 'create'], ['TraitFirst', 'factory'], ['Live', 'run'], ['Local', 'localMethod'],
|
|
]) {
|
|
const obj = result.nodes.find((n) => n.kind === 'module' && n.name === owner)!;
|
|
const member = result.nodes.find((n) => n.kind === 'method' && n.name === method)!;
|
|
expect(obj).toBeDefined();
|
|
expect(member).toBeDefined();
|
|
expect(result.edges.some((e) => e.kind === 'contains' && e.source === obj.id && e.target === member.id)).toBe(true);
|
|
}
|
|
expect(result.nodes.some((n) => n.kind === 'class' && n.name === 'ObjectFirst')).toBe(true);
|
|
expect(result.nodes.some((n) => n.kind === 'trait' && n.name === 'TraitFirst')).toBe(true);
|
|
expect(result.nodes.some((n) => n.kind === 'module' && n.name === 'Empty')).toBe(true);
|
|
});
|
|
|
|
it('torture pins: import first-segment names, companion pairs, value-ref edges', () => {
|
|
const src = fs.readFileSync(path.join(FIXTURE_DIR, 'torture.scala'), 'utf8');
|
|
const result = assertParity('fixtures/torture.scala', src, 40);
|
|
// Imports are named the FIRST path segment.
|
|
const imports = result.nodes.filter((n) => n.kind === 'import');
|
|
expect(imports.length).toBeGreaterThan(0);
|
|
expect(imports.every((n) => !n.name.includes('.'))).toBe(true);
|
|
// No namespace node, ever (package headers ignored).
|
|
expect(result.nodes.some((n) => n.kind === 'namespace')).toBe(false);
|
|
// Value-ref edges exist and are metadata-tagged.
|
|
expect(result.edges.some((e) => e.kind === 'references' && e.metadata?.valueRef === true)).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('a hasError flag without ERROR nodes defers (MISSING-only tree)', () => {
|
|
// A tree whose only defect is a MISSING node: hasError is true, yet there
|
|
// is no ERROR node to find. The kernel must defer on the FLAG.
|
|
const missingOnly = 'object O { val x = }\n';
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(tryKernelExtract('src/missing.scala', missingOnly, 'scala')).toBeNull();
|
|
process.env.CODEGRAPH_KERNEL = '0';
|
|
const viaWasm = extractFromSource('src/missing.scala', missingOnly, 'scala');
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(viaWasm.nodes.some((n) => n.kind === 'file')).toBe(true);
|
|
});
|
|
|
|
it('real parse errors defer', () => {
|
|
const broken = 'class A {\n def f( = 1\n}\n';
|
|
process.env.CODEGRAPH_KERNEL_LANGS = 'all';
|
|
delete process.env.CODEGRAPH_KERNEL;
|
|
expect(tryKernelExtract('src/broken.scala', broken, 'scala')).toBeNull();
|
|
});
|
|
|
|
it('Scala 3 syntax the old grammar pin rejected now parses on both paths', () => {
|
|
// Capture-checking `^` and given-with bodies were parse errors under the
|
|
// master@0aca5d0a6f pin and deferred to wasm; v0.26.2 reads them (#1823).
|
|
assertParity('src/capture.scala', 'def f(x: List[Int]^): Int = 1\n', 2);
|
|
assertParity('src/given.scala', 'trait C\ngiven x: C with { def y = 1 }\n', 2);
|
|
});
|
|
|
|
it('keeps every parent after a parent with several argument lists (#1823)', () => {
|
|
const src = [
|
|
'class MExtAgreement(agreement: Agreement, ext: Ext)(implicit qs: QueryService)',
|
|
' extends MAgreement(agreement)(qs) with ExtAgreement with Other {',
|
|
' def foo: Int = 1',
|
|
'}',
|
|
'',
|
|
].join('\n');
|
|
const result = assertParity('src/agreement.scala', src, 3);
|
|
const parents = result.unresolvedReferences
|
|
.filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements')
|
|
.map((r) => r.referenceName)
|
|
.sort();
|
|
expect(parents).toEqual(['ExtAgreement', 'MAgreement', 'Other']);
|
|
});
|
|
});
|