GitNexus/gitnexus/test/unit/range-binding-parse-timeout.test.ts
FAll 2cfbc4a259
feat(spring): build bean candidate inventory (#2494)
* feat(java): inventory Spring bean candidates

* fix(java): fail closed on Spring annotation shadowing

* fix(java): resolve Spring beans after imports

* fix(java): remove stale bean extraction path

* style: satisfy locked Prettier version

* fix(spring): address PR review findings

* feat(spring): share bean inventory across Java and Kotlin

* fix(spring): gate bean inventory analysis completeness

* fix(kotlin): avoid reloading cached scope source

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-20 09:28:23 +01:00

195 lines
7.8 KiB
TypeScript

import { describe, it, expect, afterEach } from 'vitest';
import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared';
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.js';
import { goScopeResolver } from '../../src/core/ingestion/languages/go/scope-resolver.js';
import { cppScopeResolver } from '../../src/core/ingestion/languages/cpp/scope-resolver.js';
import { rustScopeResolver } from '../../src/core/ingestion/languages/rust/scope-resolver.js';
import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.js';
import { kotlinScopeResolver } from '../../src/core/ingestion/languages/kotlin/scope-resolver.js';
import { populateGoRangeBindings } from '../../src/core/ingestion/languages/go/range-binding.js';
import { populateCppRangeBindings } from '../../src/core/ingestion/languages/cpp/range-bindings.js';
import { populateRustRangeBindings } from '../../src/core/ingestion/languages/rust/range-binding.js';
import { populateJavaPackageSiblings } from '../../src/core/ingestion/languages/java/package-siblings.js';
import { populateKotlinPackageSiblings } from '../../src/core/ingestion/languages/kotlin/package-siblings.js';
/**
* Regression coverage for the post-finalize parse hooks after
* `parseSourceSafe` started throwing `ParseTimeoutError` (#1922). A single
* pathological file that times out must NOT abort the whole hook run — the
* hook must hard-skip it (degrade-and-continue) and still process the
* remaining good files.
*
* Strategy: build all ParsedFiles with a generous budget, THEN set a 1ms
* budget so the large file times out on the hook's cache-miss re-parse while
* the small good file still parses cleanly (tree-sitter only checks the budget
* periodically, so trivial sources complete before the first check — same
* assumption the safe-parse timeout suite relies on).
*/
const ORIGINAL_BUDGET = process.env.GITNEXUS_PARSE_TIMEOUT_MS;
afterEach(() => {
if (ORIGINAL_BUDGET === undefined) {
delete process.env.GITNEXUS_PARSE_TIMEOUT_MS;
} else {
process.env.GITNEXUS_PARSE_TIMEOUT_MS = ORIGINAL_BUDGET;
}
});
interface ResolverLike {
languageProvider: Parameters<typeof extractParsedFile>[0];
populateOwners: (p: ParsedFile) => void;
}
function parse(resolver: ResolverLike, src: string, path: string): ParsedFile {
const p = extractParsedFile(resolver.languageProvider, src, path);
if (p === undefined) throw new Error(`scope extraction failed for ${path}`);
resolver.populateOwners(p);
return p;
}
function makeEmptyIndexes(): ScopeResolutionIndexes {
return {
bindings: new Map(),
bindingAugmentations: new Map(),
imports: [],
scopeTree: { roots: [] } as any,
methodDispatch: new Map(),
sccs: [],
} as unknown as ScopeResolutionIndexes;
}
/**
* A source large enough that a 1ms parse budget reliably times out on the
* hook's cache-miss re-parse (any parse taking >1ms trips the deadline, which
* tree-sitter checks every 100 ops), but small enough that the no-budget
* `extractParsedFile` setup parse stays fast on every grammar (C++ in
* particular is slow per byte, so 15k lines made setup take ~80s/test).
*/
function pathological(repeatLine: string): string {
return repeatLine.repeat(2_000);
}
describe('post-finalize parse hooks — single timeout does not abort the run (#1922)', () => {
it('Go: times out the bad file, still binds the good file', () => {
const good = `package main
func main() {
items := []string{"a", "b"}
for _, v := range items {
_ = v
}
}`;
// Large but syntactically plausible Go so extractParsedFile succeeds.
const bad = 'package main\n' + pathological('var x = 1\n');
const goodParsed = parse(goScopeResolver as unknown as ResolverLike, good, 'good.go');
const badParsed = parse(goScopeResolver as unknown as ResolverLike, bad, 'bad.go');
const fileContents = new Map<string, string>([
['good.go', good],
['bad.go', bad],
]);
// The bad file is first, so reaching the good file proves the timeout was
// caught and the loop continued. Not throwing IS the regression assertion
// (pre-fix this aborted the whole run). The resolved binding VALUE is not
// asserted here: range-binding resolves types from state populated by
// earlier pipeline phases (propagateImportedReturnTypes etc.) that this
// isolated hook-level test does not run, so it would be undefined either way.
process.env.GITNEXUS_PARSE_TIMEOUT_MS = '1';
expect(() =>
populateGoRangeBindings([badParsed, goodParsed], makeEmptyIndexes(), { fileContents }),
).not.toThrow();
});
it('C++: times out the bad file, still binds the good file', () => {
const good = `#include <vector>
void f(std::vector<User>& users) {
for (auto& u : users) {
(void)u;
}
}`;
const bad = pathological('int x = 1;\n');
const goodParsed = parse(cppScopeResolver as unknown as ResolverLike, good, 'good.cpp');
const badParsed = parse(cppScopeResolver as unknown as ResolverLike, bad, 'bad.cpp');
const fileContents = new Map<string, string>([
['good.cpp', good],
['bad.cpp', bad],
]);
// Not throwing is the regression assertion (the timeout on the first file
// is caught and the loop continues to the good file). The resolved binding
// value depends on earlier pipeline phases not run in this isolated test.
process.env.GITNEXUS_PARSE_TIMEOUT_MS = '1';
expect(() =>
populateCppRangeBindings([badParsed, goodParsed], makeEmptyIndexes(), { fileContents }),
).not.toThrow();
});
it('Rust: times out the bad file, still binds the good file', () => {
const good = `struct User { name: String }
fn main() {
let users: Vec<User> = vec![];
for u in users {
let _ = u;
}
}`;
const bad = pathological('static X: i32 = 1;\n');
const goodParsed = parse(rustScopeResolver as unknown as ResolverLike, good, 'good.rs');
const badParsed = parse(rustScopeResolver as unknown as ResolverLike, bad, 'bad.rs');
const fileContents = new Map<string, string>([
['good.rs', good],
['bad.rs', bad],
]);
// Not throwing is the regression assertion (the timeout on the first file
// is caught and the loop continues to the good file). The resolved binding
// value depends on earlier pipeline phases not run in this isolated test.
process.env.GITNEXUS_PARSE_TIMEOUT_MS = '1';
expect(() =>
populateRustRangeBindings([badParsed, goodParsed], makeEmptyIndexes(), { fileContents }),
).not.toThrow();
});
it.each([
{
label: 'Java',
resolver: javaScopeResolver,
populate: populateJavaPackageSiblings,
extension: 'java',
packageStatement: 'package com.example;',
},
{
label: 'Kotlin',
resolver: kotlinScopeResolver,
populate: populateKotlinPackageSiblings,
extension: 'kt',
packageStatement: 'package com.example',
},
])(
'$label: a timed-out file degrades to no package without aborting siblings',
({ resolver, populate, extension, packageStatement }) => {
const a = `${packageStatement}\nclass A {}`;
const b = `${packageStatement}\nclass B {}`;
const bad = `${packageStatement}\n${pathological('class Filler {}\n')}`;
const aPath = `A.${extension}`;
const bPath = `B.${extension}`;
const badPath = `Bad.${extension}`;
const aParsed = parse(resolver as unknown as ResolverLike, a, aPath);
const bParsed = parse(resolver as unknown as ResolverLike, b, bPath);
const badParsed = parse(resolver as unknown as ResolverLike, bad, badPath);
const fileContents = new Map<string, string>([
[aPath, a],
[bPath, b],
[badPath, bad],
]);
process.env.GITNEXUS_PARSE_TIMEOUT_MS = '1';
expect(() =>
populate([badParsed, aParsed, bParsed], makeEmptyIndexes(), { fileContents }),
).not.toThrow();
},
);
});