From 6a2a6a9e2c68012394ffd14fd5889ab94506a8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Tue, 17 Mar 2026 18:44:25 -0600 Subject: [PATCH] fix: consolidate frontmatter parsing into shared module (#1040) * fix: consolidate frontmatter parsing into shared module Co-Authored-By: Claude Opus 4.6 (1M context) * fix: strip quotes from frontmatter scalar values The shared parseFrontmatterMap was missing quote-stripping that the old rule-loader had, causing 3 test failures in ttsr-rule-loader.test.ts. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/files.ts | 110 +--------------- .../extensions/gsd/migrate/parsers.ts | 85 +------------ .../extensions/shared/frontmatter.ts | 117 ++++++++++++++++++ src/resources/extensions/ttsr/rule-loader.ts | 55 +------- 4 files changed, 127 insertions(+), 240 deletions(-) create mode 100644 src/resources/extensions/shared/frontmatter.ts diff --git a/src/resources/extensions/gsd/files.ts b/src/resources/extensions/gsd/files.ts index 68d411d7e..a444deadc 100644 --- a/src/resources/extensions/gsd/files.ts +++ b/src/resources/extensions/gsd/files.ts @@ -24,6 +24,10 @@ import { parseRoadmapSlices } from './roadmap-slices.js'; import { nativeParseRoadmap, nativeExtractSection, nativeParsePlanFile, nativeParseSummaryFile, NATIVE_UNAVAILABLE } from './native-parser-bridge.js'; import { debugTime, debugCount } from './debug-logger.js'; import { CACHE_MAX } from './constants.js'; +import { splitFrontmatter, parseFrontmatterMap } from '../shared/frontmatter.js'; + +// Re-export for downstream consumers +export { splitFrontmatter, parseFrontmatterMap }; // ─── Parse Cache ────────────────────────────────────────────────────────── @@ -57,112 +61,6 @@ export function clearParseCache(): void { // ─── Helpers ─────────────────────────────────────────────────────────────── -/** - * Split markdown content into frontmatter (YAML-like) and body. - * Returns [frontmatterLines, body] where frontmatterLines is null if no frontmatter. - */ -export function splitFrontmatter(content: string): [string[] | null, string] { - const trimmed = content.trimStart(); - if (!trimmed.startsWith('---')) return [null, content]; - - const afterFirst = trimmed.indexOf('\n'); - if (afterFirst === -1) return [null, content]; - - const rest = trimmed.slice(afterFirst + 1); - const endIdx = rest.indexOf('\n---'); - if (endIdx === -1) return [null, content]; - - const fmLines = rest.slice(0, endIdx).split('\n'); - const body = rest.slice(endIdx + 4).replace(/^\n+/, ''); - return [fmLines, body]; -} - -/** - * Parse YAML-like frontmatter lines into a flat key-value map. - * Handles simple scalars and arrays (lines starting with " - "). - * Handles nested objects like requires (lines with " key: value"). - */ -export function parseFrontmatterMap(lines: string[]): Record { - const result: Record = {}; - let currentKey: string | null = null; - let currentArray: unknown[] | null = null; - let currentObj: Record | null = null; - - for (const line of lines) { - // Nested object property (4-space indent with key: value) - const nestedMatch = line.match(/^ (\w[\w_]*)\s*:\s*(.*)$/); - if (nestedMatch && currentArray && currentObj) { - currentObj[nestedMatch[1]] = nestedMatch[2].trim(); - continue; - } - - // Array item (2-space indent) - const arrayMatch = line.match(/^ - (.*)$/); - if (arrayMatch && currentKey) { - // If there's a pending nested object, push it - if (currentObj && Object.keys(currentObj).length > 0) { - currentArray!.push(currentObj); - } - currentObj = null; - - const val = arrayMatch[1].trim(); - if (!currentArray) currentArray = []; - - // Check if this array item starts a nested object (e.g. "- slice: S00") - const nestedStart = val.match(/^(\w[\w_]*)\s*:\s*(.*)$/); - if (nestedStart) { - currentObj = { [nestedStart[1]]: nestedStart[2].trim() }; - } else { - currentArray.push(val); - } - continue; - } - - // Flush previous key - if (currentKey) { - if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { - currentArray.push(currentObj); - currentObj = null; - } - if (currentArray) { - result[currentKey] = currentArray; - } - currentArray = null; - } - - // Top-level key: value - const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)$/); - if (kvMatch) { - currentKey = kvMatch[1]; - const val = kvMatch[2].trim(); - - if (val === '' || val === '[]') { - currentArray = []; - } else if (val.startsWith('[') && val.endsWith(']')) { - const inner = val.slice(1, -1).trim(); - result[currentKey] = inner ? inner.split(',').map(s => s.trim()) : []; - currentKey = null; - } else { - result[currentKey] = val; - currentKey = null; - } - } - } - - // Flush final key - if (currentKey) { - if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { - currentArray.push(currentObj); - currentObj = null; - } - if (currentArray) { - result[currentKey] = currentArray; - } - } - - return result; -} - /** Extract the text after a heading at a given level, up to the next heading of same or higher level. */ export function extractSection(body: string, heading: string, level: number = 2): string | null { // Try native parser first for better performance on large files diff --git a/src/resources/extensions/gsd/migrate/parsers.ts b/src/resources/extensions/gsd/migrate/parsers.ts index 05d46deb7..708f72a8f 100644 --- a/src/resources/extensions/gsd/migrate/parsers.ts +++ b/src/resources/extensions/gsd/migrate/parsers.ts @@ -367,88 +367,7 @@ function parseRequiresArray(raw: unknown): PlanningSummaryRequires[] { }); } -/** - * Parse YAML-like frontmatter lines into a flat key-value map. - * Like parseFrontmatterMap but supports hyphenated keys (e.g. `tech-stack:`). - */ -function parseFrontmatterMapHyphen(lines: string[]): Record { - const result: Record = {}; - let currentKey: string | null = null; - let currentArray: unknown[] | null = null; - let currentObj: Record | null = null; - - for (const line of lines) { - // Nested object property (4-space indent with key: value) - const nestedMatch = line.match(/^ ([\w][\w_-]*)\s*:\s*(.*)$/); - if (nestedMatch && currentArray && currentObj) { - currentObj[nestedMatch[1]] = nestedMatch[2].trim(); - continue; - } - - // Array item (2-space indent) - const arrayMatch = line.match(/^ - (.*)$/); - if (arrayMatch && currentKey) { - if (currentObj && Object.keys(currentObj).length > 0) { - currentArray!.push(currentObj); - } - currentObj = null; - - const val = arrayMatch[1].trim(); - if (!currentArray) currentArray = []; - - const nestedStart = val.match(/^([\w][\w_-]*)\s*:\s*(.*)$/); - if (nestedStart) { - currentObj = { [nestedStart[1]]: nestedStart[2].trim() }; - } else { - currentArray.push(val); - } - continue; - } - - // Flush previous key - if (currentKey) { - if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { - currentArray.push(currentObj); - currentObj = null; - } - if (currentArray) { - result[currentKey] = currentArray; - } - currentArray = null; - } - - // Top-level key: value (supports hyphens in key names) - const kvMatch = line.match(/^([\w][\w_-]*)\s*:\s*(.*)$/); - if (kvMatch) { - currentKey = kvMatch[1]; - const val = kvMatch[2].trim(); - - if (val === '' || val === '[]') { - currentArray = []; - } else if (val.startsWith('[') && val.endsWith(']')) { - const inner = val.slice(1, -1).trim(); - result[currentKey] = inner ? inner.split(',').map(s => s.trim()) : []; - currentKey = null; - } else { - result[currentKey] = val; - currentKey = null; - } - } - } - - // Flush final key - if (currentKey) { - if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { - currentArray.push(currentObj); - currentObj = null; - } - if (currentArray) { - result[currentKey] = currentArray; - } - } - - return result; -} +// parseFrontmatterMap from shared now supports hyphenated keys natively function parseSummaryFrontmatter(fm: Record): PlanningSummaryFrontmatter { return { @@ -473,7 +392,7 @@ function parseSummaryFrontmatter(fm: Record): PlanningSummaryFr */ export function parseOldSummary(content: string, fileName: string = '', planNumber: string = ''): PlanningSummary { const [fmLines, body] = splitFrontmatter(content); - const fm = fmLines ? parseFrontmatterMapHyphen(fmLines) : {}; + const fm = fmLines ? parseFrontmatterMap(fmLines) : {}; return { fileName, diff --git a/src/resources/extensions/shared/frontmatter.ts b/src/resources/extensions/shared/frontmatter.ts new file mode 100644 index 000000000..38a34ea02 --- /dev/null +++ b/src/resources/extensions/shared/frontmatter.ts @@ -0,0 +1,117 @@ +// Shared frontmatter parsing utilities +// Canonical implementation for splitting and parsing YAML-like frontmatter. + +/** Strip matching single or double quotes from a string value (standard YAML scalar behavior). */ +function stripQuotes(s: string): string { + if (s.length >= 2 && ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'"))) { + return s.slice(1, -1); + } + return s; +} + +/** + * Split markdown content into frontmatter (YAML-like) and body. + * Returns [frontmatterLines, body] where frontmatterLines is null if no frontmatter. + */ +export function splitFrontmatter(content: string): [string[] | null, string] { + const trimmed = content.trimStart(); + if (!trimmed.startsWith('---')) return [null, content]; + + const afterFirst = trimmed.indexOf('\n'); + if (afterFirst === -1) return [null, content]; + + const rest = trimmed.slice(afterFirst + 1); + const endIdx = rest.indexOf('\n---'); + if (endIdx === -1) return [null, content]; + + const fmLines = rest.slice(0, endIdx).split('\n'); + const body = rest.slice(endIdx + 4).replace(/^\n+/, ''); + return [fmLines, body]; +} + +/** + * Parse YAML-like frontmatter lines into a flat key-value map. + * Handles simple scalars and arrays (lines starting with " - "). + * Handles nested objects like requires (lines with " key: value"). + * Supports hyphenated keys (e.g. `tech-stack:`). + */ +export function parseFrontmatterMap(lines: string[]): Record { + const result: Record = {}; + let currentKey: string | null = null; + let currentArray: unknown[] | null = null; + let currentObj: Record | null = null; + + for (const line of lines) { + // Nested object property (4-space indent with key: value) + const nestedMatch = line.match(/^ ([\w][\w_-]*)\s*:\s*(.*)$/); + if (nestedMatch && currentArray && currentObj) { + currentObj[nestedMatch[1]] = nestedMatch[2].trim(); + continue; + } + + // Array item (2-space indent) + const arrayMatch = line.match(/^ - (.*)$/); + if (arrayMatch && currentKey) { + // If there's a pending nested object, push it + if (currentObj && Object.keys(currentObj).length > 0) { + currentArray!.push(currentObj); + } + currentObj = null; + + const val = arrayMatch[1].trim(); + if (!currentArray) currentArray = []; + + // Check if this array item starts a nested object (e.g. "- slice: S00") + const nestedStart = val.match(/^([\w][\w_-]*)\s*:\s*(.*)$/); + if (nestedStart) { + currentObj = { [nestedStart[1]]: nestedStart[2].trim() }; + } else { + currentArray.push(stripQuotes(val)); + } + continue; + } + + // Flush previous key + if (currentKey) { + if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { + currentArray.push(currentObj); + currentObj = null; + } + if (currentArray) { + result[currentKey] = currentArray; + } + currentArray = null; + } + + // Top-level key: value (supports hyphens in key names) + const kvMatch = line.match(/^([\w][\w_-]*)\s*:\s*(.*)$/); + if (kvMatch) { + currentKey = kvMatch[1]; + const val = kvMatch[2].trim(); + + if (val === '' || val === '[]') { + currentArray = []; + } else if (val.startsWith('[') && val.endsWith(']')) { + const inner = val.slice(1, -1).trim(); + result[currentKey] = inner ? inner.split(',').map(s => s.trim()) : []; + currentKey = null; + } else { + result[currentKey] = stripQuotes(val); + currentKey = null; + } + } + } + + // Flush final key + if (currentKey) { + if (currentObj && Object.keys(currentObj).length > 0 && currentArray) { + currentArray.push(currentObj); + currentObj = null; + } + if (currentArray) { + result[currentKey] = currentArray; + } + } + + return result; +} diff --git a/src/resources/extensions/ttsr/rule-loader.ts b/src/resources/extensions/ttsr/rule-loader.ts index deff72365..5c2c10f92 100644 --- a/src/resources/extensions/ttsr/rule-loader.ts +++ b/src/resources/extensions/ttsr/rule-loader.ts @@ -9,53 +9,7 @@ import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join, basename } from "node:path"; import { homedir } from "node:os"; import type { Rule } from "./ttsr-manager.js"; - -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; - -/** Minimal YAML parser for frontmatter (handles string arrays and scalars). */ -function parseFrontmatter(raw: string): Record { - const result: Record = {}; - let currentKey: string | null = null; - let currentArray: string[] | null = null; - - for (const line of raw.split("\n")) { - const trimmed = line.trimEnd(); - - // Array item under current key - if (currentKey && /^\s+-\s+/.test(trimmed)) { - const value = trimmed.replace(/^\s+-\s+/, "").replace(/^["']|["']$/g, ""); - currentArray!.push(value); - continue; - } - - // Flush previous array - if (currentKey && currentArray) { - result[currentKey] = currentArray; - currentKey = null; - currentArray = null; - } - - // Key-value or key-with-array - const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.*)$/); - if (kvMatch) { - const [, key, value] = kvMatch; - if (value.length === 0) { - // Expect array items below - currentKey = key; - currentArray = []; - } else { - result[key] = value.replace(/^["']|["']$/g, ""); - } - } - } - - // Flush trailing array - if (currentKey && currentArray) { - result[currentKey] = currentArray; - } - - return result; -} +import { splitFrontmatter, parseFrontmatterMap } from "../shared/frontmatter.js"; function parseRuleFile(filePath: string): Rule | null { let content: string; @@ -65,11 +19,10 @@ function parseRuleFile(filePath: string): Rule | null { return null; } - const match = FRONTMATTER_RE.exec(content); - if (!match) return null; + const [fmLines, body] = splitFrontmatter(content); + if (!fmLines) return null; - const [, frontmatterRaw, body] = match; - const meta = parseFrontmatter(frontmatterRaw); + const meta = parseFrontmatterMap(fmLines); const condition = meta.condition; if (!Array.isArray(condition) || condition.length === 0) return null;