mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-17 23:52:36 +00:00
* fix(parse): stabilize parse-cache chunks and cheapen ParsedFile loads Hash-bucket membership so worker count and add/delete no longer reshuffle cache keys; GC and path sidecars keep small-shard scope-resolution from full-store JSON and empty GCs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): apply review findings Drop the unused pool argument from cache-budget resolution, reuse path compare helpers, and copy durable sidecars via full shard paths. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): copy durable path sidecars via full shard paths Keep restore destinations relative to the run store even when sidecar names are derived from absolute json paths. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): fail closed on truncated ParsedFile path sidecars Skip JSON only when a sidecar is complete (NUL-free, trailing newline). Truncated listings without a NUL were able to omit wanted paths. Co-authored-by: Cursor <cursoragent@cursor.com> * style: apply prettier to ParsedFile store and tests Match the PR autofix formatter so CI quality does not flag wrap-only diffs. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): tighten ParsedFile path sidecars from review Skip sidecar writes when a path contains CR/LF, and assert the skip path does not open non-intersecting JSON shards. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): yield on sidecar skips and assert restore copies listing bytes Skipped shards now count toward the 128-shard event-loop yield, and restore tests check sidecar contents rather than existence only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): treat path sidecars as best-effort after a JSON shard write A sidecar ENOSPC/EACCES must not fail persist; load already falls back to the JSON shard when the listing is missing. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): drop stale path sidecars when a shard is no longer listing-safe Rewriting a shard with a newline-bearing path must unlink the old listing so load does not skip the JSON payload. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(parse): keep worker-integration tests aligned with hash buckets Quarantine cache-skip asserts the poison pack hash, clone-skip keeps poison and survivors in one bucket, and restore unlinks a stale dest sidecar when the durable source has none. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(parse): address review follow-ups for cache packs and sidecars Record SCHEMA_BUMP 80, pin pack locality and sidecar load/restore tests, and keep sidecar I/O best-effort with shared ENOENT handling. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): drop stale path sidecars after a failed listing write A leftover .paths file after ENOSPC (or similar) made load skip the new JSON shard. Hash expected packs with the same env budget production uses. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(store): drop path sidecars before overwriting parsed-file JSON Load trusts a leftover .paths listing, so rewriting a shard must unlink that listing first. Otherwise an interrupted sidecar refresh can hide newly written files. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(store): fail closed on truncated or CR path sidecars Count-prefix listings so a newline-terminated partial sidecar cannot skip the JSON shard, and reject CR instead of stripping it. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): expect single-file watch refresh telemetry The production analyze --watch e2e was still pinned to the old pack-cascade "8 re-parsed" line, so shard 1/3 timed out after a correct 1-file refresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): expect one reparsed file on a non-bean incremental touch Pack-cascade leftover: the drift-skip test still required 7 reparsed files after logger.ts-only edits. Cheap ParsedFile loads now reparse just that file. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
178 lines
6.7 KiB
TypeScript
178 lines
6.7 KiB
TypeScript
/**
|
|
* U14 (F7 architectural from PR #1693 review) — Function-scope env reads
|
|
* in parse-impl.
|
|
*
|
|
* Pre-U14, `CHUNK_BYTE_BUDGET` was a module-load IIFE constant that
|
|
* captured `GITNEXUS_CHUNK_BYTE_BUDGET` once and froze the value for
|
|
* the module's lifetime. That defeated `PipelineOptions.chunkByteBudget`
|
|
* (silently no-op'd because the body read the frozen constant) AND
|
|
* forced tests to use `vi.resetModules` to vary the chunk layout (see
|
|
* the U7 deferred-extraction test and the U6 multi-chunk integration
|
|
* test for examples of the workaround).
|
|
*
|
|
* After U14:
|
|
* - Option present -> option wins (per-call, no env / no vi.resetModules)
|
|
* - Option absent -> env wins (back-compat)
|
|
* - Both absent -> built-in 2 MB default
|
|
*
|
|
* This file pins all three resolution branches, plus the behavioral
|
|
* invariant the workaround was masking: two back-to-back runs in the
|
|
* same vitest worker process can use DIFFERENT `chunkByteBudget` values
|
|
* and observe DIFFERENT chunking on the same fixture WITHOUT needing
|
|
* `vi.resetModules` between them.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
|
|
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
|
import { PARSE_CACHE_VERSION, parseCacheBucketId } from '../../src/storage/parse-cache.js';
|
|
|
|
const ORIGINAL_BUDGET = process.env.GITNEXUS_CHUNK_BYTE_BUDGET;
|
|
|
|
type Fixture = Record<string, string>;
|
|
|
|
function makeRepo(fixture: Fixture): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'parse-impl-env-reads-'));
|
|
for (const [name, content] of Object.entries(fixture)) {
|
|
fs.writeFileSync(path.join(dir, name), content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function scanned(repo: string, files: string[]) {
|
|
return files.map((rel) => ({
|
|
path: rel,
|
|
size: fs.statSync(path.join(repo, rel)).size,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Capture every per-chunk progress message emitted during a run.
|
|
* parse-impl emits one per chunk in the "Parsing chunk X/Y" form, so
|
|
* counting unique chunk indices in the captured stream is a stable
|
|
* proxy for the number of chunks the loop actually produced. Avoids
|
|
* exposing internal counter state from parse-impl.
|
|
*/
|
|
async function countChunksFromProgress(
|
|
repoPath: string,
|
|
files: string[],
|
|
options?: { chunkByteBudget?: number },
|
|
): Promise<number> {
|
|
const scan = scanned(repoPath, files);
|
|
const graph = createKnowledgeGraph();
|
|
const chunkIndices = new Set<string>();
|
|
await runChunkedParseAndResolve(
|
|
graph,
|
|
scan,
|
|
files,
|
|
files.length,
|
|
repoPath,
|
|
Date.now(),
|
|
(p) => {
|
|
if (typeof p.message !== 'string') return;
|
|
const m = /Parsing chunk (\d+)\/(\d+)/.exec(p.message);
|
|
if (m !== null) chunkIndices.add(`${m[1]}/${m[2]}`);
|
|
},
|
|
// Chunk count is byte-budget-driven and emitted before the pool runs, so it
|
|
// is independent of worker vs sequential. Sequential parsing was removed, so
|
|
// this runs through the (auto-sized, dist-backed) worker pool — hence the
|
|
// integration tier.
|
|
{ ...options },
|
|
);
|
|
return chunkIndices.size;
|
|
}
|
|
|
|
describe('parse-impl chunkByteBudget resolution (U14 / F7)', () => {
|
|
let repoPath = '';
|
|
|
|
beforeEach(() => {
|
|
repoPath = makeRepo({
|
|
'a.ts': 'export const A = 1;\n',
|
|
'b.ts': 'export const B = 2;\n',
|
|
'c.ts': 'export const C = 3;\n',
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (repoPath && fs.existsSync(repoPath)) {
|
|
fs.rmSync(repoPath, { recursive: true, force: true });
|
|
}
|
|
if (ORIGINAL_BUDGET === undefined) {
|
|
delete process.env.GITNEXUS_CHUNK_BYTE_BUDGET;
|
|
} else {
|
|
process.env.GITNEXUS_CHUNK_BYTE_BUDGET = ORIGINAL_BUDGET;
|
|
}
|
|
});
|
|
|
|
it('option-first: PipelineOptions.chunkByteBudget overrides the env var', async () => {
|
|
// Force the env to a HUGE value that would normally collapse the
|
|
// fixture to a single chunk; pass a SMALL option that produces 3
|
|
// chunks. If the option wins, we observe 3 chunks; if env wins, 1.
|
|
process.env.GITNEXUS_CHUNK_BYTE_BUDGET = String(10 * 1024 * 1024);
|
|
const chunks = await countChunksFromProgress(repoPath, ['a.ts', 'b.ts', 'c.ts'], {
|
|
chunkByteBudget: 8,
|
|
});
|
|
expect(chunks).toBe(3);
|
|
});
|
|
|
|
it('env-fallback: GITNEXUS_CHUNK_BYTE_BUDGET is honored when the option is absent', async () => {
|
|
process.env.GITNEXUS_CHUNK_BYTE_BUDGET = '8';
|
|
const chunks = await countChunksFromProgress(repoPath, ['a.ts', 'b.ts', 'c.ts']);
|
|
expect(chunks).toBe(3);
|
|
});
|
|
|
|
it('default-fallback: 2 MB budget packs by bucket, not one sequential mega-chunk', async () => {
|
|
delete process.env.GITNEXUS_CHUNK_BYTE_BUDGET;
|
|
const files = ['a.ts', 'b.ts', 'c.ts'];
|
|
const expectedBuckets = new Set(files.map((f) => `typescript\0${parseCacheBucketId(f)}`));
|
|
const chunks = await countChunksFromProgress(repoPath, files);
|
|
expect(chunks).toBe(expectedBuckets.size);
|
|
});
|
|
|
|
it('per-call: two back-to-back runs with different option values observe their own values, not the previous call', async () => {
|
|
// The behavioral invariant U14 restores: a long-running host
|
|
// (eval-server, MCP daemon) calling runChunkedParseAndResolve twice
|
|
// with different chunkByteBudget values gets the value it passed,
|
|
// not whatever the first call set (pre-U14, the module-load IIFE
|
|
// froze the value at import — the option was a silent no-op).
|
|
const files = ['a.ts', 'b.ts', 'c.ts'];
|
|
delete process.env.GITNEXUS_CHUNK_BYTE_BUDGET;
|
|
const small = await countChunksFromProgress(repoPath, files, {
|
|
chunkByteBudget: 8,
|
|
});
|
|
const large = await countChunksFromProgress(repoPath, files, {
|
|
chunkByteBudget: 10 * 1024 * 1024,
|
|
});
|
|
expect(small).toBe(3);
|
|
const expectedBuckets = new Set(files.map((f) => `typescript\0${parseCacheBucketId(f)}`));
|
|
expect(large).toBe(expectedBuckets.size);
|
|
});
|
|
|
|
it('workerPoolSize 1 vs 2 produce the same cache keys when budget is unset (#3088)', async () => {
|
|
delete process.env.GITNEXUS_CHUNK_BYTE_BUDGET;
|
|
const files = ['a.ts', 'b.ts', 'c.ts'];
|
|
const keysForPool = async (workerPoolSize: number): Promise<string[]> => {
|
|
const parseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map(),
|
|
usedKeys: new Set<string>(),
|
|
};
|
|
const graph = createKnowledgeGraph();
|
|
await runChunkedParseAndResolve(
|
|
graph,
|
|
scanned(repoPath, files),
|
|
files,
|
|
files.length,
|
|
repoPath,
|
|
Date.now(),
|
|
() => {},
|
|
{ workerPoolSize, parseCache },
|
|
);
|
|
return [...parseCache.usedKeys].sort();
|
|
};
|
|
expect(await keysForPool(1)).toEqual(await keysForPool(2));
|
|
});
|
|
});
|