'use strict'; /** * Shared library for the compact-content partition guard (ADR-4139 Decision 5, * epic #4139, Phase 3 #4403). See `docs/PARTITION-RULES.md` for the operational * spec this module implements — it is the source of truth for behavior; this * file is the mechanics. * * A "registered split" is any `msd-core/workflows//detail/*.md` path * paired with the spine at `msd-core/workflows/.md`. There is no * separate registry — a pair is registered by existing on disk * (`discoverRegisteredSplits`). Everything else here is one of the five * checks PARTITION-RULES.md describes, or a primitive those checks share: * * 1. Completeness — NOT implemented here (fires once, at split time, and * needs the merge-base spine content the way the pilot test already * reads it; `normalizeNonTrivialLines` is the shared primitive it needs). * 2. Disjointness — `checkDisjointness`. * 3. Registration — `checkRegistration`. * 4. Protected content — `extractProtectedBlocks` extracts the sentinel- * wrapped spans; the guard test compares their presence itself. * 5. Boundary moves — `readBoundaryMoveTrailers`. * * `checkDetailFileSizeCap` is the pilot's size-cap assertion, generalized. * * This module only reads (filesystem + `git log`/`git merge-base`, both * read-only). No writes, no network. */ const fs = require('node:fs'); const path = require('node:path'); const { REPO_ROOT, GIT_TIMEOUT_MS, safeDirArgs, } = require('./emitted-runtime.cjs'); const { NEW_FILE_CAP, ACK_TRAILER_DELIM, normalizeAckReason } = require('./emitted-diff.cjs'); const { runGit, OUTCOME } = require('./process-seam.cjs'); /** Default scan root: `msd-core/workflows/` at repo root. */ const DEFAULT_WORKFLOWS_DIR = path.join(__dirname, '..', '..', 'msd-core', 'workflows'); /** * Discover every registered spine/detail split under `workflowsDir`. * * A split is registered by a `//detail/` DIRECTORY (never * a `/detail.md` FILE — only an actual `detail/` subdirectory counts) * containing at least one `*.md` file, where `` is exactly ONE path * segment directly under `workflowsDir` — a `detail/` dir nested two or more * segments deep (e.g. `/sub/detail/`) is walked (so its own children * are still found) but is never itself registered, per PARTITION-RULES.md's * "no nested split names" contract. * * Pure enumeration: a missing spine is reported here as `spineExists: false`, * never thrown — deciding that's a failure is check 3's (registration) job, * not discovery's. * * @param {string} [workflowsDir] * @returns {Array<{name: string, spinePath: string, detailPaths: string[], spineExists: boolean}>} */ function discoverRegisteredSplits(workflowsDir = DEFAULT_WORKFLOWS_DIR) { const found = new Map(); // name -> split record, first registration wins function walk(dir) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; // unreadable directory: nothing to discover under it } for (const entry of entries) { if (!entry.isDirectory()) continue; const full = path.join(dir, entry.name); if (entry.name === 'detail') { let mdFiles = []; try { mdFiles = fs.readdirSync(full, { withFileTypes: true }) .filter((e) => e.isFile() && e.name.endsWith('.md')) .map((e) => e.name); } catch { mdFiles = []; } if (mdFiles.length > 0) { // `dir` is detail/'s PARENT — the segment(s) between workflowsDir // and dir are the candidate ``. Only a single segment counts. const segments = path.relative(workflowsDir, dir).split(path.sep).filter(Boolean); if (segments.length === 1) { const name = segments[0]; if (!found.has(name)) { const spinePath = path.join(workflowsDir, `${name}.md`); const detailPaths = mdFiles.map((f) => path.join(full, f)).sort(); found.set(name, { name, spinePath, detailPaths, spineExists: fs.existsSync(spinePath) }); } } } } walk(full); } } walk(workflowsDir); return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); } /** * "Trivial" lines (fences, `---` rules, bare headings, bare `Label:` lines) * are boilerplate that legitimately repeats throughout any markdown file with * code blocks — excluding them from the completeness/disjointness checks is * what keeps those checks from flaring on structure rather than content. * Ported verbatim from the pilot's `tests/plan-phase-compact-split.test.cjs`, * which this module supersedes as Phase 3's generalized version of the same * check. * * Bare shell block-closer/reopener keywords (#4405) are the same class of * problem in a bash-heavy workflow like execute-phase.md: a lone `fi` or * `done` line carries no content of its own — it is pure block-structure * syntax that recurs once per `if`/`for`/`while` anywhere in the file. A * split that extracts even one `if...fi` block will otherwise always collide * with some unrelated `if...fi` block left in the spine, exactly the * structure-not-content false positive this function exists to suppress. * * A bare XML-ish tag line (#4405) — ``, ``, * `` — is the markup equivalent of the same problem: every * workflow in this corpus repeats these tags once per step/template block, so * any split that extracts even one such block collides with an unrelated one * left in the spine. The tag NAME and attributes carry structure, never prose * content, so treating the whole line as trivial is the same judgment call * `isKnownSanctionedBoilerplate` already makes for the shared launcher line — * generalized here since it recurs for any tag, not one specific string. */ function isTrivial(line) { if (/^`{3,}/.test(line)) return true; if (/^-{3,}$/.test(line)) return true; if (/^#+\s*$/.test(line)) return true; if (/^[A-Za-z][A-Za-z ]*:$/.test(line)) return true; // bare label lines like "Options:" if (/^(fi|done|esac|else|then|do|\{|\})\s*;?\s*$/.test(line)) return true; // bare shell block syntax if (/^<\/?[A-Za-z][\w-]*(\s+[^<>]*)?>$/.test(line)) return true; // bare open/close tag, alone on its own line return false; } /** * Normalize file content into the comparable unit the completeness and * disjointness checks both operate on: trimmed, non-empty, non-trivial * lines, in order, duplicates preserved (callers choose Set vs array * depending whether they need membership or a count). * * @param {string} content * @returns {string[]} */ function normalizeNonTrivialLines(content) { return content .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l.length > 0 && !isTrivial(l)); } /** * Known-boilerplate line prefixes (#4405), beyond the launcher preamble below: * an exact, verbatim paragraph or call-opener this codebase repeats at every * agent-spawn callsite across the ENTIRE corpus, not just within one file. * `execute-phase.md`, `docs-update.md`, and `new-project.md` (at minimum) each * spawn multiple agents and each carries its own copy of these — the same * sanctioned-duplication shape as the launcher preamble, just keyed on a set * of known strings instead of one. */ const KNOWN_BOILERPLATE_PREFIXES = [ '> **ORCHESTRATOR RULE — CODEX RUNTIME**:', 'Agent(prompt="', ]; /** * Is `line` one of the corpus's known sanctioned cross-file duplicates: the * canonical `msd_run` launcher bootstrap preamble (see * `msd-core/workflows/_runtime-launcher.snippet.sh`, * `tests/runtime-launcher-parity.test.cjs`), or one of the `KNOWN_BOILERPLATE_PREFIXES` * above? * * The launcher preamble's own guard mandates exactly one inlined copy in every * workflow/detail file that calls `msd_run` — spine and detail both call it, * so both legitimately carry their own copy. That is sanctioned * cross-file duplication, not something the disjointness check should ever * flag, so it is filtered out wherever this module compares lines across a * spine/detail pair. * * @param {string} line * @returns {boolean} */ function isKnownSanctionedBoilerplate(line) { if (line.startsWith('_MSD_SHIM_NAME="msd-tools.cjs";')) return true; return KNOWN_BOILERPLATE_PREFIXES.some((prefix) => line.startsWith(prefix)); } const PROTECTED_START = ''; const PROTECTED_END = ''; const PROTECTED_SINGLE = ''; /** * Extract every `` (single-line) and * ``/`` (paired) * sentinel-wrapped block from `content`. * * Never throws on a malformed sentinel (unclosed start, orphan end, a single * marker with no following content, a paired block enclosing nothing) — * those are reported best-effort in `blocks` (covering whatever content is * actually present, or an empty span) AND named explicitly in `malformed`, * mirroring the well-formedness rules `tests/plan-phase-compact-split.test.cjs`'s * `'every msd:protected sentinel...'` test already enforced for the one * existing split. The check-4 caller decides what to do with a malformed * sentinel; this function's job is only to describe the file accurately. * * Single-marker coverage: from the marker line, skip blank lines, then * collect (trimmed) lines until a blank line, another sentinel line, or EOF. * Paired coverage: every non-blank (trimmed) line strictly between the start * and end sentinel lines. * * @param {string} content * @returns {{ * blocks: Array<{kind: 'single'|'paired', lines: string[], firstLine: string}>, * malformed: Array<{line: number, message: string}>, * }} */ function extractProtectedBlocks(content) { const lines = content.split(/\r?\n/); const blocks = []; const malformed = []; const singleMarkerLines = []; let openStart = -1; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (trimmed === PROTECTED_START) { if (openStart !== -1) { // A new start while one is already open: the earlier open span is // abandoned (best-effort — it never gets a `blocks` entry) and this // one becomes the tracked open start. malformed.push({ line: i + 1, message: `nested/unclosed msd:protected:start at line ${i + 1}` }); } openStart = i; } else if (trimmed === PROTECTED_END) { if (openStart === -1) { malformed.push({ line: i + 1, message: `msd:protected:end with no matching start at line ${i + 1}` }); continue; } const covered = lines.slice(openStart + 1, i).map((l) => l.trim()).filter((l) => l.length > 0); if (covered.length === 0) { malformed.push({ line: openStart + 1, message: `protected block at lines ${openStart + 1}-${i + 1} encloses no content`, }); } blocks.push({ kind: 'paired', lines: covered, firstLine: covered[0] || '' }); openStart = -1; } else if (trimmed === PROTECTED_SINGLE) { singleMarkerLines.push(i); } } if (openStart !== -1) { malformed.push({ line: openStart + 1, message: 'a msd:protected:start sentinel was never closed' }); const covered = lines.slice(openStart + 1).map((l) => l.trim()).filter((l) => l.length > 0); blocks.push({ kind: 'paired', lines: covered, firstLine: covered[0] || '' }); } for (const idx of singleMarkerLines) { let j = idx + 1; while (j < lines.length && lines[j].trim() === '') j++; if (j >= lines.length || lines[j].trim().length === 0) { malformed.push({ line: idx + 1, message: `single protected marker at line ${idx + 1} has no following content` }); blocks.push({ kind: 'single', lines: [], firstLine: '' }); continue; } const covered = []; for (let k = j; k < lines.length; k++) { const trimmed = lines[k].trim(); if (trimmed === '' || trimmed === PROTECTED_START || trimmed === PROTECTED_END || trimmed === PROTECTED_SINGLE) break; covered.push(trimmed); } blocks.push({ kind: 'single', lines: covered, firstLine: covered[0] || '' }); } return { blocks, malformed }; } /** Trailer key for check 5 (boundary moves), PARTITION-RULES.md check 5. */ const ACK_TRAILER_BOUNDARY_MOVE = 'Boundary-Move-Declared'; // Record/value separators for the `git log --format` trailer extraction // below — same ASCII control characters, same escaped-hex-in-`separator=` // gotcha, as `readAckTrailers` (`tests/helpers/emitted-runtime.cjs`) and // `ship.md:312`'s own `%(trailers:...)` extraction. Only one trailer key // space here (unlike the hash/growth pair `readAckTrailers` reads), so no // field separator is needed — one placeholder per commit record. const BOUNDARY_RECORD_SEP = '\x1e'; // between commits const BOUNDARY_VALUE_SEP = '\x1d'; // between multiple values of the SAME trailer key on one commit /** * Bounded git invocation for the boundary-move trailer reader, built on the * never-throws process seam (`runGit`) so a PER-CALL `timeoutMs` override is * honored (mirrors `emitted-runtime.cjs`'s private `ackTrailerGit`, which * this reimplements locally since it is not exported). A git failure THROWS * — never an empty result — same "a git failure is a hard error" law as * every other git-touching helper in this test suite. */ function boundaryTrailerGit(args, { cwd = REPO_ROOT, timeoutMs = GIT_TIMEOUT_MS } = {}) { const result = runGit([...safeDirArgs(cwd), ...args], { cwd, timeoutMs }); if (result.outcome === OUTCOME.EXITED && result.exitCode === 0) { return result.stdout; } throw new Error( `boundary-move-trailer: \`git ${args.join(' ')}\` failed — outcome=${result.outcome} ` + `exitCode=${result.exitCode} stderr=${(result.stderr || '').trim()}`, ); } /** * Parse raw `Boundary-Move-Declared` trailer VALUES (no git I/O) into the * declarations map, applying the SAME rules `parseAckTrailers` * (`tests/helpers/emitted-diff.cjs`) applies to its two spaces, narrowed to * one: empty key -> error, empty reason -> error, two declarations of the * SAME key with the SAME reason (after `normalizeAckReason`) -> silent * dedupe (keep the first), two declarations of the same key with DIFFERENT * reasons -> error and drop the key entirely (never guess a winner). * * Keys here are deliberately NOT validated against `<`/`>`/whitespace or the * `__proto__`/`constructor`/`prototype` reserved set the ack-trailer parser * rejects — those defenses exist because `parseAckTrailers`' output keys * plain OBJECTS (`ackHash`/`ackGrowth` consumers). `declarations` here is a * `Map`, which has no prototype-pollution surface, so narrowing to only the * rules PARTITION-RULES.md's check 5 actually specifies avoids inventing * validation the spec never asked for. * * @param {string[]} values * @returns {{declarations: Map, errors: string[]}} */ function parseBoundaryMoveTrailerValues(values) { const errors = []; const declarations = new Map(); const conflicted = new Set(); // keys already reported ambiguous — never resurrected for (const raw of values) { const delimIndex = raw.indexOf(ACK_TRAILER_DELIM); if (delimIndex === -1) { errors.push( `${ACK_TRAILER_BOUNDARY_MOVE}: trailer value ${JSON.stringify(raw)} has no ` + `"${ACK_TRAILER_DELIM}" delimiter — expected " — "`, ); continue; } const key = raw.slice(0, delimIndex).trim(); const reason = raw.slice(delimIndex + ACK_TRAILER_DELIM.length).trim(); if (key === '') { errors.push(`${ACK_TRAILER_BOUNDARY_MOVE}: trailer value ${JSON.stringify(raw)} has an empty key`); continue; } if (reason === '') { errors.push( `${ACK_TRAILER_BOUNDARY_MOVE}: trailer value ${JSON.stringify(raw)} has an empty reason — ` + 'name it and say why', ); continue; } if (conflicted.has(key)) continue; const existing = declarations.get(key); if (existing === undefined) { declarations.set(key, { reason }); } else if (normalizeAckReason(existing.reason) === normalizeAckReason(reason)) { // Identical after normalization — dedupe silently, keep the first declaration. } else { errors.push( `${ACK_TRAILER_BOUNDARY_MOVE}: trailer key "${key}" is declared twice with ambiguous, ` + 'conflicting reasons — an ambiguous declaration cannot silently pick a winner', ); declarations.delete(key); conflicted.add(key); } } return { declarations, errors }; } /** * Read `Boundary-Move-Declared` commit trailers over `..` * (never `..` — a two-dot range would let the diff and the * commit range being checked disagree about what "this PR" means, the same * correction ADR-3942 made for its own ack trailers). * * Fails closed: an uncomputable merge-base (shallow clone, unrelated * histories, a bad ref) THROWS — never returns an empty Map, which would * silently read as "no boundary moves to excuse" and disarm check 5 exactly * the way an empty `readAckTrailers` result would disarm ADR-3942's gate. * * @param {{baseRef: string, headRef?: string, cwd?: string, timeoutMs?: number}} opts * @returns {{declarations: Map, errors: string[]}} */ function readBoundaryMoveTrailers({ baseRef, headRef = 'HEAD', cwd = REPO_ROOT, timeoutMs = GIT_TIMEOUT_MS } = {}) { let mergeBaseOut; try { mergeBaseOut = boundaryTrailerGit(['merge-base', baseRef, headRef], { cwd, timeoutMs }); } catch (err) { throw new Error( `boundary-move-trailer: could not compute a merge-base range for "${baseRef}..${headRef}" ` + '(a bad ref, a shallow clone with no common ancestor, or another git failure): ' + err.message, ); } const mergeBase = mergeBaseOut.trim(); if (!/^[0-9a-f]{40}$/.test(mergeBase)) { throw new Error( `boundary-move-trailer: git merge-base for "${baseRef}..${headRef}" returned no usable ` + `commit (${JSON.stringify(mergeBase)}) — the range is structurally uncomputable ` + '(possibly a shallow clone with no common ancestor).', ); } const valueSepHex = `%x${BOUNDARY_VALUE_SEP.codePointAt(0).toString(16).padStart(2, '0')}`; const format = `${BOUNDARY_RECORD_SEP}%(trailers:key=${ACK_TRAILER_BOUNDARY_MOVE},valueonly,separator=${valueSepHex})`; let raw; try { raw = boundaryTrailerGit(['log', `${mergeBase}..${headRef}`, `--format=${format}`], { cwd, timeoutMs }); } catch (err) { throw new Error(`boundary-move-trailer: could not read commit trailers over the range: ${err.message}`); } const normalized = raw.replace(/\r/g, ''); // a CRLF commit message must parse identically to LF const values = []; // index 0 is the (empty) text before the FIRST record separator — every // real record starts with one, by construction of the `--format` string. const records = normalized.split(BOUNDARY_RECORD_SEP).slice(1); for (const record of records) { const field = record.replace(/\n+$/, ''); // git's own between-commit newline for (const v of field.split(BOUNDARY_VALUE_SEP)) if (v !== '') values.push(v); } return parseBoundaryMoveTrailerValues(values); } /** * Check 2 (disjointness): no non-trivial line may appear in both a spine and * any of its detail parts. Checked on every registered pair regardless of * what a given PR touched — this is what keeps duplication from creeping * back in after a split is made. * * The canonical `msd_run` launcher preamble is excluded from both sides * before comparing (`isKnownSanctionedBoilerplate`) — it is sanctioned * cross-file duplication under a different guard's contract, not a * violation of this one. * * Capped at the first 20 reported violations PER SPLIT, to avoid a * runaway report on a badly-drifted pair; this is a reporting cap only, * not a detection cap — `ok` (computed by the caller from array length) is * unaffected either way since any violation at all fails the check. * * @param {ReturnType} splits - already * filtered by the caller to `spineExists: true` entries. * @returns {Array<{splitName: string, detailPath: string, line: string}>} */ function checkDisjointness(splits) { const violations = []; const PER_SPLIT_CAP = 20; for (const split of splits) { const spineLines = normalizeNonTrivialLines(fs.readFileSync(split.spinePath, 'utf8')) .filter((l) => !isKnownSanctionedBoilerplate(l)); const spineSet = new Set(spineLines); let reported = 0; for (const detailPath of split.detailPaths) { if (reported >= PER_SPLIT_CAP) break; const detailLines = normalizeNonTrivialLines(fs.readFileSync(detailPath, 'utf8')) .filter((l) => !isKnownSanctionedBoilerplate(l)); for (const line of detailLines) { if (reported >= PER_SPLIT_CAP) break; if (spineSet.has(line)) { violations.push({ splitName: split.name, detailPath, line }); reported++; } } } } return violations; } /** A detail-path-shaped substring in spine prose: `/detail/.md`. */ const DETAIL_REFERENCE_PATTERN = /[\w./-]+\/detail\/[\w./-]+\.md/g; /** * Check 3 (registration): a `/detail/*.md` with no `.md` spine * fails as an orphan; a spine that never mentions one of its own detail * paths fails as unreferenced; and any detail-path-SHAPED substring in the * spine's prose that does not resolve to a real file fails as a dangling * reference — three independent ways the spine/detail pairing can rot. * * Runs uncritically over every split `discoverRegisteredSplits` found, * including `spineExists: false` entries — that is exactly the orphan case * this check exists to name. * * @param {ReturnType} splits * @param {string} workflowsDir * @returns {Array} violations, one of: * `{kind: 'orphan_detail', name, detailPaths}` * `{kind: 'unreferenced_detail', name, detailPath}` * `{kind: 'dangling_reference', name, referencedPath}` */ function checkRegistration(splits, workflowsDir) { const violations = []; for (const split of splits) { if (!split.spineExists) { violations.push({ kind: 'orphan_detail', name: split.name, detailPaths: split.detailPaths }); continue; } const spineContent = fs.readFileSync(split.spinePath, 'utf8'); for (const detailPath of split.detailPaths) { const rel = path.relative(workflowsDir, detailPath).split(path.sep).join('/'); if (!spineContent.includes(rel)) { violations.push({ kind: 'unreferenced_detail', name: split.name, detailPath }); } } // The prose spells these out in full — e.g. `msd-core/workflows/plan-phase/ // detail/elaboration.md` — i.e. relative to the REPO ROOT (`workflowsDir`'s // grandparent: `workflowsDir` = `/msd-core/workflows`), not relative // to `workflowsDir` itself (verified against plan-phase.md's real // references; resolving against `workflowsDir` directly double-prefixes // `msd-core/workflows/` and false-positives every real reference). const repoRoot = path.join(workflowsDir, '..', '..'); const referenced = new Set(spineContent.match(DETAIL_REFERENCE_PATTERN) || []); for (const referencedPath of referenced) { const resolved = path.join(repoRoot, referencedPath); // Containment check: DETAIL_REFERENCE_PATTERN allows `.` and `/` freely, so // spine prose shaped like `../../../etc/detail/passwd.md` would otherwise // resolve outside repoRoot and turn this existence check into a path-traversal // oracle (security review finding, #4403). A resolved path outside repoRoot // can never be a real detail file this repo ships, so it is reported the same // as any other dangling reference rather than probed on disk. const relFromRoot = path.relative(repoRoot, resolved); const escapesRoot = relFromRoot === '' || relFromRoot.startsWith('..') || path.isAbsolute(relFromRoot); if (escapesRoot || !fs.existsSync(resolved)) { violations.push({ kind: 'dangling_reference', name: split.name, referencedPath }); } } } return violations; } /** * Every detail file is a NEW shipped file (it did not exist before its * split), so it must stay under the same `NEW_FILE_CAP` (32768 bytes, * `tests/helpers/emitted-diff.cjs`, ADR-1610 Decision point 3) any other * brand-new workflow/agent file is held to — generalizes the pilot's * `tests/plan-phase-compact-split.test.cjs` size assertion to every split. * * @param {ReturnType} splits * @returns {Array<{splitName: string, detailPath: string, size: number}>} */ function checkDetailFileSizeCap(splits) { const violations = []; for (const split of splits) { for (const detailPath of split.detailPaths) { const size = fs.statSync(detailPath).size; if (size >= NEW_FILE_CAP) { violations.push({ splitName: split.name, detailPath, size }); } } } return violations; } module.exports = { DEFAULT_WORKFLOWS_DIR, discoverRegisteredSplits, normalizeNonTrivialLines, isKnownSanctionedBoilerplate, extractProtectedBlocks, ACK_TRAILER_BOUNDARY_MOVE, readBoundaryMoveTrailers, parseBoundaryMoveTrailerValues, checkDisjointness, checkRegistration, checkDetailFileSizeCap, NEW_FILE_CAP, };