mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-07 08:26:11 +00:00
* fix(swift): preprocess indented conditional directives so class bodies survive parsing * fix(swift): make conditional-directive blanking comment-, string- and brace-aware (#2771) Addresses the review findings on PR #2771. The transform fired unconditionally, which turned valid Swift into parse errors while missing the most common shape it was written for. - The blank/keep decision now consults `blockCommentDepth`, so ` #endif */` — the result of commenting out a conditional block — keeps its comment terminator. Previously `hasError` went raw=false -> preprocessed=true and the rest of the file was swallowed. - The decision keys on the scanner's brace depth instead of indentation. A column-0 `#if` inside a class body is blanked (6 of 7 body shapes previously still lost the enclosing declaration) and an indented file-scope directive is not — matching what the doc comment already claimed. Bare-CR line endings, NBSP/ideographic indentation and a leading BOM are recognized too. - A group is blanked only when every branch is brace-balanced. An `#if`/`#else` that splits a declaration header leaves one unmatched `{` once both branches survive, which collapsed five top-level nodes into one and gave unrelated types fabricated `NetworkClient.` qualified names. Such a group now degrades to the pre-fix behavior. - Multiline strings honour `\"""` escapes, and a plain `"""` closes even when a `#` follows it, so the scanner no longer wedges in string state and silently stops blanking for the rest of the file. - The pound run is counted once per position and skipped. It was quadratic: 10.6s for one 64k-`#` line, well inside the 512 KB walker limit. - Extended regex literals (`#/.../#`) no longer open a phantom block comment. - Directive-free files return early, matching `stripUeMacros`. Worker parity: `emitSwiftScopeCaptures` and `emitCppScopeCaptures` re-apply their provider's `preprocessSource` on the parse-cache-miss path — Dart already did this — and the embedding parse in `ensureAndParse` applies the hook as well. Before this the worker and the scope-capture/embedding halves analyzed different programs, turning a consistent degradation into cold-run/warm-run non-determinism. A new parity test pins the equivalence for every provider that defines the hook. SCHEMA_BUMP 37 -> 38: this changes parse semantics, the chunk key hashes raw on-disk bytes, and `preprocessSource` runs after the key is computed — so a same-package-version warm cache would replay pre-fix Swift results verbatim, including across `--force`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(ingestion): apply preprocessSource once in the scope bridge (#2771) Follow-up cleanup on the review fixes. The previous commit re-applied each provider's `preprocessSource` inside `emitSwiftScopeCaptures` and `emitCppScopeCaptures`, mirroring what Dart already did — three copies of the same rule, and a contract that asked every future emitter to remember it. `extractParsedFile` is the single funnel every `emitScopeCaptures` caller passes through (parse worker, scope-resolution run, Vue script extraction), and it already receives the provider. Applying the hook there on the cache-miss path covers all three languages and every future one, names no language in shared code, and drops Dart's unconditional transform on the cache-hit path. Verified the three emitters use `sourceText` for nothing but the parse, so the substitution is output-identical — which the parity test asserts directly. Also from the cleanup pass: - the parity test derives its language list from the provider registry, so a new provider adopting the hook fails until it adds a fixture - `ensureAndParse` resolves the provider from the language it already computed, instead of a second extension table (`getProviderForFile`) - the preprocessor returns `sourceText` unchanged when no group was blanked, which is the common case for files whose only directives are top-level - `split(/(\r\n|\n|\r)/)` replaces the hand-rolled line splitter, and the per-group brace bookkeeping is two scalars instead of an array - the hint regex is derived from the line regex so the two cannot drift - unit assertions compare the WHOLE preprocessed file against the expected blanking, replacing per-line spot checks; the pipeline tests share one `runFixture` helper and `getNodesForFile` in the resolver test helpers - `LanguageProvider.preprocessSource` documents the real call sites and says plainly that the set is not closed — `populateRangeBindings` still hands language helpers raw text Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
/**
|
|
* Unit tests for character chunking and AST-aware chunking logic.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { characterChunk } from '../../src/core/embeddings/character-chunk.js';
|
|
|
|
const { createParserForLanguage } = vi.hoisted(() => ({
|
|
createParserForLanguage: vi.fn(),
|
|
}));
|
|
|
|
const { getLanguageFromFilename } = vi.hoisted(() => ({
|
|
getLanguageFromFilename: vi.fn((filePath: string) =>
|
|
filePath.endsWith('.rs') ? 'rust' : 'typescript',
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({
|
|
createParserForLanguage,
|
|
isLanguageAvailable: vi.fn().mockReturnValue(true),
|
|
resolveLanguageKey: vi.fn((language: string, filePath?: string) =>
|
|
language === 'typescript' && filePath?.endsWith('.tsx') ? 'typescript:tsx' : language,
|
|
),
|
|
}));
|
|
|
|
// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply
|
|
// `preprocessSource`, and that graph needs the real shared exports (#2771).
|
|
vi.mock('gitnexus-shared', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('gitnexus-shared')>()),
|
|
getLanguageFromFilename,
|
|
}));
|
|
|
|
import { chunkNode } from '../../src/core/embeddings/chunker.js';
|
|
|
|
type FakeNode = {
|
|
type: string;
|
|
startIndex: number;
|
|
endIndex: number;
|
|
namedChildCount: number;
|
|
namedChild: (index: number) => FakeNode | null;
|
|
childForFieldName?: (name: string) => FakeNode | null;
|
|
};
|
|
|
|
const makeFakeNode = (
|
|
type: string,
|
|
startIndex: number,
|
|
endIndex: number,
|
|
children: FakeNode[] = [],
|
|
fields: Record<string, FakeNode> = {},
|
|
): FakeNode => ({
|
|
type,
|
|
startIndex,
|
|
endIndex,
|
|
namedChildCount: children.length,
|
|
namedChild: (index: number) => children[index] ?? null,
|
|
childForFieldName: (name: string) => fields[name] ?? null,
|
|
});
|
|
|
|
const makeFunctionTree = (content: string, statementTexts: string[]) => {
|
|
const statementNodes = statementTexts.map((text) => {
|
|
const startIndex = content.indexOf(text);
|
|
return makeFakeNode('expression_statement', startIndex, startIndex + text.length);
|
|
});
|
|
|
|
const bodyStart = content.indexOf('{');
|
|
const bodyEnd = content.lastIndexOf('}') + 1;
|
|
const bodyNode = makeFakeNode('statement_block', bodyStart, bodyEnd, statementNodes);
|
|
const fnNode = makeFakeNode('function_declaration', 0, bodyEnd, [], { body: bodyNode });
|
|
const root = makeFakeNode('program', 0, content.length, [fnNode]);
|
|
|
|
return {
|
|
rootNode: root,
|
|
};
|
|
};
|
|
|
|
const makeTypedFunctionTree = (nodeType: string, content: string, statementTexts: string[]) => {
|
|
const statementNodes = statementTexts.map((text) => {
|
|
const startIndex = content.indexOf(text);
|
|
return makeFakeNode('expression_statement', startIndex, startIndex + text.length);
|
|
});
|
|
|
|
const bodyStart = content.indexOf('{');
|
|
const bodyEnd = content.lastIndexOf('}') + 1;
|
|
const bodyNode = makeFakeNode('statement_block', bodyStart, bodyEnd, statementNodes);
|
|
const fnNode = makeFakeNode(nodeType, 0, bodyEnd, [], { body: bodyNode });
|
|
const root = makeFakeNode('program', 0, content.length, [fnNode]);
|
|
|
|
return {
|
|
rootNode: root,
|
|
};
|
|
};
|
|
|
|
const makeDeclarationTree = (
|
|
nodeType: string,
|
|
bodyType: string,
|
|
content: string,
|
|
memberTexts: string[],
|
|
) => {
|
|
let searchFrom = 0;
|
|
const memberNodes = memberTexts.map((text, index) => {
|
|
const startIndex = content.indexOf(text, searchFrom);
|
|
if (startIndex < 0) {
|
|
throw new Error(`Unable to locate member text: ${text}`);
|
|
}
|
|
searchFrom = startIndex + text.length;
|
|
const inferredType =
|
|
text.includes('()') || text.includes(': void') || text.includes(': boolean')
|
|
? 'method_definition'
|
|
: 'field_definition';
|
|
return makeFakeNode(inferredType, startIndex, startIndex + text.length);
|
|
});
|
|
|
|
const bodyStart = content.indexOf('{');
|
|
const bodyEnd = content.lastIndexOf('}') + 1;
|
|
const bodyNode = makeFakeNode(bodyType, bodyStart, bodyEnd, memberNodes);
|
|
const declNode = makeFakeNode(nodeType, 0, bodyEnd, [bodyNode], { body: bodyNode });
|
|
const root = makeFakeNode('program', 0, content.length, [declNode]);
|
|
|
|
return {
|
|
rootNode: root,
|
|
};
|
|
};
|
|
|
|
describe('characterChunk', () => {
|
|
it('returns single chunk when content fits', () => {
|
|
const result = characterChunk('short content', 1, 5, 1200, 120);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].text).toBe('short content');
|
|
expect(result[0].chunkIndex).toBe(0);
|
|
expect(result[0].startOffset).toBe(0);
|
|
expect(result[0].endOffset).toBe('short content'.length);
|
|
expect(result[0].startLine).toBe(1);
|
|
expect(result[0].endLine).toBe(5);
|
|
});
|
|
|
|
it('splits long content into multiple chunks', () => {
|
|
const longContent = 'a'.repeat(3000);
|
|
const result = characterChunk(longContent, 1, 100, 1200, 120);
|
|
expect(result.length).toBeGreaterThan(1);
|
|
for (const chunk of result) {
|
|
expect(chunk.text.length).toBeLessThanOrEqual(1200);
|
|
}
|
|
});
|
|
|
|
it('maintains sequential chunkIndex and offsets', () => {
|
|
const longContent = 'x'.repeat(3000);
|
|
const result = characterChunk(longContent, 1, 100, 1200, 120);
|
|
for (let i = 0; i < result.length; i++) {
|
|
expect(result[i].chunkIndex).toBe(i);
|
|
expect(result[i].text).toBe(longContent.slice(result[i].startOffset, result[i].endOffset));
|
|
}
|
|
});
|
|
|
|
it('includes overlap between chunks', () => {
|
|
const content = 'abcdefghij'.repeat(200);
|
|
const result = characterChunk(content, 1, 50, 500, 50);
|
|
if (result.length > 1) {
|
|
const endOfFirst = result[0].text.slice(-50);
|
|
expect(result[1].text.startsWith(endOfFirst)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('keeps the first chunk on the real starting line', () => {
|
|
const content = 'alpha\nbeta\ngamma';
|
|
const result = characterChunk(content, 38, 40, 6, 0);
|
|
expect(result[0].startLine).toBe(38);
|
|
});
|
|
|
|
it('does not advance endLine when a chunk ends at a newline boundary', () => {
|
|
const content = 'aaa\nbbb\nccc';
|
|
const result = characterChunk(content, 10, 12, 4, 0);
|
|
expect(result[0].text).toBe('aaa\n');
|
|
expect(result[0].startLine).toBe(10);
|
|
expect(result[0].endLine).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('chunkNode', () => {
|
|
beforeEach(() => {
|
|
createParserForLanguage.mockReset();
|
|
getLanguageFromFilename.mockImplementation((filePath: string) =>
|
|
filePath.endsWith('.rs') ? 'rust' : 'typescript',
|
|
);
|
|
});
|
|
|
|
it('returns single chunk for short content', async () => {
|
|
const result = await chunkNode('Function', 'short', 'test.ts', 1, 5, 1200, 120);
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].chunkIndex).toBe(0);
|
|
expect(result[0].text).toBe('short');
|
|
expect(result[0].startOffset).toBe(0);
|
|
});
|
|
|
|
it('splits a class by members instead of raw character windows', async () => {
|
|
const content = [
|
|
'class Parser {',
|
|
' options: ParserOptions;',
|
|
' cache: Map<string, any>;',
|
|
' parseJSON() { return JSON.parse("{}"); }',
|
|
' validate() { return true; }',
|
|
'}',
|
|
].join('\n');
|
|
const tree = makeDeclarationTree('class_declaration', 'class_body', content, [
|
|
'options: ParserOptions;',
|
|
'cache: Map<string, any>;',
|
|
'parseJSON() { return JSON.parse("{}"); }',
|
|
'validate() { return true; }',
|
|
]);
|
|
createParserForLanguage.mockResolvedValue({
|
|
parse: vi.fn().mockReturnValue(tree),
|
|
});
|
|
|
|
const result = await chunkNode('Class', content, 'test.ts', 1, 6, 90, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].text).toContain('class Parser {');
|
|
expect(result[0].text).toContain('options: ParserOptions;');
|
|
expect(result[0].text).toContain('cache: Map<string, any>;');
|
|
expect(result[1].text).toContain('parseJSON()');
|
|
expect(result[1].text).toContain('validate()');
|
|
expect(result[0].startLine).toBe(1);
|
|
expect(result[1].startLine).toBe(4);
|
|
});
|
|
|
|
it('preserves interface signatures via declaration-aware chunking', async () => {
|
|
const content = [
|
|
'interface Handler {',
|
|
' handle(event: Event): void;',
|
|
' validate(input: string): boolean;',
|
|
' readonly name: string;',
|
|
'}',
|
|
].join('\n');
|
|
const tree = makeDeclarationTree('interface_declaration', 'object_type', content, [
|
|
'handle(event: Event): void;',
|
|
'validate(input: string): boolean;',
|
|
'readonly name: string;',
|
|
]);
|
|
createParserForLanguage.mockResolvedValue({
|
|
parse: vi.fn().mockReturnValue(tree),
|
|
});
|
|
|
|
const result = await chunkNode('Interface', content, 'test.ts', 10, 14, 500, 0);
|
|
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].text).toContain('interface Handler {');
|
|
expect(result[0].text).toContain('handle(event: Event): void;');
|
|
expect(result[0].text).toContain('validate(input: string): boolean;');
|
|
expect(result[0].text).toContain('readonly name: string;');
|
|
});
|
|
|
|
it('uses declaration-aware chunking for Struct labels', async () => {
|
|
const content = [
|
|
'struct User {',
|
|
' name: String,',
|
|
' email: String,',
|
|
' age: u32,',
|
|
' address: String,',
|
|
'}',
|
|
].join('\n');
|
|
const tree = makeDeclarationTree('struct_item', 'declaration_list', content, [
|
|
'name: String,',
|
|
'email: String,',
|
|
'age: u32,',
|
|
'address: String,',
|
|
]);
|
|
createParserForLanguage.mockResolvedValue({
|
|
parse: vi.fn().mockReturnValue(tree),
|
|
});
|
|
|
|
const result = await chunkNode('Struct', content, 'test.rs', 40, 45, 45, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].text).toContain('struct User {');
|
|
expect(result[0].text).toContain('name: String,');
|
|
expect(result[0].text).toContain('email: String');
|
|
const combinedText = result.map((chunk) => chunk.text).join('\n');
|
|
expect(combinedText).toContain('email: String');
|
|
expect(combinedText).toContain('age: u32');
|
|
expect(combinedText).toContain('address: String');
|
|
expect(result[0].startLine).toBe(40);
|
|
});
|
|
|
|
it('splits a function into multiple AST-aware chunks using snippet offsets', async () => {
|
|
const content = [
|
|
'function example() {',
|
|
' const first = 1;',
|
|
'',
|
|
' const second = 2;',
|
|
' return first + second;',
|
|
'}',
|
|
].join('\n');
|
|
const tree = makeFunctionTree(content, [
|
|
'const first = 1;',
|
|
'const second = 2;',
|
|
'return first + second;',
|
|
]);
|
|
createParserForLanguage.mockResolvedValue({
|
|
parse: vi.fn().mockReturnValue(tree),
|
|
});
|
|
|
|
const result = await chunkNode('Function', content, 'test.ts', 38, 43, 68, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].startOffset).toBe(0);
|
|
expect(result[0].endOffset).toBeGreaterThan(content.indexOf('const second = 2;'));
|
|
expect(result[0].startLine).toBe(38);
|
|
expect(result[0].endLine).toBe(42);
|
|
expect(result[0].text).toContain('function example() {');
|
|
expect(result[0].text).toContain('\n\n const second = 2;');
|
|
expect(result[1].startOffset).toBeGreaterThan(content.indexOf('const second = 2;'));
|
|
expect(result[1].startLine).toBeGreaterThanOrEqual(42);
|
|
expect(result[1].endLine).toBe(43);
|
|
expect(result[1].text.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('uses AST-aware chunking for Constructor labels too', async () => {
|
|
const content = [
|
|
'constructor() {',
|
|
' this.ready = true;',
|
|
' this.mode = "prod";',
|
|
' this.start();',
|
|
'}',
|
|
].join('\n');
|
|
const tree = makeFunctionTree(content, [
|
|
'this.ready = true;',
|
|
'this.mode = "prod";',
|
|
'this.start();',
|
|
]);
|
|
createParserForLanguage.mockResolvedValue({
|
|
parse: vi.fn().mockReturnValue(tree),
|
|
});
|
|
|
|
const result = await chunkNode('Constructor', content, 'test.ts', 12, 16, 55, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].text).toContain('constructor() {');
|
|
expect(result[0].startLine).toBe(12);
|
|
expect(result[1].startLine).toBe(14);
|
|
});
|
|
|
|
it('recognizes Rust function_item nodes for AST-aware chunking', async () => {
|
|
const content = [
|
|
'fn build_user() {',
|
|
' let first = 1;',
|
|
' let second = 2;',
|
|
' return first + second;',
|
|
'}',
|
|
].join('\n');
|
|
const tree = makeTypedFunctionTree('function_item', content, [
|
|
'let first = 1;',
|
|
'let second = 2;',
|
|
'return first + second;',
|
|
]);
|
|
createParserForLanguage.mockResolvedValue({
|
|
parse: vi.fn().mockReturnValue(tree),
|
|
});
|
|
|
|
const result = await chunkNode('Function', content, 'test.rs', 20, 24, 52, 0);
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].text).toContain('fn build_user() {');
|
|
expect(result[0].startLine).toBe(20);
|
|
expect(result[1].text).toContain('return first + second;');
|
|
});
|
|
|
|
it('falls back to character chunks when AST parsing fails', async () => {
|
|
createParserForLanguage.mockRejectedValueOnce(new Error('no parser'));
|
|
|
|
const content = 'x'.repeat(3000);
|
|
const result = await chunkNode('Function', content, 'test.tsx', 1, 100, 1200, 120);
|
|
expect(result.length).toBeGreaterThan(1);
|
|
expect(result[0].startOffset).toBe(0);
|
|
});
|
|
});
|