diff --git a/package-lock.json b/package-lock.json index a64edb5bad..ca0ec48f21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4975,7 +4975,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -11609,7 +11609,7 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -13891,6 +13891,15 @@ "node": ">=18" } }, + "node_modules/tree-sitter-wasms": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz", + "integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==", + "license": "Unlicense", + "dependencies": { + "tree-sitter-wasms": "^0.1.11" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -14215,7 +14224,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/vary": { @@ -14475,6 +14484,12 @@ "node": ">=18" } }, + "node_modules/web-tree-sitter": { + "version": "0.20.8", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.8.tgz", + "integrity": "sha512-weOVgZ3aAARgdnb220GqYuh7+rZU0Ka9k9yfKtGAzEYMa6GgiCzW9JjQRJyCJakvibQW+dfjJdihjInKuuCAUQ==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -14871,6 +14886,8 @@ "version": "0.2.0", "license": "AGPL-3.0-only", "dependencies": { + "tree-sitter-wasms": "^0.1.13", + "web-tree-sitter": "^0.20.8", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 8845eb60ca..651d0e1382 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -642,3 +642,15 @@ safe defaults and `warnings` explains any dropped or invalid fields. `.gittensory-miner.yml` → `.github/gittensory-miner.yml` → the `.json` variants). It is IO-free — the caller injects the existence check — so a caller reads the returned path and feeds its content to `parseMinerGoalSpecContent`. See `.gittensory-miner.yml.example` for the documented fields. + +## Repo map builder + +`buildRepoMap(files)` gives a coding-agent driver (or the acceptance-criteria/prompt-packet builders upstream of +it) a compact, structural view of a target repository — function/class/method/interface/type signatures — without +paying the token cost of dumping full file contents into a prompt. It parses with `web-tree-sitter` (the WASM +binding, not a native addon, since this package also ships a Cloudflare Workers deployment target) using prebuilt +grammars from `tree-sitter-wasms`. Supported today: JavaScript/TypeScript/TSX. A file with an unsupported extension +or a grammar that fails to load/parse is reported via `skipped` on its `RepoMapFileEntry`, never thrown — this +module's contract is "extract what it safely can," not "block the whole driver invocation." `renderRepoMap(entries)` +renders a bounded plain-text outline, truncating (with a marker) once a configurable char budget is exceeded so it +can't blow out a prompt budget on a large repo. (#4280) diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json index ac52d07dd5..ddba8ce1cd 100644 --- a/packages/gittensory-engine/package.json +++ b/packages/gittensory-engine/package.json @@ -65,6 +65,8 @@ "test": "npm run build && rm -rf dist-test && tsc -p tsconfig.test.json && node --test \"dist-test/**/*.test.js\"" }, "dependencies": { + "tree-sitter-wasms": "^0.1.13", + "web-tree-sitter": "^0.20.8", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 7e870d96bc..c39b5a718a 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -220,6 +220,19 @@ export { type RunCodingAgentAttemptOptions, } from "./miner/driver-factory.js"; export * from "./miner/attempt-metering.js"; +export { + buildRepoMap, + extractRepoMapSymbols, + renderRepoMap, + resolveRepoMapLanguage, + type BuildRepoMapOptions, + type LoadRepoMapLanguageFn, + type RepoMapFileEntry, + type RepoMapSkipReason, + type RepoMapSourceFile, + type RepoMapSymbol, + type RepoMapSymbolKind, +} from "./miner/repo-map.js"; export * from "./plan-export.js"; export { countPlanStepsByStatus } from "./plan-step-stats.js"; export { countPlanSteps } from "./plan-step-count.js"; diff --git a/packages/gittensory-engine/src/miner/repo-map.ts b/packages/gittensory-engine/src/miner/repo-map.ts new file mode 100644 index 0000000000..59f44ba52a --- /dev/null +++ b/packages/gittensory-engine/src/miner/repo-map.ts @@ -0,0 +1,219 @@ +// Tree-sitter-based repo map builder (#4280): gives a coding-agent driver (or the acceptance-criteria/prompt- +// packet builders upstream of it) a compact, structural view of a target repository -- function/class/method +// signatures -- without paying the token cost of dumping full file contents into a prompt. Uses `web-tree-sitter` +// (the WASM binding) with prebuilt grammars from `tree-sitter-wasms`, not a native addon: this package also ships +// a Cloudflare Workers deployment target where native Node addons are not an option. This module only ever runs +// in the local miner/CLI process, but the WASM binding keeps that door open and needs no native build step. +// +// Supported today: JavaScript/TypeScript/TSX (this repo's own dominant languages). A file whose extension has no +// mapped grammar is skipped (not crashed on) with `skipped: "unsupported_language"`; a grammar that fails to load +// or a parse that throws is caught the same way, with `skipped: "grammar_unavailable"` -- this module's contract +// is "extract what it safely can," never "block the whole driver invocation." +// +// Known scope limit: only `function`/`class` declarations and expressions, `method_definition`, `interface`, and +// `type` alias nodes are extracted -- an arrow function or class expression bound via a `const foo = ...` +// declarator is not walked up to its binding identifier, so it is either missed (arrow functions aren't matched +// at all yet) or reported as "" (a bare class/function expression). Good enough for a compact outline +// today; resolving binding names is a reasonable follow-up, not attempted here. + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import Parser from "web-tree-sitter"; + +const require = createRequire(import.meta.url); + +export type RepoMapSymbolKind = "function" | "class" | "method" | "interface" | "type"; + +export type RepoMapSymbol = { + kind: RepoMapSymbolKind; + name: string; + signature: string; + line: number; +}; + +export type RepoMapSkipReason = "unsupported_language" | "grammar_unavailable"; + +export type RepoMapFileEntry = { + path: string; + language: string | null; + symbols: readonly RepoMapSymbol[]; + skipped?: RepoMapSkipReason; +}; + +export type RepoMapSourceFile = { + path: string; + sourceText: string; +}; + +/** File extension (including the leading dot) -> tree-sitter grammar name in `tree-sitter-wasms`. */ +const LANGUAGE_BY_EXTENSION: Readonly> = Object.freeze({ + ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".mts": "typescript", + ".cts": "typescript", + ".tsx": "tsx", +}); + +const SYMBOL_NODE_KIND: Readonly> = Object.freeze({ + function_declaration: "function", + // `function_expression`/`class` (bare, unnamed) cover `export default function() {}` / `export default class {}` + // and other expression positions -- these have no `name` field, so `nameOf` reports them as "" + // rather than skipping them outright. + function_expression: "function", + class_declaration: "class", + class: "class", + method_definition: "method", + interface_declaration: "interface", + type_alias_declaration: "type", +}); + +function extensionOf(path: string): string { + const dot = path.lastIndexOf("."); + return dot === -1 ? "" : path.slice(dot); +} + +/** Pure: map a file path to the grammar name that would parse it, or null if unsupported. */ +export function resolveRepoMapLanguage(path: string): string | null { + return LANGUAGE_BY_EXTENSION[extensionOf(path)] ?? null; +} + +/** Test/injection seam for loading a compiled grammar -- real WASM-file IO lives only in the default + * implementation, so a test can inject a failing loader to exercise `grammar_unavailable` without needing an + * actually-broken WASM file. */ +export type LoadRepoMapLanguageFn = (languageName: string) => Promise; + +let parserInitialized: Promise | null = null; + +async function defaultLoadRepoMapLanguage(languageName: string): Promise { + parserInitialized ??= Parser.init(); + await parserInitialized; + const wasmPath = require.resolve(`tree-sitter-wasms/out/tree-sitter-${languageName}.wasm`); + return Parser.Language.load(readFileSync(wasmPath)); +} + +/** First line of a symbol node's own text, trimmed and bounded to `maxChars` (with an ellipsis marker when cut), + * so one huge one-line minified function can't blow out the rendered output on its own. */ +function signatureOf(node: Parser.SyntaxNode, maxChars: number): string { + const firstLine = node.text.split("\n", 1)[0]!.trim(); + return firstLine.length > maxChars ? `${firstLine.slice(0, maxChars)}…` : firstLine; +} + +function nameOf(node: Parser.SyntaxNode): string { + return node.childForFieldName("name")?.text ?? ""; +} + +/** Walk a parsed tree collecting one `RepoMapSymbol` per matched node kind (function/class/method/interface/ + * type declarations). Pure given an already-parsed tree. */ +export function extractRepoMapSymbols(tree: Parser.Tree, maxSignatureChars = 120): RepoMapSymbol[] { + const symbols: RepoMapSymbol[] = []; + function walk(node: Parser.SyntaxNode): void { + const kind = SYMBOL_NODE_KIND[node.type]; + if (kind) { + symbols.push({ + kind, + name: nameOf(node), + signature: signatureOf(node, maxSignatureChars), + line: node.startPosition.row + 1, + }); + } + for (const child of node.namedChildren) walk(child); + } + walk(tree.rootNode); + return symbols; +} + +export type BuildRepoMapOptions = { + loadLanguage?: LoadRepoMapLanguageFn | undefined; + maxSignatureChars?: number | undefined; +}; + +/** Build one `RepoMapFileEntry` per source file: unsupported extensions and grammar/parse failures are caught + * and reported via `skipped`, never thrown -- see module header. A language's grammar is only loaded once per + * call even across many files of the same language. */ +export async function buildRepoMap( + files: readonly RepoMapSourceFile[], + options: BuildRepoMapOptions = {}, +): Promise { + const loadLanguage = options.loadLanguage ?? defaultLoadRepoMapLanguage; + const maxSignatureChars = options.maxSignatureChars ?? 120; + const languageCache = new Map(); + + async function resolveLanguage(name: string): Promise { + const cached = languageCache.get(name); + if (cached !== undefined) return cached; + try { + const language = await loadLanguage(name); + languageCache.set(name, language); + return language; + } catch { + languageCache.set(name, null); + return null; + } + } + + const entries: RepoMapFileEntry[] = []; + for (const file of files) { + const languageName = resolveRepoMapLanguage(file.path); + if (!languageName) { + entries.push({ path: file.path, language: null, symbols: [], skipped: "unsupported_language" }); + continue; + } + const language = await resolveLanguage(languageName); + if (!language) { + entries.push({ path: file.path, language: languageName, symbols: [], skipped: "grammar_unavailable" }); + continue; + } + try { + const parser = new Parser(); + parser.setLanguage(language); + const tree = parser.parse(file.sourceText); + entries.push({ + path: file.path, + language: languageName, + symbols: extractRepoMapSymbols(tree, maxSignatureChars), + }); + } catch { + entries.push({ path: file.path, language: languageName, symbols: [], skipped: "grammar_unavailable" }); + } + } + return entries; +} + +/** 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. */ +export function renderRepoMap(entries: readonly RepoMapFileEntry[], maxOutputChars = 20_000): string { + const lines: string[] = []; + let length = 0; + let truncated = false; + + function pushLine(line: string): boolean { + const addedLength = length === 0 ? line.length : line.length + 1; // +1 for the joining newline + if (length + addedLength > maxOutputChars) { + truncated = true; + return false; + } + lines.push(line); + length += addedLength; + return true; + } + + outer: for (const entry of entries) { + if (entry.skipped) { + if (!pushLine(`${entry.path}: (skipped: ${entry.skipped})`)) break outer; + } else if (entry.symbols.length === 0) { + if (!pushLine(`${entry.path}: (no symbols)`)) break outer; + } else { + if (!pushLine(`${entry.path}:`)) break outer; + for (const symbol of entry.symbols) { + if (!pushLine(` ${symbol.kind} ${symbol.name} (line ${symbol.line}): ${symbol.signature}`)) break outer; + } + } + } + + if (truncated) lines.push("… (repo map truncated to fit the output budget)"); + return lines.join("\n"); +} diff --git a/test/unit/repo-map.test.ts b/test/unit/repo-map.test.ts new file mode 100644 index 0000000000..bbb91bb37f --- /dev/null +++ b/test/unit/repo-map.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from "vitest"; +import { + buildRepoMap, + extractRepoMapSymbols, + renderRepoMap, + resolveRepoMapLanguage, + type LoadRepoMapLanguageFn, + type RepoMapFileEntry, +} from "../../packages/gittensory-engine/src/index"; + +describe("resolveRepoMapLanguage (#4280)", () => { + it("maps known extensions to their grammar name", () => { + expect(resolveRepoMapLanguage("src/foo.js")).toBe("javascript"); + expect(resolveRepoMapLanguage("src/foo.mjs")).toBe("javascript"); + expect(resolveRepoMapLanguage("src/foo.cjs")).toBe("javascript"); + expect(resolveRepoMapLanguage("src/foo.jsx")).toBe("javascript"); + expect(resolveRepoMapLanguage("src/foo.ts")).toBe("typescript"); + expect(resolveRepoMapLanguage("src/foo.mts")).toBe("typescript"); + expect(resolveRepoMapLanguage("src/foo.cts")).toBe("typescript"); + expect(resolveRepoMapLanguage("src/foo.tsx")).toBe("tsx"); + }); + + it("returns null for an unsupported or missing extension", () => { + expect(resolveRepoMapLanguage("README.md")).toBeNull(); + expect(resolveRepoMapLanguage("Makefile")).toBeNull(); + expect(resolveRepoMapLanguage("src/foo.py")).toBeNull(); + }); +}); + +describe("buildRepoMap + extractRepoMapSymbols (#4280)", () => { + it("extracts function/class/method declarations from a real typescript parse, with 1-indexed line numbers", async () => { + const sourceText = [ + "export function add(a: number, b: number): number {", + " return a + b;", + "}", + "", + "export class Widget {", + " render(): string {", + " return 'ok';", + " }", + "}", + ].join("\n"); + + const [entry] = await buildRepoMap([{ path: "src/widget.ts", sourceText }]); + expect(entry).toBeDefined(); + expect(entry!.language).toBe("typescript"); + expect(entry!.skipped).toBeUndefined(); + expect(entry!.symbols).toEqual([ + // The `export` keyword lives on the enclosing export_statement node, not the declaration node itself, so + // it is not part of the extracted signature. + { kind: "function", name: "add", signature: "function add(a: number, b: number): number {", line: 1 }, + { kind: "class", name: "Widget", signature: "class Widget {", line: 5 }, + { kind: "method", name: "render", signature: "render(): string {", line: 6 }, + ]); + }); + + it("extracts interface and type-alias declarations, distinct from a plain function/class", async () => { + const sourceText = [ + "export interface Point {", + " x: number;", + " y: number;", + "}", + "", + "export type Pair = [Point, Point];", + ].join("\n"); + + const [entry] = await buildRepoMap([{ path: "src/geometry.ts", sourceText }]); + expect(entry!.symbols.map((s) => s.kind)).toEqual(["interface", "type"]); + expect(entry!.symbols.map((s) => s.name)).toEqual(["Point", "Pair"]); + }); + + it("parses .tsx files with the tsx grammar and extracts a function component declaration", async () => { + const sourceText = [ + "export function Button(props: { label: string }) {", + " return null;", + "}", + ].join("\n"); + const [entry] = await buildRepoMap([{ path: "src/Button.tsx", sourceText }]); + expect(entry!.language).toBe("tsx"); + expect(entry!.symbols.map((s) => s.name)).toEqual(["Button"]); + }); + + it("parses plain .js with the javascript grammar", async () => { + const [entry] = await buildRepoMap([{ path: "src/legacy.js", sourceText: "function helper() {}\n" }]); + expect(entry!.language).toBe("javascript"); + expect(entry!.symbols).toEqual([{ kind: "function", name: "helper", signature: "function helper() {}", line: 1 }]); + }); + + it("truncates an overlong one-line signature and marks it with an ellipsis", async () => { + const longBody = "x".repeat(200); + const sourceText = `function longOne() { const ${longBody} = 1; }`; + const [entry] = await buildRepoMap([{ path: "src/long.ts", sourceText }], { maxSignatureChars: 30 }); + expect(entry!.symbols[0]!.signature.endsWith("…")).toBe(true); + expect(entry!.symbols[0]!.signature.length).toBe(31); // 30 chars + the ellipsis marker + }); + + it("reports an anonymous name for an unnamed class/function expression (e.g. export default class/function)", async () => { + const [classEntry] = await buildRepoMap([ + { path: "src/anon-class.ts", sourceText: "export default class { method() {} }" }, + ]); + const classSymbol = classEntry!.symbols.find((s) => s.kind === "class"); + expect(classSymbol?.name).toBe(""); + + const [functionEntry] = await buildRepoMap([ + { path: "src/anon-function.ts", sourceText: "export default function() { return 1; }" }, + ]); + const functionSymbol = functionEntry!.symbols.find((s) => s.kind === "function"); + expect(functionSymbol?.name).toBe(""); + }); + + it("skips a file with an unsupported extension without attempting to load any grammar", async () => { + let loadCalls = 0; + const countingLoader: LoadRepoMapLanguageFn = async () => { + loadCalls += 1; + throw new Error("should not be called"); + }; + const [entry] = await buildRepoMap([{ path: "README.md", sourceText: "# hello" }], { + loadLanguage: countingLoader, + }); + expect(entry).toEqual({ path: "README.md", language: null, symbols: [], skipped: "unsupported_language" }); + expect(loadCalls).toBe(0); + }); + + it("reports grammar_unavailable (not a thrown error) when the injected loader rejects", async () => { + const failingLoader: LoadRepoMapLanguageFn = async () => { + throw new Error("wasm load failed"); + }; + const [entry] = await buildRepoMap([{ path: "src/foo.ts", sourceText: "function f() {}" }], { + loadLanguage: failingLoader, + }); + expect(entry).toEqual({ path: "src/foo.ts", language: "typescript", symbols: [], skipped: "grammar_unavailable" }); + }); + + it("reports grammar_unavailable when parsing itself throws, even though the grammar loaded fine", async () => { + const throwingParseLanguage = {} as never; // setLanguage(this) will throw inside Parser -- not a real Language + const brokenLoader: LoadRepoMapLanguageFn = async () => throwingParseLanguage; + const [entry] = await buildRepoMap([{ path: "src/foo.ts", sourceText: "function f() {}" }], { + loadLanguage: brokenLoader, + }); + expect(entry).toEqual({ path: "src/foo.ts", language: "typescript", symbols: [], skipped: "grammar_unavailable" }); + }); + + it("parses multiple files of the same language correctly using the real (non-injected) default loader", async () => { + const entries = await buildRepoMap([ + { path: "a.ts", sourceText: "function a() {}" }, + { path: "b.ts", sourceText: "function b() {}" }, + { path: "c.ts", sourceText: "function c() {}" }, + ]); + expect(entries.every((entry) => entry.language === "typescript" && entry.skipped === undefined)).toBe(true); + expect(entries.map((entry) => entry.symbols[0]!.name)).toEqual(["a", "b", "c"]); + }); + + it("an injected loader is invoked once per distinct language across multiple files, not once per file", async () => { + let loadCalls = 0; + const stubLanguage = { stub: true } as never; + const countingLoader: LoadRepoMapLanguageFn = async () => { + loadCalls += 1; + return stubLanguage; + }; + // The stub language will make parser.setLanguage/parse throw, which is fine -- this test only cares that + // loadLanguage itself was called exactly once per distinct language name, not about parse success. + await buildRepoMap( + [ + { path: "a.ts", sourceText: "" }, + { path: "b.ts", sourceText: "" }, + { path: "c.js", sourceText: "" }, + ], + { loadLanguage: countingLoader }, + ); + expect(loadCalls).toBe(2); // one for "typescript" (a.ts, b.ts), one for "javascript" (c.js) + }); +}); + +describe("renderRepoMap (#4280)", () => { + const normalEntry: RepoMapFileEntry = { + path: "src/widget.ts", + language: "typescript", + symbols: [{ kind: "function", name: "add", signature: "export function add() {", line: 1 }], + }; + const skippedEntry: RepoMapFileEntry = { path: "README.md", language: null, symbols: [], skipped: "unsupported_language" }; + const emptyEntry: RepoMapFileEntry = { path: "src/empty.ts", language: "typescript", symbols: [] }; + + it("renders one line per symbol plus a header line per file", () => { + const output = renderRepoMap([normalEntry]); + expect(output).toBe("src/widget.ts:\n function add (line 1): export function add() {"); + }); + + it("renders every symbol of a multi-symbol file, not just the first", () => { + 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 }, + ], + }; + const output = renderRepoMap([multiSymbolEntry]); + expect(output).toBe("src/multi.ts:\n function a (line 1): function a() {\n function b (line 3): function b() {"); + }); + + it("notes a skipped file with its skip reason", () => { + expect(renderRepoMap([skippedEntry])).toBe("README.md: (skipped: unsupported_language)"); + }); + + it("notes a file with no symbols", () => { + expect(renderRepoMap([emptyEntry])).toBe("src/empty.ts: (no symbols)"); + }); + + it("returns an empty string for zero entries", () => { + expect(renderRepoMap([])).toBe(""); + }); + + it("truncates once the char budget is exceeded and appends a truncation marker", () => { + const manyEntries: RepoMapFileEntry[] = Array.from({ length: 50 }, (_, i) => ({ + path: `src/file${i}.ts`, + language: "typescript", + symbols: [{ kind: "function", name: `fn${i}`, signature: `export function fn${i}() {`, line: 1 }], + })); + const output = renderRepoMap(manyEntries, 200); + expect(output.length).toBeLessThanOrEqual(200 + "\n… (repo map truncated to fit the output budget)".length); + expect(output.endsWith("… (repo map truncated to fit the output budget)")).toBe(true); + }); + + it("does not truncate when everything fits comfortably under the budget", () => { + const output = renderRepoMap([normalEntry], 20_000); + expect(output).not.toContain("truncated"); + }); + + 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)"); + }); + + 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)"); + }); + + 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)"); + }); + + it("truncates partway through a multi-symbol file's symbol list, keeping the symbols that already fit", () => { + 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 }, + ], + }; + 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)`); + }); +}); + +describe("extractRepoMapSymbols default maxSignatureChars (#4280)", () => { + it("uses a 120-char default when not passed explicitly by buildRepoMap's caller", async () => { + const shortSource = "function shortFn(a) {}"; + const [entry] = await buildRepoMap([{ path: "src/short.js", sourceText: shortSource }]); + expect(entry!.symbols[0]!.signature).toBe(shortSource); + }); +});