From e1900f4d45406eac56851904682683f50fd9e916 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 23 Mar 2026 14:20:35 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(gsd):=20add=20codebase=20map=20?= =?UTF-8?q?=E2=80=94=20structural=20orientation=20for=20fresh=20agent=20co?= =?UTF-8?q?ntexts?= 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); + } +}); From 97f4d5d2593827d92072641ed38f35ca0c0f4475 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 23 Mar 2026 16:51:31 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(gsd):=20harden=20codebase-map=20?= =?UTF-8?q?=E2=80=94=20bug=20fixes,=20UX=20polish,=20and=20expanded=20test?= =?UTF-8?q?s?= 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("