fix(2137): skip worktree isolation when .gitmodules detected (#2144)
* 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 * fix(2137): skip worktree isolation when .gitmodules detected When a project contains git submodules, worktree isolation cannot correctly handle submodule commits — three separate gaps exist in worktree setup, executor commit protocol, and merge-back. Rather than patch each gap individually, detect .gitmodules at phase start and fall back to sequential execution, which handles submodules transparently (Option B). Affected workflows: execute-phase.md, quick.md --------- Co-authored-by: David Sienkowski <dave@sienkowski.com>
This commit is contained in:
302
sdk/src/query/frontmatter-mutation.ts
Normal file
302
sdk/src/query/frontmatter-mutation.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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 { join, isAbsolute } from 'node:path';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { normalizeMd } 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);
|
||||
}
|
||||
|
||||
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath);
|
||||
|
||||
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 } };
|
||||
};
|
||||
Reference in New Issue
Block a user