Merge pull request #2141 from open-gsd/chore/2128-phase-id-drift-guard
chore(#2121): phase-id anti-divergence guard + markdown ReDoS hardening — Phase 4
This commit is contained in:
5
.changeset/phase-id-redos-hardening.md
Normal file
5
.changeset/phase-id-redos-hardening.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Security
|
||||
pr: 2141
|
||||
---
|
||||
**Hardened phase/roadmap/plan markdown parsing against quadratic-time (ReDoS) CPU exhaustion** — a crafted `ROADMAP.md`, `STATE.md`, or `PLAN.md` with large runs of unclosed `(`, `[`, `<tag>`, `<!--`, or `<details>` could drive the phase-header, Plans-count, `files_modified`, and `<tag>`-block parsers into O(n²) scans (tens of seconds on a ~1.5 MB file). Every affected regex is now linear: header tag/bracket clauses are length-bounded, the Plans-count scan is section-local, and all `<tag>…</tag>` extraction routes through a single ReDoS-safe seam. (#2128)
|
||||
@@ -1442,7 +1442,7 @@ function reconcileByPhaseTable(content, deps, timestamp, log) {
|
||||
* source to substitute. This is honest — better than silently leaving `[X]`
|
||||
* which looks like a value.
|
||||
*/
|
||||
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
|
||||
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/;
|
||||
function stripTemplatePlaceholders(content, timestamp, log) {
|
||||
// Scan body `**Field:** value` lines; when value matches the placeholder
|
||||
// shape, replace with `(pending)`. We deliberately do NOT touch fields that
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"check:env": "node scripts/check-env.cjs",
|
||||
"check:alias-drift": "node scripts/check-alias-drift.cjs",
|
||||
"check:identity-drift": "node scripts/lint-package-identity-drift.cjs",
|
||||
"check:phase-id-drift": "node scripts/lint-phase-id-drift.cjs",
|
||||
"check:integrity": "node scripts/check-npm-integrity.cjs",
|
||||
"build": "npm run generate:identity && npm run build:lib && npm run gen:plugin-skills && npm run gen:loop-host-contract && npm run gen:capability-registry && npm run build:hooks",
|
||||
"build:hooks": "node scripts/build-hooks.js",
|
||||
|
||||
150
scripts/lint-phase-id-drift.cjs
Normal file
150
scripts/lint-phase-id-drift.cjs
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Anti-divergence drift guard for the phase-identifier parsing seam
|
||||
* (epic #2121, Phase 4 / issue #2128, locked by ADR-2121 Decision 7).
|
||||
*
|
||||
* `src/phase-id.cts` is the SINGLE canonical owner of phase-ID parsing. Its
|
||||
* `PHASE_NUMBER_TOKEN_SOURCE` (and `phaseMarkdownRegexSource` for a known number)
|
||||
* is the one place the phase-number-token grammar `\d+[A-Z]?(?:\.\d+)*` is
|
||||
* defined. Every other module that scans/enumerates phase headings must build
|
||||
* its regex from that source rather than re-deriving the grammar as a literal —
|
||||
* otherwise the trio drifts again (the #2111 / #2114 / #2104 recurrence loop this
|
||||
* epic closes).
|
||||
*
|
||||
* This lint makes the invariant machine-enforced: it FAILS the moment a literal
|
||||
* re-derivation of the canonical token grammar is introduced anywhere in
|
||||
* `src/**` outside `phase-id.cts`, unless the site is deliberately sanctioned
|
||||
* with a `// phase-id-owner: <reason>` comment (on the same line or the line
|
||||
* directly above). Sites that build their regex from `PHASE_NUMBER_TOKEN_SOURCE`
|
||||
* carry no literal grammar and pass automatically.
|
||||
*
|
||||
* Detection is intentionally NARROW: only the contiguous canonical token
|
||||
* (`\d+[A-Z]?(?:\.\d+)*`, its `[A-Za-z]` and `[.-]` near-variants, in both
|
||||
* regex-literal `\d` and `new RegExp` template `\\d` escaping) is drift. Bare
|
||||
* `\d+` probes, `[\w][\w.-]*` ids, digits-only captures, status-message text
|
||||
* (`Phase\s+\d`), and pipe-table structures are NOT phase-token re-derivations
|
||||
* and are not flagged.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// The canonical phase-number token as it appears in SOURCE TEXT:
|
||||
// \d+[A-Z]?(?:\.\d+)* in a regex literal -> one backslash before d/.
|
||||
// \\d+[A-Z]?(?:\\.\\d+)* in a template string -> two backslashes
|
||||
// Tolerated near-variants so a trivial rewrite does not silently evade the guard:
|
||||
// digit class \d \\d or [0-9]
|
||||
// letter class [A-Z] or [A-Za-z]
|
||||
// sub-phase sep \. \\. or [.-] (dot-or-dash)
|
||||
// KNOWN, ACCEPTED limits of a per-line textual scan (covered instead by the
|
||||
// identity guard + code review, not by this regex): a re-derivation split
|
||||
// across lines via string concatenation, a capturing `(\.\d+)*` in place of the
|
||||
// non-capturing group, or a semantically-equivalent restructuring. This guard
|
||||
// targets the common case — an accidental copy of the exact grammar — not an
|
||||
// adversary deliberately obfuscating a re-derivation.
|
||||
const TOKEN_DRIFT_RE = /(?:\\{1,2}d|\[0-9\])\+\[A-Z(?:a-z)?\]\??\(\?:(?:\\{1,2}\.|\[\.-\])(?:\\{1,2}d|\[0-9\])\+\)\*/;
|
||||
|
||||
// A `phase-id-owner:` sanction must be a DEDICATED `//` comment line (the marker
|
||||
// as the line's leading token). A `//` or the phrase embedded in a string
|
||||
// literal or trailing a code line is NOT a comment and must never suppress a real
|
||||
// flag — so sanctions live on their own line directly above the regex.
|
||||
const OWNER_RE = /^\s*\/\/.*phase-id-owner:/;
|
||||
const CANON_REF = 'PHASE_NUMBER_TOKEN_SOURCE';
|
||||
|
||||
/**
|
||||
* Pure: find every literal re-derivation of the canonical phase-number token in
|
||||
* `text` that is NOT sanctioned. A site is sanctioned when the nearest preceding
|
||||
* NON-BLANK line is a dedicated `// phase-id-owner:` comment (blank lines between
|
||||
* the comment and the regex are tolerated, so an auto-formatter cannot reactivate
|
||||
* the flag), or when the regex line references `PHASE_NUMBER_TOKEN_SOURCE` (built
|
||||
* from the canonical source, not a literal). A `//`/phrase inside a string or
|
||||
* trailing a code line does NOT count — put the sanction on its own line above.
|
||||
* Returns [{ line, found }].
|
||||
*/
|
||||
function findPhaseIdRegexDrift(text) {
|
||||
const out = [];
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const m = TOKEN_DRIFT_RE.exec(line);
|
||||
if (!m) continue;
|
||||
if (line.includes(CANON_REF)) continue;
|
||||
let j = i - 1;
|
||||
while (j >= 0 && lines[j].trim() === '') j--; // nearest preceding non-blank line
|
||||
if (j >= 0 && OWNER_RE.test(lines[j])) continue;
|
||||
out.push({ line: i + 1, found: m[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Authored TypeScript source only (the generated bin/lib/*.cjs mirror it).
|
||||
const SCAN_DIRS = ['src'];
|
||||
const SCAN_EXT = new Set(['.cts', '.ts', '.mts']);
|
||||
// The canonical owner defines the grammar; it is exempt by construction.
|
||||
const EXEMPT = new Set([path.join('src', 'phase-id.cts')]);
|
||||
|
||||
function walk(dir, acc) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
||||
walk(full, acc);
|
||||
} else if (entry.isFile() && SCAN_EXT.has(path.extname(entry.name))) {
|
||||
acc.push(full);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the authored source tree and return every unsanctioned phase-token
|
||||
* re-derivation, each annotated with the repo-relative file path.
|
||||
*/
|
||||
function scanRepo(root) {
|
||||
const violations = [];
|
||||
for (const dir of SCAN_DIRS) {
|
||||
for (const file of walk(path.join(root, dir), [])) {
|
||||
const rel = path.relative(root, file);
|
||||
if (EXEMPT.has(rel)) continue;
|
||||
let text;
|
||||
try {
|
||||
text = fs.readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const d of findPhaseIdRegexDrift(text)) {
|
||||
violations.push({ file: rel, ...d });
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const root = path.join(__dirname, '..');
|
||||
const violations = scanRepo(root);
|
||||
if (violations.length === 0) {
|
||||
process.stdout.write('ok phase-id-drift: no unsanctioned phase-token re-derivations outside phase-id.cts\n');
|
||||
return;
|
||||
}
|
||||
process.stderr.write('phase-id-drift: literal re-derivation(s) of the canonical phase-number token found.\n');
|
||||
process.stderr.write('Build the regex from phase-id.cjs `PHASE_NUMBER_TOKEN_SOURCE` (or phaseMarkdownRegexSource for a\n');
|
||||
process.stderr.write('known number), or sanction the site with a dedicated `// phase-id-owner: <reason>`\n');
|
||||
process.stderr.write('comment on the line directly above the regex:\n');
|
||||
for (const d of violations) {
|
||||
process.stderr.write(` ${d.file}:${d.line} ${d.found}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { findPhaseIdRegexDrift, scanRepo, TOKEN_DRIFT_RE };
|
||||
@@ -20,6 +20,9 @@ const { planningDir } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -482,7 +485,7 @@ function scanUatGaps(planDir: string): UatGapItem[] {
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
|
||||
let files: string[];
|
||||
@@ -552,7 +555,7 @@ function scanVerificationGaps(planDir: string): VerificationGapItem[] {
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
|
||||
let files: string[];
|
||||
@@ -614,7 +617,7 @@ function scanContextQuestions(planDir: string): ContextQuestionItem[] {
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
|
||||
let files: string[];
|
||||
|
||||
@@ -139,11 +139,14 @@ function loadPlanContents(phaseDir: string): string[] {
|
||||
}
|
||||
|
||||
const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i;
|
||||
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi;
|
||||
const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]{0,1000})?>((?:(?!<(?:objective|tasks?|action)[\s>])[\s\S])*?)<\/(?:objective|tasks?|action)>/gi;
|
||||
|
||||
function stripCommentsAndFences(text: string): string {
|
||||
// HTML-comment stripping stays caller-side (the seam does not strip HTML comments).
|
||||
const htmlStripped = text.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
// Stop-at-next-open body (ReDoS-safe, #2128); an UNCLOSED `<!--` does not match,
|
||||
// so downstream tags are preserved (unlike a `(?:-->|$)` fallback, which would
|
||||
// wipe to EOF and fail-close the decision-coverage gate).
|
||||
const htmlStripped = text.replace(/<!--(?:(?!<!--)[\s\S])*?-->/g, ' ');
|
||||
// Fenced-code stripping: delegate to the canonical CommonMark-correct seam.
|
||||
// replaces the prior independent regex copy (```` ``` ``` ```` + `~~~ ~~~`).
|
||||
return stripFencedCode(htmlStripped).text;
|
||||
|
||||
@@ -1334,7 +1334,7 @@ function cmdTodoMatchPhase(cwd: string, phase: string | undefined, raw: boolean)
|
||||
for (const pf of planFiles) {
|
||||
const planContent = platformReadSync(path.join(phaseDir, pf));
|
||||
if (planContent === null) continue;
|
||||
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
|
||||
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
|
||||
if (fmFiles) {
|
||||
phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
|
||||
}
|
||||
@@ -1516,8 +1516,8 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
// Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
|
||||
// Also tolerates optional [bracket-token] scope prefix on phase headings.
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
||||
const key = normalizePhaseName(match[1]);
|
||||
|
||||
30
src/init.cts
30
src/init.cts
@@ -73,7 +73,7 @@ const {
|
||||
extractCurrentMilestone,
|
||||
} = roadmapParser;
|
||||
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
|
||||
const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId;
|
||||
const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId;
|
||||
const { pruneOrphanedWorktrees } = worktreeSafety;
|
||||
|
||||
const {
|
||||
@@ -1161,8 +1161,8 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(currentSection)) !== null) {
|
||||
if (/^999(?:\.|$)/.test(m[1])) continue;
|
||||
@@ -1181,7 +1181,7 @@ function cmdInitMilestoneOp(cwd: string, raw: boolean): void {
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isDirectory()) continue;
|
||||
const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/);
|
||||
const m = stripProjectCodePrefix(e.name).match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`));
|
||||
if (!m) continue;
|
||||
diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
|
||||
}
|
||||
@@ -1319,14 +1319,14 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
})();
|
||||
|
||||
const _checkboxStates = new Map<string, boolean>();
|
||||
const _cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
||||
const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
|
||||
let _cbMatch: RegExpExecArray | null;
|
||||
while ((_cbMatch = _cbPattern.exec(content)) !== null) {
|
||||
_checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
|
||||
}
|
||||
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
||||
const phases: Record<string, unknown>[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -1465,7 +1465,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
);
|
||||
const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number'] as string), p]));
|
||||
|
||||
const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
||||
const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
|
||||
let _allMatch: RegExpExecArray | null;
|
||||
while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
|
||||
const phaseNum = normalizePhaseNumber(_allMatch[1]);
|
||||
@@ -1499,7 +1499,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
||||
) {
|
||||
phase['deps_satisfied'] = true;
|
||||
} else {
|
||||
const depNums = (phase['depends_on'] as string).match(/\d+[A-Z]?(?:\.\d+)*/gi) || [];
|
||||
const depNums = (phase['depends_on'] as string).match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || [];
|
||||
phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
|
||||
phase['dep_phases'] = depNums;
|
||||
}
|
||||
@@ -1688,14 +1688,14 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
|
||||
fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'),
|
||||
cwd,
|
||||
);
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
||||
let hm: RegExpExecArray | null;
|
||||
while ((hm = headingPattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhaseNums.add(hm[1]);
|
||||
roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
|
||||
}
|
||||
const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
|
||||
const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
|
||||
let cbm: RegExpExecArray | null;
|
||||
while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
|
||||
roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
|
||||
@@ -1714,14 +1714,14 @@ function cmdInitProgress(cwd: string, raw: boolean): void {
|
||||
.map((e) => e.name)
|
||||
.filter(isDirInMilestone)
|
||||
.sort((a, b) => {
|
||||
const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const pa = a.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
const pb = b.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
if (!pa || !pb) return a.localeCompare(b);
|
||||
return parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
||||
});
|
||||
|
||||
for (const dir of dirs) {
|
||||
const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
const dirMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
||||
const phaseNumber = dirMatch ? dirMatch[1] : dir;
|
||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||
seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0');
|
||||
|
||||
@@ -520,25 +520,25 @@ export function iterateBullets(sectionText: string): BulletItem[] {
|
||||
* fenced code blocks itself. If a `<tagName>` block appears inside a fenced code
|
||||
* block and should be excluded, the caller should apply `stripFencedCode` first.
|
||||
*
|
||||
* **Nested tags are NOT supported.** The underlying regex uses a non-greedy
|
||||
* `[\s\S]*?` match, which means it closes at the FIRST `</tagName>` encountered.
|
||||
* Given `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns
|
||||
* `['<x>inner']` — the inner `<x>` is captured as literal text, and the second
|
||||
* `</x>` is left unmatched (or matched as a second block with empty inner text
|
||||
* if another `<x>` follows). Callers that need to handle nested tags must
|
||||
* pre-process the input or use a proper XML/HTML parser.
|
||||
* **Nested tags are NOT supported.** The body scan terminates at the NEXT
|
||||
* opening of the same tag (the ReDoS-safe boundary, #2128). Given
|
||||
* `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns `['inner']`
|
||||
* — the well-formed inner block; the unterminated outer `<x>` is skipped.
|
||||
* Callers that need true nesting must use a proper XML/HTML parser.
|
||||
*
|
||||
* `allowAttributes` (default `false`): when `true`, the opening tag may carry
|
||||
* bounded attributes (`<tag foo="x">`) — needed for `<task type="…">` blocks.
|
||||
* Leave `false` for tags that must match exactly (e.g. `<decisions>`), and never
|
||||
* enable it for a tag where an attributed form is semantically distinct.
|
||||
*
|
||||
* Generalises `decisions.cts`'s bespoke `matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)`
|
||||
* so tier T1 can drop its own copy (tracked duplication until T1 lands).
|
||||
*/
|
||||
export function extractTaggedBlocks(content: string, tagName: string): string[] {
|
||||
export function extractTaggedBlocks(content: string, tagName: string, allowAttributes = false): string[] {
|
||||
if (typeof content !== 'string' || content.length === 0) return [];
|
||||
if (typeof tagName !== 'string' || tagName.length === 0) return [];
|
||||
|
||||
// Escape the tag name for safe interpolation into a RegExp.
|
||||
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g');
|
||||
|
||||
const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
|
||||
const results: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
@@ -547,6 +547,43 @@ export function extractTaggedBlocks(content: string, tagName: string): string[]
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
|
||||
* `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
|
||||
*
|
||||
* Safety: the body terminates at the NEXT opening of this tag (stop-at-next-open)
|
||||
* instead of lazily rescanning the whole remaining document for a `</tag>` that
|
||||
* may never appear — so a document full of unclosed `<tag>` openings scans
|
||||
* LINEARLY, not quadratically (#2128). Group 1 is the block body.
|
||||
*
|
||||
* `allowAttributes`: when `true`, the opener accepts bounded attributes
|
||||
* (`<tag foo="x">`) and the body boundary is `<tag` followed by a space or `>`.
|
||||
* When `false`, the opener is the EXACT `<tag>` and the boundary is exact `<tag>`,
|
||||
* so an attributed `<tag foo>` is neither an opener nor a boundary — it is body
|
||||
* content. That exact form is load-bearing for `<details>` stripping: `<details
|
||||
* open>` marks the ACTIVE milestone and must be preserved, not stripped (#557).
|
||||
*/
|
||||
function taggedBlockPattern(tagName: string, flags: string, allowAttributes: boolean): RegExp {
|
||||
const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const open = allowAttributes ? `<${esc}(?:\\s[^>]{0,1000})?>` : `<${esc}>`;
|
||||
const boundary = allowAttributes ? `<${esc}[\\s>]` : `<${esc}>`;
|
||||
return new RegExp(`${open}((?:(?!${boundary})[\\s\\S])*?)</${esc}>`, flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every `<tagName>…</tagName>` block (opening tag, body, and closing tag)
|
||||
* from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same
|
||||
* hardened pattern, `.replace(…, '')` instead of body extraction. `allowAttributes`
|
||||
* defaults to `false` so `<details open>` (active milestone) is preserved (#557);
|
||||
* case-insensitive by default (matching the `<details>` strip call sites), pass
|
||||
* `caseSensitive` to force exact-case matching.
|
||||
*/
|
||||
export function stripTaggedBlocks(content: string, tagName: string, allowAttributes = false, caseSensitive = false): string {
|
||||
if (typeof content !== 'string' || content.length === 0) return '';
|
||||
if (typeof tagName !== 'string' || tagName.length === 0) return content;
|
||||
return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi', allowAttributes), '');
|
||||
}
|
||||
|
||||
// ─── replaceSection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@ import ioMod = require('./io.cjs');
|
||||
const { output, error } = ioMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod;
|
||||
const { escapeRegex, normalizePhaseName, phaseTokenMatches, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
|
||||
@@ -176,8 +176,8 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
|
||||
if (stateVersion && stateVersion === version) {
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
||||
const noDirectoryPhases: string[] = [];
|
||||
let pm: RegExpExecArray | null;
|
||||
const phaseDirEntries = ((): string[] => {
|
||||
|
||||
@@ -37,9 +37,21 @@ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
|
||||
// Enumeration/parse call sites that read phase headers from a regex *literal*
|
||||
// (rather than a `new RegExp` built from an interpolated phase number) cannot
|
||||
// reference this constant; they inline its literal-regex mirror instead —
|
||||
// `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this
|
||||
// `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this
|
||||
// source. Both forms must change together; see the #1729 regression test.
|
||||
const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?';
|
||||
const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
||||
|
||||
// #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an
|
||||
// optional single-letter variant suffix and optional dotted sub-phases
|
||||
// (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to
|
||||
// phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source
|
||||
// for ONE KNOWN number; reference this constant when a call site must match ANY
|
||||
// phase and capture its token. Enumeration/parse sites inline this into a
|
||||
// `new RegExp(...)` instead of re-deriving the grammar as a literal, so every
|
||||
// phase-token producer shares one owner. The anti-divergence guard
|
||||
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
|
||||
// introduced outside this module without a `// phase-id-owner:` justification.
|
||||
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
||||
|
||||
function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string {
|
||||
const input = String(value);
|
||||
@@ -350,6 +362,7 @@ export = {
|
||||
escapeRegex,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
OPTIONAL_PHASE_TAG_SOURCE,
|
||||
PHASE_NUMBER_TOKEN_SOURCE,
|
||||
stripProjectCodePrefix,
|
||||
normalizePhaseName,
|
||||
getMilestoneFromPhaseId,
|
||||
|
||||
@@ -37,6 +37,7 @@ const {
|
||||
phaseTokenMatches,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
OPTIONAL_PHASE_TAG_SOURCE,
|
||||
PHASE_NUMBER_TOKEN_SOURCE,
|
||||
} = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
||||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
@@ -374,8 +375,9 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
|
||||
if (!match) continue;
|
||||
|
||||
const dirMatch =
|
||||
match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
|
||||
match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
match.match(
|
||||
new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')
|
||||
) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
||||
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||
|
||||
@@ -711,13 +713,13 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?:
|
||||
// (section header, roadmap bullet, or on-disk directory) is counted:
|
||||
|
||||
// 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||||
// 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
|
||||
// The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
|
||||
// or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
|
||||
// are counted and cannot collide with a freshly-added phase. (#1229)
|
||||
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
||||
const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
|
||||
|
||||
const usedPhaseNums = new Set<number>();
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -809,8 +811,8 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
let maxPhase = 0;
|
||||
if (config.phase_naming !== 'custom') {
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
@@ -1193,7 +1195,7 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
// #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
|
||||
// is re-emitted verbatim — a tagged later phase still gets renumbered.
|
||||
content = content.replace(
|
||||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi,
|
||||
/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi,
|
||||
(_match, prefix: string, num: string, suffix: string) =>
|
||||
`${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`,
|
||||
);
|
||||
@@ -1517,7 +1519,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
}
|
||||
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i',
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(
|
||||
@@ -1672,7 +1674,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
.sort((a, b) => comparePhaseNum(a, b));
|
||||
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
|
||||
if (dm) {
|
||||
if (/^999(?:\.|$)/.test(dm[1])) continue;
|
||||
if (comparePhaseNum(dm[1], phaseNum) > 0) {
|
||||
@@ -1702,10 +1704,13 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
// phase. Allow optional `**`/`__` emphasis after the marker and stop
|
||||
// the name capture at emphasis so bold names slug cleanly; the number
|
||||
// capture is unchanged.
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
|
||||
// ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
|
||||
// `### Phase N (Cluster B): X` resolves. Captures are unchanged.
|
||||
const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
|
||||
const phasePattern = new RegExp(
|
||||
`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
|
||||
'gi'
|
||||
);
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
|
||||
if (comparePhaseNum(pm[1], phaseNum) > 0) {
|
||||
@@ -1741,8 +1746,10 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
if (isLastPhase && roadmapContent !== null) {
|
||||
try {
|
||||
const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const cbPattern =
|
||||
/-\s*\[(x| )\]\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
|
||||
const cbPattern = new RegExp(
|
||||
`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`,
|
||||
'gi'
|
||||
);
|
||||
let cbm: RegExpExecArray | null;
|
||||
let lowestOutstanding: { num: string; name: string } | null = null;
|
||||
while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
|
||||
|
||||
@@ -67,13 +67,14 @@ function checkW021(content: string): W021Warning[] {
|
||||
// Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0
|
||||
// OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates)
|
||||
// Capture the major integer.
|
||||
const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
|
||||
const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
|
||||
|
||||
// Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]{0,200}\))?\s*:/i;
|
||||
// Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
|
||||
const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i;
|
||||
// phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE.
|
||||
const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
|
||||
|
||||
let currentMilestoneMajor: number | null = null;
|
||||
const lines = content.split('\n');
|
||||
|
||||
@@ -32,7 +32,7 @@ const {
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
import { platformReadSync } from './shell-command-projection.cjs';
|
||||
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
import { tokenizeHeadings, stripTaggedBlocks } from './markdown-sectionizer.cjs';
|
||||
|
||||
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
||||
|
||||
@@ -40,7 +40,7 @@ import { tokenizeHeadings } from './markdown-sectionizer.cjs';
|
||||
* Strip shipped milestone content wrapped in <details> blocks.
|
||||
*/
|
||||
function stripShippedMilestones(content: string): string {
|
||||
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
||||
return stripTaggedBlocks(content, 'details');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,10 +96,9 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
|
||||
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
|
||||
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
|
||||
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
|
||||
const preamble = content.slice(0, preambleCutoff)
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
const preamble = stripTaggedBlocks(content.slice(0, preambleCutoff), 'details')
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
return preamble + content.slice(detailsOpenIdx, detailsEnd);
|
||||
}
|
||||
@@ -177,10 +176,9 @@ function extractCurrentMilestone(content: string, cwd?: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
const preamble = beforeMilestones
|
||||
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
const preamble = stripTaggedBlocks(beforeMilestones, 'details')
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]{0,200}\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
||||
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
||||
|
||||
return detailsSection
|
||||
@@ -215,7 +213,7 @@ interface RoadmapPhaseResult {
|
||||
function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null {
|
||||
// #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
|
||||
const headingPattern = new RegExp(
|
||||
`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
|
||||
`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
|
||||
'i'
|
||||
);
|
||||
const headings = tokenizeHeadings(content);
|
||||
@@ -370,7 +368,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
|
||||
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
||||
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
||||
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
||||
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings && phaseIdConvention === 'milestone-prefixed') {
|
||||
console.warn(
|
||||
'[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
|
||||
@@ -427,8 +425,8 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
|
||||
// Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
|
||||
// T4 seam migration: phase headings inside fences are excluded automatically.
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseHeadingPattern = /^(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
|
||||
for (const h of tokenizeHeadings(roadmap)) {
|
||||
if (h.level < 2 || h.level > 4) continue;
|
||||
const pm = phaseHeadingPattern.exec(h.text);
|
||||
@@ -459,6 +457,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
// the milestone as a bogus "46-6" id.
|
||||
const numericRe = roadmapUsesHyphenedIds
|
||||
? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
|
||||
// phase-id-owner: the [A-Za-z] letter class does real case handling here — this regex carries NO /i flag; kept literal, not source-byte-equal to the canonical PHASE_NUMBER_TOKEN_SOURCE.
|
||||
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
||||
|
||||
function isDirInMilestone(dirName: string): boolean {
|
||||
|
||||
@@ -16,20 +16,23 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
const { stripProjectCodePrefix } = phaseIdMod;
|
||||
const { stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
|
||||
// ─── Regex helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
|
||||
// Captures: (hashes)(spaces)(phase-number)(rest-of-line)
|
||||
const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
|
||||
const LEGACY_PHASE_HEADING_RE = new RegExp(
|
||||
`^(#{2,4})\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})\\s*:(.*)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
// Matches already-migrated phase headings: ### Phase M-NN: Name
|
||||
const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
|
||||
const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
|
||||
|
||||
// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
|
||||
// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
|
||||
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
|
||||
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -172,7 +175,7 @@ function extractPhaseNumFromDir(dirName: string): string | null {
|
||||
const stripped = stripProjectCodePrefix(dirName);
|
||||
// Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
|
||||
// e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
|
||||
const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
||||
const m = stripped.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})(?:-|$)`, 'i'));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
@@ -188,7 +191,7 @@ function buildNewDirName(oldDirName: string, newId: string, projectCode: string
|
||||
const stripped = stripProjectCodePrefix(oldDirName);
|
||||
|
||||
// Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
|
||||
const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
|
||||
const slugMatch = stripped.match(new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}-(.*)`, 'i'));
|
||||
const slug = slugMatch ? slugMatch[1] : stripped;
|
||||
|
||||
// Build M-NN prefix (zero-pad both parts)
|
||||
@@ -341,7 +344,7 @@ function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}
|
||||
// Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
|
||||
const oldLine = lines[entry.lineIndex];
|
||||
const newLine = oldLine.replace(
|
||||
/^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i,
|
||||
new RegExp(`^(#{2,4}\\s*(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*:)`, 'i'),
|
||||
`$1${mapping.newId}$2`
|
||||
);
|
||||
if (newLine !== oldLine) {
|
||||
@@ -364,7 +367,9 @@ function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}
|
||||
if (roadmapEdits.some(e => e.lineIndex === i)) continue;
|
||||
|
||||
// Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
|
||||
const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
|
||||
const checklistMatch = line.match(
|
||||
new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)(${PHASE_NUMBER_TOKEN_SOURCE})(\\s*[:\\s*])`, 'i')
|
||||
);
|
||||
if (checklistMatch) {
|
||||
const legacyNum = checklistMatch[2];
|
||||
const cIntPart = parseInt(legacyNum, 10);
|
||||
@@ -393,7 +398,7 @@ function computeMigrationPlan(cwd: string, options: Record<string, unknown> = {}
|
||||
|
||||
if (newId) {
|
||||
const newLine = line.replace(
|
||||
/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i,
|
||||
new RegExp(`^(\\s*-\\s*\\[[ x]\\]\\s*\\*{0,2}Phase\\s+)${PHASE_NUMBER_TOKEN_SOURCE}(\\s*[:\\s*])`, 'i'),
|
||||
`$1${newId}$2`
|
||||
);
|
||||
if (newLine !== line) {
|
||||
|
||||
@@ -126,7 +126,7 @@ function countPhasePlansAndSummaries(phaseDir: string): PhasePlansAndSummaries {
|
||||
function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: string): PhaseSearchResult | null {
|
||||
// #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
|
||||
const headingPattern = new RegExp(
|
||||
`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
|
||||
`^(?:\\[[^\\]]{1,200}\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`,
|
||||
'i'
|
||||
);
|
||||
const headings = tokenizeHeadings(content);
|
||||
@@ -297,8 +297,9 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
const phasesDir = planningPaths(cwd).phases;
|
||||
|
||||
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
|
||||
const phases: Array<{
|
||||
number: string;
|
||||
name: string;
|
||||
@@ -343,7 +344,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
const restOfContent = content.slice(sectionStart);
|
||||
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
|
||||
// recognised as section boundaries.
|
||||
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
|
||||
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]{1,200}\]\s*)?Phase\s+\d[\d.-]*/i);
|
||||
const sectionEnd = nextHeader ? sectionStart + nextHeader.index! : content.length;
|
||||
const section = content.slice(sectionStart, sectionEnd);
|
||||
|
||||
@@ -437,6 +438,7 @@ function cmdRoadmapAnalyze(cwd: string, raw: boolean): void {
|
||||
// The char class must allow `-` (not just `.`) so dash-separated milestone-prefixed
|
||||
// IDs (e.g. `1-01`) match the detail-heading scanner above; otherwise they truncate
|
||||
// at the dash (`1-01` -> `1`) and every such phase reports a phantom missing detail.
|
||||
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
||||
const checklistPattern = /-\s*\[[ x]\]\s*\*\*Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)/gi;
|
||||
const checklistPhases = new Set<string>();
|
||||
let checklistMatch: RegExpExecArray | null;
|
||||
@@ -543,7 +545,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
// `**Plans:** N plans` — bold "Plans:" (colon inside bold)
|
||||
// `Plans: N plans` — plain text header
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`,
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
@@ -613,11 +615,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
|
||||
// Pattern A: anchor to bare `Plans:` header (preferred).
|
||||
// Pattern B: fallback to bold summary when no bare header exists.
|
||||
const insertRowsPatternA = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
const insertRowsPatternB = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
|
||||
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
|
||||
|
||||
@@ -1818,7 +1818,7 @@ function reconcileByPhaseTable(
|
||||
* source to substitute. This is honest — better than silently leaving `[X]`
|
||||
* which looks like a value.
|
||||
*/
|
||||
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]+\]\s*$|^\s*-\s*$/;
|
||||
const TEMPLATE_PLACEHOLDER_VALUE = /^\s*\[[^\]]{1,200}\]\s*$|^\s*-\s*$/;
|
||||
|
||||
function stripTemplatePlaceholders(
|
||||
content: string,
|
||||
|
||||
@@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs');
|
||||
const { loadConfig } = configLoaderMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod;
|
||||
const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import roadmapParserMod = require('./roadmap-parser.cjs');
|
||||
const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
|
||||
@@ -1406,6 +1406,7 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// neither the denominator nor the numerator (mirrors the heading
|
||||
// exclusion below). Project-code-aware via phaseKeyFromDir.
|
||||
if (retiredPhaseNums.size > 0 && retiredPhaseNums.has(phaseKeyFromDir(dir))) continue;
|
||||
// phase-id-owner: dir-name dedup grouping; diverges from extractPhaseToken/phaseKeyFromDir on project-code-prefixed and multi-segment milestone dirs. Kept local.
|
||||
const m = dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
||||
const key = m ? m[1].toLowerCase() : dir;
|
||||
if (!seenPhaseNums.has(key)) {
|
||||
@@ -1441,8 +1442,8 @@ function buildStateFrontmatter(bodyContent: string, cwd: string | undefined): Re
|
||||
// truth for total_phases (#549).
|
||||
let roadmapPhaseCount = 0;
|
||||
if (roadmapScope !== null) {
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
|
||||
// Only count tokens that contain at least one digit — excludes
|
||||
@@ -2394,7 +2395,7 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
if (completed) diskCompletedPhases++;
|
||||
|
||||
// Track the highest phase with incomplete plans (or any plans)
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
if (phaseMatch && plans > 0) {
|
||||
if (summaries < plans) {
|
||||
// Incomplete phase — this is likely the current one
|
||||
@@ -2418,8 +2419,8 @@ function cmdStateSync(cwd: string, options: StateSyncOptions | undefined, raw: b
|
||||
try {
|
||||
let roadmapPhaseCount = 0;
|
||||
if (syncRoadmapScope !== null) {
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phaseHeadingPattern.exec(syncRoadmapScope)) !== null) {
|
||||
// Only count tokens that contain at least one digit — excludes
|
||||
|
||||
@@ -29,6 +29,9 @@ const { planningDir } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -82,7 +85,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
.sort();
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
const phaseDir = path.join(phasesDir, dir);
|
||||
const files = fs.readdirSync(phaseDir);
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
|
||||
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
|
||||
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
||||
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
||||
@@ -62,7 +62,7 @@ export function canonicalPlanStem(stem: string): string {
|
||||
// #2043: the plan component (after the phase number) must be zero-padded
|
||||
// (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
|
||||
// for a "46-6" phase/plan pair.
|
||||
const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d{2,})/i);
|
||||
const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i'));
|
||||
return m ? m[1] : stem;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV
|
||||
const roadmapPhaseVariants = new Set<string>();
|
||||
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
|
||||
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = phasePattern.exec(roadmapContent)) !== null) {
|
||||
roadmapPhases.add(m[1]);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PACKAGE_NAME } from './package-identity.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs';
|
||||
import { isCanonicalPlanningFile } from './artifacts.cjs';
|
||||
import { extractTaggedBlocks } from './markdown-sectionizer.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
|
||||
import agentInstallCheck = require('./agent-install-check.cjs');
|
||||
const { checkAgentsInstalled } = agentInstallCheck;
|
||||
@@ -37,7 +38,7 @@ import configLoaderMod = require('./config-loader.cjs');
|
||||
const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
|
||||
const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { findPhaseInternal } = phaseLocatorMod;
|
||||
@@ -205,8 +206,13 @@ function scanNegativeGrepCommentEcho(content: string): { errors: string[]; warni
|
||||
// while a prose echo on the same line is still caught.
|
||||
const cmdSpanRe =
|
||||
/grep(?:\s+-{1,2}[A-Za-z][A-Za-z-]*)+\s+(?:'[^']*'|"[^"]*"|[^\s'"|>&;]+)[^\n]*?(?:==|-eq|=)\s*0\b/g;
|
||||
// Security scan: must see the FULL text up to the first </action> — including a
|
||||
// malformed inner <action> — so a grep-echo-0 trick cannot hide behind a
|
||||
// deliberately-unclosed tag. Use a bounded to-first-close scan (ReDoS-safe via
|
||||
// the {0,20000} cap, #2128), NOT the stop-at-next-open extractTaggedBlocks seam
|
||||
// (which would drop the span before an unterminated inner <action>).
|
||||
const actionZones: string[] = [];
|
||||
const actionRe = /<action>([\s\S]*?)<\/action>/g;
|
||||
const actionRe = /<action>([\s\S]{0,20000}?)<\/action>/g;
|
||||
let acm: RegExpExecArray | null;
|
||||
while ((acm = actionRe.exec(text)) !== null) actionZones.push(acm[1]);
|
||||
const scannableActionText = actionZones.map((zone) => zone.replace(cmdSpanRe, ' ')).join('\n');
|
||||
@@ -364,32 +370,21 @@ function scanFileWideNegativeGateConflict(content: string): { warnings: string[]
|
||||
gateText: string; // <verify>+<automated>+<acceptance_criteria> text
|
||||
reqText: string; // <action>+<acceptance_criteria> text (requirement side)
|
||||
}
|
||||
const taskRe = /<task[^>]*>([\s\S]*?)<\/task>/g;
|
||||
const tasks: TaskInfo[] = [];
|
||||
let tm: RegExpExecArray | null;
|
||||
while ((tm = taskRe.exec(text)) !== null) {
|
||||
const tc = tm[1];
|
||||
for (const tc of extractTaggedBlocks(text, 'task', true)) {
|
||||
// Extract task name.
|
||||
const namem = tc.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const name = namem ? namem[1].trim() : 'unnamed';
|
||||
const namem = extractTaggedBlocks(tc, 'name');
|
||||
const name = namem.length ? namem[0].trim() : 'unnamed';
|
||||
// Extract <files> entries.
|
||||
const filesm = tc.match(/<files>([\s\S]*?)<\/files>/);
|
||||
const filesText = filesm ? filesm[1] : '';
|
||||
const filesArr = extractTaggedBlocks(tc, 'files');
|
||||
const filesText = filesArr.length ? filesArr[0] : '';
|
||||
const files = filesText.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
|
||||
// Gate text: <verify>/<automated>/<acceptance_criteria>.
|
||||
const gateFragments: string[] = [];
|
||||
for (const tag of ['verify', 'automated', 'acceptance_criteria']) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(tc)) !== null) gateFragments.push(mm[1]);
|
||||
}
|
||||
for (const tag of ['verify', 'automated', 'acceptance_criteria']) gateFragments.push(...extractTaggedBlocks(tc, tag));
|
||||
// Requirement text: <action>/<acceptance_criteria>.
|
||||
const reqFragments: string[] = [];
|
||||
for (const tag of ['action', 'acceptance_criteria']) {
|
||||
const re = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
||||
let mm: RegExpExecArray | null;
|
||||
while ((mm = re.exec(tc)) !== null) reqFragments.push(mm[1]);
|
||||
}
|
||||
for (const tag of ['action', 'acceptance_criteria']) reqFragments.push(...extractTaggedBlocks(tc, tag));
|
||||
// Strip XML tags from gate text so segments containing embedded
|
||||
// XML closing tags (e.g. <automated>cmd</automated> nested inside <verify>)
|
||||
// don't bleed into the file-path token extraction.
|
||||
@@ -577,19 +572,16 @@ function cmdVerifyPlanStructure(cwd: string, filePath: string, raw: boolean): vo
|
||||
if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`);
|
||||
}
|
||||
|
||||
const taskPattern = /<task[^>]*>([\s\S]*?)<\/task>/g;
|
||||
const tasks: Record<string, unknown>[] = [];
|
||||
let taskMatch: RegExpExecArray | null;
|
||||
while ((taskMatch = taskPattern.exec(content)) !== null) {
|
||||
const taskContent = taskMatch[1];
|
||||
const nameMatch = taskContent.match(/<name>([\s\S]*?)<\/name>/);
|
||||
const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
|
||||
for (const taskContent of extractTaggedBlocks(content, 'task', true)) {
|
||||
const nameArr = extractTaggedBlocks(taskContent, 'name');
|
||||
const taskName = nameArr.length ? nameArr[0].trim() : 'unnamed';
|
||||
const hasFiles = /<files>/.test(taskContent);
|
||||
const hasAction = /<action>/.test(taskContent);
|
||||
const hasVerify = /<verify>/.test(taskContent);
|
||||
const hasDone = /<done>/.test(taskContent);
|
||||
|
||||
if (!nameMatch) errors.push('Task missing <name> element');
|
||||
if (nameArr.length === 0) errors.push('Task missing <name> element');
|
||||
if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
|
||||
if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
|
||||
if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
|
||||
@@ -1073,7 +1065,7 @@ function checkMilestonePrefixMismatches(
|
||||
): MilestoneMismatch[] {
|
||||
const mismatches: MilestoneMismatch[] = [];
|
||||
const sections: { version: string; start: number; end: number }[] = [];
|
||||
const sectionRx = /^#{1,3}\s+(?:\[[^\]]+\]\s*)?.*v(\d+\.\d+)/gim;
|
||||
const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = sectionRx.exec(roadmapContent)) !== null) {
|
||||
if (sections.length > 0) sections[sections.length - 1].end = m.index;
|
||||
@@ -1081,8 +1073,8 @@ function checkMilestonePrefixMismatches(
|
||||
}
|
||||
for (const section of sections) {
|
||||
const content = roadmapContent.slice(section.start, section.end);
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = phaseRx.exec(content)) !== null) {
|
||||
const phaseId = pm[1];
|
||||
@@ -1302,14 +1294,18 @@ function cmdValidateHealth(
|
||||
repairs.push('regenerateState');
|
||||
} else {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
const phaseRefs = [...stateContent.matchAll(/[Pp]hase\s+(\d+[A-Z]?(?:\.\d+)*)/g)].map(
|
||||
const phaseRefs = [
|
||||
...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')),
|
||||
].map(
|
||||
(m) => m[1],
|
||||
);
|
||||
const validPhases = collectDiskPhases(planBase);
|
||||
try {
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const all = [...roadmapRaw.matchAll(/#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)/gi)];
|
||||
const all = [
|
||||
...roadmapRaw.matchAll(new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi')),
|
||||
];
|
||||
for (const m of all) validPhases.add(m[1]);
|
||||
}
|
||||
} catch {
|
||||
@@ -1808,8 +1804,8 @@ function cmdValidateHealth(
|
||||
if (isMarkedComplete) {
|
||||
const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const scopedContent = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
// #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
|
||||
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
||||
const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
||||
const unstarted: string[] = [];
|
||||
let pm: RegExpExecArray | null;
|
||||
// Non-hoisted: load-order matters (circular dep guard)
|
||||
@@ -2090,7 +2086,7 @@ function cmdVerifySchemaDrift(
|
||||
const planFiles = fs.readdirSync(phaseDir).filter((f) => f.endsWith('-PLAN.md'));
|
||||
for (const pf of planFiles) {
|
||||
const content = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
|
||||
const fmMatch = content.match(/files_modified:\s*\[([^\]]*)\]/);
|
||||
const fmMatch = content.match(/files_modified:\s*\[([^\]]{0,8000})\]/);
|
||||
if (fmMatch) {
|
||||
const files = fmMatch[1].split(',').map((f) => f.trim()).filter(Boolean);
|
||||
allFiles.push(...files);
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
collectSection,
|
||||
iterateBullets,
|
||||
extractTaggedBlocks,
|
||||
stripTaggedBlocks,
|
||||
replaceSection,
|
||||
} = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
||||
|
||||
@@ -1015,15 +1016,14 @@ describe('stripFencedCode and tokenizeHeadings: backtick info string with backti
|
||||
|
||||
// ─── FIX 6: extractTaggedBlocks — nested tag behavior ─────────────────────────
|
||||
|
||||
describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitation)', () => {
|
||||
test('nested <x><x>…</x></x> closes at first </x> (non-greedy; nested tags not supported)', () => {
|
||||
// Non-greedy match: <x>([\s\S]*?)</x> closes at the FIRST </x>.
|
||||
// So <x><x>inner</x></x> → first block captures "<x>inner", second </x> is unmatched.
|
||||
describe('extractTaggedBlocks: nested same-name tag behavior (#2128 stop-at-next-open)', () => {
|
||||
test('nested <x><x>inner</x></x> extracts the well-formed inner block', () => {
|
||||
// #2128: the ReDoS-safe body scan terminates at the NEXT opening <x>, so the
|
||||
// unterminated outer <x> is skipped and the inner block is extracted.
|
||||
const content = '<x><x>inner</x></x>';
|
||||
const result = extractTaggedBlocks(content, 'x');
|
||||
// The first match closes at the first </x>, capturing "<x>inner"
|
||||
assert.equal(result.length, 1, 'non-greedy match produces exactly one result from nested input');
|
||||
assert.equal(result[0], '<x>inner', 'inner capture is the content up to the first closing tag');
|
||||
assert.equal(result.length, 1, 'exactly one result from nested input');
|
||||
assert.equal(result[0], 'inner', 'the well-formed inner block is extracted; the unterminated outer is skipped');
|
||||
});
|
||||
|
||||
test('back-to-back blocks (not nested) are both extracted', () => {
|
||||
@@ -1033,6 +1033,24 @@ describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitat
|
||||
assert.equal(result[0], 'first');
|
||||
assert.equal(result[1], 'second');
|
||||
});
|
||||
|
||||
test('#2128: a document full of unclosed <x> openings stays linear and yields no match', () => {
|
||||
const content = '<x>a\n'.repeat(50) + 'no closing tag';
|
||||
assert.deepEqual(extractTaggedBlocks(content, 'x'), [], 'no </x> anywhere -> no blocks');
|
||||
});
|
||||
|
||||
test('#557 / #2128: attr-intolerant by default preserves <details open>; opt-in matches <task type=…>', () => {
|
||||
// stripTaggedBlocks(details) must PRESERVE <details open> (the active-milestone
|
||||
// marker) and strip only bare <details>; extractTaggedBlocks(task, true) must
|
||||
// match attributed tasks, and must NOT when allowAttributes is left false.
|
||||
assert.equal(
|
||||
stripTaggedBlocks('X<details>shipped</details>Y<details open>active</details>Z', 'details'),
|
||||
'XY<details open>active</details>Z',
|
||||
'#557: <details open> preserved; bare <details> stripped',
|
||||
);
|
||||
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task', true), ['body'], 'attributed task matched with allowAttributes=true');
|
||||
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task'), [], 'attributed task NOT matched with allowAttributes=false');
|
||||
});
|
||||
});
|
||||
|
||||
// Parity guard removed in T5 (ADR-1372): uat-predicate now imports stripFencedCode
|
||||
|
||||
172
tests/phase-id-drift-guard.test.cjs
Normal file
172
tests/phase-id-drift-guard.test.cjs
Normal file
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Anti-divergence guard for the phase-identifier parsing seam
|
||||
* (epic #2121 Phase 4 / issue #2128, ADR-2121 Decision 7).
|
||||
*
|
||||
* `src/phase-id.cts` is the single canonical owner of phase-ID parsing. Two guards
|
||||
* keep it that way:
|
||||
* 1. DRIFT SCANNER (scripts/lint-phase-id-drift.cjs) — fails CI if any module
|
||||
* outside phase-id.cts re-derives the canonical phase-number token as a
|
||||
* literal without a `// phase-id-owner:` sanction.
|
||||
* 2. IDENTITY guard — phase-id.cjs exports the complete locked surface, and no
|
||||
* consumer re-exports a DIVERGENT copy of a canonical function (re-export,
|
||||
* never re-implement).
|
||||
*
|
||||
* Behavioral throughout: assertions drive `findPhaseIdRegexDrift` / `scanRepo`
|
||||
* and compare object identity — no `readFileSync().includes()` in a test body.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { findPhaseIdRegexDrift, scanRepo } = require(
|
||||
path.join(ROOT, 'scripts', 'lint-phase-id-drift.cjs'),
|
||||
);
|
||||
const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs'));
|
||||
|
||||
// The locked canonical surface (ADR-2121 Decision 1/2; PHASE_NUMBER_TOKEN_SOURCE
|
||||
// added in Phase 4). Every name is exported by phase-id.cjs; the identity guard
|
||||
// forbids any other module from re-exporting a divergent copy of one.
|
||||
const CANONICAL = [
|
||||
'escapeRegex', 'OPTIONAL_PROJECT_CODE_PREFIX_SOURCE', 'OPTIONAL_PHASE_TAG_SOURCE',
|
||||
'PHASE_NUMBER_TOKEN_SOURCE', 'stripProjectCodePrefix', 'normalizePhaseName',
|
||||
'getMilestoneFromPhaseId', 'getPhaseDirFromPhaseId', 'phaseMarkdownRegexSource',
|
||||
'phaseMarkdownRegexSourceExact', 'comparePhaseNum', 'extractPhaseToken',
|
||||
'phaseTokenMatches', 'parsePhaseFromProse', 'stripConfiguredProjectCodePrefix',
|
||||
'isForeignPrefixedPhaseQuery', 'roadmapPhaseLookupSources',
|
||||
];
|
||||
|
||||
describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => {
|
||||
test('a regex built from PHASE_NUMBER_TOKEN_SOURCE is NOT drift', () => {
|
||||
assert.deepEqual(
|
||||
findPhaseIdRegexDrift('const re = new RegExp(`Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`);'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a literal re-derivation of the canonical token IS flagged (fail-first)', () => {
|
||||
const v = findPhaseIdRegexDrift('const re = /Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].found, '\\d+[A-Z]?(?:\\.\\d+)*');
|
||||
});
|
||||
|
||||
test('a re-derivation inside a new RegExp template (\\\\d escaping) IS flagged', () => {
|
||||
const v = findPhaseIdRegexDrift('new RegExp(`Phase\\\\s+(\\\\d+[A-Z]?(?:\\\\.\\\\d+)*)`)');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
|
||||
test('the [A-Za-z], [.-] and [0-9] near-variants ARE flagged (no trivial evasion)', () => {
|
||||
assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Za-z]?(?:\\.\\d+)*)/').length, 1, '[A-Za-z] letter class');
|
||||
assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Z]?(?:[.-]\\d+)*)/').length, 1, '[.-] separator');
|
||||
assert.equal(findPhaseIdRegexDrift('/([0-9]+[A-Z]?(?:\\.[0-9]+)*)/').length, 1, '[0-9] in place of \\d');
|
||||
});
|
||||
|
||||
test('a dedicated preceding // phase-id-owner: comment line suppresses the flag', () => {
|
||||
assert.deepEqual(
|
||||
findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a blank line between the // phase-id-owner: comment and the regex still suppresses', () => {
|
||||
assert.deepEqual(
|
||||
findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a trailing same-line // phase-id-owner: is NOT a sanction (must be a dedicated line above)', () => {
|
||||
// The marker must lead its own comment line; a trailing comment on a code
|
||||
// line is not honored, so the regex is still flagged.
|
||||
const v = findPhaseIdRegexDrift('const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/; // phase-id-owner: not honored here');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
|
||||
test('a // phase-id-owner: embedded in a STRING literal does NOT suppress (decoy)', () => {
|
||||
// A `//` inside a string is not a comment — help/doc text that quotes the
|
||||
// sanction syntax must not silently suppress a real re-derivation.
|
||||
const decoyLine = findPhaseIdRegexDrift('const help = "use // phase-id-owner: <reason>"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(decoyLine.length, 1);
|
||||
const decoyPrev = findPhaseIdRegexDrift('const help = "use // phase-id-owner: <reason>";\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(decoyPrev.length, 1);
|
||||
});
|
||||
|
||||
test('a bare "phase-id-owner:" substring with no // does NOT suppress', () => {
|
||||
const v = findPhaseIdRegexDrift('const msg = "ping the phase-id-owner for review"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
|
||||
test('non-token phase regexes are NOT flagged (no false positives)', () => {
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/^Executing Phase\\s+\\d+/'), [], 'status-message bare \\d+');
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/#{2,4}\\s*Phase\\s+(\\d+)[A-Z]?(?:\\.\\d+)*/'), [], 'digits-only capture is non-contiguous');
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/Phase\\s+([\\w][\\w.-]*)/'), [], '\\w id grammar is not the canonical token');
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/\\|\\s*Phase\\s*\\|\\s*Plans\\s*\\|/'), [], 'pipe-table structure');
|
||||
});
|
||||
|
||||
test('reports 1-based line numbers', () => {
|
||||
const v = findPhaseIdRegexDrift('line1\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;\nline3');
|
||||
assert.equal(v[0].line, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2128 phase-id drift scanner: the live repo is clean', () => {
|
||||
test('scanRepo finds zero unsanctioned phase-token re-derivations', () => {
|
||||
const violations = scanRepo(ROOT);
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
'unsanctioned phase-token re-derivation(s) — build from PHASE_NUMBER_TOKEN_SOURCE or add // phase-id-owner:\n' +
|
||||
violations.map((d) => ` ${d.file}:${d.line} ${d.found}`).join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2128 phase-id single-owner identity guard', () => {
|
||||
test('phase-id.cjs exports the complete locked canonical surface', () => {
|
||||
for (const name of CANONICAL) {
|
||||
assert.ok(name in phaseId, `phase-id.cjs must export the canonical member '${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no consumer module re-exports a DIVERGENT copy of a canonical phase-id function', () => {
|
||||
// Forward guard: if any built lib module re-exports a name that phase-id.cjs
|
||||
// owns, it MUST be the identical reference — a re-export, never a local
|
||||
// re-implementation. All consumers pass today (none re-export); the guard
|
||||
// fails the moment a divergent copy ships.
|
||||
const libDir = path.join(ROOT, 'gsd-core', 'bin', 'lib');
|
||||
const consumers = fs.readdirSync(libDir).filter((f) => f.endsWith('.cjs') && f !== 'phase-id.cjs');
|
||||
let checked = 0;
|
||||
const requireFailures = [];
|
||||
for (const f of consumers) {
|
||||
let mod;
|
||||
try {
|
||||
mod = require(path.join(libDir, f));
|
||||
} catch (e) {
|
||||
// Surfaced, not silently skipped — a module that cannot be required
|
||||
// would otherwise erode the guard's coverage without any signal.
|
||||
requireFailures.push(`${f}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
if (!mod || typeof mod !== 'object') continue; // bare-function exports carry no named canonical member
|
||||
checked++;
|
||||
for (const name of CANONICAL) {
|
||||
if (Object.prototype.hasOwnProperty.call(mod, name)) {
|
||||
assert.strictEqual(
|
||||
mod[name],
|
||||
phaseId[name],
|
||||
`${f} re-exports '${name}' but it is NOT the phase-id.cjs reference — re-export the canonical, do not re-implement`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(requireFailures, [], `consumer module(s) failed to require (guard coverage would silently degrade):\n ${requireFailures.join('\n ')}`);
|
||||
// Coverage floor: the vast majority of the ~150 built lib modules export an
|
||||
// object and must actually be inspected — not a token "at least one".
|
||||
assert.ok(checked > consumers.length * 0.75, `expected to inspect most of the ${consumers.length} consumer modules, only inspected ${checked}`);
|
||||
});
|
||||
});
|
||||
@@ -348,6 +348,23 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header
|
||||
assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present');
|
||||
});
|
||||
|
||||
test('#2128: the pre-colon tag is length-bounded so the tag clause cannot ReDoS', () => {
|
||||
// The tag body `[^)\n]*` was unbounded, making the optional-group + /g scan
|
||||
// quadratic on adversarial ROADMAP.md/STATE.md (a long run of `(` after a
|
||||
// header). Bounding it to {0,200} keeps the match linear; a 200-char tag body
|
||||
// still matches (real tags are a handful of chars), 201 does not.
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
|
||||
// Boundary coverage (CLAUDE.md): limit-1, limit, limit+1.
|
||||
assert.ok(re.test(`### Phase 26 (${'x'.repeat(199)}): T`), 'a 199-char tag body (limit-1) is within the bound');
|
||||
assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body (limit) is within the bound');
|
||||
assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body (limit+1) exceeds the bound');
|
||||
// Linearity guard: the adversarial input that was ~18.8s unbounded resolves
|
||||
// near-instantly now. Assert bounded work, not wall-clock (no clock seam):
|
||||
// the bounded source contains an explicit upper repetition limit.
|
||||
assert.match(phaseId.OPTIONAL_PHASE_TAG_SOURCE, /\{0,\d+\}/, 'tag body must carry an explicit upper bound');
|
||||
});
|
||||
|
||||
test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => {
|
||||
// The resolver (get-phase) and the capture-all enumeration regexes are
|
||||
// separate code paths. Fixing only the resolver left `roadmap analyze`
|
||||
@@ -410,11 +427,12 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header
|
||||
|
||||
test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => {
|
||||
// Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites
|
||||
// inline `(?:\s*\([^)\n]*\))?`. If one is edited without the other the two
|
||||
// header families silently diverge. Assert behavioral equivalence over a
|
||||
// representative header corpus so the split cannot drift undetected.
|
||||
// inline `(?:\s*\([^)\n]{0,200}\))?`. If one is edited without the other the
|
||||
// two header families silently diverge (the body is bounded to {0,200} in
|
||||
// both since #2128 — a ReDoS fix that MUST stay in lockstep). Assert
|
||||
// behavioral equivalence over a representative header corpus.
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]*\\))?';
|
||||
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
||||
const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
|
||||
const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`);
|
||||
for (const sample of [
|
||||
|
||||
@@ -77,6 +77,19 @@ describe('roadmap-parser: stripShippedMilestones', () => {
|
||||
assert.ok(!result.includes('closed content'), 'content removed');
|
||||
assert.ok(result.includes('after'), 'after content preserved');
|
||||
});
|
||||
|
||||
test('#557: preserves an active <details open> block while stripping shipped bare <details>', () => {
|
||||
// <details open> marks the ACTIVE milestone (roadmap.analyze must still see its
|
||||
// phases); only closed/shipped bare <details> blocks are stripped. Regression for
|
||||
// #557, which the #2128 shared-seam migration briefly reintroduced via the seam's
|
||||
// attribute-tolerance — the details strip is now attr-INTOLERANT to keep #557 fixed.
|
||||
const input = '<details>\nshipped phase\n</details>\n<details open>\n- [ ] **Phase 9: Active**\n</details>\nafter';
|
||||
const result = stripShippedMilestones(input);
|
||||
assert.ok(!result.includes('shipped phase'), 'shipped bare <details> stripped');
|
||||
assert.ok(result.includes('<details open>'), 'active <details open> tag preserved');
|
||||
assert.ok(result.includes('Phase 9: Active'), 'active-milestone phases preserved');
|
||||
assert.ok(result.includes('after'), 'trailing content preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractCurrentMilestone ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user