Files
msd-core/tests/helpers/compact-content-split.cjs
Tom Boucher a0f8f956c4 enhance(#4139): Phase 3 — partition rules + the five checks (#4497)
* enhance(#4139): Phase 3 — partition rules + the five checks

ADR-4139 Decision 5, epic #4139 Phase 3. Issue #4403's own "Proposed behavior"
section lists four checks; the ADR's Decision 5 and its own phase table ("partition
rules + the five checks") list five — the same four plus "boundary moves are
declared, ongoing". Same issue-vs-ADR drift Phase 2 hit on the detail.md vs
detail/*.md layout: the ADR is the locked, reviewed document, so it wins. This PR
implements all five.

docs/PARTITION-RULES.md (new) is the partition-rules document: the partition rule
itself, the protected-content list and <!-- gsd:protected --> sentinel syntax
(relocated unchanged from gsd-core/references/compact-content-protected-content.md,
now deleted — it was never referenced by any runtime workflow Read, only by the
predecessor test as documentation, so nothing at runtime regresses, and removing it
from gsd-core/references/ also drops it from all 19 installed-project shipped-content
trees for a file nothing ever read), and the five checks explained for a human
reader. Referenced from a new CONTRIBUTING.md subsection under "Editing shipped
content".

tests/helpers/compact-content-split.cjs (new) is the shared mechanics: split
discovery (any gsd-core/workflows/<name>/detail/*.md paired with <name>.md — no
registry file, a pair is registered by existing on disk), line normalization
(carries forward Phase 2's bare-label-line isTrivial fix and the canonical
gsd_run-launcher-preamble exclusion), sentinel extraction, and a
Boundary-Move-Declared commit-trailer reader that is a direct structural port of
tests/helpers/emitted-runtime.cjs's Emitted-Drift-Ack-Hash/-Growth trailer reader
(ADR-3942) — same merge-base range, same fail-closed throw on an uncomputable range,
same dedupe/conflict rules.

tests/compact-content-partition-guard.test.cjs (new) is the actual guard, superseding
tests/plan-phase-compact-split.test.cjs (deleted — its per-pair checks are now the
general guard's job for plan-phase specifically). Checks 2 (disjointness) and 3
(registration + size cap) run unconditionally against every registered split. Checks
1 (completeness, fires once per split on the PR that introduces a new detail/ path),
4 (protected content — no trailer can ever excuse this one, unlike check 5) and 5
(boundary moves declared) are PR-diff-scoped against the resolved base ref and skip
cleanly when there's nothing to compare (a fresh clone, no PR in flight) — a
deliberate asymmetry from check 5's trailer reader, which must throw rather than
silently pass when ITS range is uncomputable, since that function is answering "did
this PR declare its moves" rather than "is there even a diff to look at". Each of the
five checks carries a RED (deliberately broken fixture) / GREEN (fixed) test pair,
built against synthetic temp files or real throwaway git repos, per this repo's rule
that a guard nobody has seen go red is not yet a guard. Building the real fixtures
caught and fixed one real bug before it shipped: check 4's line-presence test was
using the trivial-line-filtered normalizer, so a byte-identical spine falsely
reported its own protected code-fence line as "deleted" — fixed with a
non-filtering membership check.

Extending docs/INVENTORY.md's "Workflow Sub-Files" table for `detail` surfaced a
pre-existing, unrelated gap in the SAME area: gsd-core/workflows/<name>/templates/*.md
is a fourth workflow sub-file kind that already existed on disk and was already known
to lint-response-language-coverage.cjs's FRAGMENT_DIRS, but was invisible to
gen-inventory-manifest.cjs and undocumented in that table. Fixed alongside it, same
pattern, same PR, rather than deferred.

Also, mechanically required by the new fourth sub-file kind:
- scripts/lint-response-language-coverage.cjs: `detail` added to FRAGMENT_DIRS
  alongside modes/steps/templates — a detail/<part>.md inherits its parent's
  response_language coverage through the same per-file proof, not a parallel one.
- tests/workflow-size-budget.test.cjs: explicit regression test locking that
  detail/ files are governed solely by the hard, non-waivable NEW_FILE_CAP
  (tests/helpers/emitted-diff.cjs) and never by the XL/LARGE/DEFAULT spine tiers —
  true by construction (measureWorkflows/listWorkflowStems don't recurse), made
  explicit per the issue's own Done-when item rather than left true-by-omission.
- scripts/gen-inventory-manifest.cjs: `workflow_detail` and `workflow_templates`
  NESTED_FAMILIES entries; docs/INVENTORY-MANIFEST.json regenerated
  (plan-phase/detail/elaboration.md, discuss-phase/templates/*.md now tracked);
  docs/INVENTORY.md's table updated to four kinds.

Verified: `npm run lint:ci` clean with the eslint cache cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4403): review findings + a real gsd-test failure in the new guard

Two orthogonal review passes (Standards + Spec, isolated sub-agents) plus a
separate security review ran against the prior commit. Fixed everything each
surfaced:

- Security (Low, path-traversal existence oracle): checkRegistration's
  dangling-reference check extracted detail-path-shaped substrings from spine
  PROSE via a regex that permits `.`/`/` freely, then joined them onto repoRoot
  and probed fs.existsSync with no containment check — a spine file containing
  `../../../etc/detail/passwd.md`-shaped text could make the guard test file
  existence outside the repo. Added a path.relative-based containment check
  before the fs.existsSync call; anything that resolves outside repoRoot is now
  reported as a dangling reference directly, never probed on disk.
- Standards (Boundary Coverage): the size-cap fixtures covered NEW_FILE_CAP and
  NEW_FILE_CAP-1 but not NEW_FILE_CAP+1 — added the third boundary-point case
  CLAUDE.md's TEST RULES require (limit-1/limit/limit+1).
- Standards (Property-Based Testing): extractProtectedBlocks (a sentinel
  parser) and the new parseBoundaryMoveTrailerValues (a declare/dedupe/conflict
  parser, bijective-shaped) had no fast-check property test. Added three: a
  render/parse bijectivity property for the trailer parser (mirroring the exact
  ADR-3942 sibling test's alphabet/idiom), a dedupe-is-idempotent property for
  the same parser, and a well-formed-sentinel-round-trips property for
  extractProtectedBlocks.

Then dispatched gsd-test on the resulting commit. It found a real bug the
reviews couldn't have caught (none of them can run inside gsd-test's sandbox):
checks 4/5's real-repo assertion failed against plan-phase's own split,
reporting DISK_PLANS/#3218-comment lines as "undeclared boundary moves" —
content Phase 2 (#4402) legitimately moved into detail/elaboration.md months
before this PR's Boundary-Move-Declared mechanism existed to require a
trailer for it. Root cause: `resolveBase()`'s own doc comment already documents
that no `origin/*` remote-tracking ref exists inside the gsd-test sandbox
container, and its fallback candidate (a bare `next` branch) can resolve to a
point in history that predates an already-merged, already-reviewed split —
making that split look "newly introduced" from the sandbox's vantage point.
Check 1 (completeness) already scopes itself correctly to only genuinely-new
detail paths (git diff status 'A'); checks 4 and 5 did not share that scoping,
so a stale base made them re-litigate a settled split retroactively. Fixed by
having checks 4/5 skip any split name check 1 already counted as newly-split —
their own premise ("did an EXISTING split shed/undeclare something") does not
apply to a split that is, from the resolved base's vantage point, brand new;
that is check 1's domain alone. Verified locally (25/25 tests pass via a
direct `node -e` require, since `node --test` is blocked in this repo) and via
re-reasoning through the exact real-repo scenario the gsd-test failure showed.

Also regenerated all 19 tests/fixtures/install-tree/*.json goldens — the
prior commit's deletion of gsd-core/references/compact-content-protected-content.md
was never reflected there, which is what golden-install-tree.test.cjs's other
19 failures in the same gsd-test run were.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4403): backfill changeset pr number to 4497

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4403): isolate codex-config.test.cjs into its own chunk, root-causing the Windows CI failure

PR #4497's "full test (windows-latest, 24, shard 2/3)" job failed: run-tests
killed chunk 3/8 at the 600s per-chunk backstop, with codex-config.test.cjs
(weight 17.87, by far the chunk's dominant cost) packed alongside 39 other
files. Traced, not assumed:

- scripts/run-tests.cjs's own timeout-headroom comment for the OUTER
  per-shard timeout documents that "adding one test file reshuffled 115 of
  268 unit files between shards" — shard/chunk composition is architecturally
  known to be unstable to single-file additions, which is exactly what this
  PR's own new tests/compact-content-partition-guard.test.cjs is.
- A second comment, dated 2026-09-06 (one day before this PR, PR #4428's own
  CI), already documents the SAME chunk hitting the SAME 600s backstop with
  the SAME file (codex-config.test.cjs, "a genuinely MEASURED weight of
  17.87 — not a stale-table miss") dominating it — the fix then was cutting
  the Windows per-chunk budget from 60 to 40. That cut clearly was not
  enough: two documented incidents in two days, at two different budget
  settings, both centered on one file that alone consumes ~45% of even the
  reduced Windows budget.
- tests/test-timings.json's own header confirms its source data
  (test-events-linux-node22/24.jsonl) is Linux-only, and run-tests.cjs's own
  chunk-timeout diagnostic already prints "real Windows cost runs ~2.2x the
  recorded figure" — the packer's weight-balancing is working off data that
  is both stale (table last regenerated 2026-08-07) and known to
  underestimate the platform where the failure occurs.

Given codex-config.test.cjs is disproportionately heavy AND every companion
sharing its chunk is decided by a packing algorithm already documented as
reshuffling unpredictably on any new file, tuning the shared budget a third
time only moves the marginal line to wherever the next new file happens to
land — it does not remove the gamble. Isolating codex-config.test.cjs into
its own dedicated single-file chunk, unconditionally and on every platform,
removes it at the source: the file never enters the pool packChunks balances,
so no other file's packing changes, and no future single-file addition
(mine or anyone else's) can silently reintroduce this exact failure by
landing in its chunk.

Extracted as a small pure function, partitionIsolatedFiles (mirroring this
file's existing pattern of pulling packing/analysis logic out of main() for
in-process unit coverage — see computeSweepProtectSet, analyzeChunkEvents),
with 6 new tests in tests/run-tests-harness.test.cjs covering basename
matching across path separators, near-miss non-matches, the empty-list case,
and the isolated-set contents.

Root cause is now closed rather than papered over with a retry: this failure
is a property of one specific heavy file's chunk placement, not something
that recurs randomly. If codex-config.test.cjs itself is ever genuinely sped
up, this isolation can be revisited — this is a packing-side mitigation for
a known file's cost, not a claim the cost is irreducible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:44:53 -04:00

557 lines
23 KiB
JavaScript

'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 `gsd-core/workflows/<name>/detail/*.md` path
* paired with the spine at `gsd-core/workflows/<name>.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: `gsd-core/workflows/` at repo root. */
const DEFAULT_WORKFLOWS_DIR = path.join(__dirname, '..', '..', 'gsd-core', 'workflows');
/**
* Discover every registered spine/detail split under `workflowsDir`.
*
* A split is registered by a `<workflowsDir>/<name>/detail/` DIRECTORY (never
* a `<name>/detail.md` FILE — only an actual `detail/` subdirectory counts)
* containing at least one `*.md` file, where `<name>` is exactly ONE path
* segment directly under `workflowsDir` — a `detail/` dir nested two or more
* segments deep (e.g. `<name>/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 `<name>`. 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.
*/
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:"
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));
}
/**
* Is `line` the canonical `gsd_run` launcher bootstrap preamble (see
* `gsd-core/workflows/_runtime-launcher.snippet.sh`,
* `tests/runtime-launcher-parity.test.cjs`)?
*
* That other guard's OWN contract mandates exactly one inlined copy in every
* workflow/detail file that calls `gsd_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 isCanonicalLauncherPreamble(line) {
return line.startsWith('_GSD_SHIM_NAME="gsd-tools.cjs";');
}
const PROTECTED_START = '<!-- gsd:protected:start -->';
const PROTECTED_END = '<!-- gsd:protected:end -->';
const PROTECTED_SINGLE = '<!-- gsd:protected -->';
/**
* Extract every `<!-- gsd:protected -->` (single-line) and
* `<!-- gsd:protected:start -->`/`<!-- gsd:protected:end -->` (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 gsd: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 gsd:protected:start at line ${i + 1}` });
}
openStart = i;
} else if (trimmed === PROTECTED_END) {
if (openStart === -1) {
malformed.push({ line: i + 1, message: `gsd: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 gsd: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<string, {reason: string}>, 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 "<spine-path> — <reason>"`,
);
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 `<merge-base>..<headRef>`
* (never `<baseRef>..<headRef>` — 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<string, {reason: string}>, 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 `gsd_run` launcher preamble is excluded from both sides
* before comparing (`isCanonicalLauncherPreamble`) — 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<typeof discoverRegisteredSplits>} 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) => !isCanonicalLauncherPreamble(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) => !isCanonicalLauncherPreamble(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: `<segments>/detail/<segments>.md`. */
const DETAIL_REFERENCE_PATTERN = /[\w./-]+\/detail\/[\w./-]+\.md/g;
/**
* Check 3 (registration): a `<name>/detail/*.md` with no `<name>.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<typeof discoverRegisteredSplits>} splits
* @param {string} workflowsDir
* @returns {Array<object>} 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. `gsd-core/workflows/plan-phase/
// detail/elaboration.md` — i.e. relative to the REPO ROOT (`workflowsDir`'s
// grandparent: `workflowsDir` = `<root>/gsd-core/workflows`), not relative
// to `workflowsDir` itself (verified against plan-phase.md's real
// references; resolving against `workflowsDir` directly double-prefixes
// `gsd-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<typeof discoverRegisteredSplits>} 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,
isCanonicalLauncherPreamble,
extractProtectedBlocks,
ACK_TRAILER_BOUNDARY_MOVE,
readBoundaryMoveTrailers,
parseBoundaryMoveTrailerValues,
checkDisjointness,
checkRegistration,
checkDetailFileSizeCap,
NEW_FILE_CAP,
};