Add configurable codebase map options via preferences.md (exclude_patterns, max_files, collapse_threshold), expose --collapse-threshold as a CLI flag, and auto-generate CODEBASE.md during project init for instant agent orientation. Closes #3509
196 lines
6.2 KiB
TypeScript
196 lines
6.2 KiB
TypeScript
/**
|
|
* 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";
|
|
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
import type { CodebaseMapOptions } from "./codebase-generator.js";
|
|
|
|
const USAGE =
|
|
"Usage: /gsd codebase [generate|update|stats]\n\n" +
|
|
" generate [--max-files N] [--collapse-threshold N] — Generate or regenerate CODEBASE.md\n" +
|
|
" update [--max-files N] [--collapse-threshold N] — 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.\n\n" +
|
|
"Configure defaults via preferences.md:\n" +
|
|
" codebase:\n" +
|
|
" exclude_patterns: [\"docs/\", \"fixtures/\"]\n" +
|
|
" max_files: 1000\n" +
|
|
" collapse_threshold: 15";
|
|
|
|
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 options = resolveCodebaseOptions(args, ctx);
|
|
if (options === 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, options, 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 options = resolveCodebaseOptions(args, ctx);
|
|
if (options === false) return;
|
|
|
|
const result = updateCodebaseMap(basePath, options);
|
|
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",
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve codebase map options by merging preferences with CLI flags.
|
|
* CLI flags override preferences; preferences override built-in defaults.
|
|
* Returns false if validation failed (error already shown to user).
|
|
*/
|
|
function resolveCodebaseOptions(args: string, ctx: ExtensionCommandContext): CodebaseMapOptions | false {
|
|
// Load preferences defaults
|
|
const prefs = loadEffectiveGSDPreferences()?.preferences?.codebase;
|
|
|
|
// Parse CLI flags
|
|
const maxFilesStr = extractFlag(args, "--max-files");
|
|
const collapseStr = extractFlag(args, "--collapse-threshold");
|
|
|
|
// Validate --max-files
|
|
let maxFiles: number | undefined;
|
|
if (maxFilesStr) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Validate --collapse-threshold
|
|
let collapseThreshold: number | undefined;
|
|
if (collapseStr) {
|
|
collapseThreshold = parseInt(collapseStr, 10);
|
|
if (isNaN(collapseThreshold) || collapseThreshold < 1) {
|
|
ctx.ui.notify("--collapse-threshold must be a positive integer (e.g. --collapse-threshold 15).", "warning");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
// CLI flags override preferences
|
|
maxFiles: maxFiles ?? prefs?.max_files,
|
|
collapseThreshold: collapseThreshold ?? prefs?.collapse_threshold,
|
|
excludePatterns: prefs?.exclude_patterns,
|
|
};
|
|
}
|
|
|
|
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];
|
|
}
|