diff --git a/INSTALL.md b/INSTALL.md index 9397021..b63bef5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -705,6 +705,12 @@ ssf execution review changes/my-change --wave foundation --base --head ` 解析,且必须位于 +`/.superpowers/sdd/reviews/` 之下。`--base` 和 `--head` 必须是该 +`` Git 工作树中的真实 commit,且 `base` 必须是 `head` 的祖先。 +`/.superpowers/sdd/reviews/` 的目录层级必须是物理、非符号链接目录; +report 本身必须为普通、非空、非符号链接文件。 + 每一个 wave 均须有当前 `pass` review receipt,才可启动依赖 wave 或进入 closing; 修订计划会废止旧 receipt。#47 所提出的恢复、切换与手动保存 slash command 尚未实现, 不能假定有 `/ssf:*` 命令。 diff --git a/README.md b/README.md index 456803b..eaaa45c 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,12 @@ ssf execution review changes/my-change --wave foundation --base --head ` 解析,且必须位于 +`/.superpowers/sdd/reviews/` 之下。`--base` 和 `--head` 必须是该 +`` Git 工作树中的真实 commit,且 `base` 必须是 `head` 的祖先。 +`/.superpowers/sdd/reviews/` 的目录层级必须是物理、非符号链接目录; +report 本身必须为普通、非空、非符号链接文件。 + 每个 wave 的 review receipt 必须是当前 revision 的 `pass`,依赖 wave 和 closing 才会放行;修订计划会使旧 receipt 失效。恢复、切换和手动保存等 #47 的 slash command 尚未实现,不能据此假定存在 `/ssf:*` 命令。 diff --git a/docs/README_en.md b/docs/README_en.md index 2a0fba6..62f88cd 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -253,6 +253,13 @@ ssf execution review changes/my-change --wave foundation --base --head ` and must remain under +`/.superpowers/sdd/reviews/`. `--base` and `--head` must be real commits +in the `` Git worktree, and `base` must be an ancestor of `head`. +The `/.superpowers/sdd/reviews/` directory hierarchy must be physical, +non-symlink directories. The report itself must be a regular, non-empty, +non-symlink file. + Every planned wave needs a current `pass` review receipt before dependent waves or closing may proceed; revising a plan invalidates earlier receipts. The recovery, switching, and manual-save slash commands proposed in #47 are diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index 5513608..b8958f9 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -1,6 +1,7 @@ import { createHash, randomUUID } from 'node:crypto'; -import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { computeArtifactsHash, computeContractHash } from './hash.mjs'; import { getOverlayPaths } from './sdd-overlay.mjs'; import { readState } from './state-loader.mjs'; @@ -103,13 +104,14 @@ export function recordReview(changeDir, waveId, receipt) { throw new Error("Review receipt status must be 'pass' or 'fail'"); } for (const field of ['base', 'head']) requireText(receipt?.[field], `receipt.${field}`); - validateReviewReportEvidence(receipt?.report); + const report = validateReviewReportEvidence(changeDir, receipt?.report); + const { base, head } = validateReviewRange(changeDir, receipt.base, receipt.head); const savedReceipt = { status: receipt.status, - base: receipt.base, - head: receipt.head, - report: receipt.report, + base, + head, + report, plan_hash: plan.hash, plan_revision: plan.revision, recorded_at: new Date().toISOString(), @@ -131,10 +133,12 @@ export function readCurrentReview(changeDir, waveId, plan = readPlan(changeDir)) try { const receipt = JSON.parse(readFileSync(filePath, 'utf8')); if (receipt?.plan_hash !== plan.hash || receipt?.plan_revision !== plan.revision) return null; + const range = validateReviewRange(changeDir, receipt?.base, receipt?.head); + if (receipt.base !== range.base || receipt.head !== range.head) return null; // A passing receipt is current evidence only while its referenced report // remains safe and readable. Recheck it here because reports can be // deleted or replaced after the receipt was recorded. - if (receipt?.status === 'pass') validateReviewReportEvidence(receipt.report); + if (receipt?.status === 'pass') validateReviewReportEvidence(changeDir, receipt.report); return receipt; } catch { return null; @@ -165,15 +169,18 @@ export function describeWaves(changeDir, plan = readPlan(changeDir)) { }); } -function validateReviewReportEvidence(report) { +function validateReviewReportEvidence(changeDir, report) { requireText(report, 'receipt.report'); if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(report)) { throw new Error('Review report evidence path is unsafe'); } + const { changeRoot, reviewsDir } = getPhysicalReviewsDirectory(changeDir); + const reportPath = isAbsolute(report) ? resolve(report) : resolve(changeRoot, report); + let metadata; try { - metadata = lstatSync(report); + metadata = lstatSync(reportPath); } catch (error) { throw new Error(`Review report evidence cannot be read: ${error.message}`); } @@ -183,6 +190,72 @@ function validateReviewReportEvidence(report) { if (metadata.size === 0) { throw new Error('Review report evidence must be non-empty'); } + const realReportPath = realpathSync(reportPath); + const realOverlayRelativePath = relative(reviewsDir, realReportPath); + if (realOverlayRelativePath === '' || realOverlayRelativePath === '..' || realOverlayRelativePath.startsWith(`..${sep}`) || isAbsolute(realOverlayRelativePath)) { + throw new Error('Review report evidence must resolve inside the change review overlay'); + } + return relative(changeRoot, realReportPath); +} + +function getPhysicalReviewsDirectory(changeDir) { + let changeRoot; + try { + changeRoot = realpathSync(changeDir); + } catch (error) { + throw new Error(`Review report evidence cannot resolve the change directory: ${error.message}`); + } + + let directory = changeRoot; + for (const component of ['.superpowers', 'sdd', 'reviews']) { + directory = join(directory, component); + let metadata; + try { + metadata = lstatSync(directory); + } catch (error) { + throw new Error(`Review report evidence cannot read the ${component} overlay directory: ${error.message}`); + } + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error('Review report evidence requires physical .superpowers/sdd/reviews overlay directories'); + } + } + return { changeRoot, reviewsDir: directory }; +} + +function validateReviewRange(changeDir, base, head) { + const gitRoot = getGitRoot(changeDir); + const resolvedBase = resolveGitCommit(gitRoot, base, 'base'); + const resolvedHead = resolveGitCommit(gitRoot, head, 'head'); + try { + execFileSync('git', ['-C', gitRoot, 'merge-base', '--is-ancestor', resolvedBase, resolvedHead], { + stdio: 'ignore', + }); + } catch { + throw new Error('Review receipt base must be an ancestor of head'); + } + return { base: resolvedBase, head: resolvedHead }; +} + +function getGitRoot(changeDir) { + try { + return execFileSync('git', ['-C', changeDir, 'rev-parse', '--show-toplevel'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + throw new Error('Review receipts require the change directory to be inside a Git work tree'); + } +} + +function resolveGitCommit(gitRoot, revision, field) { + try { + return execFileSync('git', ['-C', gitRoot, 'rev-parse', '--verify', `${revision}^{commit}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + throw new Error(`Review receipt ${field} must name an existing Git commit`); + } } function blockedDependencies(changeDir, plan, wave) { diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 57039b5..24f68b2 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -1,16 +1,21 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); let changeDir; +let gitRefs; -function runSsf(args) { +function runSsf(args, cwd = process.cwd()) { try { - const stdout = execFileSync(process.execPath, [CLI, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const stdout = execFileSync(process.execPath, [CLI, ...args], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); return { exitCode: 0, stdout, stderr: '', json: tryJson(stdout) }; } catch (error) { return { @@ -22,6 +27,10 @@ function runSsf(args) { } } +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + function tryJson(text) { try { return JSON.parse(text); } catch { return null; } } @@ -42,16 +51,33 @@ function writeChangeDirectory(directory, workflow = 'full', revision = null) { } function writeReviewReport(name, content = 'Review completed without blocking findings.\n') { - const reportsDir = join(changeDir, 'reports'); + const reportsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); mkdirSync(reportsDir, { recursive: true }); const reportPath = join(reportsDir, name); writeFileSync(reportPath, content); return reportPath; } +function initializeGitRepository(directory) { + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'tests@example.invalid']); + runGit(directory, ['config', 'user.name', 'Execution Test']); + runGit(directory, ['add', '--all']); + runGit(directory, ['commit', '--quiet', '--message', 'initial execution change']); + const base = runGit(directory, ['rev-parse', 'HEAD']); + + writeFileSync(join(directory, 'git-range-marker.txt'), 'second commit\n'); + runGit(directory, ['add', 'git-range-marker.txt']); + runGit(directory, ['commit', '--quiet', '--message', 'second execution change']); + const head = runGit(directory, ['rev-parse', 'HEAD']); + const divergent = runGit(directory, ['commit-tree', `${head}^{tree}`, '-m', 'independent execution change']); + return { base, head, divergent }; +} + beforeEach(() => { changeDir = mkdtempSync(join(tmpdir(), 'ssf-execution-cmd-')); writeChangeDirectory(changeDir); + gitRefs = initializeGitRepository(changeDir); }); afterEach(() => { @@ -118,12 +144,159 @@ describe('ssf execution', () => { }]); }); + it('keeps an overlay-relative review report current across working directories', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + writeReviewReport('wave-1.md'); + const reviewCwd = mkdtempSync(join(tmpdir(), 'ssf-review-cwd-')); + const showCwd = mkdtempSync(join(tmpdir(), 'ssf-show-cwd-')); + + try { + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, + '--report', '.superpowers/sdd/reviews/wave-1.md', '--verdict', 'pass'], reviewCwd); + assert.equal(reviewed.exitCode, 0, reviewed.stderr); + + const shown = runSsf(['execution', 'show', changeDir, '--json'], showCwd); + assert.equal(shown.exitCode, 0, shown.stderr); + assert.equal(shown.json.current, true); + assert.equal(shown.json.waves[0].receipt.status, 'pass'); + } finally { + rmSync(reviewCwd, { recursive: true, force: true }); + rmSync(showCwd, { recursive: true, force: true }); + } + }); + + it('rejects review reports outside the change overlay', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const outsideReport = join(changeDir, 'reports', 'wave-1.md'); + mkdirSync(join(changeDir, 'reports'), { recursive: true }); + writeFileSync(outsideReport, 'Review completed without blocking findings.\n'); + + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', outsideReport, '--verdict', 'pass']); + + assert.notEqual(reviewed.exitCode, 0); + assert.match(reviewed.stderr, /overlay|review/i); + }); + + it('rejects a report reached through a nested review-directory symlink', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const outsideDir = join(changeDir, 'reports'); + mkdirSync(outsideDir, { recursive: true }); + writeFileSync(join(outsideDir, 'escaped.md'), 'Review completed without blocking findings.\n'); + const reviewsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); + mkdirSync(reviewsDir, { recursive: true }); + symlinkSync(outsideDir, join(reviewsDir, 'linked'), 'dir'); + + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, + '--report', '.superpowers/sdd/reviews/linked/escaped.md', '--verdict', 'pass']); + + assert.notEqual(reviewed.exitCode, 0); + assert.match(reviewed.stderr, /overlay|review/i); + }); + + it('rejects a report when the reviews overlay root is a symlink', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const outsideReviewsDir = mkdtempSync(join(tmpdir(), 'ssf-external-reviews-')); + const reviewsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); + + try { + writeFileSync(join(outsideReviewsDir, 'wave-1.md'), 'Review completed without blocking findings.\n'); + symlinkSync(outsideReviewsDir, reviewsDir, 'dir'); + + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, + '--report', '.superpowers/sdd/reviews/wave-1.md', '--verdict', 'pass']); + + assert.notEqual(reviewed.exitCode, 0); + assert.match(reviewed.stderr, /overlay|review|symbolic/i); + } finally { + rmSync(outsideReviewsDir, { recursive: true, force: true }); + } + }); + + it('rejects a receipt range containing a nonexistent Git commit', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const forgedCommit = '0000000000000000000000000000000000000001'; + + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', forgedCommit, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + + assert.notEqual(reviewed.exitCode, 0); + assert.match(reviewed.stderr, /base|commit|Git/i); + }); + + it('rejects a receipt range whose base is not an ancestor of head', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1']); + assert.equal(planned.exitCode, 0, planned.stderr); + + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.head, '--head', gitRefs.divergent, + '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + + assert.notEqual(reviewed.exitCode, 0); + assert.match(reviewed.stderr, /ancestor|range|base/i); + }); + + it('treats a persisted pass receipt with a forged Git base as unusable', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1', '--wave', 'wave-2:serial:1.2:wave-1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + assert.equal(reviewed.exitCode, 0, reviewed.stderr); + + const receiptPath = join(changeDir, '.superpowers', 'sdd', 'reviews', Buffer.from('wave-1', 'utf8').toString('base64url') + '.json'); + const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); + receipt.base = '0000000000000000000000000000000000000001'; + writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + + const shown = runSsf(['execution', 'show', changeDir, '--json']); + assert.equal(shown.exitCode, 0, shown.stderr); + assert.equal(shown.json.waves[0].receipt, null); + assert.deepEqual(shown.json.waves[1].blockers, ['wave-1']); + assert.equal(shown.json.waves[1].eligible, false); + }); + + it('treats a persisted pass receipt with a non-ancestral Git range as unusable', () => { + const planned = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', + '--wave', 'wave-1:serial:1.1', '--wave', 'wave-2:serial:1.2:wave-1']); + assert.equal(planned.exitCode, 0, planned.stderr); + const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + assert.equal(reviewed.exitCode, 0, reviewed.stderr); + + const receiptPath = join(changeDir, '.superpowers', 'sdd', 'reviews', Buffer.from('wave-1', 'utf8').toString('base64url') + '.json'); + const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); + receipt.base = gitRefs.head; + receipt.head = gitRefs.divergent; + writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); + + const shown = runSsf(['execution', 'show', changeDir, '--json']); + assert.equal(shown.exitCode, 0, shown.stderr); + assert.equal(shown.json.waves[0].receipt, null); + assert.deepEqual(shown.json.waves[1].blockers, ['wave-1']); + assert.equal(shown.json.waves[1].eligible, false); + }); + it('does not show a pass receipt after its report evidence is deleted', () => { runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', '--reason', 'full workflow default', '--wave', 'wave-1:serial:1.1']); const reportPath = writeReviewReport('wave-1.md'); const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'abc1234', '--head', 'def5678', '--report', reportPath, '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass']); assert.equal(reviewed.exitCode, 0, reviewed.stderr); rmSync(reportPath); @@ -149,7 +322,7 @@ describe('ssf execution', () => { assert.deepEqual(shown.json.waves[1].blockers, ['wave-1']); const premature = runSsf(['execution', 'review', changeDir, '--wave', 'wave-2', - '--base', 'abc1234', '--head', 'def5678', '--report', 'reports/wave-2.md', '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', 'reports/wave-2.md', '--verdict', 'pass']); assert.notEqual(premature.exitCode, 0); assert.match(premature.stderr, /wave-1.*pass|dependencies/i); }); @@ -186,7 +359,7 @@ describe('ssf execution', () => { assert.equal(initial.exitCode, 0, initial.stderr); const reportPath = writeReviewReport('wave-1.md'); const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'abc1234', '--head', 'def5678', '--report', reportPath, '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', reportPath, '--verdict', 'pass']); assert.equal(reviewed.exitCode, 0, reviewed.stderr); const revised = runSsf(['execution', 'revise', changeDir, '--mode', 'sdd', @@ -205,7 +378,7 @@ describe('ssf execution', () => { '--reason', 'full workflow default', '--wave', 'wave-1:serial:1.1']); assert.equal(initial.exitCode, 0, initial.stderr); const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'base-1', '--head', 'head-1', '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); assert.equal(reviewed.exitCode, 0, reviewed.stderr); const replanned = runSsf(['execution', 'revise', changeDir, '--mode', 'sdd', @@ -227,7 +400,7 @@ describe('ssf execution', () => { '--reason', 'full workflow default', '--wave', 'wave-1:serial:1.1']); assert.equal(initial.exitCode, 0, initial.stderr); const reviewed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'base-1', '--head', 'head-1', '--report', writeReviewReport('stale-wave-1.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('stale-wave-1.md'), '--verdict', 'pass']); assert.equal(reviewed.exitCode, 0, reviewed.stderr); writeFileSync(join(changeDir, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 Updated task\n- [ ] 1.2 Recovery task\n'); @@ -252,7 +425,7 @@ describe('ssf execution', () => { assert.equal(planned.exitCode, 0, planned.stderr); const failed = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'base-fail', '--head', 'head-fail', '--report', writeReviewReport('wave-1-fail.md'), '--verdict', 'fail']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1-fail.md'), '--verdict', 'fail']); assert.equal(failed.exitCode, 0, failed.stderr); let shown = runSsf(['execution', 'show', changeDir, '--json']); @@ -264,7 +437,7 @@ describe('ssf execution', () => { assert.deepEqual(shown.json.waves[1].blockers, ['wave-1']); const replacement = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'base-pass', '--head', 'head-pass', '--report', writeReviewReport('wave-1-pass.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1-pass.md'), '--verdict', 'pass']); assert.equal(replacement.exitCode, 0, replacement.stderr); shown = runSsf(['execution', 'show', changeDir, '--json']); @@ -303,15 +476,15 @@ describe('ssf execution', () => { '--wave', 'wave-1:parallel:1.1,1.2']); const result = runSsf(['execution', 'review', changeDir, '--wave', 'wave-1', - '--base', 'abc1234', '--head', 'def5678', '--report', 'reports/wave-1.md', '--verdict', 'maybe', '--json']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', 'reports/wave-1.md', '--verdict', 'maybe', '--json']); assert.notEqual(result.exitCode, 0); assert.match(result.stderr, /pass.*fail|verdict/i); }); it('rejects a review without exactly one wave selector', () => { - const result = runSsf(['execution', 'review', changeDir, '--base', 'abc1234', - '--head', 'def5678', '--report', 'reports/wave-1.md', '--verdict', 'pass']); + const result = runSsf(['execution', 'review', changeDir, '--base', gitRefs.base, + '--head', gitRefs.head, '--report', 'reports/wave-1.md', '--verdict', 'pass']); assert.notEqual(result.exitCode, 0); assert.match(result.stderr, /--wave is required/); diff --git a/tests/lib/execution-control-plane.test.mjs b/tests/lib/execution-control-plane.test.mjs index 75aa70d..1e36352 100644 --- a/tests/lib/execution-control-plane.test.mjs +++ b/tests/lib/execution-control-plane.test.mjs @@ -69,6 +69,46 @@ describe('execution control plane instructions', () => { 'CLI help describes SDD replanning instead of only inline upgrades'); }); + it('documents portable and auditable review receipt evidence', () => { + const localizedDocuments = ['README.md', 'INSTALL.md']; + + for (const path of localizedDocuments) { + const content = read(path); + assert.match(content, + /--report.*相对于.*.*解析.*\/.superpowers\/sdd\/reviews/is, + `${path} resolves review reports from the change directory into its reviews overlay`); + assert.match(content, /--base.*--head.*真实.*commit/is, + `${path} requires real commits for review ranges`); + assert.match(content, /.*Git.*工作树/is, + `${path} binds review ranges to the change worktree`); + assert.match(content, /base.*head.*祖先/is, + `${path} requires base to precede head`); + assert.match(content, + /\/.superpowers\/sdd\/reviews\/.*物理.*非符号链接/is, + `${path} requires physical, non-symlink review overlay directories`); + assert.match(content, + /report.*普通.*非空.*非符号链接.*文件/is, + `${path} requires review reports to be regular, non-empty, non-symlink files`); + } + + const english = read('docs/README_en.md'); + assert.match(english, + /--report.*resolved relative to.*.*must remain under.*\/.superpowers\/sdd\/reviews/is, + 'English documentation resolves review reports from the change directory into its reviews overlay'); + assert.match(english, /--base.*--head.*real commits/is, + 'English documentation requires real commits for review ranges'); + assert.match(english, /.*Git worktree/is, + 'English documentation binds review ranges to the change worktree'); + assert.match(english, /base.*ancestor.*head/is, + 'English documentation requires base to precede head'); + assert.match(english, + /\/.superpowers\/sdd\/reviews\/.*physical.*non-symlink/is, + 'English documentation requires physical, non-symlink review overlay directories'); + assert.match(english, + /report.*regular.*non-empty.*non-symlink.*file/is, + 'English documentation requires review reports to be regular, non-empty, non-symlink files'); + }); + it('keeps execution mode and review gates machine-backed in every entry point', () => { const workflowStart = read('skills/workflow-start/SKILL.md'); const buildExecutor = read('skills/build-executor/SKILL.md'); diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 891158e..695d097 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -9,12 +10,14 @@ import { import { readState } from '../../scripts/lib/state-loader.mjs'; let changeDir; +let gitRefs; beforeEach(() => { changeDir = mkdtempSync(join(tmpdir(), 'execution-plan-')); writeFileSync(join(changeDir, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 First task\n- [ ] 1.2 Second task\n'); writeFileSync(join(changeDir, 'execution-contract.md'), '# Execution Contract\n\nCurrent contract.\n'); writeFileSync(join(changeDir, '.spec-superflow.yaml'), 'state: approved-for-build\nworkflow: full\nrevision: 2\n'); + gitRefs = initializeGitRepository(changeDir); }); afterEach(() => { @@ -22,13 +25,30 @@ afterEach(() => { }); function writeReviewReport(name, content = 'Review completed without blocking findings.\n') { - const reportsDir = join(changeDir, 'reports'); + const reportsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); mkdirSync(reportsDir, { recursive: true }); const reportPath = join(reportsDir, name); writeFileSync(reportPath, content); return reportPath; } +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +function initializeGitRepository(directory) { + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'tests@example.invalid']); + runGit(directory, ['config', 'user.name', 'Execution Plan Test']); + runGit(directory, ['add', '--all']); + runGit(directory, ['commit', '--quiet', '--message', 'initial execution plan change']); + const base = runGit(directory, ['rev-parse', 'HEAD']); + writeFileSync(join(directory, 'git-range-marker.txt'), 'second commit\n'); + runGit(directory, ['add', 'git-range-marker.txt']); + runGit(directory, ['commit', '--quiet', '--message', 'second execution plan change']); + return { base, head: runGit(directory, ['rev-parse', 'HEAD']) }; +} + describe('execution plan data contract', () => { it('creates a current SDD plan with an auditable parallel wave', () => { const plan = createPlan(changeDir, { @@ -218,13 +238,13 @@ describe('execution plan data contract', () => { const reportPath = writeReviewReport('wave-1.md'); const receipt = recordReview(changeDir, 'wave-1', { - status: 'pass', base: 'abc1234', head: 'def5678', report: reportPath, + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: reportPath, }); assert.equal(receipt.status, 'pass'); assert.ok(receipt.recorded_at); const reviewsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); - const receiptFiles = readdirSync(reviewsDir); + const receiptFiles = readdirSync(reviewsDir).filter(fileName => fileName.endsWith('.json')); assert.equal(receiptFiles.length, 1); assert.deepEqual( JSON.parse(readFileSync(join(reviewsDir, receiptFiles[0]), 'utf8')), @@ -232,7 +252,7 @@ describe('execution plan data contract', () => { ); assert.throws( () => recordReview(changeDir, 'unknown-wave', { - status: 'pass', base: 'abc1234', head: 'def5678', report: reportPath, + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: reportPath, }), /unknown wave/, ); @@ -251,19 +271,20 @@ describe('execution plan data contract', () => { const percentReport = writeReviewReport('percent.md', 'Percent wave passed.\n'); const underscoreReport = writeReviewReport('underscore.md', 'Underscore wave failed.\n'); recordReview(changeDir, 'a%', { - status: 'pass', base: 'base-percent', head: 'head-percent', report: percentReport, + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: percentReport, }); recordReview(changeDir, 'a_25', { - status: 'fail', base: 'base-underscore', head: 'head-underscore', report: underscoreReport, + status: 'fail', base: gitRefs.base, head: gitRefs.head, report: underscoreReport, }); const reviewsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); const receipts = readdirSync(reviewsDir) + .filter(fileName => fileName.endsWith('.json')) .sort() .map(fileName => JSON.parse(readFileSync(join(reviewsDir, fileName), 'utf8'))); assert.equal(receipts.length, 2); - assert.ok(receipts.some(receipt => receipt.report === percentReport)); - assert.ok(receipts.some(receipt => receipt.report === underscoreReport)); + assert.ok(receipts.some(receipt => receipt.report === join('.superpowers', 'sdd', 'reviews', 'percent.md'))); + assert.ok(receipts.some(receipt => receipt.report === join('.superpowers', 'sdd', 'reviews', 'underscore.md'))); }); it('rejects missing, non-file, empty, and symbolic-link report evidence before writing a receipt', () => { @@ -273,7 +294,7 @@ describe('execution plan data contract', () => { }); writePlan(changeDir, plan); - const reportsDir = join(changeDir, 'reports'); + const reportsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); mkdirSync(reportsDir, { recursive: true }); const emptyReport = join(reportsDir, 'empty.md'); const directoryReport = join(reportsDir, 'directory'); @@ -290,10 +311,10 @@ describe('execution plan data contract', () => { symlinkReport, ]) { assert.throws(() => recordReview(changeDir, 'wave-1', { - status: 'pass', base: 'abc1234', head: 'def5678', report, + status: 'pass', base: gitRefs.base, head: gitRefs.head, report, }), /report evidence|review report/i); assert.equal(lstatSync(join(reportsDir, 'valid.md')).isFile(), true); - assert.equal(existsSync(join(changeDir, '.superpowers', 'sdd', 'reviews')), false); + assert.equal(readdirSync(reportsDir).filter(fileName => fileName.endsWith('.json')).length, 0); } }); @@ -306,10 +327,10 @@ describe('execution plan data contract', () => { const reportPath = writeReviewReport('audit.md'); const receipt = recordReview(changeDir, 'wave-1', { - status: 'pass', base: 'abc1234', head: 'def5678', report: reportPath, + status: 'pass', base: gitRefs.base, head: gitRefs.head, report: reportPath, }); - assert.equal(receipt.report, reportPath); + assert.equal(receipt.report, join('.superpowers', 'sdd', 'reviews', 'audit.md')); }); it('returns validation failures instead of throwing for malformed plans', () => { diff --git a/tests/lib/guard-specs-merged.test.mjs b/tests/lib/guard-specs-merged.test.mjs index ea21ae5..0fe1a1c 100644 --- a/tests/lib/guard-specs-merged.test.mjs +++ b/tests/lib/guard-specs-merged.test.mjs @@ -27,9 +27,27 @@ function makeChangeDir(withDelta) { ? '## ADDED Requirements\n\n### Requirement: New\n\nThe system SHALL do new.\n\n#### Scenario: New\n- **WHEN** x\n- **THEN** y\n' : '## Requirements\n\n### Requirement: Existing\n\nThe system SHALL exist.\n\n#### Scenario: Existing\n- **WHEN** a\n- **THEN** b\n'; writeFileSync(join(dir, 'specs', 'test.md'), specsContent); + initializeGitRepository(dir); return dir; } +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +function initializeGitRepository(directory) { + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'tests@example.invalid']); + runGit(directory, ['config', 'user.name', 'Guard Specs Merged Test']); + runGit(directory, ['add', '--all']); + runGit(directory, ['commit', '--quiet', '--message', 'initial closing guard change']); + const base = runGit(directory, ['rev-parse', 'HEAD']); + writeFileSync(join(directory, 'git-range-marker.txt'), 'second commit\n'); + runGit(directory, ['add', 'git-range-marker.txt']); + runGit(directory, ['commit', '--quiet', '--message', 'second closing guard change']); + return { base, head: runGit(directory, ['rev-parse', 'HEAD']) }; +} + function cleanup(dir) { if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } @@ -41,12 +59,15 @@ function runClosingGuard(dir, extraState = '') { join(dir, '.spec-superflow.yaml'), `state: executing\nworkflow: full\nchange_name: test\ndp_6_result: pass: ok\n${extraState}`, ); - const report = join(dir, 'close-review.md'); - writeFileSync(report, 'review passed\n'); execFileSync('node', [CLI, 'execution', 'plan', dir, '--mode', 'sdd', '--reason', 'closing guard regression fixture', '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); + const report = join(dir, '.superpowers', 'sdd', 'reviews', 'close-review.md'); + mkdirSync(join(dir, '.superpowers', 'sdd', 'reviews'), { recursive: true }); + writeFileSync(report, 'review passed\n'); + const base = execFileSync('git', ['rev-parse', 'HEAD~1'], { cwd: dir, encoding: 'utf8' }).trim(); + const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); execFileSync('node', [CLI, 'execution', 'review', dir, '--wave', 'close', - '--base', 'base', '--head', 'head', '--report', report, '--verdict', 'pass'], { stdio: 'pipe', timeout: 5000 }); + '--base', base, '--head', head, '--report', report, '--verdict', 'pass'], { stdio: 'pipe', timeout: 5000 }); execFileSync('node', [GUARD, 'check', dir, 'executing', 'closing', '--json'], { stdio: 'pipe', timeout: 5000 }); return { ok: true, out: '' }; } catch (e) { diff --git a/tests/lib/guard-tests-passing.test.mjs b/tests/lib/guard-tests-passing.test.mjs index 94efff1..3c14802 100644 --- a/tests/lib/guard-tests-passing.test.mjs +++ b/tests/lib/guard-tests-passing.test.mjs @@ -25,9 +25,27 @@ function makeChangeDir() { writeFileSync(join(dir, 'tasks.md'), '# Tasks\n\n- [x] Task 1\n- [x] Task 2\n'); writeFileSync(join(dir, 'specs', 'test.md'), '## ADDED Requirements\n\n### Requirement: Test\n\nThe system SHALL test.\n\n#### Scenario: Test\n- **WHEN** test\n- **THEN** test\n'); writeFileSync(join(dir, 'execution-contract.md'), '# Execution Contract\n\n## Intent Lock\nTest.\n'); + initializeGitRepository(dir); return dir; } +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +function initializeGitRepository(directory) { + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'tests@example.invalid']); + runGit(directory, ['config', 'user.name', 'Guard Tests Passing Test']); + runGit(directory, ['add', '--all']); + runGit(directory, ['commit', '--quiet', '--message', 'initial closing guard change']); + const base = runGit(directory, ['rev-parse', 'HEAD']); + writeFileSync(join(directory, 'git-range-marker.txt'), 'second commit\n'); + runGit(directory, ['add', 'git-range-marker.txt']); + runGit(directory, ['commit', '--quiet', '--message', 'second closing guard change']); + return { base, head: runGit(directory, ['rev-parse', 'HEAD']) }; +} + function cleanup(dir) { if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); } @@ -46,12 +64,15 @@ function runClosingGuard(dir, extraState = '') { `state: executing\nworkflow: full\nchange_name: test\nspec_merged: true\n${extraState}`, ); rmSync(join(dir, '.superpowers'), { recursive: true, force: true }); - const report = join(dir, 'close-review.md'); - writeFileSync(report, 'review passed\n'); execFileSync('node', [CLI, 'execution', 'plan', dir, '--mode', 'sdd', '--reason', 'closing guard regression fixture', '--wave', 'close:serial:1.1'], { stdio: 'pipe', timeout: 5000 }); + const report = join(dir, '.superpowers', 'sdd', 'reviews', 'close-review.md'); + mkdirSync(join(dir, '.superpowers', 'sdd', 'reviews'), { recursive: true }); + writeFileSync(report, 'review passed\n'); + const base = runGit(dir, ['rev-parse', 'HEAD~1']); + const head = runGit(dir, ['rev-parse', 'HEAD']); execFileSync('node', [CLI, 'execution', 'review', dir, '--wave', 'close', - '--base', 'base', '--head', 'head', '--report', report, '--verdict', 'pass'], { stdio: 'pipe', timeout: 5000 }); + '--base', base, '--head', head, '--report', report, '--verdict', 'pass'], { stdio: 'pipe', timeout: 5000 }); execFileSync('node', [GUARD, 'check', dir, 'executing', 'closing', '--json'], { stdio: 'pipe', timeout: 5000 }); return { ok: true, stderr: '' }; } catch (e) { diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index c91935a..21c876d 100644 --- a/tests/lib/guard.test.mjs +++ b/tests/lib/guard.test.mjs @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os'; import { execFileSync } from 'node:child_process'; let tempDir; +let gitRefs; const GUARD_PATH = join(process.cwd(), 'scripts/guard/guard.mjs'); const CLI_PATH = join(process.cwd(), 'scripts/spec-superflow.mjs'); @@ -18,6 +19,23 @@ function runNodeScript(scriptPath, args) { }); } +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +function initializeGitRepository(directory) { + runGit(directory, ['init', '--quiet']); + runGit(directory, ['config', 'user.email', 'tests@example.invalid']); + runGit(directory, ['config', 'user.name', 'Guard Control Records Test']); + runGit(directory, ['add', '--all']); + runGit(directory, ['commit', '--quiet', '--message', 'initial guard control records change']); + const base = runGit(directory, ['rev-parse', 'HEAD']); + writeFileSync(join(directory, 'git-range-marker.txt'), 'second commit\n'); + runGit(directory, ['add', 'git-range-marker.txt']); + runGit(directory, ['commit', '--quiet', '--message', 'second guard control records change']); + return { base, head: runGit(directory, ['rev-parse', 'HEAD']) }; +} + describe('guard: transition matrix', () => { before(() => { tempDir = mkdtempSync(join(tmpdir(), 'ssf-guard-test-')); @@ -271,6 +289,7 @@ describe('guard: execution control records', () => { writeFileSync(join(dir, 'execution-contract.md'), '# Execution Contract\n\n## Intent Lock\n\nGuard control records.\n'); writeFileSync(join(dir, '.spec-superflow.yaml'), 'state: approved-for-build\nworkflow: full\n'); runNodeScript(CLI_PATH, ['state', 'init', dir]); + gitRefs = initializeGitRepository(dir); } function createCurrentPlan() { @@ -292,7 +311,7 @@ describe('guard: execution control records', () => { } function writeReviewReport(name, content = 'Review completed without blocking findings.\n') { - const reportsDir = join(dir, 'reports'); + const reportsDir = join(dir, '.superpowers', 'sdd', 'reviews'); mkdirSync(reportsDir, { recursive: true }); const reportPath = join(reportsDir, name); writeFileSync(reportPath, content); @@ -465,9 +484,9 @@ describe('guard: execution control records', () => { assert.match(reviewCheck.failures.join('\n'), /wave-1|receipt/i); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-1', - '--base', 'base-1', '--head', 'head-1', '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', - '--base', 'base-2', '--head', 'head-2', '--report', writeReviewReport('wave-2.md'), '--verdict', 'fail']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2.md'), '--verdict', 'fail']); result = run('executing', 'closing'); reviewCheck = result.output.checks.find(check => check.dimension === 'execution-reviews-passed'); @@ -476,7 +495,7 @@ describe('guard: execution control records', () => { assert.match(reviewCheck.failures.join('\n'), /wave-2.*fail/i); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', - '--base', 'base-2-repair', '--head', 'head-2-repair', '--report', writeReviewReport('wave-2-repair.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2-repair.md'), '--verdict', 'pass']); result = run('executing', 'closing'); reviewCheck = result.output.checks.find(check => check.dimension === 'execution-reviews-passed'); @@ -489,9 +508,9 @@ describe('guard: execution control records', () => { createCurrentPlan(); recordPassingClosingPrerequisites(); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-1', - '--base', 'base-1', '--head', 'head-1', '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-1.md'), '--verdict', 'pass']); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', - '--base', 'base-2', '--head', 'head-2', '--report', writeReviewReport('wave-2.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2.md'), '--verdict', 'pass']); const result = run('executing', 'closing'); @@ -540,9 +559,9 @@ describe('guard: execution control records', () => { recordPassingClosingPrerequisites(); const waveOneReport = writeReviewReport('wave-1.md'); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-1', - '--base', 'base-1', '--head', 'head-1', '--report', waveOneReport, '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', waveOneReport, '--verdict', 'pass']); runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', - '--base', 'base-2', '--head', 'head-2', '--report', writeReviewReport('wave-2.md'), '--verdict', 'pass']); + '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2.md'), '--verdict', 'pass']); replacement.replace(waveOneReport);