Merge pull request #3510 from jeremymcs/feat/codebase-map-enhancements
feat(gsd): enhance /gsd codebase with preferences, --collapse-threshold, and auto-init
This commit is contained in:
commit
1c4219ee2e
8 changed files with 262 additions and 21 deletions
|
|
@ -14,14 +14,21 @@ import {
|
|||
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] — 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.";
|
||||
" 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,
|
||||
|
|
@ -34,15 +41,15 @@ export async function handleCodebase(
|
|||
|
||||
switch (sub) {
|
||||
case "generate": {
|
||||
const maxFiles = parseMaxFiles(args, ctx);
|
||||
if (maxFiles === false) return; // validation failed, message already shown
|
||||
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, { maxFiles: maxFiles ?? undefined }, existingDescriptions);
|
||||
const result = generateCodebaseMap(basePath, options, existingDescriptions);
|
||||
|
||||
if (result.fileCount === 0) {
|
||||
ctx.ui.notify(
|
||||
|
|
@ -73,10 +80,10 @@ export async function handleCodebase(
|
|||
return;
|
||||
}
|
||||
|
||||
const maxFiles = parseMaxFiles(args, ctx);
|
||||
if (maxFiles === false) return;
|
||||
const options = resolveCodebaseOptions(args, ctx);
|
||||
if (options === false) return;
|
||||
|
||||
const result = updateCodebaseMap(basePath, { maxFiles: maxFiles ?? undefined });
|
||||
const result = updateCodebaseMap(basePath, options);
|
||||
writeCodebaseMap(basePath, result.content);
|
||||
|
||||
ctx.ui.notify(
|
||||
|
|
@ -141,19 +148,44 @@ function showStats(basePath: string, ctx: ExtensionCommandContext): void {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse and validate --max-files flag.
|
||||
* Returns the parsed number, undefined if flag not present, or false if invalid.
|
||||
* 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 parseMaxFiles(args: string, ctx: ExtensionCommandContext): number | undefined | false {
|
||||
const maxFilesStr = extractFlag(args, "--max-files");
|
||||
if (!maxFilesStr) return undefined;
|
||||
function resolveCodebaseOptions(args: string, ctx: ExtensionCommandContext): CodebaseMapOptions | false {
|
||||
// Load preferences defaults
|
||||
const prefs = loadEffectiveGSDPreferences()?.preferences?.codebase;
|
||||
|
||||
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;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
return maxFiles;
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -229,8 +229,10 @@ 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: "generate --collapse-threshold", desc: "Generate with custom collapse threshold (default: 20)" },
|
||||
{ cmd: "update", desc: "Incremental update (preserves descriptions)" },
|
||||
{ cmd: "update --max-files", desc: "Update with custom file limit" },
|
||||
{ cmd: "update --collapse-threshold", desc: "Update with custom collapse threshold" },
|
||||
{ cmd: "stats", desc: "Show file count, description coverage, and generation time" },
|
||||
{ cmd: "help", desc: "Show usage and available subcommands" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { gsdRoot } from "./paths.js";
|
|||
import { assertSafeDirectory } from "./validate-directory.js";
|
||||
import type { ProjectDetection, ProjectSignals } from "./detection.js";
|
||||
import { runSkillInstallStep } from "./skill-catalog.js";
|
||||
import { generateCodebaseMap, writeCodebaseMap } from "./codebase-generator.js";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -238,6 +239,17 @@ export async function showProjectInit(
|
|||
ensureGitignore(basePath);
|
||||
untrackRuntimeFiles(basePath);
|
||||
|
||||
// Auto-generate codebase map for instant agent orientation
|
||||
try {
|
||||
const result = generateCodebaseMap(basePath);
|
||||
if (result.fileCount > 0) {
|
||||
writeCodebaseMap(basePath, result.content);
|
||||
ctx.ui.notify(`Codebase map generated: ${result.fileCount} files`, "info");
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — codebase map generation failure should never block project init
|
||||
}
|
||||
|
||||
ctx.ui.notify("GSD initialized. Starting your first milestone...", "info");
|
||||
|
||||
return { completed: true, bootstrapped: true };
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"stale_commit_threshold_minutes",
|
||||
"context_management",
|
||||
"experimental",
|
||||
"codebase",
|
||||
]);
|
||||
|
||||
/** Canonical list of all dispatch unit types. */
|
||||
|
|
@ -211,6 +212,16 @@ export interface ExperimentalPreferences {
|
|||
rtk?: boolean;
|
||||
}
|
||||
|
||||
/** Configuration for the codebase map generator (/gsd codebase). */
|
||||
export interface CodebaseMapPreferences {
|
||||
/** Additional directory/file patterns to exclude (e.g. ["docs/", "fixtures/"]). Merged with built-in defaults. */
|
||||
exclude_patterns?: string[];
|
||||
/** Max files to include in the map. Default: 500. */
|
||||
max_files?: number;
|
||||
/** Files-per-directory threshold before collapsing to a summary line. Default: 20. */
|
||||
collapse_threshold?: number;
|
||||
}
|
||||
|
||||
export interface GSDPreferences {
|
||||
version?: number;
|
||||
mode?: WorkflowMode;
|
||||
|
|
@ -275,6 +286,8 @@ export interface GSDPreferences {
|
|||
* See the preferences reference for details on each feature.
|
||||
*/
|
||||
experimental?: ExperimentalPreferences;
|
||||
/** Configuration for the codebase map generator (/gsd codebase). */
|
||||
codebase?: CodebaseMapPreferences;
|
||||
}
|
||||
|
||||
export interface LoadedGSDPreferences {
|
||||
|
|
|
|||
|
|
@ -857,5 +857,50 @@ export function validatePreferences(preferences: GSDPreferences): {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Codebase Map ──────────────────────────────────────────────────
|
||||
if (preferences.codebase !== undefined) {
|
||||
if (typeof preferences.codebase === "object" && preferences.codebase !== null) {
|
||||
const cb = preferences.codebase as Record<string, unknown>;
|
||||
const validCb: import("./preferences-types.js").CodebaseMapPreferences = {};
|
||||
|
||||
if (cb.exclude_patterns !== undefined) {
|
||||
if (Array.isArray(cb.exclude_patterns) && cb.exclude_patterns.every((p: unknown) => typeof p === "string")) {
|
||||
validCb.exclude_patterns = cb.exclude_patterns as string[];
|
||||
} else {
|
||||
errors.push("codebase.exclude_patterns must be an array of strings");
|
||||
}
|
||||
}
|
||||
if (cb.max_files !== undefined) {
|
||||
const mf = typeof cb.max_files === "number" ? cb.max_files : Number(cb.max_files);
|
||||
if (Number.isFinite(mf) && mf >= 1) {
|
||||
validCb.max_files = Math.floor(mf);
|
||||
} else {
|
||||
errors.push("codebase.max_files must be a positive integer");
|
||||
}
|
||||
}
|
||||
if (cb.collapse_threshold !== undefined) {
|
||||
const ct = typeof cb.collapse_threshold === "number" ? cb.collapse_threshold : Number(cb.collapse_threshold);
|
||||
if (Number.isFinite(ct) && ct >= 1) {
|
||||
validCb.collapse_threshold = Math.floor(ct);
|
||||
} else {
|
||||
errors.push("codebase.collapse_threshold must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
const knownCbKeys = new Set(["exclude_patterns", "max_files", "collapse_threshold"]);
|
||||
for (const key of Object.keys(cb)) {
|
||||
if (!knownCbKeys.has(key)) {
|
||||
warnings.push(`unknown codebase key "${key}" — ignored`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(validCb).length > 0) {
|
||||
validated.codebase = validCb;
|
||||
}
|
||||
} else {
|
||||
errors.push("codebase must be an object");
|
||||
}
|
||||
}
|
||||
|
||||
return { preferences: validated, errors, warnings };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export type {
|
|||
AutoSupervisorConfig,
|
||||
RemoteQuestionsConfig,
|
||||
CmuxPreferences,
|
||||
CodebaseMapPreferences,
|
||||
GSDPreferences,
|
||||
LoadedGSDPreferences,
|
||||
SkillResolution,
|
||||
|
|
@ -372,6 +373,17 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
|
|||
service_tier: override.service_tier ?? base.service_tier,
|
||||
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
|
||||
show_token_cost: override.show_token_cost ?? base.show_token_cost,
|
||||
codebase: (base.codebase || override.codebase)
|
||||
? {
|
||||
...(base.codebase ?? {}),
|
||||
...(override.codebase ?? {}),
|
||||
// Merge exclude_patterns arrays rather than overriding
|
||||
exclude_patterns: [
|
||||
...((base.codebase?.exclude_patterns) ?? []),
|
||||
...((override.codebase?.exclude_patterns) ?? []),
|
||||
].filter(Boolean),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -486,3 +486,66 @@ test("getCodebaseMapStats: reads total file count from header for accuracy with
|
|||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── excludePatterns from options ────────────────────────────────────────
|
||||
|
||||
test("generateCodebaseMap: custom excludePatterns filters additional directories", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "src/utils.ts");
|
||||
addFile(base, ".cache-data/data/index.lance");
|
||||
addFile(base, "docs/guide.md");
|
||||
|
||||
const result = generateCodebaseMap(base, {
|
||||
excludePatterns: [".cache-data/", "docs/"],
|
||||
});
|
||||
assert.ok(result.content.includes("`src/main.ts`"));
|
||||
assert.ok(result.content.includes("`src/utils.ts`"));
|
||||
assert.ok(!result.content.includes(".cache-data"));
|
||||
assert.ok(!result.content.includes("guide.md"));
|
||||
assert.equal(result.fileCount, 2);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("generateCodebaseMap: collapseThreshold option overrides default", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
// Create 10 files in one directory — below default threshold (20)
|
||||
// but above a custom threshold of 5
|
||||
for (let i = 0; i < 10; i++) {
|
||||
addFile(base, `src/comp${i}.ts`);
|
||||
}
|
||||
|
||||
// With default threshold (20), files should NOT collapse
|
||||
const expanded = generateCodebaseMap(base);
|
||||
assert.ok(expanded.content.includes("`src/comp0.ts`"));
|
||||
|
||||
// With custom threshold (5), files SHOULD collapse
|
||||
const collapsed = generateCodebaseMap(base, { collapseThreshold: 5 });
|
||||
assert.ok(collapsed.content.includes("10 files"));
|
||||
assert.ok(!collapsed.content.includes("`src/comp0.ts`\n"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
test("updateCodebaseMap: respects excludePatterns option", () => {
|
||||
const base = makeTmpRepo();
|
||||
try {
|
||||
addFile(base, "src/main.ts");
|
||||
addFile(base, "vendor-extra/lib.js");
|
||||
|
||||
const initial = generateCodebaseMap(base);
|
||||
writeCodebaseMap(base, initial.content);
|
||||
|
||||
// Update with exclusion should remove vendor-extra files
|
||||
const result = updateCodebaseMap(base, { excludePatterns: ["vendor-extra/"] });
|
||||
assert.ok(result.content.includes("`src/main.ts`"));
|
||||
assert.ok(!result.content.includes("vendor-extra"));
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -461,3 +461,65 @@ test("experimental.rtk defaults to off in new project preferences", () => {
|
|||
assert.notEqual(prefs, null);
|
||||
assert.equal(prefs!.experimental?.rtk, undefined);
|
||||
});
|
||||
|
||||
// ── Codebase Map Preferences ─────────────────────────────────────────────────
|
||||
|
||||
test("codebase preferences validate and pass through correctly", () => {
|
||||
const result = validatePreferences({
|
||||
codebase: {
|
||||
exclude_patterns: ["docs/", "fixtures/"],
|
||||
max_files: 1000,
|
||||
collapse_threshold: 15,
|
||||
},
|
||||
});
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.deepEqual(result.preferences.codebase?.exclude_patterns, ["docs/", "fixtures/"]);
|
||||
assert.equal(result.preferences.codebase?.max_files, 1000);
|
||||
assert.equal(result.preferences.codebase?.collapse_threshold, 15);
|
||||
});
|
||||
|
||||
test("codebase preferences reject invalid types", () => {
|
||||
const result = validatePreferences({
|
||||
codebase: {
|
||||
exclude_patterns: "not-an-array" as any,
|
||||
max_files: -5,
|
||||
collapse_threshold: 0,
|
||||
},
|
||||
});
|
||||
assert.ok(result.errors.some(e => e.includes("exclude_patterns must be an array")));
|
||||
assert.ok(result.errors.some(e => e.includes("max_files must be a positive")));
|
||||
assert.ok(result.errors.some(e => e.includes("collapse_threshold must be a positive")));
|
||||
});
|
||||
|
||||
test("codebase preferences warn on unknown keys", () => {
|
||||
const result = validatePreferences({
|
||||
codebase: {
|
||||
exclude_patterns: ["docs/"],
|
||||
unknown_key: true,
|
||||
} as any,
|
||||
});
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.ok(result.warnings.some(w => w.includes('unknown codebase key "unknown_key"')));
|
||||
assert.deepEqual(result.preferences.codebase?.exclude_patterns, ["docs/"]);
|
||||
});
|
||||
|
||||
test("codebase preferences parse from markdown frontmatter", () => {
|
||||
const content = [
|
||||
"---",
|
||||
"version: 1",
|
||||
"codebase:",
|
||||
" exclude_patterns:",
|
||||
' - "docs/"',
|
||||
' - ".cache/"',
|
||||
" max_files: 800",
|
||||
" collapse_threshold: 10",
|
||||
"---",
|
||||
].join("\n");
|
||||
const prefs = parsePreferencesMarkdown(content);
|
||||
assert.notEqual(prefs, null);
|
||||
const result = validatePreferences(prefs!);
|
||||
assert.equal(result.errors.length, 0);
|
||||
assert.deepEqual(result.preferences.codebase?.exclude_patterns, ["docs/", ".cache/"]);
|
||||
assert.equal(result.preferences.codebase?.max_files, 800);
|
||||
assert.equal(result.preferences.codebase?.collapse_threshold, 10);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue