fix: semver 3+ segment parsing and CRLF frontmatter corruption recovery

- fix(core): getMilestoneInfo() version regex `\d+\.\d+` only matched
  2-segment versions (v1.2). Changed to `\d+(?:\.\d+)+` to support
  3+ segments (v1.2.1, v2.0.1). Same fix in roadmap.cjs milestone
  extraction pattern.

- fix(state): stripFrontmatter() used `^---\n` (LF-only) which failed
  to strip CRLF frontmatter blocks. When STATE.md had dual frontmatter
  blocks from prior CRLF corruption, each writeStateMd() call preserved
  the stale block and prepended a new wrong one. Now handles CRLF and
  strips all stacked frontmatter blocks.

- fix(frontmatter): extractFrontmatter() always used the first ---
  block. When dual blocks exist from corruption, the first is stale.
  Now uses the last block (most recent sync).
This commit is contained in:
0Shard
2026-03-17 17:18:48 +02:00
parent 4915d28f98
commit 7156f02ed5
4 changed files with 23 additions and 7 deletions

View File

@@ -512,7 +512,8 @@ function getMilestoneInfo(cwd) {
// First: check for list-format roadmaps using 🚧 (in-progress) marker
// e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+\.\d+)\s+([^*]+)\*\*/);
// e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (in progress)"
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (inProgressMatch) {
return {
version: 'v' + inProgressMatch[1],
@@ -523,15 +524,16 @@ function getMilestoneInfo(cwd) {
// Second: heading-format roadmaps — strip shipped milestones in <details> blocks
const cleaned = stripShippedMilestones(roadmap);
// Extract version and name from the same ## heading for consistency
const headingMatch = cleaned.match(/## .*v(\d+\.\d+)[:\s]+([^\n(]+)/);
// Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc.
const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
if (headingMatch) {
return {
version: 'v' + headingMatch[1],
name: headingMatch[2].trim(),
};
}
// Fallback: try bare version match
const versionMatch = cleaned.match(/v(\d+\.\d+)/);
// Fallback: try bare version match (greedy — capture longest version string)
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
return {
version: versionMatch ? versionMatch[0] : 'v1.0',
name: 'milestone',

View File

@@ -10,7 +10,11 @@ const { safeReadFile, normalizeMd, output, error } = require('./core.cjs');
function extractFrontmatter(content) {
const frontmatter = {};
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
// Find ALL frontmatter blocks at the start of the file.
// If multiple blocks exist (corruption from CRLF mismatch), use the LAST one
// since it represents the most recent state sync.
const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)];
const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null;
if (!match) return frontmatter;
const yaml = match[1];

View File

@@ -181,7 +181,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
// Extract milestone info
const milestones = [];
const milestonePattern = /##\s*(.*v(\d+\.\d+)[^(\n]*)/gi;
const milestonePattern = /##\s*(.*v(\d+(?:\.\d+)+)[^(\n]*)/gi;
let mMatch;
while ((mMatch = milestonePattern.exec(content)) !== null) {
milestones.push({

View File

@@ -664,7 +664,17 @@ function buildStateFrontmatter(bodyContent, cwd) {
}
function stripFrontmatter(content) {
return content.replace(/^---\n[\s\S]*?\n---\n*/, '');
// Strip ALL frontmatter blocks at the start of the file.
// Handles CRLF line endings and multiple stacked blocks (corruption recovery).
// Greedy: keeps stripping ---...--- blocks separated by optional whitespace.
let result = content;
// eslint-disable-next-line no-constant-condition
while (true) {
const stripped = result.replace(/^\s*---\r?\n[\s\S]*?\r?\n---\s*/, '');
if (stripped === result) break;
result = stripped;
}
return result;
}
function syncStateFrontmatter(content, cwd) {