Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/loopover-engine/src/miner/repo-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,10 @@ export async function buildRepoMap(
/** Render entries into a bounded plain-text outline: one line per symbol (`kind name (line N): signature`),
* skipped/empty files noted with a one-line placeholder. Stops once `maxOutputChars` would be exceeded and
* appends a truncation marker, so a caller/prompt-builder can tell the map is partial rather than complete. */
/** The one-line marker appended when the map is truncated to fit `maxOutputChars`. Its length is reserved
* from the budget so the rendered string never exceeds `maxOutputChars` (#9617). */
const TRUNCATION_MARKER = "… (repo map truncated to fit the output budget)";

export function renderRepoMap(
entries: readonly RepoMapFileEntry[],
maxOutputChars = 20_000,
Expand Down Expand Up @@ -378,6 +382,19 @@ export function renderRepoMap(
}
}

if (truncated) lines.push("… (repo map truncated to fit the output budget)");
// A complete render is emitted at full budget, byte-for-byte as before — no marker, no reserved headroom.
if (!truncated) return lines.join("\n");

// Truncated: the marker itself must fit inside maxOutputChars (#9617) — pushLine budgets content but the
// marker used to be appended unbudgeted, overshooting by up to its own length + a newline. If the budget
// can't even hold the marker alone, an over-budget marker-only string is worse than nothing: return "".
if (maxOutputChars < TRUNCATION_MARKER.length) return "";
// Reserve room for the marker (plus its joining newline while content lines remain) by dropping trailing
// content lines until it fits, then append it.
while (lines.length > 0 && length + TRUNCATION_MARKER.length + 1 > maxOutputChars) {
const removed = lines.pop()!;
length -= lines.length === 0 ? removed.length : removed.length + 1;
}
lines.push(TRUNCATION_MARKER);
return lines.join("\n");
}
55 changes: 55 additions & 0 deletions packages/loopover-engine/test/repo-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { renderRepoMap, type RepoMapFileEntry } from "../dist/index.js";

// Engine-suite (node:test) coverage for renderRepoMap's output-budget contract (#9617) so the `engine`
// Codecov flag credits the changed lines, mirroring the behavior assertions in test/unit/repo-map.test.ts.
const MARKER = "… (repo map truncated to fit the output budget)";

function symbolEntries(n: number): RepoMapFileEntry[] {
return Array.from({ length: n }, (_, i) => ({
path: `src/file${i}.ts`,
language: "typescript",
symbols: [{ kind: "function", name: `fn${i}`, signature: `export function fn${i}() {`, line: 1 }],
}));
}

test("renders a complete map byte-for-byte (no marker) when everything fits", () => {
const entry: RepoMapFileEntry = {
path: "src/a.ts",
language: "typescript",
symbols: [{ kind: "function", name: "a", signature: "function a() {", line: 1 }],
};
const output = renderRepoMap([entry], 20_000);
assert.equal(output, "src/a.ts:\n function a (line 1): function a() {");
assert.ok(!output.includes("truncated"));
});

test("keeps the output within maxOutputChars including the reserved marker", () => {
for (const budget of [0, 5, MARKER.length - 1, MARKER.length, MARKER.length + 10, 100, 500]) {
assert.ok(renderRepoMap(symbolEntries(40), budget).length <= budget);
}
});

test("returns the empty string when the budget cannot hold even the marker", () => {
assert.equal(renderRepoMap(symbolEntries(40), MARKER.length - 1), "");
});

test("returns the marker alone when the budget holds the marker but no content", () => {
assert.equal(renderRepoMap(symbolEntries(40), MARKER.length), MARKER);
});

test("keeps the symbols that already fit alongside the reserved marker when truncating a large later symbol", () => {
const entry: RepoMapFileEntry = {
path: "src/multi.ts",
language: "typescript",
symbols: [
{ kind: "function", name: "a", signature: "function a() {", line: 1 },
{ kind: "function", name: "b", signature: "function bWithAVeryLongSignatureThatExceedsTheMarkerLength() {", line: 3 },
],
};
const headerAndFirstSymbol = "src/multi.ts:\n function a (line 1): function a() {";
const output = renderRepoMap([entry], headerAndFirstSymbol.length + 1 + MARKER.length);
assert.equal(output, `${headerAndFirstSymbol}\n${MARKER}`);
});
57 changes: 38 additions & 19 deletions test/unit/repo-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,9 +466,9 @@ describe("renderRepoMap (#4280)", () => {
}),
);
const output = renderRepoMap(manyEntries, 200);
expect(output.length).toBeLessThanOrEqual(
200 + "\n… (repo map truncated to fit the output budget)".length,
);
// #9617: the marker is reserved from the budget, so the WHOLE result stays within maxOutputChars —
// the marker no longer overshoots (previously up to its own length + a newline over budget).
expect(output.length).toBeLessThanOrEqual(200);
expect(
output.endsWith("… (repo map truncated to fit the output budget)"),
).toBe(true);
Expand All @@ -479,39 +479,58 @@ describe("renderRepoMap (#4280)", () => {
expect(output).not.toContain("truncated");
});

