Spaces:
Runtime error
Runtime error
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2 | ; | |
| /** | |
| * Frontmatter β YAML frontmatter parsing, serialization, and CRUD commands | |
| * | |
| * ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed | |
| * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour | |
| * from the prior hand-written .cjs; only strict types are added. | |
| */ | |
| var __importDefault = (this && this.__importDefault) || function (mod) { | |
| return (mod && mod.__esModule) ? mod : { "default": mod }; | |
| }; | |
| const node_fs_1 = __importDefault(require("node:fs")); | |
| const node_path_1 = __importDefault(require("node:path")); | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const ioMod = require("./io.cjs"); | |
| const { output, error } = ioMod; | |
| const shell_command_projection_cjs_1 = 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; | |
| 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/); | |
| const 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 | |
| const newObj = value === '[' ? [] : {}; | |
| current.obj[key] = newObj; | |
| current.key = null; | |
| // Push new context for potential nested content | |
| stack.push({ obj: newObj, 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 { | |
| // eslint-disable-next-line @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions | |
| lines.push(` ${subsubkey}: ${subsubval}`); | |
| } | |
| } | |
| } | |
| else { | |
| // eslint-disable-next-line @typescript-eslint/no-base-to-string | |
| 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 match = content.match(/^---\r?\n[\s\S]+?\r?\n---/); | |
| if (match) { | |
| // Identity-preservation (additive, lossless round-trip): `reconstructFrontmatter` is a | |
| // deliberately lossy serializer β it cannot faithfully re-emit nested object-list items | |
| // (e.g. must_haves.artifacts / must_haves.prohibitions, whose items are `{ path, provides }` | |
| // / `{ statement, status, β¦ }` maps). When the caller is writing back a value that is | |
| // STRUCTURALLY UNCHANGED from the original parse (the canonical CRUD round-trip and the | |
| // #644 prohibition schema round-trip both do this), regenerating from the lossy object would | |
| // silently mangle those blocks. Detect that case by deep-equality against a re-parse of the | |
| // original frontmatter and preserve the ORIGINAL raw text verbatim β a true no-op splice. | |
| // This touches neither the parser (`extractFrontmatter`) nor `parseMustHavesBlock`; it only | |
| // makes the existing splice faithful when nothing changed. A genuine mutation (different | |
| // object) still flows through `reconstructFrontmatter` exactly as before. | |
| try { | |
| if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) { | |
| return content; | |
| } | |
| } | |
| catch { | |
| /* fall through to regeneration on any comparison hiccup */ | |
| } | |
| const yamlStr = reconstructFrontmatter(newObj); | |
| return `---\n${yamlStr}\n---` + content.slice(match[0].length); | |
| } | |
| const yamlStr = reconstructFrontmatter(newObj); | |
| return `---\n${yamlStr}\n---\n\n` + content; | |
| } | |
| /** | |
| * Structural deep-equality for two parsed frontmatter objects. Order-sensitive for arrays | |
| * (YAML lists are ordered), key-order-insensitive for objects. Used only by `spliceFrontmatter` | |
| * to recognize a no-op write-back; intentionally narrow (handles the string / string[] / | |
| * nested-object shapes `extractFrontmatter` produces). | |
| */ | |
| function frontmatterDeepEqual(a, b) { | |
| if (a === b) | |
| return true; | |
| if (a == null || b == null) | |
| return a === b; | |
| if (Array.isArray(a) || Array.isArray(b)) { | |
| if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) | |
| return false; | |
| return a.every((v, i) => frontmatterDeepEqual(v, b[i])); | |
| } | |
| if (typeof a === 'object' && typeof b === 'object') { | |
| const ao = a; | |
| const bo = b; | |
| const ak = Object.keys(ao); | |
| const bk = Object.keys(bo); | |
| if (ak.length !== bk.length) | |
| return false; | |
| return ak.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && frontmatterDeepEqual(ao[k], bo[k])); | |
| } | |
| return false; | |
| } | |
| 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 indentMatch = line.match(/^(\s*)/); | |
| const indent = indentMatch ? indentMatch[1].length : 0; | |
| // 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])) { | |
| const existing = (current)[lastKey]; | |
| (current)[lastKey] = existing ? [existing] : []; | |
| } | |
| 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 = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath); | |
| const content = (0, shell_command_projection_cjs_1.platformReadSync)(fullPath); | |
| if (!content) { | |
| output({ error: 'File not found', path: filePath }, raw, undefined); | |
| return; | |
| } | |
| const fm = extractFrontmatter(content); | |
| if (field) { | |
| const value = fm[field]; | |
| if (value === undefined) { | |
| output({ error: 'Field not found', field }, raw, undefined); | |
| return; | |
| } | |
| output({ [field]: value }, raw, JSON.stringify(value)); | |
| } | |
| else { | |
| output(fm, raw, undefined); | |
| } | |
| } | |
| 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 = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath); | |
| if (!node_fs_1.default.existsSync(fullPath)) { | |
| output({ error: 'File not found', path: filePath }, raw, undefined); | |
| return; | |
| } | |
| const content = node_fs_1.default.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); | |
| (0, shell_command_projection_cjs_1.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 = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath); | |
| if (!node_fs_1.default.existsSync(fullPath)) { | |
| output({ error: 'File not found', path: filePath }, raw, undefined); | |
| return; | |
| } | |
| const content = node_fs_1.default.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); | |
| (0, shell_command_projection_cjs_1.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 = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath); | |
| const content = (0, shell_command_projection_cjs_1.platformReadSync)(fullPath); | |
| if (!content) { | |
| output({ error: 'File not found', path: filePath }, raw, undefined); | |
| 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, | |
| // Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a | |
| // frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME | |
| // function as `extractFrontmatter` β a bare-object parse with no behavior change β exposed under | |
| // the alias so the prohibition schema round-trip and any future caller can use the canonical name. | |
| parseFrontmatter: extractFrontmatter, | |
| reconstructFrontmatter, | |
| spliceFrontmatter, | |
| parseMustHavesBlock, | |
| FRONTMATTER_SCHEMAS, | |
| cmdFrontmatterGet, | |
| cmdFrontmatterSet, | |
| cmdFrontmatterMerge, | |
| cmdFrontmatterValidate, | |
| }; | |