fix: consolidate frontmatter parsing into shared module (#1040)

* fix: consolidate frontmatter parsing into shared module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-17 18:44:25 -06:00 committed by GitHub
parent 7ba993cbfb
commit 6a2a6a9e2c
4 changed files with 127 additions and 240 deletions

View file

@ -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<string, unknown> {
const result: Record<string, unknown> = {};
let currentKey: string | null = null;
let currentArray: unknown[] | null = null;
let currentObj: Record<string, string> | 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

View file

@ -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<string, unknown> {
const result: Record<string, unknown> = {};
let currentKey: string | null = null;
let currentArray: unknown[] | null = null;
let currentObj: Record<string, string> | 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<string, unknown>): PlanningSummaryFrontmatter {
return {
@ -473,7 +392,7 @@ function parseSummaryFrontmatter(fm: Record<string, unknown>): 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,

View file

@ -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<string, unknown> {
const result: Record<string, unknown> = {};
let currentKey: string | null = null;
let currentArray: unknown[] | null = null;
let currentObj: Record<string, string> | 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;
}

View file

@ -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<string, unknown> {
const result: Record<string, unknown> = {};
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;