Merge pull request #2283 from jeremymcs/feat/codebase-map

feat(gsd): codebase map — structural orientation for fresh agent contexts
This commit is contained in:
Tom Boucher 2026-04-01 10:49:01 -04:00 committed by GitHub
commit 77220d1dde
7 changed files with 1042 additions and 2 deletions

View file

@ -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,

View file

@ -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 <!-- gsd:collapsed-descriptions --> comment blocks to preserve
* descriptions for files in collapsed directories across incremental updates.
*/
export function parseCodebaseMap(content: string): Map<string, string> {
const descriptions = new Map<string, string>();
let inCollapsedBlock = false;
for (const line of content.split("\n")) {
// Track collapsed-description comment blocks
if (line.trimStart().startsWith("<!-- gsd:collapsed-descriptions")) {
inCollapsedBlock = true;
continue;
}
if (inCollapsedBlock && 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<string, string>,
collapseThreshold: number,
): DirectoryGroup[] {
const dirMap = new Map<string, FileEntry[]>();
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<string, number>();
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("<!-- gsd:collapsed-descriptions");
lines.push(...descLines);
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<string, string>,
): { 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<string, string>();
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<string, string>();
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,
};
}

View file

@ -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<void> {
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];
}

View file

@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
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 <server>)" },
{ 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(

View file

@ -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;
}

View file

@ -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<GSDRootFileKey, string> = {
REQUIREMENTS: "requirements.md",
OVERRIDES: "overrides.md",
KNOWLEDGE: "knowledge.md",
CODEBASE: "codebase.md",
};
// ─── GSD Root Discovery ───────────────────────────────────────────────────────

View file

@ -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)*
<!-- gsd:collapsed-descriptions
- \`src/components/Foo.ts\` — The Foo component
- \`src/components/Bar.ts\` — The Bar component
-->
`;
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<string, string>();
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("<!-- gsd:collapsed-descriptions"));
assert.ok(result.content.includes("`src/components/comp00.ts` — The first component"));
// Re-parsing should recover the description
const recovered = parseCodebaseMap(result.content);
assert.equal(recovered.get("src/components/comp00.ts"), "The first component");
} finally {
cleanup(base);
}
});
// ─── updateCodebaseMap ───────────────────────────────────────────────────
test("updateCodebaseMap: preserves descriptions on update", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/main.ts");
addFile(base, "src/utils.ts");
const initial = generateCodebaseMap(base, undefined, new Map([["src/main.ts", "Entry point"]]));
writeCodebaseMap(base, initial.content);
addFile(base, "src/new.ts");
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);
}
});
test("updateCodebaseMap: tracks removed files", () => {
const base = makeTmpRepo();
try {
addFile(base, "src/keep.ts");
addFile(base, "src/remove.ts");
// Commit so git rm can operate
execSync("git -c user.email=t@t.com -c user.name=T commit -m init", { cwd: base, stdio: "ignore" });
const initial = generateCodebaseMap(base);
writeCodebaseMap(base, initial.content);
execSync("git rm src/remove.ts", { cwd: base, stdio: "ignore" });
const result = updateCodebaseMap(base);
assert.equal(result.removed, 1);
assert.equal(result.unchanged, 1);
assert.equal(result.fileCount, 1);
assert.ok(!result.content.includes("remove.ts"));
} finally {
cleanup(base);
}
});
test("updateCodebaseMap: propagates truncated flag", () => {
const base = makeTmpRepo();
try {
for (let i = 0; i < 10; i++) addFile(base, `file${i}.ts`);
const initial = generateCodebaseMap(base, { maxFiles: 5 });
writeCodebaseMap(base, initial.content);
const result = updateCodebaseMap(base, { maxFiles: 5 });
assert.equal(result.truncated, true);
} finally {
cleanup(base);
}
});
test("updateCodebaseMap: preserves descriptions from collapsed directories", () => {
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 in the (collapsed) components dir
const descriptions = new Map([["src/components/comp00.ts", "The first component"]]);
const initial = generateCodebaseMap(base, undefined, descriptions);
writeCodebaseMap(base, initial.content);
// Update should recover description from the hidden comment
const result = updateCodebaseMap(base);
const recovered = parseCodebaseMap(result.content);
assert.equal(recovered.get("src/components/comp00.ts"), "The first component");
} 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);
}
});
test("writeCodebaseMap: creates .gsd/ directory if missing", () => {
const base = join(tmpdir(), `gsd-codebase-test-${randomUUID()}`);
mkdirSync(base, { recursive: true });
// Intentionally do NOT pre-create .gsd/
try {
const outPath = writeCodebaseMap(base, "# Codebase Map\n");
assert.ok(existsSync(outPath));
} 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 | Files: 3 | Described: 2/3\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); // from header, not parse count
assert.equal(stats.describedCount, 2);
assert.equal(stats.undescribedCount, 1);
assert.equal(stats.generatedAt, "2026-03-23T14:00:00Z");
} finally {
cleanup(base);
}
});
test("getCodebaseMapStats: reads total file count from header for accuracy with collapsed dirs", () => {
const base = makeTmpRepo();
try {
// Simulate a map with a collapsed dir: header says 30 files but parser only sees 2
const content = [
"# Codebase Map",
"",
"Generated: 2026-03-23T14:00:00Z | Files: 30 | Described: 2/30",
"",
"### src/components/",
"- *(28 files: 28 .ts)*",
"",
"### src/",
"- `main.ts` — Entry point",
"- `utils.ts` — Utilities",
].join("\n");
writeCodebaseMap(base, content);
const stats = getCodebaseMapStats(base);
assert.equal(stats.fileCount, 30); // from header, not from parseCodebaseMap
assert.equal(stats.describedCount, 2);
assert.equal(stats.undescribedCount, 28);
} finally {
cleanup(base);
}
});