mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 11:24:49 +08:00
fix(explore): a named function too long for its share renders instead of the file's tail (#2783)
Root cause: an oversize cluster keeps its first-ranked member however big it is, and the ceiling windows it (CG-30). Ties on importance went to the shorter span, so in the issue's file, one 200-line function plus the line that calls it, the one-line `path = build_report()` took that guarantee. The search matches it through its signature, so it is an entry point too. The function the query named then no longer fit beside it and was dropped, and `codegraph_explore build_report` returned the file's last six lines under the verbatim-source header. A file asked for by path did the same, because every definition in it ranks below the bare edge lines. Where a body was windowed, nothing in the block said so. The footer's advice, ask for the symbol by name, returned the same window, and it suggested `codegraph_node`, which the default MCP surface does not list. Fix, all in the explore render path (query time only): - Rule 1: among cluster members of one importance, a definition the query named ranks ahead of one its search only matched, before the shorter span. - Rule 2: a fragment does not spend the oversize guarantee. When the kept members render under MIN_CHARS, the member they priced out is kept and windowed like a first-ranked one. That member must be one the query named, or a definition in a file pinned by path with nothing named in the cluster. - Rule 3: a body the ceiling window cuts ends with the per-symbol view's note, "... lines X-Y of `name` elided - codegraph_explore `file:X-Y` returns them" (one shared helper now). Each note is paid for by its own part's last lines, never a focus line, the member's first line or a member the part shows whole. So no section grows and no other file's budget moves. - Rule 4: the trim footer and the dynamic-boundary note name `codegraph_node` only when it is on the served tool surface. Validation: - New explore-queried-oversize-symbol.test.ts, 9 tests: 6 fail on main. Reverting any one rule fails that rule's own tests (2, 2, 3 and 1). - All 37 explore suites pass (414 tests). - Deterministic A/B: one index per repo, a fresh handler per query, five arms (main, then each rule added in turn). 820 queries per arm: the 50 longest functions by name, their files by path, caller/callee pairs, call chains, random names and flow questions. - The queried function's head (def plus 10 lines) renders, main -> fix: express 50 -> 50, flask 49 -> 50, excalidraw 33 -> 48, django 43 -> 47, airflow 43 -> 48, of 50 each. - Queried body lines rendered, main -> fix: excalidraw 6,615 -> 9,391, django 5,337 -> 5,662, airflow 6,320 -> 6,749. - Rule 1 changed 32 responses, each hand-triaged as the queried function now rendering. Only 4 of the 504 ordinary flow queries are among them. - Rule 2 changed none. - Rule 3 wrote 838 notes, all checked against source and index, at 2.8 to 3.9 source lines each. Bytes a note frees flow to lower files by the existing carry-forward. - Rule 4's 236 diffs are exactly the removed mention. - No response exceeds the 25,000-char inline cap. Fixes #2486 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0c3e12ddbb
commit
1890b10cc4
@@ -218,6 +218,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- `codegraph_explore`'s "Dynamic-dispatch links among your symbols" list now shows every link on its own line. Before, the links from an interface method to its implementations all read alike, like `TrySet → TrySet`, so only one was listed, chosen by the order of the index rather than by your query: asking about gin's `tryToSetValue setter.TrySet multipartRequest.TrySet setByForm` showed `headerSource`'s implementation and hid `formSource`'s. Both ends of such a link are now named with their type, like `setter.TrySet → formSource.TrySet`. When there are more links than the list shows, the implementations your query names come first and those in tests or examples last, and a hop the Flow already shows is not repeated.
|
||||
- `codegraph_explore`, `codegraph_node`, `codegraph_callers`, `codegraph_callees`, `codegraph_impact` and the `codegraph callers`, `callees` and `impact` commands now find a symbol written with its package, the way Go code names it: `refresh.Discovery.refresh`, `storage.Queryable.Querier`, or `gin.Context.Next` in gin's root package. Before, such a name found nothing, so a question written that way lost the symbol and explore's Flow had nothing to connect. A name the search ranked below other results, like gin's `binding.Default`, is found as well, and a Python or Rust module followed by a class and method works the same way.
|
||||
- When `codegraph_explore` finds more dynamic-dispatch links than its list shows, the symbols you named now take turns, so each one's links make the list: asking about prometheus's `refresh.Discovery.refresh hetzner.refresher.refresh hcloudDiscovery.refresh` now lists hetzner's `robotDiscovery` beside the links into other discovery packages that used to fill the list.
|
||||
- `codegraph_explore` now shows a function you name even when it is too long for its share of the answer: its definition and the start of its body, with a note where the body is cut that says which lines were left out and which `codegraph_explore` query returns them. Before, a file made of one long function and a line that calls it could come back as just its last few lines, without the function, and asking for the file by path did the same. A trimmed answer also no longer suggests `codegraph_node`, which isn't offered by default. Thanks @Huy3ko for the report and @danusha2345 for the first fix. (#2486)
|
||||
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
|
||||
- In Objective-C, a struct, union or enum defined in a variable declaration, like `static struct { Class isa; } placeholder;` or `enum { stateType, stateParamName } state;`, is now indexed with its enum values, next to the variable, as in C and C++. Before, only the variable was, so the type was missing from search and `codegraph_explore`. The same went for a `typedef enum { … } Name;` or `typedef struct … Name;` written after a line holding only a macro, like `OF_ASSUME_NONNULL_BEGIN` or `RCT_EXTERN_C_BEGIN`: ObjFW's `OFZIPArchiveEntryCompressionMethod` and every value in it were missing, and so was React Native's `RCTMethodInfo`. A struct, union or enum with no name of its own takes the name of the variable it declares, so ObjFW's class-cluster placeholders are found as `placeholder`, one declared inside a function or method is named after its variable instead of `<anonymous>`, and the comment above the declaration becomes the type's description. As in C, a type that a `.m` file defines links only from that file, so a call elsewhere that just shares its name, like ObjFW's calls of AmigaOS's `Lock(path, mode)`, doesn't link to the runtime's private `struct Lock`. Re-index Objective-C projects after upgrading.
|
||||
- In Objective-C, a macro written alone on its line, like `NS_ASSUME_NONNULL_BEGIN`, `OF_SUBCLASSING_RESTRICTED` or React Native's `RCT_EXTERN_C_BEGIN`, no longer hides the declaration below it. Before, the parser read such a macro as the start of a declaration, so an `@interface` under it could come out as a function and a `typedef` under it as two variables: many of ObjFW's classes, with their properties and methods and the classes they extend and protocols they adopt, were missing, and so were classes and protocols in React Native and KSCrash headers. A message to `super` from a class whose `@interface` follows an attribute macro like `NS_SWIFT_NAME(…)` still reaches the superclass. Re-index Objective-C projects after upgrading.
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* A symbol the query asks for renders even when it is too long for its file's
|
||||
* share of the answer, and the block says where it was cut (#2486).
|
||||
*
|
||||
* `shrinkCluster` keeps the first-ranked member of an oversize cluster however
|
||||
* big it is and lets the ceiling window it (CG-30). Among members of one
|
||||
* importance, the shorter span ranked first. So in a file that is one long
|
||||
* function plus the line that calls it,
|
||||
*
|
||||
* def build_report(...): # 200 statements, 15 KB
|
||||
* ...
|
||||
* if __name__ == '__main__':
|
||||
* path = build_report() # a search hit too: its signature names it
|
||||
*
|
||||
* the one-line `path` took that guarantee, the function then did not fit beside
|
||||
* it, and `codegraph_explore build_report` returned the file's last six lines,
|
||||
* under a header calling the block the verbatim source. Asking for the file by
|
||||
* path did the same. A body the window does cut now carries the line range and
|
||||
* the explore query that returns the rest, since asking for the symbol by name
|
||||
* again returns the same window.
|
||||
*/
|
||||
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';
|
||||
|
||||
const STATEMENTS = 200;
|
||||
const DEF = "def build_report(output_path: str = 'reports/report.pdf'):";
|
||||
|
||||
/** The issue's own reproducer, line for line. */
|
||||
function reportSource(): string {
|
||||
return [
|
||||
'"""Generate the phase-1 validation report as PDF."""',
|
||||
'from reportlab.platypus import SimpleDocTemplate',
|
||||
'',
|
||||
'',
|
||||
DEF,
|
||||
' """Build the phase-1 validation report as a PDF."""',
|
||||
' doc = SimpleDocTemplate(output_path)',
|
||||
' story = []',
|
||||
...Array.from({ length: STATEMENTS }, (_, i) =>
|
||||
` story.append(Paragraph(f'section ${i} of the report', styles['BodyText']))`),
|
||||
' doc.build(story)',
|
||||
' return output_path',
|
||||
'',
|
||||
'',
|
||||
"if __name__ == '__main__':",
|
||||
' path = build_report()',
|
||||
' print(path)',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* The long function, then a 27-line function the search matches only through
|
||||
* its docstring. Not a fragment, so nothing but rank decides which of the two
|
||||
* the cluster keeps.
|
||||
*/
|
||||
function publishSource(): string {
|
||||
return [
|
||||
'"""Report generation."""',
|
||||
'',
|
||||
'',
|
||||
DEF,
|
||||
' """Build the phase-1 validation report as a PDF."""',
|
||||
' story = []',
|
||||
...Array.from({ length: 260 }, (_, i) =>
|
||||
` story.append(Paragraph(f'section ${i} of the report body', styles['BodyText']))`),
|
||||
' return output_path',
|
||||
'',
|
||||
'',
|
||||
'def publish():',
|
||||
' """Run build_report and copy the result where readers find it."""',
|
||||
' target = build_report()',
|
||||
...Array.from({ length: 24 }, (_, i) => ` print('publishing step ${i} of the release checklist', target)`),
|
||||
' return target',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** A constant the query can name, right above a long function that has nothing to do with it. */
|
||||
function limitsSource(): string {
|
||||
return [
|
||||
'"""Limits for the report."""',
|
||||
'',
|
||||
'MAX_SECTIONS = 5',
|
||||
'',
|
||||
'def render_everything(doc):',
|
||||
...Array.from({ length: STATEMENTS }, (_, i) =>
|
||||
` doc.write(f'unrelated statement number ${i} of the long neighbour function')`),
|
||||
' return doc',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
type Project = { dir: string; cg: CodeGraph };
|
||||
|
||||
async function project(files: Record<string, string>): Promise<Project> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2486-'));
|
||||
for (const [rel, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, rel), text);
|
||||
const cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
return { dir, cg };
|
||||
}
|
||||
|
||||
function destroy(p: Project | undefined): void {
|
||||
if (!p) return;
|
||||
p.cg.destroy();
|
||||
fs.rmSync(p.dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** A handler per call: a session that already holds a file's lines elides them. */
|
||||
async function explore(p: Project, query: string): Promise<string> {
|
||||
const result = await new ToolHandler(p.cg).execute('codegraph_explore', { query });
|
||||
return result.content?.[0]?.text ?? '';
|
||||
}
|
||||
|
||||
/** The `<n>\t<line>` form explore renders a source line in. */
|
||||
const rendered = (n: number, line: string): string => `${n}\t${line}`;
|
||||
|
||||
describe('#2486 — the queried symbol renders when it alone overruns its file\'s budget', () => {
|
||||
let report: Project | undefined;
|
||||
let byName = '';
|
||||
// The default tool surface: a shell that exports an allowlist must not change what this asserts.
|
||||
const allowlist = process.env.CODEGRAPH_MCP_TOOLS;
|
||||
|
||||
beforeAll(async () => {
|
||||
delete process.env.CODEGRAPH_MCP_TOOLS;
|
||||
report = await project({ 'report_gen.py': reportSource() });
|
||||
byName = await explore(report, 'build_report');
|
||||
}, 120_000);
|
||||
afterAll(() => {
|
||||
destroy(report);
|
||||
if (allowlist === undefined) delete process.env.CODEGRAPH_MCP_TOOLS;
|
||||
else process.env.CODEGRAPH_MCP_TOOLS = allowlist;
|
||||
});
|
||||
|
||||
it('fixture shape: one function far bigger than the whole response budget, beside a one-line search hit', () => {
|
||||
const nodes = report!.cg.getNodesInFile('report_gen.py');
|
||||
const fn = nodes.find((n) => n.name === 'build_report' && n.kind === 'function')!;
|
||||
expect(fn.startLine).toBe(5);
|
||||
expect(fn.endLine).toBe(STATEMENTS + 10);
|
||||
expect(reportSource().length).toBeGreaterThan(13_000);
|
||||
// The search matches the variable through its signature, `= build_report()`.
|
||||
const hit = nodes.find((n) => n.name === 'path')!;
|
||||
expect(hit.startLine).toBe(hit.endLine);
|
||||
expect(hit.startLine - fn.endLine).toBeLessThan(7);
|
||||
});
|
||||
|
||||
it('renders the definition and the head of its body, not the file tail', () => {
|
||||
expect(byName).toContain(rendered(5, DEF));
|
||||
expect(byName).toContain("section 0 of the report'");
|
||||
expect(byName.length).toBeLessThanOrEqual(19_500);
|
||||
});
|
||||
|
||||
it('says inside the block where the body was cut, and that query returns the rest', async () => {
|
||||
const note = byName.match(/… lines (\d+)-(\d+) of `build_report` elided — codegraph_explore `(report_gen\.py:\d+-\d+)` returns them/);
|
||||
expect(note, 'no cut note in:\n' + byName.slice(-1500)).not.toBeNull();
|
||||
const [, from, to, followUp] = note!;
|
||||
expect(Number(to)).toBe(STATEMENTS + 10);
|
||||
// The note sits right after the last line shown.
|
||||
expect(byName).toContain(`${Number(from) - 1}\t`);
|
||||
expect(byName).not.toContain(`\n${from}\t`);
|
||||
const rest = await explore(report!, followUp!);
|
||||
expect(rest).toContain(rendered(Number(from), ` story.append(Paragraph(f'section ${Number(from) - 9} of the report', styles['BodyText']))`));
|
||||
expect(rest).toContain(rendered(STATEMENTS + 10, ' return output_path'));
|
||||
});
|
||||
|
||||
it('names no follow-up tool the default surface does not list', () => {
|
||||
expect(byName).toContain('Some file sections were trimmed for size');
|
||||
expect(byName).not.toContain('codegraph_node');
|
||||
});
|
||||
|
||||
it('a file asked for by path renders from its first definition, not its tail', async () => {
|
||||
const byPath = await explore(report!, 'report_gen.py');
|
||||
expect(byPath).toContain(rendered(5, DEF));
|
||||
expect(byPath).toContain("section 0 of the report'");
|
||||
expect(byPath).toContain('of `build_report` elided — codegraph_explore `report_gen.py:');
|
||||
expect(byPath.length).toBeLessThanOrEqual(19_500);
|
||||
});
|
||||
|
||||
it('a query naming the one-liner too still renders the function', async () => {
|
||||
const both = await explore(report!, 'build_report path');
|
||||
expect(both).toContain(rendered(5, DEF));
|
||||
expect(both).toContain("section 0 of the report'");
|
||||
});
|
||||
|
||||
it('still names codegraph_node when the tool is served', async () => {
|
||||
process.env.CODEGRAPH_MCP_TOOLS = 'explore,node';
|
||||
try {
|
||||
expect(await explore(report!, 'build_report')).toContain('(or `codegraph_node`)');
|
||||
} finally {
|
||||
delete process.env.CODEGRAPH_MCP_TOOLS;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2486 — a name the query spelled outranks a search hit of the same importance', () => {
|
||||
let publish: Project | undefined;
|
||||
afterAll(() => destroy(publish));
|
||||
|
||||
it('renders the named function, not only the docstring match beside it', async () => {
|
||||
publish = await project({ 'report_gen.py': publishSource() });
|
||||
const text = await explore(publish, 'build_report');
|
||||
expect(text).toContain(rendered(4, DEF));
|
||||
expect(text).toContain("section 0 of the report body'");
|
||||
expect(text).toContain('of `build_report` elided — codegraph_explore `report_gen.py:');
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
describe('#2486 — what the query did not ask for is left as it was', () => {
|
||||
let limits: Project | undefined;
|
||||
afterAll(() => destroy(limits));
|
||||
|
||||
it('a one-line constant the query names renders without the long function beside it', async () => {
|
||||
limits = await project({ 'limits.py': limitsSource() });
|
||||
const text = await explore(limits, 'MAX_SECTIONS');
|
||||
expect(text).toContain(rendered(3, 'MAX_SECTIONS = 5'));
|
||||
expect(text).not.toContain('unrelated statement number 100');
|
||||
}, 120_000);
|
||||
});
|
||||
+153
-11
@@ -1050,6 +1050,16 @@ export function formatGapMarker(
|
||||
return `\n\n... (gap: ${names}) ...\n\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The note a body cut short carries where it is cut: which of its lines were
|
||||
* left out, and the explore query that returns them. A line range is a query
|
||||
* explore renders as exactly that span, so the rest is one call away; asking
|
||||
* for the symbol by name again would return the same window (#2486).
|
||||
*/
|
||||
export function elidedBodyNote(filePath: string, name: string, from: number, to: number): string {
|
||||
return `\n… lines ${from}-${to} of \`${name}\` elided — codegraph_explore \`${filePath}:${from}-${to}\` returns them`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join rendered parts with gap markers that name whatever the trim skipped.
|
||||
*
|
||||
@@ -2149,6 +2159,19 @@ export class ToolHandler {
|
||||
return !allow || allow.has(name.replace(/^codegraph_/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a tool is on the surface `tools/list` shows: the allowlist when one
|
||||
* is set, the default surface otherwise. Not the same question as
|
||||
* `isToolAllowed` (with no allowlist, every defined tool may still be
|
||||
* called). Explore names a follow-up tool only when the agent can see it:
|
||||
* `codegraph_node` is not on the default surface (#2486).
|
||||
*/
|
||||
private isToolServed(name: string): boolean {
|
||||
const short = name.replace(/^codegraph_/, '');
|
||||
const allow = this.toolAllowlist();
|
||||
return allow ? allow.has(short) : DEFAULT_MCP_TOOLS.has(short);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool definitions with dynamic descriptions based on project size.
|
||||
* The codegraph_explore tool description includes a budget recommendation
|
||||
@@ -3674,7 +3697,7 @@ export class ToolHandler {
|
||||
'',
|
||||
...notes,
|
||||
'',
|
||||
'> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run codegraph_explore or codegraph_node on a candidate; source for the sites above is included below.',
|
||||
`> These sites choose their call target at runtime (registry / bus / reflection) — the site shown IS where the flow continues. To follow it, run codegraph_explore${this.isToolServed('node') ? ' or codegraph_node' : ''} on a candidate; source for the sites above is included below.`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -5819,7 +5842,17 @@ export class ToolHandler {
|
||||
// renders. Parts are SPAN-KEYED: every part carries the exact line range
|
||||
// its text was sliced from (see `buildSection`).
|
||||
const SPINE_WINDOW = 28; // lines each side of a focus line (the next-hop call site)
|
||||
type SectionPart = { range: ExploreLineRange; text: string };
|
||||
type SectionPart = {
|
||||
range: ExploreLineRange;
|
||||
text: string;
|
||||
/**
|
||||
* Set on a part a ceiling window cut inside a member's body: the member's
|
||||
* name and lines. Its `text` then ends with the note `noteCutBodies`
|
||||
* writes, and `renoteCutBodies` rewrites that note when the part after it
|
||||
* is dropped.
|
||||
*/
|
||||
cut?: { host: string; hostStart: number; hostEnd: number };
|
||||
};
|
||||
/**
|
||||
* Bounded overshoot for one cluster's render (CG-30).
|
||||
*
|
||||
@@ -6162,9 +6195,7 @@ export class ToolHandler {
|
||||
const windows = bodyWindows.get(n.id)!;
|
||||
windows.forEach((w, k) => {
|
||||
const holeEnd = (windows[k + 1]?.start ?? n.endLine + 1) - 1;
|
||||
const hole = holeEnd > w.end
|
||||
? `\n… lines ${w.end + 1}-${holeEnd} of \`${n.name}\` elided — codegraph_explore \`${filePath}:${w.end + 1}-${holeEnd}\` returns them`
|
||||
: '';
|
||||
const hole = holeEnd > w.end ? elidedBodyNote(filePath, n.name, w.end + 1, holeEnd) : '';
|
||||
skel.push({ range: w, text: renderSpan(w) + hole });
|
||||
});
|
||||
coveredUntil = n.endLine;
|
||||
@@ -6453,7 +6484,13 @@ export class ToolHandler {
|
||||
// qualified `SQLCompiler.as_sql` is no longer outranked by a denser cluster
|
||||
// around `SQLInsertCompiler.as_sql` that merely matched the bare name.
|
||||
const EXACT_IMPORTANCE = 11;
|
||||
const ranges: Array<{ start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: number; qualifiedName?: string }> = [...rangeNodes.values()]
|
||||
const ranges: Array<{
|
||||
start: number; end: number; name: string; kind: string; importance: number; spine: boolean; spineCallLine?: number; qualifiedName?: string;
|
||||
/** A definition the query spelled by name, not one its search only matched. */
|
||||
named?: boolean;
|
||||
/** A symbol's own range, as opposed to an edge line or an anchored span. */
|
||||
definition?: boolean;
|
||||
}> = [...rangeNodes.values()]
|
||||
// Drop whole-file envelope nodes (containers covering >50% of the file).
|
||||
.filter(n => !(ENVELOPE_KINDS.has(n.kind) && (n.endLine - n.startLine + 1) > fileLines.length * 0.5))
|
||||
.map(n => {
|
||||
@@ -6468,7 +6505,11 @@ export class ToolHandler {
|
||||
// processRunExecutionData, the named flow ENTRY at L1562, is a large
|
||||
// low-density method that lost the budget to denser blocks and got cut, so
|
||||
// the agent Read it back — the very thing explore exists to prevent).
|
||||
return { start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance, spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id), qualifiedName: n.qualifiedName };
|
||||
return {
|
||||
start: n.startLine, end: n.endLine, name: n.name, kind: n.kind, importance,
|
||||
spine: flow.pathNodeIds.has(n.id), spineCallLine: flow.spineCallSites.get(n.id), qualifiedName: n.qualifiedName,
|
||||
named: namedSeedIds.has(n.id) || flow.namedNodeIds.has(n.id), definition: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Add edge source locations in this file — captures template references
|
||||
@@ -6764,11 +6805,22 @@ export class ToolHandler {
|
||||
// ahead of spine because the exact branch below keeps members in this
|
||||
// order until the cap: a long spine method first would price out the
|
||||
// body the agent singled out.
|
||||
//
|
||||
// Among members of one importance, a definition the query spelled by
|
||||
// name goes ahead of one its search only matched, and only then does the
|
||||
// shorter span win. The first member is the one kept however big it is,
|
||||
// so this decides which symbol that guarantee covers. Shorter-first gave
|
||||
// it to `path = build_report()` — a search hit on its signature, one
|
||||
// line — and `build_report`, the 200-line function the query named,
|
||||
// then no longer fit and the section was the file's last six lines
|
||||
// (#2486).
|
||||
const byImportance = [...c.members].sort((a, b) =>
|
||||
(guarded ? Number(isProtectedMember(b)) - Number(isProtectedMember(a)) : 0)
|
||||
|| Number(b.importance >= EXACT_IMPORTANCE) - Number(a.importance >= EXACT_IMPORTANCE)
|
||||
|| (b.spine ? 1 : 0) - (a.spine ? 1 : 0)
|
||||
|| b.importance - a.importance || (a.end - a.start) - (b.end - b.start) || a.start - b.start);
|
||||
|| b.importance - a.importance
|
||||
|| Number(!!b.named) - Number(!!a.named)
|
||||
|| (a.end - a.start) - (b.end - b.start) || a.start - b.start);
|
||||
const keep: ExploreRange[] = [];
|
||||
if (c.maxImportance >= EXACT_IMPORTANCE) {
|
||||
// An EXACT target prices EVERY member in rendered chars, protected
|
||||
@@ -6798,13 +6850,23 @@ export class ToolHandler {
|
||||
// exact cost of the protected set; each incidental member then adds its
|
||||
// own padded span, an over-estimate that errs toward the cluster below.
|
||||
let rendered: number | undefined;
|
||||
// The best-ranked member dropped for size that the query asked for:
|
||||
// one it named, or — in a file it pinned by path and named nothing in
|
||||
// this cluster of — a definition. See below the loop. Not the second
|
||||
// kind while a cluster below is owed room (`guarded`): nothing here
|
||||
// outranks what that cluster's named members are waiting for.
|
||||
const askedForInFile = !guarded && pinnedSet.has(filePath) && !c.members.some(isProtectedMember);
|
||||
let droppedAskedFor: ExploreRange | undefined;
|
||||
for (const r of byImportance) {
|
||||
const sz = sizeOf(r) + GAP_MARKER.length;
|
||||
// Always keep the most important range, even if it alone is oversize —
|
||||
// an empty section sends the agent to Read, which costs far more. How
|
||||
// far it may overshoot is bounded by the caller's ceiling (CG-30), which
|
||||
// windows a runaway member instead of dropping it.
|
||||
if (keep.length > 0 && kept + sz > cap) continue;
|
||||
if (keep.length > 0 && kept + sz > cap) {
|
||||
if (!droppedAskedFor && (r.named || (askedForInFile && r.definition))) droppedAskedFor = r;
|
||||
continue;
|
||||
}
|
||||
if (guarded && keep.length > 0 && !isProtectedMember(r)) {
|
||||
rendered ??= sectionText(dedupeSpans(partsOfMembers(keep)).parts).length;
|
||||
const cost = paddedCost(r) + GAP_MARKER.length;
|
||||
@@ -6814,6 +6876,18 @@ export class ToolHandler {
|
||||
keep.push(r);
|
||||
kept += sz;
|
||||
}
|
||||
// A fragment does not spend the guarantee above. When what was kept
|
||||
// renders to less than a readable slice, the member it priced out is
|
||||
// kept the way a first-ranked one is, and the caller's ceiling windows
|
||||
// it. Two shapes reach this: the query names a one-liner beside the
|
||||
// long function it also names (`build_report path`), and a file asked
|
||||
// for by path, where every definition ranks below the bare edge lines
|
||||
// (importance 1 against 2) — `report_gen.py` kept `path = build_report()`
|
||||
// and its call edge and dropped the function around them (#2486).
|
||||
if (droppedAskedFor
|
||||
&& sectionText(dedupeSpans(partsOfMembers(keep)).parts).length < EXPLORE_ALLOCATION.MIN_CHARS) {
|
||||
keep.push(droppedAskedFor);
|
||||
}
|
||||
}
|
||||
if (keep.length === c.members.length) return null;
|
||||
return partsOfMembers(keep);
|
||||
@@ -6844,6 +6918,66 @@ export class ToolHandler {
|
||||
return [...exactFocus, ...(c.spineCallLine ? [c.spineCallLine] : []), ...named];
|
||||
};
|
||||
|
||||
/**
|
||||
* The note a part's `cut` reads as, for the part's lines ending at `end`:
|
||||
* the member's lines from there to the next part shown, or to its end.
|
||||
*/
|
||||
const cutNoteOf = (p: SectionPart, next: SectionPart | undefined, end: number): string => {
|
||||
const cut = p.cut!;
|
||||
const holeEnd = Math.min(cut.hostEnd, next ? next.range.start - 1 : Infinity);
|
||||
return holeEnd > end ? elidedBodyNote(filePath, cut.host, end + 1, holeEnd) : '';
|
||||
};
|
||||
/**
|
||||
* Each cut part's note written again from its `cut`, for after a part
|
||||
* behind it was dropped: the hole it names now runs on to the next part
|
||||
* that is left.
|
||||
*/
|
||||
const renoteCutBodies = (parts: ReadonlyArray<SectionPart>): SectionPart[] =>
|
||||
parts.map((p, i) => (p.cut ? { ...p, text: renderSpan(p.range) + cutNoteOf(p, parts[i + 1], p.range.end) } : p));
|
||||
/**
|
||||
* `windowed` (a ceiling window of `original`) with a note wherever the
|
||||
* window cut a part short inside a member's body. The innermost member
|
||||
* wins, so a method cut inside a class names the method. A part that ends
|
||||
* where its source part ends was not cut, so a gap the member shrink left
|
||||
* between whole members is never noted: its gap marker names it already.
|
||||
*
|
||||
* Each note is paid by the part it ends, which gives up lines from its end
|
||||
* until it is no longer than it was — never its member's first line, a
|
||||
* focus line, a line of a window's minimum, or a line of a member the part
|
||||
* shows whole (a class's note must not cut a method inside it that was
|
||||
* complete). So a note costs a line or two of the body it is cut from and
|
||||
* nothing anywhere else: the window's spare room is what the files below
|
||||
* draw on, and on a saturated response it is spoken for. A part that
|
||||
* cannot pay goes out as it was, without a note.
|
||||
*/
|
||||
const noteCutBodies = (
|
||||
windowed: ReadonlyArray<SectionPart>, original: ReadonlyArray<SectionPart>,
|
||||
c: ExploreCluster, focus: ReadonlyArray<number>,
|
||||
): SectionPart[] => {
|
||||
const marked = windowed.map((w): SectionPart => {
|
||||
const source = original.find((p) => p.range.start <= w.range.start && w.range.end <= p.range.end);
|
||||
if (!source || w.range.end >= source.range.end) return w;
|
||||
const host = c.members
|
||||
.filter((m) => m.definition && m.start <= w.range.end && m.end > w.range.end)
|
||||
.sort((a, b) => (a.end - a.start) - (b.end - b.start))[0];
|
||||
return host ? { ...w, cut: { host: host.name, hostStart: host.start, hostEnd: host.end } } : w;
|
||||
});
|
||||
return marked.map((p, i) => {
|
||||
if (!p.cut) return p;
|
||||
let lowest = Math.max(p.range.start + MIN_WINDOW_LINES - 1, p.cut.hostStart);
|
||||
for (const l of focus) if (l >= p.range.start && l <= p.range.end) lowest = Math.max(lowest, l);
|
||||
for (const m of c.members) {
|
||||
if (m.definition && m.start >= p.range.start && m.end <= p.range.end) lowest = Math.max(lowest, m.end);
|
||||
}
|
||||
for (let end = p.range.end; end >= lowest; end--) {
|
||||
const range = { start: p.range.start, end };
|
||||
const text = renderSpan(range) + cutNoteOf(p, marked[i + 1], end);
|
||||
if (text.length <= p.text.length) return { range, text, cut: p.cut };
|
||||
}
|
||||
return { range: p.range, text: p.text };
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* One cluster's final parts: built, shrunk if it overruns `cap`, then
|
||||
* passed through the session history (CG-18).
|
||||
@@ -6892,7 +7026,12 @@ export class ToolHandler {
|
||||
if (!Number.isFinite(ceiling) || sectionText(r.parts).length <= ceiling) return r;
|
||||
// Windows are subsets of spans dedupeSpans already cleared, so the record
|
||||
// still only ever claims source that was actually sent.
|
||||
const parts = windowToCeiling(r.parts, ceiling, focusLinesOf(c));
|
||||
// A body the window cuts says so where it is cut, with the query for
|
||||
// the rest (#2486): the agent otherwise learns of the cut only from a
|
||||
// footer whose advice — ask for the symbol by name — returns this same
|
||||
// window.
|
||||
const focus = focusLinesOf(c);
|
||||
const parts = noteCutBodies(windowToCeiling(r.parts, ceiling, focus), r.parts, c, focus);
|
||||
return { parts, covered: r.covered, shrunk: true };
|
||||
};
|
||||
if (sectionText(base.parts).length <= Math.min(cap, guard ?? Infinity)) {
|
||||
@@ -7252,6 +7391,9 @@ export class ToolHandler {
|
||||
if (!holdsFocus(reshrunk.parts[k]!)) { drop = k; break; }
|
||||
}
|
||||
reshrunk.parts.splice(drop, 1);
|
||||
// The part before the dropped one may have noted a cut that now runs
|
||||
// on through the dropped part's lines.
|
||||
reshrunk.parts = renoteCutBodies(reshrunk.parts);
|
||||
reshrunk.shrunk = true;
|
||||
}
|
||||
renderedClusters.set(idx, reshrunk);
|
||||
@@ -7414,7 +7556,7 @@ export class ToolHandler {
|
||||
? exploreCompletenessNotes(filesIncluded, trimmedShown, [...renderedFilePaths, ...fileGroups.keys()])
|
||||
.map((note) => ['', '---', note])
|
||||
: anyFileTrimmed || trimmedShown.length > 0
|
||||
? [['', `> Some file sections were trimmed for size. Elided symbols are named inside gap markers as \`name (file:line)\` and preferred in the file header — run another \`codegraph_explore\` (or \`codegraph_node\`) with those exact names for their source.`]]
|
||||
? [['', `> Some file sections were trimmed for size. Elided symbols are named inside gap markers as \`name (file:line)\` and preferred in the file header — run another \`codegraph_explore\`${this.isToolServed('node') ? ' (or `codegraph_node`)' : ''} with those exact names for their source.`]]
|
||||
: [];
|
||||
/** Whether a trimmed section survives in `text` — picks a fallback note's wording after a cut. */
|
||||
const trimmedIn = (text: string): boolean =>
|
||||
|
||||
Reference in New Issue
Block a user