@peterxiaoyang/superspec 0.1.44 → 0.1.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/cli.js +23 -24
- package/dist/code_review.js +7 -2
- package/dist/format.d.ts +20 -2
- package/dist/format.js +217 -26
- package/dist/git_state.d.ts +12 -1
- package/dist/git_state.js +45 -0
- package/dist/install.d.ts +1 -0
- package/dist/install.js +12 -0
- package/dist/next.d.ts +1 -1
- package/dist/next.js +3 -8
- package/dist/openspec.d.ts +13 -0
- package/dist/openspec.js +28 -0
- package/dist/phase_confirmation.d.ts +6 -0
- package/dist/phase_confirmation.js +22 -6
- package/dist/phase_plan.d.ts +10 -1
- package/dist/phase_plan.js +250 -37
- package/dist/record.d.ts +1 -1
- package/dist/record.js +38 -30
- package/dist/review.js +18 -2
- package/dist/sync.js +13 -4
- package/dist/task.js +15 -2
- package/dist/task_evidence.d.ts +1 -1
- package/dist/task_evidence.js +85 -10
- package/dist/transition.d.ts +4 -3
- package/dist/transition.js +166 -29
- package/dist/types.d.ts +38 -1
- package/dist/types.js +1 -0
- package/dist/workflow_config.d.ts +24 -0
- package/dist/workflow_config.js +127 -0
- package/package.json +1 -1
- package/templates/workflow/AGENTS.md +1 -1
- package/templates/workflow/agents/architect.toml +1 -1
- package/templates/workflow/agents/code-reviewer.toml +1 -1
- package/templates/workflow/agents/critic.toml +1 -1
- package/templates/workflow/agents/executor.toml +1 -1
- package/templates/workflow/agents/explore.toml +1 -1
- package/templates/workflow/agents/test-engineer.toml +1 -1
- package/templates/workflow/agents/test-runner.toml +1 -1
- package/templates/workflow/agents/verifier.toml +1 -1
- package/templates/workflow/prompts/architect.md +25 -33
- package/templates/workflow/prompts/code-reviewer.md +19 -67
- package/templates/workflow/prompts/critic.md +36 -87
- package/templates/workflow/prompts/executor.md +17 -19
- package/templates/workflow/prompts/explore.md +12 -46
- package/templates/workflow/prompts/test-engineer.md +22 -35
- package/templates/workflow/prompts/test-runner.md +11 -21
- package/templates/workflow/prompts/verifier.md +13 -37
- package/templates/workflow/skills/superspec-apply/SKILL.md +17 -85
- package/templates/workflow/skills/superspec-explore/SKILL.md +57 -66
- package/templates/workflow/skills/superspec-propose/SKILL.md +76 -129
- package/templates/workflow/skills/superspec-review/SKILL.md +14 -73
package/README.md
CHANGED
|
@@ -131,7 +131,7 @@ superspec.cmd install
|
|
|
131
131
|
|
|
132
132
|
你日常主要记住这四个入口就够了。
|
|
133
133
|
|
|
134
|
-
|
|
134
|
+
工作流档位由项目配置统一控制,不通过命令行临时指定。探索阶段会按配置创建相应审查工作项;计划阶段按同一档位创建 `critic`、`architect`、`test-engineer` 等必要审查后再进入实现准备。
|
|
135
135
|
|
|
136
136
|
## 它会多保存哪些记录
|
|
137
137
|
|
|
@@ -191,6 +191,18 @@ superspec install
|
|
|
191
191
|
|
|
192
192
|
`superspec install` 会创建缺失的 `openspec/config.yaml`,或在没有顶层 `context` 时追加这段官方中文 context。如果文件已经有顶层 `context`,SuperSpec 不会覆盖它。
|
|
193
193
|
|
|
194
|
+
它还会创建项目级 `.superspec/config.json`,用于统一设置整个工作流的默认档位:
|
|
195
|
+
|
|
196
|
+
```json
|
|
197
|
+
{
|
|
198
|
+
"workflow": {
|
|
199
|
+
"mode": "normal"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
可选值为 `minimal`、`normal`、`strict`。`next`、Explore、Propose、进入 Apply 和 Review 都读取此配置;`--risk` 不再是用户可用的工作流入口。已启动的 task attempt 和已经建立的审查策略仍按其事件快照执行,不会被中途改配置追溯改写。
|
|
205
|
+
|
|
194
206
|
可以用下面的命令检查生成的 instructions 是否包含语言上下文:
|
|
195
207
|
|
|
196
208
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,7 @@ import { recordTestRun, recordTestRunContent } from "./task.js";
|
|
|
13
13
|
import { RecordInputDecodingError, decodeRecordInput } from "./record_input.js";
|
|
14
14
|
import { probeOpenSpec, openspecStatus, changeRoot } from "./openspec.js";
|
|
15
15
|
import { SUPERSPEC_VERSION } from "./version.js";
|
|
16
|
+
import { WorkflowConfigError, workflowRiskForProject } from "./workflow_config.js";
|
|
16
17
|
const PACKAGE_NAME = "@peterxiaoyang/superspec";
|
|
17
18
|
const OPENSPEC_PACKAGE_NAME = "@fission-ai/openspec";
|
|
18
19
|
const OPENSPEC_REQUIRED_VERSION = "1.4.1";
|
|
@@ -67,19 +68,15 @@ function proposeReadyExitCode(result) {
|
|
|
67
68
|
return 0;
|
|
68
69
|
return result.events_written === 0 && result.message.includes("不能") ? 1 : 0;
|
|
69
70
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const risk = parseReviewRisk(opts.risk);
|
|
79
|
-
if (!risk) {
|
|
80
|
-
console.error("--risk 只能是 minimal、normal 或 strict");
|
|
71
|
+
function ensureWorkflowMode(projectRoot, _opts) {
|
|
72
|
+
try {
|
|
73
|
+
workflowRiskForProject(projectRoot);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
console.error(err instanceof WorkflowConfigError ? err.message : String(err));
|
|
78
|
+
return false;
|
|
81
79
|
}
|
|
82
|
-
return risk;
|
|
83
80
|
}
|
|
84
81
|
function parseVersion(version) {
|
|
85
82
|
const match = version.trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
@@ -516,6 +513,7 @@ async function main(argv) {
|
|
|
516
513
|
transition 子命令:
|
|
517
514
|
init / explore / sync / next / propose-ready / start-apply
|
|
518
515
|
task-start --task <T> / task-complete --task <T> [--input -]
|
|
516
|
+
reopen --to explore|propose|apply --reason <TEXT>
|
|
519
517
|
reopen --to apply --reason <TEXT> [--review-fix <JOB#FINDING>]
|
|
520
518
|
reopen --to propose --reason <TEXT> [--review-finding <JOB#FINDING>]
|
|
521
519
|
review-ready / accept
|
|
@@ -640,16 +638,20 @@ jobs 子命令:
|
|
|
640
638
|
}
|
|
641
639
|
case "transition": {
|
|
642
640
|
const cr = changeRoot(projectRoot, change);
|
|
641
|
+
// mode 只能由项目配置和已冻结 round 快照决定;任何 transition 都不接受 --risk。
|
|
642
|
+
if (opts.risk !== undefined) {
|
|
643
|
+
console.error("工作流模式由 .superspec/config.json 的 workflow.mode 控制,不支持 --risk");
|
|
644
|
+
return 1;
|
|
645
|
+
}
|
|
643
646
|
switch (subcommand) {
|
|
644
647
|
case "init":
|
|
645
648
|
console.log(JSON.stringify(transitionInit(projectRoot, change, cr), null, 2));
|
|
646
649
|
return 0;
|
|
647
650
|
case "explore":
|
|
648
651
|
{
|
|
649
|
-
|
|
650
|
-
if (!risk)
|
|
652
|
+
if (!ensureWorkflowMode(projectRoot, opts))
|
|
651
653
|
return 1;
|
|
652
|
-
console.log(JSON.stringify(transitionExplore(projectRoot, change, cr
|
|
654
|
+
console.log(JSON.stringify(transitionExplore(projectRoot, change, cr), null, 2));
|
|
653
655
|
}
|
|
654
656
|
return 0;
|
|
655
657
|
case "sync": {
|
|
@@ -664,18 +666,16 @@ jobs 子命令:
|
|
|
664
666
|
return 0;
|
|
665
667
|
}
|
|
666
668
|
case "next": {
|
|
667
|
-
|
|
668
|
-
if (!risk)
|
|
669
|
+
if (!ensureWorkflowMode(projectRoot, opts))
|
|
669
670
|
return 1;
|
|
670
|
-
const result = nextCmd(projectRoot, change, cr
|
|
671
|
+
const result = nextCmd(projectRoot, change, cr);
|
|
671
672
|
console.log(JSON.stringify(result, null, 2));
|
|
672
673
|
return 0;
|
|
673
674
|
}
|
|
674
675
|
case "propose-ready": {
|
|
675
|
-
|
|
676
|
-
if (!risk)
|
|
676
|
+
if (!ensureWorkflowMode(projectRoot, opts))
|
|
677
677
|
return 1;
|
|
678
|
-
const result = proposeReady(projectRoot, change, cr
|
|
678
|
+
const result = proposeReady(projectRoot, change, cr);
|
|
679
679
|
console.log(JSON.stringify(result, null, 2));
|
|
680
680
|
return proposeReadyExitCode(result);
|
|
681
681
|
}
|
|
@@ -740,10 +740,9 @@ jobs 子命令:
|
|
|
740
740
|
return transitionExitCode(result);
|
|
741
741
|
}
|
|
742
742
|
case "review-ready": {
|
|
743
|
-
|
|
744
|
-
if (!risk)
|
|
743
|
+
if (!ensureWorkflowMode(projectRoot, opts))
|
|
745
744
|
return 1;
|
|
746
|
-
const result = reviewReady(projectRoot, change, cr
|
|
745
|
+
const result = reviewReady(projectRoot, change, cr);
|
|
747
746
|
console.log(JSON.stringify(result, null, 2));
|
|
748
747
|
return transitionExitCode(result);
|
|
749
748
|
}
|
package/dist/code_review.js
CHANGED
|
@@ -348,7 +348,9 @@ function changedPathsBetweenSnapshots(projectRoot, start, completed) {
|
|
|
348
348
|
return { paths: [...changed].sort(), partial_reason: partialReason };
|
|
349
349
|
}
|
|
350
350
|
function testEvidenceForAttempt(events, attempt) {
|
|
351
|
-
const declaredTests = attempt.contract_mode === true
|
|
351
|
+
const declaredTests = attempt.contract_mode === true
|
|
352
|
+
? attempt.required_evidence?.test_ids ?? attempt.contract?.tests ?? []
|
|
353
|
+
: [];
|
|
352
354
|
const eventsByTest = new Map();
|
|
353
355
|
for (const ev of events) {
|
|
354
356
|
if (ev.event_type !== "test_run_recorded")
|
|
@@ -411,14 +413,17 @@ function taskExecutionIndexFromEvents(projectRoot, events) {
|
|
|
411
413
|
const started = attempts.get(payload.attempt_id);
|
|
412
414
|
const attempt = started?.attempt;
|
|
413
415
|
const effectiveContract = attempt?.contract_mode === true ? attempt.contract ?? null : null;
|
|
416
|
+
const requiredEvidence = attempt?.required_evidence ?? null;
|
|
414
417
|
const changedResult = changedPathsBetweenSnapshots(projectRoot, started?.boundary ?? null, boundaryFromPayload(ev.payload));
|
|
415
418
|
entries.push({
|
|
416
419
|
task_id: payload.task_id,
|
|
417
420
|
attempt_id: payload.attempt_id,
|
|
421
|
+
execution_policy: attempt?.execution_policy ?? "tdd",
|
|
418
422
|
changed_paths: changedResult ? changedResult.paths : null,
|
|
419
423
|
...(changedResult?.partial_reason ? { changed_paths_partial_reason: changedResult.partial_reason } : {}),
|
|
420
424
|
contract: effectiveContract,
|
|
421
|
-
|
|
425
|
+
required_evidence: requiredEvidence,
|
|
426
|
+
declared_tests: requiredEvidence?.test_ids ?? effectiveContract?.tests ?? [],
|
|
422
427
|
scope_note: payload.scope_note && typeof payload.scope_note === "object" && !Array.isArray(payload.scope_note)
|
|
423
428
|
? payload.scope_note
|
|
424
429
|
: null,
|
package/dist/format.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ExecutionContract, type ExecutionPolicy } from "./types.ts";
|
|
2
2
|
/** 从 discovery.md 提取"待确认问题"段内的未确认项数量 */
|
|
3
3
|
export declare function countDiscoveryOpenQuestions(content: string): number;
|
|
4
4
|
export interface DiscoveryChainCoverageCheck {
|
|
@@ -35,6 +35,8 @@ export interface ParsedExecutionRequirement {
|
|
|
35
35
|
taskId: string;
|
|
36
36
|
lineIdx: number;
|
|
37
37
|
contract: ExecutionContract;
|
|
38
|
+
/** 执行依据中实际出现的字段;用于区分“测试为空”和“遗漏测试字段”。 */
|
|
39
|
+
declaredFields: Array<keyof ExecutionContract>;
|
|
38
40
|
errors: string[];
|
|
39
41
|
}
|
|
40
42
|
export interface TestContractEntry {
|
|
@@ -51,6 +53,11 @@ export type TestContractParseResult = {
|
|
|
51
53
|
};
|
|
52
54
|
/** 解析 tasks.md 的全部任务行 */
|
|
53
55
|
export declare function parseTasksMd(content: string): ParsedTask[];
|
|
56
|
+
/**
|
|
57
|
+
* tasks.md 的机械结构校验。任务是否拆分合理、顺序是否符合真实依赖仍由
|
|
58
|
+
* Critic/Architect 判断;这里仅拒绝引擎无法可靠驱动的格式。
|
|
59
|
+
*/
|
|
60
|
+
export declare function validateTasksDocument(content: string): string[];
|
|
54
61
|
export declare function hasTaskBoundExecutionRequirements(content: string): boolean;
|
|
55
62
|
export declare function parseExecutionRequirements(content: string): ParsedExecutionRequirement[];
|
|
56
63
|
export declare function orphanExecutionRequirementErrors(content: string): string[];
|
|
@@ -62,13 +69,24 @@ export declare function adoptedContractForTask(content: string, taskId: string,
|
|
|
62
69
|
export declare function isReviewFixTaskId(taskId: string): boolean;
|
|
63
70
|
export declare function isCharacterizationTask(task: ParsedTask): boolean;
|
|
64
71
|
export declare function parseTestContractEntries(content: string): TestContractParseResult;
|
|
72
|
+
export interface ProposalImpactValidation {
|
|
73
|
+
ok: boolean;
|
|
74
|
+
message: string;
|
|
75
|
+
}
|
|
76
|
+
/** OpenSpec 项目的 proposal 采用固定 Impact 表格,供状态机进行纯结构校验。 */
|
|
77
|
+
export declare function validateProposalImpact(content: string): ProposalImpactValidation;
|
|
65
78
|
export interface ExecutionRequirementValidation {
|
|
66
79
|
ok: boolean;
|
|
67
80
|
mode: boolean;
|
|
68
81
|
contracts: ParsedExecutionRequirement[];
|
|
69
82
|
errors: string[];
|
|
70
83
|
}
|
|
71
|
-
|
|
84
|
+
/**
|
|
85
|
+
* 在已初始化的当前工作流中,把执行依据的文件/锚点可解析性作为状态机协议。
|
|
86
|
+
* “该材料是否足以支撑 task”仍然是 Critic/Architect 的语义判断。
|
|
87
|
+
*/
|
|
88
|
+
export declare function validateExecutionRequirementDocumentReferences(changeRoot: string, contracts: readonly ParsedExecutionRequirement[]): string[];
|
|
89
|
+
export declare function validateExecutionRequirements(content: string, testContractContent: string | null, executionPolicy?: ExecutionPolicy, executionRequirementVersion?: 1 | 2): ExecutionRequirementValidation;
|
|
72
90
|
/** 返回未完成任务 */
|
|
73
91
|
export declare function pendingTasksInContent(content: string): ParsedTask[];
|
|
74
92
|
/** 在 tasks.md 中按 taskId 精确查找任务(词边界,不误判子串) */
|
package/dist/format.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// SuperSpec 流程引擎 — format.ts:文档格式解析的唯一权威源
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
3
|
+
// 所有可机械判定的文档协议、格式定义和解析逻辑都在这里。skills 只指导
|
|
4
|
+
// 生成与语义判断,不得自行充当格式校验器或在其它地方重复解析。
|
|
5
|
+
import { readFileSync, existsSync, realpathSync } from "node:fs";
|
|
6
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { GREEN_ONLY_NO_TDD_REASON } from "./types.js";
|
|
7
8
|
// ===== discovery.md =====
|
|
8
9
|
//
|
|
9
10
|
// 格式(explore skill 定义):
|
|
@@ -51,6 +52,7 @@ const DISCOVERY_CHAIN_REQUIRED_COLUMNS = [
|
|
|
51
52
|
"证据",
|
|
52
53
|
"状态",
|
|
53
54
|
];
|
|
55
|
+
const DISCOVERY_CHAIN_STATUSES = new Set(["已确认", "未知阻塞", "未知非阻塞"]);
|
|
54
56
|
export function splitMarkdownTableRow(line) {
|
|
55
57
|
const trimmed = line.trim();
|
|
56
58
|
if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
|
|
@@ -94,6 +96,9 @@ export function validateDiscoveryChainCoverage(content) {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
const status = cells[statusIdx]?.trim() ?? "";
|
|
99
|
+
if (!DISCOVERY_CHAIN_STATUSES.has(status)) {
|
|
100
|
+
return { ok: false, message: `链路五要素第 ${rowNum} 行状态必须是 已确认、未知阻塞 或 未知非阻塞`, present: true };
|
|
101
|
+
}
|
|
97
102
|
if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
|
|
98
103
|
return { ok: false, message: "链路五要素存在未知阻塞,但待确认问题中没有未解决项", present: true };
|
|
99
104
|
}
|
|
@@ -151,8 +156,11 @@ const CONTRACT_FIELD_ALIASES = {
|
|
|
151
156
|
"Design": "design",
|
|
152
157
|
"来源": "source",
|
|
153
158
|
"Source": "source",
|
|
154
|
-
|
|
155
|
-
"
|
|
159
|
+
// 原因是历史文档字段;新文档用验收,二者都归一为 acceptance。
|
|
160
|
+
"验收": "acceptance",
|
|
161
|
+
"Acceptance": "acceptance",
|
|
162
|
+
"原因": "acceptance",
|
|
163
|
+
"Reason": "acceptance",
|
|
156
164
|
"边界": "guard",
|
|
157
165
|
"Guard": "guard",
|
|
158
166
|
};
|
|
@@ -179,6 +187,28 @@ export function parseTasksMd(content) {
|
|
|
179
187
|
}
|
|
180
188
|
return tasks;
|
|
181
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* tasks.md 的机械结构校验。任务是否拆分合理、顺序是否符合真实依赖仍由
|
|
192
|
+
* Critic/Architect 判断;这里仅拒绝引擎无法可靠驱动的格式。
|
|
193
|
+
*/
|
|
194
|
+
export function validateTasksDocument(content) {
|
|
195
|
+
const errors = [];
|
|
196
|
+
if (!/^#\s+Tasks\s*$/m.test(content))
|
|
197
|
+
errors.push("tasks.md 缺少顶级 # Tasks 标题");
|
|
198
|
+
const tasks = parseTasksMd(content);
|
|
199
|
+
const seen = new Set();
|
|
200
|
+
for (const task of tasks) {
|
|
201
|
+
if (seen.has(task.taskId))
|
|
202
|
+
errors.push(`tasks.md task ID 重复:${task.taskId}`);
|
|
203
|
+
seen.add(task.taskId);
|
|
204
|
+
}
|
|
205
|
+
for (const [index, line] of content.split("\n").entries()) {
|
|
206
|
+
if (/^\s+-\s+\[[ xX]\]\s+/.test(line)) {
|
|
207
|
+
errors.push(`tasks.md 第 ${index + 1} 行存在缩进 checkbox;只有顶格 checkbox 可以作为可执行 task`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return errors;
|
|
211
|
+
}
|
|
182
212
|
function isTopLevelTaskLine(line) {
|
|
183
213
|
return TASK_LINE_RE.test(line);
|
|
184
214
|
}
|
|
@@ -197,7 +227,7 @@ function emptyContract() {
|
|
|
197
227
|
tests: [],
|
|
198
228
|
design: null,
|
|
199
229
|
source: [],
|
|
200
|
-
|
|
230
|
+
acceptance: null,
|
|
201
231
|
guard: null,
|
|
202
232
|
};
|
|
203
233
|
}
|
|
@@ -224,7 +254,7 @@ function parseExecutionRequirementBlock(lines, task, headerIdx) {
|
|
|
224
254
|
if (/^\s*-\s+\[[ xX]\]/.test(line)) {
|
|
225
255
|
errors.push(`${task.taskId} 的执行依据内不能包含复选框`);
|
|
226
256
|
}
|
|
227
|
-
const fieldMatch = line.match(/^\s*-\s*(测试|Tests|设计|Design|来源|Source|原因|Reason|边界|Guard)\s*[::]\s*(.*)$/);
|
|
257
|
+
const fieldMatch = line.match(/^\s*-\s*(测试|Tests|设计|Design|来源|Source|验收|Acceptance|原因|Reason|边界|Guard)\s*[::]\s*(.*)$/);
|
|
228
258
|
if (fieldMatch) {
|
|
229
259
|
const key = CONTRACT_FIELD_ALIASES[fieldMatch[1]];
|
|
230
260
|
const value = fieldMatch[2].trim();
|
|
@@ -247,7 +277,7 @@ function parseExecutionRequirementBlock(lines, task, headerIdx) {
|
|
|
247
277
|
}
|
|
248
278
|
index += 1;
|
|
249
279
|
}
|
|
250
|
-
return { taskId: task.taskId, lineIdx: task.lineIdx, contract, errors };
|
|
280
|
+
return { taskId: task.taskId, lineIdx: task.lineIdx, contract, declaredFields: [...seen], errors };
|
|
251
281
|
}
|
|
252
282
|
export function hasTaskBoundExecutionRequirements(content) {
|
|
253
283
|
const lines = content.split("\n");
|
|
@@ -325,15 +355,23 @@ export function parseTestContractEntries(content) {
|
|
|
325
355
|
if (row.length === 0)
|
|
326
356
|
break;
|
|
327
357
|
const testId = (row[testIdIdx] ?? "").trim();
|
|
328
|
-
|
|
329
|
-
|
|
358
|
+
const scenario = (row[scenarioIdx] ?? "").trim();
|
|
359
|
+
if (!testId) {
|
|
360
|
+
return { ok: false, entries: [], message: `test-contract.md 第 ${rowIndex + 1} 行缺少 test_id` };
|
|
361
|
+
}
|
|
362
|
+
if (!/^TEST-[A-Za-z0-9_-]+$/.test(testId)) {
|
|
363
|
+
return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 格式无效:${testId}` };
|
|
364
|
+
}
|
|
330
365
|
if (seen.has(testId)) {
|
|
331
366
|
return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 重复:${testId}` };
|
|
332
367
|
}
|
|
368
|
+
if (!scenario) {
|
|
369
|
+
return { ok: false, entries: [], message: `test-contract.md 中 ${testId} 缺少 scenario` };
|
|
370
|
+
}
|
|
333
371
|
seen.add(testId);
|
|
334
372
|
entries.push({
|
|
335
373
|
test_id: testId,
|
|
336
|
-
scenario
|
|
374
|
+
scenario,
|
|
337
375
|
});
|
|
338
376
|
}
|
|
339
377
|
}
|
|
@@ -343,28 +381,180 @@ export function parseTestContractEntries(content) {
|
|
|
343
381
|
return { ok: false, entries: [], message: "test-contract.md 没有 TEST-* 行" };
|
|
344
382
|
return { ok: true, entries };
|
|
345
383
|
}
|
|
346
|
-
|
|
347
|
-
|
|
384
|
+
/** OpenSpec 项目的 proposal 采用固定 Impact 表格,供状态机进行纯结构校验。 */
|
|
385
|
+
export function validateProposalImpact(content) {
|
|
386
|
+
const body = sectionBodyByHeadings(content, ["Impact"]);
|
|
387
|
+
if (body == null)
|
|
388
|
+
return { ok: false, message: "proposal.md 缺少 ## Impact" };
|
|
389
|
+
const tableLines = body.split("\n").filter(line => line.trim().startsWith("|"));
|
|
390
|
+
if (tableLines.length < 3 || !isMarkdownTableSeparator(tableLines[1])) {
|
|
391
|
+
return { ok: false, message: "proposal.md 的 Impact 必须包含 Area / Reason 表格" };
|
|
392
|
+
}
|
|
393
|
+
const header = splitMarkdownTableRow(tableLines[0]).map(cell => cell.toLowerCase());
|
|
394
|
+
const areaIdx = header.indexOf("area");
|
|
395
|
+
const reasonIdx = header.indexOf("reason");
|
|
396
|
+
if (areaIdx < 0 || reasonIdx < 0) {
|
|
397
|
+
return { ok: false, message: "proposal.md 的 Impact 表格缺少 Area 或 Reason 列" };
|
|
398
|
+
}
|
|
399
|
+
const rows = tableLines.slice(2).map(splitMarkdownTableRow).filter(cells => cells.length > 0);
|
|
400
|
+
if (rows.length === 0)
|
|
401
|
+
return { ok: false, message: "proposal.md 的 Impact 表格至少需要一行" };
|
|
402
|
+
for (const [index, row] of rows.entries()) {
|
|
403
|
+
if (!row[areaIdx]?.trim() || !row[reasonIdx]?.trim()) {
|
|
404
|
+
return { ok: false, message: `proposal.md 的 Impact 第 ${index + 1} 行缺少 Area 或 Reason` };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { ok: true, message: "proposal.md Impact 结构有效" };
|
|
408
|
+
}
|
|
409
|
+
function isQualifiedDocumentRef(value) {
|
|
410
|
+
const ref = value.trim();
|
|
411
|
+
return /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[^\s#]+\.md#[^\s].*$/.test(ref);
|
|
412
|
+
}
|
|
413
|
+
function executionRequirementReferenceErrors(contract) {
|
|
414
|
+
const errors = [];
|
|
415
|
+
if (contract.contract.design && !isQualifiedDocumentRef(contract.contract.design)) {
|
|
416
|
+
errors.push(`${contract.taskId} 的设计必须使用 文件.md#标题 的可定位引用`);
|
|
417
|
+
}
|
|
418
|
+
for (const source of contract.contract.source) {
|
|
419
|
+
if (!isQualifiedDocumentRef(source)) {
|
|
420
|
+
errors.push(`${contract.taskId} 的来源必须使用 文件.md#标题 的可定位引用:${source}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return errors;
|
|
424
|
+
}
|
|
425
|
+
function parseQualifiedDocumentRef(value) {
|
|
426
|
+
const ref = value.trim();
|
|
427
|
+
const separator = ref.indexOf("#");
|
|
428
|
+
if (separator <= 0 || separator === ref.length - 1)
|
|
429
|
+
return null;
|
|
430
|
+
return { path: ref.slice(0, separator), anchor: ref.slice(separator + 1).trim() };
|
|
431
|
+
}
|
|
432
|
+
function isPathInside(root, target) {
|
|
433
|
+
const rel = relative(root, target);
|
|
434
|
+
return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
435
|
+
}
|
|
436
|
+
function documentContainsAnchor(content, anchor) {
|
|
437
|
+
if (/^(?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+$/.test(anchor)) {
|
|
438
|
+
const token = new RegExp(`(?:^|[^A-Za-z0-9_-])${escapeRegex(anchor)}(?![A-Za-z0-9_-])`);
|
|
439
|
+
return token.test(content);
|
|
440
|
+
}
|
|
441
|
+
// 支持 Markdown ATX 标题可选的 closing sequence(`## Route ##`),但正文
|
|
442
|
+
// 中同名文字仍不能冒充可定位锚点。
|
|
443
|
+
const heading = new RegExp(`^#{1,6}[\\t ]+${escapeRegex(anchor)}(?:[\\t ]+#+)?[\\t ]*$`, "m");
|
|
444
|
+
return heading.test(content);
|
|
445
|
+
}
|
|
446
|
+
function documentAnchorParts(anchor) {
|
|
447
|
+
const parts = anchor.split(",").map(part => part.trim()).filter(Boolean);
|
|
448
|
+
return parts.length > 1 && parts.every(part => /^(?:TEST|CHAIN|IDC)-[A-Za-z0-9_-]+$/.test(part))
|
|
449
|
+
? parts
|
|
450
|
+
: [anchor];
|
|
451
|
+
}
|
|
452
|
+
function canonicalDocumentRefPath(path) {
|
|
453
|
+
if (path === "discovery.md" || path === "test-contract.md") {
|
|
454
|
+
return join(".superspec", "artifacts", path);
|
|
455
|
+
}
|
|
456
|
+
return path;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* 在已初始化的当前工作流中,把执行依据的文件/锚点可解析性作为状态机协议。
|
|
460
|
+
* “该材料是否足以支撑 task”仍然是 Critic/Architect 的语义判断。
|
|
461
|
+
*/
|
|
462
|
+
export function validateExecutionRequirementDocumentReferences(changeRoot, contracts) {
|
|
463
|
+
const root = resolve(changeRoot);
|
|
464
|
+
const realRoot = realpathSync(root);
|
|
465
|
+
const errors = [];
|
|
466
|
+
for (const contract of contracts) {
|
|
467
|
+
const refs = [contract.contract.design, ...contract.contract.source].filter((value) => Boolean(value));
|
|
468
|
+
for (const ref of refs) {
|
|
469
|
+
const parsed = parseQualifiedDocumentRef(ref);
|
|
470
|
+
if (!parsed)
|
|
471
|
+
continue; // 语法错误由 executionRequirementReferenceErrors 报告。
|
|
472
|
+
const target = resolve(root, canonicalDocumentRefPath(parsed.path));
|
|
473
|
+
if (!isPathInside(root, target)) {
|
|
474
|
+
errors.push(`${contract.taskId} 的引用越出 change 目录:${ref}`);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (!existsSync(target)) {
|
|
478
|
+
errors.push(`${contract.taskId} 的引用文件不存在:${parsed.path}`);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
// resolve/relative 只能识别字面 `..`,不能阻止 change 内的符号链接指向
|
|
482
|
+
// 外部文件;按真实路径再次校验,确保引用材料仍属于当前 change。
|
|
483
|
+
let realTarget;
|
|
484
|
+
try {
|
|
485
|
+
realTarget = realpathSync(target);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
errors.push(`${contract.taskId} 的引用文件无法解析:${parsed.path}`);
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (!isPathInside(realRoot, realTarget)) {
|
|
492
|
+
errors.push(`${contract.taskId} 的引用越出 change 目录:${ref}`);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const targetContent = readFileSync(target, "utf8");
|
|
496
|
+
for (const anchor of documentAnchorParts(parsed.anchor)) {
|
|
497
|
+
if (!documentContainsAnchor(targetContent, anchor)) {
|
|
498
|
+
errors.push(`${contract.taskId} 的引用锚点不存在:${parsed.path}#${anchor}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return errors;
|
|
504
|
+
}
|
|
505
|
+
export function validateExecutionRequirements(content, testContractContent, executionPolicy = "tdd", executionRequirementVersion = 2) {
|
|
506
|
+
const tasks = parseTasksMd(content);
|
|
507
|
+
// 历史 green-only task 在旧版本中用标记强制进入契约模式。保留该入口,
|
|
508
|
+
// 防止已生成但尚未执行的 change 在升级后静默退回无验证的 legacy 模式;
|
|
509
|
+
// 新 Propose 不再产生此标记。
|
|
510
|
+
const hasLegacyGreenOnlyTask = tasks.some(task => task.noTddReason === GREEN_ONLY_NO_TDD_REASON);
|
|
511
|
+
// v2 不允许“所有任务都没有执行依据”这一静默回退:新 Propose 的每个普通
|
|
512
|
+
// task 都必须显式声明五字段。v1 的缺失版本仍保留旧的按需契约语义。
|
|
513
|
+
const hasV2OrdinaryTask = executionRequirementVersion === 2 && tasks.some(task => !isReviewFixTaskId(task.taskId));
|
|
514
|
+
const mode = hasTaskBoundExecutionRequirements(content) || hasLegacyGreenOnlyTask || hasV2OrdinaryTask;
|
|
348
515
|
const contracts = parseExecutionRequirements(content);
|
|
349
516
|
// 孤儿检测必须在 mode=false 的 early return 之前:全部块都悬空时 mode=false,
|
|
350
517
|
// 恰恰是最需要报错的场景(否则契约模式静默失效)
|
|
351
518
|
const errors = [...orphanExecutionRequirementErrors(content), ...contracts.flatMap(item => item.errors)];
|
|
352
519
|
if (!mode)
|
|
353
520
|
return { ok: errors.length === 0, mode, contracts, errors };
|
|
354
|
-
const tasks = parseTasksMd(content);
|
|
355
521
|
const contractsByTask = new Map(contracts.map(item => [item.taskId, item]));
|
|
356
522
|
const declaredTestIds = new Set();
|
|
357
523
|
let needsTestContract = false;
|
|
358
524
|
for (const task of tasks) {
|
|
359
525
|
const contract = contractsByTask.get(task.taskId);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
526
|
+
const legacyGreenOnly = task.noTddReason === GREEN_ONLY_NO_TDD_REASON;
|
|
527
|
+
if (legacyGreenOnly && task.tddRequired) {
|
|
528
|
+
errors.push(`${task.taskId} 的 no_tdd_reason=${GREEN_ONLY_NO_TDD_REASON} 必须同时声明 tdd_required:false`);
|
|
529
|
+
}
|
|
530
|
+
if (legacyGreenOnly && executionPolicy !== "green_only") {
|
|
531
|
+
errors.push(`${task.taskId} 的 no_tdd_reason=${GREEN_ONLY_NO_TDD_REASON} 只允许用于 GREEN-only apply`);
|
|
532
|
+
}
|
|
533
|
+
// v2 是本次改造后的新计划:每个普通 task 必须声明完整五字段,
|
|
534
|
+
// `测试:` 可显式为空以表达非行为任务。旧计划只保持原 TDD 契约要求。
|
|
535
|
+
if (executionRequirementVersion === 2 && !isReviewFixTaskId(task.taskId) && !contract) {
|
|
536
|
+
errors.push(`${task.taskId} 缺少执行依据`);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (executionRequirementVersion === 1 && task.tddRequired && !isReviewFixTaskId(task.taskId) && !contract) {
|
|
540
|
+
errors.push(`${task.taskId} 缺少执行依据`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (contract && executionRequirementVersion === 2) {
|
|
544
|
+
if (!contract.declaredFields.includes("tests"))
|
|
545
|
+
errors.push(`${task.taskId} 的执行依据缺少测试字段`);
|
|
546
|
+
if (!contract.contract.design)
|
|
547
|
+
errors.push(`${task.taskId} 的执行依据缺少设计`);
|
|
548
|
+
if (contract.contract.source.length === 0)
|
|
549
|
+
errors.push(`${task.taskId} 的执行依据缺少来源`);
|
|
550
|
+
if (!contract.contract.acceptance)
|
|
551
|
+
errors.push(`${task.taskId} 的执行依据缺少验收目标`);
|
|
552
|
+
if (!contract.contract.guard)
|
|
553
|
+
errors.push(`${task.taskId} 的执行依据缺少边界`);
|
|
554
|
+
errors.push(...executionRequirementReferenceErrors(contract));
|
|
555
|
+
}
|
|
556
|
+
if (contract && (executionRequirementVersion === 1 && task.tddRequired || legacyGreenOnly) && contract.contract.tests.length === 0) {
|
|
557
|
+
errors.push(`${task.taskId} 的执行依据缺少测试`);
|
|
368
558
|
}
|
|
369
559
|
if (contract && contract.contract.tests.length > 0) {
|
|
370
560
|
needsTestContract = true;
|
|
@@ -450,11 +640,12 @@ export function validateUserDecision(d) {
|
|
|
450
640
|
}
|
|
451
641
|
// ===== test-contract.md =====
|
|
452
642
|
//
|
|
453
|
-
//
|
|
643
|
+
// 格式(状态机校验,propose skill 负责生成):
|
|
454
644
|
// # Test Contract
|
|
455
645
|
// | test_id | scenario |
|
|
456
646
|
// |---|---|
|
|
457
647
|
// | TEST-001 | 注册时密码被加密 |
|
|
458
648
|
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
649
|
+
// 引擎行为:当 task 声明 TEST 时,状态机解析表格、TEST ID 和 scenario,
|
|
650
|
+
// 并在 propose-ready / start-apply 阶段拒绝无效引用;测试语义和证明力仍由
|
|
651
|
+
// Test Engineer 判断。
|
package/dist/git_state.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import type { DirtyFileFingerprint } from "./types.ts";
|
|
1
|
+
import type { BoundarySnapshot, DirtyFileFingerprint } from "./types.ts";
|
|
2
2
|
export interface GitHeadResult {
|
|
3
3
|
head: string | null;
|
|
4
4
|
reason: string;
|
|
5
5
|
}
|
|
6
|
+
export interface JavaAutoStageResult {
|
|
7
|
+
status: "staged" | "skipped" | "failed";
|
|
8
|
+
files: string[];
|
|
9
|
+
reason?: string;
|
|
10
|
+
}
|
|
6
11
|
export declare function currentGitHead(projectRoot: string): GitHeadResult;
|
|
7
12
|
export declare function normalizeGitPath(rawPath: string): string;
|
|
8
13
|
export declare function gitLines(projectRoot: string, args: string[]): {
|
|
@@ -33,4 +38,10 @@ export declare function dirtyCodePaths(projectRoot: string): {
|
|
|
33
38
|
paths: string[];
|
|
34
39
|
reason: string;
|
|
35
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* 仅暂存当前 task 启动后新生成的生产 Java 文件。
|
|
43
|
+
* 不能安全归因的既有文件改动绝不自动 git add:它们可能包含用户或并行任务的未提交内容。
|
|
44
|
+
* 测试源码和常见测试类命名会被排除;没有可用的启动边界或 Git 状态异常时,不影响任务完成。
|
|
45
|
+
*/
|
|
46
|
+
export declare function stageProductionJavaFilesSince(projectRoot: string, before: BoundarySnapshot | null): JavaAutoStageResult;
|
|
36
47
|
export declare function projectHasReadableDirectory(projectRoot: string): boolean;
|
package/dist/git_state.js
CHANGED
|
@@ -169,6 +169,51 @@ export function dirtyCodePaths(projectRoot) {
|
|
|
169
169
|
return { ok: false, paths: [], reason: dirty.reason };
|
|
170
170
|
return { ok: true, paths: [...new Set(dirty.files.map(file => file.path))].sort() };
|
|
171
171
|
}
|
|
172
|
+
function isJavaTestPath(path) {
|
|
173
|
+
const normalized = path.replace(/\\/g, "/");
|
|
174
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
175
|
+
return /(^|\/)src\/test\//i.test(normalized) ||
|
|
176
|
+
/(?:Test|Tests|TestCase|IT|ITCase)\.java$/i.test(base);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* 仅暂存当前 task 启动后新生成的生产 Java 文件。
|
|
180
|
+
* 不能安全归因的既有文件改动绝不自动 git add:它们可能包含用户或并行任务的未提交内容。
|
|
181
|
+
* 测试源码和常见测试类命名会被排除;没有可用的启动边界或 Git 状态异常时,不影响任务完成。
|
|
182
|
+
*/
|
|
183
|
+
export function stageProductionJavaFilesSince(projectRoot, before) {
|
|
184
|
+
if (!before) {
|
|
185
|
+
return { status: "skipped", files: [], reason: "missing_task_start_boundary" };
|
|
186
|
+
}
|
|
187
|
+
const current = dirtyCodeFiles(projectRoot);
|
|
188
|
+
if (!current.ok) {
|
|
189
|
+
return { status: "failed", files: [], reason: current.reason };
|
|
190
|
+
}
|
|
191
|
+
const currentByPath = new Map(current.files.map(file => [file.path, file]));
|
|
192
|
+
const beforeByPath = new Map(before.dirty_files.map(file => [file.path, file]));
|
|
193
|
+
const files = diffFingerprints(before.dirty_files, current.files)
|
|
194
|
+
.filter(path => path.toLowerCase().endsWith(".java"))
|
|
195
|
+
.filter(path => !isJavaTestPath(path))
|
|
196
|
+
// "added" 且 task-start 边界不存在,才是可归因于本任务的新生成文件。
|
|
197
|
+
// 已有未跟踪文件或已有源码的任何修改一律不碰,避免把用户工作带入 index。
|
|
198
|
+
.filter(path => !beforeByPath.has(path) && currentByPath.get(path)?.status === "added")
|
|
199
|
+
.filter(path => currentByPath.get(path)?.status !== "deleted")
|
|
200
|
+
.sort();
|
|
201
|
+
if (files.length === 0)
|
|
202
|
+
return { status: "skipped", files: [], reason: "no_changed_production_java" };
|
|
203
|
+
try {
|
|
204
|
+
execFileSync("git", ["-C", projectRoot, "add", "--", ...files], {
|
|
205
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
206
|
+
});
|
|
207
|
+
return { status: "staged", files };
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
return {
|
|
211
|
+
status: "failed",
|
|
212
|
+
files,
|
|
213
|
+
reason: err instanceof Error ? err.message : "git add failed",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
172
217
|
export function projectHasReadableDirectory(projectRoot) {
|
|
173
218
|
return existsSync(projectRoot) && statSync(projectRoot).isDirectory();
|
|
174
219
|
}
|