fix(gsd): harden codebase-map — bug fixes, UX polish, and expanded tests
Generator (codebase-generator.ts): - Fix truncation off-by-one: use filtered.length > maxFiles (not >=) - Fix collapsed-directory round-trip: emit <!-- gsd:collapsed-descriptions --> 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 <noreply@anthropic.com>
This commit is contained in:
parent
e1900f4d45
commit
97f4d5d259
6 changed files with 449 additions and 105 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,19 +56,37 @@ const DEFAULT_COLLAPSE_THRESHOLD = 20;
|
|||
|
||||
/**
|
||||
* 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)
|
||||
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("<!-- gsd:collapsed-descriptions");
|
||||
lines.push(...descLines);
|
||||
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<string, string>,
|
||||
): { 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<string, string>();
|
||||
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<string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> | 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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
|
|||
REQUIREMENTS: "requirements.md",
|
||||
OVERRIDES: "overrides.md",
|
||||
KNOWLEDGE: "knowledge.md",
|
||||
CODEBASE: "CODEBASE.md",
|
||||
CODEBASE: "codebase.md",
|
||||
};
|
||||
|
||||
// ─── GSD Root Discovery ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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)*
|
||||
<!-- 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", () => {
|
||||
|
|
@ -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("<!-- 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);
|
||||
}
|
||||
|
|
@ -163,14 +319,11 @@ test("updateCodebaseMap: preserves descriptions on update", () => {
|
|||
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);
|
||||
|
|
@ -180,6 +333,65 @@ test("updateCodebaseMap: preserves descriptions on update", () => {
|
|||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
|
@ -206,6 +418,18 @@ test("readCodebaseMap: returns null when file missing", () => {
|
|||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
|
@ -222,12 +446,12 @@ test("getCodebaseMapStats: no map returns exists=false", () => {
|
|||
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`;
|
||||
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);
|
||||
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");
|
||||
|
|
@ -235,3 +459,30 @@ test("getCodebaseMapStats: reports coverage", () => {
|
|||
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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue