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

@@ -16,13 +16,38 @@
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, isAbsolute, resolve } from 'node:path';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js';
import { escapeRegex, normalizePhaseName, planningPaths } from './helpers.js';
import { escapeRegex, normalizePhaseName, planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
/** Max length for key_links regex patterns (ReDoS mitigation). */
const MAX_KEY_LINK_PATTERN_LEN = 512;
/**
* Build a RegExp for must_haves key_links pattern matching.
* Long or nested-quantifier patterns fall back to a literal match via escapeRegex.
*/
export function regexForKeyLinkPattern(pattern: string): RegExp {
if (typeof pattern !== 'string' || pattern.length === 0) {
return /$^/;
}
if (pattern.length > MAX_KEY_LINK_PATTERN_LEN) {
return new RegExp(escapeRegex(pattern.slice(0, MAX_KEY_LINK_PATTERN_LEN)));
}
// Mitigate catastrophic backtracking on nested quantifier forms
if (/\([^)]*[\+\*][^)]*\)[\+\*]/.test(pattern)) {
return new RegExp(escapeRegex(pattern));
}
try {
return new RegExp(pattern);
} catch {
return new RegExp(escapeRegex(pattern));
}
}
// ─── verifyKeyLinks ───────────────────────────────────────────────────────
/**
@@ -48,7 +73,15 @@ export const verifyKeyLinks: QueryHandler = async (args, projectDir) => {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
}
const fullPath = isAbsolute(planFilePath) ? planFilePath : join(projectDir, planFilePath);
let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, planFilePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: planFilePath } };
}
throw err;
}
let content: string;
try {
@@ -77,37 +110,33 @@ export const verifyKeyLinks: QueryHandler = async (args, projectDir) => {
let sourceContent: string | null = null;
try {
sourceContent = await readFile(join(projectDir, check.from), 'utf-8');
const fromPath = await resolvePathUnderProject(projectDir, check.from);
sourceContent = await readFile(fromPath, 'utf-8');
} catch {
// Source file not found
// Source file not found or path invalid
}
if (!sourceContent) {
check.detail = 'Source file not found';
} else if (linkObj.pattern) {
// T-12-05: Wrap new RegExp in try/catch
try {
const regex = new RegExp(linkObj.pattern as string);
if (regex.test(sourceContent)) {
check.verified = true;
check.detail = 'Pattern found in source';
} else {
// Try target file
let targetContent: string | null = null;
try {
targetContent = await readFile(join(projectDir, check.to), 'utf-8');
} catch {
// Target file not found
}
if (targetContent && regex.test(targetContent)) {
check.verified = true;
check.detail = 'Pattern found in target';
} else {
check.detail = `Pattern "${linkObj.pattern}" not found in source or target`;
}
const regex = regexForKeyLinkPattern(linkObj.pattern as string);
if (regex.test(sourceContent)) {
check.verified = true;
check.detail = 'Pattern found in source';
} else {
let targetContent: string | null = null;
try {
const toPath = await resolvePathUnderProject(projectDir, check.to);
targetContent = await readFile(toPath, 'utf-8');
} catch {
// Target file not found
}
if (targetContent && regex.test(targetContent)) {
check.verified = true;
check.detail = 'Pattern found in target';
} else {
check.detail = `Pattern "${linkObj.pattern}" not found in source or target`;
}
} catch {
check.detail = `Invalid regex pattern: ${linkObj.pattern}`;
}
} else {
// No pattern: check if target path is referenced in source content