From ecc508139ad61af1272bc77194d4845a9800224c Mon Sep 17 00:00:00 2001 From: Twisted Fate Date: Wed, 16 Sep 2026 12:36:10 +0800 Subject: [PATCH] fix(#4730): decode entity-escaped ampersands before verify-command-paths segment splitting (#4755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#4730): decode entity-escaped ampersands before verify-command-paths segment splitting Planners emit bodies with the chain operator entity-escaped (`&&`), and the executing agent reads the decoded (rendered) form. The grounding probe split the raw text on &&/||/;/newline without decoding first, so `&&` was cut at its semicolons and a cd-form target absorbed the trailing `&` fragment — an existing directory was reported missing_dir (blocker), feeding false blockers into the revision loop. Decode `&` → `&` inside resolveVerifyCommandTarget, after result.command captures the text verbatim and before any segment splitting or target resolution, so the escaped and literal forms of the same command produce identical verdicts. Module-private helper beside splitSegments, mirroring the sibling src/verify.cts decodeEntityAmps (#3611); the two gates are separate modules and neither imports the other. Regression coverage pins escaped/literal verdict parity for: existing dir + manifest (ok), missing dir (missing_dir blocker kept), dir without manifest (no_manifest blocker kept), --prefix form, a literal & inside a quoted dir name, and a full probePhaseVerifyCommands pass whose reported command field stays verbatim. * docs(#4730): use the documented pr:0 placeholder in the changeset fragment The fragment carried pr: 4730 — the ISSUE number, the exact guess-shape DEFECT.CHANGESET-PR-FIELD-DRIFT (#3316, #3325) exists to catch: it parses as a positive integer so local lint passed, but on a real PR run the drift check would fail it against the actual PR number. CONTRIBUTING.md documents pr: 0 as the deliberate unresolved placeholder used during initial commit before the PR number exists (scripts/changeset/new.cjs accepts 0 for exactly this reason); the gate's fail_invalid_fragment on an unbackfilled 0 is the designed backfill enforcement, not a defect. No production or test changes. Backfill pr: with the real PR number once the PR is created. * docs(#4730): backfill changeset pr field with the real PR number pr: 0 → pr: 4755 (the PR carrying this fix), completing the documented placeholder workflow; the changeset gate's content validation can now pass. --------- Co-authored-by: TwistedRiCen <16397953+TwistedRiCen@users.noreply.github.com> Co-authored-by: Tom Boucher --- .changeset/graceful-newts-cheer.md | 5 ++ src/verify-command-grounding.cts | 25 ++++++- tests/verify-command-grounding.test.cjs | 87 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 .changeset/graceful-newts-cheer.md diff --git a/.changeset/graceful-newts-cheer.md b/.changeset/graceful-newts-cheer.md new file mode 100644 index 000000000..25159d89b --- /dev/null +++ b/.changeset/graceful-newts-cheer.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4755 +--- +verify-command-paths no longer reports a false missing_dir blocker when a plan's command spells the chain operator entity-escaped (cd src && npm test): the command text is now decoded to & before segment splitting, so the escaped and literal forms of the same command get identical verdicts (#4730) diff --git a/src/verify-command-grounding.cts b/src/verify-command-grounding.cts index 392073431..2baec93ec 100644 --- a/src/verify-command-grounding.cts +++ b/src/verify-command-grounding.cts @@ -530,6 +530,26 @@ function splitSegments(cmd: string): string[] { .filter(s => s.length > 0); } +/** + * Decode entity-escaped ampersands (`&` → `&`) — #4730, the grounding + * gate's share of the #3611 defect. Planners emit `` bodies with + * `&&` as the chain operator, and the executing agent reads the + * decoded (rendered) form. Without this decode, `splitSegments` cuts + * `&&` at its semicolons and a `cd` target absorbs the trailing + * `&` fragment (`rawTarget: "src &"`), reporting an existing directory + * as a `missing_dir` blocker. Applied to the command text inside + * `resolveVerifyCommandTarget` — before any segment splitting or target + * resolution, after `result.command` has captured the text verbatim — so the + * escaped and literal forms of the same command produce identical verdicts. + * Kept module-private beside `splitSegments`, mirroring the sibling + * `src/verify.cts`'s own private `decodeEntityAmps` (#3611): the two gates + * are separate modules that independently parse `` text, and + * neither imports the other. + */ +function decodeEntityAmps(s: string): string { + return s.replace(/&/g, '&'); +} + /** Strip a single matching pair of surrounding quotes, if present. */ function stripQuotes(s: string): string { if (s.length >= 2) { @@ -655,7 +675,10 @@ function resolveVerifyCommandTarget(command: unknown, options?: ResolveOptions): if (typeof command !== 'string') return result; result.command = command; - const trimmed = command.trim(); + // #4730: decode entity-escaped ampersands BEFORE segment splitting (see + // decodeEntityAmps) so the chain operator is visible to the splitter and + // the verdict matches what the executing agent's decoded form grounds to. + const trimmed = decodeEntityAmps(command.trim()); if (trimmed === '') return result; // Nyquist "MISSING — Wave 0 must create …" sentinel; Dimension 8 owns it. diff --git a/tests/verify-command-grounding.test.cjs b/tests/verify-command-grounding.test.cjs index 1c11380d5..aa3d1b292 100644 --- a/tests/verify-command-grounding.test.cjs +++ b/tests/verify-command-grounding.test.cjs @@ -308,6 +308,93 @@ describe('resolveVerifyCommandTarget — grounded forms', () => { }); }); +describe('#4730 — entity-escaped chain operators decode to the same verdict as the literal form', () => { + test('escaped && agrees with literal && when the cd target exists', () => { + const root = fixtureRoot('4730-ok'); + writePackageJson(path.join(root, 'src'), { test: 'node --version' }); + const esc = resolveVerifyCommandTarget('cd src && npm test', { projectRoot: root }); + const lit = resolveVerifyCommandTarget('cd src && npm test', { projectRoot: root }); + assert.equal(esc.status, 'ok'); + assert.equal(esc.status, lit.status); + assert.equal(esc.reason, lit.reason); + assert.equal(esc.severity, lit.severity); + assert.equal(esc.form, lit.form); + assert.equal(esc.target, lit.target); + assert.equal(esc.rawTarget, lit.rawTarget); + assert.equal(esc.manifest, lit.manifest); + }); + + test('escaped && on a missing dir keeps the literal form\u2019s missing_dir blocker', () => { + const root = fixtureRoot('4730-missing'); + const esc = resolveVerifyCommandTarget('cd nope && npm test', { projectRoot: root }); + const lit = resolveVerifyCommandTarget('cd nope && npm test', { projectRoot: root }); + assert.equal(esc.status, 'broken'); + assert.equal(esc.reason, 'missing_dir'); + assert.equal(esc.severity, 'blocker'); + assert.equal(esc.status, lit.status); + assert.equal(esc.reason, lit.reason); + assert.equal(esc.severity, lit.severity); + assert.equal(esc.rawTarget, lit.rawTarget); + }); + + test('escaped && on a dir without a manifest keeps the literal form\u2019s no_manifest blocker', () => { + const root = fixtureRoot('4730-nomanifest'); + fs.mkdirSync(path.join(root, 'docs-only'), { recursive: true }); + const esc = resolveVerifyCommandTarget('cd docs-only && npm run lint', { projectRoot: root }); + const lit = resolveVerifyCommandTarget('cd docs-only && npm run lint', { projectRoot: root }); + assert.equal(esc.status, 'broken'); + assert.equal(esc.reason, 'no_manifest'); + assert.equal(esc.severity, 'blocker'); + assert.equal(esc.status, lit.status); + assert.equal(esc.reason, lit.reason); + assert.equal(esc.severity, lit.severity); + }); + + test('escaped && agrees with literal && for the --prefix form', () => { + const root = fixtureRoot('4730-prefix'); + writePackageJson(path.join(root, 'web'), { build: 'vite build' }); + const esc = resolveVerifyCommandTarget('npm --prefix ./web run build && echo done', { + projectRoot: root, + }); + const lit = resolveVerifyCommandTarget('npm --prefix ./web run build && echo done', { + projectRoot: root, + }); + assert.equal(esc.status, 'ok'); + assert.equal(esc.status, lit.status); + assert.equal(esc.form, lit.form); + assert.equal(esc.target, lit.target); + }); + + test('a literal & inside a quoted dir name survives the decode intact', () => { + const root = fixtureRoot('4730-litamp'); + writePackageJson(path.join(root, 'a&b'), { test: 'node --version' }); + const esc = resolveVerifyCommandTarget('cd "a&b" && npm test', { projectRoot: root }); + const lit = resolveVerifyCommandTarget('cd "a&b" && npm test', { projectRoot: root }); + assert.equal(esc.status, 'ok'); + assert.equal(esc.status, lit.status); + assert.equal(esc.target, lit.target); + }); + + test('probe of a plan with an escaped block is ok and reports the command verbatim', () => { + const root = fixtureRoot('4730-probe'); + writePackageJson(path.join(root, 'src'), { test: 'node --version' }); + const phaseDir = path.join(root, '01-demo'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync( + path.join(phaseDir, '01-01-PLAN.md'), + '\ndemo\n\ncd src && npm test\n\n\n', + 'utf8', + ); + const r = probePhaseVerifyCommands({ phaseDir, projectRoot: root }); + assert.equal(r.status, 'ok'); + assert.equal(r.counts.blocker, 0); + assert.equal(r.commands.length, 1); + assert.equal(r.commands[0].command, 'cd src && npm test'); + assert.equal(r.commands[0].status, 'ok'); + assert.equal(r.commands[0].target, path.join(root, 'src')); + }); +}); + describe('#2401 review Finding 1 — script check runs for --prefix form regardless of flag order', () => { test('npm --prefix ./web run is script_missing/warning', () => { const root = fixtureRoot('finding1-prefix-first-missing');