fix(gsd): add codebase validation in validatePreferences so preferences are not silently dropped

The codebase preferences block was accepted as a known key but never
validated or assigned in validatePreferences(), causing all user-configured
codebase defaults to be silently discarded. Adds validation for
exclude_patterns (string[]), max_files (positive int), and collapse_threshold
(positive int) with unknown-key warnings and 4 new tests.
This commit is contained in:
Jeremy 2026-04-04 15:01:15 -05:00
parent bbe67da02c
commit 4ddb9ca8a5
2 changed files with 107 additions and 0 deletions

View file

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

View file

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