feat(gsd): add codebase map — structural orientation for fresh agent contexts
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.
This commit is contained in:
parent
51519e6cda
commit
e1900f4d45
7 changed files with 698 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
328
src/resources/extensions/gsd/codebase-generator.ts
Normal file
328
src/resources/extensions/gsd/codebase-generator.ts
Normal file
|
|
@ -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<string, string> {
|
||||
const descriptions = new Map<string, string>();
|
||||
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<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 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<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})*`);
|
||||
} 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 } {
|
||||
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<string, string>();
|
||||
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<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
|
||||
const result = generateCodebaseMap(basePath, options, existingDescriptions);
|
||||
|
||||
// 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 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,
|
||||
};
|
||||
}
|
||||
105
src/resources/extensions/gsd/commands-codebase.ts
Normal file
105
src/resources/extensions/gsd/commands-codebase.ts
Normal file
|
|
@ -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<void> {
|
||||
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<string, string> | 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];
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
237
src/resources/extensions/gsd/tests/codebase-generator.test.ts
Normal file
237
src/resources/extensions/gsd/tests/codebase-generator.test.ts
Normal file
|
|
@ -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<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"));
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue