From e1900f4d45406eac56851904682683f50fd9e916 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 23 Mar 2026 14:20:35 -0500 Subject: [PATCH] =?UTF-8?q?feat(gsd):=20add=20codebase=20map=20=E2=80=94?= =?UTF-8?q?=20structural=20orientation=20for=20fresh=20agent=20contexts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /gsd codebase command that generates .gsd/CODEBASE.md — a table of contents for the project giving agents instant structural awareness. Eliminates the 10-30+ tool call "exploration tax" that fresh agent contexts pay to understand what exists and where things live. Components: - codebase-generator.ts: walks git ls-files, groups by directory, renders with one-liner descriptions, supports incremental updates that preserve existing descriptions - commands-codebase.ts: CLI handler (generate, update, stats) - system-context.ts: injects CODEBASE.md into system prompt at session start (alongside KNOWLEDGE.md) - paths.ts: adds CODEBASE to GSD_ROOT_FILES - catalog.ts: registers command with nested completions Features: - Incremental update preserves agent-written descriptions - Directories with >20 files collapsed to summary - Token budget: ~2-4K for 100 files, scaling to ~20K for 500 - Configurable excludes and max file count Closes #2229 14 unit tests, all passing. --- .../gsd/bootstrap/system-context.ts | 15 +- .../extensions/gsd/codebase-generator.ts | 328 ++++++++++++++++++ .../extensions/gsd/commands-codebase.ts | 105 ++++++ .../extensions/gsd/commands/catalog.ts | 8 +- .../extensions/gsd/commands/handlers/ops.ts | 5 + src/resources/extensions/gsd/paths.ts | 2 + .../gsd/tests/codebase-generator.test.ts | 237 +++++++++++++ 7 files changed, 698 insertions(+), 2 deletions(-) create mode 100644 src/resources/extensions/gsd/codebase-generator.ts create mode 100644 src/resources/extensions/gsd/commands-codebase.ts create mode 100644 src/resources/extensions/gsd/tests/codebase-generator.test.ts diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index 0a8255fdc..8a27e5fca 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -94,11 +94,24 @@ export async function buildBeforeAgentStartResult( } } + let codebaseBlock = ""; + 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}`; + } + } catch { + // skip + } + } + warnDeprecatedAgentInstructions(); const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); const worktreeBlock = buildWorktreeContextBlock(); - const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`; + const fullSystem = `${event.systemPrompt}\n\n[SYSTEM CONTEXT — GSD]\n\n${systemContent}${preferenceBlock}${knowledgeBlock}${codebaseBlock}${memoryBlock}${newSkillsBlock}${worktreeBlock}`; stopContextTimer({ systemPromptSize: fullSystem.length, diff --git a/src/resources/extensions/gsd/codebase-generator.ts b/src/resources/extensions/gsd/codebase-generator.ts new file mode 100644 index 000000000..1d4c39cb3 --- /dev/null +++ b/src/resources/extensions/gsd/codebase-generator.ts @@ -0,0 +1,328 @@ +/** + * GSD Codebase Map Generator + * + * Produces .gsd/CODEBASE.md — a structural table of contents for the project. + * Gives fresh agent contexts instant orientation without filesystem exploration. + * + * Generation: walk `git ls-files`, group by directory, output with descriptions. + * Maintenance: agent updates descriptions as it works; incremental update preserves them. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join, dirname, extname } from "node:path"; + +import { execSync } from "node:child_process"; +import { gsdRoot } from "./paths.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface CodebaseMapOptions { + excludePatterns?: string[]; + maxFiles?: number; + collapseThreshold?: number; +} + +interface FileEntry { + path: string; + description: string; +} + +interface DirectoryGroup { + path: string; + files: FileEntry[]; + collapsed: boolean; +} + +// ─── Defaults ──────────────────────────────────────────────────────────────── + +const DEFAULT_EXCLUDES = [ + ".gsd/", + ".planning/", + ".git/", + "node_modules/", + "dist/", + "build/", + ".next/", + "coverage/", + "__pycache__/", + ".venv/", + "vendor/", +]; + +const DEFAULT_MAX_FILES = 500; +const DEFAULT_COLLAPSE_THRESHOLD = 20; + +// ─── Parsing ───────────────────────────────────────────────────────────────── + +/** + * Parse an existing CODEBASE.md to extract file → description mappings. + */ +export function parseCodebaseMap(content: string): Map { + const descriptions = new Map(); + for (const line of content.split("\n")) { + // Match: - `path/to/file.ts` — Description here + const match = line.match(/^- `(.+?)` — (.+)$/); + if (match) { + descriptions.set(match[1], match[2]); + } + // Match: - `path/to/file.ts` (no description) + const bareMatch = line.match(/^- `(.+?)`\s*$/); + if (bareMatch) { + descriptions.set(bareMatch[1], ""); + } + } + return descriptions; +} + +// ─── File Enumeration ──────────────────────────────────────────────────────── + +function shouldExclude(filePath: string, excludes: string[]): boolean { + for (const pattern of excludes) { + if (pattern.endsWith("/")) { + if (filePath.startsWith(pattern) || filePath.includes(`/${pattern}`)) return true; + } else if (filePath === pattern || filePath.endsWith(`/${pattern}`)) { + return true; + } + } + // Skip binary/lock files + const ext = extname(filePath).toLowerCase(); + if ([".lock", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".svg"].includes(ext)) { + return true; + } + return false; +} + +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 { + return []; + } +} + +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; +} + +// ─── Grouping ──────────────────────────────────────────────────────────────── + +function groupByDirectory( + files: string[], + descriptions: Map, + collapseThreshold: number, +): DirectoryGroup[] { + const dirMap = new Map(); + + for (const file of files) { + const dir = dirname(file); + const dirKey = dir === "." ? "" : dir; + if (!dirMap.has(dirKey)) { + dirMap.set(dirKey, []); + } + dirMap.get(dirKey)!.push({ + path: file, + description: descriptions.get(file) ?? "", + }); + } + + const groups: DirectoryGroup[] = []; + const sortedDirs = [...dirMap.keys()].sort(); + + for (const dir of sortedDirs) { + const files = dirMap.get(dir)!; + files.sort((a, b) => a.path.localeCompare(b.path)); + + groups.push({ + path: dir, + files, + collapsed: files.length > collapseThreshold, + }); + } + + return groups; +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string { + const lines: string[] = []; + const now = new Date().toISOString().slice(0, 19) + "Z"; + const described = groups.reduce((sum, g) => sum + g.files.filter((f) => f.description).length, 0); + + lines.push("# Codebase Map"); + lines.push(""); + lines.push(`Generated: ${now} | Files: ${totalFiles} | Described: ${described}/${totalFiles}`); + if (truncated) { + lines.push(`Note: Truncated to first ${totalFiles} files. Run with higher --max-files to include all.`); + } + lines.push(""); + + 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) { + // Summarize collapsed directories + const extensions = new Map(); + for (const f of group.files) { + const ext = extname(f.path) || "(no ext)"; + extensions.set(ext, (extensions.get(ext) ?? 0) + 1); + } + const extSummary = [...extensions.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([ext, count]) => `${count} ${ext}`) + .join(", "); + lines.push(`- *(${group.files.length} files: ${extSummary})*`); + } else { + for (const file of group.files) { + if (file.description) { + lines.push(`- \`${file.path}\` — ${file.description}`); + } else { + lines.push(`- \`${file.path}\``); + } + } + } + lines.push(""); + } + + return lines.join("\n"); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Generate a fresh CODEBASE.md from scratch. + * Preserves existing descriptions if `existingDescriptions` is provided. + */ +export function generateCodebaseMap( + basePath: string, + options?: CodebaseMapOptions, + existingDescriptions?: Map, +): { content: string; fileCount: number; truncated: boolean } { + 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 descriptions = existingDescriptions ?? new Map(); + const groups = groupByDirectory(files, descriptions, collapseThreshold); + const content = renderCodebaseMap(groups, files.length, truncated); + + return { content, fileCount: files.length, truncated }; +} + +/** + * Incremental update: re-scan files, preserve existing descriptions, + * add new files, remove deleted files. + */ +export function updateCodebaseMap( + basePath: string, + options?: CodebaseMapOptions, +): { content: string; added: number; removed: number; unchanged: number; fileCount: number } { + const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); + + // Load existing descriptions + let existingDescriptions = new Map(); + if (existsSync(codebasePath)) { + const existing = readFileSync(codebasePath, "utf-8"); + existingDescriptions = parseCodebaseMap(existing); + } + + const existingFiles = new Set(existingDescriptions.keys()); + + // Generate new map preserving descriptions + const result = generateCodebaseMap(basePath, options, existingDescriptions); + + // 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 removed = 0; + for (const f of existingFiles) { + if (!currentSet.has(f)) removed++; + } + + return { + content: result.content, + added: newFiles.size, + removed, + unchanged: currentFiles.length - newFiles.size, + fileCount: result.fileCount, + }; +} + +/** + * Write CODEBASE.md to .gsd/ directory. + */ +export function writeCodebaseMap(basePath: string, content: string): string { + const root = gsdRoot(basePath); + mkdirSync(root, { recursive: true }); + const outPath = join(root, "CODEBASE.md"); + writeFileSync(outPath, content, "utf-8"); + return outPath; +} + +/** + * Read existing CODEBASE.md, or return null if it doesn't exist. + */ +export function readCodebaseMap(basePath: string): string | null { + const codebasePath = join(gsdRoot(basePath), "CODEBASE.md"); + if (!existsSync(codebasePath)) return null; + try { + return readFileSync(codebasePath, "utf-8"); + } catch { + return null; + } +} + +/** + * Get stats about the codebase map. + */ +export function getCodebaseMapStats(basePath: string): { + exists: boolean; + fileCount: number; + describedCount: number; + undescribedCount: number; + generatedAt: string | null; +} { + const content = readCodebaseMap(basePath); + if (!content) { + return { exists: false, fileCount: 0, describedCount: 0, undescribedCount: 0, generatedAt: null }; + } + + 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, + describedCount: described, + undescribedCount: descriptions.size - described, + generatedAt: dateMatch?.[1] ?? null, + }; +} diff --git a/src/resources/extensions/gsd/commands-codebase.ts b/src/resources/extensions/gsd/commands-codebase.ts new file mode 100644 index 000000000..6769c2cbf --- /dev/null +++ b/src/resources/extensions/gsd/commands-codebase.ts @@ -0,0 +1,105 @@ +/** + * GSD Command — /gsd codebase + * + * Generate and manage the codebase map (.gsd/CODEBASE.md). + * Subcommands: generate, update, stats + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { + generateCodebaseMap, + updateCodebaseMap, + writeCodebaseMap, + getCodebaseMapStats, + readCodebaseMap, + parseCodebaseMap, +} from "./codebase-generator.js"; + +export async function handleCodebase( + args: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise { + const basePath = process.cwd(); + const parts = args.trim().split(/\s+/); + const sub = parts[0] ?? ""; + + switch (sub) { + case "": + case "generate": { + const maxFilesStr = extractFlag(args, "--max-files"); + const maxFiles = maxFilesStr ? parseInt(maxFilesStr, 10) : undefined; + + // Preserve existing descriptions on bare `/gsd codebase` + let existingDescriptions: Map | undefined; + if (sub === "") { + const existing = readCodebaseMap(basePath); + if (existing) { + existingDescriptions = parseCodebaseMap(existing); + } + } + + 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)` : ""), + "success", + ); + return; + } + + case "update": { + const result = updateCodebaseMap(basePath); + writeCodebaseMap(basePath, result.content); + + ctx.ui.notify( + `Codebase map updated: ${result.fileCount} files\n` + + ` Added: ${result.added} | Removed: ${result.removed} | Unchanged: ${result.unchanged}`, + "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; + } + + 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).", + "warning", + ); + } +} + +function extractFlag(args: string, flag: string): string | undefined { + const regex = new RegExp(`${flag}\\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 8045c85be..088da9a60 100644 --- a/src/resources/extensions/gsd/commands/catalog.ts +++ b/src/resources/extensions/gsd/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const GSD_COMMAND_DESCRIPTION = - "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink"; + "GSD — Get Shit Done: /gsd help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, @@ -71,6 +71,7 @@ export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "mcp", desc: "MCP server status and connectivity check (status, check )" }, { cmd: "rethink", desc: "Conversational project reorganization — reorder, park, discard, add milestones" }, { cmd: "workflow", desc: "Custom workflow lifecycle (new, run, list, validate, pause, resume)" }, + { cmd: "codebase", desc: "Generate and manage codebase map (.gsd/CODEBASE.md)" }, ]; const NESTED_COMPLETIONS: CompletionMap = { @@ -224,6 +225,11 @@ const NESTED_COMPLETIONS: CompletionMap = { { cmd: "pause", desc: "Pause custom workflow auto-mode" }, { cmd: "resume", desc: "Resume paused custom workflow auto-mode" }, ], + codebase: [ + { cmd: "generate", desc: "Generate or regenerate CODEBASE.md" }, + { cmd: "update", desc: "Incremental update (preserves descriptions)" }, + { cmd: "stats", desc: "Show coverage and staleness" }, + ], }; function filterOptions( diff --git a/src/resources/extensions/gsd/commands/handlers/ops.ts b/src/resources/extensions/gsd/commands/handlers/ops.ts index a1996dfef..4ebfad1bf 100644 --- a/src/resources/extensions/gsd/commands/handlers/ops.ts +++ b/src/resources/extensions/gsd/commands/handlers/ops.ts @@ -206,5 +206,10 @@ Examples: await handleRethink(trimmed, ctx, pi); return true; } + if (trimmed === "codebase" || trimmed.startsWith("codebase ")) { + const { handleCodebase } = await import("../../commands-codebase.js"); + await handleCodebase(trimmed.replace(/^codebase\s*/, "").trim(), ctx, pi); + return true; + } return false; } diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index ccd3c59f6..a87f87e86 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -264,6 +264,7 @@ export const GSD_ROOT_FILES = { REQUIREMENTS: "REQUIREMENTS.md", OVERRIDES: "OVERRIDES.md", KNOWLEDGE: "KNOWLEDGE.md", + CODEBASE: "CODEBASE.md", } as const; export type GSDRootFileKey = keyof typeof GSD_ROOT_FILES; @@ -276,6 +277,7 @@ const LEGACY_GSD_ROOT_FILES: Record = { REQUIREMENTS: "requirements.md", OVERRIDES: "overrides.md", KNOWLEDGE: "knowledge.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 new file mode 100644 index 000000000..782170d45 --- /dev/null +++ b/src/resources/extensions/gsd/tests/codebase-generator.test.ts @@ -0,0 +1,237 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; + +import { + parseCodebaseMap, + generateCodebaseMap, + updateCodebaseMap, + writeCodebaseMap, + readCodebaseMap, + getCodebaseMapStats, +} from "../codebase-generator.ts"; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function makeTmpRepo(): string { + const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + execSync("git init", { cwd: base, stdio: "ignore" }); + return base; +} + +function addFile(base: string, path: string, content = ""): void { + const fullPath = join(base, path); + mkdirSync(join(fullPath, ".."), { recursive: true }); + writeFileSync(fullPath, content || `// ${path}\n`, "utf-8"); + execSync(`git add "${path}"`, { cwd: base, stdio: "ignore" }); +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── parseCodebaseMap ──────────────────────────────────────────────────── + +test("parseCodebaseMap: parses file with description", () => { + const content = `# Codebase Map + +### src/ +- \`main.ts\` — Application entry point +- \`utils.ts\` — Shared utilities +`; + + const map = parseCodebaseMap(content); + assert.equal(map.size, 2); + assert.equal(map.get("main.ts"), "Application entry point"); + assert.equal(map.get("utils.ts"), "Shared utilities"); +}); + +test("parseCodebaseMap: parses file without description", () => { + const content = `- \`config.ts\`\n- \`index.ts\` — Entry\n`; + const map = parseCodebaseMap(content); + assert.equal(map.size, 2); + assert.equal(map.get("config.ts"), ""); + assert.equal(map.get("index.ts"), "Entry"); +}); + +test("parseCodebaseMap: empty content returns empty map", () => { + const map = parseCodebaseMap(""); + assert.equal(map.size, 0); +}); + +test("parseCodebaseMap: ignores non-matching lines", () => { + const content = `# Codebase Map\n\nGenerated: 2026-03-23\n\n### src/\n- \`file.ts\` — desc\n`; + const map = parseCodebaseMap(content); + assert.equal(map.size, 1); +}); + +// ─── generateCodebaseMap ───────────────────────────────────────────────── + +test("generateCodebaseMap: generates from git ls-files", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "src/utils.ts"); + addFile(base, "README.md"); + + const result = generateCodebaseMap(base); + assert.ok(result.content.includes("# Codebase Map")); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(result.content.includes("`src/utils.ts`")); + assert.ok(result.content.includes("README.md")); + assert.equal(result.fileCount, 3); + assert.equal(result.truncated, false); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: excludes .gsd/ files", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, ".gsd/PROJECT.md"); + + const result = generateCodebaseMap(base); + assert.ok(result.content.includes("`src/main.ts`")); + assert.ok(!result.content.includes("PROJECT.md")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: preserves existing descriptions", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "src/utils.ts"); + + const descriptions = new Map(); + descriptions.set("src/main.ts", "App entry point"); + + 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); + } +}); + +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")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: respects 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); + } finally { + cleanup(base); + } +}); + +// ─── updateCodebaseMap ─────────────────────────────────────────────────── + +test("updateCodebaseMap: preserves descriptions on update", () => { + const base = makeTmpRepo(); + try { + addFile(base, "src/main.ts"); + addFile(base, "src/utils.ts"); + + // Generate initial map with a description + const initial = generateCodebaseMap(base, undefined, new Map([["src/main.ts", "Entry point"]])); + writeCodebaseMap(base, initial.content); + + // Add a new file + addFile(base, "src/new.ts"); + + // Update should preserve the description + const result = updateCodebaseMap(base); + assert.ok(result.content.includes("`src/main.ts` — Entry point")); + assert.equal(result.added, 1); + assert.equal(result.fileCount, 3); + } finally { + cleanup(base); + } +}); + +// ─── writeCodebaseMap / readCodebaseMap ────────────────────────────────── + +test("writeCodebaseMap + readCodebaseMap roundtrip", () => { + const base = makeTmpRepo(); + try { + const content = "# Codebase Map\n\n- `test.ts` — A test file\n"; + const outPath = writeCodebaseMap(base, content); + assert.ok(existsSync(outPath)); + + const read = readCodebaseMap(base); + assert.equal(read, content); + } finally { + cleanup(base); + } +}); + +test("readCodebaseMap: returns null when file missing", () => { + const base = makeTmpRepo(); + try { + const result = readCodebaseMap(base); + assert.equal(result, null); + } finally { + cleanup(base); + } +}); + +// ─── getCodebaseMapStats ───────────────────────────────────────────────── + +test("getCodebaseMapStats: no map returns exists=false", () => { + const base = makeTmpRepo(); + try { + const stats = getCodebaseMapStats(base); + assert.equal(stats.exists, false); + assert.equal(stats.fileCount, 0); + } finally { + cleanup(base); + } +}); + +test("getCodebaseMapStats: reports coverage", () => { + const base = makeTmpRepo(); + try { + const content = `# Codebase Map\n\nGenerated: 2026-03-23T14:00:00Z\n\n- \`a.ts\` — Has desc\n- \`b.ts\`\n- \`c.ts\` — Also has\n`; + writeCodebaseMap(base, content); + + const stats = getCodebaseMapStats(base); + assert.equal(stats.exists, true); + assert.equal(stats.fileCount, 3); + assert.equal(stats.describedCount, 2); + assert.equal(stats.undescribedCount, 1); + assert.equal(stats.generatedAt, "2026-03-23T14:00:00Z"); + } finally { + cleanup(base); + } +});