From 08f67cf810c1ea2c50b77cbbe504a1ac4430d803 Mon Sep 17 00:00:00 2001 From: MageByte Date: Fri, 31 Jul 2026 21:58:20 +0800 Subject: [PATCH 01/22] fix: remove full workflow recovery friction --- .../.spec-superflow.yaml | 46 +++++++++ .../.superpowers/sdd/execution-plan.json | 93 +++++++++++++++++++ .../sdd/execution-recommendation.json | 43 +++++++++ .../.superpowers/sdd/workflow-selection.json | 44 +++++++++ changes/fix-full-flow-friction/design.md | 25 +++++ .../execution-contract.md | 29 ++++++ changes/fix-full-flow-friction/proposal.md | 26 ++++++ .../specs/sdd-execution-control/spec.md | 10 ++ .../specs/spec-publication/spec.md | 10 ++ .../specs/workflow-routing/spec.md | 10 ++ .../specs/worktree-isolation/spec.md | 10 ++ changes/fix-full-flow-friction/tasks.md | 16 ++++ scripts/ensure-branch.mjs | 39 +++++++- scripts/guard/guard.mjs | 2 +- scripts/lib/cmd-state.mjs | 2 + scripts/lib/cmd-validate.mjs | 34 ++++++- scripts/lib/execution-plan.mjs | 9 +- skills/build-executor/SKILL.md | 5 +- skills/build-executor/re-review-prompt.md | 2 +- skills/build-executor/task-reviewer-prompt.md | 4 +- skills/code-reviewer/SKILL.md | 2 +- skills/code-reviewer/code-reviewer-prompt.md | 2 +- tests/lib/cmd-state.test.mjs | 14 +++ tests/lib/cmd-validate-paths.test.mjs | 17 ++++ tests/lib/ensure-branch.test.mjs | 21 ++++- tests/lib/execution-plan.test.mjs | 11 +++ 26 files changed, 508 insertions(+), 18 deletions(-) create mode 100644 changes/fix-full-flow-friction/.spec-superflow.yaml create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/workflow-selection.json create mode 100644 changes/fix-full-flow-friction/design.md create mode 100644 changes/fix-full-flow-friction/execution-contract.md create mode 100644 changes/fix-full-flow-friction/proposal.md create mode 100644 changes/fix-full-flow-friction/specs/sdd-execution-control/spec.md create mode 100644 changes/fix-full-flow-friction/specs/spec-publication/spec.md create mode 100644 changes/fix-full-flow-friction/specs/workflow-routing/spec.md create mode 100644 changes/fix-full-flow-friction/specs/worktree-isolation/spec.md create mode 100644 changes/fix-full-flow-friction/tasks.md diff --git a/changes/fix-full-flow-friction/.spec-superflow.yaml b/changes/fix-full-flow-friction/.spec-superflow.yaml new file mode 100644 index 0000000..ea46b37 --- /dev/null +++ b/changes/fix-full-flow-friction/.spec-superflow.yaml @@ -0,0 +1,46 @@ +# .spec-superflow.yaml — lightweight state machine +# Derived data. Always rebuildable from artifacts. Lost/corrupt → fall back to content-level detection. + +# === Core state === +state: executing +workflow: full +revision: 1 + +# === Hashes (fast staleness detection) === +artifacts_hash: sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc +contract_hash: sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb + +# === Execution progress === +execution_mode: sdd +execution_plan_hash: sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce +execution_plan_revision: 1 +batches_completed: 0 +test_result: null +spec_merged: false +spec_publication_receipt: null + +# === Metadata === +change_name: fix-full-flow-friction +last_transition: 2026-07-31T13:45:16.970Z +last_transition_from: approved-for-build +last_transition_to: executing + +# === Decision points === +dp_0_decisions: scope=修复隔离 worktree、审查回执初始化、Delta 基线预检与状态哈希同步;constraints=兼容现有 CLI 与多平台;artifact_language=zh-CN;workflow_path=full;recommended=full;communication=自动推进并报告门禁 +dp_0_result: confirmed +dp_0_confirmed: true +dp_0_timestamp: 2026-07-31T13:42:35Z +dp_1_result: null +dp_1_timestamp: null +dp_2_result: approved: planning artifacts cover all four observed failures and remain concise +dp_2_timestamp: 2026-07-31T13:45:00Z +dp_3_result: approved: user approval covers the P0→P1 execution contract +dp_3_timestamp: 2026-07-31T13:45:00Z +dp_4_result: sdd: plan revision 1; user-confirmed; 用户批准 P0→P1 全链路修复;采用 CLI 对五项跨模块任务的推荐模式 +dp_4_timestamp: 2026-07-31T13:45:16.200Z +dp_5_result: null +dp_5_timestamp: null +dp_6_result: null +dp_6_timestamp: null +dp_7_result: null +dp_7_timestamp: null diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json b/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json new file mode 100644 index 0000000..6fe16c5 --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json @@ -0,0 +1,93 @@ +{ + "mode": "sdd", + "source": "user-confirmed", + "rationale": "用户批准 P0→P1 全链路修复;采用 CLI 对五项跨模块任务的推荐模式", + "waves": [ + { + "id": "wave-1", + "strategy": "serial", + "tasks": [ + "1.1", + "1.2", + "2.1", + "2.2", + "2.3" + ], + "depends_on": [] + } + ], + "artifacts_hash": "sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc", + "contract_hash": "sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb", + "workflow": "full", + "revision": 1, + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The 5 tasks exceed the configured inline threshold of 3." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 5, + "planned_task_count": 5, + "planned_wave_count": 1, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "recommendation_receipt": { + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The 5 tasks exceed the configured inline threshold of 3." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 5, + "planned_task_count": 5, + "planned_wave_count": 1, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "waves": [ + { + "id": "wave-1", + "strategy": "serial", + "tasks": [ + "1.1", + "1.2", + "2.1", + "2.2", + "2.3" + ], + "depends_on": [] + } + ], + "artifacts_hash": "sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc", + "contract_hash": "sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb", + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-07-31T13:45:05.685Z", + "hash": "sha256:d36a655ce664e66523deb4652f39e63d905ce749bc85ca6162cfa4a44c05f29b" + }, + "selection": { + "confirmed": true, + "followed_recommendation": true, + "acknowledged_non_recommendation": false + }, + "hash": "sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce" +} diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json b/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json new file mode 100644 index 0000000..841bbda --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json @@ -0,0 +1,43 @@ +{ + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The 5 tasks exceed the configured inline threshold of 3." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 5, + "planned_task_count": 5, + "planned_wave_count": 1, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "waves": [ + { + "id": "wave-1", + "strategy": "serial", + "tasks": [ + "1.1", + "1.2", + "2.1", + "2.2", + "2.3" + ], + "depends_on": [] + } + ], + "artifacts_hash": "sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc", + "contract_hash": "sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb", + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-07-31T13:45:05.685Z", + "hash": "sha256:d36a655ce664e66523deb4652f39e63d905ce749bc85ca6162cfa4a44c05f29b" +} diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/workflow-selection.json b/changes/fix-full-flow-friction/.superpowers/sdd/workflow-selection.json new file mode 100644 index 0000000..e007dbd --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/workflow-selection.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "available_modes": [ + "full", + "hotfix", + "tweak", + "quick" + ], + "facts": { + "task_count": 4, + "file_count": 8, + "config_doc_only": "no", + "schema_api_change": "no", + "new_module": "no", + "behavioral_constraint_change": "yes", + "cross_module_change": "yes", + "uncertainty": "low", + "request_kind": "standard" + }, + "missing_facts": [], + "status": "ready", + "recommendation": { + "mode": "full", + "reasons": [ + "Risk signals require the user to choose Quick or Full." + ], + "risk_reasons": [ + "behavioral constraint changed (PRD, spec, design, data, or permission)", + "cross-module change" + ] + }, + "created_at": "2026-07-31T13:42:35.552Z", + "selection": { + "mode": "full", + "reason": "用户批准按 P0→P1 修复全流程可靠性与开销问题", + "followed_recommendation": true, + "acknowledged_non_recommendation": false, + "accepted_automatically": false, + "risk_override": false, + "verification_strategy": null, + "selected_at": "2026-07-31T13:42:35.666Z" + }, + "hash": "sha256:d224fb114fe6401be95224ec3f59752cb5c209b56acc59448187e0cea936cbee" +} diff --git a/changes/fix-full-flow-friction/design.md b/changes/fix-full-flow-friction/design.md new file mode 100644 index 0000000..a17a5af --- /dev/null +++ b/changes/fix-full-flow-friction/design.md @@ -0,0 +1,25 @@ +# 技术设计 + +## 事实与目标 + +- change 工件在执行前通常尚未提交,新的 worktree 不会自动包含它们。 +- 规格同步只能安全地应用到已有的主规格;这一点必须在规划验证时失败。 +- 目标是把失败前移并消除人工 `mkdir`、`rebuild` 等恢复动作。 + +## 决策 + +### 隔离只复制当前 change + +- **选择**:从 Git 根目录计算兄弟 worktree;创建后仅复制 change 目录到同一相对位置。 +- **理由**:保留隔离性,不复制用户其他未提交改动,同时让合同可用。 +- **替代方案**:强制先提交规划工件;会制造无意义提交并增加操作。 + +### 验证执行无副作用的 Delta 预检 + +- **选择**:标准 `changes/` 路径下,`validate` 将 Delta 应用到内存中的主规格。 +- **理由**:复用发布逻辑,缺失 MODIFIED/REMOVED/RENAMED 目标会在实现前被报告。 + +## 风险与验证 + +- 路径逃逸 → 仅接受 Git 根目录内的 change 相对路径 → 临时仓库测试。 +- 兼容性 → 非标准独立验证夹具不要求主规格 → 既有验证测试。 diff --git a/changes/fix-full-flow-friction/execution-contract.md b/changes/fix-full-flow-friction/execution-contract.md new file mode 100644 index 0000000..5631955 --- /dev/null +++ b/changes/fix-full-flow-friction/execution-contract.md @@ -0,0 +1,29 @@ +# 执行合同:全流程可靠性修复 + +## Intent Lock + +- **范围内**:隔离路径与工件可用性、首次 review 回执、Delta 基线预检、状态转换哈希。 +- **范围外**:新阶段、审批模型和规格格式重写。 + +## Approved Behavior + +- 在 `main`/`master` 执行 isolate 时,worktree 位于仓库旁,且只带入 active change。 +- 首次 review 无需手工创建证据目录;报告必须有可发现的规范位置。 +- `ssf validate` 在实现前拒绝不能应用到主规格的 Delta。 +- 确认后的 Full change 可先进入 `specifying`;每次转换后的状态检查一致。 + +## Constraints + +- 保留 CLI 参数、`--force` 语义、独立验证夹具兼容性和现有 overlay 安全边界。 +- 复用既有 publication/apply 逻辑;不引入运行时依赖。 + +## Wave 1 + +- **任务**:1.1、1.2、2.1、2.2、2.3。 +- **策略**:`serial`;这些改动共享 CLI/状态边界。 +- **完成标准**:每项新增回归测试先失败后通过;`npm run build && npm test` 通过;独立审查通过。 + +## Rewind Rules + +- 若必须复制 change 目录以外的未提交文件,回到 design。 +- 若 Delta 预检破坏独立夹具,回到 specifying。 diff --git a/changes/fix-full-flow-friction/proposal.md b/changes/fix-full-flow-friction/proposal.md new file mode 100644 index 0000000..9c2e469 --- /dev/null +++ b/changes/fix-full-flow-friction/proposal.md @@ -0,0 +1,26 @@ +# 全流程可靠性修复 + +## Why + +一次真实的 Full 演练表明,隔离后的工作树缺少未提交的规划工件,执行无法依据合同继续;首次审查回执和缺失主规格又会在过晚阶段失败。这些不是业务风险,而是工作流自身增加的等待、重复解释和 token 消耗。 + +## What Changes + +- 让隔离工作树位于仓库旁并带入当前 change 的规划工件。 +- 让审查回执在首次写入时初始化证据目录,并明确报告位置。 +- 在 `ssf validate` 阶段预检 Delta 对主规格的可应用性。 +- 消除 entering `specifying` 的工件顺序矛盾,并在状态迁移时同步哈希。 + +## Scope + +### In Scope + +- 隔离、审查回执、验证和状态转换的可靠性与测试。 + +### Out of Scope + +- 新增工作流阶段、修改 Full 的审批语义,或重写既有规格格式。 + +## Verification + +- 新增回归测试;运行 `npm run build && npm test`。 diff --git a/changes/fix-full-flow-friction/specs/sdd-execution-control/spec.md b/changes/fix-full-flow-friction/specs/sdd-execution-control/spec.md new file mode 100644 index 0000000..2191883 --- /dev/null +++ b/changes/fix-full-flow-friction/specs/sdd-execution-control/spec.md @@ -0,0 +1,10 @@ +## ADDED Requirements + +### Requirement: First review evidence initialization + +The system SHALL create the physical review evidence overlay before validating or recording a first wave review report. + +#### Scenario: First review receipt + +- **WHEN** a planned wave records its first review +- **THEN** a report stored in the review overlay can be recorded without a manual directory creation step diff --git a/changes/fix-full-flow-friction/specs/spec-publication/spec.md b/changes/fix-full-flow-friction/specs/spec-publication/spec.md new file mode 100644 index 0000000..810886e --- /dev/null +++ b/changes/fix-full-flow-friction/specs/spec-publication/spec.md @@ -0,0 +1,10 @@ +## ADDED Requirements + +### Requirement: Delta baseline preflight + +The system SHALL report an invalid MODIFIED, REMOVED, or RENAMED delta during standard change validation when its canonical baseline cannot accept that operation. + +#### Scenario: Missing modified requirement + +- **WHEN** a change modifies a requirement absent from its canonical baseline +- **THEN** `ssf validate` fails before implementation or release synchronization diff --git a/changes/fix-full-flow-friction/specs/workflow-routing/spec.md b/changes/fix-full-flow-friction/specs/workflow-routing/spec.md new file mode 100644 index 0000000..7f6f21d --- /dev/null +++ b/changes/fix-full-flow-friction/specs/workflow-routing/spec.md @@ -0,0 +1,10 @@ +## ADDED Requirements + +### Requirement: Specifying entry and state freshness + +The system SHALL allow a confirmed Full change to enter `specifying` before planning artifacts exist, and SHALL persist current artifact and contract hashes after every successful state transition. + +#### Scenario: Start specification after intake + +- **WHEN** DP-0 is confirmed for a Full change with no planning artifacts +- **THEN** the transition to `specifying` succeeds and a subsequent state check is consistent diff --git a/changes/fix-full-flow-friction/specs/worktree-isolation/spec.md b/changes/fix-full-flow-friction/specs/worktree-isolation/spec.md new file mode 100644 index 0000000..76ddfa5 --- /dev/null +++ b/changes/fix-full-flow-friction/specs/worktree-isolation/spec.md @@ -0,0 +1,10 @@ +## MODIFIED Requirements + +### Requirement: 保持隔离安全行为 + +isolate wrapper SHALL 保留 literal command invocation、15 秒 child timeout、显式 `--force` 转发,以及没有批准时拒绝在 protected branch 原地编辑的现有行为;在创建 worktree 时,系统 SHALL 将其放在 Git 仓库旁,并让 active change 工件位于相同的仓库相对路径。 + +#### Scenario: Uncommitted planning artifacts + +- **WHEN** an active change contains uncommitted planning artifacts +- **THEN** the isolated worktree contains that change directory without copying unrelated working-tree changes diff --git a/changes/fix-full-flow-friction/tasks.md b/changes/fix-full-flow-friction/tasks.md new file mode 100644 index 0000000..c72d8ed --- /dev/null +++ b/changes/fix-full-flow-friction/tasks.md @@ -0,0 +1,16 @@ +# 实现任务 + +## 交付与证明 + +| 批次 | 交付结果 | 证明 | +|---|---|---| +| 1 | 隔离与审查回执无需人工恢复 | 专项测试 | +| 2 | Delta 与状态错误在前置阶段发现 | 专项测试 | + +## Tasks + +- [ ] **1.1 修复 worktree 隔离**:计算仓库级路径并复制当前 change;证明:`node --test tests/lib/ensure-branch.test.mjs`。 +- [ ] **1.2 初始化审查证据目录**:写回执前创建 overlay,并更新报告路径说明;证明:`node --test tests/lib/execution-plan.test.mjs`。 +- [ ] **2.1 前置 Delta 基线检查**:`ssf validate` 检测不可应用的修改;证明:`node --test tests/lib/cmd-validate-paths.test.mjs`。 +- [ ] **2.2 修复状态进入与哈希同步**:允许正确的 specifying 入口并持久化当前哈希;证明:`node --test tests/lib/cmd-state.test.mjs`。 +- [ ] **2.3 跑全量回归**:构建并运行全部测试;证明:`npm run build && npm test`。 diff --git a/scripts/ensure-branch.mjs b/scripts/ensure-branch.mjs index df966eb..4ac18f6 100644 --- a/scripts/ensure-branch.mjs +++ b/scripts/ensure-branch.mjs @@ -11,6 +11,8 @@ // the same form proven safe by install-cursor.mjs / install.mjs. There is no // string-form shell command, no variable command, and no dynamic args array. import { execFileSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, realpathSync } from 'node:fs'; +import { basename, dirname, join, relative, resolve, sep } from 'node:path'; const changeDir = process.argv[2]; const changeName = process.argv[3]; @@ -24,6 +26,11 @@ if (!changeDir) { const PROTECTED = ['main', 'master']; const GIT_OPTS = { encoding: 'utf-8', cwd: changeDir, stdio: ['ignore', 'pipe', 'pipe'] }; +function insideRepository(repoRoot, candidate) { + const relativePath = relative(repoRoot, candidate); + return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`); +} + // Determine current branch (literal arg array). let branch = ''; try { @@ -40,14 +47,40 @@ if (!PROTECTED.includes(branch)) { console.error(`ensure-branch: on protected branch '${branch}'. Creating an isolated implementation context...`); -const repoName = changeDir.split('/').filter(Boolean).pop() || 'repo'; +let repoRoot; +try { + repoRoot = realpathSync((execFileSync('git', ['rev-parse', '--show-toplevel'], GIT_OPTS) || '').trim()); +} catch { + console.error('ensure-branch: could not determine the Git repository root.'); + process.exit(1); +} + +const sourceChangeDir = realpathSync(resolve(changeDir)); +if (!insideRepository(repoRoot, sourceChangeDir)) { + console.error('ensure-branch: change directory must be inside the Git repository.'); + process.exit(1); +} +const changeRelativePath = relative(repoRoot, sourceChangeDir); +const repoName = basename(repoRoot) || 'repo'; const name = changeName || repoName; -const worktreePath = `../${repoName}-${name}`; +const worktreePath = join(dirname(repoRoot), `${repoName}-${name}`); + +function copyActiveChange(worktreeRoot) { + if (!existsSync(sourceChangeDir)) return; + const targetChangeDir = join(worktreeRoot, changeRelativePath); + mkdirSync(dirname(targetChangeDir), { recursive: true }); + cpSync(sourceChangeDir, targetChangeDir, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + }); +} // Preferred: git worktree (literal arg array). try { execFileSync('git', ['worktree', 'add', worktreePath, '-b', name], { ...GIT_OPTS, stdio: 'inherit' }); - console.log(`ensure-branch: created git worktree at ${worktreePath} on branch '${name}'. Make all implementation edits there.`); + copyActiveChange(worktreePath); + console.log(`ensure-branch: created git worktree at ${worktreePath} on branch '${name}' with active change artifacts. Make all implementation edits there.`); process.exit(0); } catch (e) { console.error(`ensure-branch: worktree creation failed: ${(e.stderr || e.stdout || e.message || 'unknown').toString().trim()}`); diff --git a/scripts/guard/guard.mjs b/scripts/guard/guard.mjs index 895668f..a4deb6e 100644 --- a/scripts/guard/guard.mjs +++ b/scripts/guard/guard.mjs @@ -18,7 +18,7 @@ import { isDirectWorkflowReceipt, readWorkflowSelection } from '../lib/workflow- // Transition matrix: : → required check dimensions const TRANSITION_CHECKS = { // Forward transitions - 'exploring:specifying': ['artifacts-exist'], + 'exploring:specifying': [], 'specifying:bridging': ['artifacts-exist', 'schema-valid'], 'bridging:approved-for-build': ['artifacts-exist', 'schema-valid', 'contract-fresh', 'dp-gate-passed'], 'approved-for-build:executing': ['artifacts-exist', 'contract-fresh', 'dp-gate-passed', 'execution-plan-ready'], diff --git a/scripts/lib/cmd-state.mjs b/scripts/lib/cmd-state.mjs index 9c5b8d7..689d4ae 100644 --- a/scripts/lib/cmd-state.mjs +++ b/scripts/lib/cmd-state.mjs @@ -171,6 +171,8 @@ export async function run(args) { } state.state = toState; + state.artifacts_hash = computeArtifactsHash(changeDir); + state.contract_hash = computeContractHash(changeDir); state.last_transition_from = fromState; state.last_transition_to = toState; state.last_transition = new Date().toISOString(); diff --git a/scripts/lib/cmd-validate.mjs b/scripts/lib/cmd-validate.mjs index 7c1cef1..5501676 100644 --- a/scripts/lib/cmd-validate.mjs +++ b/scripts/lib/cmd-validate.mjs @@ -1,8 +1,9 @@ // ssf validate — validate artifacts in a change directory import { readFileSync, existsSync, statSync } from 'node:fs'; -import { join, basename } from 'node:path'; +import { join, basename, dirname, resolve } from 'node:path'; import { loadConfig } from './config-loader.mjs'; import { validateSpecPathLayout, relativeSpecPath } from './spec-paths.mjs'; +import { applyDeltaToBaselineDetailed } from './spec-publication.mjs'; async function getValidator() { const mod = await import('../../dist/index.js'); @@ -22,6 +23,25 @@ function printReport(label, report) { } } +function projectRootForStandardChange(changeDir) { + const resolvedChangeDir = resolve(changeDir); + const changesDir = dirname(resolvedChangeDir); + return basename(changesDir) === 'changes' ? dirname(changesDir) : null; +} + +function preflightDeltaBaseline(projectRoot, specFile, content) { + if (!projectRoot) return null; + const capability = basename(dirname(specFile)); + const baselinePath = join(projectRoot, 'specs', capability, 'spec.md'); + const baseline = existsSync(baselinePath) ? readFileSync(baselinePath, 'utf-8') : ''; + try { + applyDeltaToBaselineDetailed(baseline, content, capability); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + export async function run(args) { if (args.length < 1) { console.error('Usage: ssf validate '); @@ -36,6 +56,7 @@ export async function run(args) { const config = loadConfig(process.cwd()); const changeName = basename(changeDir); + const projectRoot = projectRootForStandardChange(changeDir); const validator = await getValidator(); console.log(`🔍 Validating: ${changeDir}`); @@ -69,6 +90,17 @@ export async function run(args) { const rel = relativeSpecPath(changeDir, specFile); printReport(rel, report); if (!report.valid) hasErrors = true; + if (report.valid) { + const preflightFailure = preflightDeltaBaseline(projectRoot, specFile, content); + if (preflightFailure) { + printReport(`${rel} (baseline preflight)`, { + valid: false, + issues: [{ level: 'ERROR', path: rel, message: preflightFailure }], + summary: { errors: 1, warnings: 0, info: 0 }, + }); + hasErrors = true; + } + } } // Basic structural validation for design.md and tasks.md (shared pattern) diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index d16df5f..291a613 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -52,6 +52,7 @@ export function writePlan(changeDir, plan) { const paths = getOverlayPaths(changeDir); mkdirSync(paths.root, { recursive: true }); + mkdirSync(paths.reviews, { recursive: true }); atomicWrite(paths.executionPlan, `${JSON.stringify(plan, null, 2)}\n`); writeExecutionPlanSummary(changeDir, plan); return readPlan(changeDir); @@ -125,6 +126,10 @@ 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}`); + const paths = getOverlayPaths(changeDir); + const planPaths = getPlanScopedPaths(changeDir, plan); + mkdirSync(paths.reviews, { recursive: true }); + mkdirSync(planPaths.reviews, { recursive: true }); const reportEvidence = validateReviewReportEvidence(changeDir, receipt?.report); const { base, head } = validateReviewRange(changeDir, receipt.base, receipt.head); const currentReview = readCurrentReviewEvidence(changeDir, waveId, plan); @@ -154,10 +159,6 @@ export function recordReview(changeDir, waveId, receipt) { // Keep the established root receipt as a compatibility mirror, while the // authoritative current-plan copy preserves history across plan revisions. // Both retain the same receipt shape, including report integrity evidence. - const paths = getOverlayPaths(changeDir); - const planPaths = getPlanScopedPaths(changeDir, plan); - mkdirSync(paths.reviews, { recursive: true }); - mkdirSync(planPaths.reviews, { recursive: true }); const serializedReceipt = `${JSON.stringify(savedReceipt, null, 2)}\n`; atomicWrite(join(paths.reviews, `${safeFileName(waveId)}.json`), serializedReceipt); atomicWrite(join(planPaths.reviews, `${safeFileName(waveId)}.json`), serializedReceipt); diff --git a/skills/build-executor/SKILL.md b/skills/build-executor/SKILL.md index 4f3e09f..5fb8c75 100644 --- a/skills/build-executor/SKILL.md +++ b/skills/build-executor/SKILL.md @@ -132,8 +132,9 @@ For Full/legacy Hotfix by default. Dispatch according to the persisted plan, rev 4. After every wave, write a non-empty persisted regular-file review report (separate from the implementer's report), then record exactly one receipt that names that review report: ```bash ssf execution review \ - --wave --base --head --report --verdict + --wave --base --head --report .superpowers/sdd/reviews/.md --verdict ``` + `ssf execution plan` creates this review overlay. Store report evidence in it; paths outside the overlay are rejected for audit safety. Do not begin a dependent wave until its predecessor receipt is `pass`. 5. Critical/Important findings require a `fail` receipt, a focused repair, re-review, then a replacement `pass` receipt. Never advance or close with a missing or failed receipt. @@ -157,7 +158,7 @@ history, and must not write, edit, or modify a repair-state file directly. sixth repair. - Every focused re-review still writes its separate persisted report and is recorded only through `ssf execution review --wave --base - --head --report --verdict `. + --head --report .superpowers/sdd/reviews/-rereview.md --verdict `. A replacement `pass` receipt is the only evidence that resolves the wave. ### Per-Task Loop diff --git a/skills/build-executor/re-review-prompt.md b/skills/build-executor/re-review-prompt.md index 069763e..b3e05b5 100644 --- a/skills/build-executor/re-review-prompt.md +++ b/skills/build-executor/re-review-prompt.md @@ -35,7 +35,7 @@ Subagent (general-purpose): ## Output - Write a distinct non-empty review report to [REVIEW_REPORT_FILE], with the + Write a distinct non-empty review report to `[CHANGE_DIR]/.superpowers/sdd/reviews/[WAVE_ID]-rereview.md`, with the prior finding, scoped-diff evidence, verdict, and file:line references. Then provide exactly this receipt command to the controller: diff --git a/skills/build-executor/task-reviewer-prompt.md b/skills/build-executor/task-reviewer-prompt.md index 3ab1bfa..632d2fd 100644 --- a/skills/build-executor/task-reviewer-prompt.md +++ b/skills/build-executor/task-reviewer-prompt.md @@ -155,8 +155,8 @@ Subagent (general-purpose): ## Output Format - Write your full review to [REVIEW_REPORT_FILE]. This distinct review report - path must point to a non-empty, persisted review report before the + Write your full review to `[CHANGE_DIR]/.superpowers/sdd/reviews/[WAVE_ID].md`. + This distinct review report must be non-empty and persisted before the controller records a receipt. After the verdict, provide the exact receipt command for the controller: diff --git a/skills/code-reviewer/SKILL.md b/skills/code-reviewer/SKILL.md index 79355bf..e19b1b7 100644 --- a/skills/code-reviewer/SKILL.md +++ b/skills/code-reviewer/SKILL.md @@ -16,7 +16,7 @@ Two responsibilities: requesting review (dispatching a reviewer subagent) and re 1. Get SHAs: `BASE_SHA=$(git rev-parse HEAD~1)` and `HEAD_SHA=$(git rev-parse HEAD)` 2. Dispatch `general-purpose` subagent using template at `skills/code-reviewer/code-reviewer-prompt.md` 3. Fill placeholders: `[DESCRIPTION]` (what was built), `[PLAN_OR_REQUIREMENTS]` (contract/spec reference), `[BASE_SHA]`, `[HEAD_SHA]`, `[WAVE_ID]`, and a distinct `[REVIEW_REPORT_FILE]`. -4. Require the reviewer to write a non-empty persisted review report at `[REVIEW_REPORT_FILE]`, then record that exact path in the wave receipt: `ssf execution review --wave --base --head --report --verdict `. +4. Require the reviewer to write a non-empty persisted review report at `.superpowers/sdd/reviews/.md`, then record that exact in-overlay path in the wave receipt. The execution plan initializes this directory; paths outside it are rejected for audit safety. 5. Act on feedback: Critical/Important findings require a `fail` receipt, focused repair, re-review, and replacement `pass` receipt before a dependent wave or closing can proceed. Note Minor for later, push back with reasoning if reviewer is wrong. ### Minimality And Scope diff --git a/skills/code-reviewer/code-reviewer-prompt.md b/skills/code-reviewer/code-reviewer-prompt.md index 007289e..cd85753 100644 --- a/skills/code-reviewer/code-reviewer-prompt.md +++ b/skills/code-reviewer/code-reviewer-prompt.md @@ -87,7 +87,7 @@ Subagent (general-purpose): ## Output Format - Write your full verdict to [REVIEW_REPORT_FILE]. This review report path + Write your full verdict to `[CHANGE_DIR]/.superpowers/sdd/reviews/[WAVE_ID].md`. This review report path must point to a non-empty, persisted review report before the controller records a receipt. Include the wave ID, base SHA, head SHA, and that review report path. End with the exact receipt command: diff --git a/tests/lib/cmd-state.test.mjs b/tests/lib/cmd-state.test.mjs index a0c9030..12f2728 100644 --- a/tests/lib/cmd-state.test.mjs +++ b/tests/lib/cmd-state.test.mjs @@ -114,6 +114,20 @@ describe('cmd-state: transition', () => { const result = ssf(`state transition ${tempDir} specifying`); assert.equal(result.exitCode, 0); assert.ok(result.stdout.includes('exploring -> specifying')); + assert.equal(ssf(`state check ${tempDir}`).exitCode, 0, 'a successful transition must refresh artifact hashes'); + }); + + it('allows a Full change to enter specifying before planning artifacts exist and refreshes hashes', () => { + const emptyChange = mkdtempSync(join(tmpdir(), 'ssf-state-empty-specifying-')); + try { + assert.equal(ssf(`state init ${emptyChange}`).exitCode, 0); + assert.equal(ssf(`state set ${emptyChange} workflow full`).exitCode, 0); + const transition = ssf(`state transition ${emptyChange} specifying`); + assert.equal(transition.exitCode, 0, transition.stderr); + assert.equal(ssf(`state check ${emptyChange}`).exitCode, 0); + } finally { + rmSync(emptyChange, { recursive: true, force: true }); + } }); it('uses the caller project directory for a relative change path', () => { diff --git a/tests/lib/cmd-validate-paths.test.mjs b/tests/lib/cmd-validate-paths.test.mjs index c3fbb83..c4df1cb 100644 --- a/tests/lib/cmd-validate-paths.test.mjs +++ b/tests/lib/cmd-validate-paths.test.mjs @@ -28,6 +28,10 @@ function writeValidSpec(file) { writeFileSync(file, '## ADDED Requirements\n\n### Requirement: Canonical path\n\nThe system SHALL validate canonical specs.\n\n#### Scenario: Valid spec\n- **WHEN** validation runs\n- **THEN** the spec is checked'); } +function writeModifiedSpec(file, name = 'Existing requirement') { + writeFileSync(file, `## MODIFIED Requirements\n\n### Requirement: ${name}\n\nThe system SHALL validate the published baseline.\n\n#### Scenario: Existing baseline\n- **WHEN** validation runs\n- **THEN** the requirement is present`); +} + describe('validate commands: spec paths', () => { before(() => { tempRoot = mkdtempSync(join(tmpdir(), 'ssf-validate-paths-')); @@ -95,6 +99,19 @@ describe('validate commands: spec paths', () => { assert.match(result.stdout, /specs\/ui-theme\/spec\.md/); }); + it('ssf validate rejects a MODIFIED delta whose standard-project baseline is missing', () => { + const repo = mkdtempSync(join(tempRoot, 'missing-baseline-repo-')); + const dir = join(repo, 'changes', 'missing-baseline'); + mkdirSync(join(dir, 'specs', 'ui-theme'), { recursive: true }); + writeBaseChange(dir); + writeModifiedSpec(join(dir, 'specs', 'ui-theme', 'spec.md')); + + const result = runNode([CLI, 'validate', dir]); + + assert.equal(result.exitCode, 1, result.stdout + result.stderr); + assert.match(result.stdout + result.stderr, /Cannot modify missing requirement 'Existing requirement'/); + }); + it('ssf validate rejects nested spec.md even when a canonical spec is present', () => { const dir = mkdtempSync(join(tempRoot, 'nested-')); writeBaseChange(dir); diff --git a/tests/lib/ensure-branch.test.mjs b/tests/lib/ensure-branch.test.mjs index 1fbb97d..4630063 100644 --- a/tests/lib/ensure-branch.test.mjs +++ b/tests/lib/ensure-branch.test.mjs @@ -7,10 +7,9 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { execSync } from 'node:child_process'; import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..', '..'); @@ -56,4 +55,22 @@ describe('BUG/#15: ensure-branch enforces isolation', () => { assert.equal(r.ok, true, `ensure-branch should pass on feature branch, got: ${r.out}`); assert.match(r.out, /already isolated/i); }); + + it('SHALL create a sibling worktree and carry only the active change artifacts from main', () => { + const changeDir = join(repoDir, 'changes', 'planned-change'); + mkdirSync(changeDir, { recursive: true }); + writeFileSync(join(changeDir, 'proposal.md'), 'Uncommitted planning artifact.'); + git(repoDir, 'checkout', '-q', 'main'); + + const r = run(`"${changeDir}" planned-change`); + const worktree = join(dirname(repoDir), `${basename(repoDir)}-planned-change`); + + try { + assert.equal(r.ok, true, r.out); + assert.equal(existsSync(join(worktree, 'changes', 'planned-change', 'proposal.md')), true); + assert.equal(existsSync(join(worktree, 'changes', 'planned-change', 'README.md')), false); + } finally { + if (existsSync(worktree)) git(repoDir, 'worktree', 'remove', '--force', worktree); + } + }); }); diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 4cdd25a..6a9ea11 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -400,6 +400,17 @@ describe('execution plan data contract', () => { ); }); + it('initializes the review overlay when an execution plan is written', () => { + const plan = createPlan(changeDir, { + mode: 'sdd', source: 'default', rationale: 'first review must not need mkdir', + waves: [{ id: 'wave-1', strategy: 'serial', tasks: ['1.1'], depends_on: [] }], + }); + + writePlan(changeDir, plan); + + assert.equal(existsSync(join(changeDir, '.superpowers', 'sdd', 'reviews')), true); + }); + it('persists receipts independently for wave IDs with encoded-name collisions', () => { const plan = createPlan(changeDir, { mode: 'sdd', source: 'default', rationale: 'review receipt naming', From 5df08a5ea28a81f190d9242663c83f1f7af3ef0e Mon Sep 17 00:00:00 2001 From: MageByte Date: Fri, 31 Jul 2026 22:03:14 +0800 Subject: [PATCH 02/22] fix: constrain isolated worktree names --- scripts/ensure-branch.mjs | 12 ++++++++++++ tests/lib/ensure-branch.test.mjs | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/scripts/ensure-branch.mjs b/scripts/ensure-branch.mjs index 4ac18f6..cb98aca 100644 --- a/scripts/ensure-branch.mjs +++ b/scripts/ensure-branch.mjs @@ -31,6 +31,14 @@ function insideRepository(repoRoot, candidate) { return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`); } +function isSafePathSegment(value) { + return typeof value === 'string' + && value.length > 0 + && value !== '.' + && value !== '..' + && !/[\\/\u0000-\u001f]/.test(value); +} + // Determine current branch (literal arg array). let branch = ''; try { @@ -63,6 +71,10 @@ if (!insideRepository(repoRoot, sourceChangeDir)) { const changeRelativePath = relative(repoRoot, sourceChangeDir); const repoName = basename(repoRoot) || 'repo'; const name = changeName || repoName; +if (!isSafePathSegment(name)) { + console.error('ensure-branch: change name must be a single safe path segment.'); + process.exit(1); +} const worktreePath = join(dirname(repoRoot), `${repoName}-${name}`); function copyActiveChange(worktreeRoot) { diff --git a/tests/lib/ensure-branch.test.mjs b/tests/lib/ensure-branch.test.mjs index 4630063..d659b5d 100644 --- a/tests/lib/ensure-branch.test.mjs +++ b/tests/lib/ensure-branch.test.mjs @@ -73,4 +73,15 @@ describe('BUG/#15: ensure-branch enforces isolation', () => { if (existsSync(worktree)) git(repoDir, 'worktree', 'remove', '--force', worktree); } }); + + it('SHALL reject a change name that is not one safe path segment', () => { + const changeDir = join(repoDir, 'changes', 'safe-change'); + mkdirSync(changeDir, { recursive: true }); + git(repoDir, 'checkout', '-q', 'main'); + + const r = run(`"${changeDir}" ../../outside`); + + assert.equal(r.ok, false, r.out); + assert.match(r.out, /single safe path segment/i); + }); }); From a0732a38b8d24df844d7ab893bf4af22334dd2a2 Mon Sep 17 00:00:00 2001 From: MageByte Date: Fri, 31 Jul 2026 22:09:18 +0800 Subject: [PATCH 03/22] docs: record full test verification blocker --- changes/fix-full-flow-friction/.spec-superflow.yaml | 4 ++-- changes/fix-full-flow-friction/tasks.md | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/changes/fix-full-flow-friction/.spec-superflow.yaml b/changes/fix-full-flow-friction/.spec-superflow.yaml index ea46b37..fd285a3 100644 --- a/changes/fix-full-flow-friction/.spec-superflow.yaml +++ b/changes/fix-full-flow-friction/.spec-superflow.yaml @@ -40,7 +40,7 @@ dp_4_result: sdd: plan revision 1; user-confirmed; 用户批准 P0→P1 全链 dp_4_timestamp: 2026-07-31T13:45:16.200Z dp_5_result: null dp_5_timestamp: null -dp_6_result: null -dp_6_timestamp: null +dp_6_result: fail: npm test concurrent runner stalled for over four minutes in synchronous child-process tests; targeted regressions pass +dp_6_timestamp: 2026-07-31T14:08:00Z dp_7_result: null dp_7_timestamp: null diff --git a/changes/fix-full-flow-friction/tasks.md b/changes/fix-full-flow-friction/tasks.md index c72d8ed..662d864 100644 --- a/changes/fix-full-flow-friction/tasks.md +++ b/changes/fix-full-flow-friction/tasks.md @@ -9,8 +9,8 @@ ## Tasks -- [ ] **1.1 修复 worktree 隔离**:计算仓库级路径并复制当前 change;证明:`node --test tests/lib/ensure-branch.test.mjs`。 -- [ ] **1.2 初始化审查证据目录**:写回执前创建 overlay,并更新报告路径说明;证明:`node --test tests/lib/execution-plan.test.mjs`。 -- [ ] **2.1 前置 Delta 基线检查**:`ssf validate` 检测不可应用的修改;证明:`node --test tests/lib/cmd-validate-paths.test.mjs`。 -- [ ] **2.2 修复状态进入与哈希同步**:允许正确的 specifying 入口并持久化当前哈希;证明:`node --test tests/lib/cmd-state.test.mjs`。 +- [x] **1.1 修复 worktree 隔离**:计算仓库级路径并复制当前 change;证明:`node --test tests/lib/ensure-branch.test.mjs`。 +- [x] **1.2 初始化审查证据目录**:写回执前创建 overlay,并更新报告路径说明;证明:`node --test tests/lib/execution-plan.test.mjs`。 +- [x] **2.1 前置 Delta 基线检查**:`ssf validate` 检测不可应用的修改;证明:`node --test tests/lib/cmd-validate-paths.test.mjs`。 +- [x] **2.2 修复状态进入与哈希同步**:允许正确的 specifying 入口并持久化当前哈希;证明:`node --test tests/lib/cmd-state.test.mjs`。 - [ ] **2.3 跑全量回归**:构建并运行全部测试;证明:`npm run build && npm test`。 From bd6d2848785538e1f4159bc7dbc724ba0ed0a615 Mon Sep 17 00:00:00 2001 From: MageByte Date: Fri, 31 Jul 2026 22:50:22 +0800 Subject: [PATCH 04/22] test: align workflow guard and review overlay fixtures --- tests/lib/cmd-execution.test.mjs | 1 + tests/lib/guard.test.mjs | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 040cfe1..61fe421 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -322,6 +322,7 @@ describe('ssf execution', () => { const reviewsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); try { + rmSync(reviewsDir, { recursive: true, force: true }); writeFileSync(join(outsideReviewsDir, 'wave-1.md'), 'Review completed without blocking findings.\n'); symlinkSync(outsideReviewsDir, reviewsDir, 'dir'); diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index e3da5d4..662703a 100644 --- a/tests/lib/guard.test.mjs +++ b/tests/lib/guard.test.mjs @@ -67,11 +67,10 @@ describe('guard: transition matrix', () => { } } - it('exploring→specifying requires artifacts-exist', () => { + it('exploring→specifying permits a confirmed intake before planning artifacts exist', () => { const result = runGuard('exploring', 'specifying'); assert.equal(result.exitCode, 0, `Expected exit 0 but got ${result.exitCode}: ${JSON.stringify(result.output)}`); - const checks = result.output.checks; - assert.ok(checks.some(c => c.dimension === 'artifacts-exist')); + assert.deepEqual(result.output.checks, []); }); it('specifying→bridging requires artifacts-exist + schema-valid', () => { From 2e47381342371e9dbf1f56c1e175d88c7683d426 Mon Sep 17 00:00:00 2001 From: MageByte Date: Fri, 31 Jul 2026 23:27:39 +0800 Subject: [PATCH 05/22] fix: stabilize full workflow verification --- .../.spec-superflow.yaml | 30 ++++++------- .../.superpowers/sdd/execution-plan.json | 42 +++++++++-------- .../sdd/execution-recommendation.json | 20 +++++---- .../repair-state/d2F2ZS0x.json | 24 ++++++++++ .../reviews/d2F2ZS0x.json | 10 +++++ .../.superpowers/sdd/reviews/d2F2ZS0x.json | 10 +++++ .../sdd/reviews/wave-1-final-review.md | 39 ++++++++++++++++ .../sdd/reviews/wave-1-rereview.md | 38 ++++++++++++++++ .../.superpowers/sdd/reviews/wave-1.md | 45 +++++++++++++++++++ changes/fix-full-flow-friction/design.md | 8 ++++ .../execution-contract.md | 10 +++-- changes/fix-full-flow-friction/proposal.md | 11 +++-- .../specs/test-discipline/spec.md | 10 +++++ changes/fix-full-flow-friction/tasks.md | 5 ++- package.json | 2 +- skills/build-executor/task-reviewer-prompt.md | 2 +- skills/code-reviewer/SKILL.md | 2 +- tests/lib/guard.test.mjs | 7 +-- tests/lib/node20-test-entry.test.mjs | 3 +- 19 files changed, 260 insertions(+), 58 deletions(-) create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/repair-state/d2F2ZS0x.json create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/reviews/d2F2ZS0x.json create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-rereview.md create mode 100644 changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1.md create mode 100644 changes/fix-full-flow-friction/specs/test-discipline/spec.md diff --git a/changes/fix-full-flow-friction/.spec-superflow.yaml b/changes/fix-full-flow-friction/.spec-superflow.yaml index fd285a3..3ba337e 100644 --- a/changes/fix-full-flow-friction/.spec-superflow.yaml +++ b/changes/fix-full-flow-friction/.spec-superflow.yaml @@ -4,16 +4,16 @@ # === Core state === state: executing workflow: full -revision: 1 +revision: 3 # === Hashes (fast staleness detection) === -artifacts_hash: sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc -contract_hash: sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb +artifacts_hash: sha256:7693e010ec1f533dec4e3af108674c2785f2cb7ded9b089132a45842100834af +contract_hash: sha256:9381f13b937436653782bc59950653367500be839b92ef9b87a217591fc2f04e # === Execution progress === execution_mode: sdd -execution_plan_hash: sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce -execution_plan_revision: 1 +execution_plan_hash: sha256:e6c2ad664930f4769e76d2fa0319fb1cd5b556a65a46970f41ad7d0b53ff07cf +execution_plan_revision: 3 batches_completed: 0 test_result: null spec_merged: false @@ -21,26 +21,26 @@ spec_publication_receipt: null # === Metadata === change_name: fix-full-flow-friction -last_transition: 2026-07-31T13:45:16.970Z +last_transition: 2026-07-31T15:05:54.455Z last_transition_from: approved-for-build last_transition_to: executing # === Decision points === -dp_0_decisions: scope=修复隔离 worktree、审查回执初始化、Delta 基线预检与状态哈希同步;constraints=兼容现有 CLI 与多平台;artifact_language=zh-CN;workflow_path=full;recommended=full;communication=自动推进并报告门禁 +dp_0_decisions: scope=修复隔离 worktree、审查回执初始化、Delta 基线预检、状态哈希同步与默认测试并发;constraints=兼容现有 CLI、多平台与 Node 20;artifact_language=zh-CN;workflow_path=full;recommended=full;communication=自动推进并报告门禁 dp_0_result: confirmed dp_0_confirmed: true dp_0_timestamp: 2026-07-31T13:42:35Z dp_1_result: null dp_1_timestamp: null -dp_2_result: approved: planning artifacts cover all four observed failures and remain concise -dp_2_timestamp: 2026-07-31T13:45:00Z -dp_3_result: approved: user approval covers the P0→P1 execution contract -dp_3_timestamp: 2026-07-31T13:45:00Z -dp_4_result: sdd: plan revision 1; user-confirmed; 用户批准 P0→P1 全链路修复;采用 CLI 对五项跨模块任务的推荐模式 -dp_4_timestamp: 2026-07-31T13:45:16.200Z +dp_2_result: approved: user confirmed adding npm test concurrent-stall diagnosis and repair to this change +dp_2_timestamp: 2026-07-31T15:05:00Z +dp_3_result: approved: user confirmed the updated contract including controlled default test concurrency +dp_3_timestamp: 2026-07-31T15:05:00Z +dp_4_result: sdd: plan revision 3; user-confirmed-revision; 完成任务记录补充全量验证证据;范围和实现不变,刷新当前执行计划哈希 +dp_4_timestamp: 2026-07-31T15:24:36.454Z dp_5_result: null dp_5_timestamp: null -dp_6_result: fail: npm test concurrent runner stalled for over four minutes in synchronous child-process tests; targeted regressions pass -dp_6_timestamp: 2026-07-31T14:08:00Z +dp_6_result: pass: npm test completed 630/630 with --test-concurrency=2 in 379 seconds +dp_6_timestamp: 2026-07-31T15:24:00Z dp_7_result: null dp_7_timestamp: null diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json b/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json index 6fe16c5..fda5e2b 100644 --- a/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json +++ b/changes/fix-full-flow-friction/.superpowers/sdd/execution-plan.json @@ -1,7 +1,7 @@ { "mode": "sdd", - "source": "user-confirmed", - "rationale": "用户批准 P0→P1 全链路修复;采用 CLI 对五项跨模块任务的推荐模式", + "source": "user-confirmed-revision", + "rationale": "完成任务记录补充全量验证证据;范围和实现不变,刷新当前执行计划哈希", "waves": [ { "id": "wave-1", @@ -11,15 +11,17 @@ "1.2", "2.1", "2.2", - "2.3" + "2.3", + "3.1", + "3.2" ], "depends_on": [] } ], - "artifacts_hash": "sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc", - "contract_hash": "sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb", + "artifacts_hash": "sha256:7693e010ec1f533dec4e3af108674c2785f2cb7ded9b089132a45842100834af", + "contract_hash": "sha256:9381f13b937436653782bc59950653367500be839b92ef9b87a217591fc2f04e", "workflow": "full", - "revision": 1, + "revision": 3, "recommendation": { "available_modes": [ "inline", @@ -29,13 +31,13 @@ "recommendation": { "mode": "sdd", "reasons": [ - "The 5 tasks exceed the configured inline threshold of 3." + "The 7 tasks exceed the configured inline threshold of 3." ] }, "facts": { "workflow": "full", - "documented_task_count": 5, - "planned_task_count": 5, + "documented_task_count": 7, + "planned_task_count": 7, "planned_wave_count": 1, "has_parallel_wave": false, "inline_threshold": 3 @@ -51,13 +53,13 @@ "recommendation": { "mode": "sdd", "reasons": [ - "The 5 tasks exceed the configured inline threshold of 3." + "The 7 tasks exceed the configured inline threshold of 3." ] }, "facts": { "workflow": "full", - "documented_task_count": 5, - "planned_task_count": 5, + "documented_task_count": 7, + "planned_task_count": 7, "planned_wave_count": 1, "has_parallel_wave": false, "inline_threshold": 3 @@ -72,22 +74,24 @@ "1.2", "2.1", "2.2", - "2.3" + "2.3", + "3.1", + "3.2" ], "depends_on": [] } ], - "artifacts_hash": "sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc", - "contract_hash": "sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb", + "artifacts_hash": "sha256:7693e010ec1f533dec4e3af108674c2785f2cb7ded9b089132a45842100834af", + "contract_hash": "sha256:9381f13b937436653782bc59950653367500be839b92ef9b87a217591fc2f04e", "workflow": "full", - "execution_plan_revision_at_recommendation": null, - "created_at": "2026-07-31T13:45:05.685Z", - "hash": "sha256:d36a655ce664e66523deb4652f39e63d905ce749bc85ca6162cfa4a44c05f29b" + "execution_plan_revision_at_recommendation": 2, + "created_at": "2026-07-31T15:24:36.256Z", + "hash": "sha256:64f1ce0bb9c6bc9049e7ca08742bd3b27797c2e75505045f2e02c489e2b07580" }, "selection": { "confirmed": true, "followed_recommendation": true, "acknowledged_non_recommendation": false }, - "hash": "sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce" + "hash": "sha256:e6c2ad664930f4769e76d2fa0319fb1cd5b556a65a46970f41ad7d0b53ff07cf" } diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json b/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json index 841bbda..81b1ae2 100644 --- a/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json +++ b/changes/fix-full-flow-friction/.superpowers/sdd/execution-recommendation.json @@ -8,13 +8,13 @@ "recommendation": { "mode": "sdd", "reasons": [ - "The 5 tasks exceed the configured inline threshold of 3." + "The 7 tasks exceed the configured inline threshold of 3." ] }, "facts": { "workflow": "full", - "documented_task_count": 5, - "planned_task_count": 5, + "documented_task_count": 7, + "planned_task_count": 7, "planned_wave_count": 1, "has_parallel_wave": false, "inline_threshold": 3 @@ -29,15 +29,17 @@ "1.2", "2.1", "2.2", - "2.3" + "2.3", + "3.1", + "3.2" ], "depends_on": [] } ], - "artifacts_hash": "sha256:8ac845ced825308a3face093a4aa3ee883755bdce1b2d586ea136102a6be12bc", - "contract_hash": "sha256:c9792965ddd8c3066a9f09949809b0d4495301ade2e4eeac8977fe8a8c4977eb", + "artifacts_hash": "sha256:7693e010ec1f533dec4e3af108674c2785f2cb7ded9b089132a45842100834af", + "contract_hash": "sha256:9381f13b937436653782bc59950653367500be839b92ef9b87a217591fc2f04e", "workflow": "full", - "execution_plan_revision_at_recommendation": null, - "created_at": "2026-07-31T13:45:05.685Z", - "hash": "sha256:d36a655ce664e66523deb4652f39e63d905ce749bc85ca6162cfa4a44c05f29b" + "execution_plan_revision_at_recommendation": 2, + "created_at": "2026-07-31T15:24:36.256Z", + "hash": "sha256:64f1ce0bb9c6bc9049e7ca08742bd3b27797c2e75505045f2e02c489e2b07580" } diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/repair-state/d2F2ZS0x.json b/changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/repair-state/d2F2ZS0x.json new file mode 100644 index 0000000..75c3622 --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/repair-state/d2F2ZS0x.json @@ -0,0 +1,24 @@ +{ + "plan_hash": "sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce", + "plan_revision": 1, + "wave_id": "wave-1", + "status": "resolved", + "failure_count": 1, + "previous_head": "5df08a5ea28a81f190d9242663c83f1f7af3ef0e", + "previous_report": ".superpowers/sdd/reviews/wave-1.md", + "failures": [ + { + "base": "91050984126a87821faee887e5ee612337498233", + "head": "08f67cf810c1ea2c50b77cbbe504a1ac4430d803", + "report": ".superpowers/sdd/reviews/wave-1.md", + "recorded_at": "2026-07-31T14:02:35.613Z" + } + ], + "resolution": { + "base": "08f67cf810c1ea2c50b77cbbe504a1ac4430d803", + "head": "5df08a5ea28a81f190d9242663c83f1f7af3ef0e", + "report": ".superpowers/sdd/reviews/wave-1-rereview.md", + "recorded_at": "2026-07-31T14:03:56.532Z" + }, + "updated_at": "2026-07-31T14:03:56.535Z" +} diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/reviews/d2F2ZS0x.json b/changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/reviews/d2F2ZS0x.json new file mode 100644 index 0000000..1850fc8 --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/plans/r1-c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce/reviews/d2F2ZS0x.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "08f67cf810c1ea2c50b77cbbe504a1ac4430d803", + "head": "5df08a5ea28a81f190d9242663c83f1f7af3ef0e", + "report": ".superpowers/sdd/reviews/wave-1-rereview.md", + "report_sha256": "sha256:f5ab4820492d9d9f1d59297153a494872f0eea02c5a36e688ba83950426a2e5c", + "plan_hash": "sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce", + "plan_revision": 1, + "recorded_at": "2026-07-31T14:03:56.532Z" +} diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json new file mode 100644 index 0000000..1850fc8 --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "08f67cf810c1ea2c50b77cbbe504a1ac4430d803", + "head": "5df08a5ea28a81f190d9242663c83f1f7af3ef0e", + "report": ".superpowers/sdd/reviews/wave-1-rereview.md", + "report_sha256": "sha256:f5ab4820492d9d9f1d59297153a494872f0eea02c5a36e688ba83950426a2e5c", + "plan_hash": "sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce", + "plan_revision": 1, + "recorded_at": "2026-07-31T14:03:56.532Z" +} diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md new file mode 100644 index 0000000..a7293eb --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md @@ -0,0 +1,39 @@ +# Wave 1 最终聚焦审查报告 + +- wave:`wave-1` +- 范围:`a0732a3..bd6d284` +- verdict: pass + +## 结论 + +本次仅修正测试夹具,使其适配执行计划现在会预创建 review overlay 的既定行为。实现边界未被放宽。 + +## 核对结果 + +- `exploring -> specifying` 的新入口语义与合同一致:guard 不要求尚未生成的规划工件(`scripts/guard/guard.mjs:19-25`);对应状态测试以空 Full change 验证转换和随后的哈希一致性(`tests/lib/cmd-state.test.mjs:120-129`)。 +- 新 symlink 测试先删除由 `execution plan` 创建的物理 `reviews/` 目录,再将该目录替换为指向外部目录的符号链接(`tests/lib/cmd-execution.test.mjs:317-334`)。它因此能够真正到达 `getPhysicalReviewsDirectory` 的目录级符号链接检查(`scripts/lib/execution-plan.mjs:387-408`),并验证该检查拒绝外部证据,而不是因夹具已存在而提前失败。 + +## 分级发现 + +### Critical + +无。 + +### Important + +无。 + +### Minor + +无。 + +## 测试证据 + +- 通过:`node --test tests/lib/cmd-execution.test.mjs`。 +- 通过:`git diff --check a0732a3..bd6d284`。 + +**Ready to merge?** Yes。 + +```bash +ssf execution review changes/fix-full-flow-friction --wave wave-1 --base a0732a3 --head bd6d284 --report .superpowers/sdd/reviews/wave-1-final-review.md --verdict pass +``` diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-rereview.md b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-rereview.md new file mode 100644 index 0000000..a6a5cea --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-rereview.md @@ -0,0 +1,38 @@ +# Wave 1 聚焦复审报告 + +- wave:`wave-1` +- 范围:`08f67cf..5df08a5` +- verdict: pass + +## 复审结论 + +先前的 Important 问题已修复。`isSafePathSegment` 拒绝空值、`.`、`..`、路径分隔符和控制字符,且在生成 `worktreePath` 前执行(`scripts/ensure-branch.mjs:34-40, 73-76`)。因此 `../../outside` 不会再经 `join()` 规范化后逃逸仓库旁的目标命名空间。 + +正常场景仍保持:安全名称 `planned-change` 继续创建同级 worktree,并将 active change 工件复制到相同仓库相对路径(`scripts/ensure-branch.mjs:77-96`)。 + +## 分级发现 + +### Critical + +无。 + +### Important + +无。 + +### Minor + +无。 + +## 测试证据 + +- 通过:`node --test tests/lib/ensure-branch.test.mjs`(4/4)。 +- 覆盖正常同级 worktree 与 active change 复制:`tests/lib/ensure-branch.test.mjs:59-75`。 +- 覆盖此前的路径逃逸输入:`tests/lib/ensure-branch.test.mjs:77-86`。 +- 通过:`git diff --check 08f67cf..5df08a5`。 + +**Ready to merge?** Yes。 + +```bash +ssf execution review changes/fix-full-flow-friction --wave wave-1 --base 08f67cf --head 5df08a5 --report .superpowers/sdd/reviews/wave-1-rereview.md --verdict pass +``` diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1.md b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1.md new file mode 100644 index 0000000..024037d --- /dev/null +++ b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1.md @@ -0,0 +1,45 @@ +# Wave 1 审查报告 + +- wave:`wave-1` +- 范围:`9105098..08f67cf` +- 合同:[execution-contract.md](../../../execution-contract.md) +- verdict: fail + +## Strengths + +- 隔离工作树现在从 Git 仓库根目录计算相邻路径,并只复制 active change;实测已提交目录上有未提交工件时,目标工作树得到更新后的工件内容,未复制无关文件(`scripts/ensure-branch.mjs:50-83`)。 +- `writePlan` 与 `recordReview` 均会初始化 review overlay,避免第一次写回执需要人工建目录(`scripts/lib/execution-plan.mjs:40-56, 129-133`)。 +- Delta 预检复用发布实现,且仅对标准 `changes/` 布局启用,保留独立夹具的验证兼容性(`scripts/lib/cmd-validate.mjs:26-42, 87-103`)。 +- 状态转换在写入前刷新 artifact 与 contract hash,修复了转换后立即 `state check` 不一致的问题(`scripts/lib/cmd-state.mjs:173-179`)。 + +## Issues + +### Critical + +无。 + +### Important + +1. `scripts/ensure-branch.mjs:65-66` — `changeName` 被直接拼接进 worktree 路径,未拒绝路径分隔符或 `..`。例如 `join('/tmp', 'repo-../../outside')` 的结果为 `/tmp/outside`,因此 `node scripts/ensure-branch.mjs changes/x ../../outside` 可把 worktree 放到并非仓库相邻命名空间的位置,违背合同中“位于仓库旁”的保证,也可能与用户已有目录冲突。 + - 修复:将 name 限制为单个安全路径段(拒绝 `.`、`..`、`/`、`\\`、NUL),或对路径解析后校验 `dirname(worktreePath) === dirname(repoRoot)`;同时为 `../../outside` 增加拒绝型回归测试。 + +### Minor + +无。 + +## Test Evidence + +- 通过:`node --test tests/lib/ensure-branch.test.mjs tests/lib/execution-plan.test.mjs tests/lib/cmd-validate-paths.test.mjs tests/lib/cmd-state.test.mjs` +- 通过:`npm run build && npm test`(由本轮前的独立审查执行;本次快速复审未重复运行)。 +- 通过:`git diff --check 9105098..08f67cf`。 +- 手工复现:已提交 active change 加未提交覆盖内容后执行 isolate,目标 worktree 的同路径文件内容为未提交版本;验证了正常复制路径。 + +## Assessment + +**Ready to merge?** No。 + +唯一 Important 问题位于此次 P0 隔离路径修复的核心边界;需先限制 `changeName`,并以专项测试证明不能逃逸目标父目录,再记录通过回执。 + +```bash +ssf execution review changes/fix-full-flow-friction --wave wave-1 --base 9105098 --head 08f67cf --report .superpowers/sdd/reviews/wave-1.md --verdict fail +``` diff --git a/changes/fix-full-flow-friction/design.md b/changes/fix-full-flow-friction/design.md index a17a5af..89cd60f 100644 --- a/changes/fix-full-flow-friction/design.md +++ b/changes/fix-full-flow-friction/design.md @@ -19,7 +19,15 @@ - **选择**:标准 `changes/` 路径下,`validate` 将 Delta 应用到内存中的主规格。 - **理由**:复用发布逻辑,缺失 MODIFIED/REMOVED/RENAMED 目标会在实现前被报告。 +### 默认回归采用受控低并发 + +- **选择**:在 `npm test` 的 Node 原生测试入口显式设置 `--test-concurrency=2`,不改变测试文件集合。 +- **理由**:本地基准显示,三个会递归启动 CLI/Git 的重型文件在并发度 2 下 97/97 通过、约 146 秒完成;宿主默认并发会同时调度数十个文件,放大同步子进程竞争并让长时间没有完成输出看起来像卡死。 +- **替代方案**:全部串行可通过但约 155 秒,收益很小;重写 27 个集成测试或删减测试超出本次修复范围。 +- **后果**:完整回归的并发上限可预期,普通测试仍以两个文件并行;CI 与本地使用同一入口。 + ## 风险与验证 - 路径逃逸 → 仅接受 Git 根目录内的 change 相对路径 → 临时仓库测试。 - 兼容性 → 非标准独立验证夹具不要求主规格 → 既有验证测试。 +- Node 20 兼容性 → 只使用 Node 原生已支持的 `--test-concurrency` 参数 → 入口配置测试与 CI 矩阵。 diff --git a/changes/fix-full-flow-friction/execution-contract.md b/changes/fix-full-flow-friction/execution-contract.md index 5631955..c581f06 100644 --- a/changes/fix-full-flow-friction/execution-contract.md +++ b/changes/fix-full-flow-friction/execution-contract.md @@ -2,8 +2,8 @@ ## Intent Lock -- **范围内**:隔离路径与工件可用性、首次 review 回执、Delta 基线预检、状态转换哈希。 -- **范围外**:新阶段、审批模型和规格格式重写。 +- **范围内**:隔离路径与工件可用性、首次 review 回执、Delta 基线预检、状态转换哈希,以及默认回归的受控文件级并发。 +- **范围外**:新阶段、审批模型、规格格式重写、删减回归测试或重写每个集成测试。 ## Approved Behavior @@ -11,6 +11,7 @@ - 首次 review 无需手工创建证据目录;报告必须有可发现的规范位置。 - `ssf validate` 在实现前拒绝不能应用到主规格的 Delta。 - 确认后的 Full change 可先进入 `specifying`;每次转换后的状态检查一致。 +- `npm test` SHALL 保持执行现有 E2E 与库测试集合,并把 Node 文件级并发固定为 2。 ## Constraints @@ -19,11 +20,12 @@ ## Wave 1 -- **任务**:1.1、1.2、2.1、2.2、2.3。 +- **任务**:1.1、1.2、2.1、2.2、2.3、3.1、3.2。 - **策略**:`serial`;这些改动共享 CLI/状态边界。 -- **完成标准**:每项新增回归测试先失败后通过;`npm run build && npm test` 通过;独立审查通过。 +- **完成标准**:每项新增回归测试先失败后通过;重型集成批次在受控并发下通过;`npm run build && npm test` 通过;独立审查通过。 ## Rewind Rules - 若必须复制 change 目录以外的未提交文件,回到 design。 - 若 Delta 预检破坏独立夹具,回到 specifying。 +- 若受控并发遗漏既有测试文件或不被 Node 20 支持,回到 specifying。 diff --git a/changes/fix-full-flow-friction/proposal.md b/changes/fix-full-flow-friction/proposal.md index 9c2e469..c993c48 100644 --- a/changes/fix-full-flow-friction/proposal.md +++ b/changes/fix-full-flow-friction/proposal.md @@ -10,17 +10,22 @@ - 让审查回执在首次写入时初始化证据目录,并明确报告位置。 - 在 `ssf validate` 阶段预检 Delta 对主规格的可应用性。 - 消除 entering `specifying` 的工件顺序矛盾,并在状态迁移时同步哈希。 +- 将默认回归入口的文件级并发限制为可验证的低并发,避免同时派生大量 Node、CLI 与 Git 子进程。 ## Scope ### In Scope -- 隔离、审查回执、验证和状态转换的可靠性与测试。 +- 隔离、审查回执、验证、状态转换和回归测试执行的可靠性与测试。 ### Out of Scope -- 新增工作流阶段、修改 Full 的审批语义,或重写既有规格格式。 +- 新增工作流阶段、修改 Full 的审批语义、重写既有规格格式,或删减既有回归测试。 + +## Impact + +- `npm test` 保持执行现有 E2E 与 `tests/lib/*.test.mjs` 集合,但不会再按宿主默认并发数同时启动全部测试文件。 ## Verification -- 新增回归测试;运行 `npm run build && npm test`。 +- 新增回归测试;验证受控并发的重型集成批次;运行 `npm run build && npm test`。 diff --git a/changes/fix-full-flow-friction/specs/test-discipline/spec.md b/changes/fix-full-flow-friction/specs/test-discipline/spec.md new file mode 100644 index 0000000..cc0df18 --- /dev/null +++ b/changes/fix-full-flow-friction/specs/test-discipline/spec.md @@ -0,0 +1,10 @@ +## ADDED Requirements + +### Requirement: Controlled default test concurrency + +The default `npm test` command SHALL run the existing E2E and library test file set with Node file-level concurrency fixed at two, rather than inheriting an unbounded host-dependent default. + +#### Scenario: Run the default regression command + +- **WHEN** a maintainer or CI runs `npm test` +- **THEN** the command executes `tests/e2e.test.mjs` and `tests/lib/*.test.mjs` with `--test-concurrency=2` diff --git a/changes/fix-full-flow-friction/tasks.md b/changes/fix-full-flow-friction/tasks.md index 662d864..c043c84 100644 --- a/changes/fix-full-flow-friction/tasks.md +++ b/changes/fix-full-flow-friction/tasks.md @@ -6,6 +6,7 @@ |---|---|---| | 1 | 隔离与审查回执无需人工恢复 | 专项测试 | | 2 | Delta 与状态错误在前置阶段发现 | 专项测试 | +| 3 | 默认回归不会被宿主并发放大 | 入口测试与全量回归 | ## Tasks @@ -13,4 +14,6 @@ - [x] **1.2 初始化审查证据目录**:写回执前创建 overlay,并更新报告路径说明;证明:`node --test tests/lib/execution-plan.test.mjs`。 - [x] **2.1 前置 Delta 基线检查**:`ssf validate` 检测不可应用的修改;证明:`node --test tests/lib/cmd-validate-paths.test.mjs`。 - [x] **2.2 修复状态进入与哈希同步**:允许正确的 specifying 入口并持久化当前哈希;证明:`node --test tests/lib/cmd-state.test.mjs`。 -- [ ] **2.3 跑全量回归**:构建并运行全部测试;证明:`npm run build && npm test`。 +- [x] **2.3 修正冲突的入口测试**:将无产物拒绝断言移动到 `specifying → bridging`;证明:`node --test tests/lib/guard.test.mjs`。 +- [x] **3.1 限制默认测试文件并发**:在 `package.json` 保持完整测试集合且固定 Node 文件级并发为 2;证明:`node --test tests/lib/node20-test-entry.test.mjs` 与重型集成批次。 +- [x] **3.2 跑全量回归**:构建并运行全部测试;证明:`npm run build && npm test`(630/630 通过)。 diff --git a/package.json b/package.json index 9c58694..ab7eaf3 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "type": "module", "scripts": { "build": "tsc", - "test": "node --test tests/e2e.test.mjs tests/lib/*.test.mjs", + "test": "node --test --test-concurrency=2 tests/e2e.test.mjs tests/lib/*.test.mjs", "test:raw-mode": "node --test tests/lib/raw-mode-smoke.test.mjs", "validate": "node scripts/validate-artifacts", "version": "node scripts/spec-superflow.mjs version $npm_new_version && node scripts/check-version-consistency.mjs && git add -A", diff --git a/skills/build-executor/task-reviewer-prompt.md b/skills/build-executor/task-reviewer-prompt.md index 632d2fd..74840a8 100644 --- a/skills/build-executor/task-reviewer-prompt.md +++ b/skills/build-executor/task-reviewer-prompt.md @@ -156,7 +156,7 @@ Subagent (general-purpose): ## Output Format Write your full review to `[CHANGE_DIR]/.superpowers/sdd/reviews/[WAVE_ID].md`. - This distinct review report must be non-empty and persisted before the + This review report path must point to a non-empty, persisted review report before the controller records a receipt. After the verdict, provide the exact receipt command for the controller: diff --git a/skills/code-reviewer/SKILL.md b/skills/code-reviewer/SKILL.md index e19b1b7..12d317d 100644 --- a/skills/code-reviewer/SKILL.md +++ b/skills/code-reviewer/SKILL.md @@ -16,7 +16,7 @@ Two responsibilities: requesting review (dispatching a reviewer subagent) and re 1. Get SHAs: `BASE_SHA=$(git rev-parse HEAD~1)` and `HEAD_SHA=$(git rev-parse HEAD)` 2. Dispatch `general-purpose` subagent using template at `skills/code-reviewer/code-reviewer-prompt.md` 3. Fill placeholders: `[DESCRIPTION]` (what was built), `[PLAN_OR_REQUIREMENTS]` (contract/spec reference), `[BASE_SHA]`, `[HEAD_SHA]`, `[WAVE_ID]`, and a distinct `[REVIEW_REPORT_FILE]`. -4. Require the reviewer to write a non-empty persisted review report at `.superpowers/sdd/reviews/.md`, then record that exact in-overlay path in the wave receipt. The execution plan initializes this directory; paths outside it are rejected for audit safety. +4. Require the reviewer to write a non-empty persisted review report at `.superpowers/sdd/reviews/.md`, then record that exact in-overlay path in the wave receipt with `ssf execution review --wave --base --head --report .superpowers/sdd/reviews/.md --verdict `. The execution plan initializes this directory; paths outside it are rejected for audit safety. 5. Act on feedback: Critical/Important findings require a `fail` receipt, focused repair, re-review, and replacement `pass` receipt before a dependent wave or closing can proceed. Note Minor for later, push back with reasoning if reviewer is wrong. ### Minimality And Scope diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index 662703a..4a905dc 100644 --- a/tests/lib/guard.test.mjs +++ b/tests/lib/guard.test.mjs @@ -706,9 +706,10 @@ describe('guard: artifacts-exist check', () => { } } - it('fails when no artifacts exist', () => { - const result = runGuard('exploring', 'specifying'); - // artifacts-exist should fail — no proposal, specs, etc. + it('fails when a transition that requires artifacts has none', () => { + const result = runGuard('specifying', 'bridging'); + // Intake is artifact-free; bridging is the first full-workflow transition + // that must reject a change without proposal/spec artifacts. const artifactsCheck = result.output.checks.find(c => c.dimension === 'artifacts-exist'); assert.ok(artifactsCheck); assert.equal(artifactsCheck.pass, false); diff --git a/tests/lib/node20-test-entry.test.mjs b/tests/lib/node20-test-entry.test.mjs index e6cf710..1961ee1 100644 --- a/tests/lib/node20-test-entry.test.mjs +++ b/tests/lib/node20-test-entry.test.mjs @@ -7,9 +7,10 @@ const ROOT = process.cwd(); const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); describe('Node 20 test entry', () => { - it('runs only ESM JavaScript test files without strip-types', () => { + it('runs the complete ESM test set with controlled Node concurrency', () => { assert.match(pkg.scripts.test, /tests\/e2e\.test\.mjs/); assert.match(pkg.scripts.test, /tests\/lib\/\*\.test\.mjs/); + assert.match(pkg.scripts.test, /--test-concurrency=2/); assert.doesNotMatch(pkg.scripts.test, /experimental-strip-types/); assert.equal(existsSync(join(ROOT, 'tests', 'e2e.test.mjs')), true); assert.equal(existsSync(join(ROOT, 'tests', 'e2e.test.ts')), false); From 1ad5993ebd4aa278fe7488fd6d5a86f2acbd9929 Mon Sep 17 00:00:00 2001 From: MageByte Date: Fri, 31 Jul 2026 23:38:00 +0800 Subject: [PATCH 06/22] docs: publish full workflow reliability specs --- .../.spec-superflow.yaml | 22 ++++----- .../.superpowers/sdd/reviews/d2F2ZS0x.json | 14 +++--- .../sdd/reviews/wave-1-final-review.md | 45 ++++++++----------- specs/sdd-execution-control/spec.md | 9 ++++ specs/spec-publication/spec.md | 9 ++++ specs/test-discipline/spec.md | 9 ++++ specs/workflow-routing/spec.md | 11 ++++- 7 files changed, 74 insertions(+), 45 deletions(-) diff --git a/changes/fix-full-flow-friction/.spec-superflow.yaml b/changes/fix-full-flow-friction/.spec-superflow.yaml index 3ba337e..1833127 100644 --- a/changes/fix-full-flow-friction/.spec-superflow.yaml +++ b/changes/fix-full-flow-friction/.spec-superflow.yaml @@ -2,7 +2,7 @@ # Derived data. Always rebuildable from artifacts. Lost/corrupt → fall back to content-level detection. # === Core state === -state: executing +state: closing workflow: full revision: 3 @@ -15,15 +15,15 @@ execution_mode: sdd execution_plan_hash: sha256:e6c2ad664930f4769e76d2fa0319fb1cd5b556a65a46970f41ad7d0b53ff07cf execution_plan_revision: 3 batches_completed: 0 -test_result: null -spec_merged: false -spec_publication_receipt: null +test_result: pass +spec_merged: true +spec_publication_receipt: eyJ2ZXJzaW9uIjoxLCJzb3VyY2VfaGFzaCI6InNoYTI1Njo1ZDI0NmUwNTYzYjViM2FkYWE0ODk1ODRiY2UzYTE4Y2QxOGYzZjM2MDZlODMwMDA5OGEzOGRmZWRhNDA2ZGMwIiwiYmFzZWxpbmVfYmVmb3JlX2hhc2giOiJzaGEyNTY6ODIxM2Q2NTMwZGUxNTQ0ZDJkNTBiNTA4OGJjMzg4YTNiZWMwMjUzYmUzOTU1MDYzNTQ4MjBmZTkxMTlkNTNiNCIsImJhc2VsaW5lX2FmdGVyX2hhc2giOiJzaGEyNTY6OTBkOTljNDhhZmI4ZDM5MGFmN2I0NTk1NzcyNDQ0MGY1NWVjN2JkNWJmZDM3MTFlMzQyMzFkYTRhMjlkMWRiNCIsImNhcGFiaWxpdGllcyI6WyJzZGQtZXhlY3V0aW9uLWNvbnRyb2wiLCJzcGVjLXB1YmxpY2F0aW9uIiwidGVzdC1kaXNjaXBsaW5lIiwid29ya2Zsb3ctcm91dGluZyIsIndvcmt0cmVlLWlzb2xhdGlvbiJdfQ # === Metadata === change_name: fix-full-flow-friction -last_transition: 2026-07-31T15:05:54.455Z -last_transition_from: approved-for-build -last_transition_to: executing +last_transition: 2026-07-31T15:37:13.796Z +last_transition_from: executing +last_transition_to: closing # === Decision points === dp_0_decisions: scope=修复隔离 worktree、审查回执初始化、Delta 基线预检、状态哈希同步与默认测试并发;constraints=兼容现有 CLI、多平台与 Node 20;artifact_language=zh-CN;workflow_path=full;recommended=full;communication=自动推进并报告门禁 @@ -40,7 +40,7 @@ dp_4_result: sdd: plan revision 3; user-confirmed-revision; 完成任务记录 dp_4_timestamp: 2026-07-31T15:24:36.454Z dp_5_result: null dp_5_timestamp: null -dp_6_result: pass: npm test completed 630/630 with --test-concurrency=2 in 379 seconds -dp_6_timestamp: 2026-07-31T15:24:00Z -dp_7_result: null -dp_7_timestamp: null +dp_6_result: pass: post-sync npm test completed 630/630 with --test-concurrency=2 in 388 seconds +dp_6_timestamp: 2026-07-31T15:35:00Z +dp_7_result: confirmed: delta specs published, wave-1 review passed, and post-sync full regression passed +dp_7_timestamp: 2026-07-31T15:35:00Z diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json index 1850fc8..63d57de 100644 --- a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json +++ b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/d2F2ZS0x.json @@ -1,10 +1,10 @@ { "status": "pass", - "base": "08f67cf810c1ea2c50b77cbbe504a1ac4430d803", - "head": "5df08a5ea28a81f190d9242663c83f1f7af3ef0e", - "report": ".superpowers/sdd/reviews/wave-1-rereview.md", - "report_sha256": "sha256:f5ab4820492d9d9f1d59297153a494872f0eea02c5a36e688ba83950426a2e5c", - "plan_hash": "sha256:c60fb2e941c76d37c98dfcd724e7dba7b60417307685a922d176303b951e96ce", - "plan_revision": 1, - "recorded_at": "2026-07-31T14:03:56.532Z" + "base": "bd6d2848785538e1f4159bc7dbc724ba0ed0a615", + "head": "2e47381342371e9dbf1f56c1e175d88c7683d426", + "report": ".superpowers/sdd/reviews/wave-1-final-review.md", + "report_sha256": "sha256:4321285c563404c0da5cc90a44eb407d13eaed136cbc9b29b8ed9775c35cedb5", + "plan_hash": "sha256:e6c2ad664930f4769e76d2fa0319fb1cd5b556a65a46970f41ad7d0b53ff07cf", + "plan_revision": 3, + "recorded_at": "2026-07-31T15:28:59.246Z" } diff --git a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md index a7293eb..2d9fed3 100644 --- a/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md +++ b/changes/fix-full-flow-friction/.superpowers/sdd/reviews/wave-1-final-review.md @@ -1,39 +1,32 @@ -# Wave 1 最终聚焦审查报告 +# Wave 1 最终审查报告 - wave:`wave-1` -- 范围:`a0732a3..bd6d284` -- verdict: pass +- base:`bd6d284` +- head:`2e47381` +- verdict:`pass` -## 结论 - -本次仅修正测试夹具,使其适配执行计划现在会预创建 review overlay 的既定行为。实现边界未被放宽。 - -## 核对结果 - -- `exploring -> specifying` 的新入口语义与合同一致:guard 不要求尚未生成的规划工件(`scripts/guard/guard.mjs:19-25`);对应状态测试以空 Full change 验证转换和随后的哈希一致性(`tests/lib/cmd-state.test.mjs:120-129`)。 -- 新 symlink 测试先删除由 `execution plan` 创建的物理 `reviews/` 目录,再将该目录替换为指向外部目录的符号链接(`tests/lib/cmd-execution.test.mjs:317-334`)。它因此能够真正到达 `getPhysicalReviewsDirectory` 的目录级符号链接检查(`scripts/lib/execution-plan.mjs:387-408`),并验证该检查拒绝外部证据,而不是因夹具已存在而提前失败。 - -## 分级发现 - -### Critical - -无。 +## 范围 -### Important +复核默认 `npm test` 的受控并发、Node 20 入口测试、guard 夹具、审查报告位置/回执合同,以及 `fix-full-flow-friction` 的执行合同和 Delta 规格。 -无。 +## 结论 -### Minor +此前发现的 Important 已关闭:`changes/fix-full-flow-friction/specs/test-discipline/spec.md` 已由提交 `2e47381` 追踪,明确要求保留既有 E2E 与库测试集合并固定 `--test-concurrency=2`。`package.json` 与入口测试落实该要求;执行计划为当前 revision 3,旧 revision 的回执不会被当作当前回执。未发现 Critical、Important 或 Minor 问题。 -无。 +## 验证 -## 测试证据 +- `node --test tests/lib/node20-test-entry.test.mjs`:1/1 通过。 +- `node --test tests/lib/execution-plan.test.mjs --test-name-pattern='review|receipt|overlay'`:27/27 通过。 +- `node --test tests/lib/guard.test.mjs`:37/37 通过。 +- `node scripts/spec-superflow.mjs validate changes/fix-full-flow-friction`:通过。 +- `node scripts/spec-superflow.mjs execution show changes/fix-full-flow-friction --json`:当前 revision 3 计划有效,wave-1 等待本次回执。 -- 通过:`node --test tests/lib/cmd-execution.test.mjs`。 -- 通过:`git diff --check a0732a3..bd6d284`。 +## 分级发现 -**Ready to merge?** Yes。 +- Critical:无。 +- Important:无。 +- Minor:无。 ```bash -ssf execution review changes/fix-full-flow-friction --wave wave-1 --base a0732a3 --head bd6d284 --report .superpowers/sdd/reviews/wave-1-final-review.md --verdict pass +ssf execution review changes/fix-full-flow-friction --wave wave-1 --base bd6d284 --head 2e47381 --report .superpowers/sdd/reviews/wave-1-final-review.md --verdict pass ``` diff --git a/specs/sdd-execution-control/spec.md b/specs/sdd-execution-control/spec.md index 8dc4bd0..7402bdc 100644 --- a/specs/sdd-execution-control/spec.md +++ b/specs/sdd-execution-control/spec.md @@ -59,3 +59,12 @@ - **WHEN** execution plan 的每个 wave 都有当前 pass receipt - **THEN** 系统删除该计划作用域下可再生的工作文件 - **AND** closing guard 仍可读取通过 receipt + +### Requirement: First review evidence initialization + +The system SHALL create the physical review evidence overlay before validating or recording a first wave review report. + +#### Scenario: First review receipt + +- **WHEN** a planned wave records its first review +- **THEN** a report stored in the review overlay can be recorded without a manual directory creation step diff --git a/specs/spec-publication/spec.md b/specs/spec-publication/spec.md index 9ded58d..fa9b07b 100644 --- a/specs/spec-publication/spec.md +++ b/specs/spec-publication/spec.md @@ -53,3 +53,12 @@ - **WHEN** delta 应用后产生缺少 Purpose 或 Requirements 的候选主规格 - **THEN** 同步失败并报告能力路径和校验原因 - **AND** 任何目标主规格均不发生变化 + +### Requirement: Delta baseline preflight + +The system SHALL report an invalid MODIFIED, REMOVED, or RENAMED delta during standard change validation when its canonical baseline cannot accept that operation. + +#### Scenario: Missing modified requirement + +- **WHEN** a change modifies a requirement absent from its canonical baseline +- **THEN** `ssf validate` fails before implementation or release synchronization diff --git a/specs/test-discipline/spec.md b/specs/test-discipline/spec.md index 36fb332..000009e 100644 --- a/specs/test-discipline/spec.md +++ b/specs/test-discipline/spec.md @@ -30,3 +30,12 @@ build-executor MUST 明确说明纯人工文案或说明性文档改动不要求 - **WHEN** 一个任务只修改人工可读文档且不改变可执行行为 - **THEN** 执行计划要求相应的文档验证 - **AND** 不将缺少单元测试视为缺陷 + +### Requirement: Controlled default test concurrency + +The default `npm test` command SHALL run the existing E2E and library test file set with Node file-level concurrency fixed at two, rather than inheriting an unbounded host-dependent default. + +#### Scenario: Run the default regression command + +- **WHEN** a maintainer or CI runs `npm test` +- **THEN** the command executes `tests/e2e.test.mjs` and `tests/lib/*.test.mjs` with `--test-concurrency=2` diff --git a/specs/workflow-routing/spec.md b/specs/workflow-routing/spec.md index 5e85062..750bc19 100644 --- a/specs/workflow-routing/spec.md +++ b/specs/workflow-routing/spec.md @@ -1,6 +1,6 @@ # 工作流路由能力规格 -## MODIFIED Requirements +## Requirements ### Requirement: closing 是成功完成后的终态 @@ -53,3 +53,12 @@ - **WHEN** CLI guard 或 state transition 收到任意以 `closing` 为起点的目标状态 - **THEN** 系统 MUST 拒绝该转换,包括历史遗留的 `closing → specifying`,并且不得修改状态文件 + +### Requirement: Specifying entry and state freshness + +The system SHALL allow a confirmed Full change to enter `specifying` before planning artifacts exist, and SHALL persist current artifact and contract hashes after every successful state transition. + +#### Scenario: Start specification after intake + +- **WHEN** DP-0 is confirmed for a Full change with no planning artifacts +- **THEN** the transition to `specifying` succeeds and a subsequent state check is consistent From 58558518e04756f13e25075ee31b9be9610fb8ca Mon Sep 17 00:00:00 2001 From: MageByte Date: Sat, 1 Aug 2026 19:35:50 +0800 Subject: [PATCH 07/22] fix: align task briefs with current task template --- scripts/task-brief | 5 ++- tests/lib/task-brief.test.mjs | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/lib/task-brief.test.mjs diff --git a/scripts/task-brief b/scripts/task-brief index a0d90c5..3296f01 100755 --- a/scripts/task-brief +++ b/scripts/task-brief @@ -38,11 +38,14 @@ awk -v n="$n" ' !infence && /^#+[ \t]+Task[ \t]+[0-9]+/ { intask = ($0 ~ ("^#+[ \t]+Task[ \t]+" n "([^0-9]|$)")) } + !infence && /^- \[[ xX]\][ \t]+(\*\*)?[0-9]+\.[0-9]+/ { + intask = ($0 ~ ("^- \\[[ xX]\\][ \t]+(\\*\\*)?" n "([^0-9]|$)")) + } intask { print } ' "$plan" > "$out" if [ ! -s "$out" ]; then - echo "task ${n} not found in ${plan} (no heading matching 'Task ${n}')" >&2 + echo "task ${n} not found in ${plan} (no matching Task heading or checkbox task line)" >&2 exit 3 fi diff --git a/tests/lib/task-brief.test.mjs b/tests/lib/task-brief.test.mjs new file mode 100644 index 0000000..6243017 --- /dev/null +++ b/tests/lib/task-brief.test.mjs @@ -0,0 +1,67 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const ROOT = process.cwd(); +const TASK_BRIEF = join(ROOT, 'scripts', 'task-brief'); +const tempDirs = []; + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe('task-brief', () => { + it('continues to extract a legacy Task heading', () => { + const directory = mkdtempSync(join(tmpdir(), 'ssf-task-brief-')); + tempDirs.push(directory); + const tasksPath = join(directory, 'tasks.md'); + const outputPath = join(directory, 'task-1.1-brief.md'); + writeFileSync(tasksPath, [ + '## Task 1.1: Legacy task', + '', + 'Keep this task detail.', + '', + '## Task 1.2: Another task', + '', + 'Do not include this detail.', + '', + ].join('\n')); + + execFileSync('bash', [TASK_BRIEF, tasksPath, '1.1', outputPath], { encoding: 'utf8' }); + + const brief = readFileSync(outputPath, 'utf8'); + assert.match(brief, /Legacy task/); + assert.doesNotMatch(brief, /Another task/); + }); + + it('extracts one checkbox task from the current tasks template', () => { + const directory = mkdtempSync(join(tmpdir(), 'ssf-task-brief-')); + tempDirs.push(directory); + const tasksPath = join(directory, 'tasks.md'); + const outputPath = join(directory, 'task-1.1-brief.md'); + writeFileSync(tasksPath, [ + '# 实现任务', + '', + '## 任务', + '', + '- [ ] **1.1 建立种子副本夹具**:修改 `tests/helpers/git-fixture.mjs`;证明:`node --test tests/lib/task-brief.test.mjs`。', + '- [ ] **1.2 迁移重型测试**:修改 `tests/lib/cmd-execution.test.mjs`;证明:`npm test`。', + '', + '## 实施备注', + '', + '- 保留隔离边界。', + '', + ].join('\n')); + + execFileSync('bash', [TASK_BRIEF, tasksPath, '1.1', outputPath], { encoding: 'utf8' }); + + const brief = readFileSync(outputPath, 'utf8'); + assert.match(brief, /建立种子副本夹具/); + assert.doesNotMatch(brief, /迁移重型测试/); + }); +}); From 96d1e0cb66965792a61a1d9e4da4fcc58391ddc3 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sat, 1 Aug 2026 19:48:39 +0800 Subject: [PATCH 08/22] test: add reusable Git seed fixture --- tests/helpers/git-seed-fixture.mjs | 68 +++++++++++++++ tests/helpers/git-seed-fixture.test.mjs | 106 ++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 tests/helpers/git-seed-fixture.mjs create mode 100644 tests/helpers/git-seed-fixture.test.mjs diff --git a/tests/helpers/git-seed-fixture.mjs b/tests/helpers/git-seed-fixture.mjs new file mode 100644 index 0000000..7656773 --- /dev/null +++ b/tests/helpers/git-seed-fixture.mjs @@ -0,0 +1,68 @@ +import { execFileSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +/** + * Create one seeded Git repository for a test suite and independent copies for + * its cases. Callers populate the initial worktree; this fixture supplies the + * local Git identity and stable two-commit history shared by every copy. + */ +export function createGitSeedFixture({ + setup = () => {}, + initialCommitMessage = 'initial test fixture', + secondCommit = { + path: 'git-range-marker.txt', + content: 'second commit\n', + message: 'second test fixture commit', + }, + config = {}, + prefix = 'ssf-git-seed-', + copyPrefix = 'ssf-git-copy-', +} = {}) { + const root = mkdtempSync(join(tmpdir(), prefix)); + const seed = join(root, 'seed'); + const copies = new Set(); + mkdirSync(seed); + + setup(seed); + runGit(seed, ['init', '--quiet']); + runGit(seed, ['config', 'user.name', config.name ?? 'Spec Superflow Test']); + runGit(seed, ['config', 'user.email', config.email ?? 'tests@example.invalid']); + for (const [key, value] of Object.entries(config.values ?? {})) { + runGit(seed, ['config', '--local', key, value]); + } + runGit(seed, ['add', '--all']); + runGit(seed, ['commit', '--quiet', '--message', initialCommitMessage]); + const base = runGit(seed, ['rev-parse', 'HEAD']); + + writeFileSync(join(seed, secondCommit.path), secondCommit.content); + runGit(seed, ['add', '--all']); + runGit(seed, ['commit', '--quiet', '--message', secondCommit.message]); + const head = runGit(seed, ['rev-parse', 'HEAD']); + + return { + seed, + base, + head, + createCopy() { + const copy = mkdtempSync(join(tmpdir(), copyPrefix)); + cpSync(seed, copy, { + recursive: true, + dereference: false, + preserveTimestamps: true, + verbatimSymlinks: true, + }); + copies.add(copy); + return copy; + }, + dispose() { + for (const copy of copies) rmSync(copy, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + }, + }; +} diff --git a/tests/helpers/git-seed-fixture.test.mjs b/tests/helpers/git-seed-fixture.test.mjs new file mode 100644 index 0000000..dcdc0d1 --- /dev/null +++ b/tests/helpers/git-seed-fixture.test.mjs @@ -0,0 +1,106 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + lstatSync, + readFileSync, + readlinkSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { createGitSeedFixture } from './git-seed-fixture.mjs'; + +function runGit(directory, args) { + return execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim(); +} + +function snapshot(directory) { + return { + head: runGit(directory, ['rev-parse', 'HEAD']), + commits: Number(runGit(directory, ['rev-list', '--count', 'HEAD'])), + config: runGit(directory, ['config', '--local', '--get', 'fixture.label']), + status: runGit(directory, ['status', '--short', '--ignored']), + linkTarget: readlinkSync(join(directory, 'tracked-link')), + }; +} + +describe('Git seed/copy fixture', () => { + let fixture; + let firstCopy; + let adjacentCopy; + + before(() => { + fixture = createGitSeedFixture({ + setup(directory) { + writeFileSync(join(directory, '.gitignore'), '*.ignored\n'); + writeFileSync(join(directory, 'tracked.txt'), 'seeded content\n'); + writeFileSync(join(directory, 'alternate.txt'), 'alternate content\n'); + symlinkSync('tracked.txt', join(directory, 'tracked-link')); + }, + initialCommitMessage: 'initial fixture', + secondCommit: { + path: 'second-commit.txt', + content: 'second commit\n', + message: 'second fixture commit', + }, + config: { name: 'Fixture Test', email: 'fixtures@example.invalid', values: { 'fixture.label': 'seed' } }, + }); + firstCopy = fixture.createCopy(); + adjacentCopy = fixture.createCopy(); + }); + + after(() => { + fixture?.dispose(); + }); + + it('gives every copy an independent worktree, .git metadata, and seeded two-commit history', () => { + const seed = snapshot(fixture.seed); + const first = snapshot(firstCopy); + const adjacent = snapshot(adjacentCopy); + + assert.equal(seed.commits, 2); + assert.deepEqual(first, seed); + assert.deepEqual(adjacent, seed); + assert.notEqual(firstCopy, adjacentCopy); + assert.notEqual(firstCopy, fixture.seed); + assert.notEqual(adjacentCopy, fixture.seed); + const seedGitDirectory = lstatSync(join(fixture.seed, '.git')); + const firstGitDirectory = lstatSync(join(firstCopy, '.git')); + const adjacentGitDirectory = lstatSync(join(adjacentCopy, '.git')); + assert.equal(seedGitDirectory.isSymbolicLink(), false); + assert.equal(firstGitDirectory.isSymbolicLink(), false); + assert.equal(adjacentGitDirectory.isSymbolicLink(), false); + assert.notEqual(firstGitDirectory.ino, adjacentGitDirectory.ino); + assert.notEqual(firstGitDirectory.ino, seedGitDirectory.ino); + }); + + it('keeps the seed and adjacent copy unchanged when one copy mutates Git and filesystem state', () => { + const expectedSeed = snapshot(fixture.seed); + const expectedAdjacent = snapshot(adjacentCopy); + + runGit(firstCopy, ['config', '--local', 'fixture.label', 'changed']); + writeFileSync(join(firstCopy, 'untracked.txt'), 'only first copy\n'); + writeFileSync(join(firstCopy, 'only-first.ignored'), 'only first copy\n'); + unlinkSync(join(firstCopy, 'tracked-link')); + symlinkSync('alternate.txt', join(firstCopy, 'tracked-link')); + writeFileSync(join(firstCopy, 'tracked.txt'), 'changed only in first copy\n'); + + assert.equal(runGit(firstCopy, ['config', '--local', '--get', 'fixture.label']), 'changed'); + assert.match(runGit(firstCopy, ['status', '--short', '--ignored']), /\?\? untracked\.txt/); + assert.match(runGit(firstCopy, ['status', '--short', '--ignored']), /!! only-first\.ignored/); + assert.equal(readlinkSync(join(firstCopy, 'tracked-link')), 'alternate.txt'); + + assert.deepEqual(snapshot(fixture.seed), expectedSeed); + assert.deepEqual(snapshot(adjacentCopy), expectedAdjacent); + assert.equal(existsSync(join(fixture.seed, 'untracked.txt')), false); + assert.equal(existsSync(join(adjacentCopy, 'untracked.txt')), false); + assert.equal(existsSync(join(fixture.seed, 'only-first.ignored')), false); + assert.equal(existsSync(join(adjacentCopy, 'only-first.ignored')), false); + assert.equal(readFileSync(join(fixture.seed, 'tracked.txt'), 'utf8'), 'seeded content\n'); + assert.equal(readFileSync(join(adjacentCopy, 'tracked.txt'), 'utf8'), 'seeded content\n'); + }); +}); From 9262967f63fe9bce8dbf09a171fd5e29e61bcf01 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sat, 1 Aug 2026 20:02:03 +0800 Subject: [PATCH 09/22] test: add internal command and guard boundaries --- .../workspace/task-1.2-implementer-report.md | 46 +++++++ scripts/guard/guard.mjs | 66 +++++---- scripts/lib/cmd-execution.mjs | 60 ++++---- scripts/spec-superflow.mjs | 58 +++++--- ...internal-command-guard-boundaries.test.mjs | 130 ++++++++++++++++++ 5 files changed, 290 insertions(+), 70 deletions(-) create mode 100644 changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md create mode 100644 tests/lib/internal-command-guard-boundaries.test.mjs diff --git a/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md b/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md new file mode 100644 index 0000000..ca21dbf --- /dev/null +++ b/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md @@ -0,0 +1,46 @@ +# Task 1.2 implementer report + +## Result + +Added internal, script-local in-process boundaries for CLI dispatch, execution +command output, and guard evaluation. They are not re-exported from `src/`, +package exports, or public documentation. The public CLI and guard wrappers +remain child-process smoke-tested for success, validation failure, streams, +exit status, and relative-path cwd behavior. + +## TDD evidence + +- **RED:** `node --test tests/lib/internal-command-guard-boundaries.test.mjs` + failed before implementation: `SyntaxError: The requested module + '../../scripts/spec-superflow.mjs' does not provide an export named + 'dispatchCli'` (1 test file failed; 0 passed). +- **GREEN:** `node --test tests/lib/internal-command-guard-boundaries.test.mjs` + passed: 7 tests, 0 failed, 2 suites (6.31 s). It covers injected command, + execution, and guard streams plus both wrapper smoke matrices. +- **Regression:** `npm run build` succeeded; `node --test + tests/lib/cmd-execution.test.mjs tests/lib/guard.test.mjs + tests/lib/cmd-runtime.test.mjs` exited 0; `git diff --check` passed. +- **Falsifiability:** Removing stream forwarding, returning the wrong exit + status, or treating the wrapper cwd as the repository root makes the focused + tests fail. + +## Changed files + +- `scripts/spec-superflow.mjs` +- `scripts/lib/cmd-execution.mjs` +- `scripts/guard/guard.mjs` +- `tests/lib/internal-command-guard-boundaries.test.mjs` + +## Self-review and risk + +The script exports are intentionally internal test seams only; no library or +documented API surface changed. Legacy commands can still own their existing +process-level exits, while the repeated execution command and guard paths now +return statuses for in-process use. Wrapper smoke tests retain the public +boundary. The remaining risk is un-migrated heavy suites, explicitly reserved +for task 1.3. + +## Commit range + +- Base: `96d1e0cb66965792a61a1d9e4da4fcc58391ddc3` +- Head: `HEAD` (the Task 1.2 implementation commit) diff --git a/scripts/guard/guard.mjs b/scripts/guard/guard.mjs index a4deb6e..96d4e17 100644 --- a/scripts/guard/guard.mjs +++ b/scripts/guard/guard.mjs @@ -2,6 +2,9 @@ // scripts/guard/guard.mjs — dimension-based phase transition guard // Usage: node guard.mjs check [--json] import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { realpathSync } from 'node:fs'; import { checkArtifactsExist } from './checks/artifacts-exist.mjs'; import { checkTasksComplete } from './checks/tasks-complete.mjs'; import { checkTestsPassing } from './checks/tests-passing.mjs'; @@ -127,8 +130,12 @@ function directTestResultCheck(changeDir) { }; } -async function main() { +export async function runGuard(args, { + stdout = process.stdout, + stderr = process.stderr, +} = {}) { const { positionals, values } = parseArgs({ + args, options: { json: { type: 'boolean', default: false }, workflow: { type: 'string', default: 'full' }, @@ -138,8 +145,8 @@ async function main() { const subcommand = positionals[0]; if (subcommand !== 'check') { - console.error('Usage: guard.mjs check [--json] [--workflow ]'); - process.exit(2); + stderr.write('Usage: guard.mjs check [--json] [--workflow ]\n'); + return { exitCode: 2 }; } const changeDir = positionals[1]; @@ -150,13 +157,13 @@ async function main() { const VALID_WORKFLOWS = ['full', 'hotfix', 'tweak', 'quick']; if (!VALID_WORKFLOWS.includes(workflow)) { - console.error(`Invalid workflow: ${workflow}. Must be one of: ${VALID_WORKFLOWS.join(', ')}`); - process.exit(2); + stderr.write(`Invalid workflow: ${workflow}. Must be one of: ${VALID_WORKFLOWS.join(', ')}\n`); + return { exitCode: 2 }; } if (!changeDir || !fromState || !toState) { - console.error('Usage: guard.mjs check [--json]'); - process.exit(2); + stderr.write('Usage: guard.mjs check [--json]\n'); + return { exitCode: 2 }; } const key = `${fromState}:${toState}`; @@ -166,31 +173,31 @@ async function main() { if (!dimensions) { const valid = Object.keys(TRANSITION_CHECKS).join(', '); const msg = `Unknown transition: ${fromState} -> ${toState}. Valid transitions: ${valid}`; - if (useJson) console.log(JSON.stringify({ pass: false, checks: [], error: msg })); - else console.error(msg); - process.exit(1); + if (useJson) stdout.write(`${JSON.stringify({ pass: false, checks: [], error: msg })}\n`); + else stderr.write(`${msg}\n`); + return { exitCode: 1 }; } const workflowCheck = checkWorkflowAllowed(key, workflow); if (!workflowCheck.pass) { if (useJson) { - console.log(JSON.stringify({ pass: false, checks: workflowCheck.checks }, null, 2)); + stdout.write(`${JSON.stringify({ pass: false, checks: workflowCheck.checks }, null, 2)}\n`); } else { - console.error('Guard checks failed:'); + stderr.write('Guard checks failed:\n'); for (const c of workflowCheck.checks) { for (const f of c.failures) { - console.error(` [FAIL] ${c.dimension}: ${f}`); + stderr.write(` [FAIL] ${c.dimension}: ${f}\n`); } } } - process.exit(1); + return { exitCode: 1 }; } if (dimensions.length === 0) { const result = { pass: true, checks: [] }; - if (useJson) console.log(JSON.stringify(result)); - else console.log('All checks passed (no checks required for this transition).'); - process.exit(0); + if (useJson) stdout.write(`${JSON.stringify(result)}\n`); + else stdout.write('All checks passed (no checks required for this transition).\n'); + return { exitCode: 0 }; } const CHECK_RUNNERS = { @@ -224,26 +231,33 @@ async function main() { pass = checks.every(c => c.pass); if (useJson) { - console.log(JSON.stringify({ pass, checks }, null, 2)); + stdout.write(`${JSON.stringify({ pass, checks }, null, 2)}\n`); } else { if (pass) { - console.log('All checks passed.'); + stdout.write('All checks passed.\n'); } else { - console.error('Guard checks failed:'); + stderr.write('Guard checks failed:\n'); for (const c of checks) { if (!c.pass) { for (const f of c.failures) { - console.error(` [FAIL] ${c.dimension}: ${f}`); + stderr.write(` [FAIL] ${c.dimension}: ${f}\n`); } } } } } - process.exit(pass ? 0 : 1); + return { exitCode: pass ? 0 : 1 }; +} + +async function main() { + try { + const result = await runGuard(process.argv.slice(2)); + process.exitCode = result.exitCode; + } catch (err) { + console.error('Guard error:', err.message); + process.exitCode = 1; + } } -main().catch(err => { - console.error('Guard error:', err.message); - process.exit(1); -}); +if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/lib/cmd-execution.mjs b/scripts/lib/cmd-execution.mjs index f769b71..de201b2 100644 --- a/scripts/lib/cmd-execution.mjs +++ b/scripts/lib/cmd-execution.mjs @@ -9,7 +9,7 @@ import { readState, writeState } from './state-loader.mjs'; const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review']; -export async function run(args) { +export async function run(args, io = { stdout: process.stdout, stderr: process.stderr }) { const { positionals, values } = parseArgs({ args, options: { @@ -31,27 +31,31 @@ export async function run(args) { const changeDir = positionals[1]; if (values.help || subcommand === undefined) { - printHelp(); - return; + printHelp(io); + return { exitCode: 0 }; } - if (!SUBCOMMANDS.includes(subcommand)) usage(`Unknown execution subcommand: ${subcommand}`); - if (!changeDir) usage('Usage: ssf execution [options]'); + if (!SUBCOMMANDS.includes(subcommand)) return usage(`Unknown execution subcommand: ${subcommand}`, io); + if (!changeDir) return usage('Usage: ssf execution [options]', io); switch (subcommand) { case 'recommend': - return recommendAndPrint(changeDir, values); + recommendAndPrint(changeDir, values, io); + return { exitCode: 0 }; case 'plan': - return createAndPrintPlan(changeDir, values, false); + createAndPrintPlan(changeDir, values, false, io); + return { exitCode: 0 }; case 'show': - return showPlan(changeDir, values.json); + return showPlan(changeDir, values.json, io); case 'revise': - return createAndPrintPlan(changeDir, values, true); + createAndPrintPlan(changeDir, values, true, io); + return { exitCode: 0 }; case 'review': - return recordAndPrintReview(changeDir, values); + recordAndPrintReview(changeDir, values, io); + return { exitCode: 0 }; } } -function createAndPrintPlan(changeDir, values, revise) { +function createAndPrintPlan(changeDir, values, revise, io) { requireMode(values.mode); requireOption(values.reason, '--reason'); requireSafeReason(values.reason); @@ -98,10 +102,10 @@ function createAndPrintPlan(changeDir, values, revise) { }); const saved = writePlan(changeDir, plan); writeExecutionSummary(changeDir, saved); - print(values.json, { ok: true, plan: saved }, `Execution plan revision ${saved.revision} recorded (${saved.mode}).`); + print(values.json, { ok: true, plan: saved }, `Execution plan revision ${saved.revision} recorded (${saved.mode}).`, io); } -function recommendAndPrint(changeDir, values) { +function recommendAndPrint(changeDir, values, io) { const waves = values.wave?.length ? parseWaves(values.wave) : []; const receipt = writeRecommendationReceipt(changeDir, createRecommendationReceipt(changeDir, waves)); const recommendation = receipt.recommendation; @@ -111,21 +115,21 @@ function recommendAndPrint(changeDir, values) { `Recommended: ${recommendation.recommendation.mode}`, ...recommendation.recommendation.reasons.map(reason => `- ${reason}`), ]; - print(values.json, { ok: true, recommendation, receipt }, lines.join('\n')); + print(values.json, { ok: true, recommendation, receipt }, lines.join('\n'), io); } -function showPlan(changeDir, json) { +function showPlan(changeDir, json, io) { const plan = readPlan(changeDir); if (!plan) throw new Error('No execution plan has been recorded'); const validation = validatePlan(changeDir, plan); const current = validation.valid; const waves = describeWaves(changeDir, plan); print(json, { ok: current, current, plan, valid: current, failures: validation.failures, waves }, - validation.valid ? `Execution plan revision ${plan.revision} is current.` : validation.failures.join('\n')); - if (!validation.valid) process.exitCode = 1; + validation.valid ? `Execution plan revision ${plan.revision} is current.` : validation.failures.join('\n'), io); + return { exitCode: validation.valid ? 0 : 1 }; } -function recordAndPrintReview(changeDir, values) { +function recordAndPrintReview(changeDir, values, io) { requireOption(values.wave?.[0], '--wave'); if (values.wave.length !== 1) throw new Error('Review requires exactly one --wave value'); requireOption(values.base, '--base'); @@ -138,7 +142,7 @@ function recordAndPrintReview(changeDir, values) { head: values.head, report: values.report, }); - print(values.json, { ok: true, wave: values.wave[0], receipt }, `Review for ${values.wave[0]} recorded: ${receipt.status}.`); + print(values.json, { ok: true, wave: values.wave[0], receipt }, `Review for ${values.wave[0]} recorded: ${receipt.status}.`, io); } function writeExecutionSummary(changeDir, plan) { @@ -186,21 +190,21 @@ function requireSafeReason(reason) { } } -function usage(message) { - console.error(message); - printHelp(); - process.exit(2); +function usage(message, io) { + io.stderr.write(`${message}\n`); + printHelp(io); + return { exitCode: 2 }; } -function print(json, value, message) { - console.log(json ? JSON.stringify(value) : message); +function print(json, value, message, io) { + io.stdout.write(`${json ? JSON.stringify(value) : message}\n`); } -function printHelp() { - console.log(`Usage: +function printHelp(io) { + io.stdout.write(`Usage: ssf execution recommend [--wave ::[:]] [--json] ssf execution plan --mode --confirm --reason --wave ::[:] [--acknowledge-recommendation] ssf execution show [--json] ssf execution revise --mode sdd --confirm --reason --wave ::[:] [--acknowledge-recommendation] - ssf execution review --wave --base --head --report --verdict pass|fail`); + ssf execution review --wave --base --head --report --verdict pass|fail\n`); } diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index 58ef2c0..d9e8f72 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -3,6 +3,9 @@ // Usage: ssf [options] import { parseArgs } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { realpathSync } from 'node:fs'; const COMMANDS = { list: () => import('./lib/cmd-list.mjs'), @@ -144,12 +147,17 @@ Examples: ssf uninstall-codebuddy --dry-run `; -async function main() { - const args = process.argv.slice(2); +export async function dispatchCli(args, { + commands = COMMANDS, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + const writeStdout = text => stdout.write(text); + const writeStderr = text => stderr.write(text); if (args.length === 0 || args.includes('--help') || args.includes('-h')) { - console.log(HELP); - process.exit(0); + writeStdout(`${HELP}\n`); + return { exitCode: 0 }; } if (args.includes('--version') || args.includes('-v')) { @@ -158,24 +166,42 @@ async function main() { new URL('../package.json', import.meta.url), 'utf-8' ) ); - console.log(pkg.version); - process.exit(0); + writeStdout(`${pkg.version}\n`); + return { exitCode: 0 }; } const command = args[0]; const commandArgs = args.slice(1); - if (!COMMANDS[command]) { - console.error(`Unknown command: ${command}`); - console.error(`Run "ssf --help" for available commands.`); - process.exit(2); + if (!commands[command]) { + writeStderr(`Unknown command: ${command}\n`); + writeStderr('Run "ssf --help" for available commands.\n'); + return { exitCode: 2 }; + } + + const previousExitCode = process.exitCode; + process.exitCode = undefined; + try { + const mod = await commands[command](); + const result = await mod.run(commandArgs, { stdout, stderr }); + const exitCode = result?.exitCode ?? process.exitCode ?? 0; + process.exitCode = previousExitCode; + return { exitCode }; + } catch (err) { + process.exitCode = previousExitCode; + writeStderr(`Error: ${err.message}\n`); + return { exitCode: 1 }; } +} - const mod = await COMMANDS[command](); - await mod.run(commandArgs); +async function main() { + const result = await dispatchCli(process.argv.slice(2)); + process.exitCode = result.exitCode; } -main().catch(err => { - console.error(`Error: ${err.message}`); - process.exit(1); -}); +if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) { + main().catch(err => { + console.error(`Error: ${err.message}`); + process.exitCode = 1; + }); +} diff --git a/tests/lib/internal-command-guard-boundaries.test.mjs b/tests/lib/internal-command-guard-boundaries.test.mjs new file mode 100644 index 0000000..b1a3acc --- /dev/null +++ b/tests/lib/internal-command-guard-boundaries.test.mjs @@ -0,0 +1,130 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { dispatchCli } from '../../scripts/spec-superflow.mjs'; +import { run as runExecution } from '../../scripts/lib/cmd-execution.mjs'; +import { runGuard } from '../../scripts/guard/guard.mjs'; + +const ROOT = process.cwd(); +const CLI = join(ROOT, 'scripts', 'spec-superflow.mjs'); +const GUARD = join(ROOT, 'scripts', 'guard', 'guard.mjs'); +const tempDirs = []; + +afterEach(() => { + while (tempDirs.length > 0) rmSync(tempDirs.pop(), { recursive: true, force: true }); +}); + +function makeTempDir(prefix) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function captureIo() { + const output = { stdout: '', stderr: '' }; + return { + output, + io: { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }, + }; +} + +function writeValidChange(dir) { + mkdirSync(join(dir, 'specs', 'boundary'), { recursive: true }); + writeFileSync(join(dir, 'proposal.md'), '## Why\nThis boundary fixture has enough detail for validation.\n## What Changes\n- Add a boundary.\n'); + writeFileSync(join(dir, 'design.md'), '# Design\n'); + writeFileSync(join(dir, 'tasks.md'), '# Tasks\n'); + writeFileSync(join(dir, 'specs', 'boundary', 'spec.md'), '## ADDED Requirements\n### Requirement: Boundary\nThe system SHALL retain wrapper behavior.\n#### Scenario: Validate\n- **WHEN** invoked\n- **THEN** it passes.\n'); +} + +describe('internal command and guard boundaries', () => { + it('dispatches a command in-process with injected streams', async () => { + const { io, output } = captureIo(); + let receivedArgs; + let receivedIo; + const result = await dispatchCli(['fixture', 'value'], { + ...io, + commands: { + fixture: async () => ({ run: async (args, commandIo) => { + receivedArgs = args; + receivedIo = commandIo; + commandIo.stdout.write('command output\n'); + } }), + }, + }); + + assert.equal(result.exitCode, 0); + assert.deepEqual(receivedArgs, ['value']); + assert.equal(receivedIo.stdout, io.stdout); + assert.deepEqual(output, { stdout: 'command output\n', stderr: '' }); + }); + + it('evaluates a guard transition in-process with injected streams', async () => { + const { io, output } = captureIo(); + const result = await runGuard(['check', '.', 'exploring', 'specifying', '--json'], io); + + assert.equal(result.exitCode, 0); + assert.deepEqual(JSON.parse(output.stdout), { pass: true, checks: [] }); + assert.equal(output.stderr, ''); + }); + + it('reports execution-command validation failures in-process without exiting', async () => { + const { io, output } = captureIo(); + const result = await runExecution(['unknown'], io); + + assert.equal(result.exitCode, 2); + assert.match(output.stderr, /unknown execution subcommand/i); + assert.match(output.stdout, /ssf execution recommend/i); + }); +}); + +describe('public command wrappers', () => { + it('keeps the CLI success path, stdout, exit code, and cwd semantics', () => { + const cwd = makeTempDir('ssf-cli-wrapper-'); + writeFileSync(join(cwd, 'spec-superflow.config.json'), '{"execution":{"inlineThreshold":17}}\n'); + const result = spawnSync(process.execPath, [CLI, 'runtime', 'config', '--get', 'execution.inlineThreshold'], { + cwd, + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), '17'); + assert.equal(result.stderr, ''); + }); + + it('keeps the CLI validation failure, stderr, and exit code', () => { + const result = spawnSync(process.execPath, [CLI, 'runtime', 'asset', 'read', '../package.json'], { encoding: 'utf8' }); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /allowlist/i); + }); + + it('keeps the guard success path, stdout, exit code, and cwd semantics', () => { + const cwd = makeTempDir('ssf-guard-wrapper-'); + writeValidChange(cwd); + const result = spawnSync(process.execPath, [GUARD, 'check', '.', 'specifying', 'bridging', '--json'], { + cwd, + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).pass, true); + assert.equal(result.stderr, ''); + }); + + it('keeps the guard validation failure, stderr, and exit code', () => { + const result = spawnSync(process.execPath, [GUARD, 'check', '.', 'exploring', 'specifying', '--workflow', 'invalid'], { + encoding: 'utf8', + }); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /invalid workflow/i); + }); +}); From 1eadb1a3da9d41044fcb2dec175f2f65c0ab3b0c Mon Sep 17 00:00:00 2001 From: MageByte Date: Sat, 1 Aug 2026 23:25:56 +0800 Subject: [PATCH 10/22] test: reuse Git fixtures in heavy suites --- tests/lib/cmd-execution.test.mjs | 47 ++++++++++++++++------------ tests/lib/execution-plan.test.mjs | 51 ++++++++++++++++++------------- tests/lib/guard.test.mjs | 39 ++++++++++++++++------- 3 files changed, 85 insertions(+), 52 deletions(-) diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 61fe421..437edc9 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -1,14 +1,16 @@ -import { afterEach, beforeEach, describe, it } from 'node:test'; +import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; +import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); let changeDir; let gitRefs; +let fixture; function runSsf(args, cwd = process.cwd(), { confirmPlan = true, acknowledgePlan = true, prepareRecommendation = true } = {}) { const isPlan = args[0] === 'execution' && ['plan', 'revise'].includes(args[1]); @@ -93,22 +95,6 @@ function currentReceiptPath(waveId) { return join(getPlanScopedPaths(changeDir, plan).reviews, `${Buffer.from(waveId, 'utf8').toString('base64url')}.json`); } -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 }; -} - function createRepairCommit(label) { const marker = join(changeDir, `repair-${label}.txt`); writeFileSync(marker, `${label}\n`); @@ -117,16 +103,37 @@ function createRepairCommit(label) { return runGit(changeDir, ['rev-parse', 'HEAD']); } +before(() => { + fixture = createGitSeedFixture({ + setup: writeChangeDirectory, + initialCommitMessage: 'initial execution change', + secondCommit: { + path: 'git-range-marker.txt', + content: 'second commit\n', + message: 'second execution change', + }, + prefix: 'ssf-execution-cmd-seed-', + copyPrefix: 'ssf-execution-cmd-', + }); +}); + beforeEach(() => { - changeDir = mkdtempSync(join(tmpdir(), 'ssf-execution-cmd-')); - writeChangeDirectory(changeDir); - gitRefs = initializeGitRepository(changeDir); + changeDir = fixture.createCopy(); + gitRefs = { + base: fixture.base, + head: fixture.head, + divergent: runGit(changeDir, ['commit-tree', `${fixture.head}^{tree}`, '-m', 'independent execution change']), + }; }); afterEach(() => { rmSync(changeDir, { recursive: true, force: true }); }); +after(() => { + fixture.dispose(); +}); + describe('ssf execution', () => { it('records DP-4 and state summary after a user-confirmed recommended SDD plan', () => { const result = runSsf(['execution', 'plan', changeDir, '--mode', 'sdd', diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 6a9ea11..d3bc829 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -1,31 +1,53 @@ -import { afterEach, beforeEach, describe, it } from 'node:test'; +import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; import { createPlan as createRawPlan, describeWaves, readPlan, recordReview, validatePlan, writePlan, } from '../../scripts/lib/execution-plan.mjs'; import { createRecommendationReceipt, recommendExecutionModes } from '../../scripts/lib/execution-recommendation.mjs'; import { readState } from '../../scripts/lib/state-loader.mjs'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; +import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; let changeDir; let gitRefs; +let fixture; + +function writeExecutionPlanChange(directory) { + writeFileSync(join(directory, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 First task\n- [ ] 1.2 Second task\n'); + writeFileSync(join(directory, 'execution-contract.md'), '# Execution Contract\n\nCurrent contract.\n'); + writeFileSync(join(directory, '.spec-superflow.yaml'), 'state: approved-for-build\nworkflow: full\nrevision: 2\n'); +} + +before(() => { + fixture = createGitSeedFixture({ + setup: writeExecutionPlanChange, + initialCommitMessage: 'initial execution plan change', + secondCommit: { + path: 'git-range-marker.txt', + content: 'second commit\n', + message: 'second execution plan change', + }, + prefix: 'execution-plan-seed-', + copyPrefix: 'execution-plan-', + }); +}); 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); + changeDir = fixture.createCopy(); + gitRefs = { base: fixture.base, head: fixture.head }; }); afterEach(() => { rmSync(changeDir, { recursive: true, force: true }); }); +after(() => { + fixture.dispose(); +}); + function writeReviewReport(name, content = 'Review completed without blocking findings.\n') { const reportsDir = join(changeDir, '.superpowers', 'sdd', 'reviews'); mkdirSync(reportsDir, { recursive: true }); @@ -38,19 +60,6 @@ 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']) }; -} - function createRepairCommit(label) { const marker = join(changeDir, `repair-${label}.txt`); writeFileSync(marker, `${label}\n`); diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index 4a905dc..abf36fa 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'; import { acceptWorkflowRecommendation, saveWorkflowRecommendation } from '../../scripts/lib/workflow-recommendation.mjs'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; +import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; let tempDir; let gitRefs; @@ -364,13 +365,25 @@ describe('guard: hotfix minimal contract', () => { describe('guard: execution control records', () => { let dir; + let fixture; before(() => { - dir = mkdtempSync(join(tmpdir(), 'ssf-guard-control-records-')); + fixture = createGitSeedFixture({ + setup: writeFreshFullState, + initialCommitMessage: 'initial guard control records change', + secondCommit: { + path: 'git-range-marker.txt', + content: 'second commit\n', + message: 'second guard control records change', + }, + prefix: 'ssf-guard-control-records-seed-', + copyPrefix: 'ssf-guard-control-records-', + }); }); after(() => { if (dir) rmSync(dir, { recursive: true, force: true }); + fixture?.dispose(); }); function run(fromState, toState, workflow = 'full') { @@ -386,17 +399,21 @@ describe('guard: execution control records', () => { } } + function writeFreshFullState(directory) { + mkdirSync(join(directory, 'specs', 'execution'), { recursive: true }); + writeFileSync(join(directory, 'proposal.md'), '## Why\nThis proposal has enough context to verify guard control records in a full workflow.\n## What Changes\n- Enforce recorded execution control data.\n'); + writeFileSync(join(directory, 'design.md'), '# Design\n\n## Context\nGuard control records.\n'); + writeFileSync(join(directory, 'tasks.md'), '# Tasks\n\n- [x] 1.1 First task\n- [x] 1.2 Second task\n'); + writeFileSync(join(directory, 'specs', 'execution', 'spec.md'), '## Requirements\n\n### Requirement: Execution control records\nThe system SHALL require current execution control records.\n\n#### Scenario: Guard transition\n- **WHEN** execution starts\n- **THEN** the guard verifies control records.\n'); + writeFileSync(join(directory, 'execution-contract.md'), '# Execution Contract\n\n## Intent Lock\n\nGuard control records.\n'); + writeFileSync(join(directory, '.spec-superflow.yaml'), 'state: approved-for-build\nworkflow: full\n'); + runNodeScript(CLI_PATH, ['state', 'init', directory]); + } + function prepareFreshFullState() { - rmSync(dir, { recursive: true, force: true }); - mkdirSync(join(dir, 'specs', 'execution'), { recursive: true }); - writeFileSync(join(dir, 'proposal.md'), '## Why\nThis proposal has enough context to verify guard control records in a full workflow.\n## What Changes\n- Enforce recorded execution control data.\n'); - writeFileSync(join(dir, 'design.md'), '# Design\n\n## Context\nGuard control records.\n'); - writeFileSync(join(dir, 'tasks.md'), '# Tasks\n\n- [x] 1.1 First task\n- [x] 1.2 Second task\n'); - writeFileSync(join(dir, 'specs', 'execution', 'spec.md'), '## Requirements\n\n### Requirement: Execution control records\nThe system SHALL require current execution control records.\n\n#### Scenario: Guard transition\n- **WHEN** execution starts\n- **THEN** the guard verifies control records.\n'); - 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); + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = fixture.createCopy(); + gitRefs = { base: fixture.base, head: fixture.head }; } function createCurrentPlan() { From 40a069f86f7d5dc9673144139968499bb5f6b6d9 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sat, 1 Aug 2026 23:37:44 +0800 Subject: [PATCH 11/22] test: exercise heavy suites in process --- scripts/lib/cmd-execution.mjs | 2 +- tests/lib/cmd-execution.test.mjs | 22 +++++++-- tests/lib/guard.test.mjs | 84 ++++++++++++++++---------------- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/scripts/lib/cmd-execution.mjs b/scripts/lib/cmd-execution.mjs index de201b2..c834584 100644 --- a/scripts/lib/cmd-execution.mjs +++ b/scripts/lib/cmd-execution.mjs @@ -9,7 +9,7 @@ import { readState, writeState } from './state-loader.mjs'; const SUBCOMMANDS = ['recommend', 'plan', 'show', 'revise', 'review']; -export async function run(args, io = { stdout: process.stdout, stderr: process.stderr }) { +export function run(args, io = { stdout: process.stdout, stderr: process.stderr }) { const { positionals, values } = parseArgs({ args, options: { diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 437edc9..6667b2f 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; +import { run as runExecution } from '../../scripts/lib/cmd-execution.mjs'; import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); @@ -23,15 +24,12 @@ function runSsf(args, cwd = process.cwd(), { confirmPlan = true, acknowledgePlan const changePath = effectiveArgs[2]; const waves = effectiveArgs.flatMap((value, index) => value === '--wave' ? ['--wave', effectiveArgs[index + 1]] : []).filter(Boolean); try { - execFileSync(process.execPath, [CLI, 'execution', 'recommend', changePath, ...waves], { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'ignore', 'pipe'], - }); + runExecutionInProcess(['recommend', changePath, ...waves]); } catch { // Let the requested command report malformed arguments through the usual test helper. } } + if (effectiveArgs[0] === 'execution') return runExecutionInProcess(effectiveArgs.slice(1)); try { const stdout = execFileSync(process.execPath, [CLI, ...effectiveArgs], { cwd, @@ -49,6 +47,20 @@ function runSsf(args, cwd = process.cwd(), { confirmPlan = true, acknowledgePlan } } +function runExecutionInProcess(args) { + const output = { stdout: '', stderr: '' }; + const io = { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }; + try { + const result = runExecution(args, io); + return { exitCode: result.exitCode, ...output, json: tryJson(output.stdout) }; + } catch (error) { + return { exitCode: 1, ...output, stderr: `${output.stderr}${error.message}\n`, json: tryJson(output.stdout) }; + } +} + function requiresAcknowledgement(args) { const mode = args[args.indexOf('--mode') + 1]; const waves = args.flatMap((value, index) => value === '--wave' ? [args[index + 1]] : []).filter(Boolean); diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index abf36fa..c56785b 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'; import { acceptWorkflowRecommendation, saveWorkflowRecommendation } from '../../scripts/lib/workflow-recommendation.mjs'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; +import { runGuard as runGuardInProcess } from '../../scripts/guard/guard.mjs'; import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; let tempDir; @@ -386,16 +387,17 @@ describe('guard: execution control records', () => { fixture?.dispose(); }); - function run(fromState, toState, workflow = 'full') { + async function run(fromState, toState, workflow = 'full') { + const output = { stdout: '', stderr: '' }; + const io = { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }; try { - const stdout = runNodeScript(GUARD_PATH, ['check', dir, fromState, toState, '--json', '--workflow', workflow]); - return { exitCode: 0, output: JSON.parse(stdout.trim()) }; - } catch (err) { - if (err.stdout) { - try { return { exitCode: err.status, output: JSON.parse(err.stdout.trim()) }; } - catch { return { exitCode: err.status, output: err.stderr || err.message }; } - } - return { exitCode: err.status || 1, output: err.stderr || err.message }; + const result = await runGuardInProcess(['check', dir, fromState, toState, '--json', '--workflow', workflow], io); + return { exitCode: result.exitCode, output: JSON.parse(output.stdout.trim()) }; + } catch (error) { + return { exitCode: 1, output: output.stderr || error.message }; } } @@ -444,11 +446,11 @@ describe('guard: execution control records', () => { return reportPath; } - it('rejects arbitrary DP-4 text when no current execution plan exists', () => { + it('rejects arbitrary DP-4 text when no current execution plan exists', async () => { prepareFreshFullState(); setStateField('dp_4_result', 'anything'); - const result = run('approved-for-build', 'executing'); + const result = await run('approved-for-build', 'executing'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -457,10 +459,10 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /plan.*missing|execution plan/i); }); - it('rejects a debugging return without a current execution plan in full workflow', () => { + it('rejects a debugging return without a current execution plan in full workflow', async () => { prepareFreshFullState(); - const result = run('debugging', 'executing'); + const result = await run('debugging', 'executing'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -469,11 +471,11 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /plan.*missing|execution plan/i); }); - it('rejects a debugging return without a current execution plan in hotfix workflow', () => { + it('rejects a debugging return without a current execution plan in hotfix workflow', async () => { prepareFreshFullState(); setStateField('workflow', 'hotfix'); - const result = run('debugging', 'executing', 'hotfix'); + const result = await run('debugging', 'executing', 'hotfix'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -482,22 +484,22 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /plan.*missing|execution plan/i); }); - it('keeps a debugging return in tweak workflow free of contract checks', () => { + it('keeps a debugging return in tweak workflow free of contract checks', async () => { prepareFreshFullState(); setStateField('workflow', 'tweak'); - const result = run('debugging', 'executing', 'tweak'); + const result = await run('debugging', 'executing', 'tweak'); assert.equal(result.exitCode, 0, JSON.stringify(result.output)); assert.deepEqual(result.output.checks, []); }); - it('rejects a debugging return when the execution plan is stale', () => { + it('rejects a debugging return when the execution plan is stale', async () => { prepareFreshFullState(); createCurrentPlan(); writeFileSync(join(dir, 'tasks.md'), '# Tasks\n\n- [x] 1.1 Changed task\n'); - const result = run('debugging', 'executing'); + const result = await run('debugging', 'executing'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -506,12 +508,12 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /stale: artifacts hash mismatch/i); }); - it('rejects a debugging return when the execution plan mode mismatches state', () => { + it('rejects a debugging return when the execution plan mode mismatches state', async () => { prepareFreshFullState(); createCurrentPlan(); setStateField('execution_mode', 'inline'); - const result = run('debugging', 'executing'); + const result = await run('debugging', 'executing'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -520,12 +522,12 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /mode does not match state/i); }); - it('rejects a debugging return when DP-4 forges the current plan revision', () => { + it('rejects a debugging return when DP-4 forges the current plan revision', async () => { prepareFreshFullState(); createCurrentPlan(); setStateField('dp_4_result', 'sdd: plan revision 10; forged revision reference'); - const result = run('debugging', 'executing'); + const result = await run('debugging', 'executing'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -534,12 +536,12 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /DP-4.*revision/i); }); - it('rejects DP-4 that names a different execution plan revision', () => { + it('rejects DP-4 that names a different execution plan revision', async () => { prepareFreshFullState(); createCurrentPlan(); setStateField('dp_4_result', 'sdd: plan revision 10; forged revision reference'); - const result = run('approved-for-build', 'executing'); + const result = await run('approved-for-build', 'executing'); assert.equal(result.exitCode, 1); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); @@ -547,28 +549,28 @@ describe('guard: execution control records', () => { assert.match(planCheck.failures.join('\n'), /DP-4.*revision/i); }); - it('keeps tweak transitions exempt from execution plan and review receipt checks', () => { + it('keeps tweak transitions exempt from execution plan and review receipt checks', async () => { prepareFreshFullState(); setStateField('workflow', 'tweak'); setStateField('dp_4_result', 'tweak execution selected'); - const executing = run('approved-for-build', 'executing', 'tweak'); + const executing = await run('approved-for-build', 'executing', 'tweak'); assert.equal(executing.exitCode, 0, JSON.stringify(executing.output)); assert.ok(!executing.output.checks.some(check => check.dimension === 'execution-plan-ready')); recordPassingClosingPrerequisites(); - const closing = run('executing', 'closing', 'tweak'); + const closing = await run('executing', 'closing', 'tweak'); assert.equal(closing.exitCode, 0, JSON.stringify(closing.output)); assert.ok(!closing.output.checks.some(check => check.dimension === 'execution-reviews-passed')); }); - it('rejects full and hotfix closing without a current execution plan', () => { + it('rejects full and hotfix closing without a current execution plan', async () => { for (const workflow of ['full', 'hotfix']) { prepareFreshFullState(); setStateField('workflow', workflow); recordPassingClosingPrerequisites(); - const result = run('executing', 'closing', workflow); + const result = await run('executing', 'closing', workflow); const planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); assert.equal(result.exitCode, 1, workflow); assert.ok(planCheck, workflow); @@ -577,12 +579,12 @@ describe('guard: execution control records', () => { } }); - it('rejects stale and state-mismatched execution plans before executing', () => { + it('rejects stale and state-mismatched execution plans before executing', async () => { prepareFreshFullState(); createCurrentPlan(); writeFileSync(join(dir, 'tasks.md'), '# Tasks\n\n- [x] 1.1 Changed task\n'); - let result = run('approved-for-build', 'executing'); + let result = await run('approved-for-build', 'executing'); let planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); assert.equal(result.exitCode, 1); assert.equal(planCheck.pass, false); @@ -591,19 +593,19 @@ describe('guard: execution control records', () => { prepareFreshFullState(); createCurrentPlan(); setStateField('execution_mode', 'inline'); - result = run('approved-for-build', 'executing'); + result = await run('approved-for-build', 'executing'); planCheck = result.output.checks.find(check => check.dimension === 'execution-plan-ready'); assert.equal(result.exitCode, 1); assert.equal(planCheck.pass, false); assert.match(planCheck.failures.join('\n'), /mode does not match state/i); }); - it('blocks closing until every planned wave has a passing review receipt', () => { + it('blocks closing until every planned wave has a passing review receipt', async () => { prepareFreshFullState(); createCurrentPlan(); recordPassingClosingPrerequisites(); - let result = run('executing', 'closing'); + let result = await run('executing', 'closing'); let reviewCheck = result.output.checks.find(check => check.dimension === 'execution-reviews-passed'); assert.equal(result.exitCode, 1); assert.equal(reviewCheck.pass, false); @@ -614,7 +616,7 @@ describe('guard: execution control records', () => { runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2.md'), '--verdict', 'fail']); - result = run('executing', 'closing'); + result = await run('executing', 'closing'); reviewCheck = result.output.checks.find(check => check.dimension === 'execution-reviews-passed'); assert.equal(result.exitCode, 1); assert.equal(reviewCheck.pass, false); @@ -623,13 +625,13 @@ describe('guard: execution control records', () => { runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2-repair.md'), '--verdict', 'pass']); - result = run('executing', 'closing'); + result = await run('executing', 'closing'); reviewCheck = result.output.checks.find(check => check.dimension === 'execution-reviews-passed'); assert.equal(result.exitCode, 0, JSON.stringify(result.output)); assert.equal(reviewCheck.pass, true); }); - it('allows closing with passing receipts when existing checks also pass', () => { + it('allows closing with passing receipts when existing checks also pass', async () => { prepareFreshFullState(); createCurrentPlan(); recordPassingClosingPrerequisites(); @@ -638,13 +640,13 @@ describe('guard: execution control records', () => { runNodeScript(CLI_PATH, ['execution', 'review', dir, '--wave', 'wave-2', '--base', gitRefs.base, '--head', gitRefs.head, '--report', writeReviewReport('wave-2.md'), '--verdict', 'pass']); - const result = run('executing', 'closing'); + const result = await run('executing', 'closing'); assert.equal(result.exitCode, 0, JSON.stringify(result.output)); assert.equal(result.output.checks.find(check => check.dimension === 'execution-reviews-passed').pass, true); }); - it('blocks closing when a persisted passing review report is no longer safe evidence', () => { + it('blocks closing when a persisted passing review report is no longer safe evidence', async () => { const replacements = [ { name: 'deleted', @@ -692,7 +694,7 @@ describe('guard: execution control records', () => { replacement.replace(waveOneReport); - const result = run('executing', 'closing'); + const result = await run('executing', 'closing'); const reviewCheck = result.output.checks.find(check => check.dimension === 'execution-reviews-passed'); assert.equal(result.exitCode, 1, replacement.name); assert.equal(reviewCheck.pass, false, replacement.name); From 1307a2cfaff225815dc12dee07391d33b578c8bf Mon Sep 17 00:00:00 2001 From: MageByte Date: Sat, 1 Aug 2026 23:41:57 +0800 Subject: [PATCH 12/22] docs: standardize workflow handoffs --- skills/bug-investigator/SKILL.md | 33 ++++++++++ skills/build-executor/SKILL.md | 33 ++++++++++ skills/code-reviewer/SKILL.md | 33 ++++++++++ skills/contract-builder/SKILL.md | 33 ++++++++++ skills/need-explorer/SKILL.md | 33 ++++++++++ skills/release-archivist/SKILL.md | 40 +++++++++++++ skills/spec-merger/SKILL.md | 40 +++++++++++++ skills/spec-writer/SKILL.md | 33 ++++++++++ skills/workflow-start/SKILL.md | 33 ++++++++++ tests/lib/workflow-handoff-docs.test.mjs | 76 ++++++++++++++++++++++++ 10 files changed, 387 insertions(+) create mode 100644 tests/lib/workflow-handoff-docs.test.mjs diff --git a/skills/bug-investigator/SKILL.md b/skills/bug-investigator/SKILL.md index 54d5883..e9d66b1 100644 --- a/skills/bug-investigator/SKILL.md +++ b/skills/bug-investigator/SKILL.md @@ -75,3 +75,36 @@ If truly environmental/timing-dependent/external: document what you investigated - **Parse failures**: Report raw output, ask for clarification — don't guess - **Missing files**: Escalate immediately — not a normal debugging scenario - **User interruption**: Re-read investigation report on resume, continue from last completed phase + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/build-executor/SKILL.md b/skills/build-executor/SKILL.md index 5fb8c75..2a3c0ef 100644 --- a/skills/build-executor/SKILL.md +++ b/skills/build-executor/SKILL.md @@ -234,3 +234,36 @@ For Full or legacy Hotfix, do not report completion until tests pass, contract o - **Parse failures**: Stop and report exact line/format issue. Route back to `contract-builder`. - **Missing artifacts**: Route back to appropriate upstream skill. Don't guess. - **User interruption**: Progress ledger enables recovery. Check ledger on resume. + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/code-reviewer/SKILL.md b/skills/code-reviewer/SKILL.md index 12d317d..7eb8655 100644 --- a/skills/code-reviewer/SKILL.md +++ b/skills/code-reviewer/SKILL.md @@ -82,3 +82,36 @@ Suggestion breaks existing functionality, reviewer lacks context, violates YAGNI - **Parse failures**: Report specific file, request regenerated review package - **Missing files**: Regenerate via `scripts/review-package`. Empty diff = nothing to review - **User interruption**: Re-read review report on resume, continue from next unreviewed batch + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/contract-builder/SKILL.md b/skills/contract-builder/SKILL.md index 4010ca0..a76946d 100644 --- a/skills/contract-builder/SKILL.md +++ b/skills/contract-builder/SKILL.md @@ -79,3 +79,36 @@ For a legacy Hotfix, after writing the minimal contract, run `ssf state init `. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/need-explorer/SKILL.md b/skills/need-explorer/SKILL.md index 9f58072..f32b720 100644 --- a/skills/need-explorer/SKILL.md +++ b/skills/need-explorer/SKILL.md @@ -71,3 +71,36 @@ Do not produce implementation code. This skill stabilizes intent, not builds. - **Parse failures**: Report the specific file, proceed with available information - **Missing files**: Note absent essential files as constraints, continue - **User interruption**: Exploration is stateless — on resume, re-ask the current question + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/release-archivist/SKILL.md b/skills/release-archivist/SKILL.md index 911a865..2c9ec83 100644 --- a/skills/release-archivist/SKILL.md +++ b/skills/release-archivist/SKILL.md @@ -123,3 +123,43 @@ Quick and direct Hotfix use a concise verification summary: changed files, focus - **Missing files**: If audit can't generate, run `ssf audit` manually - **User interruption**: Re-run verification from the beginning on resume - **DP gaps**: Flag missing DPs during DP-6; ask user whether to proceed or return + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Closing-in-progress report + +- Current stage: `executing`; release verification or archive work is still running. +- Completed / blocker: ``. +- Next stage: complete the remaining release or archive step, then transition to `closing` (not `none`). +- Entry condition: all release and archive work is complete and the transition succeeds. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/spec-merger/SKILL.md b/skills/spec-merger/SKILL.md index e430965..5602d26 100644 --- a/skills/spec-merger/SKILL.md +++ b/skills/spec-merger/SKILL.md @@ -71,3 +71,43 @@ Output sync report table: Capability, ADDED/MODIFIED/REMOVED/RENAMED counts, Sta - **Parse failures**: Report file and section. Do not attempt partial merges. - **No deltas**: If change has no delta sections, report nothing to merge and exit cleanly. - **User interruption**: On resume, check for merge conflict markers before proceeding. + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Closing-in-progress report + +- Current stage: `executing`; release verification or archive work is still running. +- Completed / blocker: ``. +- Next stage: complete the remaining release or archive step, then transition to `closing` (not `none`). +- Entry condition: all release and archive work is complete and the transition succeeds. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/spec-writer/SKILL.md b/skills/spec-writer/SKILL.md index 4bd368f..93bc25c 100644 --- a/skills/spec-writer/SKILL.md +++ b/skills/spec-writer/SKILL.md @@ -76,3 +76,36 @@ Do not start implementation after writing planning artifacts. Once stable, valid - **Missing templates**: Fall back to artifact structure defined in this skill - **User interruption**: Artifacts on disk are the recovery checkpoint; resume from first missing/incomplete one - **Validation failure**: Fix before handoff — do not hand off broken artifacts + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/skills/workflow-start/SKILL.md b/skills/workflow-start/SKILL.md index 24c4098..b84bafb 100644 --- a/skills/workflow-start/SKILL.md +++ b/skills/workflow-start/SKILL.md @@ -222,3 +222,36 @@ Decision point references when routing: - **Parse failures**: Fall back to content-level detection if `.spec-superflow.yaml` is malformed - **Missing files**: Route to the skill that generates the missing files - **User interruption**: Re-inspect change directory content (not cached state) on resume + +## Standard User-Facing Handoff + +End every user-facing phase report with this concise handoff. Only a successfully +persisted `closing` state and `abandoned` are terminal. + +### Normal report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Blocked report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Approval-wait report + +- Current stage: ``. +- Completed / blocker: ``. +- Next stage: ``. +- Entry condition: ``. + +### Successful terminal report + +- Current stage: successfully persisted `closing` or `abandoned`. +- Completed / blocker: ``. +- Next stage: `none`. +- Entry condition: no further transition exists. diff --git a/tests/lib/workflow-handoff-docs.test.mjs b/tests/lib/workflow-handoff-docs.test.mjs new file mode 100644 index 0000000..5b707f1 --- /dev/null +++ b/tests/lib/workflow-handoff-docs.test.mjs @@ -0,0 +1,76 @@ +// Documentation contract for Wave 2 Task 2.1: every workflow skill gives a +// concise, consistent user-facing handoff at normal, blocked, and approval gates. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = process.cwd(); +const SKILLS = [ + 'workflow-start', + 'need-explorer', + 'spec-writer', + 'contract-builder', + 'build-executor', + 'bug-investigator', + 'code-reviewer', + 'release-archivist', + 'spec-merger', +]; + +function readSkill(skill) { + return readFileSync(join(ROOT, 'skills', skill, 'SKILL.md'), 'utf8'); +} + +function handoffSection(content) { + const heading = '## Standard User-Facing Handoff'; + const start = content.indexOf(heading); + assert.notEqual(start, -1, 'missing standard handoff section'); + const next = content.indexOf('\n## ', start + heading.length); + return content.slice(start, next === -1 ? undefined : next); +} + +describe('workflow handoff documentation contract', () => { + it('gives all nine workflow skills the same four-field handoff for normal, blocked, and approval waits', () => { + for (const skill of SKILLS) { + const handoff = handoffSection(readSkill(skill)); + + for (const scenario of ['Normal report', 'Blocked report', 'Approval-wait report']) { + assert.match(handoff, new RegExp(`### ${scenario}`), + `${skill} must cover ${scenario.toLowerCase()}`); + } + for (const field of ['Current stage:', 'Completed / blocker:', 'Next stage:', 'Entry condition:']) { + assert.match(handoff, new RegExp(field), `${skill} must include ${field}`); + } + } + }); + + it('keeps only successfully persisted closing and abandoned terminal with next stage none', () => { + for (const skill of SKILLS) { + const handoff = handoffSection(readSkill(skill)); + assert.match(handoff, /only.*successfully persisted `closing`.*`abandoned`.*terminal/is, + `${skill} must limit terminal status to persisted closing and abandoned`); + assert.match(handoff, /Successful terminal report[\s\S]*?Current stage: successfully persisted `closing` or `abandoned`[\s\S]*?Next stage: `none`/, + `${skill} must report terminal next stage as none`); + } + }); + + it('treats release and archive work before closing as a continuing stage', () => { + const archivist = handoffSection(readSkill('release-archivist')); + const merger = handoffSection(readSkill('spec-merger')); + + for (const [skill, handoff] of [ + ['release-archivist', archivist], + ['spec-merger', merger], + ]) { + assert.match(handoff, /Closing-in-progress report/, + `${skill} must distinguish closing in progress`); + assert.match(handoff, /Current stage: `executing`.*release.*archive.*still.*running/is, + `${skill} must keep release work in executing`); + assert.match(handoff, /Next stage: .*not.*`none`/is, + `${skill} must retain a next stage before closing persists`); + assert.match(handoff, /Entry condition: .*release.*archive.*complete.*transition.*succeeds/is, + `${skill} must require successful release completion before terminal close`); + } + }); +}); From 433389b5a9a76c382eb88e54b02de77602d31eab Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 2 Aug 2026 19:08:54 +0800 Subject: [PATCH 13/22] test: run publication guard scenarios in process --- scripts/lib/cmd-sync.mjs | 42 +++++++------ tests/lib/guard-specs-merged.test.mjs | 91 ++++++++++++++++++--------- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/scripts/lib/cmd-sync.mjs b/scripts/lib/cmd-sync.mjs index 7d2ed1f..cc9f2f6 100644 --- a/scripts/lib/cmd-sync.mjs +++ b/scripts/lib/cmd-sync.mjs @@ -75,16 +75,19 @@ function candidateValidationIssues(candidateReport, baseline, capabilityDir, val return candidateReport.issues.filter(issue => !isMissingPurposeIssue(issue, purposeErrorMessage)); } -export async function run(args) { +export async function run(args, { + stdout = process.stdout, + stderr = process.stderr, +} = {}) { if (args.length < 1) { - console.error('Usage: ssf sync '); - process.exit(2); + stderr.write('Usage: ssf sync \n'); + return { exitCode: 2 }; } const requestedChangeDir = args[0]; if (!existsSync(requestedChangeDir)) { - console.error(`Error: "${requestedChangeDir}" not found`); - process.exit(2); + stderr.write(`Error: "${requestedChangeDir}" not found\n`); + return { exitCode: 2 }; } const context = resolvePublicationContext(requestedChangeDir); @@ -111,8 +114,8 @@ export async function run(args) { } const layout = validateSpecPathLayout(dirPath, { requireSpecs: false }); if (!layout.pass) { - for (const failure of layout.failures) console.error(failure); - process.exit(1); + for (const failure of layout.failures) stderr.write(`${failure}\n`); + return { exitCode: 1 }; } for (const specFile of layout.specFiles) { allDeltas.push({ changeName: dir, content: readFileSync(specFile, 'utf-8') }); @@ -123,20 +126,20 @@ export async function run(args) { if (allDeltas.length > 0) { const conflictReport = validator.detectSyncConflicts(allDeltas); if (conflictReport.hasConflicts) { - console.log('⚠️ Sync conflicts detected:\n'); + stdout.write('⚠️ Sync conflicts detected:\n\n'); for (const conflict of conflictReport.conflicts) { - console.log(` Requirement: "${conflict.requirement}"`); - console.log(` Modified by: ${conflict.changes.join(', ')}\n`); + stdout.write(` Requirement: "${conflict.requirement}"\n`); + stdout.write(` Modified by: ${conflict.changes.join(', ')}\n\n`); } - console.log('Resolve conflicts before syncing. Consider syncing changes one at a time.'); - process.exit(1); + stdout.write('Resolve conflicts before syncing. Consider syncing changes one at a time.\n'); + return { exitCode: 1 }; } } const layout = validateSpecPathLayout(changeDir, { requireSpecs: true }); if (!layout.pass) { - for (const failure of layout.failures) console.error(failure); - process.exit(1); + for (const failure of layout.failures) stderr.write(`${failure}\n`); + return { exitCode: 1 }; } const changeSpecsDir = join(changeDir, 'specs'); @@ -180,11 +183,11 @@ export async function run(args) { publishAtomically(publications.filter(publication => publication.changed)); for (const publication of publications) { if (publication.changed) { - console.log(` 📋 Published canonical baseline: specs/${publication.capabilityDir}/spec.md`); + stdout.write(` 📋 Published canonical baseline: specs/${publication.capabilityDir}/spec.md\n`); } else { - console.log(` 📋 Canonical baseline already synchronized: specs/${publication.capabilityDir}/spec.md`); + stdout.write(` 📋 Canonical baseline already synchronized: specs/${publication.capabilityDir}/spec.md\n`); } - for (const warning of publication.warnings) console.log(` ⚠️ ${warning}`); + for (const warning of publication.warnings) stdout.write(` ⚠️ ${warning}\n`); } // A receipt belongs to the active change, never to the published baseline. @@ -196,10 +199,11 @@ export async function run(args) { state.spec_merged = true; state.spec_publication_receipt = encodePublicationReceipt(receipt); writeState(changeDir, state); - console.log(' 🧾 Wrote publication receipt to .spec-superflow.yaml'); + stdout.write(' 🧾 Wrote publication receipt to .spec-superflow.yaml\n'); } - console.log(`\n✅ Published ${layout.specFiles.length} canonical spec(s) from ${path.basename(changeDir)} to specs/`); + stdout.write(`\n✅ Published ${layout.specFiles.length} canonical spec(s) from ${path.basename(changeDir)} to specs/\n`); + return { exitCode: 0 }; } function publishAtomically(publications) { diff --git a/tests/lib/guard-specs-merged.test.mjs b/tests/lib/guard-specs-merged.test.mjs index 0f1bb04..48ad4b9 100644 --- a/tests/lib/guard-specs-merged.test.mjs +++ b/tests/lib/guard-specs-merged.test.mjs @@ -5,13 +5,8 @@ import { execFileSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, appendFileSync, rmSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(__dirname, '..', '..'); -const GUARD = join(ROOT, 'scripts', 'guard', 'guard.mjs'); -const CLI = join(ROOT, 'scripts', 'spec-superflow.mjs'); +import { dispatchCli } from '../../scripts/spec-superflow.mjs'; +import { runGuard } from '../../scripts/guard/guard.mjs'; function makeChangeFixture(withDelta) { const repo = mkdtempSync(join(tmpdir(), 'ssf-specs-merged-')); @@ -48,7 +43,27 @@ function cleanup(fixture) { if (fixture && existsSync(fixture.repo)) rmSync(fixture.repo, { recursive: true, force: true }); } -function runClosingGuard(fixture, { extraState = '', synchronize = false, mutateSource = false, mutateBaseline = false } = {}) { +async function runCli(args) { + const output = { stdout: '', stderr: '' }; + const io = { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }; + const result = await dispatchCli(args, io); + return { ...result, output }; +} + +async function runGuardWithCapturedOutput(args) { + const output = { stdout: '', stderr: '' }; + const io = { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }; + const result = await runGuard(args, io); + return { ...result, output }; +} + +async function runClosingGuard(fixture, { extraState = '', synchronize = false, mutateSource = false, mutateBaseline = false } = {}) { const { repo, dir } = fixture; try { rmSync(join(dir, '.superpowers'), { recursive: true, force: true }); @@ -57,27 +72,32 @@ function runClosingGuard(fixture, { extraState = '', synchronize = false, mutate `state: executing\nworkflow: full\nchange_name: test\ndp_6_result: pass: ok\n${extraState}`, ); if (synchronize) { - execFileSync('node', [CLI, 'sync', dir], { cwd: repo, stdio: 'pipe', timeout: 5000 }); + const sync = await runCli(['sync', dir]); + if (sync.exitCode !== 0) throw new Error(`${sync.output.stdout}\n${sync.output.stderr}`); } if (mutateSource) appendFileSync(join(dir, 'specs', 'test', 'spec.md'), '\n\n'); if (mutateBaseline) appendFileSync(join(repo, 'specs', 'test', 'spec.md'), '\n\n'); - execFileSync('node', [CLI, 'execution', 'recommend', dir, - '--wave', 'close:serial:1.1'], { cwd: repo, stdio: 'pipe', timeout: 5000 }); - execFileSync('node', [CLI, 'execution', 'plan', dir, '--mode', 'sdd', + const recommendation = await runCli(['execution', 'recommend', dir, + '--wave', 'close:serial:1.1']); + if (recommendation.exitCode !== 0) throw new Error(`${recommendation.output.stdout}\n${recommendation.output.stderr}`); + const plan = await runCli(['execution', 'plan', dir, '--mode', 'sdd', '--confirm', '--acknowledge-recommendation', '--reason', 'closing guard regression fixture', - '--wave', 'close:serial:1.1'], { cwd: repo, stdio: 'pipe', timeout: 5000 }); + '--wave', 'close:serial:1.1']); + if (plan.exitCode !== 0) throw new Error(`${plan.output.stdout}\n${plan.output.stderr}`); 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(repo, ['rev-parse', 'HEAD~1']); const head = runGit(repo, ['rev-parse', 'HEAD']); - execFileSync('node', [CLI, 'execution', 'review', dir, '--wave', 'close', - '--base', base, '--head', head, '--report', report, '--verdict', 'pass'], { cwd: repo, stdio: 'pipe', timeout: 5000 }); - execFileSync('node', [GUARD, 'check', dir, 'executing', 'closing', '--json'], { cwd: repo, stdio: 'pipe', timeout: 5000 }); + const review = await runCli(['execution', 'review', dir, '--wave', 'close', + '--base', base, '--head', head, '--report', report, '--verdict', 'pass']); + if (review.exitCode !== 0) throw new Error(`${review.output.stdout}\n${review.output.stderr}`); + const guard = await runGuardWithCapturedOutput(['check', dir, 'executing', 'closing', '--json']); + if (guard.exitCode !== 0) throw new Error(`${guard.output.stdout}\n${guard.output.stderr}`); return { ok: true, out: '' }; } catch (e) { const out = `${e.stdout?.toString() || ''}\n${e.stderr?.toString() || ''}`; - return { ok: false, out: out || e.message }; + return { ok: false, out: out.trim() || e.message }; } } @@ -95,36 +115,49 @@ describe('BUG/#28: publication receipt gate before closing', () => { [noDelta, delta, legacyBoolean, published, staleSource, staleBaseline].forEach(cleanup); }); - it('SHALL allow closing when there are no delta specs', () => { - const r = runClosingGuard(noDelta); + it('routes publication through the in-process dispatcher while preserving its visible result', async () => { + const output = { stdout: '', stderr: '' }; + const io = { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }; + + const result = await dispatchCli(['sync', published.dir], io); + + assert.equal(result.exitCode, 0, output.stderr); + assert.match(output.stdout, /Published 1 canonical spec/i); + }); + + it('SHALL allow closing when there are no delta specs', async () => { + const r = await runClosingGuard(noDelta); assert.equal(r.ok, true, `closing should be allowed without delta specs, got: ${r.out}`); }); - it('SHALL block closing when delta specs exist but no publication receipt exists', () => { - const r = runClosingGuard(delta); + it('SHALL block closing when delta specs exist but no publication receipt exists', async () => { + const r = await runClosingGuard(delta); assert.equal(r.ok, false, 'closing must be blocked until a receipt is recorded'); assert.match(r.out, /publication receipt|sync|delta specs/i); }); - it('SHALL block a legacy spec_merged boolean without a publication receipt', () => { - const r = runClosingGuard(legacyBoolean, { extraState: 'spec_merged: true\n' }); + it('SHALL block a legacy spec_merged boolean without a publication receipt', async () => { + const r = await runClosingGuard(legacyBoolean, { extraState: 'spec_merged: true\n' }); assert.equal(r.ok, false, 'spec_merged=true alone must not prove publication'); assert.match(r.out, /publication receipt|spec_merged/i); }); - it('SHALL allow closing with a current publication receipt', () => { - const r = runClosingGuard(published, { synchronize: true }); + it('SHALL allow closing with a current publication receipt', async () => { + const r = await runClosingGuard(published, { synchronize: true }); assert.equal(r.ok, true, `closing should be allowed after unchanged publication, got: ${r.out}`); }); - it('SHALL block closing when the source delta changes after publication', () => { - const r = runClosingGuard(staleSource, { synchronize: true, mutateSource: true }); + it('SHALL block closing when the source delta changes after publication', async () => { + const r = await runClosingGuard(staleSource, { synchronize: true, mutateSource: true }); assert.equal(r.ok, false, 'a changed delta must invalidate its publication receipt'); assert.match(r.out, /delta has changed|publication receipt/i); }); - it('SHALL block closing when the published baseline changes after publication', () => { - const r = runClosingGuard(staleBaseline, { synchronize: true, mutateBaseline: true }); + it('SHALL block closing when the published baseline changes after publication', async () => { + const r = await runClosingGuard(staleBaseline, { synchronize: true, mutateBaseline: true }); assert.equal(r.ok, false, 'a changed baseline must invalidate its publication receipt'); assert.match(r.out, /baseline has changed|publication receipt/i); }); From 98122abd6922b4c0f132b12b5dc66c7e6112480e Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 2 Aug 2026 19:21:35 +0800 Subject: [PATCH 14/22] test: consolidate repeated execution evidence --- .../verification-risk-ownership.md | 6 ++++ tests/lib/execution-plan.test.mjs | 3 +- tests/lib/guard.test.mjs | 21 ------------ .../lib/verification-risk-ownership.test.mjs | 32 +++++++++++++++++++ 4 files changed, 39 insertions(+), 23 deletions(-) create mode 100644 changes/optimize-test-runtime-guidance/verification-risk-ownership.md create mode 100644 tests/lib/verification-risk-ownership.test.mjs diff --git a/changes/optimize-test-runtime-guidance/verification-risk-ownership.md b/changes/optimize-test-runtime-guidance/verification-risk-ownership.md new file mode 100644 index 0000000..b011e77 --- /dev/null +++ b/changes/optimize-test-runtime-guidance/verification-risk-ownership.md @@ -0,0 +1,6 @@ +| 独立风险 | 原完整链路 | 唯一端到端所有者 | 快速合同位置 | 删减理由 | +| --- | --- | --- | --- | --- | +| 发布回执的新鲜度与 closing gate | `guard-specs-merged` 中每例重启 `sync`、`execution` 和 guard | `tests/lib/guard-specs-merged.test.mjs`:六个 `BUG/#28` 场景 | 同文件的 `dispatchCli`/`runGuard` 注入流测试;`tests/lib/internal-command-guard-boundaries.test.mjs` | 公共 wrapper 已有独立冒烟;重复 Node 启动不增加回执风险覆盖。 | +| review 报告证据不可用 | `guard.test.mjs` 为删除、空文件、目录、符号链接和控制字符路径分别重建 plan/review | `tests/lib/guard.test.mjs`:`blocks closing when a persisted passing review report is no longer safe evidence`(删除、符号链接) | `tests/lib/execution-plan.test.mjs`:`rejects missing, non-file, empty, and symbolic-link report evidence before writing a receipt` | guard 只需端到端代表例;其余输入类别属于同一证据验证器。 | +| repair 阈值与熔断 | 每个失败计数都创建新的 Git repair commit 并重建完整 review 链 | `tests/lib/execution-plan.test.mjs`:`opens an adjudication circuit breaker after five unresolved review failures and blocks dependents` | 同测试以连续 receipt 直接推进中间计数;`starts repair state from the first failed review and rejects a non-contiguous repair range` 覆盖 Git 连续性 | 首次 repair 和第五次熔断仍可观察;中间计数不需要五次不同 Git 工作树。 | +| 公共 CLI/guard wrapper 语义 | 重型套件把每个内部断言都作为 child process 执行 | `tests/lib/internal-command-guard-boundaries.test.mjs`:`public command wrappers` 四个场景 | 同文件的 `internal command and guard boundaries` 场景 | 成功/失败、stdout/stderr、exit code、cwd 仅需一组公共边界证明。 | diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index d3bc829..ed2bfd7 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -553,13 +553,12 @@ describe('execution plan data contract', () => { writePlan(changeDir, plan); let base = gitRefs.base; - let head = gitRefs.head; + const head = gitRefs.head; for (let failure = 1; failure <= 5; failure += 1) { recordReview(changeDir, 'wave-1', { status: 'fail', base, head, report: writeReviewReport(`failure-${failure}.md`), }); base = head; - head = createRepairCommit(`failure-${failure}`); } const [blocked, dependent] = describeWaves(changeDir, plan); diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index c56785b..c975726 100644 --- a/tests/lib/guard.test.mjs +++ b/tests/lib/guard.test.mjs @@ -652,17 +652,6 @@ describe('guard: execution control records', () => { name: 'deleted', replace: reportPath => rmSync(reportPath), }, - { - name: 'empty', - replace: reportPath => writeFileSync(reportPath, ''), - }, - { - name: 'directory', - replace: reportPath => { - rmSync(reportPath); - mkdirSync(reportPath); - }, - }, { name: 'symbolic link', replace: reportPath => { @@ -670,16 +659,6 @@ describe('guard: execution control records', () => { symlinkSync(writeReviewReport('replacement-target.md'), reportPath); }, }, - { - name: 'control-character path', - replace: reportPath => { - const plan = JSON.parse(readFileSync(join(dir, '.superpowers', 'sdd', 'execution-plan.json'), 'utf8')); - const receiptPath = join(getPlanScopedPaths(dir, plan).reviews, `${Buffer.from('wave-1').toString('base64url')}.json`); - const receipt = JSON.parse(readFileSync(receiptPath, 'utf8')); - receipt.report = `${reportPath}\nforged`; - writeFileSync(receiptPath, `${JSON.stringify(receipt)}\n`); - }, - }, ]; for (const replacement of replacements) { diff --git a/tests/lib/verification-risk-ownership.test.mjs b/tests/lib/verification-risk-ownership.test.mjs new file mode 100644 index 0000000..b1bc90a --- /dev/null +++ b/tests/lib/verification-risk-ownership.test.mjs @@ -0,0 +1,32 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CHANGE_DIR = join(process.cwd(), 'changes', 'optimize-test-runtime-guidance'); +const MATRIX_PATH = join(CHANGE_DIR, 'verification-risk-ownership.md'); +const REQUIRED_COLUMNS = [ + '独立风险', + '原完整链路', + '唯一端到端所有者', + '快速合同位置', + '删减理由', +]; + +describe('verification risk ownership matrix', () => { + it('names one end-to-end owner and a fast contract for every migrated repeated risk', () => { + assert.equal(existsSync(MATRIX_PATH), true, 'risk ownership matrix must be recorded with the change'); + const lines = readFileSync(MATRIX_PATH, 'utf8').split('\n').filter(line => line.startsWith('|')); + assert.deepEqual(lines[0].split('|').filter(Boolean).map(value => value.trim()), REQUIRED_COLUMNS); + + const rows = lines.slice(2).map(line => line.split('|').filter(Boolean).map(value => value.trim())); + assert.ok(rows.length >= 4, 'matrix covers publication, report-evidence, repair-threshold, and wrapper risks'); + for (const row of rows) { + assert.equal(row.length, REQUIRED_COLUMNS.length, `matrix row has every required field: ${row.join(' | ')}`); + assert.ok(row.every(Boolean), `matrix row is complete: ${row.join(' | ')}`); + } + + const owners = rows.map(row => row[2]); + assert.equal(new Set(owners).size, owners.length, 'each migrated risk has one distinct end-to-end owner'); + }); +}); From 70954028cb343bb2100dbf69800261e50987a93a Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 2 Aug 2026 19:37:18 +0800 Subject: [PATCH 15/22] test: complete in-process runtime coverage --- .../verification-risk-ownership.md | 9 ++-- .../wave-1-verification.md | 14 ++++++ scripts/guard/checks/schema-valid.mjs | 19 +------ scripts/guard/guard.mjs | 11 +++-- tests/lib/cmd-execution.test.mjs | 48 ++++++++++++------ tests/lib/guard.test.mjs | 49 +++++++++++++++++-- .../lib/verification-risk-ownership.test.mjs | 19 +++++++ 7 files changed, 124 insertions(+), 45 deletions(-) create mode 100644 changes/optimize-test-runtime-guidance/wave-1-verification.md diff --git a/changes/optimize-test-runtime-guidance/verification-risk-ownership.md b/changes/optimize-test-runtime-guidance/verification-risk-ownership.md index b011e77..a18cc2f 100644 --- a/changes/optimize-test-runtime-guidance/verification-risk-ownership.md +++ b/changes/optimize-test-runtime-guidance/verification-risk-ownership.md @@ -1,6 +1,9 @@ | 独立风险 | 原完整链路 | 唯一端到端所有者 | 快速合同位置 | 删减理由 | | --- | --- | --- | --- | --- | -| 发布回执的新鲜度与 closing gate | `guard-specs-merged` 中每例重启 `sync`、`execution` 和 guard | `tests/lib/guard-specs-merged.test.mjs`:六个 `BUG/#28` 场景 | 同文件的 `dispatchCli`/`runGuard` 注入流测试;`tests/lib/internal-command-guard-boundaries.test.mjs` | 公共 wrapper 已有独立冒烟;重复 Node 启动不增加回执风险覆盖。 | | review 报告证据不可用 | `guard.test.mjs` 为删除、空文件、目录、符号链接和控制字符路径分别重建 plan/review | `tests/lib/guard.test.mjs`:`blocks closing when a persisted passing review report is no longer safe evidence`(删除、符号链接) | `tests/lib/execution-plan.test.mjs`:`rejects missing, non-file, empty, and symbolic-link report evidence before writing a receipt` | guard 只需端到端代表例;其余输入类别属于同一证据验证器。 | -| repair 阈值与熔断 | 每个失败计数都创建新的 Git repair commit 并重建完整 review 链 | `tests/lib/execution-plan.test.mjs`:`opens an adjudication circuit breaker after five unresolved review failures and blocks dependents` | 同测试以连续 receipt 直接推进中间计数;`starts repair state from the first failed review and rejects a non-contiguous repair range` 覆盖 Git 连续性 | 首次 repair 和第五次熔断仍可观察;中间计数不需要五次不同 Git 工作树。 | -| 公共 CLI/guard wrapper 语义 | 重型套件把每个内部断言都作为 child process 执行 | `tests/lib/internal-command-guard-boundaries.test.mjs`:`public command wrappers` 四个场景 | 同文件的 `internal command and guard boundaries` 场景 | 成功/失败、stdout/stderr、exit code、cwd 仅需一组公共边界证明。 | +| 公共 wrapper 成功/失败、stdout/stderr、退出码和 cwd | 重型套件把每个内部断言都作为 child process 执行 | `tests/lib/internal-command-guard-boundaries.test.mjs`:`public command wrappers` 四个场景 | 同文件的 `internal command and guard boundaries` 场景 | 成功/失败、stdout/stderr、exit code、cwd 仅需一组公共边界证明。 | +| 状态迁移 | `guard.test.mjs` 的每个转换矩阵场景都启动 guard wrapper | `tests/lib/guard.test.mjs`:`guard: transition matrix` | `tests/lib/internal-command-guard-boundaries.test.mjs`:`evaluates a guard transition in-process with injected streams` | 每个合法与非法转换仍经同一 guard 规则评估;只有进程边界在 wrapper smoke 中另行证明。 | +| Git ancestry | receipt 失败路径反复经 CLI 重建执行计划和 review | `tests/lib/execution-plan.test.mjs`:`rejects a receipt range whose base is not an ancestor of head` | `tests/lib/execution-plan.test.mjs`:`treats a persisted pass receipt with a non-ancestral Git range as unusable` | 同一 ancestry 验证器已覆盖写入与读取两侧;不需要额外 Node wrapper。 | +| 发布回执新鲜度 | `guard-specs-merged` 中每例重启 `sync`、`execution` 和 guard | `tests/lib/guard-specs-merged.test.mjs`:六个 `BUG/#28` 场景 | 同文件的 `dispatchCli`/`runGuard` 注入流测试 | 公共 wrapper 已有独立冒烟;重复 Node 启动不增加回执风险覆盖。 | +| 首次可重试 | 第一次 failed review 后重建 CLI/guard 进程才检查 repair 状态 | `tests/lib/execution-plan.test.mjs`:`makes a failed current wave retryable while blocking dependents until its replacement pass receipt` | `tests/lib/execution-plan.test.mjs`:`starts repair state from the first failed review and rejects a non-contiguous repair range` | 首次失败必须仍触发可观察的 repair 状态;后续准备不需要额外 wrapper。 | +| 第五次熔断 | 每个失败计数都创建新的 Git repair commit 并重建完整 review 链 | `tests/lib/execution-plan.test.mjs`:`shows a fifth unresolved repair as adjudication-required rather than dispatching another retry` | 同测试以连续 receipt 直接推进中间计数 | 仅第五次是不可删的熔断边界;中间计数不需要五次不同 Git 工作树。 | diff --git a/changes/optimize-test-runtime-guidance/wave-1-verification.md b/changes/optimize-test-runtime-guidance/wave-1-verification.md new file mode 100644 index 0000000..f2e642d --- /dev/null +++ b/changes/optimize-test-runtime-guidance/wave-1-verification.md @@ -0,0 +1,14 @@ +# Wave 1 定向验证与计时 + +测量方式:在同一 macOS 15.7.7 / Node 24.4.1 参考机上,直接运行各测试文件;测试 runner 输出的 suite `duration_ms` 用于和优化前完整回归日志中的同名 suite 对照。断言没有因计时而删除:仅将重复 child-process 链路改为进程内边界,公共 wrapper 冒烟仍由 `internal-command-guard-boundaries` 持有。 + +| 套件 | 命令 | 优化前完整回归同名 suite | Wave 1 定向结果 | 覆盖差异 | +| --- | --- | --- | --- | --- | +| 发布回执 | `node --test tests/lib/guard-specs-merged.test.mjs` | 94.69s,6 个回执场景 | 7/7 pass,suite 2.99s(进程 real 3.29s) | 保留 no-delta、缺 receipt、legacy boolean、current、source stale、baseline stale;新增注入流可观察断言。 | +| 执行计划 | `node --test tests/lib/execution-plan.test.mjs` | 91.87s,28 个场景 | 27/27 pass,suite 3.80s | 保留五种报告证据和第五次熔断;中间 repair 计数不再创建不同 Git commit,连续性仍由首次 retry 场景覆盖。 | +| `ssf execution` | `node --test tests/lib/cmd-execution.test.mjs` | 87.26s,33 个场景 | 33/33 pass,suite 6.34s(进程 real 18.86s) | state get/set/init 与 execution 均进程内;计划、revision、Git range、删除报告、首次 retry、第五次熔断仍断言。 | +| guard 控制记录 | `node --test tests/lib/guard.test.mjs` | 63.10s,14 个控制记录场景 | 37/37 pass,control-record suite 3.22s、全文件 4.07s | guard/CLI 重复调用进程内;报告删除和符号链接保留端到端,空/目录等由 execution-plan 快速合同覆盖。 | + +附加边界证据:`node --test tests/lib/internal-command-guard-boundaries.test.mjs` 覆盖 `scripts/spec-superflow.mjs` 与 `scripts/guard/guard.mjs` 的成功、验证失败、stdout/stderr、exit code 和 cwd;`node --test tests/lib/verification-risk-ownership.test.mjs` 校验所有权矩阵。 + +这不是最终全量时间结论;Wave 3 仍须先构建、再单独计时完整 `npm test`。 diff --git a/scripts/guard/checks/schema-valid.mjs b/scripts/guard/checks/schema-valid.mjs index 906f4bd..a2bc3eb 100644 --- a/scripts/guard/checks/schema-valid.mjs +++ b/scripts/guard/checks/schema-valid.mjs @@ -2,30 +2,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { validateSpecPathLayout, relativeSpecPath } from '../../lib/spec-paths.mjs'; - -// Cached Validator instance, lazily loaded from dist/ -let _Validator = null; - -async function getValidator() { - if (_Validator) return _Validator; - try { - const distPath = new URL('../../../dist/index.js', import.meta.url).pathname; - const mod = await import(distPath); - _Validator = mod.Validator; - return _Validator; - } catch (err) { - throw new Error(`Failed to load Validator engine. Run 'npm run build' first. Original error: ${err.message}`); - } -} +import { Validator } from '../../../dist/index.js'; /** * Validate all artifacts in a change directory using the Validator engine. * Returns { pass, failures[] } — pass is true only if all artifacts are valid. */ -export async function checkSchemaValid(changeDir) { +export function checkSchemaValid(changeDir) { const failures = []; const warnings = []; - const Validator = await getValidator(); const validator = new Validator(); // Validate proposal.md diff --git a/scripts/guard/guard.mjs b/scripts/guard/guard.mjs index 96d4e17..a5fae09 100644 --- a/scripts/guard/guard.mjs +++ b/scripts/guard/guard.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { realpathSync } from 'node:fs'; import { checkArtifactsExist } from './checks/artifacts-exist.mjs'; +import { checkSchemaValid } from './checks/schema-valid.mjs'; import { checkTasksComplete } from './checks/tasks-complete.mjs'; import { checkTestsPassing } from './checks/tests-passing.mjs'; import { checkContractFresh } from './checks/contract-fresh.mjs'; @@ -130,7 +131,7 @@ function directTestResultCheck(changeDir) { }; } -export async function runGuard(args, { +export function runGuard(args, { stdout = process.stdout, stderr = process.stderr, } = {}) { @@ -202,7 +203,7 @@ export async function runGuard(args, { const CHECK_RUNNERS = { 'artifacts-exist': (dir) => checkArtifactsExist(dir), - 'schema-valid': async (dir) => (await import('./checks/schema-valid.mjs')).checkSchemaValid(dir), + 'schema-valid': (dir) => checkSchemaValid(dir), 'contract-fresh': (dir) => checkContractFresh(dir), 'contract-current': (dir) => checkContractCurrent(dir), 'tasks-complete': (dir) => checkTasksComplete(dir), @@ -222,7 +223,7 @@ export async function runGuard(args, { for (const dim of dimensions) { const runner = CHECK_RUNNERS[dim]; const result = runner - ? await runner(changeDir) + ? runner(changeDir) : { pass: false, failures: [`Unknown dimension: ${dim}`] }; checks.push({ dimension: dim, pass: result.pass, failures: result.failures || [] }); if (!result.pass) pass = false; @@ -250,9 +251,9 @@ export async function runGuard(args, { return { exitCode: pass ? 0 : 1 }; } -async function main() { +function main() { try { - const result = await runGuard(process.argv.slice(2)); + const result = runGuard(process.argv.slice(2)); process.exitCode = result.exitCode; } catch (err) { console.error('Guard error:', err.message); diff --git a/tests/lib/cmd-execution.test.mjs b/tests/lib/cmd-execution.test.mjs index 6667b2f..8ba9199 100644 --- a/tests/lib/cmd-execution.test.mjs +++ b/tests/lib/cmd-execution.test.mjs @@ -6,9 +6,10 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; import { run as runExecution } from '../../scripts/lib/cmd-execution.mjs'; +import { readState, writeState, rebuildState } from '../../scripts/lib/state-loader.mjs'; +import { computeArtifactsHash, computeContractHash } from '../../scripts/lib/hash.mjs'; import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; -const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); let changeDir; let gitRefs; let fixture; @@ -30,21 +31,8 @@ function runSsf(args, cwd = process.cwd(), { confirmPlan = true, acknowledgePlan } } if (effectiveArgs[0] === 'execution') return runExecutionInProcess(effectiveArgs.slice(1)); - try { - const stdout = execFileSync(process.execPath, [CLI, ...effectiveArgs], { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - return { exitCode: 0, stdout, stderr: '', json: tryJson(stdout) }; - } catch (error) { - return { - exitCode: error.status ?? 1, - stdout: error.stdout?.toString() ?? '', - stderr: error.stderr?.toString() ?? '', - json: tryJson(error.stdout?.toString() ?? ''), - }; - } + if (effectiveArgs[0] === 'state') return runStateInProcess(effectiveArgs.slice(1)); + throw new Error(`Test helper has no in-process boundary for ${effectiveArgs[0]}`); } function runExecutionInProcess(args) { @@ -61,6 +49,34 @@ function runExecutionInProcess(args) { } } +function runStateInProcess(args) { + const [subcommand, directory, field, value] = args; + const useJson = args.includes('--json'); + const output = { stdout: '', stderr: '' }; + try { + if (subcommand === 'init') { + mkdirSync(directory, { recursive: true }); + rebuildState(directory, { computeArtifactsHash, computeContractHash }); + output.stdout = useJson + ? JSON.stringify({ ok: true, artifacts_hash: computeArtifactsHash(directory), contract_hash: computeContractHash(directory) }) + : 'State initialized.\n'; + } else if (subcommand === 'get') { + const state = readState(directory); + output.stdout = useJson ? JSON.stringify({ field, value: state[field] ?? null }) : `${state[field] ?? 'null'}\n`; + } else if (subcommand === 'set') { + const state = readState(directory); + state[field] = value; + writeState(directory, state); + output.stdout = useJson ? JSON.stringify({ ok: true, field, value }) : `Set ${field}.\n`; + } else { + throw new Error(`unsupported in-process state subcommand: ${subcommand}`); + } + return { exitCode: 0, ...output, json: tryJson(output.stdout) }; + } catch (error) { + return { exitCode: 1, ...output, stderr: `${error.message}\n`, json: tryJson(output.stdout) }; + } +} + function requiresAcknowledgement(args) { const mode = args[args.indexOf('--mode') + 1]; const waves = args.flatMap((value, index) => value === '--wave' ? [args[index + 1]] : []).filter(Boolean); diff --git a/tests/lib/guard.test.mjs b/tests/lib/guard.test.mjs index c975726..cf552c6 100644 --- a/tests/lib/guard.test.mjs +++ b/tests/lib/guard.test.mjs @@ -9,6 +9,9 @@ import { execFileSync } from 'node:child_process'; import { acceptWorkflowRecommendation, saveWorkflowRecommendation } from '../../scripts/lib/workflow-recommendation.mjs'; import { getPlanScopedPaths } from '../../scripts/lib/sdd-overlay.mjs'; import { runGuard as runGuardInProcess } from '../../scripts/guard/guard.mjs'; +import { run as runExecution } from '../../scripts/lib/cmd-execution.mjs'; +import { readState, writeState, rebuildState } from '../../scripts/lib/state-loader.mjs'; +import { computeArtifactsHash, computeContractHash } from '../../scripts/lib/hash.mjs'; import { createGitSeedFixture } from '../helpers/git-seed-fixture.mjs'; let tempDir; @@ -17,10 +20,48 @@ const GUARD_PATH = join(process.cwd(), 'scripts/guard/guard.mjs'); const CLI_PATH = join(process.cwd(), 'scripts/spec-superflow.mjs'); function runNodeScript(scriptPath, args) { - return execFileSync(process.execPath, [scriptPath, ...args], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); + const output = { stdout: '', stderr: '' }; + const io = { + stdout: { write: text => { output.stdout += text; } }, + stderr: { write: text => { output.stderr += text; } }, + }; + let result; + try { + if (scriptPath === GUARD_PATH) result = runGuardInProcess(args, io); + else if (scriptPath === CLI_PATH && args[0] === 'execution') result = runExecution(args.slice(1), io); + else if (scriptPath === CLI_PATH && args[0] === 'state') result = runStateInProcess(args.slice(1), io); + else throw new Error(`No in-process boundary for ${scriptPath}`); + } catch (error) { + output.stderr += `${error.message}\n`; + result = { exitCode: 1 }; + } + if (result.exitCode === 0) return output.stdout; + const error = new Error(output.stderr || `command exited ${result.exitCode}`); + error.status = result.exitCode; + error.stdout = output.stdout; + error.stderr = output.stderr; + throw error; +} + +function runStateInProcess(args, io) { + const [subcommand, directory, field, value] = args; + const useJson = args.includes('--json'); + if (subcommand === 'init') { + mkdirSync(directory, { recursive: true }); + const state = rebuildState(directory, { computeArtifactsHash, computeContractHash }); + io.stdout.write(useJson + ? `${JSON.stringify({ ok: true, artifacts_hash: state.artifacts_hash, contract_hash: state.contract_hash })}\n` + : 'State initialized.\n'); + return { exitCode: 0 }; + } + if (subcommand === 'set') { + const state = readState(directory); + state[field] = value; + writeState(directory, state); + io.stdout.write(useJson ? `${JSON.stringify({ ok: true, field, value })}\n` : `Set ${field}.\n`); + return { exitCode: 0 }; + } + throw new Error(`unsupported in-process state subcommand: ${subcommand}`); } function runGit(directory, args) { diff --git a/tests/lib/verification-risk-ownership.test.mjs b/tests/lib/verification-risk-ownership.test.mjs index b1bc90a..3110341 100644 --- a/tests/lib/verification-risk-ownership.test.mjs +++ b/tests/lib/verification-risk-ownership.test.mjs @@ -29,4 +29,23 @@ describe('verification risk ownership matrix', () => { const owners = rows.map(row => row[2]); assert.equal(new Set(owners).size, owners.length, 'each migrated risk has one distinct end-to-end owner'); }); + + it('assigns every non-removable anchor to one distinct end-to-end owner', () => { + const matrix = readFileSync(MATRIX_PATH, 'utf8'); + const anchors = [ + '公共 wrapper 成功/失败', + '状态迁移', + 'Git ancestry', + '发布回执新鲜度', + '首次可重试', + '第五次熔断', + ]; + + for (const anchor of anchors) { + const row = matrix.split('\n').find(line => line.startsWith('|') && line.includes(anchor)); + assert.ok(row, `matrix records the non-removable ${anchor} anchor`); + const owner = row.split('|').filter(Boolean).map(value => value.trim())[2]; + assert.ok(owner, `${anchor} has an end-to-end owner`); + } + }); }); From 721341de1510c86627fcbc590c56eef8742b8235 Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 10:15:35 +0800 Subject: [PATCH 16/22] feat: add lightweight internal workflow path --- scripts/guard/guard.mjs | 6 +- scripts/lib/cmd-workflow.mjs | 37 ++++++- scripts/lib/workflow-recommendation.mjs | 116 ++++++++++++++++++++- scripts/spec-superflow.mjs | 6 +- tests/lib/cmd-workflow.test.mjs | 33 +++++- tests/lib/workflow-recommendation.test.mjs | 71 ++++++++++++- 6 files changed, 251 insertions(+), 18 deletions(-) diff --git a/scripts/guard/guard.mjs b/scripts/guard/guard.mjs index a5fae09..8624667 100644 --- a/scripts/guard/guard.mjs +++ b/scripts/guard/guard.mjs @@ -77,7 +77,7 @@ const DIRECT_SHORT_PATH_CHECKS = { const TRANSITION_WORKFLOW_REQUIREMENTS = { 'exploring:bridging': ['hotfix'], - 'exploring:approved-for-build': ['tweak', 'quick', 'hotfix'], + 'exploring:approved-for-build': ['tweak', 'quick', 'hotfix', 'lightweight'], }; function checkWorkflowAllowed(key, workflow) { @@ -94,7 +94,7 @@ function checkWorkflowAllowed(key, workflow) { } function resolveDimensions(key, workflow, directShortPath) { - if (workflow === 'quick') return DIRECT_SHORT_PATH_CHECKS[key] ?? TRANSITION_CHECKS[key]; + if (workflow === 'quick' || workflow === 'lightweight') return DIRECT_SHORT_PATH_CHECKS[key] ?? TRANSITION_CHECKS[key]; if (workflow === 'hotfix' && key === 'exploring:approved-for-build') { return DIRECT_SHORT_PATH_CHECKS[key]; } @@ -156,7 +156,7 @@ export function runGuard(args, { const useJson = values.json; const workflow = values.workflow; - const VALID_WORKFLOWS = ['full', 'hotfix', 'tweak', 'quick']; + const VALID_WORKFLOWS = ['full', 'hotfix', 'tweak', 'quick', 'lightweight']; if (!VALID_WORKFLOWS.includes(workflow)) { stderr.write(`Invalid workflow: ${workflow}. Must be one of: ${VALID_WORKFLOWS.join(', ')}\n`); return { exitCode: 2 }; diff --git a/scripts/lib/cmd-workflow.mjs b/scripts/lib/cmd-workflow.mjs index 7b841a1..ea52937 100644 --- a/scripts/lib/cmd-workflow.mjs +++ b/scripts/lib/cmd-workflow.mjs @@ -21,9 +21,20 @@ const OPTIONS = { 'cross-module-change': { type: 'string' }, uncertainty: { type: 'string' }, 'request-kind': { type: 'string' }, + 'affected-path': { type: 'string', multiple: true }, + 'production-behavior': { type: 'string' }, + 'public-boundary': { type: 'string' }, + installer: { type: 'string' }, + 'state-machine': { type: 'string' }, + 'external-side-effect': { type: 'string' }, + 'data-permission-config-semantics': { type: 'string' }, + 'expected-behavior-clear': { type: 'string' }, + 'verification-reproducible': { type: 'string' }, + 'impact-paths-complete': { type: 'string' }, mode: { type: 'string' }, confirm: { type: 'boolean', default: false }, reason: { type: 'string' }, + 'scope-confirmation': { type: 'string' }, 'acknowledge-recommendation': { type: 'boolean', default: false }, source: { type: 'string' }, verification: { type: 'string' }, @@ -39,7 +50,19 @@ const BOOLEAN_FACTS = { 'cross-module-change': ['yes', 'no', 'unknown'], }; -const SELECTABLE_WORKFLOW_MODES = Object.freeze(['full', 'hotfix', 'tweak', 'quick']); +const SELECTABLE_WORKFLOW_MODES = Object.freeze([...WORKFLOW_MODES]); + +const LIGHTWEIGHT_EXCLUSION_OPTIONS = { + 'production-behavior': 'production_behavior', + 'public-boundary': 'public_boundary', + installer: 'installer', + 'state-machine': 'state_machine', + 'external-side-effect': 'external_side_effect', + 'data-permission-config-semantics': 'data_permission_config_semantics', + 'expected-behavior-clear': 'expected_behavior_clear', + 'verification-reproducible': 'verification_reproducible', + 'impact-paths-complete': 'impact_paths_complete', +}; class UsageError extends Error {} @@ -99,6 +122,7 @@ function select(changeDir, state, values) { confirmed: values.confirm, acknowledged: values['acknowledge-recommendation'], verificationStrategy: parseVerification(values.verification), + scopeConfirmation: values['scope-confirmation'], }); persistWorkflowSelection(changeDir, state, record); return print({ ok: true, source: 'user-confirmed', record }, values.json); @@ -175,6 +199,9 @@ function factsFrom(values) { cross_module_change: parseFact(values['cross-module-change'], 'cross-module-change'), uncertainty: parseFact(values.uncertainty, 'uncertainty'), request_kind: parseRequestKind(values['request-kind']), + affected_paths: values['affected-path'] ?? null, + exclusion_checks: Object.fromEntries(Object.entries(LIGHTWEIGHT_EXCLUSION_OPTIONS) + .map(([option, key]) => [key, parseFact(values[option], option)])), }; } @@ -206,7 +233,9 @@ function parseCount(value, name) { function parseFact(value, name) { if (value === undefined) return 'unknown'; - const allowed = name === 'uncertainty' ? ['low', 'high', 'unknown'] : BOOLEAN_FACTS[name]; + const allowed = name === 'uncertainty' + ? ['low', 'high', 'unknown'] + : (BOOLEAN_FACTS[name] ?? ['yes', 'no', 'unknown']); if (!allowed.includes(value)) throw new UsageError(`${name} must be one of: ${allowed.join(', ')}`); return value; } @@ -295,8 +324,8 @@ function fail(message, exitCode) { function printHelp() { console.log(`Usage: - ssf workflow recommend [--task-count ] [--file-count ] [--config-doc-only yes|no|unknown] [--schema-api-change yes|no|unknown] [--new-module yes|no|unknown] [--behavioral-constraint-change yes|no] [--cross-module-change yes|no] [--uncertainty low|high|unknown] [--request-kind standard|incident] [--json] - ssf workflow select --mode full|hotfix|tweak|quick --confirm --reason [--acknowledge-recommendation] [--verification tdd|new-test|bounded] [--json] + ssf workflow recommend [--task-count ] [--file-count ] [--config-doc-only yes|no|unknown] [--schema-api-change yes|no|unknown] [--new-module yes|no|unknown] [--behavioral-constraint-change yes|no] [--cross-module-change yes|no] [--uncertainty low|high|unknown] [--request-kind standard|incident] [--affected-path ] [--production-behavior yes|no|unknown] [--public-boundary yes|no|unknown] [--installer yes|no|unknown] [--state-machine yes|no|unknown] [--external-side-effect yes|no|unknown] [--data-permission-config-semantics yes|no|unknown] [--expected-behavior-clear yes|no|unknown] [--verification-reproducible yes|no|unknown] [--impact-paths-complete yes|no|unknown] [--json] + ssf workflow select --mode full|hotfix|tweak|quick|lightweight --confirm --reason [--scope-confirmation ] [--acknowledge-recommendation] [--verification tdd|new-test|bounded] [--json] ssf workflow accept --source direct-request [--verification tdd|new-test|bounded] [--json] ssf workflow show [--json]`); } diff --git a/scripts/lib/workflow-recommendation.mjs b/scripts/lib/workflow-recommendation.mjs index bb00b20..4ef5475 100644 --- a/scripts/lib/workflow-recommendation.mjs +++ b/scripts/lib/workflow-recommendation.mjs @@ -5,10 +5,22 @@ import { import { dirname } from 'node:path'; import { getOverlayPaths } from './sdd-overlay.mjs'; -export const WORKFLOW_MODES = Object.freeze(['full', 'hotfix', 'tweak', 'quick']); +export const WORKFLOW_MODES = Object.freeze(['full', 'hotfix', 'tweak', 'quick', 'lightweight']); const BOOLEAN_FACTS = ['config_doc_only', 'schema_api_change', 'new_module', 'behavioral_constraint_change', 'cross_module_change']; const FACT_KEYS = ['task_count', 'file_count', ...BOOLEAN_FACTS, 'uncertainty']; +const LIGHTWEIGHT_EXCLUSION_KEYS = [ + 'production_behavior', + 'public_boundary', + 'installer', + 'state_machine', + 'external_side_effect', + 'data_permission_config_semantics', + 'expected_behavior_clear', + 'verification_reproducible', + 'impact_paths_complete', +]; +const LIGHTWEIGHT_PATH_PREFIXES = ['tests/', 'docs/', 'test-support/']; export function normalizeWorkflowFacts(input = {}) { return { @@ -21,6 +33,8 @@ export function normalizeWorkflowFacts(input = {}) { cross_module_change: normalizeEnum(input.cross_module_change, ['yes', 'no', 'unknown']), uncertainty: normalizeEnum(input.uncertainty, ['low', 'high', 'unknown']), request_kind: normalizeRequestKind(input.request_kind), + affected_paths: normalizeAffectedPaths(input.affected_paths), + exclusion_checks: normalizeExclusionChecks(input.exclusion_checks), }; } @@ -42,6 +56,10 @@ export function recommendWorkflowPath(input = {}) { if (riskReasons.length) { return ready(base, 'full', 'Risk signals require the user to choose Quick or Full.', riskReasons); } + const lightweight = assessLightweightEligibility(facts); + if (lightweight.considered && !lightweight.eligible) { + return ready(base, 'full', 'Lightweight execution requires complete proof of a low-risk internal change.', lightweight.reasons); + } if (facts.config_doc_only === 'yes' && facts.task_count <= 4 && facts.file_count <= 4) { return ready(base, 'tweak', 'Config/doc-only work is within the tweak thresholds.'); } @@ -49,6 +67,9 @@ export function recommendWorkflowPath(input = {}) { && facts.task_count <= 2 && facts.file_count <= 2) { return ready(base, 'hotfix', 'Bounded incident work is within the hotfix thresholds.'); } + if (lightweight.eligible) { + return ready(base, 'lightweight', 'The change is proven to be low-risk internal test, documentation, or test-support work.'); + } if (facts.config_doc_only === 'no' && facts.task_count <= 3 && facts.file_count <= 3) { return ready(base, 'quick', 'Bounded low-risk code work is within the quick thresholds.'); } @@ -58,7 +79,7 @@ export function recommendWorkflowPath(input = {}) { export function saveWorkflowRecommendation(changeDir, facts) { const recommendation = recommendWorkflowPath(facts); const record = withHash({ - schema_version: 1, + schema_version: 2, ...recommendation, created_at: new Date().toISOString(), selection: null, @@ -106,13 +127,22 @@ function normalizeLegacyRecord(record) { request_kind: record.facts.request_kind ?? 'standard', behavioral_constraint_change: record.facts.behavioral_constraint_change ?? 'no', cross_module_change: record.facts.cross_module_change ?? 'no', + affected_paths: record.facts.affected_paths ?? null, + exclusion_checks: record.facts.exclusion_checks ?? normalizeExclusionChecks(), }, }; } return record; } -export function recordWorkflowSelection(changeDir, { mode, reason, confirmed, acknowledged, verificationStrategy }) { +export function recordWorkflowSelection(changeDir, { + mode, + reason, + confirmed, + acknowledged, + verificationStrategy, + scopeConfirmation, +}) { const loaded = readWorkflowSelection(changeDir); if (!loaded.valid) throw new Error(loaded.failures.join('; ')); if (loaded.record.status !== 'ready' || !loaded.record.recommendation) { @@ -132,6 +162,13 @@ export function recordWorkflowSelection(changeDir, { mode, reason, confirmed, ac if (riskOverride && !isVerificationStrategy(verificationStrategy)) { throw new Error('risk-acknowledged Quick selection requires --verification tdd|new-test|bounded'); } + if (mode === 'lightweight' && !isVerificationStrategy(verificationStrategy)) { + throw new Error('lightweight selection requires --verification tdd|new-test|bounded'); + } + if (mode === 'lightweight' && !isSafeReason(scopeConfirmation)) { + throw new Error('lightweight selection requires a non-empty single-line scope confirmation'); + } + const confirmedAt = new Date().toISOString(); const selected = withHash({ ...withoutHash(loaded.record), selection: { @@ -142,7 +179,11 @@ export function recordWorkflowSelection(changeDir, { mode, reason, confirmed, ac accepted_automatically: false, risk_override: riskOverride, verification_strategy: verificationStrategy ?? (mode === 'quick' ? 'bounded' : null), - selected_at: new Date().toISOString(), + scope_confirmation: mode === 'lightweight' ? scopeConfirmation : null, + verification_result: null, + escalation_reason: null, + confirmed_at: confirmedAt, + selected_at: confirmedAt, }, }); writeRecord(changeDir, selected); @@ -189,8 +230,16 @@ export function acceptWorkflowRecommendation(changeDir, { source, verificationSt export function isDirectWorkflowReceipt(record, state) { const selection = record?.selection; const mode = selection?.mode; - if (!['quick', 'hotfix'].includes(mode) || state?.workflow !== mode) return false; + if (!['quick', 'hotfix', 'lightweight'].includes(mode) || state?.workflow !== mode) return false; if (record?.status !== 'ready') return false; + if (mode === 'lightweight') { + return record?.recommendation?.mode === 'lightweight' + && selection.accepted_automatically === false + && isSafeReason(selection.scope_confirmation) + && isVerificationStrategy(selection.verification_strategy) + && isIsoTimestamp(selection.confirmed_at) + && assessLightweightEligibility(record.facts).eligible; + } const directAcceptance = record?.recommendation?.mode === mode && selection.accepted_automatically === true && selection.source === 'direct-request'; const acknowledgedQuick = mode === 'quick' && selection.accepted_automatically === false @@ -213,6 +262,28 @@ function riskReasonsFor(facts) { return reasons; } +function assessLightweightEligibility(facts) { + const checks = facts.exclusion_checks; + const paths = facts.affected_paths; + const considered = paths !== null || LIGHTWEIGHT_EXCLUSION_KEYS.some(key => checks[key] !== 'unknown'); + if (!considered) return { considered: false, eligible: false, reasons: [] }; + + const reasons = []; + if (!Array.isArray(paths) || paths.length === 0) { + reasons.push('affected paths cannot be proven'); + } else { + const nonInternal = paths.filter(path => !isLightweightPath(path)); + if (nonInternal.length) reasons.push(`affected paths are outside tests/docs/test-support: ${nonInternal.join(', ')}`); + } + for (const key of LIGHTWEIGHT_EXCLUSION_KEYS) { + const value = checks[key]; + const required = ['expected_behavior_clear', 'verification_reproducible', 'impact_paths_complete'].includes(key) ? 'yes' : 'no'; + if (value === 'unknown') reasons.push(`${key} cannot be proven`); + else if (value !== required) reasons.push(`${key}=${value}`); + } + return { considered: true, eligible: reasons.length === 0, reasons }; +} + function isVerificationStrategy(value) { return ['tdd', 'new-test', 'bounded'].includes(value); } @@ -230,6 +301,12 @@ function assertModeEligible(mode, facts) { || facts.file_count > 2 || facts.config_doc_only !== 'no' || riskReasons.length > 0)) { throw new Error('Hotfix requires an incident with at most 2 non-document tasks/files and no risk signals; choose Full'); } + if (mode === 'lightweight') { + const lightweight = assessLightweightEligibility(facts); + if (!lightweight.eligible) { + throw new Error(`Lightweight requires complete proof of low-risk internal work; ${lightweight.reasons.join('; ')}`); + } + } } function normalizeCount(value) { @@ -246,6 +323,30 @@ function normalizeEnum(value, allowed) { return value; } +function normalizeAffectedPaths(value) { + if (value === null || value === undefined) return null; + if (!Array.isArray(value) || value.length === 0 || value.some(path => typeof path !== 'string' || path.length === 0)) { + throw new Error('affected_paths must be a non-empty array of paths'); + } + return [...value]; +} + +function normalizeExclusionChecks(value = {}) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('exclusion_checks must be an object'); + } + const result = {}; + for (const key of LIGHTWEIGHT_EXCLUSION_KEYS) { + result[key] = normalizeEnum(value[key], ['yes', 'no', 'unknown']); + } + return result; +} + +function isLightweightPath(path) { + return !path.startsWith('/') && !path.split('/').includes('..') + && LIGHTWEIGHT_PATH_PREFIXES.some(prefix => path.startsWith(prefix)); +} + function withoutHash(record) { const { hash, ...content } = record; return content; @@ -279,3 +380,8 @@ function isSafeReason(value) { && value.trim().length > 0 && !/[\p{Cc}\p{Zl}\p{Zp}]/u.test(value); } + +function isIsoTimestamp(value) { + return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) + && Number.isFinite(Date.parse(value)); +} diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index d9e8f72..644db51 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -89,9 +89,9 @@ Commands: Recover an explicit change context without changing the shell runtime check-update Run a portable update check for canonical skills runtime infer Infer workflow mode without a plugin-root path - workflow recommend [--task-count ] [--file-count ] [--config-doc-only yes|no|unknown] [--schema-api-change yes|no|unknown] [--new-module yes|no|unknown] [--behavioral-constraint-change yes|no] [--cross-module-change yes|no] [--uncertainty low|high|unknown] [--request-kind standard|incident] - Persist observed intake facts and recommend full, hotfix, tweak, or quick without selecting one - workflow select --mode full|hotfix|tweak|quick --confirm --reason [--acknowledge-recommendation] [--verification tdd|new-test|bounded] + workflow recommend [--task-count ] [--file-count ] [--config-doc-only yes|no|unknown] [--schema-api-change yes|no|unknown] [--new-module yes|no|unknown] [--behavioral-constraint-change yes|no] [--cross-module-change yes|no] [--uncertainty low|high|unknown] [--request-kind standard|incident] [--affected-path ] [--production-behavior yes|no|unknown] [--public-boundary yes|no|unknown] [--installer yes|no|unknown] [--state-machine yes|no|unknown] [--external-side-effect yes|no|unknown] [--data-permission-config-semantics yes|no|unknown] [--expected-behavior-clear yes|no|unknown] [--verification-reproducible yes|no|unknown] [--impact-paths-complete yes|no|unknown] + Persist observed intake facts and recommend full, hotfix, tweak, quick, or lightweight without selecting one + workflow select --mode full|hotfix|tweak|quick|lightweight --confirm --reason [--scope-confirmation ] [--acknowledge-recommendation] [--verification tdd|new-test|bounded] Persist a user-confirmed path; a risk-acknowledged Quick requires a verification choice workflow accept --source direct-request --verification tdd|new-test|bounded Directly accept a recommended quick or hotfix workflow with the user's chosen verification diff --git a/tests/lib/cmd-workflow.test.mjs b/tests/lib/cmd-workflow.test.mjs index 27e01cd..81cd603 100644 --- a/tests/lib/cmd-workflow.test.mjs +++ b/tests/lib/cmd-workflow.test.mjs @@ -70,10 +70,39 @@ afterEach(() => { }); describe('ssf workflow', () => { + it('writes a lightweight selection with one scope confirmation through the CLI', () => { + const recommendResult = runSsf(['workflow', 'recommend', changeDir, + '--task-count', '2', '--file-count', '2', '--config-doc-only', 'no', + '--schema-api-change', 'no', '--new-module', 'no', + '--behavioral-constraint-change', 'no', '--cross-module-change', 'no', '--uncertainty', 'low', + '--affected-path', 'tests/lib/workflow-recommendation.test.mjs', + '--affected-path', 'tests/helpers/workflow-fixture.mjs', + '--production-behavior', 'no', '--public-boundary', 'no', '--installer', 'no', + '--state-machine', 'no', '--external-side-effect', 'no', '--data-permission-config-semantics', 'no', + '--expected-behavior-clear', 'yes', '--verification-reproducible', 'yes', '--impact-paths-complete', 'yes', + '--json']); + assert.equal(recommendResult.exitCode, 0, recommendResult.stderr); + assert.equal(recommendResult.json.recommendation.mode, 'lightweight'); + + const selected = runSsf(['workflow', 'select', changeDir, '--mode', 'lightweight', + '--confirm', '--reason', 'internal test refactor', + '--scope-confirmation', 'affected paths and exclusions reviewed once', + '--verification', 'new-test', '--json']); + assert.equal(selected.exitCode, 0, selected.stderr); + assert.equal(readState(changeDir).workflow, 'lightweight'); + assert.equal(selected.json.record.selection.scope_confirmation, 'affected paths and exclusions reviewed once'); + assert.match(selected.json.record.selection.confirmed_at, /^\d{4}-\d{2}-\d{2}T/); + + const guard = runSsf(['runtime', 'guard', 'check', changeDir, + 'exploring', 'approved-for-build', '--workflow', 'lightweight', '--json']); + assert.equal(guard.exitCode, 0, guard.stderr); + assert.equal(guard.json.pass, true); + }); + it('advertises both direct acceptance and an explicit risk-acknowledged Quick selection', () => { const result = runSsf(['--help']); assert.equal(result.exitCode, 0, result.stderr); - assert.match(result.stdout, /workflow select .*full\|hotfix\|tweak\|quick/); + assert.match(result.stdout, /workflow select .*full\|hotfix\|tweak\|quick\|lightweight/); assert.match(result.stdout, /workflow accept --source direct-request/); }); @@ -319,7 +348,7 @@ describe('ssf workflow', () => { 'task_count', 'file_count', 'config_doc_only', 'schema_api_change', 'new_module', 'behavioral_constraint_change', 'cross_module_change', 'uncertainty', ]); - assert.deepEqual(result.json.available_modes, ['full', 'hotfix', 'tweak', 'quick']); + assert.deepEqual(result.json.available_modes, ['full', 'hotfix', 'tweak', 'quick', 'lightweight']); assert.equal(result.json.recommendation, null); assert.equal(result.json.receipt.exists, false); diff --git a/tests/lib/workflow-recommendation.test.mjs b/tests/lib/workflow-recommendation.test.mjs index bb9d2a9..224374f 100644 --- a/tests/lib/workflow-recommendation.test.mjs +++ b/tests/lib/workflow-recommendation.test.mjs @@ -37,10 +37,79 @@ function hashRecord(record) { } describe('workflow path recommendation', () => { + it('recommends and persists a confirmed lightweight internal change', () => { + const changeDir = mkdtempSync(join(tmpdir(), 'ssf-workflow-lightweight-')); + const lightweightFacts = { + ...base, + affected_paths: ['tests/lib/workflow-recommendation.test.mjs', 'tests/helpers/workflow-fixture.mjs'], + exclusion_checks: { + production_behavior: 'no', + public_boundary: 'no', + installer: 'no', + state_machine: 'no', + external_side_effect: 'no', + data_permission_config_semantics: 'no', + expected_behavior_clear: 'yes', + verification_reproducible: 'yes', + impact_paths_complete: 'yes', + }, + }; + try { + const recommendation = saveWorkflowRecommendation(changeDir, lightweightFacts); + assert.equal(recommendation.recommendation.mode, 'lightweight'); + + const selected = recordWorkflowSelection(changeDir, { + mode: 'lightweight', + reason: 'remove repeated internal test setup', + scopeConfirmation: 'affected paths and exclusions reviewed once', + verificationStrategy: 'new-test', + confirmed: true, + acknowledged: false, + }); + + assert.equal(selected.selection.mode, 'lightweight'); + assert.deepEqual(selected.facts.affected_paths, lightweightFacts.affected_paths); + assert.equal(selected.selection.scope_confirmation, 'affected paths and exclusions reviewed once'); + assert.equal(selected.selection.verification_strategy, 'new-test'); + assert.match(selected.selection.confirmed_at, /^\d{4}-\d{2}-\d{2}T/); + assert.equal(readWorkflowSelection(changeDir).record.selection.mode, 'lightweight'); + } finally { + rmSync(changeDir, { recursive: true, force: true }); + } + }); + + it('routes incomplete or excluded lightweight evidence to Full', () => { + const incomplete = recommendWorkflowPath({ + ...base, + affected_paths: ['tests/lib/workflow-recommendation.test.mjs'], + exclusion_checks: { production_behavior: 'no' }, + }); + assert.equal(incomplete.recommendation.mode, 'full'); + assert.match(incomplete.recommendation.risk_reasons.join(' '), /cannot be proven/i); + + const excluded = recommendWorkflowPath({ + ...base, + affected_paths: ['scripts/install.mjs'], + exclusion_checks: { + production_behavior: 'no', + public_boundary: 'no', + installer: 'yes', + state_machine: 'no', + external_side_effect: 'no', + data_permission_config_semantics: 'no', + expected_behavior_clear: 'yes', + verification_reproducible: 'yes', + impact_paths_complete: 'yes', + }, + }); + assert.equal(excluded.recommendation.mode, 'full'); + assert.match(excluded.recommendation.risk_reasons.join(' '), /installer/i); + }); + it('recommends quick for a bounded low-risk code change', () => { const result = recommendWorkflowPath({ ...base, task_count: 3, file_count: 3 }); assert.equal(result.recommendation.mode, 'quick'); - assert.deepEqual(result.available_modes, ['full', 'hotfix', 'tweak', 'quick']); + assert.deepEqual(result.available_modes, ['full', 'hotfix', 'tweak', 'quick', 'lightweight']); }); it('recommends hotfix only for a bounded incident', () => { From 94d9cd2693dbb7b752f021365bb050cb6ff6bc43 Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 10:21:14 +0800 Subject: [PATCH 17/22] feat: escalate lightweight workflow risks --- scripts/lib/cmd-workflow.mjs | 36 +++++++++++- scripts/lib/workflow-recommendation.mjs | 31 ++++++++++ tests/lib/cmd-workflow.test.mjs | 32 ++++++++++ tests/lib/workflow-recommendation.test.mjs | 68 ++++++++++++++++++++-- 4 files changed, 158 insertions(+), 9 deletions(-) diff --git a/scripts/lib/cmd-workflow.mjs b/scripts/lib/cmd-workflow.mjs index ea52937..5fe123e 100644 --- a/scripts/lib/cmd-workflow.mjs +++ b/scripts/lib/cmd-workflow.mjs @@ -4,6 +4,7 @@ import { parseArgs } from 'node:util'; import { WORKFLOW_MODES, acceptWorkflowRecommendation, + escalateLightweightWorkflow, recommendWorkflowPath, readWorkflowSelection, recordWorkflowSelection, @@ -77,11 +78,11 @@ export async function run(args) { const { positionals, values } = parsed; const [subcommand, changeDir] = positionals; if (values.help || subcommand === undefined) return printHelp(); - if (!['recommend', 'select', 'accept', 'show'].includes(subcommand)) { - return fail('Usage: ssf workflow ', 2); + if (!['recommend', 'select', 'accept', 'escalate', 'show'].includes(subcommand)) { + return fail('Usage: ssf workflow ', 2); } if (positionals.length !== 2 || !changeDir) { - return fail('Usage: ssf workflow ', 2); + return fail('Usage: ssf workflow ', 2); } try { @@ -100,6 +101,7 @@ export async function run(args) { if (subcommand === 'recommend') return recommend(changeDir, values); if (subcommand === 'show') return show(changeDir, state, values.json); if (subcommand === 'accept') return accept(changeDir, state, values); + if (subcommand === 'escalate') return escalate(changeDir, state, values); return select(changeDir, state, values); } catch (error) { if (error instanceof UsageError) return fail(error.message, 2); @@ -141,6 +143,33 @@ function accept(changeDir, state, values) { return print({ ok: true, source: 'direct-request', record }, values.json); } +function escalate(changeDir, state, values) { + if (state.workflow !== 'lightweight') { + throw new Error('only an active lightweight workflow can be escalated'); + } + const record = escalateLightweightWorkflow(changeDir, { reason: values.reason }); + const fromState = state.state; + state.workflow = 'full'; + state.state = fromState === 'exploring' ? 'exploring' : 'specifying'; + state.execution_mode = null; + state.execution_plan_hash = null; + state.execution_plan_revision = null; + state.batches_completed = 0; + state.test_result = null; + state.spec_merged = false; + for (const decision of [2, 3, 4, 6, 7]) { + state[`dp_${decision}_result`] = null; + state[`dp_${decision}_confirmed`] = null; + state[`dp_${decision}_timestamp`] = null; + } + state.dp_0_decisions = appendDecision(state.dp_0_decisions, 'workflow_path=full; escalated_from=lightweight'); + state.last_transition_from = fromState; + state.last_transition_to = state.state; + state.last_transition = new Date().toISOString(); + writeState(changeDir, state); + return print({ ok: true, source: 'lightweight-escalation', record, workflow: state.workflow, state: state.state }, values.json); +} + function persistWorkflowSelection(changeDir, state, record) { const summary = `workflow_path=${record.selection.mode}; recommended=${record.recommendation.mode}; followed_recommendation=${record.selection.followed_recommendation}`; state.workflow = record.selection.mode; @@ -327,5 +356,6 @@ function printHelp() { ssf workflow recommend [--task-count ] [--file-count ] [--config-doc-only yes|no|unknown] [--schema-api-change yes|no|unknown] [--new-module yes|no|unknown] [--behavioral-constraint-change yes|no] [--cross-module-change yes|no] [--uncertainty low|high|unknown] [--request-kind standard|incident] [--affected-path ] [--production-behavior yes|no|unknown] [--public-boundary yes|no|unknown] [--installer yes|no|unknown] [--state-machine yes|no|unknown] [--external-side-effect yes|no|unknown] [--data-permission-config-semantics yes|no|unknown] [--expected-behavior-clear yes|no|unknown] [--verification-reproducible yes|no|unknown] [--impact-paths-complete yes|no|unknown] [--json] ssf workflow select --mode full|hotfix|tweak|quick|lightweight --confirm --reason [--scope-confirmation ] [--acknowledge-recommendation] [--verification tdd|new-test|bounded] [--json] ssf workflow accept --source direct-request [--verification tdd|new-test|bounded] [--json] + ssf workflow escalate --reason [--json] ssf workflow show [--json]`); } diff --git a/scripts/lib/workflow-recommendation.mjs b/scripts/lib/workflow-recommendation.mjs index 4ef5475..b957176 100644 --- a/scripts/lib/workflow-recommendation.mjs +++ b/scripts/lib/workflow-recommendation.mjs @@ -190,6 +190,37 @@ export function recordWorkflowSelection(changeDir, { return selected; } +export function escalateLightweightWorkflow(changeDir, { reason }) { + const loaded = readWorkflowSelection(changeDir); + if (!loaded.valid) throw new Error(loaded.failures.join('; ')); + const selection = loaded.record.selection; + if (selection?.mode !== 'lightweight') { + throw new Error('only an active lightweight workflow can be escalated'); + } + if (!isSafeReason(reason)) { + throw new Error('lightweight escalation requires a non-empty single-line reason'); + } + + const escalatedAt = new Date().toISOString(); + const escalated = withHash({ + ...withoutHash(loaded.record), + selection: { + ...selection, + mode: 'full', + reason: `Escalated from lightweight: ${reason}`, + followed_recommendation: false, + acknowledged_non_recommendation: false, + risk_override: false, + escalation_reason: reason, + escalated_from: 'lightweight', + escalated_at: escalatedAt, + selected_at: escalatedAt, + }, + }); + writeRecord(changeDir, escalated); + return escalated; +} + export function acceptWorkflowRecommendation(changeDir, { source, verificationStrategy }) { const loaded = readWorkflowSelection(changeDir); if (!loaded.valid) throw new Error(loaded.failures.join('; ')); diff --git a/tests/lib/cmd-workflow.test.mjs b/tests/lib/cmd-workflow.test.mjs index 81cd603..d40d0c4 100644 --- a/tests/lib/cmd-workflow.test.mjs +++ b/tests/lib/cmd-workflow.test.mjs @@ -99,6 +99,38 @@ describe('ssf workflow', () => { assert.equal(guard.json.pass, true); }); + it('stops lightweight execution and restores Full planning gates when risk appears', () => { + const recommended = runSsf(['workflow', 'recommend', changeDir, + '--task-count', '2', '--file-count', '2', '--config-doc-only', 'no', + '--schema-api-change', 'no', '--new-module', 'no', + '--behavioral-constraint-change', 'no', '--cross-module-change', 'no', '--uncertainty', 'low', + '--affected-path', 'tests/lib/workflow-recommendation.test.mjs', + '--production-behavior', 'no', '--public-boundary', 'no', '--installer', 'no', + '--state-machine', 'no', '--external-side-effect', 'no', '--data-permission-config-semantics', 'no', + '--expected-behavior-clear', 'yes', '--verification-reproducible', 'yes', '--impact-paths-complete', 'yes', + '--json']); + assert.equal(recommended.exitCode, 0, recommended.stderr); + assert.equal(runSsf(['workflow', 'select', changeDir, '--mode', 'lightweight', '--confirm', + '--reason', 'internal test refactor', '--scope-confirmation', 'scope reviewed once', + '--verification', 'new-test']).exitCode, 0); + writeState('state: executing\nworkflow: lightweight\ndp_3_result: approved legacy contract\ndp_4_result: approved legacy plan\n'); + + const escalated = runSsf(['workflow', 'escalate', changeDir, + '--reason', 'public CLI behavior is now affected', '--json']); + assert.equal(escalated.exitCode, 0, escalated.stderr); + assert.equal(readState(changeDir).workflow, 'full'); + assert.equal(readState(changeDir).state, 'specifying'); + assert.equal(readState(changeDir).dp_3_result, null); + assert.equal(readState(changeDir).dp_4_result, null); + assert.equal(escalated.json.record.selection.escalation_reason, 'public CLI behavior is now affected'); + + const fullGate = runSsf(['runtime', 'guard', 'check', changeDir, + 'specifying', 'bridging', '--workflow', 'full', '--json']); + assert.equal(fullGate.exitCode, 1); + assert.equal(fullGate.json.pass, false); + assert.equal(fullGate.json.checks.some(check => check.dimension === 'artifacts-exist' && !check.pass), true); + }); + it('advertises both direct acceptance and an explicit risk-acknowledged Quick selection', () => { const result = runSsf(['--help']); assert.equal(result.exitCode, 0, result.stderr); diff --git a/tests/lib/workflow-recommendation.test.mjs b/tests/lib/workflow-recommendation.test.mjs index 224374f..f002af2 100644 --- a/tests/lib/workflow-recommendation.test.mjs +++ b/tests/lib/workflow-recommendation.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { acceptWorkflowRecommendation, + escalateLightweightWorkflow, recommendWorkflowPath, recordWorkflowSelection, readWorkflowSelection, @@ -87,13 +88,47 @@ describe('workflow path recommendation', () => { assert.equal(incomplete.recommendation.mode, 'full'); assert.match(incomplete.recommendation.risk_reasons.join(' '), /cannot be proven/i); - const excluded = recommendWorkflowPath({ + const safeChecks = { + production_behavior: 'no', + public_boundary: 'no', + installer: 'no', + state_machine: 'no', + external_side_effect: 'no', + data_permission_config_semantics: 'no', + expected_behavior_clear: 'yes', + verification_reproducible: 'yes', + impact_paths_complete: 'yes', + }; + for (const [excludedCheck, value] of Object.entries({ + production_behavior: 'yes', + public_boundary: 'yes', + installer: 'yes', + state_machine: 'yes', + external_side_effect: 'yes', + data_permission_config_semantics: 'yes', + expected_behavior_clear: 'no', + verification_reproducible: 'no', + impact_paths_complete: 'no', + })) { + const excluded = recommendWorkflowPath({ + ...base, + affected_paths: ['tests/lib/workflow-recommendation.test.mjs'], + exclusion_checks: { ...safeChecks, [excludedCheck]: value }, + }); + assert.equal(excluded.recommendation.mode, 'full', excludedCheck); + assert.match(excluded.recommendation.risk_reasons.join(' '), new RegExp(excludedCheck)); + } + }); + + it('records a discovered lightweight risk before routing the receipt to Full', () => { + const changeDir = mkdtempSync(join(tmpdir(), 'ssf-workflow-escalation-')); + const lightweightFacts = { ...base, - affected_paths: ['scripts/install.mjs'], + affected_paths: ['tests/lib/workflow-recommendation.test.mjs'], exclusion_checks: { production_behavior: 'no', public_boundary: 'no', - installer: 'yes', + installer: 'no', state_machine: 'no', external_side_effect: 'no', data_permission_config_semantics: 'no', @@ -101,9 +136,30 @@ describe('workflow path recommendation', () => { verification_reproducible: 'yes', impact_paths_complete: 'yes', }, - }); - assert.equal(excluded.recommendation.mode, 'full'); - assert.match(excluded.recommendation.risk_reasons.join(' '), /installer/i); + }; + try { + saveWorkflowRecommendation(changeDir, lightweightFacts); + recordWorkflowSelection(changeDir, { + mode: 'lightweight', + reason: 'internal test refactor', + scopeConfirmation: 'affected paths and exclusions reviewed once', + verificationStrategy: 'new-test', + confirmed: true, + acknowledged: false, + }); + + const escalated = escalateLightweightWorkflow(changeDir, { + reason: 'public CLI behavior is now affected', + }); + + assert.equal(escalated.selection.mode, 'full'); + assert.equal(escalated.selection.escalated_from, 'lightweight'); + assert.equal(escalated.selection.escalation_reason, 'public CLI behavior is now affected'); + assert.match(escalated.selection.escalated_at, /^\d{4}-\d{2}-\d{2}T/); + assert.equal(readWorkflowSelection(changeDir).record.selection.mode, 'full'); + } finally { + rmSync(changeDir, { recursive: true, force: true }); + } }); it('recommends quick for a bounded low-risk code change', () => { From 86b625782be38a4fcd4a6c74e9b7f9bee4410636 Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 10:28:18 +0800 Subject: [PATCH 18/22] fix: require lightweight closing evidence --- .../workspace/wave-2-repair-1-report.md | 29 ++++++++++ scripts/guard/guard.mjs | 26 ++++++++- scripts/lib/cmd-workflow.mjs | 30 +++++++++-- scripts/lib/workflow-recommendation.mjs | 53 +++++++++++++++++++ tests/lib/cmd-workflow.test.mjs | 43 +++++++++++++++ 5 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md diff --git a/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md b/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md new file mode 100644 index 0000000..db92fb0 --- /dev/null +++ b/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md @@ -0,0 +1,29 @@ +# Wave 2 repair 1 report + +## Scope + +Resolve the Wave 2 Important finding only: an eligible `lightweight` change +must not close with `test_result: pass` alone. + +## RED + +Added a lightweight closing test. Before the repair, the guard returned success +with only a valid selection and `test_result: pass`; the test expected a block. + +## GREEN + +- Added `ssf workflow evidence` to write one focused-review summary and a + passing verification command/result into the selected, hash-protected + `workflow-selection.json` receipt. +- A second evidence write is rejected, so the receipt represents exactly one + focused review. +- Lightweight `executing -> closing` now requires the direct receipt, + `test_result: pass`, and the lightweight completion evidence. +- No Full planning pack, contract, DP-3/DP-4, execution plan, or wave receipt + is created or required by this path. + +## Verification + +`node --test tests/lib/workflow-recommendation.test.mjs tests/lib/cmd-workflow.test.mjs` + +Result: 46 passing, 0 failing. diff --git a/scripts/guard/guard.mjs b/scripts/guard/guard.mjs index 8624667..0df385c 100644 --- a/scripts/guard/guard.mjs +++ b/scripts/guard/guard.mjs @@ -17,7 +17,11 @@ import { checkDp3Approved } from './checks/dp3-approved.mjs'; import { checkExecutionPlanReady } from './checks/execution-plan-ready.mjs'; import { checkExecutionReviewsPassed } from './checks/execution-reviews-passed.mjs'; import { readState } from '../lib/state-loader.mjs'; -import { isDirectWorkflowReceipt, readWorkflowSelection } from '../lib/workflow-recommendation.mjs'; +import { + hasLightweightCompletionEvidence, + isDirectWorkflowReceipt, + readWorkflowSelection, +} from '../lib/workflow-recommendation.mjs'; // Transition matrix: : → required check dimensions const TRANSITION_CHECKS = { @@ -75,6 +79,11 @@ const DIRECT_SHORT_PATH_CHECKS = { 'debugging:executing': ['direct-short-path'], }; +const LIGHTWEIGHT_SHORT_PATH_CHECKS = { + ...DIRECT_SHORT_PATH_CHECKS, + 'executing:closing': ['direct-short-path', 'direct-test-result', 'lightweight-completion-evidence'], +}; + const TRANSITION_WORKFLOW_REQUIREMENTS = { 'exploring:bridging': ['hotfix'], 'exploring:approved-for-build': ['tweak', 'quick', 'hotfix', 'lightweight'], @@ -94,7 +103,8 @@ function checkWorkflowAllowed(key, workflow) { } function resolveDimensions(key, workflow, directShortPath) { - if (workflow === 'quick' || workflow === 'lightweight') return DIRECT_SHORT_PATH_CHECKS[key] ?? TRANSITION_CHECKS[key]; + if (workflow === 'quick') return DIRECT_SHORT_PATH_CHECKS[key] ?? TRANSITION_CHECKS[key]; + if (workflow === 'lightweight') return LIGHTWEIGHT_SHORT_PATH_CHECKS[key] ?? TRANSITION_CHECKS[key]; if (workflow === 'hotfix' && key === 'exploring:approved-for-build') { return DIRECT_SHORT_PATH_CHECKS[key]; } @@ -131,6 +141,17 @@ function directTestResultCheck(changeDir) { }; } +function lightweightCompletionEvidenceCheck(changeDir) { + const receipt = readWorkflowSelection(changeDir); + if (!receipt.valid || !hasLightweightCompletionEvidence(receipt.record)) { + return { + pass: false, + failures: ['lightweight closing requires exactly one focused review and a persisted passing verification command/result'], + }; + } + return { pass: true, failures: [] }; +} + export function runGuard(args, { stdout = process.stdout, stderr = process.stderr, @@ -215,6 +236,7 @@ export function runGuard(args, { 'execution-reviews-passed': (dir) => checkExecutionReviewsPassed(dir), 'direct-short-path': (dir) => directShortPathCheck(dir, workflow), 'direct-test-result': (dir) => directTestResultCheck(dir), + 'lightweight-completion-evidence': (dir) => lightweightCompletionEvidenceCheck(dir), }; const checks = []; diff --git a/scripts/lib/cmd-workflow.mjs b/scripts/lib/cmd-workflow.mjs index 5fe123e..7571697 100644 --- a/scripts/lib/cmd-workflow.mjs +++ b/scripts/lib/cmd-workflow.mjs @@ -5,10 +5,13 @@ import { WORKFLOW_MODES, acceptWorkflowRecommendation, escalateLightweightWorkflow, + hasLightweightCompletionEvidence, recommendWorkflowPath, + recordLightweightCompletionEvidence, readWorkflowSelection, recordWorkflowSelection, saveWorkflowRecommendation, + isDirectWorkflowReceipt, } from './workflow-recommendation.mjs'; import { readState, writeState } from './state-loader.mjs'; @@ -36,6 +39,9 @@ const OPTIONS = { confirm: { type: 'boolean', default: false }, reason: { type: 'string' }, 'scope-confirmation': { type: 'string' }, + 'focused-review': { type: 'string' }, + 'verification-command': { type: 'string' }, + 'verification-result': { type: 'string' }, 'acknowledge-recommendation': { type: 'boolean', default: false }, source: { type: 'string' }, verification: { type: 'string' }, @@ -78,11 +84,11 @@ export async function run(args) { const { positionals, values } = parsed; const [subcommand, changeDir] = positionals; if (values.help || subcommand === undefined) return printHelp(); - if (!['recommend', 'select', 'accept', 'escalate', 'show'].includes(subcommand)) { - return fail('Usage: ssf workflow ', 2); + if (!['recommend', 'select', 'accept', 'evidence', 'escalate', 'show'].includes(subcommand)) { + return fail('Usage: ssf workflow ', 2); } if (positionals.length !== 2 || !changeDir) { - return fail('Usage: ssf workflow ', 2); + return fail('Usage: ssf workflow ', 2); } try { @@ -101,6 +107,7 @@ export async function run(args) { if (subcommand === 'recommend') return recommend(changeDir, values); if (subcommand === 'show') return show(changeDir, state, values.json); if (subcommand === 'accept') return accept(changeDir, state, values); + if (subcommand === 'evidence') return evidence(changeDir, state, values); if (subcommand === 'escalate') return escalate(changeDir, state, values); return select(changeDir, state, values); } catch (error) { @@ -143,6 +150,22 @@ function accept(changeDir, state, values) { return print({ ok: true, source: 'direct-request', record }, values.json); } +function evidence(changeDir, state, values) { + const loaded = readWorkflowSelection(changeDir); + if (state.workflow !== 'lightweight' || !loaded.valid || !isDirectWorkflowReceipt(loaded.record, state)) { + throw new Error('lightweight completion evidence requires an active selected lightweight receipt'); + } + const record = recordLightweightCompletionEvidence(changeDir, { + focusedReview: values['focused-review'], + verificationCommand: values['verification-command'], + verificationResult: values['verification-result'], + }); + if (!hasLightweightCompletionEvidence(record)) { + throw new Error('lightweight completion evidence must contain one focused review and a passing verification result'); + } + return print({ ok: true, source: 'lightweight-completion-evidence', record }, values.json); +} + function escalate(changeDir, state, values) { if (state.workflow !== 'lightweight') { throw new Error('only an active lightweight workflow can be escalated'); @@ -356,6 +379,7 @@ function printHelp() { ssf workflow recommend [--task-count ] [--file-count ] [--config-doc-only yes|no|unknown] [--schema-api-change yes|no|unknown] [--new-module yes|no|unknown] [--behavioral-constraint-change yes|no] [--cross-module-change yes|no] [--uncertainty low|high|unknown] [--request-kind standard|incident] [--affected-path ] [--production-behavior yes|no|unknown] [--public-boundary yes|no|unknown] [--installer yes|no|unknown] [--state-machine yes|no|unknown] [--external-side-effect yes|no|unknown] [--data-permission-config-semantics yes|no|unknown] [--expected-behavior-clear yes|no|unknown] [--verification-reproducible yes|no|unknown] [--impact-paths-complete yes|no|unknown] [--json] ssf workflow select --mode full|hotfix|tweak|quick|lightweight --confirm --reason [--scope-confirmation ] [--acknowledge-recommendation] [--verification tdd|new-test|bounded] [--json] ssf workflow accept --source direct-request [--verification tdd|new-test|bounded] [--json] + ssf workflow evidence --focused-review --verification-command --verification-result pass [--json] ssf workflow escalate --reason [--json] ssf workflow show [--json]`); } diff --git a/scripts/lib/workflow-recommendation.mjs b/scripts/lib/workflow-recommendation.mjs index b957176..8eb8154 100644 --- a/scripts/lib/workflow-recommendation.mjs +++ b/scripts/lib/workflow-recommendation.mjs @@ -190,6 +190,59 @@ export function recordWorkflowSelection(changeDir, { return selected; } +export function recordLightweightCompletionEvidence(changeDir, { + focusedReview, + verificationCommand, + verificationResult, +}) { + const loaded = readWorkflowSelection(changeDir); + if (!loaded.valid) throw new Error(loaded.failures.join('; ')); + const selection = loaded.record.selection; + if (selection?.mode !== 'lightweight' || loaded.record.recommendation?.mode !== 'lightweight') { + throw new Error('lightweight completion evidence requires a selected lightweight receipt'); + } + if (selection.completion !== null && selection.completion !== undefined) { + throw new Error('lightweight completion evidence is already recorded; a second focused review is not allowed'); + } + if (!isSafeReason(focusedReview)) { + throw new Error('lightweight focused review must be non-empty single-line text'); + } + if (!isSafeReason(verificationCommand)) { + throw new Error('lightweight verification command must be non-empty single-line text'); + } + const result = String(verificationResult ?? '').trim().toLowerCase(); + if (result !== 'pass') { + throw new Error('lightweight completion verification result must be pass'); + } + + const recordedAt = new Date().toISOString(); + const updated = withHash({ + ...withoutHash(loaded.record), + selection: { + ...selection, + completion: { + focused_review: focusedReview, + verification: { + command: verificationCommand, + result, + }, + recorded_at: recordedAt, + }, + }, + }); + writeRecord(changeDir, updated); + return updated; +} + +export function hasLightweightCompletionEvidence(record) { + const completion = record?.selection?.completion; + return record?.selection?.mode === 'lightweight' + && isSafeReason(completion?.focused_review) + && isSafeReason(completion?.verification?.command) + && completion?.verification?.result === 'pass' + && isIsoTimestamp(completion?.recorded_at); +} + export function escalateLightweightWorkflow(changeDir, { reason }) { const loaded = readWorkflowSelection(changeDir); if (!loaded.valid) throw new Error(loaded.failures.join('; ')); diff --git a/tests/lib/cmd-workflow.test.mjs b/tests/lib/cmd-workflow.test.mjs index d40d0c4..74fae6a 100644 --- a/tests/lib/cmd-workflow.test.mjs +++ b/tests/lib/cmd-workflow.test.mjs @@ -99,6 +99,49 @@ describe('ssf workflow', () => { assert.equal(guard.json.pass, true); }); + it('requires one focused review and persisted verification before lightweight closing', () => { + const recommended = runSsf(['workflow', 'recommend', changeDir, + '--task-count', '2', '--file-count', '2', '--config-doc-only', 'no', + '--schema-api-change', 'no', '--new-module', 'no', + '--behavioral-constraint-change', 'no', '--cross-module-change', 'no', '--uncertainty', 'low', + '--affected-path', 'tests/lib/workflow-recommendation.test.mjs', + '--production-behavior', 'no', '--public-boundary', 'no', '--installer', 'no', + '--state-machine', 'no', '--external-side-effect', 'no', '--data-permission-config-semantics', 'no', + '--expected-behavior-clear', 'yes', '--verification-reproducible', 'yes', '--impact-paths-complete', 'yes', + '--json']); + assert.equal(recommended.exitCode, 0, recommended.stderr); + assert.equal(runSsf(['workflow', 'select', changeDir, '--mode', 'lightweight', '--confirm', + '--reason', 'internal test refactor', '--scope-confirmation', 'scope reviewed once', + '--verification', 'new-test']).exitCode, 0); + writeState('state: executing\nworkflow: lightweight\ntest_result: pass: targeted coverage\n'); + + const blocked = runSsf(['runtime', 'guard', 'check', changeDir, + 'executing', 'closing', '--workflow', 'lightweight', '--json']); + assert.equal(blocked.exitCode, 1, blocked.stderr); + assert.equal(blocked.json.checks.find(check => check.dimension === 'lightweight-completion-evidence').pass, false); + + const evidence = runSsf(['workflow', 'evidence', changeDir, + '--focused-review', 'Focused review: no scope expansion and assertions remain independent.', + '--verification-command', 'node --test tests/lib/workflow-recommendation.test.mjs', + '--verification-result', 'pass', '--json']); + assert.equal(evidence.exitCode, 0, evidence.stderr); + assert.equal(evidence.json.record.selection.completion.verification.result, 'pass'); + + const closing = runSsf(['runtime', 'guard', 'check', changeDir, + 'executing', 'closing', '--workflow', 'lightweight', '--json']); + assert.equal(closing.exitCode, 0, closing.stderr); + assert.deepEqual(closing.json.checks.map(check => check.dimension), [ + 'direct-short-path', 'direct-test-result', 'lightweight-completion-evidence', + ]); + + const secondReview = runSsf(['workflow', 'evidence', changeDir, + '--focused-review', 'Second focused review must be rejected.', + '--verification-command', 'node --test tests/lib/workflow-recommendation.test.mjs', + '--verification-result', 'pass', '--json']); + assert.equal(secondReview.exitCode, 1); + assert.match(secondReview.stderr, /already recorded/i); + }); + it('stops lightweight execution and restores Full planning gates when risk appears', () => { const recommended = runSsf(['workflow', 'recommend', changeDir, '--task-count', '2', '--file-count', '2', '--config-doc-only', 'no', From f055921bcc6a0bc5ec05aa14dd95f853569c4519 Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 13:57:47 +0800 Subject: [PATCH 19/22] perf: reuse immutable Git review validation --- scripts/lib/execution-plan.mjs | 90 +++++++++++++++++++++---------- tests/lib/execution-plan.test.mjs | 27 +++++++++- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index 291a613..0435b30 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -11,6 +11,8 @@ export const EXECUTION_MODES = ['inline', 'batch-inline', 'sdd']; const WAVE_STRATEGIES = new Set(['parallel', 'serial']); const REVIEW_STATUSES = new Set(['pass', 'fail']); const MAX_REPAIR_FAILURES = 5; +const FULL_COMMIT_SHA = /^[0-9a-f]{40}$/i; +const defaultGitRangeValidator = createGitRangeValidator(); export function createPlan(changeDir, input) { const state = readState(changeDir); @@ -409,39 +411,71 @@ function getPhysicalReviewsDirectory(changeDir) { } 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 }; + return defaultGitRangeValidator.validate(changeDir, base, head); } -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'); +/** + * Builds the internal Git proof boundary used by execution-plan validation. + * The cache is intentionally process-local and only trusts complete immutable + * commit IDs. Mutable revision inputs must be resolved on every call. + */ +export function createGitRangeValidator(runGit = defaultRunGit) { + const rootsByChangeDir = new Map(); + const commitsByRepository = new Map(); + const verifiedRanges = new Map(); + + function getGitRoot(changeDir, cacheable) { + const changeKey = resolve(changeDir); + if (cacheable && rootsByChangeDir.has(changeKey)) return rootsByChangeDir.get(changeKey); + let gitRoot; + try { + gitRoot = runGit(['-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'); + } + if (cacheable) rootsByChangeDir.set(changeKey, gitRoot); + return gitRoot; } -} -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 resolveCommit(gitRoot, revision, field, cacheable) { + const cacheKey = `${gitRoot}\u0000${revision}`; + if (cacheable && commitsByRepository.has(cacheKey)) return commitsByRepository.get(cacheKey); + let resolved; + try { + resolved = runGit(['-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`); + } + if (cacheable) commitsByRepository.set(cacheKey, resolved); + return resolved; } + + return { + validate(changeDir, base, head) { + const cacheable = FULL_COMMIT_SHA.test(base) && FULL_COMMIT_SHA.test(head); + const gitRoot = getGitRoot(changeDir, cacheable); + const resolvedBase = resolveCommit(gitRoot, base, 'base', cacheable); + const resolvedHead = resolveCommit(gitRoot, head, 'head', cacheable); + const rangeKey = `${gitRoot}\u0000${resolvedBase}\u0000${resolvedHead}`; + if (cacheable && verifiedRanges.has(rangeKey)) return verifiedRanges.get(rangeKey); + try { + runGit(['-C', gitRoot, 'merge-base', '--is-ancestor', resolvedBase, resolvedHead], { stdio: 'ignore' }); + } catch { + throw new Error('Review receipt base must be an ancestor of head'); + } + const result = { base: resolvedBase, head: resolvedHead }; + if (cacheable) verifiedRanges.set(rangeKey, result); + return result; + }, + }; +} + +function defaultRunGit(args, options) { + return execFileSync('git', args, options); } function blockedDependencies(changeDir, plan, wave) { diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index ed2bfd7..85f2602 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { - createPlan as createRawPlan, describeWaves, readPlan, recordReview, validatePlan, writePlan, + createGitRangeValidator, createPlan as createRawPlan, describeWaves, readPlan, recordReview, validatePlan, writePlan, } from '../../scripts/lib/execution-plan.mjs'; import { createRecommendationReceipt, recommendExecutionModes } from '../../scripts/lib/execution-recommendation.mjs'; import { readState } from '../../scripts/lib/state-loader.mjs'; @@ -86,6 +86,31 @@ function createPlan(directory, input) { } describe('execution plan data contract', () => { + it('reuses one immutable Git review range but re-resolves symbolic revisions', () => { + const base = 'a'.repeat(40); + const head = 'b'.repeat(40); + const calls = []; + const validator = createGitRangeValidator((args) => { + calls.push(args); + if (args[2] === 'rev-parse' && args[3] === '--show-toplevel') return '/repo\n'; + if (args[2] === 'rev-parse' && args[3] === '--verify') { + const revision = args[4].replace(/\^\{commit\}$/, ''); + return `${revision === 'HEAD' ? base : revision}\n`; + } + if (args[2] === 'merge-base' && args[3] === '--is-ancestor') return ''; + throw new Error(`Unexpected Git command: ${args.join(' ')}`); + }); + + assert.deepEqual(validator.validate('/change', base, head), { base, head }); + assert.equal(calls.length, 4, 'the first immutable range resolves root, two commits, and ancestry'); + + assert.deepEqual(validator.validate('/change', base, head), { base, head }); + assert.equal(calls.length, 4, 'the same immutable range must not relaunch Git'); + + assert.deepEqual(validator.validate('/change', 'HEAD', head), { base, head }); + assert.equal(calls.length, 8, 'symbolic revisions must be resolved and validated again'); + }); + it('recommends inline for one small sequential task', () => { const result = recommendExecutionModes({ workflow: 'full', From 2bc27fbfeb99d1316c70f4e70351eff17ea28401 Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 14:00:00 +0800 Subject: [PATCH 20/22] fix: cache immutable Git ancestry failures --- scripts/lib/execution-plan.mjs | 7 ++++++- tests/lib/execution-plan.test.mjs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/scripts/lib/execution-plan.mjs b/scripts/lib/execution-plan.mjs index 0435b30..a3f56fb 100644 --- a/scripts/lib/execution-plan.mjs +++ b/scripts/lib/execution-plan.mjs @@ -461,10 +461,15 @@ export function createGitRangeValidator(runGit = defaultRunGit) { const resolvedBase = resolveCommit(gitRoot, base, 'base', cacheable); const resolvedHead = resolveCommit(gitRoot, head, 'head', cacheable); const rangeKey = `${gitRoot}\u0000${resolvedBase}\u0000${resolvedHead}`; - if (cacheable && verifiedRanges.has(rangeKey)) return verifiedRanges.get(rangeKey); + if (cacheable && verifiedRanges.has(rangeKey)) { + const cached = verifiedRanges.get(rangeKey); + if (cached === null) throw new Error('Review receipt base must be an ancestor of head'); + return cached; + } try { runGit(['-C', gitRoot, 'merge-base', '--is-ancestor', resolvedBase, resolvedHead], { stdio: 'ignore' }); } catch { + if (cacheable) verifiedRanges.set(rangeKey, null); throw new Error('Review receipt base must be an ancestor of head'); } const result = { base: resolvedBase, head: resolvedHead }; diff --git a/tests/lib/execution-plan.test.mjs b/tests/lib/execution-plan.test.mjs index 85f2602..f5050ec 100644 --- a/tests/lib/execution-plan.test.mjs +++ b/tests/lib/execution-plan.test.mjs @@ -111,6 +111,24 @@ describe('execution plan data contract', () => { assert.equal(calls.length, 8, 'symbolic revisions must be resolved and validated again'); }); + it('reuses an immutable non-ancestor result without treating it as valid', () => { + const base = 'c'.repeat(40); + const head = 'd'.repeat(40); + const calls = []; + const validator = createGitRangeValidator((args) => { + calls.push(args); + if (args[2] === 'rev-parse' && args[3] === '--show-toplevel') return '/repo\n'; + if (args[2] === 'rev-parse' && args[3] === '--verify') return `${args[4].replace(/\^\{commit\}$/, '')}\n`; + if (args[2] === 'merge-base' && args[3] === '--is-ancestor') throw new Error('not an ancestor'); + throw new Error(`Unexpected Git command: ${args.join(' ')}`); + }); + + assert.throws(() => validator.validate('/change', base, head), /ancestor/i); + assert.equal(calls.length, 4); + assert.throws(() => validator.validate('/change', base, head), /ancestor/i); + assert.equal(calls.length, 4, 'the same immutable failing range must not relaunch Git'); + }); + it('recommends inline for one small sequential task', () => { const result = recommendExecutionModes({ workflow: 'full', From e91bcb4038fb165110f1bc356ee9c71135d6b95a Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 14:11:15 +0800 Subject: [PATCH 21/22] docs: archive runtime optimization evidence --- .../workspace/task-1.2-implementer-report.md | 46 ------- .../workspace/wave-2-repair-1-report.md | 29 ----- .../decision-point-audit.md | 74 +++++++++++ .../optimize-test-runtime-guidance/design.md | 85 +++++++++++++ .../execution-contract.md | 72 +++++++++++ .../proposal.md | 40 ++++++ .../specs/test-discipline/spec.md | 116 ++++++++++++++++++ .../specs/workflow-routing/spec.md | 56 +++++++++ .../optimize-test-runtime-guidance/tasks.md | 27 ++++ .../wave-5-verification.md | 44 +++++++ specs/test-discipline/spec.md | 115 +++++++++++++++++ specs/workflow-routing/spec.md | 55 +++++++++ 12 files changed, 684 insertions(+), 75 deletions(-) delete mode 100644 changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md delete mode 100644 changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md create mode 100644 changes/optimize-test-runtime-guidance/decision-point-audit.md create mode 100644 changes/optimize-test-runtime-guidance/design.md create mode 100644 changes/optimize-test-runtime-guidance/execution-contract.md create mode 100644 changes/optimize-test-runtime-guidance/proposal.md create mode 100644 changes/optimize-test-runtime-guidance/specs/test-discipline/spec.md create mode 100644 changes/optimize-test-runtime-guidance/specs/workflow-routing/spec.md create mode 100644 changes/optimize-test-runtime-guidance/tasks.md create mode 100644 changes/optimize-test-runtime-guidance/wave-5-verification.md diff --git a/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md b/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md deleted file mode 100644 index ca21dbf..0000000 --- a/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r1-379358673ca6e6970ef14243b92a89931f0545416adaa4e61a6461c433d738d5/workspace/task-1.2-implementer-report.md +++ /dev/null @@ -1,46 +0,0 @@ -# Task 1.2 implementer report - -## Result - -Added internal, script-local in-process boundaries for CLI dispatch, execution -command output, and guard evaluation. They are not re-exported from `src/`, -package exports, or public documentation. The public CLI and guard wrappers -remain child-process smoke-tested for success, validation failure, streams, -exit status, and relative-path cwd behavior. - -## TDD evidence - -- **RED:** `node --test tests/lib/internal-command-guard-boundaries.test.mjs` - failed before implementation: `SyntaxError: The requested module - '../../scripts/spec-superflow.mjs' does not provide an export named - 'dispatchCli'` (1 test file failed; 0 passed). -- **GREEN:** `node --test tests/lib/internal-command-guard-boundaries.test.mjs` - passed: 7 tests, 0 failed, 2 suites (6.31 s). It covers injected command, - execution, and guard streams plus both wrapper smoke matrices. -- **Regression:** `npm run build` succeeded; `node --test - tests/lib/cmd-execution.test.mjs tests/lib/guard.test.mjs - tests/lib/cmd-runtime.test.mjs` exited 0; `git diff --check` passed. -- **Falsifiability:** Removing stream forwarding, returning the wrong exit - status, or treating the wrapper cwd as the repository root makes the focused - tests fail. - -## Changed files - -- `scripts/spec-superflow.mjs` -- `scripts/lib/cmd-execution.mjs` -- `scripts/guard/guard.mjs` -- `tests/lib/internal-command-guard-boundaries.test.mjs` - -## Self-review and risk - -The script exports are intentionally internal test seams only; no library or -documented API surface changed. Legacy commands can still own their existing -process-level exits, while the repeated execution command and guard paths now -return statuses for in-process use. Wrapper smoke tests retain the public -boundary. The remaining risk is un-migrated heavy suites, explicitly reserved -for task 1.3. - -## Commit range - -- Base: `96d1e0cb66965792a61a1d9e4da4fcc58391ddc3` -- Head: `HEAD` (the Task 1.2 implementation commit) diff --git a/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md b/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md deleted file mode 100644 index db92fb0..0000000 --- a/changes/optimize-test-runtime-guidance/.superpowers/sdd/plans/r2-42e8f1d5a5b90042b85dc91462bbd8eee6360a32abcd0b6522dbe403a9b71716/workspace/wave-2-repair-1-report.md +++ /dev/null @@ -1,29 +0,0 @@ -# Wave 2 repair 1 report - -## Scope - -Resolve the Wave 2 Important finding only: an eligible `lightweight` change -must not close with `test_result: pass` alone. - -## RED - -Added a lightweight closing test. Before the repair, the guard returned success -with only a valid selection and `test_result: pass`; the test expected a block. - -## GREEN - -- Added `ssf workflow evidence` to write one focused-review summary and a - passing verification command/result into the selected, hash-protected - `workflow-selection.json` receipt. -- A second evidence write is rejected, so the receipt represents exactly one - focused review. -- Lightweight `executing -> closing` now requires the direct receipt, - `test_result: pass`, and the lightweight completion evidence. -- No Full planning pack, contract, DP-3/DP-4, execution plan, or wave receipt - is created or required by this path. - -## Verification - -`node --test tests/lib/workflow-recommendation.test.mjs tests/lib/cmd-workflow.test.mjs` - -Result: 46 passing, 0 failing. diff --git a/changes/optimize-test-runtime-guidance/decision-point-audit.md b/changes/optimize-test-runtime-guidance/decision-point-audit.md new file mode 100644 index 0000000..a05b3bf --- /dev/null +++ b/changes/optimize-test-runtime-guidance/decision-point-audit.md @@ -0,0 +1,74 @@ +# Decision-Point Audit Report + +**变更**: optimize-test-runtime-guidance +**生成时间**: 2026-08-03T06:09:58.094Z +**当前状态**: executing + +## 汇总表 + +| DP | 名称 | 结果 | 时间戳 | +|----|------|------|--------| +| DP-0 | 用户确认门禁 | confirmed | 2026-08-01T09:00:00Z | +| DP-1 | 需求确认 | confirmed: use equivalence-class coverage and one end-to-end owner per risk; remove redundant full-chain assertions while retaining every independent risk rule, first-retry evidence, and fifth-retry circuit-breaker evidence | 2026-08-02T07:20:08Z | +| DP-2 | 工件审查 | approved: user approved risk-adaptive lightweight execution, mapped in-process hotspots, and one-end-to-end-owner equivalence coverage | 2026-08-02T10:47:10Z | +| DP-3 | 契约批准 | approved: user directed immediate remediation of repeated Git and guard validation costs under the refreshed runtime contract | 2026-08-02T10:55:09Z | +| DP-4 | 执行模式选择 | sdd: plan revision 3; user-confirmed-revision; continue approved runtime remediation with Git validation reuse and final measurement | 2026-08-03T05:53:40.442Z | +| DP-5 | 调试升级 | root cause confirmed: task-brief parses legacy Task headings while the current tasks template and SDD overlay use checkbox task lines; add regression coverage and align the parser | 2026-08-01T11:34:41Z | +| DP-6 | 验证失败 | pass: npm test 653/653 in 164.38s; under 180s reference target by 15.62s | 2026-08-02T05:00:33Z | +| DP-7 | 归档确认 | confirmed: performance optimization verified, review receipts complete, delta specs published, and change ready to archive | — | + +**统计**: 8/8 已记录,0/8 未记录。 + +## 逐决策点说明 + +### DP-0: 用户确认门禁 + +- **结果**: confirmed +- **时间戳**: 2026-08-01T09:00:00Z +- **解读**: 决策点 DP-0 已记录为 "confirmed"。 + +### DP-1: 需求确认 + +- **结果**: confirmed: use equivalence-class coverage and one end-to-end owner per risk; remove redundant full-chain assertions while retaining every independent risk rule, first-retry evidence, and fifth-retry circuit-breaker evidence +- **时间戳**: 2026-08-02T07:20:08Z +- **解读**: 决策点 DP-1 已记录为 "confirmed: use equivalence-class coverage and one end-to-end owner per risk; remove redundant full-chain assertions while retaining every independent risk rule, first-retry evidence, and fifth-retry circuit-breaker evidence"。 + +### DP-2: 工件审查 + +- **结果**: approved: user approved risk-adaptive lightweight execution, mapped in-process hotspots, and one-end-to-end-owner equivalence coverage +- **时间戳**: 2026-08-02T10:47:10Z +- **解读**: 决策点 DP-2 已记录为 "approved: user approved risk-adaptive lightweight execution, mapped in-process hotspots, and one-end-to-end-owner equivalence coverage"。 + +### DP-3: 契约批准 + +- **结果**: approved: user directed immediate remediation of repeated Git and guard validation costs under the refreshed runtime contract +- **时间戳**: 2026-08-02T10:55:09Z +- **解读**: 决策点 DP-3 已记录为 "approved: user directed immediate remediation of repeated Git and guard validation costs under the refreshed runtime contract"。 + +### DP-4: 执行模式选择 + +- **结果**: sdd: plan revision 3; user-confirmed-revision; continue approved runtime remediation with Git validation reuse and final measurement +- **时间戳**: 2026-08-03T05:53:40.442Z +- **解读**: 决策点 DP-4 已记录为 "sdd: plan revision 3; user-confirmed-revision; continue approved runtime remediation with Git validation reuse and final measurement"。 + +### DP-5: 调试升级 + +- **结果**: root cause confirmed: task-brief parses legacy Task headings while the current tasks template and SDD overlay use checkbox task lines; add regression coverage and align the parser +- **时间戳**: 2026-08-01T11:34:41Z +- **解读**: 决策点 DP-5 已记录为 "root cause confirmed: task-brief parses legacy Task headings while the current tasks template and SDD overlay use checkbox task lines; add regression coverage and align the parser"。 + +### DP-6: 验证失败 + +- **结果**: pass: npm test 653/653 in 164.38s; under 180s reference target by 15.62s +- **时间戳**: 2026-08-02T05:00:33Z +- **解读**: 决策点 DP-6 已记录为 "pass: npm test 653/653 in 164.38s; under 180s reference target by 15.62s"。 + +### DP-7: 归档确认 + +- **结果**: confirmed: performance optimization verified, review receipts complete, delta specs published, and change ready to archive +- **时间戳**: — +- **解读**: 决策点 DP-7 已记录为 "confirmed: performance optimization verified, review receipts complete, delta specs published, and change ready to archive"。 + +--- + +*本报告由 `ssf audit` 自动生成,仅供审计与归档参考。* diff --git a/changes/optimize-test-runtime-guidance/design.md b/changes/optimize-test-runtime-guidance/design.md new file mode 100644 index 0000000..01f1b66 --- /dev/null +++ b/changes/optimize-test-runtime-guidance/design.md @@ -0,0 +1,85 @@ +# 技术设计 + +## 已知事实与约束 + +- 本轮完整回归为 651/651 通过、188.34 秒,仍比 180 秒参考目标高 8.34 秒。`ssf execution`、`execution-plan`、发布回执和 guard 控制记录仍分别约 61、56、53、31 秒;它们反复验证相同 Git 回执范围。 +- 默认 runner 已固定文件级并发为 2,避免资源争用;不能通过恢复宿主默认并发来换取表面速度。 +- 测试必须继续使用 Node 20 支持的原生测试能力,且不增加运行时依赖或降低独立性。 +- 仅抽取 `beforeEach` helper 不能减少每案例 Git 初始化或每断言 Node 启动,无法解释 388 秒到 180 秒的目标。 +- 参考机为 macOS 15.7.7、12 个可用 CPU、Node 24.4.1、Apple Git 2.50.1;先成功构建,再只计 `npm test` 从进程启动到退出的时间。该时间只用于本地验收,不作为 CI 跨机器硬门槛。 +- 阶段提示曾分散在部分注入模板;九个流程 skill 现已有共同收口格式,但完整 Full 路径对低风险内部改动仍要求重复的计划、审批、波次和回执。 + +## 目标与非目标 + +- **目标**:共享重复夹具逻辑、减少四个慢套件的 Node 启动,并让低风险内部改动以一次确认、单批执行和一次收尾完成。 +- **非目标**:复用测试案例之间的可变目录或 Git 状态;压缩断言;改变生产 CLI 或完整路径的风险控制。 + +## 决策 + +### 决策:每套件创建 Git 种子、每案例复制隔离副本 + +- **选择**:在 `tests/helpers/` 建立 ESM 夹具模块;每个重型 suite 在 `before` 创建一次两提交 Git 种子,`beforeEach` 只复制种子到新临时目录并在 `afterEach` 清理。 +- **理由**:把重复的 `git init/config/add/commit` 从每个案例移到每个 suite 一次,同时仍提供独立目录、提交范围和清理边界。 +- **替代方案**:跨案例复用单一工作目录会更快,但会引入状态泄漏;只调大并发会重现已修复的资源争用。 +- **后果**:夹具必须证明复制副本与种子之间没有可变状态泄漏,包括独立 worktree/`.git` 元数据、HEAD、配置、提交、未跟踪/忽略文件和符号链接;复制或 Git 历史不符合预期时,停止迁移并回到本设计。 + +### 决策:按 suite 保留专用 seed 与迁移回退点 + +- **选择**:共享模块提供 seed/copy 原语;每个 suite 明确自己的初始工件与两提交历史。一次只迁移一个 suite,原 setup 在该 suite 的污染不变量、成功/失败路径和 CLI 冒烟都通过前不删除。 +- **理由**:各 suite 的起始文件不同,强行共享同一仓库会掩盖前置条件;逐 suite 保留回退点能定位复制、权限、符号链接、Git HEAD 或跨案例污染问题。 +- **替代方案**:一个全局万能 seed 需要大量条件分支并扩大耦合。 +- **后果**:批次 1 的任一不变量失败会阻塞批次 1.2、2 和 3,先修复或回退该 suite;不能以阶段提示改动绕过测试可靠性问题。 + +### 决策:为重复命令测试提供进程内边界 + +- **选择**:将 CLI dispatcher 和 guard 的可验证核心导出为内部模块函数(不经 `src/index`、package exports 或公开文档暴露),并注入输入/输出边界;重型测试通过这些函数覆盖重复成功/失败路径,仅保留 `scripts/spec-superflow.mjs` 与 `scripts/guard/guard.mjs` 的成功、验证失败、stdout/stderr、退出码和 cwd 路径 child-process 冒烟。 +- **理由**:`cmd-execution` 的大量 `execFileSync(node …)` 是 232 秒套件的主要成本;进程内调用移除每断言 Node 启动,同时不放弃公开 CLI 的边界验证。 +- **替代方案**:仅缓存子进程不能可靠隔离状态;把所有测试改为直接导入会失去参数解析和退出码的真实覆盖。 +- **后果**:导出的核心必须接受显式输入/输出边界,CLI wrapper 保持既有命令、输出和退出语义;冒烟测试失败时,阻塞交付并回到此决策。 + +### 决策:统一 skill 的阶段交接块 + +- **选择**:在每个流程 skill 的结束/阻塞规则中使用同一份简短交接格式:阶段、完成、下一阶段、进入条件;仅成功持久化的 `closing` 与 `abandoned` 明确 `none`。 +- **理由**:把用户可见的连续性要求放在真实的阶段指令中,而非依赖调用者临时发挥。 +- **替代方案**:只由 `workflow-start` 提示会遗漏后续阶段;新增运行时状态字段会扩大状态机范围。 +- **后果**:增加少量文档 token,但不改变 CLI 接口;通过文档合同测试防止后续遗漏。 + +### 决策:以风险而非工件数量选择执行纪律 + +- **选择**:新增“轻量内部改动”路径。它只适用于测试、文档或测试辅助代码,且同时满足:不改生产行为、公开 CLI/API/module/package exports、安装器、状态机、网络或其他外部副作用、数据/权限/配置语义;验证可复现且不确定性低。不能从请求和受影响路径证明上述每项为否,即为高不确定性;预期行为不清、验证不可复现、影响路径不完整也一律按高不确定性处理。该路径只保留一次用户确认、一个实现批次、一次聚焦审查与最终验证。任何不满足或执行中新增的风险信号,立即升级到既有 Full 路径。 +- **理由**:本次内部测试重构已证明,风险很低时,重复的 DP-2/DP-3/DP-4、波次回执和逐任务报告主要制造等待与 token 成本;而公开边界和高不确定性仍需要完整的可追溯控制。 +- **替代方案**:所有改动一律 Full 会持续把简单问题复杂化;所有改动一律 Quick 会丢失公开边界和高不确定性改动的审计与复核。 +- **后果**:路由需要把“内部范围”和“无风险信号”作为明确准入条件,并以合同测试锁住升级条件。记录复用既有 `.superpowers/sdd/workflow-selection.json`:`facts` 记录受影响路径与每项排除信号检查,`selection` 记录 `lightweight`、单次范围确认、验证策略和确认时间,收尾写入验证命令/结果与升级原因(如有)。router 和执行器只从该记录读取,不另造并行文档。轻量路径不创建完整 planning pack、execution contract、DP-3/DP-4、波次计划或任务级回执。 + +### 决策:先测量,再移除重复 child-process 断言 + +- **选择**:逐一定位四个慢套件的 Node 启动点;重复行为断言调用内部边界,公共 wrapper 每个保持最小的成功、失败、输出/退出码与 cwd 冒烟。映射为:`tests/lib/guard-specs-merged.test.mjs` 通过 `dispatchCli`、`runGuard` 与 publication helpers 覆盖发布回执,只保留 `scripts/spec-superflow.mjs sync`、`execution` 与 `scripts/guard/guard.mjs` 的 wrapper 冒烟;`tests/lib/execution-plan.test.mjs` 继续直接覆盖 `scripts/lib/execution-plan.mjs`,复用 Git seed/copy,不额外伪造 CLI 覆盖;`tests/lib/cmd-execution.test.mjs` 使用 `cmd-execution.run` 和 `dispatchCli`;`tests/lib/guard.test.mjs` 使用 `runGuard`、`cmd-state`/`cmd-execution` 或 `dispatchCli`。每迁移一个套件,先比较断言数和失败路径覆盖,再测量该套件与全量时间。 +- **理由**:Git seed 已消除部分初始化成本,303.68 秒说明后续主因是 child-process 重复启动,而不是并发或 Git 初始化。 +- **替代方案**:调高 `--test-concurrency` 会重新引入资源争用;删除场景会让数字变好但降低覆盖。 +- **后果**:测试内部模块可能增加窄的依赖注入点,但不暴露为生产公共 API;如果 wrapper 冒烟或失败覆盖减少,撤回该套件迁移。 + +### 决策:每条风险只保留一个完整链路所有者 + +- **选择**:建立“端到端所有权 + 快速合同”的测试分层。一个风险只在最贴近公共边界的套件完整验证一次;相同规则在 `cmd-execution`、`execution-plan`、guard 多层重合时,非所有者改为进程内模块断言。报告证据的删除、空文件、目录、符号链接和控制字符路径由 `execution-plan` 快速合同覆盖全量类型,guard 端到端只保留代表性的删除和符号链接。修复链保留首次失败可重试与第五次熔断两个端到端节点,中间计数由 `execution-plan` 控制记录直接构造验证。 +- **理由**:当前最慢测试把同一“证据不可用”或“修复次数受限”规则在多个层级反复准备 Git 历史、计划、回执和 CLI;成本成倍增加,却没有增加独立风险覆盖。 +- **替代方案**:保留所有完整链路最直观但持续超过运行时目标;删除整类失败断言虽更快,却会损失实际安全语义。 +- **后果**:每个删减必须写入 `changes//verification-risk-ownership.md`,每行包含独立风险、原完整链路位置、唯一端到端测试文件/测试名、快速合同位置和删减理由;不得删除唯一的公开 wrapper 成功/失败、状态迁移、Git ancestry、发布回执新鲜度或修复阈值证据。 + +### 决策:在单次进程内复用不可变 Git 范围验证 + +- **选择**:为 `execution-plan.mjs` 的 Git 仓库根、已解析 commit SHA 与 ancestry 结果建立仅进程内缓存。只有输入已解析为完整不可变 SHA、仓库路径相同且 base/head 完全相同才命中;`HEAD`、分支、tag、短 SHA、不同仓库或新范围每次都重新解析。 +- **理由**:本轮 188.34 秒的定向日志显示,外层 CLI 已大体改为进程内,但 `recordReview`、`readCurrentReview`、guard 的计划/回执检查仍对相同 SHA 对反复执行 `git rev-parse` 与 `merge-base`。这是一项实现重复,不是新的风险维度。 +- **替代方案**:删除 Git ancestry 检查会破坏回执可信度;跨进程或无条件缓存会让可变引用失效后仍被接受;提升测试并发只掩盖成本。 +- **后果**:新增 cache 的快速合同测试,证明同 SHA 对只走一次外部 Git 验证、不同仓库/范围和符号引用不会复用;保留现有真实 Git range、非祖先和伪造 receipt 端到端覆盖。 + +## 风险与验证 + +- 夹具复制导致测试耦合 → 比较相邻案例的目录、Git HEAD 和种子不变性 → 迁移后的失败路径与全量回归;失败时停止批次 1,不能继续批次 2。 +- 进程内边界偏离 CLI 行为 → 保留每个迁移命令的 child-process 冒烟矩阵 → 冒烟失败时回到设计,不得以单元覆盖替代。 +- 时间目标受机器波动影响 → 在参考机、无并行其他负载下记录一次完整结果,不把绝对墙钟时间设为 CI gate;超过 180 秒即保留 suite-duration 证据,从 `executing` 合法回退到 `bridging` 重建契约;只有范围或目标本身变化时才回退 `specifying` 并重走 DP-2,不得条件关闭 → 本机全量测试日志。 +- 阶段交接遗漏 → 合同测试矩阵覆盖九个流程 `SKILL.md` 的正常/阻塞/审批等待字段(不适用项显式标记)、`abandoned` 和 successful-close 两个终态 `none` 说明,以及 closing 前 release 继续条件 → 文档合同测试。 +- skill 文案不一致 → 统一标题和字段、覆盖九个流程 skill → 文档合同测试。 +- 轻量路径被误用于行为改动 → 路由合同覆盖全部排除条件和升级分支;执行中发现任一风险即停止轻量执行并回到 Full 路径 → 直接路径和升级测试。 +- 只优化测量而非成本 → 每个迁移套件保留断言数、wrapper 冒烟和前后耗时证据;全量时间超过 180 秒不得关闭 → 套件与全量计时日志。 +- Git 缓存掩盖可变引用变化 → 只缓存完整 SHA 与仓库绝对路径,任何符号输入或新范围均重新解析;保留跨仓库、非祖先和伪造回执测试 → 缓存合同与完整 Git 集成测试。 +- 等价类误合并 → 对每个删减列出端到端所有者、快速合同和仍覆盖的风险;首次重试、第五次熔断、删除与符号链接作为不可删除锚点 → 断言所有权矩阵和定向测试。 diff --git a/changes/optimize-test-runtime-guidance/execution-contract.md b/changes/optimize-test-runtime-guidance/execution-contract.md new file mode 100644 index 0000000..d5baa4c --- /dev/null +++ b/changes/optimize-test-runtime-guidance/execution-contract.md @@ -0,0 +1,72 @@ +# 执行合同:测试时长与阶段交接优化 + +## Intent Lock + +- **要解决的问题**:642 项测试虽全部通过,但全量 `npm test` 为 303.68 秒,离 180 秒参考目标差 123.68 秒;低风险内部改动也被重复工件、审批和回执拖慢。 +- **范围内**:四个热点套件消除重复 Node/Git/计划链路;同一进程内复用同一不可变 Git 回执范围的验证;每个独立风险保留一个端到端所有者;为低风险测试、文档和测试辅助改动提供可升级的轻量路径;保留九个 skill 的阶段交接合同。 +- **范围外**:删除唯一风险证据、改变生产 CLI/API/exports/安装器或 Full 路径语义、提高测试并发掩盖成本、增加运行时依赖。 + +## Approved Behavior + +1. 重型套件使用每套件 Git seed、每案例隔离副本;副本不能污染 seed 或相邻案例。 +2. `guard-specs-merged`、`cmd-execution`、`guard` 的重复行为经 `dispatchCli`、`cmd-execution.run`、`runGuard` 或窄内部模块进程内执行;`execution-plan` 保持对 `execution-plan.mjs` 的直接合同覆盖并复用 seed/copy。 +3. `scripts/spec-superflow.mjs sync`/`execution` 和 `scripts/guard/guard.mjs` 各保留成功、验证失败、stdout/stderr、退出码和 cwd 的 focused wrapper smoke。 +4. 每项独立风险只有一个完整链路所有者。`execution-plan` 快速覆盖报告删除、空文件、目录、符号链接和控制字符路径;guard 完整链路只保留删除与符号链接。修复链完整覆盖首次可重试和第五次熔断;中间次数直接覆盖控制记录。 +5. `workflow-selection.json` 支持 `lightweight` 记录:受影响路径、每项排除检查、一次范围确认、验证策略/结果和升级原因。只允许低不确定性的测试、文档或测试辅助改动;无法证明条件、公开边界/行为/安装器/状态机/外部副作用/数据/权限/配置语义或高不确定性,一律 Full。执行中新风险立即升级 Full。 +6. 九个流程 skill 均维持“当前阶段、已完成/阻塞、下一阶段、进入条件”的收口说明;成功 `closing` 与 `abandoned` 才无下一阶段。 +7. 先成功构建,再计时完整 `npm test`;全量测试集不减。超过 180 秒时记录热点证据并从 `executing` 回 `bridging`;只有目标或范围变化才回 `specifying` 重走 DP-2。 +8. 同一次 Node 进程内,对同一仓库和同一完整不可变 base/head SHA 的 Git 身份与 ancestry 验证可复用;符号引用、短 SHA、不同范围或不同仓库必须重新解析,不得以缓存绕过 Git ancestry 检查。 + +## Design Constraints + +- 内部测试边界不得经 `src/index`、package exports 或公开文档成为 API。 +- 测试仍使用 Node 20 原生能力,无新增运行时依赖;`--test-concurrency=2` 保持不变。 +- 任何迁移必须先记录 `changes/optimize-test-runtime-guidance/verification-risk-ownership.md`:独立风险、原完整链路、唯一端到端测试文件/名称、快速合同位置、删减理由。 +- 不得删除唯一的 wrapper 成功/失败、状态迁移、Git ancestry、发布回执新鲜度、首次重试或第五次熔断证据。 + +## Execution Batches + +### Batch 1:缩短四个热点且保留风险所有权 + +- **任务**:1.4、1.5、1.6(以已交付的 fixture/内部边界为基础)。 +- **路径**:`tests/lib/guard-specs-merged.test.mjs`、`tests/lib/execution-plan.test.mjs`、`tests/lib/cmd-execution.test.mjs`、`tests/lib/guard.test.mjs`、必要的 `scripts/lib/` 内部入口、`verification-risk-ownership.md`。 +- **完成标准**:四个映射成立;每个非所有者完整链路都有快速合同替代;相同 immutable Git range 在一次进程内不重复启动 Git;矩阵完整;wrapper smoke 和不可删锚点仍在。 +- **证据**:四个定向测试、wrapper smoke、cache 命中/失效合同、assertion/失败路径对比及每套件计时。 + +### Batch 2:实现风险自适应轻量路径 + +- **任务**:2.1、2.2。 +- **路径**:`scripts/lib/workflow-recommendation.mjs`、workflow/guard/CLI 路由模块、相关测试、工作流 skill 文档。 +- **完成标准**:`lightweight` 记录包含规定字段;仅合格内部改动准入;所有排除信号和“无法证明”均 Full;执行中新风险停止轻量并升级 Full。 +- **证据**:记录读写、准入、排除、升级和 Full 兼容的合同测试。 + +### Batch 3:交接合同与全量验证 + +- **任务**:3.1、4.1。 +- **依赖**:Batch 1、2 通过。 +- **完成标准**:九个 skill 的交接合同仍通过;构建和完整回归通过并写入环境、结果、四个热点耗时和总耗时。 +- **证据**:`node --test tests/lib/workflow-handoff-docs.test.mjs`、`npm run build`、计时 `npm test`。 + +## Test Obligations + +- 从失败测试开始:轻量路径记录/准入/升级、风险所有权矩阵完整性、Git cache 命中/失效、四个热点的内部边界与 wrapper smoke。 +- 必须覆盖:seed 隔离;报告五种证据状态;首次/第五次修复阈值;轻量路径的所有排除条件和未知影响;升级后 Full 控制仍生效。 +- 回归敏感区:CLI 参数、stdout/stderr/exit/cwd、Git ancestry、状态转换、publication receipt、existing Full/Quick/Hotfix/Tweak。 + +## Execution Planning + +- DP-4 前运行 `ssf execution recommend`,按以上三个 batch 定义 wave;模式、并行能力和非推荐选择由该命令的持久化 recommendation 决定。 +- 未经新的 DP-3 和 DP-4,不得使用旧的 execution plan、旧 review receipt 或已过期的批准继续实现。 + +## Review Gates + +- 每个 DP-4 wave 完成后,以 `ssf execution review` 写入当前 plan 的 `pass`/`fail` receipt;依赖 wave 只有在前置 `pass` 后才能开始。 +- Batch 1 review 特别检查:没有删掉唯一证据、矩阵路径和测试名可解析、wrapper smoke 未被替代。 +- Batch 2 review 特别检查:未知风险默认升级 Full,不能把轻量路径当作覆盖 Full 的快捷开关。 +- Batch 3 review 特别检查:全量命令没有省略文件,时间报告不把 build 混入测试时间。 + +## Escalation Rules + +- **回退 `specifying`**:目标、范围或风险定义变化;重新完成 DP-2。 +- **回退 `bridging`**:合同/计划过期,或完整回归超过 180 秒但目标与范围不变;更新测量与设计后重建合同。 +- **停止实现**:seed 污染、wrapper smoke 失败、唯一风险证据缺失、轻量路径遇到排除信号、或测试失败;先进入 debugging,不得以删断言关闭。 diff --git a/changes/optimize-test-runtime-guidance/proposal.md b/changes/optimize-test-runtime-guidance/proposal.md new file mode 100644 index 0000000..9d41e57 --- /dev/null +++ b/changes/optimize-test-runtime-guidance/proposal.md @@ -0,0 +1,40 @@ +# 测试时长与阶段交接优化 + +## Why + +发布前后的全量 `npm test` 已从约 388 秒降到 303.68 秒(642/642 通过),但仍未达到 180 秒参考目标。余下最慢的套件依旧重复启动 Node CLI:发布回执 94.69 秒、execution plan 91.87 秒、`ssf execution` 87.26 秒、guard 控制记录 63.10 秒。与此同时,完整流程把一个低风险内部测试重构拆成多份重复工件、多个审批点、波次回执和逐任务报告;这些控制没有降低该类改动的风险,却拉长了等待和 token 消耗。 + +## What Changes + +- 为重型 CLI/Git 集成测试提供每套件初始化一次、每案例复制为独立目录的 Git 种子夹具,并让可验证的命令/guard 核心逻辑可进程内调用;保留真实 CLI 冒烟覆盖。 +- 继续将四个已测量的慢套件中的重复断言迁移到进程内边界,只为各公共 wrapper 保留最小成功/失败冒烟,不删断言、不以提高并发掩盖成本。 +- 以等价类覆盖替换同一风险在多个测试层重复跑完整 Git/CLI 链路的做法:每条风险有一个端到端所有者;其它层只保留快速模块合同。首次修复可重试和第五次修复熔断仍各保留一条关键证据。 +- 将默认完整回归的参考机目标压到 180 秒以内:先成功运行 `npm run build`,再从 `npm test` 进程启动到退出单独计时(不含 build);环境为 macOS 15.7.7、12 个可用 CPU、Node 24.4.1、Apple Git 2.50.1,且没有并行负载。 +- 为所有工作流阶段定义统一的阶段交接输出:当前阶段、已完成事项、下一阶段和进入条件;终态明确没有下一阶段。 +- 增加风险自适应的执行纪律:仅内部测试、文档或测试辅助代码的低不确定性改动走轻量路径;公开行为、接口、状态机、安装器、外部副作用、数据/权限/配置语义或高不确定性改动继续走完整路径。 + +## Scope + +### In Scope + +- `cmd-execution`、`execution-plan`、guard 相关重型测试的种子夹具复用、进程内核心调用与真实 CLI 冒烟保护。 +- 发布回执、`execution-plan`、`ssf execution` 与 guard 控制记录四个慢套件的剩余 child-process 热点。 +- 流程 skill 的用户可见阶段交接约定及其文档合同测试。 +- 轻量路径在既有 `.superpowers/sdd/workflow-selection.json` 中的准入、升级和可验证收尾记录,以及相应的路由/合同测试。 +- Node 原生测试入口与 Node 20 兼容性保持不变。 + +### Out of Scope + +- 删除唯一的端到端断言、修改生产 CLI 行为、增加运行时依赖。只有所有权矩阵证明为重复风险的非所有者链路才可迁为快速合同。 +- 将有行为风险的改动静默降级为轻量路径,或把轻量路径用于发布接口、安装器、状态机、外部副作用、数据/权限/配置语义改动。 +- 删除仅有一次覆盖的失败路径、把多个语义不同的风险误判为同一等价类,或以压缩断言替代公共 wrapper 冒烟。 +- 为不同机器或 CI runner 承诺绝对一致的墙钟时间,或把本机时间目标变成 CI 的硬失败门槛。 + +## Impact + +- 测试辅助代码和工作流路由/执行规则会更新;生产运行时接口、安装器和既有完整路径保持兼容。 + +## Verification + +- 专项测试证明共享夹具仍隔离工作目录和 Git 状态;每条风险有唯一端到端所有者,四个慢套件保留 wrapper 冒烟且重复断言不再逐例启动 Node;轻量路径合同覆盖固定记录中的范围、排除信号检查、确认、验证策略/结果,及风险出现时升级完整路径。 +- `npm run build` 通过;单独计时的 `npm test` 通过,并把环境、测试总数、通过/失败/跳过数和 elapsed 写入 change 的验证报告。 diff --git a/changes/optimize-test-runtime-guidance/specs/test-discipline/spec.md b/changes/optimize-test-runtime-guidance/specs/test-discipline/spec.md new file mode 100644 index 0000000..20f8afc --- /dev/null +++ b/changes/optimize-test-runtime-guidance/specs/test-discipline/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Reusable isolated integration fixture + +The test suite SHALL create one Git seed repository per heavy suite, copy it into a fresh temporary directory for each case, and provide reusable helpers for CLI/Git integration tests without sharing mutable case state. + +#### Scenario: Run adjacent integration cases + +- **WHEN** two integration test cases prepare CLI/Git fixtures in the same suite +- **THEN** each case receives an independent temporary copy with the same seeded two-commit history, worktree, and writable `.git` metadata +- **AND** mutations in one copy cannot affect the other case or the seed + +#### Scenario: Detect seed contamination before migration continues + +- **WHEN** a copied fixture changes commits, HEAD, untracked or ignored files, Git config, symbolic links, or Git status +- **THEN** the seed and an adjacent copy retain their original HEAD, clean status, files, config, and `.git` metadata +- **AND** the suite keeps its pre-migration setup until the contamination assertion and migrated behavior tests pass + +### Requirement: In-process command coverage with CLI smoke preservation + +The test suite SHALL exercise repeated command and guard behavior through importable internal boundaries, while retaining focused child-process smoke tests for the public CLI entrypoint and exit behavior. + +#### Scenario: Test a repeated execution command behavior + +- **WHEN** a suite validates repeated execution or guard cases +- **THEN** it invokes the exported behavior without spawning a new Node process for every assertion +- **AND** focused tests still prove that the public CLI command dispatches and returns the expected exit behavior + +#### Scenario: Preserve mapped wrapper coverage + +- **WHEN** a command or guard core becomes importable for tests +- **THEN** it remains an internal module boundary that is not exported through `src/index`, package exports, or documented public APIs +- **AND** child-process smoke tests cover one success, one validation failure, stdout/stderr routing, exit code, and cwd-relative path behavior for both migrated wrappers: `scripts/spec-superflow.mjs` and `scripts/guard/guard.mjs` + +### Requirement: Measured full-regression runtime target + +The default `npm test` command SHALL retain the existing E2E and library test set. After a successful `npm run build`, the project SHALL time only the `npm test` process from start to exit on an unloaded macOS 15.7.7 machine with 12 available CPUs, Node 24.4.1, and Apple Git 2.50.1, and record environment, total/passed/failed/skipped counts, elapsed seconds, and the 180-second reference result in the change verification report. + +#### Scenario: Verify optimized regression suite + +- **WHEN** the maintainer runs a successful build followed by the full default test command after the optimization +- **THEN** the verification report records the reference environment, all test outcomes, and the test-only elapsed time +- **AND** no existing test file is omitted from the command + +#### Scenario: Miss the reference runtime target + +- **WHEN** the reference run exceeds 180 seconds while all tests pass +- **THEN** the change records suite-duration evidence and returns from `executing` to `bridging` to rebuild the contract +- **AND** it returns to `specifying` and repeats DP-2 only when the objective or scope changes +- **AND** it does not remove assertions or close conditionally + +### Requirement: Measured removal of repeated Node startup + +The four measured slow suites—`tests/lib/guard-specs-merged.test.mjs`, `tests/lib/execution-plan.test.mjs`, `tests/lib/cmd-execution.test.mjs`, and `tests/lib/guard.test.mjs`—SHALL remove repeated Node startup through their mapped in-process boundaries while retaining focused public-wrapper smoke coverage. + +#### Scenario: Migrate a measured slow suite + +- **WHEN** the publication-receipt suite runs +- **THEN** its repeated flow uses `dispatchCli`, `runGuard`, and publication helpers in process while `scripts/spec-superflow.mjs sync`/`execution` and `scripts/guard/guard.mjs` retain wrapper smoke coverage +- **AND** the execution-plan suite uses `execution-plan.mjs` with an isolated seed/copy fixture, the execution suite uses `cmd-execution.run`/`dispatchCli`, and the guard suite uses `runGuard` plus command modules or `dispatchCli` + +#### Scenario: Preserve public command wrapper semantics + +- **WHEN** a mapped internal boundary replaces repeated Node startup +- **THEN** focused wrapper tests still cover success, validation failure, stdout/stderr routing, exit code, and cwd behavior for `scripts/spec-superflow.mjs sync`/`execution` and `scripts/guard/guard.mjs` +- **AND** the execution-plan data-contract suite does not add synthetic CLI coverage because it verifies an internal module contract + +#### Scenario: Verify a suite migration + +- **WHEN** a slow suite migration is proposed +- **THEN** its assertion and failure-path coverage is compared with the prior suite +- **AND** its elapsed time is recorded before using it in the full-regression result + +### Requirement: One end-to-end owner per independent risk + +The test suite SHALL retain every independent behavior and failure risk, but each risk MUST have one named end-to-end test owner. Other tests of the same risk SHALL use an in-process module contract instead of repeating Git, plan, receipt, and CLI setup. + +#### Scenario: Record the ownership decision + +- **WHEN** a full-chain assertion is removed or converted to a module contract +- **THEN** `changes//verification-risk-ownership.md` records the independent risk, original full-chain location, unique end-to-end test file and test name, fast-contract location, and removal rationale +- **AND** the record shows no second full-chain owner for that risk + +#### Scenario: Consolidate repeated evidence-integrity coverage + +- **WHEN** report evidence is deleted, empty, a directory, a symbolic link, or has a control-character path +- **THEN** `execution-plan` retains fast coverage for every type +- **AND** guard integration retains deletion and symbolic-link representatives without repeating the other full chains + +#### Scenario: Consolidate repair-threshold coverage + +- **WHEN** review repair records are tested +- **THEN** the suite retains end-to-end proof that the first failure is retryable and the fifth unresolved failure opens the circuit breaker +- **AND** intermediate counts use direct control-record coverage instead of repeatedly rebuilding a Git-backed execution plan + +#### Scenario: Prevent unsafe assertion deletion + +- **WHEN** a full-chain assertion is removed or converted to a module contract +- **THEN** its ownership record identifies an independent risk, end-to-end owner, and replacement contract +- **AND** it does not remove the only wrapper success/failure, state-transition, Git-ancestry, publication-freshness, or repair-threshold evidence + +### Requirement: Reuse immutable review-range evidence within one process + +The execution-plan verification path SHALL avoid repeating external Git resolution for the same immutable commit SHA pair during one process invocation, while preserving fresh validation for symbolic revisions and every distinct range. + +#### Scenario: Re-check an unchanged immutable receipt range + +- **WHEN** a plan, guard, or review path validates the same resolved immutable base and head commits more than once in one process +- **THEN** it reuses the already-proven repository identity and ancestry result +- **AND** it preserves the same valid or invalid result without launching redundant Git child processes + +#### Scenario: Validate mutable or new range input + +- **WHEN** a caller provides a symbolic revision, a different commit pair, or a different repository +- **THEN** the verification path resolves and validates that input again +- **AND** it never treats a cached result as evidence for a changed reference or repository diff --git a/changes/optimize-test-runtime-guidance/specs/workflow-routing/spec.md b/changes/optimize-test-runtime-guidance/specs/workflow-routing/spec.md new file mode 100644 index 0000000..35cd211 --- /dev/null +++ b/changes/optimize-test-runtime-guidance/specs/workflow-routing/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Explicit phase handoff guidance + +Every workflow skill SHALL end every normal, blocked, or approval-waiting user-facing phase report with the detected current stage, completed work (or the blocking fact), the next stage, and the condition required to enter it. Only `abandoned` and a successfully persisted `closing` state are terminal and SHALL state that no next stage exists. + +#### Scenario: Complete a non-terminal workflow phase + +- **WHEN** a workflow skill completes its assigned non-terminal phase +- **THEN** it tells the user what was completed +- **AND** names the next stage and its entry condition + +#### Scenario: Pause for a gate or blockage + +- **WHEN** a workflow skill cannot advance because approval, validation, or a defect is unresolved +- **THEN** it identifies the blocking fact as the completed/known status +- **AND** names the next stage that will run after the gate is satisfied + +#### Scenario: Report a successful terminal workflow phase + +- **WHEN** a workflow skill detects a successfully persisted `closing` state or `abandoned` +- **THEN** it tells the user that no next stage exists + +#### Scenario: Report release work before successful close + +- **WHEN** release verification or archive work is still running before the `executing → closing` transition succeeds +- **THEN** it reports the remaining release step as the next stage rather than claiming a terminal state + +### Requirement: Risk-adaptive execution discipline + +The workflow SHALL use a lightweight execution path only for low-uncertainty changes limited to tests, documentation, or test-support code. It MUST retain the Full path for any production behavior, public CLI/API/module/package-export, installer, state-machine, external-side-effect, data, permission, configuration-semantics, or high-uncertainty change. A request is high uncertainty when any eligibility condition cannot be proven from the request and affected paths, including unclear expected behavior, non-reproducible verification, or incomplete impact paths. + +#### Scenario: Route an eligible internal change + +- **WHEN** a requested change is limited to tests, documentation, or test-support code and has no excluded risk signal +- **THEN** the workflow records affected paths, exclusion checks, the one scope confirmation, verification strategy/result, and any escalation reason in `.superpowers/sdd/workflow-selection.json` +- **AND** it executes one bounded batch and performs one focused review +- **AND** it does not require a full planning pack, execution contract, DP-3, DP-4, wave plan, or per-task receipt + +#### Scenario: Route an excluded change + +- **WHEN** a requested change includes any excluded risk signal +- **THEN** the workflow selects or preserves the Full path +- **AND** the Full path continues to require its planning, contract, approval, execution-plan, and review controls + +#### Scenario: Escalate when risk appears during lightweight execution + +- **WHEN** an excluded risk signal or material uncertainty appears after lightweight execution begins +- **THEN** the workflow stops lightweight execution before the affected change proceeds +- **AND** routes the change to the Full path with the discovered risk recorded + +#### Scenario: Miss the runtime target after executing + +- **WHEN** the unchanged-scope optimization exceeds the 180-second reference target +- **THEN** the workflow records suite-duration evidence and returns from `executing` to `bridging` to rebuild the contract +- **AND** it returns to `specifying` and repeats DP-2 only when the objective or scope changes diff --git a/changes/optimize-test-runtime-guidance/tasks.md b/changes/optimize-test-runtime-guidance/tasks.md new file mode 100644 index 0000000..e36532a --- /dev/null +++ b/changes/optimize-test-runtime-guidance/tasks.md @@ -0,0 +1,27 @@ +# 实现任务 + +## 交付与证明 + +| 批次 | 交付结果 | 依赖 | 证明 | +|---|---|---|---| +| 1 | 四个慢套件消除重复 Node 启动与重复完整链路 | 无 | 风险所有权矩阵、断言/冒烟覆盖和每套件计时 | +| 2 | 低风险内部改动走轻量执行纪律 | 无 | 路由与升级合同测试 | +| 3 | 每个流程阶段给出下一步 | 批次 1、2 | 阶段交接文档合同测试 | +| 4 | 全量时间和覆盖得到验证 | 批次 1、2、3 | 成功构建后单独计时 `npm test` | + +## Tasks + +- [x] **1.1 建立种子副本夹具**:新增 `tests/helpers/` 中的 Git seed/copy fixture;每个 suite 初始化一次、每个案例仍拥有独立 worktree/`.git` 元数据和两提交历史,并断言 seed/相邻副本的 HEAD、配置、状态、未跟踪/忽略文件和符号链接不变;证明:夹具独立性测试。 +- [x] **1.2 导出内部可测边界**:让 CLI dispatcher 与 guard 核心可进程内调用但不经 `src/index`、package exports 或公开文档成为 API;为 `scripts/spec-superflow.mjs` 与 `scripts/guard/guard.mjs` 保留成功、验证失败、stdout/stderr、exit code 与 cwd 冒烟;证明:核心行为和 child-process 矩阵。 +- [x] **1.3 逐 suite 迁移重型测试**:按 `cmd-execution`、`execution-plan`、guard 顺序迁移,只有当前 suite 的种子不变量、成功/失败路径和 CLI 冒烟均通过才删除原 setup;证明:对应三个测试文件。 +- [x] **1.4 清理四个剩余热点**:`tests/lib/guard-specs-merged.test.mjs` 用 `dispatchCli`、`runGuard` 与 publication helpers 替换重复 wrapper 调用;`tests/lib/execution-plan.test.mjs` 保持 `execution-plan.mjs` 直接覆盖并复用 Git seed/copy;`tests/lib/cmd-execution.test.mjs` 用 `cmd-execution.run` 与 `dispatchCli`;`tests/lib/guard.test.mjs` 用 `runGuard`、`cmd-state`/`cmd-execution` 或 `dispatchCli`。仅保留 `scripts/spec-superflow.mjs sync`/`execution` 和 `scripts/guard/guard.mjs` 的成功、失败、stdout/stderr、退出码、cwd 冒烟;证明:四个套件的断言/失败路径对比和计时。 +- [x] **1.5 压缩重复完整链路**:建立 `verification-risk-ownership.md` 矩阵,每行记录独立风险、原完整链路位置、唯一端到端测试文件/测试名、快速合同位置和删减理由。一个风险仅有一个端到端所有者;同一规则在 CLI、计划和 guard 层的其它覆盖改为快速模块合同。`execution-plan` 保留报告证据全部类型的快速合同,guard 只保留删除与符号链接代表例;修复链保留首次可重试和第五次熔断端到端证据,中间计数直接构造控制记录验证。不得删除公开 wrapper 成功/失败、状态迁移、Git ancestry、发布回执新鲜度或这两个修复阈值;证明:矩阵、定向测试和前后耗时。 +- [x] **1.6 消除重复 Git 范围验证**:为 `execution-plan.mjs` 引入仅进程内的 Git 验证缓存,键为仓库绝对路径和已解析的完整 immutable base/head SHA;同一键复用 repo/root、commit 与 ancestry 证明,符号引用、短 SHA、不同范围或不同仓库一律重新解析。不得删除 Git ancestry、伪造回执或非祖先端到端证据;证明:缓存命中/失效快速合同、既有真实 Git 范围回归和定向计时。 +- [x] **2.1 实现轻量内部改动路径**:扩展既有 `.superpowers/sdd/workflow-selection.json` 的 `facts` 和 `selection`,固定记录受影响路径、每项排除信号检查、`lightweight` 单次范围确认、验证策略和确认时间;只在测试、文档或测试辅助代码且能证明没有生产行为、公开边界、安装器、状态机、外部副作用、数据/权限/配置语义改动时准入。预期行为不清、验证不可复现、影响路径不完整或任一项不能证明时视为高不确定性;符合时只要求单批执行、一次聚焦审查和最终验证;证明:记录读写与准入合同测试。 +- [x] **2.2 实现风险升级**:任何排除条件或执行中新风险出现时,停止轻量路径并路由回 Full;Full 的 DP-2/DP-3/DP-4、执行计划与波次审查保持不变;证明:每个排除条件和升级分支的合同测试;前置:2.1。 +- [x] **3.1 定义阶段交接格式**:保持九个流程 skill 的正常、阻塞、审批等待、closing 前 release 继续条件和两个终态说明;证明:阶段交接文档合同测试矩阵。 +- [x] **4.1 验证优化结果**:在 macOS 15.7.7 / 12 CPU / Node 24.4.1 / Apple Git 2.50.1 且无并行其他负载时,先运行 `npm run build`,再单独计时 `npm test`;把环境、总数、通过/失败/跳过、四个热点套件耗时和 elapsed 写入 change 验证报告。超过 180 秒则带 suite-duration 证据从 `executing` 回退 `bridging` 重建契约;只有范围或目标变化才回退 `specifying` 并重走 DP-2,不得关闭;证明:`npm run build` 与计时 `npm test`。 + +## 实施备注 + +- 时间目标用于本机验收和改进判断;若同等覆盖未达到目标,保留测量证据并从 `executing` 回到 `bridging` 重建契约,而不删减测试。 diff --git a/changes/optimize-test-runtime-guidance/wave-5-verification.md b/changes/optimize-test-runtime-guidance/wave-5-verification.md new file mode 100644 index 0000000..c280c59 --- /dev/null +++ b/changes/optimize-test-runtime-guidance/wave-5-verification.md @@ -0,0 +1,44 @@ +# Wave 5 验证:全量测试时长 + +## 环境 + +- macOS 15.7.7;12 CPU +- Node.js v24.4.1 +- Apple Git 2.50.1 +- `npm run build` 在计时前通过;计时只包含后续 `npm test`。 + +## 结果 + +```text +tests 653 +pass 653 +fail 0 +cancelled 0 +skipped 0 +todo 0 +real 164.38 seconds +``` + +`npm test` 使用既有完整命令:`node --test --test-concurrency=2 tests/e2e.test.mjs tests/lib/*.test.mjs`;未省略测试文件,也未提高并发。 + +## 热点与结论 + +| 测量 | 耗时 | +|---|---:| +| 上轮全量基线 | 188.34 秒 | +| 本轮全量 | 164.38 秒 | +| 改善 | 23.96 秒(12.7%) | +| 参考目标 | 180 秒 | + +### 当前 HEAD 的单套件计时 + +以下命令均以 `node --test --test-concurrency=2 ` 单独运行并退出为 0: + +| 热点套件 | `real` | +|---|---:| +| `tests/lib/guard-specs-merged.test.mjs` | 4.06 秒 | +| `tests/lib/execution-plan.test.mjs` | 11.73 秒 | +| `tests/lib/cmd-execution.test.mjs` | 12.68 秒 | +| `tests/lib/guard.test.mjs` | 17.16 秒 | + +本轮比目标快 15.62 秒。`execution-plan` 通过同进程复用完整 immutable Git SHA 的仓库、commit 与 ancestry 证明,避免相同回执在计划、guard 和审查链中重复启动 Git。符号引用、新范围和不同仓库仍重新解析;成功与非祖先失败均有合同测试。定向四热点回归为 106/106 通过,62.90 秒(含构建)。 diff --git a/specs/test-discipline/spec.md b/specs/test-discipline/spec.md index 000009e..3d06795 100644 --- a/specs/test-discipline/spec.md +++ b/specs/test-discipline/spec.md @@ -39,3 +39,118 @@ The default `npm test` command SHALL run the existing E2E and library test file - **WHEN** a maintainer or CI runs `npm test` - **THEN** the command executes `tests/e2e.test.mjs` and `tests/lib/*.test.mjs` with `--test-concurrency=2` + +### Requirement: Reusable isolated integration fixture + +The test suite SHALL create one Git seed repository per heavy suite, copy it into a fresh temporary directory for each case, and provide reusable helpers for CLI/Git integration tests without sharing mutable case state. + +#### Scenario: Run adjacent integration cases + +- **WHEN** two integration test cases prepare CLI/Git fixtures in the same suite +- **THEN** each case receives an independent temporary copy with the same seeded two-commit history, worktree, and writable `.git` metadata +- **AND** mutations in one copy cannot affect the other case or the seed + +#### Scenario: Detect seed contamination before migration continues + +- **WHEN** a copied fixture changes commits, HEAD, untracked or ignored files, Git config, symbolic links, or Git status +- **THEN** the seed and an adjacent copy retain their original HEAD, clean status, files, config, and `.git` metadata +- **AND** the suite keeps its pre-migration setup until the contamination assertion and migrated behavior tests pass + +### Requirement: In-process command coverage with CLI smoke preservation + +The test suite SHALL exercise repeated command and guard behavior through importable internal boundaries, while retaining focused child-process smoke tests for the public CLI entrypoint and exit behavior. + +#### Scenario: Test a repeated execution command behavior + +- **WHEN** a suite validates repeated execution or guard cases +- **THEN** it invokes the exported behavior without spawning a new Node process for every assertion +- **AND** focused tests still prove that the public CLI command dispatches and returns the expected exit behavior + +#### Scenario: Preserve mapped wrapper coverage + +- **WHEN** a command or guard core becomes importable for tests +- **THEN** it remains an internal module boundary that is not exported through `src/index`, package exports, or documented public APIs +- **AND** child-process smoke tests cover one success, one validation failure, stdout/stderr routing, exit code, and cwd-relative path behavior for both migrated wrappers: `scripts/spec-superflow.mjs` and `scripts/guard/guard.mjs` + +### Requirement: Measured full-regression runtime target + +The default `npm test` command SHALL retain the existing E2E and library test set. After a successful `npm run build`, the project SHALL time only the `npm test` process from start to exit on an unloaded macOS 15.7.7 machine with 12 available CPUs, Node 24.4.1, and Apple Git 2.50.1, and record environment, total/passed/failed/skipped counts, elapsed seconds, and the 180-second reference result in the change verification report. + +#### Scenario: Verify optimized regression suite + +- **WHEN** the maintainer runs a successful build followed by the full default test command after the optimization +- **THEN** the verification report records the reference environment, all test outcomes, and the test-only elapsed time +- **AND** no existing test file is omitted from the command + +#### Scenario: Miss the reference runtime target + +- **WHEN** the reference run exceeds 180 seconds while all tests pass +- **THEN** the change records suite-duration evidence and returns from `executing` to `bridging` to rebuild the contract +- **AND** it returns to `specifying` and repeats DP-2 only when the objective or scope changes +- **AND** it does not remove assertions or close conditionally + +### Requirement: Measured removal of repeated Node startup + +The four measured slow suites—`tests/lib/guard-specs-merged.test.mjs`, `tests/lib/execution-plan.test.mjs`, `tests/lib/cmd-execution.test.mjs`, and `tests/lib/guard.test.mjs`—SHALL remove repeated Node startup through their mapped in-process boundaries while retaining focused public-wrapper smoke coverage. + +#### Scenario: Migrate a measured slow suite + +- **WHEN** the publication-receipt suite runs +- **THEN** its repeated flow uses `dispatchCli`, `runGuard`, and publication helpers in process while `scripts/spec-superflow.mjs sync`/`execution` and `scripts/guard/guard.mjs` retain wrapper smoke coverage +- **AND** the execution-plan suite uses `execution-plan.mjs` with an isolated seed/copy fixture, the execution suite uses `cmd-execution.run`/`dispatchCli`, and the guard suite uses `runGuard` plus command modules or `dispatchCli` + +#### Scenario: Preserve public command wrapper semantics + +- **WHEN** a mapped internal boundary replaces repeated Node startup +- **THEN** focused wrapper tests still cover success, validation failure, stdout/stderr routing, exit code, and cwd behavior for `scripts/spec-superflow.mjs sync`/`execution` and `scripts/guard/guard.mjs` +- **AND** the execution-plan data-contract suite does not add synthetic CLI coverage because it verifies an internal module contract + +#### Scenario: Verify a suite migration + +- **WHEN** a slow suite migration is proposed +- **THEN** its assertion and failure-path coverage is compared with the prior suite +- **AND** its elapsed time is recorded before using it in the full-regression result + +### Requirement: One end-to-end owner per independent risk + +The test suite SHALL retain every independent behavior and failure risk, but each risk MUST have one named end-to-end test owner. Other tests of the same risk SHALL use an in-process module contract instead of repeating Git, plan, receipt, and CLI setup. + +#### Scenario: Record the ownership decision + +- **WHEN** a full-chain assertion is removed or converted to a module contract +- **THEN** `changes//verification-risk-ownership.md` records the independent risk, original full-chain location, unique end-to-end test file and test name, fast-contract location, and removal rationale +- **AND** the record shows no second full-chain owner for that risk + +#### Scenario: Consolidate repeated evidence-integrity coverage + +- **WHEN** report evidence is deleted, empty, a directory, a symbolic link, or has a control-character path +- **THEN** `execution-plan` retains fast coverage for every type +- **AND** guard integration retains deletion and symbolic-link representatives without repeating the other full chains + +#### Scenario: Consolidate repair-threshold coverage + +- **WHEN** review repair records are tested +- **THEN** the suite retains end-to-end proof that the first failure is retryable and the fifth unresolved failure opens the circuit breaker +- **AND** intermediate counts use direct control-record coverage instead of repeatedly rebuilding a Git-backed execution plan + +#### Scenario: Prevent unsafe assertion deletion + +- **WHEN** a full-chain assertion is removed or converted to a module contract +- **THEN** its ownership record identifies an independent risk, end-to-end owner, and replacement contract +- **AND** it does not remove the only wrapper success/failure, state-transition, Git-ancestry, publication-freshness, or repair-threshold evidence + +### Requirement: Reuse immutable review-range evidence within one process + +The execution-plan verification path SHALL avoid repeating external Git resolution for the same immutable commit SHA pair during one process invocation, while preserving fresh validation for symbolic revisions and every distinct range. + +#### Scenario: Re-check an unchanged immutable receipt range + +- **WHEN** a plan, guard, or review path validates the same resolved immutable base and head commits more than once in one process +- **THEN** it reuses the already-proven repository identity and ancestry result +- **AND** it preserves the same valid or invalid result without launching redundant Git child processes + +#### Scenario: Validate mutable or new range input + +- **WHEN** a caller provides a symbolic revision, a different commit pair, or a different repository +- **THEN** the verification path resolves and validates that input again +- **AND** it never treats a cached result as evidence for a changed reference or repository diff --git a/specs/workflow-routing/spec.md b/specs/workflow-routing/spec.md index 750bc19..28209ca 100644 --- a/specs/workflow-routing/spec.md +++ b/specs/workflow-routing/spec.md @@ -62,3 +62,58 @@ The system SHALL allow a confirmed Full change to enter `specifying` before plan - **WHEN** DP-0 is confirmed for a Full change with no planning artifacts - **THEN** the transition to `specifying` succeeds and a subsequent state check is consistent + +### Requirement: Explicit phase handoff guidance + +Every workflow skill SHALL end every normal, blocked, or approval-waiting user-facing phase report with the detected current stage, completed work (or the blocking fact), the next stage, and the condition required to enter it. Only `abandoned` and a successfully persisted `closing` state are terminal and SHALL state that no next stage exists. + +#### Scenario: Complete a non-terminal workflow phase + +- **WHEN** a workflow skill completes its assigned non-terminal phase +- **THEN** it tells the user what was completed +- **AND** names the next stage and its entry condition + +#### Scenario: Pause for a gate or blockage + +- **WHEN** a workflow skill cannot advance because approval, validation, or a defect is unresolved +- **THEN** it identifies the blocking fact as the completed/known status +- **AND** names the next stage that will run after the gate is satisfied + +#### Scenario: Report a successful terminal workflow phase + +- **WHEN** a workflow skill detects a successfully persisted `closing` state or `abandoned` +- **THEN** it tells the user that no next stage exists + +#### Scenario: Report release work before successful close + +- **WHEN** release verification or archive work is still running before the `executing → closing` transition succeeds +- **THEN** it reports the remaining release step as the next stage rather than claiming a terminal state + +### Requirement: Risk-adaptive execution discipline + +The workflow SHALL use a lightweight execution path only for low-uncertainty changes limited to tests, documentation, or test-support code. It MUST retain the Full path for any production behavior, public CLI/API/module/package-export, installer, state-machine, external-side-effect, data, permission, configuration-semantics, or high-uncertainty change. A request is high uncertainty when any eligibility condition cannot be proven from the request and affected paths, including unclear expected behavior, non-reproducible verification, or incomplete impact paths. + +#### Scenario: Route an eligible internal change + +- **WHEN** a requested change is limited to tests, documentation, or test-support code and has no excluded risk signal +- **THEN** the workflow records affected paths, exclusion checks, the one scope confirmation, verification strategy/result, and any escalation reason in `.superpowers/sdd/workflow-selection.json` +- **AND** it executes one bounded batch and performs one focused review +- **AND** it does not require a full planning pack, execution contract, DP-3, DP-4, wave plan, or per-task receipt + +#### Scenario: Route an excluded change + +- **WHEN** a requested change includes any excluded risk signal +- **THEN** the workflow selects or preserves the Full path +- **AND** the Full path continues to require its planning, contract, approval, execution-plan, and review controls + +#### Scenario: Escalate when risk appears during lightweight execution + +- **WHEN** an excluded risk signal or material uncertainty appears after lightweight execution begins +- **THEN** the workflow stops lightweight execution before the affected change proceeds +- **AND** routes the change to the Full path with the discovered risk recorded + +#### Scenario: Miss the runtime target after executing + +- **WHEN** the unchanged-scope optimization exceeds the 180-second reference target +- **THEN** the workflow records suite-duration evidence and returns from `executing` to `bridging` to rebuild the contract +- **AND** it returns to `specifying` and repeats DP-2 only when the objective or scope changes From 3e152281c9a43563490cbb8f13b66bdf9f6fc993 Mon Sep 17 00:00:00 2001 From: MageByte Date: Mon, 3 Aug 2026 14:23:22 +0800 Subject: [PATCH 22/22] test: pin ensure-branch fixture default branch --- tests/lib/ensure-branch.test.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/lib/ensure-branch.test.mjs b/tests/lib/ensure-branch.test.mjs index d659b5d..dc97c52 100644 --- a/tests/lib/ensure-branch.test.mjs +++ b/tests/lib/ensure-branch.test.mjs @@ -35,7 +35,10 @@ describe('BUG/#15: ensure-branch enforces isolation', () => { repoDir = mkdtempSync(join(tmpdir(), 'ssf-ensure-repo-')); mkdirSync(join(repoDir, 'specs'), { recursive: true }); writeFileSync(join(repoDir, 'README.md'), 'x'); - git(repoDir, 'init', '-q'); + // The fixture checks out the protected `main` branch below. Pin it here + // instead of inheriting Git's host-specific init.defaultBranch (CI may + // otherwise create `master`). + git(repoDir, 'init', '-q', '--initial-branch=main'); git(repoDir, 'add', '-A'); git(repoDir, 'commit', '-q', '-m', 'init'); git(repoDir, 'checkout', '-q', '-b', 'feature/work');