mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +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.
159 lines
7.5 KiB
TypeScript
159 lines
7.5 KiB
TypeScript
/**
|
|
* Regression gate for the FACTORY-CLOSURE file shape (task CG-27).
|
|
*
|
|
* A `createFoo()` that returns an object of closures spans almost all of its
|
|
* file, so its indexed range is an ENVELOPE around every symbol the query
|
|
* actually wants. Svelte 5 rune stores, React custom-hook modules, IIFE
|
|
* module-pattern JS and Zustand's `create((set, get) => ({ … }))` are all
|
|
* written this way, so it is a shape rather than a one-repo quirk.
|
|
*
|
|
* CG-27 asked whether the >50%-of-file envelope drop — which fires for `class`,
|
|
* `struct`, `interface` and friends but not for `function`/`method` — should be
|
|
* extended to cover it. **Measured, it should not**, and the issue was closed as
|
|
* obsolete: `docs/benchmarks/explore-factory-closure-cg27.md` has the numbers.
|
|
* Two independent mechanisms already absorb the shape:
|
|
*
|
|
* - `shrinkCluster` orders members by (importance desc, SIZE ASC) and refuses
|
|
* any member that overruns the cap once something is kept, so a file-spanning
|
|
* member is only ever selected when it is the sole member of the top
|
|
* importance tier;
|
|
* - when it IS selected, CG-30 windows it on whole lines rather than emitting
|
|
* it whole, so the file still delivers bounded, readable source.
|
|
*
|
|
* Dropping the range instead SPLITS the file into several clusters, and only the
|
|
* first-chosen cluster may be shrunk — measured, a trivial 7-line cluster won the
|
|
* density tiebreak and the answer-bearing cluster was dropped whole, taking the
|
|
* rank-#1 file from 7,539 chars and 7 of 11 inner definitions to 397 and none.
|
|
*
|
|
* So this file pins the OUTCOME, not the mechanism: whatever future work does to
|
|
* clustering, a factory-closure file must keep delivering the closures inside it
|
|
* — that is what stops the agent Reading the file back.
|
|
*/
|
|
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 type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics';
|
|
|
|
const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'factory-closure-ts');
|
|
|
|
/** The factory file, and the closure factory whose body is nearly all of it. */
|
|
const TARGET = 'src/stores/dashboard-store.ts';
|
|
const FACTORY = 'createDashboardStore';
|
|
/** Prose the way a newcomer asks it, naming two of the closures inside. */
|
|
const QUERY = 'how does the dashboard store refresh its metrics and apply a filter';
|
|
|
|
describe('CG-27 — a factory-closure file delivers the closures inside it', () => {
|
|
let testDir: string;
|
|
let cg: CodeGraph;
|
|
let response: string;
|
|
let report: ExploreDiagnosticReport;
|
|
/** Source lines of TARGET the response actually carried. */
|
|
let delivered: Set<number>;
|
|
let sourceLines: string[];
|
|
|
|
beforeAll(async () => {
|
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg27-'));
|
|
fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
|
|
fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
|
|
|
|
cg = CodeGraph.initSync(testDir);
|
|
await cg.indexAll();
|
|
|
|
const sidecar = path.join(testDir, 'explore-diag.jsonl');
|
|
const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
|
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
|
try {
|
|
response = (await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY }))
|
|
.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;
|
|
|
|
// A line counts as delivered only when the response numbers it AND the text
|
|
// matches that source line — a line number quoted in prose must not count.
|
|
// Strip only line endings, including CRLF from a Windows Git checkout.
|
|
sourceLines = fs.readFileSync(path.join(testDir, TARGET), 'utf-8').split(/\r?\n/);
|
|
delivered = new Set();
|
|
for (const line of response.split(/\r?\n/)) {
|
|
const m = /^(\d+)\t(.*)$/.exec(line);
|
|
if (!m) continue;
|
|
const n = Number(m[1]);
|
|
if (n >= 1 && n <= sourceLines.length && sourceLines[n - 1] === m[2]) delivered.add(n);
|
|
}
|
|
}, 120_000);
|
|
|
|
afterAll(() => {
|
|
if (cg) cg.destroy();
|
|
if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
|
|
});
|
|
|
|
/** The closures defined inside the factory, straight from the index. */
|
|
const innerClosures = () => {
|
|
const nodes = cg.getNodesInFile(TARGET);
|
|
const factory = nodes.find((n) => n.name === FACTORY)!;
|
|
return nodes.filter((n) => (n.kind === 'function' || n.kind === 'method')
|
|
&& n.name !== FACTORY
|
|
&& n.startLine > factory.startLine && n.endLine <= factory.endLine);
|
|
};
|
|
|
|
describe('fixture shape — if this rots, the gate below means nothing', () => {
|
|
it('holds one symbol spanning most of the file, with closures inside it', () => {
|
|
const factory = cg.getNodesInFile(TARGET).find((n) => n.name === FACTORY);
|
|
expect(factory, `${TARGET} has no ${FACTORY} node`).toBeDefined();
|
|
// The envelope condition the >50% drop tests for — and `function`, the kind
|
|
// that drop does not cover.
|
|
expect(factory!.kind).toBe('function');
|
|
expect(factory!.endLine - factory!.startLine + 1)
|
|
.toBeGreaterThan(sourceLines.length * 0.5);
|
|
expect(innerClosures().length).toBeGreaterThanOrEqual(8);
|
|
});
|
|
|
|
it('is too long to ship whole, so it renders through the cluster path', () => {
|
|
// Past WHOLE_FILE_MAX_LINES (220 for a non-central file): the whole-file
|
|
// grace and buy arms cannot claim it, so the envelope actually matters.
|
|
expect(sourceLines.length).toBeGreaterThan(220);
|
|
expect(report.files.find((f) => f.path === TARGET)?.render).toBe('clusters');
|
|
});
|
|
});
|
|
|
|
describe('the gate', () => {
|
|
it('delivers the closures the query named, not just the factory head', () => {
|
|
const inner = innerClosures();
|
|
for (const name of ['refreshMetrics', 'applyFilter']) {
|
|
const node = inner.find((n) => n.name === name)!;
|
|
expect(node, `${name} is not an inner closure any more`).toBeDefined();
|
|
expect(delivered.has(node.startLine), `${name} definition line not delivered`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('delivers most of the closures, spread across the file', () => {
|
|
const inner = innerClosures();
|
|
const hit = inner.filter((n) => delivered.has(n.startLine));
|
|
// Measured on the `feature/CG-24` tip: 7 of 11. The bar is half, so ordinary
|
|
// budget movement does not fail the suite, but losing the closures does.
|
|
expect(hit.length).toBeGreaterThanOrEqual(Math.ceil(inner.length / 2));
|
|
// Not one contiguous head window off the top of the factory: the whole
|
|
// point is that selection reaches symbols deep in the body.
|
|
const last = inner[inner.length - 1]!;
|
|
const deepest = Math.max(...hit.map((n) => n.startLine));
|
|
expect(deepest).toBeGreaterThan((last.startLine + inner[0]!.startLine) / 2);
|
|
});
|
|
|
|
it('never renders an empty section for the file', () => {
|
|
const rec = report.files.find((f) => f.path === TARGET)!;
|
|
expect(rec.emittedChars).toBeGreaterThan(0);
|
|
expect(delivered.size).toBeGreaterThan(20);
|
|
});
|
|
|
|
it('keeps the response inside the hard ceiling', () => {
|
|
expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling);
|
|
});
|
|
});
|
|
});
|