it("#9617: result never exceeds maxOutputChars, and a budget too small for the marker yields the empty string", () => {
const marker = "… (repo map truncated to fit the output budget)";
const manyEntries: RepoMapFileEntry[] = Array.from({ length: 40 }, (_, i) => ({
path: `src/file${i}.ts`,
language: "typescript",
symbols: [{ kind: "function", name: `fn${i}`, signature: `export function fn${i}() {`, line: 1 }],
}));

// Bound holds for a spread of budgets, including ones near and below the marker's own length.
for (const budget of [0, 5, marker.length - 1, marker.length, marker.length + 10, 100, 500]) {
const out = renderRepoMap(manyEntries, budget);
expect(out.length).toBeLessThanOrEqual(budget);
}
// A budget below the marker's length can't honestly signal truncation → empty string, never an over-budget marker.
expect(renderRepoMap(manyEntries, marker.length - 1)).toBe("");
// A budget that holds the marker but no content still marks the truncation (marker only).
expect(renderRepoMap(manyEntries, marker.length)).toBe(marker);
});

// #9617: at a budget below the marker's own length, the target line still truncates at its own site
// (skipped / no-symbols / header), but there is no honest room for the marker — so the result is the
// empty string, never an over-budget marker. The three cases exercise the three non-symbol break sites.
it("truncates on a skipped-entry line when the budget is exceeded there", () => {
const output = renderRepoMap([skippedEntry, normalEntry], 5);
expect(output).toBe("… (repo map truncated to fit the output budget)");
expect(renderRepoMap([skippedEntry, normalEntry], 5)).toBe("");
});

it("truncates on a no-symbols-entry line when the budget is exceeded there", () => {
const output = renderRepoMap([emptyEntry, normalEntry], 5);
expect(output).toBe("… (repo map truncated to fit the output budget)");
expect(renderRepoMap([emptyEntry, normalEntry], 5)).toBe("");
});

it("truncates on a file's header line (before any of its symbols) when the budget is exceeded there", () => {
const output = renderRepoMap([normalEntry], 5);
expect(output).toBe("… (repo map truncated to fit the output budget)");
expect(renderRepoMap([normalEntry], 5)).toBe("");
});

it("truncates partway through a multi-symbol file's symbol list, keeping the symbols that already fit", () => {
const marker = "… (repo map truncated to fit the output budget)";
const multiSymbolEntry: RepoMapFileEntry = {
path: "src/multi.ts",
language: "typescript",
symbols: [
{ kind: "function", name: "a", signature: "function a() {", line: 1 },
{ kind: "function", name: "b", signature: "function b() {", line: 3 },
// A large second symbol (longer than the marker) so the budget can hold the first symbol AND the
// reserved marker while still truncating this one — the content-retention path #9617 must preserve.
{ kind: "function", name: "b", signature: "function bWithAVeryLongSignatureThatExceedsTheMarkerLength() {", line: 3 },
],
};
const headerAndFirstSymbol =
"src/multi.ts:\n function a (line 1): function a() {";
const output = renderRepoMap(
[multiSymbolEntry],
headerAndFirstSymbol.length,
);
expect(output).toBe(
`${headerAndFirstSymbol}\n… (repo map truncated to fit the output budget)`,
);
const headerAndFirstSymbol = "src/multi.ts:\n function a (line 1): function a() {";
// Reserve exactly the header+first-symbol content plus the marker (and its joining newline).
const budget = headerAndFirstSymbol.length + 1 + marker.length;
const output = renderRepoMap([multiSymbolEntry], budget);
expect(output).toBe(`${headerAndFirstSymbol}\n${marker}`);
expect(output.length).toBeLessThanOrEqual(budget);
});
});

Expand Down