mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
0a9f3de72c
* test: close resolution graphs before removing Windows fixtures
Close ArkTS and JVM graph connections in afterEach, including failed assertions. Verified both files on Windows 11 and macOS (32 tests).
* test: compare explore source lines across LF and CRLF checkouts
Normalize only line endings when matching emitted source against checked-out fixtures. Preserve the closure coverage and whole-line assertions. Both files pass on Windows 11 and macOS (15 tests).
* test: exercise old Git fallback without a POSIX shell shim
Inject only the unsupported ls-files invocation and run every other Git command against real repositories. Assert that each rejected call retries, use portable graph paths, and clean up fixtures. Passes on Windows 11 and macOS.
* test: normalize Python fixture before generating CRLF cases
The tests added in #2004 produced CRCRLF from a Windows checkout. Normalize the input to LF before constructing either variant. All 28 native and WASM cases pass on Windows 11 and macOS.
* test: allow time for eviction fixture indexes on Windows
The #2036 eviction test builds and reconciles nine real indexes and consistently exceeds its default five-second timeout on the VM. Allow 15 seconds for this test alone, preserving every assertion. The full file passes on Windows 11 and macOS (21 tests).
* test: finish MCP processes before resetting Windows fixtures
Launch actual servers with their runtime flags and await termination before directory removal. Use asynchronous removal retries for daemon fixtures. Establish a ready daemon and stop its first proxy before simulating PID reuse, then make source stale without opening a competing SQLite writer. Preserve lock and database byte-equality assertions. All touched files validated on Windows 11 and macOS.
* test: allow Windows named-pipe fallback polling to finish
The VM takes up to 25.7 seconds to complete the nominal six-second retry loop. Allow 30 seconds for the three fallback waits and 40 seconds for the two shorter enclosing tests, retaining their lock-preservation and read-only assertions. The full daemon file is validated on Windows 11 and macOS.
* fix(daemon): retry transient Windows PID-file sharing violations
A transient EPERM replacing daemon.pid aborted startup, observed in the quiet-client test and reproduced with a real locked handle at ba3c21e5. Retry only Windows sharing errors with five bounded delays, rechecking ownership each time and cleaning temporary files. Keep publication synchronous before client processing. Cover transient/permanent failures, changed ownership, non-retryable errors, and a real Windows handle; require a confirmed daemon round-trip before measuring a quiet session. Validated on Windows 11 and macOS.
180 lines
8.3 KiB
TypeScript
180 lines
8.3 KiB
TypeScript
/**
|
|
* Regression fixture for CG-30 — a cluster's top member may not overshoot the
|
|
* file's budget without bound.
|
|
*
|
|
* `shrinkCluster` keeps the highest-importance member of an oversize cluster
|
|
* WHOLE, deliberately: an empty file section sends the agent to Read, which is
|
|
* the outcome explore exists to prevent. What it lacked was a bound. On the
|
|
* originating repo one file emitted 22,376 chars against a 9,181-char
|
|
* reservation — 2.44x — past both the per-file budget and the spine ceiling,
|
|
* because its top member alone was that big. The overshoot is what collapses
|
|
* `headroom` for every file ranked below it (CG-31), and it has a second face:
|
|
* a member too big for the whole response ceiling makes the file drop out
|
|
* entirely rather than render short.
|
|
*
|
|
* `__tests__/fixtures/oversize-member-ts/` reproduces both permanently. Three
|
|
* report builders compete for one envelope, each a single long function far
|
|
* bigger than any reservation it can earn beside its siblings. Measured against
|
|
* the pre-fix build, this fixture produced:
|
|
*
|
|
* monthly.ts 12,391 chars emitted on a 3,334 budget (3.7x)
|
|
* quarterly.ts dropped entirely — no headroom left (the CG-31 half)
|
|
*
|
|
* The gate below is that both are now bounded AND delivered: the bound cuts the
|
|
* overshoot, and cutting the overshoot is what buys back the starved file.
|
|
*
|
|
* Measured against `spendable`, not `reserved`: the render paths bound
|
|
* themselves by the reservation PLUS whatever slack the files above left on the
|
|
* table, so a file legitimately spending inherited slack is not an overshoot.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import CodeGraph from '../src/index';
|
|
import { ToolHandler } from '../src/mcp/tools';
|
|
import { attributeSourceBytes } from '../src/mcp/explore-diagnostics';
|
|
import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics';
|
|
|
|
const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'oversize-member-ts');
|
|
|
|
/** A symbol bag spanning the three builders — the sibling files compete. */
|
|
const QUERY = 'buildMonthlyReport buildWeeklyReport buildQuarterlyReport formatReportRow persistReport';
|
|
|
|
/** The giant: one ~24K function, far past the whole-response ceiling. */
|
|
const GIANT = 'src/report/monthly.ts';
|
|
/** Mid-size: one ~11K function — the file the giant's overshoot used to starve. */
|
|
const STARVED = 'src/report/quarterly.ts';
|
|
|
|
/** The bound: 1.5x, the same multiple the spine ceiling already draws. */
|
|
const OVERSHOOT_FACTOR = 1.5;
|
|
|
|
describe('CG-30 — an oversize cluster member is bounded, not unbounded', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
let response: string;
|
|
let report: ExploreDiagnosticReport;
|
|
let bytes: Map<string, number>;
|
|
|
|
const fileOf = (p: string): ExploreDiagnosticFile => {
|
|
const rec = report.files.find((f) => f.path === p);
|
|
if (!rec) throw new Error(`${p} absent from the diagnostic report`);
|
|
return rec;
|
|
};
|
|
/** What the render paths actually bound themselves by. */
|
|
const budgetOf = (rec: ExploreDiagnosticFile): number => rec.spendable ?? rec.allowance ?? 0;
|
|
|
|
beforeAll(async () => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg30-'));
|
|
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
|
|
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
|
|
|
|
cg = CodeGraph.initSync(testDir);
|
|
await cg.indexAll();
|
|
|
|
// The per-file budget is only observable through the diagnostic sidecar, and
|
|
// the whole gate is "emitted vs what the file was allowed to spend".
|
|
const sidecar = path.join(testDir, 'explore-diag.jsonl');
|
|
const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
|
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
|
try {
|
|
const handler = new ToolHandler(cg);
|
|
const result = await handler.execute('codegraph_explore', { query: QUERY });
|
|
response = result.content?.[0]?.text ?? '';
|
|
} finally {
|
|
if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
|
|
else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
|
|
}
|
|
const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean);
|
|
report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport;
|
|
bytes = attributeSourceBytes(response);
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
if (cg) cg.destroy();
|
|
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Fixture shape — if these rot, the gate below means nothing ─────────────
|
|
|
|
describe('fixture shape', () => {
|
|
it('holds single members far bigger than any budget they can earn', () => {
|
|
for (const file of [GIANT, STARVED]) {
|
|
const source = fs.readFileSync(path.join(testDir, file), 'utf-8');
|
|
const top = cg.getNodesInFile(file)
|
|
.filter((n) => n.kind === 'function')
|
|
.sort((a, b) => (b.endLine - b.startLine) - (a.endLine - a.startLine))[0];
|
|
expect(top, `${file} has no function node`).toBeDefined();
|
|
// One symbol, most of the file — the "top member alone is oversize" shape.
|
|
expect(top!.endLine - top!.startLine).toBeGreaterThan(180);
|
|
expect(source.length).toBeGreaterThan(budgetOf(fileOf(file)) * 2);
|
|
}
|
|
});
|
|
|
|
it('is too long to ship whole, so both render through the cluster path', () => {
|
|
for (const file of [GIANT, STARVED]) {
|
|
const lineCount = fs.readFileSync(path.join(testDir, file), 'utf-8').split('\n').length;
|
|
// Past WHOLE_FILE_MAX_LINES (220 for a non-central file), so the
|
|
// whole-file paths — grace and buy — cannot claim it.
|
|
expect(lineCount, file).toBeGreaterThan(220);
|
|
expect(fileOf(file).render, file).toBe('clusters');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── The gate ──────────────────────────────────────────────────────────────
|
|
|
|
describe('bounded overshoot', () => {
|
|
it('CG-30 GATE: the giant no longer emits a multiple of its budget', () => {
|
|
const rec = fileOf(GIANT);
|
|
// Pre-fix this file emitted 12,391 on a 3,334 budget (3.7x).
|
|
expect(rec.emittedChars).toBeLessThanOrEqual(
|
|
Math.round(budgetOf(rec) * OVERSHOOT_FACTOR) + 1);
|
|
});
|
|
|
|
it('CG-30 GATE: no clustered file emits past 1.5x what it may spend', () => {
|
|
const over = report.files
|
|
.filter((f) => f.render === 'clusters' && budgetOf(f) > 0)
|
|
.filter((f) => f.emittedChars > Math.round(budgetOf(f) * OVERSHOOT_FACTOR) + 1)
|
|
.map((f) => `${f.path}: ${f.emittedChars} of ${budgetOf(f)}`);
|
|
expect(over).toEqual([]);
|
|
});
|
|
|
|
it('CG-31: the file the overshoot used to starve is delivered', () => {
|
|
// Pre-fix: dropped with skip reason `budget-clusters` — the giant above it
|
|
// had already spent the headroom this file needed.
|
|
expect(fileOf(STARVED).skipped).toBeNull();
|
|
expect(bytes.get(STARVED) ?? 0).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('never emits an empty section — the invariant the old rule protected', () => {
|
|
for (const rec of report.files) {
|
|
if (rec.render !== 'clusters') continue;
|
|
expect(rec.emittedChars, rec.path).toBeGreaterThan(0);
|
|
}
|
|
// And the windowed file still leads with the symbol the query named.
|
|
expect(response).toContain('export function buildMonthlyReport');
|
|
});
|
|
|
|
it('cuts on whole lines — a body is never sliced mid-line', () => {
|
|
const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split(/\r?\n/);
|
|
const numbered = response
|
|
.split(/\r?\n/)
|
|
.map((l) => /^(\d+)\t(.*)$/.exec(l))
|
|
.filter((m): m is RegExpExecArray => m !== null)
|
|
.filter((m) => Number(m[1]) >= 1 && Number(m[1]) <= source.length);
|
|
const matching = numbered.filter((m) => source[Number(m[1]) - 1] === m[2]);
|
|
// Every line the response numbers for this file is that whole source line.
|
|
expect(matching.length).toBeGreaterThan(20);
|
|
});
|
|
|
|
it('reports the cut rather than presenting a window as the whole file', () => {
|
|
expect(fileOf(GIANT).clipped).toBe(true);
|
|
});
|
|
|
|
it('keeps the response inside the hard ceiling', () => {
|
|
expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
|
|
});
|
|
});
|
|
});
|