Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,12 @@ ssf execution review changes/my-change --wave foundation --base <sha> --head <sh
--report .superpowers/sdd/reviews/foundation.md --verdict pass
```

`--report` 相对于 `<change>` 解析,且必须位于
`<change>/.superpowers/sdd/reviews/` 之下。`--base` 和 `--head` 必须是该
`<change>` Git 工作树中的真实 commit,且 `base` 必须是 `head` 的祖先。
`<change>/.superpowers/sdd/reviews/` 的目录层级必须是物理、非符号链接目录;
report 本身必须为普通、非空、非符号链接文件。

每一个 wave 均须有当前 `pass` review receipt,才可启动依赖 wave 或进入 closing;
修订计划会废止旧 receipt。#47 所提出的恢复、切换与手动保存 slash command 尚未实现,
不能假定有 `/ssf:*` 命令。
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ ssf execution review changes/my-change --wave foundation --base <sha> --head <sh
--report .superpowers/sdd/reviews/foundation.md --verdict pass
```

`--report` 相对于 `<change>` 解析,且必须位于
`<change>/.superpowers/sdd/reviews/` 之下。`--base` 和 `--head` 必须是该
`<change>` Git 工作树中的真实 commit,且 `base` 必须是 `head` 的祖先。
`<change>/.superpowers/sdd/reviews/` 的目录层级必须是物理、非符号链接目录;
report 本身必须为普通、非空、非符号链接文件。

每个 wave 的 review receipt 必须是当前 revision 的 `pass`,依赖 wave 和 closing
才会放行;修订计划会使旧 receipt 失效。恢复、切换和手动保存等 #47 的 slash
command 尚未实现,不能据此假定存在 `/ssf:*` 命令。
Expand Down
7 changes: 7 additions & 0 deletions docs/README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,13 @@ ssf execution review changes/my-change --wave foundation --base <sha> --head <sh
--report .superpowers/sdd/reviews/foundation.md --verdict pass
```

The `--report` path is resolved relative to `<change>` and must remain under
`<change>/.superpowers/sdd/reviews/`. `--base` and `--head` must be real commits
in the `<change>` Git worktree, and `base` must be an ancestor of `head`.
The `<change>/.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
Expand Down
91 changes: 82 additions & 9 deletions scripts/lib/execution-plan.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(),
Expand All @@ -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;
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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) {
Expand Down
Loading
Loading