Files
msd-core/sdk/src/query/frontmatter-mutation.ts
Rezolv 6f79b1dd5e 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
2026-04-12 18:15:04 -04:00

326 lines
11 KiB
TypeScript

/**
* Frontmatter mutation handlers — write operations for YAML frontmatter.
*
* Ported from get-shit-done/bin/lib/frontmatter.cjs.
* Provides reconstructFrontmatter (serialization), spliceFrontmatter (replacement),
* and query handlers for frontmatter.set, frontmatter.merge, frontmatter.validate.
*
* @example
* ```typescript
* import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js';
*
* const yaml = reconstructFrontmatter({ phase: '10', tags: ['a', 'b'] });
* // 'phase: 10\ntags: [a, b]'
*
* const updated = spliceFrontmatter('---\nold: val\n---\nbody', { new: 'val' });
* // '---\nnew: val\n---\nbody'
* ```
*/
import { readFile, writeFile } from 'node:fs/promises';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter } from './frontmatter.js';
import { normalizeMd, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
// ─── FRONTMATTER_SCHEMAS ──────────────────────────────────────────────────
/** Schema definitions for frontmatter validation. */
export const FRONTMATTER_SCHEMAS: Record<string, { required: string[] }> = {
plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
verification: { required: ['phase', 'verified', 'status', 'score'] },
};
// ─── reconstructFrontmatter ────────────────────────────────────────────────
/**
* Serialize a flat/nested object into YAML frontmatter lines.
*
* Port of `reconstructFrontmatter` from frontmatter.cjs lines 122-183.
* Handles arrays (inline/dash), nested objects (2 levels), and quoting.
*
* @param obj - Object to serialize
* @returns YAML string (without --- delimiters)
*/
export function reconstructFrontmatter(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) continue;
if (Array.isArray(value)) {
serializeArray(lines, key, value, '');
} else if (typeof value === 'object') {
lines.push(`${key}:`);
for (const [subkey, subval] of Object.entries(value as Record<string, unknown>)) {
if (subval === null || subval === undefined) continue;
if (Array.isArray(subval)) {
serializeArray(lines, subkey, subval, ' ');
} else if (typeof subval === 'object') {
lines.push(` ${subkey}:`);
for (const [subsubkey, subsubval] of Object.entries(subval as Record<string, unknown>)) {
if (subsubval === null || subsubval === undefined) continue;
if (Array.isArray(subsubval)) {
if (subsubval.length === 0) {
lines.push(` ${subsubkey}: []`);
} else {
lines.push(` ${subsubkey}:`);
for (const item of subsubval) {
lines.push(` - ${item}`);
}
}
} else {
lines.push(` ${subsubkey}: ${subsubval}`);
}
}
} else {
const sv = String(subval);
lines.push(` ${subkey}: ${needsQuoting(sv) ? `"${sv}"` : sv}`);
}
}
} else {
const sv = String(value);
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) {
lines.push(`${key}: "${sv}"`);
} else {
lines.push(`${key}: ${sv}`);
}
}
}
return lines.join('\n');
}
/** Serialize an array at the given indent level. */
function serializeArray(lines: string[], key: string, arr: unknown[], indent: string): void {
if (arr.length === 0) {
lines.push(`${indent}${key}: []`);
} else if (
arr.every(v => typeof v === 'string') &&
arr.length <= 3 &&
(arr as string[]).join(', ').length < 60
) {
lines.push(`${indent}${key}: [${(arr as string[]).join(', ')}]`);
} else {
lines.push(`${indent}${key}:`);
for (const item of arr) {
const s = String(item);
lines.push(`${indent} - ${typeof item === 'string' && needsQuoting(s) ? `"${s}"` : s}`);
}
}
}
/** Check if a string value needs quoting in YAML. */
function needsQuoting(s: string): boolean {
return s.includes(':') || s.includes('#');
}
// ─── spliceFrontmatter ─────────────────────────────────────────────────────
/**
* Replace or prepend frontmatter in content.
*
* Port of `spliceFrontmatter` from frontmatter.cjs lines 186-193.
*
* @param content - File content with potential existing frontmatter
* @param newObj - New frontmatter object to serialize
* @returns Content with updated frontmatter
*/
export function spliceFrontmatter(content: string, newObj: Record<string, unknown>): string {
const yamlStr = reconstructFrontmatter(newObj);
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
if (match) {
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
}
return `---\n${yamlStr}\n---\n\n` + content;
}
// ─── parseSimpleValue ──────────────────────────────────────────────────────
/**
* Parse a simple CLI value string into a typed value.
* Tries JSON.parse first (handles booleans, numbers, arrays, objects).
* Falls back to raw string.
*/
function parseSimpleValue(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
// ─── frontmatterSet ────────────────────────────────────────────────────────
/**
* Query handler for frontmatter.set command.
*
* Reads a file, sets a single frontmatter field, writes back with normalization.
* Port of `cmdFrontmatterSet` from frontmatter.cjs lines 328-342.
*
* @param args - args[0]: file path, args[1]: field name, args[2]: value
* @param projectDir - Project root directory
* @returns QueryResult with { updated: true, field, value }
*/
export const frontmatterSet: QueryHandler = async (args, projectDir) => {
const filePath = args[0];
const field = args[1];
const value = args[2];
if (!filePath || !field || value === undefined) {
throw new GSDError('file, field, and value required', ErrorClassification.Validation);
}
// Path traversal guard: reject null bytes
if (filePath.includes('\0')) {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
}
let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string;
try {
content = await readFile(fullPath, 'utf-8');
} catch {
return { data: { error: 'File not found', path: filePath } };
}
const fm = extractFrontmatter(content);
fm[field] = parseSimpleValue(value);
const newContent = spliceFrontmatter(content, fm);
await writeFile(fullPath, normalizeMd(newContent), 'utf-8');
return { data: { updated: true, field, value: fm[field] } };
};
// ─── frontmatterMerge ──────────────────────────────────────────────────────
/**
* Query handler for frontmatter.merge command.
*
* Reads a file, merges JSON object into existing frontmatter, writes back.
* Port of `cmdFrontmatterMerge` from frontmatter.cjs lines 344-356.
*
* @param args - args[0]: file path, args[1]: JSON string
* @param projectDir - Project root directory
* @returns QueryResult with { merged: true, fields: [...] }
*/
export const frontmatterMerge: QueryHandler = async (args, projectDir) => {
const filePath = args[0];
const jsonString = args[1];
if (!filePath || !jsonString) {
throw new GSDError('file and data required', ErrorClassification.Validation);
}
// Path traversal guard: reject null bytes (consistent with frontmatterSet)
if (filePath.includes('\0')) {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
}
let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string;
try {
content = await readFile(fullPath, 'utf-8');
} catch {
return { data: { error: 'File not found', path: filePath } };
}
let mergeData: Record<string, unknown>;
try {
mergeData = JSON.parse(jsonString) as Record<string, unknown>;
} catch {
throw new GSDError('Invalid JSON for merge data', ErrorClassification.Validation);
}
const fm = extractFrontmatter(content);
Object.assign(fm, mergeData);
const newContent = spliceFrontmatter(content, fm);
await writeFile(fullPath, normalizeMd(newContent), 'utf-8');
return { data: { merged: true, fields: Object.keys(mergeData) } };
};
// ─── frontmatterValidate ───────────────────────────────────────────────────
/**
* Query handler for frontmatter.validate command.
*
* Reads a file and checks its frontmatter against a known schema.
* Port of `cmdFrontmatterValidate` from frontmatter.cjs lines 358-369.
*
* @param args - args[0]: file path, args[1]: '--schema', args[2]: schema name
* @param projectDir - Project root directory
* @returns QueryResult with { valid, missing, present, schema }
*/
export const frontmatterValidate: QueryHandler = async (args, projectDir) => {
const filePath = args[0];
// Parse --schema flag from args
let schemaName: string | undefined;
for (let i = 1; i < args.length; i++) {
if (args[i] === '--schema' && args[i + 1]) {
schemaName = args[i + 1];
break;
}
}
if (!filePath || !schemaName) {
throw new GSDError('file and schema required', ErrorClassification.Validation);
}
// Path traversal guard: reject null bytes (consistent with frontmatterSet)
if (filePath.includes('\0')) {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
}
const schema = FRONTMATTER_SCHEMAS[schemaName];
if (!schema) {
throw new GSDError(
`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`,
ErrorClassification.Validation
);
}
let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string;
try {
content = await readFile(fullPath, 'utf-8');
} catch {
return { data: { error: 'File not found', path: filePath } };
}
const fm = extractFrontmatter(content);
const missing = schema.required.filter(f => fm[f] === undefined);
const present = schema.required.filter(f => fm[f] !== undefined);
return { data: { valid: missing.length === 0, missing, present, schema: schemaName } };
};