Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
1142 lines
61 KiB
JavaScript
1142 lines
61 KiB
JavaScript
|
|
/**
|
|
* Remediation binding-ness across the revision contract (#3771).
|
|
*
|
|
* The remediation channel used to fuse "what property failed" with "how to fix it"
|
|
* into a single `fix_hint` and never said which half binds. The checker rendered
|
|
* every hint under a "must fix" heading, the orchestrators injected the issues
|
|
* verbatim and ordered targeted updates, and the shared revision references mapped
|
|
* each hint to a prescriptive strategy. A contract-following planner therefore
|
|
* applied a hint literally even when a smaller mechanism satisfied the same
|
|
* property, or when the hint contradicted a locked decision — with no channel to
|
|
* report the conflict, and each attempt burning a revision iteration.
|
|
*
|
|
* ## What this suite locks
|
|
*
|
|
* The separation, at every link in the chain that carries it:
|
|
* - checker side — `required_property` + evidence + severity bind; `fix_hint` is
|
|
* marked non-binding, including in the human-facing blocker rendering
|
|
* - planner side — constraints are re-checked before editing, a smaller
|
|
* mechanism counts as addressing, and conflicts return `REVISION_CONFLICT`
|
|
* - orchestrators — the conflict is routed to the user or to the configured
|
|
* convergence loop WITHOUT consuming retry budget
|
|
* - field naming — the generic pattern and the plan-checker schema agree on
|
|
* `fix_hint`; `suggested_fix` is retired
|
|
*
|
|
* It also pins what must NOT have been weakened: blockers still block, severity
|
|
* still gates, the iteration caps and stall escalation still fire.
|
|
*
|
|
* ## What it cannot prove
|
|
*
|
|
* That a model acts on the text. The subject is a set of LLM prompts; no test in
|
|
* this repo can prove behavior for the checker's other dimensions either. Stated so
|
|
* the coverage claim is honest rather than implied.
|
|
*
|
|
* Multi-line phrases are asserted against a whitespace-normalized copy (`flat`), which
|
|
* is CRLF-tolerant and survives a re-wrap of the same words — the runtime loads these
|
|
* files whole, including on a checkout that produced CRLF.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('fast-check');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
// Project rules: temp dirs and their removal go through the shared helpers (cleanup carries the
|
|
// Windows-EBUSY retry budget), and every synchronous spawn is bounded.
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
// runConflictGate()/withReviews() spawn bash against a Node-native temp path built by
|
|
// createTempDir(); on win32 that path is backslash-separated and handed to Git Bash, which
|
|
// is not guaranteed present on every runner (DEFECT.WINDOWS-TEST-PORTABILITY). POSIX-sh
|
|
// execution semantics of the extracted gate are proven on the POSIX lanes.
|
|
const IS_WINDOWS = process.platform === 'win32';
|
|
|
|
/** Bound for the extracted-gate subprocess: it runs one grep over a small fixture. */
|
|
const GATE_TIMEOUT_MS = 30_000;
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
|
|
const read = (...parts) => fs.readFileSync(path.join(ROOT, ...parts), 'utf-8');
|
|
|
|
/**
|
|
* Collapse every whitespace run to one space. Multi-line phrases are asserted against
|
|
* this form so a re-wrap of the prose — which changes nothing the runtime reads — cannot
|
|
* red the suite, while the words themselves stay pinned. CRLF folds out here too.
|
|
*/
|
|
const flat = (content) => content.replace(/\s+/g, ' ');
|
|
|
|
const PLAN_CHECKER = read('agents', 'msd-plan-checker.md');
|
|
const UI_CHECKER = read('agents', 'msd-ui-checker.md');
|
|
const PLANNER_REVISION = read('msd-core', 'references', 'planner-revision.md');
|
|
const REVISION_LOOP = read('msd-core', 'references', 'revision-loop.md');
|
|
const FEW_SHOT = read('msd-core', 'references', 'few-shot-examples', 'plan-checker.md');
|
|
const PLAN_PHASE = read('msd-core', 'workflows', 'plan-phase.md');
|
|
const QUICK_LOOP = read('msd-core', 'workflows', 'quick', 'steps', 'plan-checker-loop.md');
|
|
const QUICK_BATCH_LOOP = read('msd-core', 'workflows', 'quick-batch', 'steps', 'plan-checker-loop.md');
|
|
const UI_PHASE = read('msd-core', 'workflows', 'ui-phase.md');
|
|
const DIAGNOSE = read('msd-core', 'workflows', 'diagnose-issues.md');
|
|
const CONVERGENCE = read('msd-core', 'workflows', 'plan-review-convergence.md');
|
|
const VERIFY_WORK = read('msd-core', 'workflows', 'verify-work.md');
|
|
const PLANNER = read('agents', 'msd-planner.md');
|
|
const UI_RESEARCHER = read('agents', 'msd-ui-researcher.md');
|
|
const CONTRACTS = read('msd-core', 'references', 'agent-contracts.md');
|
|
const REVIEW = read('msd-core', 'workflows', 'review.md');
|
|
const COMMANDS = read('docs', 'COMMANDS.md');
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* The convergence conflict gate, extracted from the workflow and RUN.
|
|
*
|
|
* Everything else in this suite asserts on prose. That is the right instrument for a prompt,
|
|
* but this one block is real shell that an orchestrator executes, and it has been wrong four
|
|
* times — a heading-truncated scan, a laundered read error, an inverted status, and a global
|
|
* line-shape scan that let raw reviewer text forge blocking state.
|
|
* Every one of those passed the text assertions that existed at the time. So this block gets
|
|
* executed against fixtures instead of read.
|
|
*
|
|
* Located by content (the fence containing the OPEN_CONFLICTS assignment), not by line number,
|
|
* so re-ordering the document cannot silently point this at the wrong block.
|
|
*/
|
|
function extractConflictGate() {
|
|
const fences = CONVERGENCE.split(/```/);
|
|
const block = fences.find((f) => /^bash\r?\n/.test(f) && /OPEN_CONFLICTS=\$\(awk/.test(f));
|
|
assert.ok(block, 'could not find the bash fence containing the OPEN_CONFLICTS gate');
|
|
return block.replace(/^bash\r?\n/, '');
|
|
}
|
|
|
|
/** Run the extracted gate with REVIEWS_FILE set. Returns { status, stdout, stderr }. */
|
|
function runConflictGate(reviewsFile) {
|
|
const dir = createTempDir('msd-3771-gate-');
|
|
try {
|
|
const script = path.join(dir, 'gate.sh');
|
|
fs.writeFileSync(script, `${extractConflictGate()}\nprintf '%s' "\${OPEN_CONFLICTS}"\n`);
|
|
try {
|
|
const stdout = execFileSync('bash', [script], {
|
|
env: { ...process.env, REVIEWS_FILE: reviewsFile },
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: GATE_TIMEOUT_MS,
|
|
});
|
|
return { status: 0, stdout, stderr: '' };
|
|
} catch (err) {
|
|
return { status: err.status, stdout: err.stdout || '', stderr: err.stderr || '' };
|
|
}
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The writer-side sanitize+insert gate (#3916), extracted from plan-phase.md and RUN —
|
|
* same rationale as `extractConflictGate` above: this is real shell an orchestrator
|
|
* executes, not prose an LLM applies by hand, so it is proven against fixtures.
|
|
* Located by content (the fence assigning `LINE="- [ ] REVISION_CONFLICT`).
|
|
*/
|
|
function extractWriterGate() {
|
|
const fences = PLAN_PHASE.split(/```/);
|
|
const block = fences.find((f) => /^bash\r?\n/.test(f) && /LINE="- \[ \] REVISION_CONFLICT/.test(f));
|
|
assert.ok(block, 'could not find the bash fence containing the writer-side sanitize+insert gate');
|
|
return block.replace(/^bash\r?\n/, '');
|
|
}
|
|
|
|
/** Run the writer gate against `reviewsFile` with the five conflict fields as env. Mutates the file. */
|
|
function runWriterGate(reviewsFile, fields) {
|
|
const dir = createTempDir('msd-3916-writer-');
|
|
try {
|
|
const script = path.join(dir, 'writer.sh');
|
|
fs.writeFileSync(script, extractWriterGate());
|
|
try {
|
|
execFileSync('bash', [script], {
|
|
env: {
|
|
...process.env,
|
|
CONVERGENCE_ENABLED: 'true',
|
|
REVIEWS_FILE: reviewsFile,
|
|
CONFLICT_DIMENSION: fields.dimension ?? '',
|
|
CONFLICT_PLAN: fields.plan ?? '',
|
|
CONFLICT_PROPERTY: fields.property ?? '',
|
|
CONFLICT_CONSTRAINT: fields.constraint ?? '',
|
|
CONFLICT_ALTERNATIVES: fields.alternatives ?? '',
|
|
},
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: GATE_TIMEOUT_MS,
|
|
});
|
|
return { status: 0 };
|
|
} catch (err) {
|
|
return { status: err.status, stderr: err.stderr || '' };
|
|
}
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The close-on-resolve gate (#3916 adversarial-review fix, redesigned in round 4 to match by
|
|
* CONFLICT_DIMENSION/CONFLICT_PLAN identity instead of the full sanitized line -- an agent
|
|
* re-deriving five sanitized fields byte-for-byte across a multi-minute subagent dispatch is far
|
|
* more failure-prone than re-supplying two short identifiers it already tracks), extracted from
|
|
* plan-phase.md's `**Otherwise (revised plans...` branch and RUN. Located by content (the fence
|
|
* assigning `PREFIX="- [ ] REVISION_CONFLICT`).
|
|
*/
|
|
function extractCloseGate() {
|
|
const fences = PLAN_PHASE.split(/```/);
|
|
const block = fences.find((f) => /^bash\r?\n/.test(f) && /PREFIX="- \[ \] REVISION_CONFLICT/.test(f));
|
|
assert.ok(block, 'could not find the bash fence containing the close-on-resolve gate');
|
|
return block.replace(/^bash\r?\n/, '');
|
|
}
|
|
|
|
/** Run the close gate against `reviewsFile`, closing the open `dimension`/`plan` conflict with `resolution`. */
|
|
function runCloseGate(reviewsFile, dimension, plan, resolution) {
|
|
const dir = createTempDir('msd-3916-close-');
|
|
try {
|
|
const script = path.join(dir, 'close.sh');
|
|
fs.writeFileSync(script, extractCloseGate());
|
|
try {
|
|
execFileSync('bash', [script], {
|
|
env: {
|
|
...process.env,
|
|
CONVERGENCE_ENABLED: 'true',
|
|
REVIEWS_FILE: reviewsFile,
|
|
CONFLICT_DIMENSION: dimension,
|
|
CONFLICT_PLAN: plan,
|
|
CONFLICT_RESOLUTION: resolution ?? '',
|
|
},
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: GATE_TIMEOUT_MS,
|
|
});
|
|
return { status: 0 };
|
|
} catch (err) {
|
|
return { status: err.status, stderr: err.stderr || '' };
|
|
}
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
/** Write a REVIEWS.md fixture and hand its path to `fn`. */
|
|
function withReviews(body, fn, filename = '07-REVIEWS.md') {
|
|
const dir = createTempDir('msd-3771-reviews-');
|
|
try {
|
|
const file = path.join(dir, filename);
|
|
fs.writeFileSync(file, body);
|
|
return fn(file);
|
|
} finally {
|
|
cleanup(dir);
|
|
}
|
|
}
|
|
|
|
const OPEN = (id) => `- [ ] REVISION_CONFLICT ${id} — required_property: p | conflicts with: D-1 | alternatives: a`;
|
|
const RESOLVED = (id) => `- [x] REVISION_CONFLICT ${id} — required_property: p | resolved: adopted alternative`;
|
|
const CONFLICTS_BEGIN = '<!-- msd:plan-revision-conflicts:begin -->';
|
|
const CONFLICTS_END = '<!-- msd:plan-revision-conflicts:end -->';
|
|
|
|
const reviewsArtifact = (conflicts = '', reviewerText = '') =>
|
|
`# Cross-AI Plan Review — Phase 7\n\n${CONFLICTS_BEGIN}\n## Plan-Revision Conflicts\n${conflicts}${CONFLICTS_END}\n\n${reviewerText}`;
|
|
|
|
/** Extract the canonical writer template without normalizing indentation or line wrapping. */
|
|
function extractConflictTemplate() {
|
|
const fences = REVISION_LOOP.split(/```/);
|
|
const block = fences.find((f) => /^markdown\r?\n/.test(f) && /required_property: \{property\}/.test(f));
|
|
assert.ok(block, 'could not find the canonical Plan-Revision Conflicts writer template');
|
|
return block.replace(/^markdown\r?\n/, '').replace(/\r?\n$/, '');
|
|
}
|
|
|
|
/** Agent-authored reference rendering used only to test the documented writer/reader contract. */
|
|
function renderConflictTemplate({ dimension, plan, property, constraint, alternatives }) {
|
|
const clean = (value) => String(value)
|
|
.replace(/[\r\n\t]+/g, ' ')
|
|
.replace(/^\s*[#|`-]+\s*/, '');
|
|
return extractConflictTemplate()
|
|
.replace('{dimension}', clean(dimension))
|
|
.replace('{plan}', clean(plan))
|
|
.replace('{property}', clean(property))
|
|
.replace(/\{locked decision\s+D-nn \/ CLAUDE\.md rule \/ plan constraint\}/, clean(constraint))
|
|
.replace("{the agent's alternatives}", clean(alternatives));
|
|
}
|
|
|
|
/**
|
|
* Every fenced YAML issue example that names a `fix_hint`. Each block is returned
|
|
* whole so an assertion can check the two fields co-occur rather than merely both
|
|
* existing somewhere in the file.
|
|
*/
|
|
function yamlIssueBlocks(content) {
|
|
return content
|
|
.split(/```/)
|
|
// `[>\s]*` not `\s*`: the few-shot file blockquotes its YAML (`> fix_hint:`), so an
|
|
// indent-only anchor matched nothing there and every loop over it ran zero times.
|
|
.filter((block) => /(^|\r?\n)[>\s]*fix_hint:/.test(block));
|
|
}
|
|
|
|
// ── Checker side: binding payload vs advisory remediation ──────────
|
|
|
|
describe('#3771 checker states the property and marks the example non-binding', () => {
|
|
test('the issue schema carries required_property and evidence, with binding-ness declared', () => {
|
|
const schema = PLAN_CHECKER.slice(PLAN_CHECKER.indexOf('## Issue Format'));
|
|
assert.match(schema, /required_property:.*#\s*BINDING/,
|
|
'Issue Format must declare required_property as the binding invariant');
|
|
assert.match(schema, /description:.*#\s*BINDING.*evidence/i,
|
|
'Issue Format must declare description as the binding evidence field');
|
|
assert.match(schema, /fix_hint:.*#\s*NON-BINDING/,
|
|
'Issue Format must declare fix_hint as non-binding');
|
|
});
|
|
|
|
test('a smaller mechanism counts as addressing, and a conflicting hint is never authored', () => {
|
|
assert.match(PLAN_CHECKER, /smaller or different mechanism has addressed the issue in full/,
|
|
'the checker must concede that a smaller valid mechanism fully addresses the issue');
|
|
assert.match(flat(PLAN_CHECKER), /Never author a `fix_hint` you can see contradicts/,
|
|
'the checker must not emit remediation that contradicts a constraint it can see');
|
|
});
|
|
|
|
test('every YAML issue example carries required_property alongside its fix_hint', () => {
|
|
const blocks = yamlIssueBlocks(PLAN_CHECKER);
|
|
assert.ok(blocks.length >= 15, `expected the dimension examples to be present, found ${blocks.length}`);
|
|
for (const block of blocks) {
|
|
assert.match(
|
|
block,
|
|
/(^|\r?\n)[>\s]*required_property:/,
|
|
`issue example names fix_hint but no required_property:\n${block.trim().slice(0, 240)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('progressive-disclosure issue examples carry the same binding schema', () => {
|
|
const examplesPath = path.join(
|
|
ROOT,
|
|
'msd-core',
|
|
'references',
|
|
'plan-checker-examples.md'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(examplesPath),
|
|
'the current-base plan-checker examples reference must be present after integration'
|
|
);
|
|
const blocks = yamlIssueBlocks(fs.readFileSync(examplesPath, 'utf-8'));
|
|
assert.ok(blocks.length > 0, 'the progressive-disclosure reference must contain an issue example');
|
|
for (const block of blocks) {
|
|
assert.match(
|
|
block,
|
|
/(^|\r?\n)[>\s]*required_property:/,
|
|
`progressive-disclosure issue example lacks required_property:\n${block.trim().slice(0, 200)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('the blocker rendering names the property, not the example, as what must be fixed', () => {
|
|
assert.match(
|
|
PLAN_CHECKER,
|
|
/### Blockers — these properties must hold \("must fix" is the property, never the example\)/,
|
|
'"must fix" must unambiguously refer to the required property'
|
|
);
|
|
assert.match(PLAN_CHECKER, /- Evidence: \{description\}/,
|
|
'the blocker rendering must surface the evidence');
|
|
assert.match(
|
|
PLAN_CHECKER,
|
|
/- Example fix \(non-binding — any mechanism reaching the property counts\): \{fix_hint\}/,
|
|
'the blocker rendering must label the hint non-binding at the point of display'
|
|
);
|
|
assert.doesNotMatch(PLAN_CHECKER, /(^|\r?\n)- Fix: \{fix_hint\}/,
|
|
'the bare "Fix: {fix_hint}" rendering reads as a prescription and must be gone');
|
|
});
|
|
|
|
test('the adversarial stance requires the property and evidence, not just severity', () => {
|
|
assert.match(
|
|
flat(PLAN_CHECKER),
|
|
/Neither are issues without a `required_property`/,
|
|
'a missing required_property must invalidate the finding the same way a missing severity does'
|
|
);
|
|
});
|
|
|
|
test('the success checklist gates on the binding/advisory split', () => {
|
|
assert.match(
|
|
flat(PLAN_CHECKER),
|
|
/binding `required_property` \+ evidence \+ severity, with `fix_hint` rendered as a non-binding example/,
|
|
'success_criteria must require the split, or the checker is never told to produce it'
|
|
);
|
|
});
|
|
|
|
test('the calibration examples model the split and a smaller-alternative acceptance', () => {
|
|
assert.doesNotMatch(FEW_SHOT, /suggested_fix|(^|\n)>?\s*finding:|affected_field/,
|
|
'few-shot examples must use the plan-checker schema field names, not the drifted ones');
|
|
const fewShotBlocks = yamlIssueBlocks(FEW_SHOT);
|
|
assert.ok(fewShotBlocks.length >= 3,
|
|
`expected the few-shot issue examples to be found, got ${fewShotBlocks.length} — ` +
|
|
'a zero here means the block filter stopped matching, not that the file is clean');
|
|
for (const block of fewShotBlocks) {
|
|
assert.match(block, /(^|\r?\n)[>\s]*required_property:/,
|
|
`few-shot issue example lacks required_property:\n${block.trim().slice(0, 200)}`);
|
|
}
|
|
// The smaller-alternative rule is NOT demonstrated here on purpose: this file is the
|
|
// CHECKER's calibration set, fixed by tests/few-shot-calibration.test.cjs at 2 positive +
|
|
// 2 negative, and the rule is about what the PLANNER may do with a hint. It is normative in
|
|
// the checker and in planner-revision.md, and pinned by the assertions in this suite.
|
|
assert.match(flat(FEW_SHOT), /because the binding payload is the property rather than the example, the planner may satisfy it a different way/,
|
|
'the calibration commentary must still teach that the hint does not bind');
|
|
});
|
|
});
|
|
|
|
// ── Planner side: re-check, smaller alternative, conflict channel ───
|
|
|
|
describe('#3771 revision re-checks constraints and has a conflict path', () => {
|
|
test('constraints are re-read before any edit', () => {
|
|
const stepAt = PLANNER_REVISION.indexOf('### Step 2.5');
|
|
assert.ok(stepAt > 0, 'a constraint re-check step must exist before Step 3');
|
|
const step = PLANNER_REVISION.slice(stepAt);
|
|
assert.match(step, /Locked decisions in CONTEXT\.md/, 'locked decisions must be re-checked');
|
|
assert.match(step, /capability \/ project guidance/i, 'capability guidance must be re-checked');
|
|
assert.match(step, /Constraints the existing plans already encode/, 'plan constraints must be re-checked');
|
|
});
|
|
|
|
test('binding-ness of each field is stated to the planner', () => {
|
|
assert.match(flat(PLANNER_REVISION), /`fix_hint` is \*\*one example\*\*/,
|
|
'the planner must be told the hint is an example');
|
|
assert.match(
|
|
flat(PLANNER_REVISION),
|
|
/Never treat the absence of the field as licence to apply `fix_hint` literally/,
|
|
'an older checker return without required_property must not fall back to literal application'
|
|
);
|
|
});
|
|
|
|
test('a smaller sufficient mechanism is preferred and reported as addressed', () => {
|
|
assert.match(flat(PLANNER_REVISION), /must be reported as addressed, naming the property satisfied and the mechanism used/);
|
|
});
|
|
|
|
// A marker four workflows dispatch on must be declared and emitted where the agent is
|
|
// defined, not only in the shared reference — otherwise nothing produces what they match.
|
|
test('the producing agents declare and emit REVISION_CONFLICT', () => {
|
|
for (const [name, agent] of [['msd-planner', PLANNER], ['msd-ui-researcher', UI_RESEARCHER]]) {
|
|
assert.match(agent, /```markdown\r?\n## REVISION_CONFLICT/,
|
|
`${name} must emit the marker in-fence, or check:contract-drift reports an orphan consumer`);
|
|
}
|
|
const plannerRow = CONTRACTS.split(/\r?\n/).find((l) => l.startsWith('| msd-planner |'));
|
|
const uiRow = CONTRACTS.split(/\r?\n/).find((l) => l.startsWith('| msd-ui-researcher |'));
|
|
assert.ok(plannerRow && uiRow, 'both registry rows must exist');
|
|
for (const [name, row] of [['msd-planner', plannerRow], ['msd-ui-researcher', uiRow]]) {
|
|
assert.match(row, /`## REVISION_CONFLICT`/, `${name}'s registry row must declare the marker`);
|
|
}
|
|
for (const consumer of ['quick/steps/plan-checker-loop.md', 'verify-work.md']) {
|
|
assert.ok(plannerRow.includes(consumer),
|
|
`msd-planner's Consumed by must list ${consumer} — it dispatches on the marker`);
|
|
}
|
|
});
|
|
|
|
test('conflicts return REVISION_CONFLICT carrying conflicts and alternatives', () => {
|
|
assert.match(PLANNER_REVISION, /## REVISION_CONFLICT/);
|
|
const block = PLANNER_REVISION.slice(PLANNER_REVISION.indexOf('### Step 7b'));
|
|
assert.match(block, /### Alternatives Considered/, 'the conflict must carry alternatives');
|
|
assert.match(block, /Conflicts with/, 'the conflict must name what it conflicts with');
|
|
assert.match(block, /it does not count as a failed revision iteration/,
|
|
'a conflict must not consume retry budget');
|
|
});
|
|
|
|
test('the completion checklist accepts a smaller mechanism and rejects conflicting application', () => {
|
|
const checklist = PLANNER_REVISION.slice(PLANNER_REVISION.indexOf('### Step 5: Validate Changes'));
|
|
assert.match(checklist, /smaller\/different mechanism \(both count as addressed\)/);
|
|
assert.match(checklist, /No `fix_hint` applied that contradicts a locked decision/);
|
|
assert.doesNotMatch(checklist, /- \[ \] All flagged issues addressed\r?\n/,
|
|
'the old "all flagged issues addressed" line implies literal application and must be replaced');
|
|
});
|
|
});
|
|
|
|
// ── Generic pattern: naming reconciled, literal-application removed ─
|
|
|
|
describe('#3771 generic revision pattern carries the same separation', () => {
|
|
test('the field list matches the plan-checker schema', () => {
|
|
assert.match(flat(REVISION_LOOP), /`plan`, `dimension`, `severity`, `required_property`, `description`, `task`, `fix_hint`/,
|
|
'the generic pattern must advertise exactly the plan-checker schema');
|
|
});
|
|
|
|
test('BLOCKERs are satisfied by property, not by literal application of the hint', () => {
|
|
assert.doesNotMatch(REVISION_LOOP, /For each BLOCKER: make the required change/,
|
|
'"make the required change" orders the example applied and must be gone');
|
|
assert.match(REVISION_LOOP, /For each BLOCKER: make required_property true/);
|
|
assert.match(REVISION_LOOP, /a smaller or different mechanism that makes the same property true/);
|
|
});
|
|
|
|
test('the conflict return is handled before the iteration counter and stall check', () => {
|
|
const section = REVISION_LOOP.slice(REVISION_LOOP.indexOf('### Conflict Return'));
|
|
assert.ok(section.length > 0, 'the pattern must define a conflict return');
|
|
assert.match(section, /has not failed and has not stalled/);
|
|
assert.match(flat(section), /Do NOT increment the iteration counter and do NOT update `prev_issue_count`/);
|
|
assert.match(flat(REVISION_LOOP), /The increment is step g, AFTER the producing agent returns/,
|
|
'the canonical flow must place the increment on the return path, or the rule above is unreachable');
|
|
assert.doesNotMatch(flat(REVISION_LOOP), /a\. iteration \+= 1/,
|
|
'the pre-dispatch increment is the ordering defect and must be gone');
|
|
assert.match(flat(section), /Accepting the output with the blocker still open is NOT offered here/,
|
|
'the conflict gate must not become an early exit from a blocker');
|
|
});
|
|
|
|
test('the shared contract does not describe a hand-off that no workflow performs', () => {
|
|
assert.match(flat(REVISION_LOOP), /recording is in addition to asking, never instead of it/,
|
|
'after #3771 round 2 no workflow hands a conflict to a loop and returns');
|
|
assert.doesNotMatch(flat(REVISION_LOOP), /it may route there instead of asking directly/,
|
|
'the superseded routing description must not survive as drift');
|
|
});
|
|
|
|
// The conflict text is agent-authored and lands inside a writer-owned slot. Newlines are
|
|
// still a trust boundary: an embedded record-shaped line could forge an extra blocker.
|
|
test('agent-authored conflict text is sanitized at the write boundary', () => {
|
|
assert.match(flat(REVISION_LOOP), /Sanitize before writing — the conflict text is agent-authored/,
|
|
'the shared protocol must sanitize where the untrusted text enters the file');
|
|
assert.match(flat(REVISION_LOOP), /collapse every newline and tab to a single space, and strip any leading `#`/,
|
|
'the rule must name the exact transform, or it is advice rather than a control');
|
|
assert.match(flat(REVISION_LOOP), /embedded newline can forge an extra conflict-shaped record inside the owned slot/,
|
|
'the contract must state the concrete forgery sanitization prevents');
|
|
assert.match(flat(PLAN_PHASE), /Sanitize-then-insert is real shell/,
|
|
'the workflow that does the appending must run the rule, not restate it as prose (#3916)');
|
|
for (const [name, agent] of [['planner-revision', PLANNER_REVISION], ['msd-ui-researcher', UI_RESEARCHER]]) {
|
|
assert.match(flat(agent), /\*\*Every field is one line of plain text\.\*\*/,
|
|
`${name} must forbid the shapes the writer would otherwise have to strip`);
|
|
}
|
|
assert.match(flat(CONVERGENCE), /reader counts only the first fixed slot at that position/,
|
|
'the reader must state the ownership boundary that excludes raw reviewer text');
|
|
});
|
|
|
|
// A missing or non-file artifact must never read as "no conflicts".
|
|
// Unverifiable is not the same as clean.
|
|
test('the convergence gate fails CLOSED when it cannot read or parse REVIEWS.md', () => {
|
|
assert.match(CONVERGENCE, /if \[ ! -f "\$\{REVIEWS_FILE\}" \]; then/,
|
|
'the gate must require a regular file before trusting a count of zero');
|
|
assert.match(flat(CONVERGENCE), /Refusing to declare convergence on an unverifiable gate/,
|
|
'an unreadable or malformed gate input must block, not pass');
|
|
assert.match(CONVERGENCE, /OPEN_CONFLICTS=\$\(awk/,
|
|
'the executable reader must parse the owned slot');
|
|
assert.match(CONVERGENCE, /awk_status=\$\?/,
|
|
'a parser failure must remain distinguishable from a legitimate zero');
|
|
assert.doesNotMatch(extractConflictGate(), /\|\| true/,
|
|
'the owned-block parser must not launder a failure into zero');
|
|
});
|
|
|
|
// ── The gate, EXECUTED ───────────────────────────────────────────
|
|
// Source assertions above prove the text says the right thing. These prove the shell does it.
|
|
describe('#3771 the extracted conflict gate behaves', { skip: IS_WINDOWS }, () => {
|
|
test('counts open conflicts and ignores resolved ones', () => {
|
|
withReviews(reviewsArtifact(`${OPEN('a/1')}\n${RESOLVED('b/2')}\n${OPEN('c/3')}\n`), (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.equal(r.status, 0, `gate should succeed; stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '2', 'two open, one resolved');
|
|
});
|
|
});
|
|
|
|
test('accepts a CRLF artifact without accepting a malformed CRLF boundary', () => {
|
|
const crlf = (content) => content.replace(/\n/g, '\r\n');
|
|
withReviews(crlf(reviewsArtifact(`${OPEN('a/1')}\n`)), (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.equal(r.status, 0, `valid CRLF artifact should succeed; stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '1');
|
|
});
|
|
withReviews(crlf(reviewsArtifact('').replace(CONFLICTS_END, `${CONFLICTS_END} forged`)), (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.notEqual(r.status, 0, 'a non-exact CRLF end boundary must still block');
|
|
assert.match(r.stderr, /BLOCKED/);
|
|
});
|
|
});
|
|
|
|
test('a nested opening delimiter fails CLOSED', () => {
|
|
const nested = reviewsArtifact('').replace(
|
|
'## Plan-Revision Conflicts\n',
|
|
`## Plan-Revision Conflicts\n${CONFLICTS_BEGIN}\n`
|
|
);
|
|
withReviews(nested, (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.notEqual(r.status, 0, 'a nested opening delimiter must not hide later state');
|
|
assert.match(r.stderr, /BLOCKED/);
|
|
});
|
|
});
|
|
|
|
// Adversarial-review regression (agy/gemini-3.8-flash-high, #3916 round 4): a blank line
|
|
// before the delimiter is already tolerated; the heading was not, so a formatter (Prettier,
|
|
// markdownlint) or an LLM writer inserting one would hard-abort convergence on a well-formed
|
|
// file.
|
|
test('a blank line between the opening delimiter and the heading is tolerated', () => {
|
|
const spaced = reviewsArtifact(`${OPEN('a/1')}\n`).replace(
|
|
`${CONFLICTS_BEGIN}\n## Plan-Revision Conflicts\n`,
|
|
`${CONFLICTS_BEGIN}\n\n## Plan-Revision Conflicts\n`
|
|
);
|
|
withReviews(spaced, (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.equal(r.status, 0, `a blank line before the heading must not block: ${r.stderr}`);
|
|
assert.equal(r.stdout, '1');
|
|
});
|
|
});
|
|
|
|
test('a missing or altered canonical heading fails CLOSED', () => {
|
|
for (const replacement of ['', '## Altered Conflict Heading\n']) {
|
|
const malformed = reviewsArtifact(`${OPEN('a/1')}\n`).replace(
|
|
'## Plan-Revision Conflicts\n',
|
|
replacement
|
|
);
|
|
withReviews(malformed, (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.notEqual(r.status, 0, 'a non-canonical owned block must not be accepted or regenerated');
|
|
assert.match(r.stderr, /BLOCKED/);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('an empty owned block is a legitimate zero, not an error', () => {
|
|
withReviews(reviewsArtifact('', '## Reviews\n\nNothing here.\n'), (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.equal(r.status, 0, `no matches must not fail the gate; stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '0');
|
|
});
|
|
});
|
|
|
|
// The defect that started this: a section-scoped scan stops at the first `## ` it meets.
|
|
test('an injected heading cannot hide a conflict beneath it', () => {
|
|
withReviews(reviewsArtifact(`${RESOLVED('a/1')}\n## Injected By Agent Text\n${OPEN('b/2')}\n`), (f) => {
|
|
const r = runConflictGate(f);
|
|
assert.equal(r.status, 0, `gate should succeed; stderr: ${r.stderr}`);
|
|
assert.equal(r.stdout, '1', 'the conflict below the injected heading must still count');
|
|
});
|
|
});
|
|
|
|
// An unreadable artifact must fail before the parser can emit a count.
|
|
test('a scan failure BLOCKS instead of reporting zero conflicts', () => {
|
|
const r = runConflictGate('/nonexistent/definitely-not-here/07-REVIEWS.md');
|
|
assert.notEqual(r.status, 0, 'an unreadable REVIEWS.md must not converge');
|
|
assert.match(r.stderr, /BLOCKED/, 'the gate must say why it refused');
|
|
assert.notEqual(r.stdout.trim(), '0', 'it must not emit a zero count on failure');
|
|
});
|
|
|
|
test('an empty REVIEWS_FILE path BLOCKS', () => {
|
|
const r = runConflictGate('');
|
|
assert.notEqual(r.status, 0, 'an unresolved path must not converge');
|
|
assert.match(r.stderr, /BLOCKED/);
|
|
});
|
|
});
|
|
|
|
// The slot is a blocking gate's state. One content owner, or it can be forged.
|
|
test('only plan-phase may mutate the conflicts section', () => {
|
|
assert.match(flat(CONVERGENCE), /\*\*Only `\/msd:plan-phase` mutates the contents of this slot\.\*\*/,
|
|
'the section needs exactly one declared content owner');
|
|
assert.match(flat(CONVERGENCE), /review agent preserves the existing `## Plan-Revision Conflicts` block byte-for-byte/,
|
|
'the artifact writer may delimit and preserve the slot, never synthesize its state');
|
|
});
|
|
|
|
test('an issue satisfied by a smaller mechanism counts as resolved for the loop checks', () => {
|
|
assert.match(
|
|
REVISION_LOOP,
|
|
/A remediation hint is an example, not an order/,
|
|
'the Important Notes must state the binding rule the loop depends on'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── Orchestrators: routing without burning retry budget ────────────
|
|
|
|
const ORCHESTRATORS = [
|
|
['plan-phase', PLAN_PHASE, 'iteration_count'],
|
|
['quick plan-checker-loop', QUICK_LOOP, 'iteration_count'],
|
|
// quick-batch's per-item loop was missed in the initial pass (agy/gemini-3.8-flash-high
|
|
// adversarial review, #3916 round 4) -- it hands <revision_context> to msd-planner exactly
|
|
// like quick's single-task loop, but had none of this contract until that review caught it.
|
|
['quick-batch plan-checker-loop', QUICK_BATCH_LOOP, 'iteration_count'],
|
|
['ui-phase', UI_PHASE, 'revision_count'],
|
|
// verify-work's gap-plan revision hands <revision_context> to msd-planner, so it inherits the
|
|
// contract whether or not it states it. It was missed in the first pass (#3771 round-2 review).
|
|
['verify-work gap-plan revision', VERIFY_WORK, 'iteration_count'],
|
|
];
|
|
|
|
describe('#3771 every revision orchestrator routes conflicts instead of retrying', () => {
|
|
// plan-phase @-imports revision-loop.md, so the shared Conflict Return protocol really is in
|
|
// its loaded context and it states only its own bindings. The other three do not import it and
|
|
// must carry the rules inline. `loadedFor` is what the runtime actually puts in front of each
|
|
// orchestrator — the honest surface to assert a shared rule against.
|
|
const importsShared = (content) => /@~\/\.claude\/msd-core\/references\/revision-loop\.md/.test(content);
|
|
const loadedFor = (content) => (importsShared(content) ? flat(content + '\n' + REVISION_LOOP) : flat(content));
|
|
|
|
test('plan-phase delegates the shared protocol rather than duplicating it', () => {
|
|
assert.ok(importsShared(PLAN_PHASE), 'plan-phase must @-import the reference it defers to');
|
|
assert.match(flat(PLAN_PHASE), /follow the shared Conflict Return protocol in `msd-core\/references\/revision-loop\.md`/,
|
|
'the delegation must be explicit, or the bindings have no protocol to bind to');
|
|
});
|
|
|
|
for (const [name, content, counter] of ORCHESTRATORS) {
|
|
const loaded = loadedFor(content);
|
|
|
|
test(`${name} tells the reviser the hint is non-binding`, () => {
|
|
assert.match(loaded, /`fix_hint` is ONE non-binding example route/,
|
|
`${name} must mark the remediation example non-binding in its revision prompt`);
|
|
assert.match(loaded, /smaller or different mechanism reaching the same property/,
|
|
`${name} must accept a smaller alternative`);
|
|
});
|
|
|
|
test(`${name} orders a constraint re-check before editing`, () => {
|
|
assert.match(loaded, /BEFORE editing/,
|
|
`${name} must order the constraint re-check before any edit`);
|
|
assert.match(loaded, /return `## REVISION_CONFLICT` with the conflict and\s+the alternatives rather than applying or working around it/,
|
|
`${name} must forbid applying a conflicting hint`);
|
|
});
|
|
|
|
// Four prompts state this contract; planner-revision.md is the authority they must agree
|
|
// with. Each must name where that authority is, or the next editor updates one of five.
|
|
test(`${name} names the authority its inline statement summarises`, () => {
|
|
assert.match(loaded, /Full contract:\s+`msd-core\/references\/planner-revision\.md`|see your `## Revision Conflict`\s+section/,
|
|
`${name} must point at the contract its prompt paraphrases`);
|
|
});
|
|
|
|
test(`${name} routes REVISION_CONFLICT without consuming ${counter}`, () => {
|
|
assert.match(loaded, /## REVISION_CONFLICT/,
|
|
`${name} must handle the conflict return`);
|
|
assert.match(
|
|
loaded,
|
|
new RegExp(`[Dd]o NOT increment (the iteration counter|\`?${counter}\`?)`),
|
|
`${name} must not spend a revision iteration on an unresolvable conflict`
|
|
);
|
|
});
|
|
|
|
// A counter incremented BEFORE dispatch is already spent when the conflict comes back, so
|
|
// "do NOT increment" would be unreachable prose. The increment must sit on the return path.
|
|
test(`${name} increments ${counter} on the return, not before dispatch`, () => {
|
|
assert.doesNotMatch(
|
|
flat(content),
|
|
new RegExp(`- Increment \`${counter}\` - Re-spawn`),
|
|
`${name} must not increment ${counter} before the reviser is dispatched`
|
|
);
|
|
assert.match(loaded, new RegExp(`(returns|return) [^.]*increment \`?${counter}\`?|increment \`?${counter}\`?, then re-spawn|Counter not spent: \`${counter}\``, 'i'),
|
|
`${name} must increment ${counter} only once the reviser has returned`);
|
|
});
|
|
|
|
// Not incrementing the counter removes the bound the counter provided. Something must
|
|
// replace it, or an agent returning the same conflict forever loops unattended.
|
|
// Two bounds, because one is evadable: an agent alternating property names never trips the
|
|
// repeat rule, so the repeat rule alone leaves the un-incremented path unbounded.
|
|
test(`${name} bounds conflict recurrence so the un-incremented path cannot spin`, () => {
|
|
assert.match(
|
|
loaded,
|
|
/same `required_property` (a second time in a row|twice in a row)/i,
|
|
`${name} must detect a repeated conflict rather than re-spawning forever`
|
|
);
|
|
assert.match(
|
|
loaded,
|
|
/THIRD conflict return of this loop whatever property it names/,
|
|
`${name} must cap TOTAL conflict returns — round-robin across property names evades the repeat rule`
|
|
);
|
|
});
|
|
|
|
// The conflict gate resolves the conflict; it must not become an early exit from a blocker.
|
|
test(`${name} re-evaluates a second conflict instead of falling through to the checker`, () => {
|
|
assert.match(
|
|
loaded,
|
|
/re-evaluate (its|the [a-z]+'s|the) return (from the top of this handler|here)|return to this step/,
|
|
`${name} must loop back on the re-spawn, not fall through to the checker spawn`
|
|
);
|
|
});
|
|
|
|
test(`${name} does not offer accepting the output with the blocker still open`, () => {
|
|
assert.match(
|
|
loaded,
|
|
/is NOT offered here/,
|
|
`${name} must state that accepting an unaddressed blocker is not one of the conflict options`
|
|
);
|
|
assert.match(loaded, /amend the constraint/,
|
|
`${name} must offer amending the constraint as the third resolving option`);
|
|
});
|
|
}
|
|
|
|
test('plan-phase checker retry is explicitly the non-conflict return path', () => {
|
|
const handler = PLAN_PHASE.slice(
|
|
PLAN_PHASE.indexOf('**If the planner returns `## REVISION_CONFLICT`:**'),
|
|
PLAN_PHASE.indexOf('## 12.5. Plan Bounce')
|
|
);
|
|
assert.match(
|
|
handler,
|
|
/\*\*Otherwise \(revised plans, not `## REVISION_CONFLICT`\):\*\*[\s\S]*?Spawn checker again \(step 10\), then increment `iteration_count`\./,
|
|
'the normal checker path must be disjoint from the conflict re-entry path'
|
|
);
|
|
assert.doesNotMatch(handler, /\nAfter planner returns ->/,
|
|
'an unconditional post-return instruction textually falls through from REVISION_CONFLICT');
|
|
});
|
|
|
|
test('plan-phase records the conflict on a channel it can actually test for', () => {
|
|
assert.match(PLAN_PHASE, /workflow\.plan_review_convergence/,
|
|
'plan-phase must consult the convergence config');
|
|
assert.match(PLAN_PHASE, /REVIEWS_FILE="\$\{REVIEWS_PATH\}"/,
|
|
'conflict persistence must use the path initialized by the workflow');
|
|
assert.doesNotMatch(PLAN_PHASE, /REVIEWS_FILE=\$\(ls "\$\{PHASE_DIR\}"\/\*-REVIEWS\.md/,
|
|
'a second glob lookup can select a different review artifact');
|
|
assert.match(flat(PLAN_PHASE), /CONVERGENCE_ENABLED.*true.*\[ ! -f "\$\{REVIEWS_FILE\}" \].*BLOCKED: cannot persist plan-revision conflict/i,
|
|
'enabled persistence must fail closed unless REVIEWS_PATH is a regular file');
|
|
// #3916: a phase's FIRST revision cycle can hit REVISION_CONFLICT before any REVIEWS.md
|
|
// exists, so REVIEWS_PATH is legitimately empty there — that must not hard-block the return.
|
|
assert.match(flat(PLAN_PHASE), /CONVERGENCE_ENABLED.*true.*\[ -n "\$\{REVIEWS_FILE\}" \].*\[ ! -f "\$\{REVIEWS_FILE\}" \].*BLOCKED: cannot persist plan-revision conflict/i,
|
|
'the hard-block must require a NON-EMPTY REVIEWS_FILE, or a brand-new phase with no reviews yet can never return a conflict at all');
|
|
assert.match(flat(PLAN_PHASE), /plan-phase wrote the line, so plan-phase closes it/,
|
|
'closure must have exactly one named owner, or a line can be orphaned open');
|
|
assert.match(flat(REVISION_LOOP), /never invokes `\/msd:plan-review-convergence`/,
|
|
'plan-phase runs inside that loop; invoking it would be a cycle');
|
|
// A markdown table cannot be counted by any simple filter — its header and separator rows
|
|
// look like data. The recorded shape must be one the reader can match exactly.
|
|
assert.match(flat(REVISION_LOOP), /A checkbox, not a table row/,
|
|
'the recorded conflict must be countable without parsing a table');
|
|
assert.match(REVISION_LOOP, /- \[ \] REVISION_CONFLICT \{dimension\}\/\{plan\} — required_property:/,
|
|
'the shared protocol must define the open form the convergence gate matches');
|
|
assert.match(flat(REVISION_LOOP), /owns flipping it to `- \[x\]`/,
|
|
'the close step must produce the resolved form the gate excludes');
|
|
});
|
|
|
|
test('convergence gates on the conflicts BEFORE it writes state or prints success', () => {
|
|
assert.match(CONVERGENCE, /## Plan-Revision Conflicts/,
|
|
'the convergence loop must know about the section plan-phase writes');
|
|
assert.match(CONVERGENCE, /OPEN_CONFLICTS=/,
|
|
'the count must be read from REVIEWS.md — CYCLE_SUMMARY does not carry it');
|
|
// The counter and writer must agree on both marker and ownership boundary.
|
|
assert.match(CONVERGENCE, /in_owned && \/\^- \\\[ \\\] REVISION_CONFLICT \.\*required_property:\//,
|
|
'the gate must count the conflict line shape only while inside the owned slot');
|
|
assert.match(CONVERGENCE, /msd:plan-revision-conflicts:begin/);
|
|
assert.match(CONVERGENCE, /msd:plan-revision-conflicts:end/);
|
|
assert.doesNotMatch(CONVERGENCE, /grep -c/,
|
|
'the superseded global scan would count raw reviewer text and must not return');
|
|
assert.match(flat(CONVERGENCE), /escalates rather than deadlocking/,
|
|
'the gate must state that an unresolvable conflict still terminates at MAX_CYCLES');
|
|
assert.match(
|
|
CONVERGENCE,
|
|
/\*\*If HIGH_COUNT == 0 and ACTIONABLE_COUNT == 0 and OPEN_CONFLICTS == 0 \(converged\):\*\*/,
|
|
'an open conflict must be part of the converged CONDITION, not a note after the banner'
|
|
);
|
|
// Ordering is the whole finding: the gate placed after `state planned-phase` would write
|
|
// and announce convergence over a conflict nobody resolved.
|
|
const gateAt = CONVERGENCE.indexOf('OPEN_CONFLICTS=$(awk');
|
|
const writeAt = CONVERGENCE.indexOf('msd_run state planned-phase');
|
|
const bannerAt = CONVERGENCE.indexOf('MSD ► CONVERGENCE COMPLETE');
|
|
assert.ok(gateAt > 0 && writeAt > 0 && bannerAt > 0, 'all three anchors must exist');
|
|
assert.ok(gateAt < writeAt, 'the conflict gate must precede the planned-phase state write');
|
|
assert.ok(gateAt < bannerAt, 'the conflict gate must precede the convergence banner');
|
|
assert.match(flat(CONVERGENCE), /Re-running the planner against an unchanged conflict cannot resolve it/,
|
|
'the replan step must be told that re-running alone cannot clear a conflict');
|
|
});
|
|
|
|
test('quick does not advertise a convergence route it has no artifact for', () => {
|
|
assert.match(flat(QUICK_LOOP), /A quick task has no REVIEWS\.md and no phase/,
|
|
'quick must say why the convergence route does not apply, rather than dangling a dead branch');
|
|
});
|
|
|
|
test('the conflict is surfaced to the user with its alternatives', () => {
|
|
for (const [name, content] of ORCHESTRATORS) {
|
|
assert.match(loadedFor(content), /alternatives to the user|conflict and its alternatives to the user/,
|
|
`${name} must present the alternatives rather than deciding silently`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── UI-spec loop and the gap-plan hint ─────────────────────────────
|
|
|
|
describe('#3771 the UI-spec and gap-plan hints are marked non-binding too', () => {
|
|
test('the UI checker states the property and marks its hint an example', () => {
|
|
assert.match(UI_CHECKER, /\*\*`fix_hint` is an example, never an order\.\*\*/);
|
|
assert.match(flat(UI_CHECKER), /reaches the same property by a smaller or different mechanism has resolved the issue in full/);
|
|
const uiBlocks = yamlIssueBlocks(UI_CHECKER);
|
|
assert.ok(uiBlocks.length >= 6, `expected the UI dimension examples, got ${uiBlocks.length}`);
|
|
assert.doesNotMatch(UI_CHECKER, /exact fix required/,
|
|
'the UI verdict must not order an exact fix — that is the prescription this fix removes');
|
|
assert.match(flat(UI_CHECKER), /- \*\*Dimension \{N\} — \{name\}:\*\* \{required_property\} Evidence: \{description\} Example fix \(non-binding/,
|
|
'the UI ISSUES FOUND rendering must name the property, its evidence, and a non-binding example');
|
|
for (const block of uiBlocks) {
|
|
assert.match(block, /(^|\r?\n)[>\s]*required_property:/,
|
|
`UI checker issue example lacks required_property:\n${block.trim().slice(0, 200)}`);
|
|
}
|
|
});
|
|
|
|
test('the UI-spec revision resolves listed issues rather than applying listed fixes', () => {
|
|
assert.doesNotMatch(UI_PHASE, /fix ONLY the listed issues/,
|
|
'"fix ONLY the listed issues" pairs with a prescriptive hint; it must read as resolve');
|
|
assert.match(UI_PHASE, /resolve ONLY the listed issues/);
|
|
});
|
|
|
|
test('the gap-plan hint is bound to the root cause, not to the suggested direction', () => {
|
|
assert.doesNotMatch(DIAGNOSE, /- suggested_fix: Hint for gap closure plan/,
|
|
'the gap-closure hint must not read as the binding payload');
|
|
assert.match(DIAGNOSE, /fix_hint: NON-BINDING example route for the gap closure plan/);
|
|
assert.match(flat(DIAGNOSE), /the binding payload is `root_cause`/);
|
|
});
|
|
});
|
|
|
|
// ── Preservation: nothing legitimately binding was weakened ────────
|
|
|
|
describe('#3771 preserves everything that legitimately binds', () => {
|
|
test('blockers still block and severity still gates', () => {
|
|
assert.match(PLAN_CHECKER, /Issues without a severity classification are not valid output/);
|
|
assert.match(PLAN_CHECKER, /\*\*blocker\*\* - The `required_property` must hold before execution/);
|
|
assert.match(PLAN_CHECKER, /\*\*BLOCKER\*\* — the phase goal will not be achieved if this is not fixed before execution/);
|
|
});
|
|
|
|
test('iteration caps and stall escalation still fire', () => {
|
|
assert.match(REVISION_LOOP, /## Pattern: Check-Revise-Escalate \(max 3 iterations\)/);
|
|
assert.match(REVISION_LOOP, /If the count does not decrease between consecutive iterations/);
|
|
assert.match(PLAN_PHASE, /## 12\. Revision Loop \(Max 3 Iterations\)/);
|
|
assert.match(PLAN_PHASE, /\*\*Stall detection:\*\* If `issue_count >= prev_issue_count`/);
|
|
assert.match(QUICK_LOOP, /\*\*Revision loop \(max 2 iterations\):\*\*/);
|
|
assert.match(UI_PHASE, /## 9\. Revision Loop \(Max 2 Iterations\)/);
|
|
});
|
|
|
|
test('required task fields and decision coverage still hold', () => {
|
|
assert.match(PLAN_CHECKER, /\*\*FAIL the verification\*\* if any requirement ID from the roadmap is absent/);
|
|
assert.match(PLANNER_REVISION, /\*\*DO NOT:\*\* Rewrite entire plans for minor issues/);
|
|
assert.match(REVISION_LOOP, /Do NOT introduce new issues while fixing existing ones/);
|
|
assert.match(REVISION_LOOP, /Preserve all content not flagged by the checker/);
|
|
});
|
|
});
|
|
|
|
// ── PR #3916 live review remediation ──────────────────────────────
|
|
|
|
describe('#3916 writer, persistence, reader and migration contracts agree', () => {
|
|
test('the canonical writer renders one uniquely-discriminated line that the real gate counts',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
const template = extractConflictTemplate();
|
|
assert.doesNotMatch(template, /\r?\n/, 'one conflict must be exactly one physical line');
|
|
assert.match(template, /^- \[ \] REVISION_CONFLICT /,
|
|
'the writer must start at column zero with a reader-specific discriminator');
|
|
|
|
const field = fc.oneof(
|
|
fc.constantFrom('', 'x', '# heading\nnext', '- item', '| cell', '```fence'),
|
|
fc.string({ maxLength: 32 })
|
|
);
|
|
fc.assert(fc.property(
|
|
fc.record({ dimension: field, plan: field, property: field, constraint: field, alternatives: field }),
|
|
(fields) => withReviews(reviewsArtifact(`${renderConflictTemplate(fields)}\n`), (file) => {
|
|
const result = runConflictGate(file);
|
|
assert.equal(result.status, 0, `gate should read a rendered canonical record: ${result.stderr}`);
|
|
assert.equal(result.stdout, '1', 'one rendered open conflict must count as one');
|
|
})
|
|
));
|
|
});
|
|
|
|
test('reviewer-authored conflict markers outside the owned block are not live state',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
const forged = `${OPEN('forged/reviewer')}\n`;
|
|
withReviews(reviewsArtifact('', `## Reviewer Notes\n${forged}`), (file) => {
|
|
const result = runConflictGate(file);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout, '0');
|
|
});
|
|
});
|
|
|
|
test('review regeneration preserves one deterministically bounded conflict block byte-for-byte', () => {
|
|
assert.match(flat(REVIEW), /capture only the existing conflict entry bytes after the exact/i);
|
|
assert.match(REVIEW, /\{preserved_plan_revision_conflict_entries\}/,
|
|
'the REVIEWS.md writer template needs an explicit preservation slot');
|
|
assert.match(REVIEW, /<!-- msd:plan-revision-conflicts:begin -->\n## Plan-Revision Conflicts\n\{preserved_plan_revision_conflict_entries\}\n<!-- msd:plan-revision-conflicts:end -->/,
|
|
'the first-write template must emit the canonical heading before preserved entries');
|
|
assert.match(flat(REVIEW), /restore the captured bytes at the explicit slot below/i);
|
|
});
|
|
|
|
test('the canonical flow declares and enforces both conflict counters', () => {
|
|
const flow = REVISION_LOOP.slice(REVISION_LOOP.indexOf('### Flow'), REVISION_LOOP.indexOf('### Issue Count Tracking'));
|
|
assert.match(flow, /previous_conflict_property = null/);
|
|
assert.match(flow, /conflict_return_count = 0/);
|
|
assert.match(flow, /conflict_return_count \+= 1/);
|
|
assert.match(flow, /If conflict_return_count >= 3/,
|
|
'alternating properties must still hit the total-conflict cap');
|
|
assert.doesNotMatch(flow, /same required_property[\s\S]*bounds this path/,
|
|
'the repeat-only rule must not claim it bounds alternating conflicts');
|
|
assert.match(flow, /Else: previous_conflict_property = current required_property[\s\S]*resolve it/,
|
|
'a non-repeat resolution must advance the property compared by the next return');
|
|
});
|
|
|
|
test('persisted conflicts are idempotent records and reviews-mode replanning closes them', () => {
|
|
assert.match(flat(REVISION_LOOP), /reuse the existing open line instead of appending a duplicate/i,
|
|
'identical open state needs idempotency, not a second event identity');
|
|
assert.match(flat(PLAN_PHASE), /before replanning from `--reviews`, scan `REVIEWS_PATH` for open plan-revision conflicts/i);
|
|
const initAt = PLAN_PHASE.indexOf('REVIEWS_PATH=$(_msd_field "$INIT" reviews_path)');
|
|
const scanAt = PLAN_PHASE.indexOf('**If plans exist AND the `--reviews` flag is set:**');
|
|
assert.ok(initAt > 0 && scanAt > 0, 'both REVIEWS_PATH initialization and reviews-mode scan must exist');
|
|
assert.ok(initAt < scanAt, 'REVIEWS_PATH must be initialized before reviews-mode scans it');
|
|
assert.match(flat(PLAN_PHASE), /flip the matching line to `- \[x\]` once the chosen resolution is applied/i);
|
|
});
|
|
|
|
test('REVIEWS_FILE is a quoted direct path and must be a regular file', () => {
|
|
assert.match(CONVERGENCE, /REVIEWS_FILE="\$\{phase_dir\}\/\$\{padded_phase\}-REVIEWS\.md"/);
|
|
assert.doesNotMatch(CONVERGENCE, /REVIEWS_FILE=\$\(ls \$\{phase_dir\}/,
|
|
'word-splitting and glob expansion must not select the gate input');
|
|
assert.match(CONVERGENCE, /\[ ! -f "\$\{REVIEWS_FILE\}" \]/,
|
|
'directories and other readable non-files are not valid review artifacts');
|
|
});
|
|
|
|
test('a config query failure blocks persistence instead of reading as disabled', () => {
|
|
assert.doesNotMatch(PLAN_PHASE, /config-get workflow\.plan_review_convergence 2>\/dev\/null \|\| echo "false"/);
|
|
assert.match(flat(PLAN_PHASE), /BLOCKED: cannot read workflow\.plan_review_convergence/i);
|
|
});
|
|
|
|
test('the gate reads a literal-backslash POSIX filename without rewriting it',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
withReviews(reviewsArtifact(`${OPEN('a/1')}\n`), (file) => {
|
|
const result = runConflictGate(file);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.equal(result.stdout, '1');
|
|
}, '07\\-REVIEWS.md');
|
|
assert.doesNotMatch(CONVERGENCE, /tr '\\\\' '\/'/,
|
|
'a quoted POSIX path is already exact; rewriting backslashes corrupts a valid filename');
|
|
});
|
|
|
|
test('the scope calibration stays inside a declared threshold band', () => {
|
|
assert.match(PLAN_CHECKER, /tasks: 4\r?\n\s+files: 8/,
|
|
'the warning is triggered by 4 tasks; its file count should remain in the 5-8 target band');
|
|
});
|
|
|
|
test('quick mode names locked decisions only when CONTEXT.md exists', () => {
|
|
assert.match(QUICK_LOOP, /\$\{DISCUSS_MODE \? 'locked decisions in ' \+ quick_id \+ '-CONTEXT\.md, ' : ''\}capability guidance/);
|
|
});
|
|
|
|
test('the command docs include open conflicts in the exit condition', () => {
|
|
const section = COMMANDS.slice(COMMANDS.indexOf('### `/msd-plan-review-convergence`'));
|
|
assert.match(flat(section), /open `## Plan-Revision Conflicts` entries.*must also be zero/i);
|
|
});
|
|
|
|
// The writer-side sanitize+insert step used to be a prose instruction for the
|
|
// orchestrator LLM to apply by hand (flagged as Minor across two review rounds).
|
|
// #3916 makes it real shell; these tests RUN it, composing with the existing
|
|
// reader gate, so a regression here reds the suite instead of only the prose.
|
|
test('the writer gate sanitizes hostile fields and the reader counts exactly one',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
const field = fc.oneof(
|
|
fc.constantFrom('', 'x', '# heading\nnext', '- item', '| cell', '```fence', 'a\tb\nc'),
|
|
fc.string({ maxLength: 32 })
|
|
);
|
|
fc.assert(fc.property(
|
|
fc.record({ dimension: field, plan: field, property: field, constraint: field, alternatives: field }),
|
|
(fields) => withReviews(reviewsArtifact(), (file) => {
|
|
const before = fs.readFileSync(file, 'utf-8');
|
|
const result = runWriterGate(file, fields);
|
|
assert.equal(result.status, 0, `writer gate should succeed: ${result.stderr}`);
|
|
const after = fs.readFileSync(file, 'utf-8');
|
|
const added = after.slice(before.lastIndexOf(CONFLICTS_END));
|
|
assert.doesNotMatch(added.replace(CONFLICTS_END, ''), /\r?\n.*\S/,
|
|
'exactly one physical line must be inserted before the end delimiter');
|
|
const reader = runConflictGate(file);
|
|
assert.equal(reader.status, 0, reader.stderr);
|
|
assert.equal(reader.stdout, '1', 'the reader must count the sanitized insert as one open conflict');
|
|
})
|
|
));
|
|
});
|
|
|
|
test('the writer gate is idempotent on a repeated identical conflict',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
withReviews(reviewsArtifact(), (file) => {
|
|
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
|
|
assert.equal(runWriterGate(file, fields).status, 0);
|
|
assert.equal(runWriterGate(file, fields).status, 0);
|
|
const reader = runConflictGate(file);
|
|
assert.equal(reader.status, 0, reader.stderr);
|
|
assert.equal(reader.stdout, '1', 'the same conflict recorded twice must not duplicate the line');
|
|
});
|
|
});
|
|
|
|
test('the writer gate fails closed and leaves the file untouched when the owned slot is missing',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
withReviews('# Cross-AI Plan Review — Phase 7\n\nno owned slot here\n', (file) => {
|
|
const before = fs.readFileSync(file, 'utf-8');
|
|
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
|
|
const result = runWriterGate(file, fields);
|
|
assert.notEqual(result.status, 0, 'a missing end delimiter must not silently succeed');
|
|
assert.equal(fs.readFileSync(file, 'utf-8'), before,
|
|
'a failed write must never partially mutate REVIEWS.md');
|
|
});
|
|
});
|
|
|
|
// Adversarial-review regression (agy/gemini-3.8-flash-high, #3916): `awk -v line="$LINE"`
|
|
// decodes a literal two-character `\n` in agent text into a real newline — a forgery `tr`
|
|
// (which only touches actual control bytes) cannot catch. ENVIRON does not decode escapes.
|
|
test('a literal backslash-n in agent text stays on one line (awk -v escape-decoding forgery)',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
withReviews(reviewsArtifact(), (file) => {
|
|
const fields = {
|
|
dimension: 'dim with literal \\n mid-text', plan: 'p1', property: 'prop',
|
|
constraint: 'D-1', alternatives: 'alt',
|
|
};
|
|
const result = runWriterGate(file, fields);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const reader = runConflictGate(file);
|
|
assert.equal(reader.status, 0, reader.stderr);
|
|
assert.equal(reader.stdout, '1', 'a literal backslash-n must not split the record into two lines');
|
|
});
|
|
});
|
|
|
|
// Adversarial-review regression (#3916): a resolved conflict must actually get flipped to
|
|
// `- [x]` in the SAME session that resolved it — nothing else in plan-phase revisits it, so
|
|
// an unclosed record blocks convergence forever.
|
|
test('the close gate flips a resolved conflict to [x] and the reader no longer counts it',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
withReviews(reviewsArtifact(), (file) => {
|
|
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
|
|
assert.equal(runWriterGate(file, fields).status, 0);
|
|
assert.equal(runConflictGate(file).stdout, '1');
|
|
const result = runCloseGate(file, fields.dimension, fields.plan, 'adopted alternative');
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const after = fs.readFileSync(file, 'utf-8');
|
|
assert.match(after, /^- \[x\] REVISION_CONFLICT .*\| resolved: adopted alternative$/m);
|
|
assert.doesNotMatch(after, /^- \[ \] REVISION_CONFLICT/m, 'no open line may survive a close');
|
|
const reader = runConflictGate(file);
|
|
assert.equal(reader.status, 0, reader.stderr);
|
|
assert.equal(reader.stdout, '0', 'a closed conflict must no longer count as open');
|
|
});
|
|
});
|
|
|
|
// Adversarial-review regression (agy/gemini-3.8-flash-high, #3916 round 4): with more than one
|
|
// conflict open at once, closing by identity must touch only the matching record -- a design
|
|
// that closed by a single remembered full-line string would drop whichever conflict it
|
|
// overwrote last.
|
|
test('the close gate with two open conflicts closes only the matching one', { skip: IS_WINDOWS }, () => {
|
|
withReviews(reviewsArtifact(`${OPEN('a/1')}\n${OPEN('b/2')}\n`), (file) => {
|
|
const result = runCloseGate(file, 'a', '1', 'adopted alternative');
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const after = fs.readFileSync(file, 'utf-8');
|
|
assert.match(after, /^- \[x\] REVISION_CONFLICT a\/1 .*\| resolved: adopted alternative$/m);
|
|
assert.match(after, /^- \[ \] REVISION_CONFLICT b\/2 /m, 'the unrelated open conflict must survive untouched');
|
|
assert.equal(runConflictGate(file).stdout, '1', 'exactly one conflict must remain open');
|
|
});
|
|
});
|
|
|
|
test('the close gate fails closed when the pending conflict line is not found',
|
|
{ skip: IS_WINDOWS }, () => {
|
|
withReviews(reviewsArtifact(`${OPEN('a/1')}\n`), (file) => {
|
|
const before = fs.readFileSync(file, 'utf-8');
|
|
const result = runCloseGate(file, 'never', 'written', 'x');
|
|
assert.notEqual(result.status, 0, 'closing a conflict that was never recorded must not silently succeed');
|
|
assert.equal(fs.readFileSync(file, 'utf-8'), before,
|
|
'a failed close must never partially mutate REVIEWS.md');
|
|
});
|
|
});
|
|
|
|
// Adversarial-review regression (#3916): the reader gate strips a trailing \r before
|
|
// comparing lines; both writer-side awk gates did not, so a CRLF REVIEWS.md (a Windows
|
|
// checkout) made every `$0 == ENVIRON[...]` comparison miss and fail closed forever.
|
|
test('the writer gate matches the owned end delimiter on a CRLF REVIEWS.md', { skip: IS_WINDOWS }, () => {
|
|
const crlf = (content) => content.replace(/\n/g, '\r\n');
|
|
withReviews(crlf(reviewsArtifact()), (file) => {
|
|
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
|
|
const result = runWriterGate(file, fields);
|
|
assert.equal(result.status, 0, `writer gate must match a CRLF end delimiter: ${result.stderr}`);
|
|
assert.equal(runConflictGate(file).stdout, '1');
|
|
});
|
|
});
|
|
|
|
test('the close gate matches the pending conflict line on a CRLF REVIEWS.md', { skip: IS_WINDOWS }, () => {
|
|
const crlf = (content) => content.replace(/\n/g, '\r\n');
|
|
withReviews(crlf(reviewsArtifact(`${OPEN('a/1')}\n`)), (file) => {
|
|
const result = runCloseGate(file, 'a', '1', 'adopted alternative');
|
|
assert.equal(result.status, 0, `close gate must match a CRLF-terminated open line: ${result.stderr}`);
|
|
assert.equal(runConflictGate(file).stdout, '0');
|
|
});
|
|
});
|
|
|
|
// Adversarial-review regression (#3916): the CRLF fix above must compare a CR-stripped COPY,
|
|
// not mutate `$0` in place -- `sub(/\r$/, "")` on `$0` itself silently rewrites every
|
|
// passed-through line's ending to LF on any insert or close, corrupting an unrelated file.
|
|
test('the writer gate on a CRLF REVIEWS.md leaves unrelated lines CRLF-terminated', { skip: IS_WINDOWS }, () => {
|
|
const crlf = (content) => content.replace(/\n/g, '\r\n');
|
|
withReviews(crlf(reviewsArtifact()), (file) => {
|
|
const fields = { dimension: 'd', plan: 'p1', property: 'prop', constraint: 'D-1', alternatives: 'alt' };
|
|
assert.equal(runWriterGate(file, fields).status, 0);
|
|
const after = fs.readFileSync(file, 'utf-8');
|
|
assert.ok(after.startsWith('# Cross-AI Plan Review — Phase 7\r\n'),
|
|
'a pre-existing line must keep its original CRLF ending');
|
|
assert.match(after, /- \[ \] REVISION_CONFLICT d\/p1/, 'the record must actually be inserted');
|
|
});
|
|
});
|
|
|
|
});
|