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:
parent
7ba993cbfb
commit
6a2a6a9e2c
4 changed files with 127 additions and 240 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
117
src/resources/extensions/shared/frontmatter.ts
Normal file
117
src/resources/extensions/shared/frontmatter.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue