* refactor(shell-projection): migrate roadmap.cjs writes to platformWriteSync (#3467) 2 atomicWriteFileSync calls → platformWriteSync. The seam owns markdown normalization, so the explicit utf-8 encoding arg is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate config.cjs writes to platformWriteSync (#3467) - 3 atomicWriteFileSync calls → platformWriteSync - 1 raw fs.writeFileSync (depth→granularity migration) → platformWriteSync - 2 fs.mkdirSync(planningBase, { recursive: true }) → platformEnsureDir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate docs.cjs reads to platformReadSync (#3467) 6 try { fs.readFileSync } catch {} patterns → platformReadSync(path) with explicit null guards. detectProjectType now reads package.json once and shares it across has_cli_bin/is_monorepo/has_tests checks. JSON.parse is still wrapped in a try (parsing is a separate failure mode from missing file). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate audit.cjs reads to platformReadSync (#3467) 8 try { fs.readFileSync(safeFilePath, 'utf-8') } catch { continue } patterns → const content = platformReadSync(safeFilePath); if (content === null) continue; The single safeSum case (where catch set status='unreadable' rather than continue) maps to an if/else that preserves the same semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate planning-workspace.cjs to platform* seam (#3467) - 2 try { fs.readFileSync } catch {} → platformReadSync (null on missing) - 2 fs.writeFileSync (workstream pointer writes) → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir The .lock file write at withPlanningLock is intentionally NOT migrated. That call uses { flag: 'wx' } for atomic exclusive-create, which is the correct lock-acquisition primitive. platformWriteSync's atomic-rename pattern would silently overwrite an existing lock file and break the locking guarantee. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate milestone.cjs writes to platform* seam (#3467) - 5 atomicWriteFileSync calls → platformWriteSync (4 dropped normalizeMd wrapper; seam handles .md normalization automatically) - 2 raw fs.writeFileSync (archive ROADMAP.md / REQUIREMENTS.md) → platformWriteSync - 2 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Dropped normalizeMd import (only used as write pre-call here) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate intel.cjs to platform* seam (#3467) - 7 fs.readFileSync (existsSync+readFileSync patterns and try/catch) → platformReadSync - 2 fs.writeFileSync → platformWriteSync - 1 fs.mkdirSync(intelPath, { recursive: true }) → platformEnsureDir - Consolidated dual-check (existsSync + readFileSync) into single platformReadSync call returning null on missing file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate workstream.cjs to platform* seam (#3467) - 5 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - 1 fs.writeFileSync (STATE.md initial scaffold) → platformWriteSync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate init.cjs reads/writes to platform* seam (#3467) - 11 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 1 fs.writeFileSync (skill-manifest.json) → platformWriteSync Three bare fs.readFileSync calls remain (ROADMAP/STATE reads in code paths where the file is required to exist) — these are not "Done when" violations (no try/catch wrapping, no inline existsSync guard). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate commands.cjs reads/writes to platform* seam (#3467) - 6 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 2 fs.writeFileSync → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Removed unused safeReadFile import (zero call sites in this file) Three bare fs.readFileSync calls remain (sourcePath at line 752, fullPath at 443, roadmapPath in cmdAuditOpen) — preceded by existsSync guards or in code paths where file presence is required; not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate profile-output.cjs to platform* seam (#3467) - 6 safeReadFile (from core.cjs) calls preserved by aliasing platformReadSync as safeReadFile in the import — same semantics, zero call-site changes - 3 try/JSON.parse(readFileSync) patterns → platformReadSync + try/JSON.parse - 1 existsSync+readFileSync pattern (claude.md update) → platformReadSync - 5 fs.writeFileSync → platformWriteSync - 4 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir Two bare fs.readFileSync calls remain (template reads where file must exist or fail loudly) — not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate state.cjs to platform* seam (#3467) - 4 atomicWriteFileSync calls → platformWriteSync (3 dropped normalizeMd wrapper; seam handles .md normalization) - 4 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 1 fs.writeFileSync (WAITING.json) → platformWriteSync - 1 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Dropped normalizeMd and atomicWriteFileSync imports (only used as write pre-calls here) Bare fs.readFileSync calls remain in code paths where STATE.md is required to exist (statePath reads in cmd handlers, dry-run prune) — not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate core.cjs to platform* seam (#3467) - 7 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 3 fs.writeFileSync (config writes + large-payload temp file) → platformWriteSync - 1 fs.mkdirSync (GSD_TEMP_DIR) → platformEnsureDir Three fs calls remain — they are the internal implementations of the safeReadFile and atomicWriteFileSync wrappers that core.cjs exports for backward compatibility. The wrappers are scheduled for removal in Phase 4 (#3468) and will not be migrated here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate phase.cjs writes to platform* seam (#3467) - 6 atomicWriteFileSync calls → platformWriteSync - 3 fs.writeFileSync(path.join(dirPath, '.gitkeep'), '') → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir Bare fs.readFileSync calls remain for roadmapPath/planPath reads where the file is required to exist; these are not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate verify.cjs to platform* seam (#3467) - 8 safeReadFile (from core.cjs) calls preserved by aliasing platformReadSync as safeReadFile in the import — same semantics, zero call-site changes - 1 existsSync+readFileSync inline ternary → safeReadFile (returns null) - 5 fs.writeFileSync (config writes + milestones writes) → platformWriteSync Bare fs.readFileSync calls remain for code paths where the file is required to exist (roadmap/state/config full reads); these are not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate frontmatter.cjs + update atomic-write test (#3467) - frontmatter.cjs: 2 atomicWriteFileSync calls → platformWriteSync. The legacy normalizeMd wrapper is dropped because the seam handles markdown normalization. safeReadFile preserved by aliasing platformReadSync. - atomic-write-coverage.test.cjs: update the #1972 structural invariant to assert on platformWriteSync. platformWriteSync uses the same tmp-file + atomic-rename primitive that atomicWriteFileSync did — the no-partial-write guarantee is preserved across the migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add entry for shell-projection Phase 3 migration (#3467) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(coderabbit): disable ESLint tool (repo uses custom lint scripts) CodeRabbit's review surface emits a "skipped: no ESLint configuration" warning because the repo doesn't ship ESLint config. The repo intentionally does not use ESLint — it ships its own targeted lint scripts (scripts/lint-no-source-grep.cjs, npm run lint:tests) that enforce repo-specific test-quality invariants. Adding ESLint config purely to satisfy CR would add an external dependency (CONTRIBUTING.md: "No external dependencies in core") and overlap with the existing custom lint surface. Disable the ESLint tool in CR's tools config so the skip warning stops appearing on every PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
/**
|
|
* Frontmatter — YAML frontmatter parsing, serialization, and CRUD commands
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { output, error } = require('./core.cjs');
|
|
const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
|
|
|
|
// ─── Parsing engine ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Split a YAML inline array body on commas, respecting quoted strings.
|
|
* e.g. '"a, b", c' → ['a, b', 'c']
|
|
*/
|
|
function splitInlineArray(body) {
|
|
const items = [];
|
|
let current = '';
|
|
let inQuote = null; // null | '"' | "'"
|
|
|
|
for (let i = 0; i < body.length; i++) {
|
|
const ch = body[i];
|
|
if (inQuote) {
|
|
if (ch === inQuote) {
|
|
inQuote = null;
|
|
} else {
|
|
current += ch;
|
|
}
|
|
} else if (ch === '"' || ch === "'") {
|
|
inQuote = ch;
|
|
} else if (ch === ',') {
|
|
const trimmed = current.trim();
|
|
if (trimmed) items.push(trimmed);
|
|
current = '';
|
|
} else {
|
|
current += ch;
|
|
}
|
|
}
|
|
const trimmed = current.trim();
|
|
if (trimmed) items.push(trimmed);
|
|
return items;
|
|
}
|
|
|
|
function extractFrontmatter(content) {
|
|
const frontmatter = {};
|
|
// Match frontmatter only at byte 0 — a `---` block later in the document
|
|
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
|
|
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
if (!match) return frontmatter;
|
|
|
|
const yaml = match[1];
|
|
const lines = yaml.split(/\r?\n/);
|
|
|
|
// Stack to track nested objects: [{obj, key, indent}]
|
|
// obj = object to write to, key = current key collecting array items, indent = indentation level
|
|
let stack = [{ obj: frontmatter, key: null, indent: -1 }];
|
|
|
|
for (const line of lines) {
|
|
// Skip empty lines
|
|
if (line.trim() === '') continue;
|
|
|
|
// Calculate indentation (number of leading spaces)
|
|
const indentMatch = line.match(/^(\s*)/);
|
|
const indent = indentMatch ? indentMatch[1].length : 0;
|
|
|
|
// Pop stack back to appropriate level
|
|
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
stack.pop();
|
|
}
|
|
|
|
const current = stack[stack.length - 1];
|
|
|
|
// Check for key: value pattern
|
|
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
|
|
if (keyMatch) {
|
|
const key = keyMatch[2];
|
|
const value = keyMatch[3].trim();
|
|
|
|
if (value === '' || value === '[') {
|
|
// Key with no value or opening bracket — could be nested object or array
|
|
// We'll determine based on next lines, for now create placeholder
|
|
current.obj[key] = value === '[' ? [] : {};
|
|
current.key = null;
|
|
// Push new context for potential nested content
|
|
stack.push({ obj: current.obj[key], key: null, indent });
|
|
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
// Inline array: key: [a, b, c] — quote-aware split (REG-04 fix)
|
|
current.obj[key] = splitInlineArray(value.slice(1, -1));
|
|
current.key = null;
|
|
} else {
|
|
// Simple key: value
|
|
current.obj[key] = value.replace(/^["']|["']$/g, '');
|
|
current.key = null;
|
|
}
|
|
} else if (line.trim().startsWith('- ')) {
|
|
// Array item
|
|
const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
|
|
|
|
// If current context is an empty object, convert to array
|
|
if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
|
|
// Find the key in parent that points to this object and convert it
|
|
const parent = stack.length > 1 ? stack[stack.length - 2] : null;
|
|
if (parent) {
|
|
for (const k of Object.keys(parent.obj)) {
|
|
if (parent.obj[k] === current.obj) {
|
|
parent.obj[k] = [itemValue];
|
|
current.obj = parent.obj[k];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (Array.isArray(current.obj)) {
|
|
current.obj.push(itemValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
return frontmatter;
|
|
}
|
|
|
|
function reconstructFrontmatter(obj) {
|
|
const lines = [];
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (value === null || value === undefined) continue;
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) {
|
|
lines.push(`${key}: []`);
|
|
} else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) {
|
|
lines.push(`${key}: [${value.join(', ')}]`);
|
|
} else {
|
|
lines.push(`${key}:`);
|
|
for (const item of value) {
|
|
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
|
|
}
|
|
}
|
|
} else if (typeof value === 'object') {
|
|
lines.push(`${key}:`);
|
|
for (const [subkey, subval] of Object.entries(value)) {
|
|
if (subval === null || subval === undefined) continue;
|
|
if (Array.isArray(subval)) {
|
|
if (subval.length === 0) {
|
|
lines.push(` ${subkey}: []`);
|
|
} else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) {
|
|
lines.push(` ${subkey}: [${subval.join(', ')}]`);
|
|
} else {
|
|
lines.push(` ${subkey}:`);
|
|
for (const item of subval) {
|
|
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
|
|
}
|
|
}
|
|
} else if (typeof subval === 'object') {
|
|
lines.push(` ${subkey}:`);
|
|
for (const [subsubkey, subsubval] of Object.entries(subval)) {
|
|
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}: ${sv.includes(':') || sv.includes('#') ? `"${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');
|
|
}
|
|
|
|
function spliceFrontmatter(content, newObj) {
|
|
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;
|
|
}
|
|
|
|
function parseMustHavesBlock(content, blockName) {
|
|
// Extract a specific block from must_haves in raw frontmatter YAML
|
|
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
|
|
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
if (!fmMatch) return [];
|
|
|
|
const yaml = fmMatch[1];
|
|
|
|
// Find must_haves: first to detect its indentation level
|
|
const mustHavesMatch = yaml.match(/^(\s*)must_haves:\s*$/m);
|
|
if (!mustHavesMatch) return [];
|
|
const mustHavesIndent = mustHavesMatch[1].length;
|
|
|
|
// Find the block (e.g., "truths:", "artifacts:", "key_links:") under must_haves
|
|
// It must be indented more than must_haves but we detect the actual indent dynamically
|
|
const blockPattern = new RegExp(`^(\\s+)${blockName}:\\s*$`, 'm');
|
|
const blockMatch = yaml.match(blockPattern);
|
|
if (!blockMatch) return [];
|
|
|
|
const blockIndent = blockMatch[1].length;
|
|
// The block must be nested under must_haves (more indented)
|
|
if (blockIndent <= mustHavesIndent) return [];
|
|
|
|
// Find where the block starts in the yaml string
|
|
const blockStart = yaml.indexOf(blockMatch[0]);
|
|
if (blockStart === -1) return [];
|
|
|
|
const afterBlock = yaml.slice(blockStart);
|
|
const blockLines = afterBlock.split(/\r?\n/).slice(1); // skip the header line
|
|
|
|
// List items are indented one level deeper than blockIndent
|
|
// Continuation KVs are indented one level deeper than list items
|
|
const items = [];
|
|
let current = null;
|
|
let listItemIndent = -1; // detected from first "- " line
|
|
|
|
for (const line of blockLines) {
|
|
// Skip empty lines
|
|
if (line.trim() === '') continue;
|
|
const indent = line.match(/^(\s*)/)[1].length;
|
|
// Stop at same or lower indent level than the block header
|
|
if (indent <= blockIndent && line.trim() !== '') break;
|
|
|
|
const trimmed = line.trim();
|
|
|
|
if (trimmed.startsWith('- ')) {
|
|
// Detect list item indent from the first occurrence
|
|
if (listItemIndent === -1) listItemIndent = indent;
|
|
|
|
// Only treat as a top-level list item if at the expected indent
|
|
if (indent === listItemIndent) {
|
|
if (current) items.push(current);
|
|
const afterDash = trimmed.slice(2);
|
|
const trimmedAfterDash = afterDash.trim();
|
|
// Check if it's a fully-quoted string (may contain ':' inside the quotes)
|
|
if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) ||
|
|
(trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) {
|
|
current = trimmedAfterDash.slice(1, -1);
|
|
// Check if it's a simple string item (no colon means not a key-value)
|
|
} else if (!afterDash.includes(':')) {
|
|
current = afterDash.replace(/^["']|["']$/g, '');
|
|
} else {
|
|
// Key-value on same line as dash: "- path: value"
|
|
// YAML KV always has at least one space after the colon: "key: value"
|
|
// Requiring \s+ rejects "Class::Method" and "db:seed" (no space after colon)
|
|
const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
|
|
if (kvMatch) {
|
|
current = {};
|
|
current[kvMatch[1]] = kvMatch[2];
|
|
} else {
|
|
// Looks like KV but doesn't match — treat as plain string (#2757)
|
|
current = afterDash.replace(/^["']|["']$/g, '');
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (current && typeof current === 'object' && indent > listItemIndent) {
|
|
// Continuation key-value or nested array item
|
|
if (trimmed.startsWith('- ')) {
|
|
// Array item under a key
|
|
const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
|
|
const keys = Object.keys(current);
|
|
const lastKey = keys[keys.length - 1];
|
|
if (lastKey && !Array.isArray(current[lastKey])) {
|
|
current[lastKey] = current[lastKey] ? [current[lastKey]] : [];
|
|
}
|
|
if (lastKey) current[lastKey].push(arrVal);
|
|
} else {
|
|
const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
|
|
if (kvMatch) {
|
|
const val = kvMatch[2];
|
|
// Try to parse as number
|
|
current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (current) items.push(current);
|
|
|
|
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
|
|
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
|
|
// to Option C (LLM-derived truths) instead of checking documented contracts.
|
|
if (items.length === 0 && blockLines.length > 0) {
|
|
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
|
|
if (nonEmptyLines > 0) {
|
|
process.stderr.write(
|
|
`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
|
|
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
|
|
);
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// ─── Frontmatter CRUD commands ────────────────────────────────────────────────
|
|
|
|
const FRONTMATTER_SCHEMAS = {
|
|
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'] },
|
|
};
|
|
|
|
function cmdFrontmatterGet(cwd, filePath, field, raw) {
|
|
if (!filePath) { error('file path required'); }
|
|
// Path traversal guard: reject null bytes
|
|
if (filePath.includes('\0')) { error('file path contains null bytes'); }
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
const content = safeReadFile(fullPath);
|
|
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
|
|
const fm = extractFrontmatter(content);
|
|
if (field) {
|
|
const value = fm[field];
|
|
if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
|
|
output({ [field]: value }, raw, JSON.stringify(value));
|
|
} else {
|
|
output(fm, raw);
|
|
}
|
|
}
|
|
|
|
function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
|
if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
|
|
// Path traversal guard: reject null bytes
|
|
if (filePath.includes('\0')) { error('file path contains null bytes'); }
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
|
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
let parsedValue;
|
|
try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
|
|
fm[field] = parsedValue;
|
|
const newContent = spliceFrontmatter(content, fm);
|
|
platformWriteSync(fullPath, newContent);
|
|
output({ updated: true, field, value: parsedValue }, raw, 'true');
|
|
}
|
|
|
|
function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
|
if (!filePath || !data) { error('file and data required'); }
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
|
|
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
let mergeData;
|
|
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
|
|
Object.assign(fm, mergeData);
|
|
const newContent = spliceFrontmatter(content, fm);
|
|
platformWriteSync(fullPath, newContent);
|
|
output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
|
|
}
|
|
|
|
function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
|
|
if (!filePath || !schemaName) { error('file and schema required'); }
|
|
const schema = FRONTMATTER_SCHEMAS[schemaName];
|
|
if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
|
|
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
|
|
const content = safeReadFile(fullPath);
|
|
if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
|
|
const fm = extractFrontmatter(content);
|
|
const missing = schema.required.filter(f => fm[f] === undefined);
|
|
const present = schema.required.filter(f => fm[f] !== undefined);
|
|
output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
|
|
}
|
|
|
|
module.exports = {
|
|
extractFrontmatter,
|
|
reconstructFrontmatter,
|
|
spliceFrontmatter,
|
|
parseMustHavesBlock,
|
|
FRONTMATTER_SCHEMAS,
|
|
cmdFrontmatterGet,
|
|
cmdFrontmatterSet,
|
|
cmdFrontmatterMerge,
|
|
cmdFrontmatterValidate,
|
|
};
|