feat(sdk): Phase 1 typed query foundation (gsd-sdk query) (#2118)

* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1)

Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests.

Made-with: Cursor

* chore: gitignore .cursor for local-only Cursor assets

Made-with: Cursor

* fix(sdk): harden query layer for PR review (paths, locks, CLI, ReDoS)

- resolvePathUnderProject: realpath + relative containment for frontmatter and key_links

- commitToSubrepo: path checks + sanitizeCommitMessage

- statePlannedPhase: readModifyWriteStateMd (lock); MUTATION_COMMANDS + events

- key_links: regexForKeyLinkPattern length/ReDoS guard; phase dirs: reject .. and separators

- gsd-sdk: strip --pick before parseArgs; strict parser; QueryRegistry.commands()

- progress: static GSDError import; tests updated

Made-with: Cursor

* feat(sdk): query follow-up — tests, QUERY-HANDLERS, registry, locks, intel depth

Made-with: Cursor

* docs(sdk): use ASCII punctuation in QUERY-HANDLERS.md

Made-with: Cursor
This commit is contained in:
Rezolv
2026-04-12 18:15:04 -04:00
committed by GitHub
parent 66a5f939b0
commit 6f79b1dd5e
36 changed files with 848 additions and 142 deletions

View File

@@ -45,6 +45,19 @@ function assertNoNullBytes(value: string, label: string): void {
}
}
/** Reject `..` or path separators in phase directory names. */
function assertSafePhaseDirName(dirName: string, label = 'phase directory'): void {
if (/[/\\]|\.\./.test(dirName)) {
throw new GSDError(`${label} contains invalid path segments`, ErrorClassification.Validation);
}
}
function assertSafeProjectCode(code: string): void {
if (code && /[/\\]|\.\./.test(code)) {
throw new GSDError('project_code contains invalid characters', ErrorClassification.Validation);
}
}
// ─── Slug generation (inline) ────────────────────────────────────────────
/** Generate kebab-case slug from description. Port of generateSlugInternal. */
@@ -150,6 +163,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
// Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation')
const projectCode = (config.project_code as string) || '';
assertSafeProjectCode(projectCode);
const prefix = projectCode ? `${projectCode}-` : '';
let newPhaseId: number | string = '';
@@ -164,6 +178,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
if (!newPhaseId) {
throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation);
}
assertSafePhaseDirName(String(newPhaseId), 'custom phase id');
dirName = `${prefix}${newPhaseId}-${slug}`;
} else {
// Sequential mode: find highest integer phase number (in current milestone only)
@@ -182,6 +197,8 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
dirName = `${prefix}${paddedNum}-${slug}`;
}
assertSafePhaseDirName(dirName);
const dirPath = join(planningPaths(projectDir).phases, dirName);
// Create directory with .gitkeep so git tracks empty folders
@@ -293,8 +310,10 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => {
insertConfig = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8'));
} catch { /* use defaults */ }
const projectCode = (insertConfig.project_code as string) || '';
assertSafeProjectCode(projectCode);
const pfx = projectCode ? `${projectCode}-` : '';
dirName = `${pfx}${decimalPhase}-${slug}`;
assertSafePhaseDirName(dirName);
const dirPath = join(phasesDir, dirName);
// Create directory with .gitkeep
@@ -421,6 +440,7 @@ export const phaseScaffold: QueryHandler = async (args, projectDir) => {
}
const slug = generateSlugInternal(name);
const dirNameNew = `${padded}-${slug}`;
assertSafePhaseDirName(dirNameNew, 'scaffold phase directory');
const phasesParent = planningPaths(projectDir).phases;
await mkdir(phasesParent, { recursive: true });
const dirPath = join(phasesParent, dirNameNew);