diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index d2cded710..94930375a 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -95,6 +95,27 @@ export async function buildBeforeAgentStartResult( } } + let codebaseBlock = ""; + const codebasePath = resolveGsdRootFile(process.cwd(), "CODEBASE"); + if (existsSync(codebasePath)) { + try { + 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 + } + } + warnDeprecatedAgentInstructions(); const injection = await buildGuidedExecuteContextInjection(event.prompt, process.cwd()); @@ -103,7 +124,7 @@ export async function buildBeforeAgentStartResult( const forensicsInjection = !injection ? buildForensicsContextInjection(process.cwd()) : null; 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..6fe558abb --- /dev/null +++ b/src/resources/extensions/gsd/codebase-generator.ts @@ -0,0 +1,351 @@ +/** + * 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. + * 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) — only outside collapsed blocks + if (!inCollapsedBlock) { + 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 { + const result = execSync("git ls-files", { cwd: basePath, encoding: "utf-8", timeout: 10000 }); + return result.split("\n").filter(Boolean); + } catch { + return []; + } +} + +/** + * 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 ──────────────────────────────────────────────────────────────── + +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 dirFiles = dirMap.get(dir)!; + dirFiles.sort((a, b) => a.path.localeCompare(b.path)); + + groups.push({ + path: dir, + files: dirFiles, + collapsed: dirFiles.length > collapseThreshold, + }); + } + + return groups; +} + +// ─── Rendering ─────────────────────────────────────────────────────────────── + +function renderCodebaseMap(groups: DirectoryGroup[], totalFiles: number, truncated: boolean): string { + const lines: string[] = []; + 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"); + 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)"; + 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})*`); + + // 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) { + 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; files: string[] } { + const excludes = [...DEFAULT_EXCLUDES, ...(options?.excludePatterns ?? [])]; + const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; + const collapseThreshold = options?.collapseThreshold ?? DEFAULT_COLLAPSE_THRESHOLD; + + 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, files }; +} + +/** + * 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; truncated: boolean } { + 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 — 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 + 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, + removed, + unchanged: result.files.length - added, + fileCount: result.fileCount, + truncated: result.truncated, + }; +} + +/** + * 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 }; + } + + // 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: totalFiles, + describedCount: 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 new file mode 100644 index 000000000..305f09256 --- /dev/null +++ b/src/resources/extensions/gsd/commands-codebase.ts @@ -0,0 +1,164 @@ +/** + * GSD Command — /gsd codebase + * + * Generate and manage the codebase map (.gsd/CODEBASE.md). + * Subcommands: generate, update, stats, help + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent"; + +import { + generateCodebaseMap, + updateCodebaseMap, + writeCodebaseMap, + getCodebaseMapStats, + readCodebaseMap, +} 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, + _pi: ExtensionAPI, +): Promise { + const basePath = process.cwd(); + const parts = args.trim().split(/\s+/); + const sub = parts[0] ?? ""; + + switch (sub) { + case "generate": { + const maxFiles = parseMaxFiles(args, ctx); + if (maxFiles === false) return; // validation failed, message already shown + + 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 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 all files` : ""), + "success", + ); + return; + } + + case "update": { + 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}` + + (result.truncated ? `\n⚠ Truncated — increase --max-files to include all files` : ""), + "success", + ); + return; + } + + case "stats": { + 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"); + } + return; + } + + default: + ctx.ui.notify( + `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 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 7d688d41c..02882a07c 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 = { @@ -225,6 +226,14 @@ 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: "generate --max-files", desc: "Generate with custom file limit (default: 500)" }, + { cmd: "update", desc: "Incremental update (preserves descriptions)" }, + { 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" }, + ], }; 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 1cdfc0334..8beaefdaa 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..c698fc65f --- /dev/null +++ b/src/resources/extensions/gsd/tests/codebase-generator.test.ts @@ -0,0 +1,488 @@ +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); +}); + +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", () => { + 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); + assert.equal(result.files.length, 3); + } 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: 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 { + 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")); + assert.ok(result.content.includes("`src/utils.ts`")); + } finally { + cleanup(base); + } +}); + +test("generateCodebaseMap: collapses large directories", () => { + const base = makeTmpRepo(); + try { + for (let i = 0; i < 25; i++) { + addFile(base, `src/components/comp${String(i).padStart(2, "0")}.ts`); + } + + const result = generateCodebaseMap(base); + // 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 custom collapseThreshold", () => { + const base = makeTmpRepo(); + try { + 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("