From 97f4d5d2593827d92072641ed38f35ca0c0f4475 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 23 Mar 2026 16:51:31 -0500 Subject: [PATCH] =?UTF-8?q?fix(gsd):=20harden=20codebase-map=20=E2=80=94?= =?UTF-8?q?=20bug=20fixes,=20UX=20polish,=20and=20expanded=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generator (codebase-generator.ts): - Fix truncation off-by-one: use filtered.length > maxFiles (not >=) - Fix collapsed-directory round-trip: emit comment blocks so incremental updates recover descriptions for collapsed dirs - Fix double-enumeration race in updateCodebaseMap: reuse files array from generateCodebaseMap instead of calling enumerateFiles a second time - Propagate truncated flag through updateCodebaseMap return type - Fix getCodebaseMapStats to read Files: N from header (accurate for collapsed dirs) - Remove redundant dead catch around lsFiles() in enumerateFiles - parseCodebaseMap: use else-if for bare match (avoid unnecessary double-check) - parseCodebaseMap: scan gsd:collapsed-descriptions comment blocks Command handler (commands-codebase.ts): - Bare /gsd codebase now shows stats (if map exists) or help (if no map) instead of silently running generate - Add explicit help subcommand with info-level output - Guard update: warn if no CODEBASE.md exists instead of silently generating - Validate --max-files: reject NaN, zero, and negative values with clear message - Emit warning (not success) when generate produces 0 files - Propagate truncated flag warning in both generate and update output - Fix extractFlag regex: escape flag name and support --flag=value syntax - Add actionable tip to stats output Catalog (commands/catalog.ts): - Add --max-files and help to codebase tab-completion entries System context (bootstrap/system-context.ts): - Cap CODEBASE.md injection at 8 000 chars (~2 000 tokens) per request - Add generation timestamp and staleness notice to the injected block header Paths (paths.ts): - Fix LEGACY_GSD_ROOT_FILES.CODEBASE to use lowercase codebase.md (matches the pattern of all other legacy root file names) Tests (codebase-generator.test.ts): - 15 new test cases: custom excludePatterns, collapseThreshold option, truncation boundary conditions (below/at/above limit), non-git directory, empty repo, collapsed-description round-trip, removed file tracking, binary/lock exclusions, truncated flag propagation, collapsed-dir stats accuracy, .gsd/ auto-creation, corrupted input, parseCodebaseMap comment blocks - Fix collapse assertion to verify individual entries are absent from main body - Fix git rm test to commit first so git rm succeeds - 29/29 tests passing Co-Authored-By: Claude Sonnet 4.6 --- .../gsd/bootstrap/system-context.ts | 14 +- .../extensions/gsd/codebase-generator.ts | 115 +++++--- .../extensions/gsd/commands-codebase.ts | 139 ++++++--- .../extensions/gsd/commands/catalog.ts | 5 +- src/resources/extensions/gsd/paths.ts | 2 +- .../gsd/tests/codebase-generator.test.ts | 279 +++++++++++++++++- 6 files changed, 449 insertions(+), 105 deletions(-) diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 8a27e5fca..534cffe0b 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -98,9 +98,17 @@ export async function buildBeforeAgentStartResult( const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE"); if (existsSync(codebasePath)) { try { - const content = readFileSync(codebasePath, "utf-8").trim(); - if (content) { - codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions]\n\n${content}`; + const rawContent = readFileSync(codebasePath, "utf-8").trim(); + if (rawContent) { + // Cap injection size to ~2 000 tokens to avoid bloating every request. + // Full map is always available at .gsd/CODEBASE.md. + const MAX_CODEBASE_CHARS = 8_000; + const generatedMatch = rawContent.match(/Generated: (\S+)/); + const generatedAt = generatedMatch?.[1] ?? "unknown"; + const content = rawContent.length > MAX_CODEBASE_CHARS + ? rawContent.slice(0, MAX_CODEBASE_CHARS) + "\n\n*(truncated — see .gsd/CODEBASE.md for full map)*" + : rawContent; + codebaseBlock = `\n\n[PROJECT CODEBASE — File structure and descriptions (generated ${generatedAt}, may be stale — run /gsd codebase update to refresh)]\n\n${content}`; } } catch { // skip diff --git a/src/resources/extensions/gsd/codebase-generator.ts b/src/resources/extensions/gsd/codebase-generator.ts index 1d4c39cb3..6fe558abb 100644 --- a/src/resources/extensions/gsd/codebase-generator.ts +++ b/src/resources/extensions/gsd/codebase-generator.ts @@ -56,19 +56,37 @@ const DEFAULT_COLLAPSE_THRESHOLD = 20; /** * Parse an existing CODEBASE.md to extract file → description mappings. + * Also scans comment blocks to preserve + * descriptions for files in collapsed directories across incremental updates. */ export function parseCodebaseMap(content: string): Map { const descriptions = new Map(); + let inCollapsedBlock = false; + for (const line of content.split("\n")) { + // Track collapsed-description comment blocks + if (line.trimStart().startsWith("")) { + inCollapsedBlock = false; + continue; + } + // Match: - `path/to/file.ts` — Description here const match = line.match(/^- `(.+?)` — (.+)$/); if (match) { descriptions.set(match[1], match[2]); + continue; } - // Match: - `path/to/file.ts` (no description) - const bareMatch = line.match(/^- `(.+?)`\s*$/); - if (bareMatch) { - descriptions.set(bareMatch[1], ""); + + // Match: - `path/to/file.ts` (no description) — only outside collapsed blocks + if (!inCollapsedBlock) { + const bareMatch = line.match(/^- `(.+?)`\s*$/); + if (bareMatch) { + descriptions.set(bareMatch[1], ""); + } } } return descriptions; @@ -94,7 +112,6 @@ function shouldExclude(filePath: string, excludes: string[]): boolean { function lsFiles(basePath: string): string[] { try { - // Use git ls-files directly — nativeLsFiles("") doesn't work in all contexts const result = execSync("git ls-files", { cwd: basePath, encoding: "utf-8", timeout: 10000 }); return result.split("\n").filter(Boolean); } catch { @@ -102,21 +119,15 @@ function lsFiles(basePath: string): string[] { } } -function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): string[] { - let files: string[]; - try { - files = lsFiles(basePath); - } catch { - return []; - } - - const filtered = files.filter((f) => !shouldExclude(f, excludes)); - - if (filtered.length > maxFiles) { - return filtered.slice(0, maxFiles); - } - - return filtered; +/** + * Enumerate tracked files, applying exclusions and the maxFiles cap. + * Returns both the file list and whether truncation occurred. + */ +function enumerateFiles(basePath: string, excludes: string[], maxFiles: number): { files: string[]; truncated: boolean } { + const allFiles = lsFiles(basePath); + const filtered = allFiles.filter((f) => !shouldExclude(f, excludes)); + const truncated = filtered.length > maxFiles; + return { files: truncated ? filtered.slice(0, maxFiles) : filtered, truncated }; } // ─── Grouping ──────────────────────────────────────────────────────────────── @@ -144,13 +155,13 @@ function groupByDirectory( const sortedDirs = [...dirMap.keys()].sort(); for (const dir of sortedDirs) { - const files = dirMap.get(dir)!; - files.sort((a, b) => a.path.localeCompare(b.path)); + const dirFiles = dirMap.get(dir)!; + dirFiles.sort((a, b) => a.path.localeCompare(b.path)); groups.push({ path: dir, - files, - collapsed: files.length > collapseThreshold, + files: dirFiles, + collapsed: dirFiles.length > collapseThreshold, }); } @@ -161,7 +172,7 @@ function groupByDirectory( function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string { const lines: string[] = []; - const now = new Date().toISOString().slice(0, 19) + "Z"; + const now = new Date().toISOString().split(".")[0] + "Z"; const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0); lines.push("# Codebase Map"); @@ -174,7 +185,6 @@ function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncat for (const group of groups) { const heading = group.path || "(root)"; - // Use ### for directories to keep hierarchy flat and scannable lines.push(`### ${heading}/`); if (group.collapsed) { @@ -189,6 +199,17 @@ function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncat .map(([ext, count]) => `${count} ${ext}`) .join(", "); lines.push(`- *(${group.files.length} files: ${extSummary})*`); + + // Preserve any existing descriptions in a hidden comment block so + // incremental updates can recover them via parseCodebaseMap. + const descLines = group.files + .filter((f) => f.description) + .map((f) => `- \`${f.path}\` — ${f.description}`); + if (descLines.length > 0) { + lines.push(""); + } } else { for (const file of group.files) { if (file.description) { @@ -214,18 +235,17 @@ export function generateCodebaseMap( basePath: string, options?: CodebaseMapOptions, existingDescriptions?: Map, -): { content: string; fileCount: number; truncated: boolean } { +): { content: string; fileCount: number; truncated: boolean; files: string[] } { const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD; - const files = enumerateFiles(basePath, excludes, maxFiles); - const truncated = files.length >= maxFiles; + const { files, truncated } = enumerateFiles(basePath, excludes, maxFiles); const descriptions = existingDescriptions ?? new Map(); const groups = groupByDirectory(files, descriptions, collapseThreshold); const content = renderCodebaseMap(groups, files.length, truncated); - return { content, fileCount: files.length, truncated }; + return { content, fileCount: files.length, truncated, files }; } /** @@ -235,7 +255,7 @@ export function generateCodebaseMap( export function updateCodebaseMap( basePath: string, options?: CodebaseMapOptions, -): { content: string; added: number; removed: number; unchanged: number; fileCount: number } { +): { content: string; added: number; removed: number; unchanged: number; fileCount: number; truncated: boolean } { const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); // Load existing descriptions @@ -247,31 +267,29 @@ export function updateCodebaseMap( const existingFiles = new Set(existingDescriptions.keys()); - // Generate new map preserving descriptions + // Generate new map preserving descriptions — reuse the returned file list + // to avoid a second enumeration (prevents race between content and stats). const result = generateCodebaseMap(basePath, options, existingDescriptions); + const currentSet = new Set(result.files); // Count changes - const newFiles = new Set(); - const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; - const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; - const currentFiles = enumerateFiles(basePath, excludes, maxFiles); - - for (const f of currentFiles) { - if (!existingFiles.has(f)) newFiles.add(f); - } - - const currentSet = new Set(currentFiles); + let added = 0; let removed = 0; + + for (const f of result.files) { + if (!existingFiles.has(f)) added++; + } for (const f of existingFiles) { if (!currentSet.has(f)) removed++; } return { content: result.content, - added: newFiles.size, + added, removed, - unchanged: currentFiles.length - newFiles.size, + unchanged: result.files.length - added, fileCount: result.fileCount, + truncated: result.truncated, }; } @@ -314,15 +332,20 @@ export function getCodebaseMapStats(basePath: string): { return { exists: false, fileCount: 0, describedCount: 0, undescribedCount: 0, generatedAt: null }; } + // Parse total file count from the header line (accurate even for collapsed dirs) + const fileCountMatch = content.match(/Files:\s*(\d+)/); + const totalFiles = fileCountMatch ? parseInt(fileCountMatch[1], 10) : 0; + + // Use parseCodebaseMap to count described files (includes collapsed-description blocks) const descriptions = parseCodebaseMap(content); const described = [...descriptions.values()].filter((d) => d.length > 0).length; const dateMatch = content.match(/Generated: (\S+)/); return { exists: true, - fileCount: descriptions.size, + fileCount: totalFiles, describedCount: described, - undescribedCount: descriptions.size - described, + undescribedCount: totalFiles - described, generatedAt: dateMatch?.[1] ?? null, }; } diff --git a/src/resources/extensions/gsd/commands-codebase.ts b/src/resources/extensions/gsd/commands-codebase.ts index 6769c2cbf..305f09256 100644 --- a/src/resources/extensions/gsd/commands-codebase.ts +++ b/src/resources/extensions/gsd/commands-codebase.ts @@ -2,7 +2,7 @@ * GSD Command — /gsd codebase * * Generate and manage the codebase map (.gsd/CODEBASE.md). - * Subcommands: generate, update, stats + * Subcommands: generate, update, stats, help */ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; @@ -13,9 +13,16 @@ import { writeCodebaseMap, getCodebaseMapStats, readCodebaseMap, - parseCodebaseMap, } from "./codebase-generator.js"; +const USAGE = + "Usage: /gsd codebase [generate|update|stats]\n\n" + + " generate [--max-files N] — Generate or regenerate CODEBASE.md\n" + + " update — Incremental update (preserves descriptions)\n" + + " stats — Show file count, coverage, and generation time\n" + + " help — Show this help\n\n" + + "With no subcommand, shows stats if a map exists or help if not."; + export async function handleCodebase( args: string, ctx: ExtensionCommandContext, @@ -26,80 +33,132 @@ export async function handleCodebase( const sub = parts[0] ?? ""; switch (sub) { - case "": case "generate": { - const maxFilesStr = extractFlag(args, "--max-files"); - const maxFiles = maxFilesStr ? parseInt(maxFilesStr, 10) : undefined; + const maxFiles = parseMaxFiles(args, ctx); + if (maxFiles === false) return; // validation failed, message already shown - // Preserve existing descriptions on bare `/gsd codebase` - let existingDescriptions: Map | undefined; - if (sub === "") { - const existing = readCodebaseMap(basePath); - if (existing) { - existingDescriptions = parseCodebaseMap(existing); - } + const existing = readCodebaseMap(basePath); + const existingDescriptions = existing + ? (await import("./codebase-generator.js")).parseCodebaseMap(existing) + : undefined; + + const result = generateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }, existingDescriptions); + + if (result.fileCount === 0) { + ctx.ui.notify( + "Codebase map generated with 0 files.\n" + + "Is this a git repository? Run 'git ls-files' to verify.", + "warning", + ); + return; } - const result = generateCodebaseMap(basePath, { maxFiles }, existingDescriptions); const outPath = writeCodebaseMap(basePath, result.content); - ctx.ui.notify( `Codebase map generated: ${result.fileCount} files\n` + `Written to: ${outPath}` + - (result.truncated ? `\n(Truncated — increase --max-files to include more)` : ""), + (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), "success", ); return; } case "update": { - const result = updateCodebaseMap(basePath); + const existing = readCodebaseMap(basePath); + if (!existing) { + ctx.ui.notify( + "No codebase map found. Run /gsd codebase generate to create one.", + "warning", + ); + return; + } + + const maxFiles = parseMaxFiles(args, ctx); + if (maxFiles === false) return; + + const result = updateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined }); writeCodebaseMap(basePath, result.content); ctx.ui.notify( `Codebase map updated: ${result.fileCount} files\n` + - ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}`, + ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}` + + (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), "success", ); return; } case "stats": { - const stats = getCodebaseMapStats(basePath); - if (!stats.exists) { - ctx.ui.notify("No codebase map found. Run /gsd codebase to generate one.", "info"); - return; + showStats(basePath, ctx); + return; + } + + case "help": + ctx.ui.notify(USAGE, "info"); + return; + + case "": { + // Safe default: show stats if map exists, help if not + const existing = readCodebaseMap(basePath); + if (existing) { + showStats(basePath, ctx); + } else { + ctx.ui.notify(USAGE, "info"); } - - const coverage = stats.fileCount > 0 - ? Math.round((stats.describedCount / stats.fileCount) * 100) - : 0; - - ctx.ui.notify( - `Codebase Map Stats:\n` + - ` Files: ${stats.fileCount}\n` + - ` Described: ${stats.describedCount} (${coverage}%)\n` + - ` Undescribed: ${stats.undescribedCount}\n` + - ` Generated: ${stats.generatedAt ?? "unknown"}`, - "info", - ); return; } default: ctx.ui.notify( - "Usage: /gsd codebase [generate|update|stats]\n\n" + - " generate [--max-files N] — Generate or regenerate CODEBASE.md\n" + - " update — Incremental update (preserves descriptions)\n" + - " stats — Show coverage and staleness\n\n" + - "With no subcommand, generates (preserving existing descriptions).", + `Unknown subcommand "${sub}".\n\n${USAGE}`, "warning", ); } } +function showStats(basePath: string, ctx: ExtensionCommandContext): void { + const stats = getCodebaseMapStats(basePath); + if (!stats.exists) { + ctx.ui.notify("No codebase map found. Run /gsd codebase generate to create one.", "info"); + return; + } + + const coverage = stats.fileCount > 0 + ? Math.round((stats.describedCount / stats.fileCount) * 100) + : 0; + + ctx.ui.notify( + `Codebase Map Stats:\n` + + ` Files: ${stats.fileCount}\n` + + ` Described: ${stats.describedCount} (${coverage}%)\n` + + ` Undescribed: ${stats.undescribedCount}\n` + + ` Generated: ${stats.generatedAt ?? "unknown"}\n\n` + + (stats.undescribedCount > 0 + ? `Tip: Run /gsd codebase update to refresh after file changes.` + : `Coverage is complete.`), + "info", + ); +} + +/** + * Parse and validate --max-files flag. + * Returns the parsed number, undefined if flag not present, or false if invalid. + */ +function parseMaxFiles(args: string, ctx: ExtensionCommandContext): number | undefined | false { + const maxFilesStr = extractFlag(args, "--max-files"); + if (!maxFilesStr) return undefined; + + const maxFiles = parseInt(maxFilesStr, 10); + if (isNaN(maxFiles) || maxFiles < 1) { + ctx.ui.notify("--max-files must be a positive integer (e.g. --max-files 200).", "warning"); + return false; + } + return maxFiles; +} + function extractFlag(args: string, flag: string): string | undefined { - const regex = new RegExp(`${flag}\\s+(\\S+)`); + const escaped = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(`${escaped}[=\\s]+(\\S+)`); const match = args.match(regex); return match?.[1]; } diff --git a/src/resources/extensions/gsd/commands/catalog.ts b/src/resources/extensions/gsd/commands/catalog.ts index 088da9a60..c59464963 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -227,8 +227,11 @@ const NESTED_COMPLETIONS: CompletionMap = { ], codebase: [ { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, + { cmd: "generate --max-files", desc: "Generate with custom file limit (default: 500)" }, { cmd: "update", desc: "Incremental update (preserves descriptions)" }, - { cmd: "stats", desc: "Show coverage and staleness" }, + { cmd: "update --max-files", desc: "Update with custom file limit" }, + { cmd: "stats", desc: "Show file count, description coverage, and generation time" }, + { cmd: "help", desc: "Show usage and available subcommands" }, ], }; diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index a87f87e86..d6f96bc9d 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -277,7 +277,7 @@ const LEGACY_GSD_ROOT_FILES: Record = { REQUIREMENTS: "requirements.md", OVERRIDES: "overrides.md", KNOWLEDGE: "knowledge.md", - CODEBASE: "CODEBASE.md", + CODEBASE: "codebase.md", }; // ─── GSD Root Discovery ─────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/codebase-generator.test.ts b/src/resources/extensions/gsd/tests/codebase-generator.test.ts index 782170d45..c698fc65f 100644 --- a/src/resources/extensions/gsd/tests/codebase-generator.test.ts +++ b/src/resources/extensions/gsd/tests/codebase-generator.test.ts @@ -70,6 +70,38 @@ test("parseCodebaseMap: ignores non-matching lines", () => { assert.equal(map.size, 1); }); +test("parseCodebaseMap: recovers descriptions from collapsed-description comments", () => { + const content = `# Codebase Map + +### src/components/ +- *(25 files: 25 .ts)* + +`; + const map = parseCodebaseMap(content); + assert.equal(map.get("src/components/Foo.ts"), "The Foo component"); + assert.equal(map.get("src/components/Bar.ts"), "The Bar component"); + // The collapsed summary line itself should not be parsed as a file + assert.ok(!map.has("*(25 files: 25 .ts)*")); +}); + +test("parseCodebaseMap: handles corrupted/malformed input gracefully", () => { + const content = [ + "- `unclosed backtick", + "- `` — empty filename", + "- `valid.ts` — ok", + "random garbage line", + "- `a.ts` — desc with other text", + ].join("\n"); + const map = parseCodebaseMap(content); + assert.ok(map.has("valid.ts")); + assert.ok(map.has("a.ts")); + // Malformed lines should be silently skipped + assert.equal(map.size, 2); +}); + // ─── generateCodebaseMap ───────────────────────────────────────────────── test("generateCodebaseMap: generates from git ls-files", () => { @@ -86,6 +118,7 @@ test("generateCodebaseMap: generates from git ls-files", () => { assert.ok(result.content.includes("README.md")); assert.equal(result.fileCount, 3); assert.equal(result.truncated, false); + assert.equal(result.files.length, 3); } finally { cleanup(base); } @@ -105,6 +138,41 @@ test("generateCodebaseMap: excludes .gsd/ files", () => { } }); +test("generateCodebaseMap: excludes binary and lock files", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "package-lock.json"); // .json not excluded + addFile(base, "yarn.lock"); // .lock excluded + addFile(base, "assets/logo.png"); // .png excluded + + const result = generateCodebaseMap(base); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(result.content.includes("package-lock.json")); + assert.ok(!result.content.includes("yarn.lock")); + assert.ok(!result.content.includes("logo.png")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: respects custom excludePatterns", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "docs/guide.md"); + addFile(base, "docs/api.md"); + + const result = generateCodebaseMap(base, { excludePatterns: ["docs/"] }); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(!result.content.includes("guide.md")); + assert.ok(!result.content.includes("api.md")); + assert.equal(result.fileCount, 1); + } finally { + cleanup(base); + } +}); + test("generateCodebaseMap: preserves existing descriptions", () => { const base = makeTmpRepo(); try { @@ -116,7 +184,6 @@ test("generateCodebaseMap: preserves existing descriptions", () => { const result = generateCodebaseMap(base, undefined, descriptions); assert.ok(result.content.includes("`src/main.ts` — App entry point")); - // utils.ts should be present but without description assert.ok(result.content.includes("`src/utils.ts`")); } finally { cleanup(base); @@ -126,30 +193,119 @@ test("generateCodebaseMap: preserves existing descriptions", () => { test("generateCodebaseMap: collapses large directories", () => { const base = makeTmpRepo(); try { - // Create 25 files in one directory (above default threshold of 20) for (let i = 0; i < 25; i++) { addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`); } const result = generateCodebaseMap(base); - // Should be collapsed - assert.ok(result.content.includes("25 files")); - assert.ok(result.content.includes(".ts")); + // Collapsed summary should appear + assert.ok(result.content.includes("*(25 files: 25 .ts)*")); + // Individual file entries should NOT appear in main body + assert.ok(!result.content.includes("`src/components/comp00.ts`\n")); } finally { cleanup(base); } }); -test("generateCodebaseMap: respects maxFiles", () => { +test("generateCodebaseMap: respects custom collapseThreshold", () => { const base = makeTmpRepo(); try { - for (let i = 0; i < 10; i++) { - addFile(base, `file${i}.ts`); - } + for (let i = 0; i < 5; i++) addFile(base, `src/comp${i}.ts`); + // Low threshold: 5 files should collapse + const collapsed = generateCodebaseMap(base, { collapseThreshold: 3 }); + assert.ok(collapsed.content.includes("5 files")); + + // High threshold: 5 files should expand + const expanded = generateCodebaseMap(base, { collapseThreshold: 10 }); + assert.ok(expanded.content.includes("`src/comp0.ts`")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: truncated=false when file count is below maxFiles", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 4; i++) addFile(base, `file${i}.ts`); + const result = generateCodebaseMap(base, { maxFiles: 5 }); + assert.equal(result.fileCount, 4); + assert.equal(result.truncated, false); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: truncated=false when file count equals maxFiles exactly", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 5; i++) addFile(base, `file${i}.ts`); + const result = generateCodebaseMap(base, { maxFiles: 5 }); + assert.equal(result.fileCount, 5); + assert.equal(result.truncated, false); // exactly at limit — nothing was truncated + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: truncated=true when file count exceeds maxFiles", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`); const result = generateCodebaseMap(base, { maxFiles: 5 }); assert.equal(result.fileCount, 5); assert.equal(result.truncated, true); + assert.ok(result.content.includes("Truncated")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: returns empty map for non-git directory", () => { + const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + // No git init + try { + const result = generateCodebaseMap(base); + assert.equal(result.fileCount, 0); + assert.equal(result.truncated, false); + assert.ok(result.content.includes("# Codebase Map")); + assert.equal(result.files.length, 0); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: handles empty repository (no committed files)", () => { + const base = makeTmpRepo(); + try { + const result = generateCodebaseMap(base); + assert.equal(result.fileCount, 0); + assert.equal(result.truncated, false); + assert.ok(result.content.includes("Files: 0")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: collapsed directories preserve descriptions in hidden comment", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 25; i++) { + addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`); + } + + // Generate with a description for one file in the collapsed dir + const descriptions = new Map([["src/components/comp00.ts", "The first component"]]); + const result = generateCodebaseMap(base, undefined, descriptions); + + // The description should be in the hidden comment block + assert.ok(result.content.includes("