A file's ranked clusters were all-or-nothing past the first one: the top-ranked cluster was taken (shrunk to fit when it had to be) and every cluster below it was rendered whole, then either fit the remainder or was dropped entirely. On a file whose top-ranked cluster is TRIVIAL that discards the answer — django's `db/models/sql/query.py` kept a 22-line glue cluster and dropped the 624-line `Query` body, spending 1,923 of a 7,947 reservation; okhttp's `RealInterceptorChain.kt` did the same behind its import header. The response stayed full, which is why this was invisible: the unspent reservation carried forward exactly as designed and a file scoring a fifth as much took the bytes. Two sites, the same rule — hold the remainder while it is still worth a section (CG-26's between-FILES lesson, applied between CLUSTERS): - selection now shrinks a later cluster into what is left of the file's budget, by the same whole-member rule the first cluster already used; - the ceiling trim re-renders the weakest cluster into the room that remains before dropping it. On excalidraw's `typeChecks.ts` the section-cost estimate missed by 13 chars and a 1,512-char cluster — the file's highest-SCORING one — was thrown away to pay for it. Cluster RANKING is untouched: measured, both real cases lost on `maxImportance`, not on the density tiebreak the issue suspected, and density-first is what keeps Alamofire's `Session.swift` from burying its methods under the property list. Suite (6 repos, clean-rebuilt indexes): all 8 starvation flags cleared, +1,012 source chars net. django's `sql/query.py` 1,923 -> 10,082 of 7,947, okhttp's `RealInterceptorChain.kt` 1,474 -> 6,038 of 6,058, gin's `routergroup.go` 3,273 -> 5,632. okhttp trades its rank-6 file (score 21) for +7,196 chars in the two files that answer the question. Ships two fixtures pulling in opposite directions (`starved-cluster-ts` and `dense-header-ts`), a `spendShareAtLeast` gate in probe-allocation, and probe-file-spend.mjs — a standing per-file reservation-vs-delivered sweep.
171 lines
7.5 KiB
TypeScript
171 lines
7.5 KiB
TypeScript
/**
|
|
* Regression gate for CLUSTER-LEVEL STARVATION inside one file (task CG-36).
|
|
*
|
|
* A file's ranked clusters used to be all-or-nothing past the first one: the
|
|
* top-ranked cluster was taken (shrunk to fit if it had to be), and every
|
|
* cluster below it was rendered whole and then either fit the remainder or was
|
|
* dropped entirely. On a file whose top-ranked cluster is TRIVIAL that discards
|
|
* the answer — django's `db/models/sql/query.py` kept a 22-line glue cluster and
|
|
* dropped the 624-line `Query` body beneath it, spending 1,923 of a 7,947
|
|
* reservation, and okhttp's `RealInterceptorChain.kt` did the same behind its
|
|
* import header.
|
|
*
|
|
* What makes it hard to see is that the response stays FULL: the unspent
|
|
* reservation carries forward exactly as designed, so a lower-scoring file takes
|
|
* the bytes and every envelope-share measure still looks healthy. The gate is
|
|
* therefore per-file spend, not share.
|
|
*
|
|
* Two fixtures, pulling in opposite directions — read them together:
|
|
*
|
|
* - `starved-cluster-ts` is the defect. Its answer-bearing cluster must be
|
|
* SHRUNK into whatever the trivial cluster left, not dropped.
|
|
* - `dense-header-ts` is the Session.swift shape that cluster ranking puts
|
|
* importance ahead of density FOR. Its query's methods sit ~200 lines under
|
|
* a dense property list, and they must keep winning the budget. Any future
|
|
* rework of selection or shrinking has to satisfy both.
|
|
*/
|
|
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';
|
|
|
|
interface Run {
|
|
dir: string;
|
|
cg: CodeGraph;
|
|
response: string;
|
|
report: ExploreDiagnosticReport;
|
|
}
|
|
|
|
/** Copy a fixture tree to a temp dir, index it, and run one explore call. */
|
|
async function runFixture(fixture: string, query: string): Promise<Run> {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg36-'));
|
|
fs.cpSync(path.join(__dirname, 'fixtures', fixture), dir, { recursive: true });
|
|
fs.rmSync(path.join(dir, '.codegraph'), { recursive: true, force: true });
|
|
|
|
const cg = CodeGraph.initSync(dir);
|
|
await cg.indexAll();
|
|
|
|
const sidecar = path.join(dir, 'explore-diag.jsonl');
|
|
const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
|
|
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
|
let response: string;
|
|
try {
|
|
response = (await new ToolHandler(cg).execute('codegraph_explore', { 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);
|
|
return { dir, cg, response, report: JSON.parse(written[written.length - 1]!) };
|
|
}
|
|
|
|
function teardown(run: Run | undefined): void {
|
|
if (!run) return;
|
|
run.cg.destroy();
|
|
if (fs.existsSync(run.dir)) fs.rmSync(run.dir, { recursive: true, force: true });
|
|
}
|
|
|
|
describe('CG-36 — a trivial cluster must not starve the answer-bearing one', () => {
|
|
const TARGET = 'src/pipeline/chain.ts';
|
|
const QUERY = 'how does a request travel from sendRequest to the socket';
|
|
let run: Run;
|
|
let target: ExploreDiagnosticReport['files'][number];
|
|
|
|
beforeAll(async () => {
|
|
run = await runFixture('starved-cluster-ts', QUERY);
|
|
target = run.report.files.find((f) => f.path === TARGET)!;
|
|
}, 120_000);
|
|
|
|
afterAll(() => teardown(run));
|
|
|
|
describe('fixture shape — if this rots, the gate below means nothing', () => {
|
|
it('renders through the cluster path, with the answer past the trivial helper', () => {
|
|
expect(target, `${TARGET} is not among the ranked candidates`).toBeDefined();
|
|
expect(target.render).toBe('clusters');
|
|
// The helper the entry point calls directly, and the class it does not.
|
|
const nodes = run.cg.getNodesInFile(TARGET);
|
|
const helper = nodes.find((n) => n.name === 'describeChain')!;
|
|
const proceed = nodes.find((n) => n.name === 'proceed')!;
|
|
expect(helper).toBeDefined();
|
|
expect(proceed).toBeDefined();
|
|
// Far enough apart to cluster separately at any gap threshold we ship.
|
|
expect(proceed.startLine - helper.endLine).toBeGreaterThan(20);
|
|
});
|
|
|
|
it('reserves the file the largest share, so an unspent share is a defect', () => {
|
|
expect(target.allowance ?? 0).toBeGreaterThan(4000);
|
|
const others = run.report.files.filter((f) => f.path !== TARGET);
|
|
for (const f of others) expect(f.allowance ?? 0).toBeLessThan(target.allowance!);
|
|
});
|
|
});
|
|
|
|
describe('the gate', () => {
|
|
it('spends most of the reservation it was given', () => {
|
|
// 28.8% on the CG-24 epic tip, 131% (its reservation plus carry-forward
|
|
// slack it can now actually use) with the fix. The bar is deliberately
|
|
// well below both so ordinary budget movement does not fail the suite.
|
|
expect(target.finalChars / target.allowance!).toBeGreaterThan(0.6);
|
|
});
|
|
|
|
it('delivers the flow the query asked about, not just the helper beside it', () => {
|
|
// Both ends of the in-file flow, in the cluster that used to be dropped.
|
|
expect(run.response).toContain('async proceed(request: PipelineRequest)');
|
|
expect(run.response).toContain('private async writeAndRead(request: PipelineRequest)');
|
|
});
|
|
|
|
it('keeps the response inside the hard ceiling', () => {
|
|
expect(run.report.envelope.chars).toBeLessThanOrEqual(run.report.budget.hardCeiling);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('CG-36 — a dense declaration block must not bury the query\'s methods', () => {
|
|
const TARGET = 'src/net/session.ts';
|
|
const QUERY = 'how does perform create a URLRequest and start the task';
|
|
let run: Run;
|
|
let target: ExploreDiagnosticReport['files'][number];
|
|
|
|
beforeAll(async () => {
|
|
run = await runFixture('dense-header-ts', QUERY);
|
|
target = run.report.files.find((f) => f.path === TARGET)!;
|
|
}, 120_000);
|
|
|
|
afterAll(() => teardown(run));
|
|
|
|
describe('fixture shape — if this rots, the gate below means nothing', () => {
|
|
it('has a dense low-importance header and the named methods far below it', () => {
|
|
expect(target, `${TARGET} is not among the ranked candidates`).toBeDefined();
|
|
expect(target.render).toBe('clusters');
|
|
const nodes = run.cg.getNodesInFile(TARGET);
|
|
const perform = nodes.find((n) => n.name === 'perform')!;
|
|
expect(perform).toBeDefined();
|
|
// The header block: many adjacent declarations above the first named
|
|
// method, which is what makes it the densest region of the file.
|
|
const above = nodes.filter((n) => n.endLine < perform.startLine
|
|
&& (n.kind === 'property' || n.kind === 'field' || n.kind === 'method'));
|
|
expect(above.length).toBeGreaterThan(20);
|
|
expect(perform.startLine).toBeGreaterThan(150);
|
|
});
|
|
});
|
|
|
|
describe('the gate', () => {
|
|
it('delivers all three methods the query named', () => {
|
|
expect(run.response).toContain('async perform(url: string, method: string');
|
|
expect(run.response).toContain('didCreateURLRequest(request: URLRequest)');
|
|
expect(run.response).toContain('task(request: URLRequest, identifier: number)');
|
|
});
|
|
|
|
it('spends the file\'s reservation on them', () => {
|
|
expect(target.finalChars / target.allowance!).toBeGreaterThan(0.6);
|
|
});
|
|
|
|
it('keeps the response inside the hard ceiling', () => {
|
|
expect(run.report.envelope.chars).toBeLessThanOrEqual(run.report.budget.hardCeiling);
|
|
});
|
|
});
|
|
});
|