Files
msd-core/tests/code-review-fix-pipeline-regression.test.cjs
Tom Boucher 1d5d77951c fix(#3190): commit review.md in --auto loop; fix report env var (#3434)
* fix(#3190): commit review.md in --auto loop; fix report env var

Three coupled defects in gsd-core/workflows/code-review-fix.md:

- The --auto re-review loop overwrote REVIEW.md each iteration but the
  single docs commit staged only REVIEW-FIX.md, so the committed REVIEW.md
  stayed at iteration 1 and contradicted the committed REVIEW-FIX.md. The
  --auto commit now stages the converged REVIEW.md alongside REVIEW-FIX.md
  (guarded on AUTO_MODE; non-auto single-pass runs unchanged).
- The two inline frontmatter validators (HAS_STATUS, FIX_FRONTMATTER)
  exported REVIEW_PATH into a node -e body that reads process.env.
  FIX_REPORT_PATH, so the status check was always empty and REVIEW-FIX.md
  was never committed. Both now export FIX_REPORT_PATH.
- On successful convergence the spent .iterN.md backups are removed so the
  phase directory is clean; they are retained on degradation for post-mortem.

Regression test: tests/code-review-fix-pipeline-regression.test.cjs.

* chore(#3190): set changeset pr to 3434

---------

Co-authored-by: sim <sim@local>
2026-08-14 00:26:07 -04:00

186 lines
9.1 KiB
JavaScript

// allow-test-rule: source-text-is-the-product (see #3190)
// The workflow .md IS the product: its embedded bash/node snippets are loaded
// and executed verbatim by the agent host at runtime. Testing that the deployed
// commit-fix step exports the env var its own inline `node -e` body reads, and
// that the docs commit stages the converged REVIEW.md alongside REVIEW-FIX.md,
// asserts the deployed contract — not an implementation detail. There is no
// runtime API that exposes these inlined snippets; the text IS the spec.
//
// Pattern mirrors tests/code-review-pipeline-regression.test.cjs: a behavioral
// run of the inline parser body (via process-seam) PLUS docs-parity on the
// workflow text. See .gsd/bug/fix-3190-code-review-fix-auto-rewrite-review/.
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runNode } = require('./helpers/process-seam.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const ROOT = path.resolve(__dirname, '..');
const WORKFLOW_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'code-review-fix.md');
// ---------------------------------------------------------------------------
// The FIX_REPORT frontmatter validator body, mirrored from code-review-fix.md's
// commit_fix_report HAS_STATUS inline `node -e` script. If those lines change,
// this must be updated in tandem (the docs-parity assertions below catch a
// mismatch at the env-name level).
//
// Bug (#3190): the deployed workflow launches this body with REVIEW_PATH
// exported but the body reads process.env.FIX_REPORT_PATH — so at runtime the
// readFileSync throws and HAS_STATUS silently becomes "". The behavioral tests
// below prove the BODY is sound when FIX_REPORT_PATH is wired, and that the
// mis-wiring is the sole failure mode.
// ---------------------------------------------------------------------------
const VALIDATOR_BODY = `
const fs = require('fs');
const content = fs.readFileSync(process.env.FIX_REPORT_PATH, 'utf-8');
const match = content.replace(/\\r\\n/g, '\\n').match(/^---\\n([\\s\\S]*?)\\n---/);
if (match && /status:/.test(match[1])) { console.log('valid'); } else { console.log('invalid'); }
`;
const VALID_FIX_REPORT = [
'---',
'status: all_fixed',
'findings_in_scope: 3',
'fixed: 3',
'skipped: 0',
'iteration: 2',
'---',
'',
'All findings resolved across 2 iterations.',
].join('\n');
/** Slice the workflow text of a single <step name="...">…</step> region. */
function stepRegion(src, name) {
const open = src.indexOf(`<step name="${name}">`);
assert.notStrictEqual(open, -1, `step "${name}" not found in workflow`);
const close = src.indexOf('</step>', open);
assert.notStrictEqual(close, -1, `step "${name}" has no closing </step>`);
return src.slice(open, close + '</step>'.length);
}
describe('#3190 — code-review-fix --auto REVIEW.md commit + FIX_REPORT_PATH env wiring', () => {
// -------------------------------------------------------------------------
// Criterion 3 — behavioral: the validator body correctly detects a valid
// status field WHEN the env var it reads is actually exported.
// -------------------------------------------------------------------------
test('T1 — validator body prints "valid" for a well-formed REVIEW-FIX.md when FIX_REPORT_PATH is set', () => {
const dir = createTempDir();
const report = path.join(dir, '05-REVIEW-FIX.md');
fs.writeFileSync(report, VALID_FIX_REPORT, 'utf8');
try {
const res = runNode(['-e', VALIDATOR_BODY], {
env: { ...process.env, FIX_REPORT_PATH: report },
});
assert.strictEqual(res.exitCode, 0, `validator should exit 0; stderr:\n${res.stderr}`);
assert.strictEqual(res.stdout.trim(), 'valid');
} finally {
cleanup(dir);
}
});
// -------------------------------------------------------------------------
// Criterion 3 — behavioral bug demo: the SAME body, with FIX_REPORT_PATH
// unset (exactly what today's `REVIEW_PATH=`-only export produces in the
// subshell), throws inside readFileSync → stdout is empty → HAS_STATUS="" →
// the "invalid frontmatter — Not committing" branch fires unconditionally.
// This proves the env-name coupling is load-bearing and that the body is not
// the culprit.
// -------------------------------------------------------------------------
test('T2 — validator body does NOT print "valid" when FIX_REPORT_PATH is unset (the mis-wiring)', () => {
const env = { ...process.env };
delete env.FIX_REPORT_PATH;
delete env.REVIEW_PATH;
const res = runNode(['-e', VALIDATOR_BODY], { env });
// The throw crashes node (non-zero), stdout has no "valid" — exactly the
// silent empty string the 2>/dev/null command substitution yields in bash.
assert.ok(
!res.stdout.includes('valid'),
`stdout should not contain "valid" when FIX_REPORT_PATH is unset; got: ${res.stdout}`,
);
});
// -------------------------------------------------------------------------
// Criterion 3 — docs-parity: the commit_fix_report HAS_STATUS validator must
// export FIX_REPORT_PATH (the var its body reads), not REVIEW_PATH.
// -------------------------------------------------------------------------
test('T3 — commit_fix_report HAS_STATUS exports FIX_REPORT_PATH, not REVIEW_PATH', () => {
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const region = stepRegion(src, 'commit_fix_report');
assert.ok(
region.includes('HAS_STATUS=$(FIX_REPORT_PATH="${FIX_REPORT_PATH}" node -e'),
'HAS_STATUS must launch node with FIX_REPORT_PATH exported to match the process.env.FIX_REPORT_PATH its body reads',
);
assert.ok(
!region.includes('HAS_STATUS=$(REVIEW_PATH="${REVIEW_PATH}" node -e'),
'HAS_STATUS must not export REVIEW_PATH into a script that reads process.env.FIX_REPORT_PATH',
);
});
// -------------------------------------------------------------------------
// Criterion 3 — docs-parity: the present_results FIX_FRONTMATTER extractor
// must export FIX_REPORT_PATH, not REVIEW_PATH (else every summary field is
// blank).
// -------------------------------------------------------------------------
test('T4 — present_results FIX_FRONTMATTER exports FIX_REPORT_PATH, not REVIEW_PATH', () => {
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const region = stepRegion(src, 'present_results');
assert.ok(
region.includes('FIX_FRONTMATTER=$(FIX_REPORT_PATH="${FIX_REPORT_PATH}" node -e'),
'FIX_FRONTMATTER must launch node with FIX_REPORT_PATH exported to match the process.env.FIX_REPORT_PATH its body reads',
);
assert.ok(
!region.includes('FIX_FRONTMATTER=$(REVIEW_PATH="${REVIEW_PATH}" node -e'),
'FIX_FRONTMATTER must not export REVIEW_PATH into a script that reads process.env.FIX_REPORT_PATH',
);
});
// -------------------------------------------------------------------------
// Criterion 1 — docs-parity: in --auto the single docs commit must stage the
// converged REVIEW.md alongside REVIEW-FIX.md, gated on AUTO_MODE (non-auto
// single-pass runs never rewrite REVIEW.md and stay out of scope).
// -------------------------------------------------------------------------
test('T5 — commit_fix_report stages REVIEW.md alongside REVIEW-FIX.md when AUTO_MODE', () => {
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const region = stepRegion(src, 'commit_fix_report');
assert.ok(
region.includes('AUTO_MODE'),
'commit_fix_report must be aware of AUTO_MODE (only --auto rewrites REVIEW.md)',
);
assert.ok(
region.includes('REVIEW_PATH') && region.includes('COMMIT_FILES'),
'commit_fix_report must build a COMMIT_FILES list that includes REVIEW_PATH in --auto',
);
assert.ok(
region.includes('--files "${COMMIT_FILES[@]}"'),
'the docs commit must stage the assembled COMMIT_FILES array (REVIEW-FIX.md [+ REVIEW.md in --auto])',
);
});
// -------------------------------------------------------------------------
// Criterion 2 — docs-parity: on successful convergence the spent .iterN.md
// backups are removed so the phase directory is clean. Backup CREATION is
// unchanged (out of scope); they are retained when the loop degrades.
// -------------------------------------------------------------------------
test('T6 — auto_iteration_loop removes spent .iterN.md backups on convergence', () => {
const src = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const region = stepRegion(src, 'auto_iteration_loop');
assert.ok(
region.includes('CONVERGED'),
'auto_iteration_loop must track a CONVERGED flag distinguishing convergence from degradation',
);
assert.ok(
/rm -f[\s\S]*?\.iter[\s\S]*?\*[\s\S]*?\.md/.test(region),
'on convergence the loop must remove spent .iterN.md backups (rm -f … .iter*.md)',
);
// Backups are still CREATED before each overwrite (the unchanged mechanism).
assert.ok(
region.includes('.iter${ITERATION}.md'),
'backup creation (cp … .iter${ITERATION}.md) must remain intact',
);
});
});