@peterxiaoyang/superspec 0.1.55 → 0.1.57
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 +28 -32
- package/dist/approved_ref.d.ts +26 -0
- package/dist/approved_ref.js +159 -0
- package/dist/cli.js +2 -0
- package/dist/code_review.d.ts +7 -0
- package/dist/code_review.js +92 -1
- package/dist/format.d.ts +25 -1
- package/dist/format.js +235 -1
- package/dist/next.js +4 -1
- package/dist/phase_confirmation.js +7 -2
- package/dist/phase_plan.d.ts +6 -1
- package/dist/phase_plan.js +172 -44
- package/dist/propose_round.d.ts +7 -1
- package/dist/propose_round.js +35 -0
- package/dist/record.js +27 -7
- package/dist/transition.js +22 -7
- package/dist/types.d.ts +28 -1
- package/dist/workflow_config.d.ts +15 -0
- package/dist/workflow_config.js +47 -0
- package/package.json +1 -1
- package/templates/workflow/AGENTS.md +2 -0
- package/templates/workflow/agents-md/code-reviewer.md +1 -1
- package/templates/workflow/agents-md/critic.md +1 -1
- package/templates/workflow/prompts/architect.md +1 -0
- package/templates/workflow/prompts/code-reviewer.md +4 -4
- package/templates/workflow/prompts/critic.md +3 -1
- package/templates/workflow/prompts/executor.md +2 -0
- package/templates/workflow/prompts/explore.md +1 -1
- package/templates/workflow/skills/superspec-explore/SKILL.md +16 -12
- package/templates/workflow/skills/superspec-propose/SKILL.md +27 -4
package/README.md
CHANGED
|
@@ -4,15 +4,26 @@
|
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://github.com/Fission-AI/OpenSpec)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
AI 写代码很快。真正慢的是之后:范围悄悄变大、完成全凭一张嘴、review 时问一句「这里为什么要改」它才承认不该改。
|
|
8
|
+
|
|
9
|
+
SuperSpec 是一套跑在 AI 编程代理(Codex 等)下的需求变更工作流引擎。它把「不要乱改、别过度设计、做完要有证据」从 prompt 劝说升级成**机器门禁**——约束写在 CLI 和数据契约里,不靠模型自觉。
|
|
8
10
|
|
|
9
11
|
```text
|
|
10
12
|
Explore → Propose → Apply → Review → Accepted
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## 它拦得住什么
|
|
16
|
+
|
|
17
|
+
| 你遇到的问题 | SuperSpec 的机制 |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| 「顺手」改了不该改的,加了不该加的方法 | 每个任务绑定五字段执行依据:测试 / 设计 / 来源 / 验收 / 边界,引用可解析性由状态机校验;实现越界必须走结构化的范围说明,而不是一句道歉 |
|
|
20
|
+
| 说自己做完了,测过没有无从考证 | 任务启动即冻结证据要求:该 RED 的先失败、该 GREEN 的真通过,测试证据绑定任务尝试;全程事件日志带摘要,可回放、防篡改 |
|
|
21
|
+
| 审查抓到了问题,修复时又把新架构夹带回来 | 阻塞问题必须标注类型(漏做 / 破坏已有 / 计划外新增)并**锚定到已批准材料**才能受理;修复任务的指令只兑现锚点,审查建议里的架构永远不是授权 |
|
|
22
|
+
| 审查形同虚设,「看起来没问题」就通过 | critic / architect / test-engineer / code-reviewer / verifier 独立角色持密封工作项审查,pass 必须说明覆盖了什么,fail 必须给出可追溯的证据 |
|
|
23
|
+
|
|
24
|
+
这些约束全部由引擎在提交口强制执行:答不出锚点的阻塞问题直接拒收,而不是等你在对话里追问。
|
|
14
25
|
|
|
15
|
-
##
|
|
26
|
+
## 快速开始
|
|
16
27
|
|
|
17
28
|
要求 Node.js `>=20.19.0`。
|
|
18
29
|
|
|
@@ -22,18 +33,17 @@ cd <your-project>
|
|
|
22
33
|
superspec install
|
|
23
34
|
```
|
|
24
35
|
|
|
25
|
-
`superspec install` 会把工作流入口、角色配置和运行时目录同步到当前项目,并准备 OpenSpec
|
|
36
|
+
`superspec install` 会把工作流入口、角色配置和运行时目录同步到当前项目,并准备 [OpenSpec](https://github.com/Fission-AI/OpenSpec) 依赖。
|
|
26
37
|
|
|
27
|
-
|
|
38
|
+
然后在 Codex 里对它说话:
|
|
28
39
|
|
|
29
|
-
```
|
|
30
|
-
superspec
|
|
31
|
-
superspec status
|
|
40
|
+
```text
|
|
41
|
+
superspec-explore change <change-name>。<你的需求>
|
|
32
42
|
```
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
工作流接管之后,每一步该做什么由 `superspec transition next` 决定——AI 不自由发挥流程,只在被授权的范围内干活。
|
|
35
45
|
|
|
36
|
-
|
|
46
|
+
## 工作流入口
|
|
37
47
|
|
|
38
48
|
| Skill | 作用 |
|
|
39
49
|
| --- | --- |
|
|
@@ -42,35 +52,21 @@ superspec status
|
|
|
42
52
|
| `superspec-apply` | 按已批准任务修改代码并验证 |
|
|
43
53
|
| `superspec-review` | 审查实现并完成最终验证 |
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
## 配置
|
|
55
|
+
修不动会自己拐弯:能由既有任务解释的问题留在 Apply 内闭环;需要改需求、验收或技术取舍的,回到计划阶段重新确认。
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{
|
|
53
|
-
"workflow": {
|
|
54
|
-
"mode": "normal",
|
|
55
|
-
"hosts": ["codex"]
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
```
|
|
57
|
+
## 轻量,且知道自己的边界
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
- **三档强度**:`minimal` / `normal` / `strict`(`.superspec/config.json`),按变更风险选择计划阶段的审查强度。
|
|
60
|
+
- **不绑架日常**:简单的单文件修改不必走完整工作流;普通请求也不会自动进入 SuperSpec,只有显式调用入口才启动。
|
|
61
|
+
- **诚实的能力边界**:它是流程与审计辅助,不是安全隔离或发布审批系统;不能替代代码审计、权限控制、合规检查和人工判断。
|
|
61
62
|
|
|
62
63
|
## 与 OpenSpec 的关系
|
|
63
64
|
|
|
64
|
-
OpenSpec
|
|
65
|
-
|
|
66
|
-
```text
|
|
67
|
-
OpenSpec:这次要改变什么
|
|
68
|
-
SuperSpec:如何在边界内把它交付
|
|
69
|
-
```
|
|
65
|
+
OpenSpec 负责「这次要改变什么」,提供变更材料与规格结构;SuperSpec 负责「如何在边界内把它交付」,组织 AI 的探索、计划、实现、审查与证据记录。
|
|
70
66
|
|
|
71
|
-
##
|
|
67
|
+
## 评测
|
|
72
68
|
|
|
73
|
-
|
|
69
|
+
仓库自带密封评测考场(隔离工作区 + 真实 Agent 运行 + 硬门禁 + 双模型语义复盘),用于验证工作流行为本身,而不只是 Prompt 文案。见 [`evals/README.md`](evals/README.md)。
|
|
74
70
|
|
|
75
71
|
## 致谢
|
|
76
72
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CodeReviewClaimKind } from "./types.ts";
|
|
2
|
+
export declare const CODE_REVIEW_CLAIM_KINDS: readonly ["missing_approved", "breaks_existing", "unjustified_addition"];
|
|
3
|
+
export type ApprovedRefKind = "test" | "requirement" | "task" | "design" | "proposal" | "structure";
|
|
4
|
+
export interface ResolvedApprovedRef {
|
|
5
|
+
raw: string;
|
|
6
|
+
kind: ApprovedRefKind;
|
|
7
|
+
short: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function isCodeReviewClaimKind(value: unknown): value is CodeReviewClaimKind;
|
|
10
|
+
export declare function shortApprovedRef(raw: string): string;
|
|
11
|
+
export declare function resolveApprovedRef(changeRoot: string, raw: unknown): {
|
|
12
|
+
ok: true;
|
|
13
|
+
value: ResolvedApprovedRef;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: string;
|
|
17
|
+
};
|
|
18
|
+
export declare function resolveApprovedRefs(changeRoot: string, refs: unknown): {
|
|
19
|
+
ok: true;
|
|
20
|
+
values: ResolvedApprovedRef[];
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
reasons: string[];
|
|
24
|
+
};
|
|
25
|
+
export declare function hasBehaviorAnchor(values: readonly ResolvedApprovedRef[]): boolean;
|
|
26
|
+
export declare function reviewFixReason(claimKind: CodeReviewClaimKind, refs: readonly string[]): string;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// SuperSpec 代码审查 approved_refs:只做存在性解析,不做语义匹配。
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { headingExists, parseTasksMd, parseTestContractEntries, parseStructureChangeLedger } from "./format.js";
|
|
5
|
+
export const CODE_REVIEW_CLAIM_KINDS = [
|
|
6
|
+
"missing_approved",
|
|
7
|
+
"breaks_existing",
|
|
8
|
+
"unjustified_addition",
|
|
9
|
+
];
|
|
10
|
+
const TEST_ID_RE = /^TEST-[A-Za-z0-9_-]+$/;
|
|
11
|
+
const STRUCTURE_LEDGER_ID_RE = /^SC-[A-Za-z0-9_-]+$/;
|
|
12
|
+
const TEST_CONTRACT_REL = join(".superspec", "artifacts", "test-contract.md");
|
|
13
|
+
function isPathInside(root, target) {
|
|
14
|
+
const rel = relative(root, target);
|
|
15
|
+
return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
16
|
+
}
|
|
17
|
+
function shortTestId(raw) {
|
|
18
|
+
const trimmed = raw.trim();
|
|
19
|
+
if (TEST_ID_RE.test(trimmed))
|
|
20
|
+
return trimmed;
|
|
21
|
+
const hash = trimmed.lastIndexOf("#");
|
|
22
|
+
if (hash >= 0) {
|
|
23
|
+
const id = trimmed.slice(hash + 1).trim();
|
|
24
|
+
if (TEST_ID_RE.test(id))
|
|
25
|
+
return id;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function readChangeFile(changeRoot, relPath) {
|
|
30
|
+
const target = resolve(changeRoot, relPath);
|
|
31
|
+
if (!isPathInside(resolve(changeRoot), target) && resolve(changeRoot) !== target)
|
|
32
|
+
return null;
|
|
33
|
+
try {
|
|
34
|
+
if (!statSync(target).isFile())
|
|
35
|
+
return null;
|
|
36
|
+
return readFileSync(target, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function isCodeReviewClaimKind(value) {
|
|
43
|
+
return typeof value === "string" && CODE_REVIEW_CLAIM_KINDS.includes(value);
|
|
44
|
+
}
|
|
45
|
+
export function shortApprovedRef(raw) {
|
|
46
|
+
const testId = shortTestId(raw);
|
|
47
|
+
if (testId)
|
|
48
|
+
return testId;
|
|
49
|
+
const req = /#Requirement:\s*(.+)$/.exec(raw.trim());
|
|
50
|
+
if (req)
|
|
51
|
+
return `Requirement: ${req[1].trim()}`;
|
|
52
|
+
const hash = raw.lastIndexOf("#");
|
|
53
|
+
if (hash >= 0 && hash < raw.length - 1)
|
|
54
|
+
return raw.slice(hash + 1).trim();
|
|
55
|
+
return raw.trim();
|
|
56
|
+
}
|
|
57
|
+
export function resolveApprovedRef(changeRoot, raw) {
|
|
58
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
59
|
+
return { ok: false, reason: "approved_refs 条目必须是非空字符串" };
|
|
60
|
+
}
|
|
61
|
+
const ref = raw.trim();
|
|
62
|
+
if (/[\u0000-\u001f\u007f]/.test(ref)) {
|
|
63
|
+
return { ok: false, reason: `approved_refs 条目不能包含换行等控制字符:${JSON.stringify(ref)}` };
|
|
64
|
+
}
|
|
65
|
+
const testId = shortTestId(ref);
|
|
66
|
+
if (testId) {
|
|
67
|
+
if (ref.includes("#") && !ref.endsWith(`#${testId}`)) {
|
|
68
|
+
return { ok: false, reason: `TEST 引用只能是裸 TEST-ID 或以 #TEST-ID 结尾指向 test-contract.md:${ref}` };
|
|
69
|
+
}
|
|
70
|
+
if (ref.includes("#")) {
|
|
71
|
+
const path = ref.slice(0, ref.lastIndexOf("#")).replace(/\\/g, "/");
|
|
72
|
+
const allowed = path === TEST_CONTRACT_REL.replace(/\\/g, "/")
|
|
73
|
+
|| path === "test-contract.md"
|
|
74
|
+
|| path.endsWith("/test-contract.md");
|
|
75
|
+
if (!allowed)
|
|
76
|
+
return { ok: false, reason: `TEST 引用只能指向 test-contract.md:${ref}` };
|
|
77
|
+
}
|
|
78
|
+
const content = readChangeFile(changeRoot, TEST_CONTRACT_REL);
|
|
79
|
+
if (content == null)
|
|
80
|
+
return { ok: false, reason: `无法读取 ${TEST_CONTRACT_REL.replace(/\\/g, "/")},无法校验 TEST 引用:${ref}` };
|
|
81
|
+
const parsed = parseTestContractEntries(content);
|
|
82
|
+
if (!parsed.ok || !parsed.entries.some(entry => entry.test_id === testId)) {
|
|
83
|
+
return { ok: false, reason: `${TEST_CONTRACT_REL.replace(/\\/g, "/")} 中不存在 ${testId}` };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, value: { raw: ref, kind: "test", short: testId } };
|
|
86
|
+
}
|
|
87
|
+
const separator = ref.indexOf("#");
|
|
88
|
+
if (separator <= 0 || separator === ref.length - 1) {
|
|
89
|
+
return { ok: false, reason: `锚点格式无法解析,应为 文件#标题 或 TEST-ID:${ref}` };
|
|
90
|
+
}
|
|
91
|
+
const path = ref.slice(0, separator).replace(/\\/g, "/");
|
|
92
|
+
const anchor = ref.slice(separator + 1).trim();
|
|
93
|
+
const content = readChangeFile(changeRoot, path);
|
|
94
|
+
if (content == null)
|
|
95
|
+
return { ok: false, reason: `${path} 在当前 change 中不存在` };
|
|
96
|
+
if (path === "tasks.md") {
|
|
97
|
+
const tasks = parseTasksMd(content);
|
|
98
|
+
if (!tasks.some(task => task.taskId === anchor))
|
|
99
|
+
return { ok: false, reason: `tasks.md 中不存在任务 ${anchor}` };
|
|
100
|
+
return { ok: true, value: { raw: ref, kind: "task", short: anchor } };
|
|
101
|
+
}
|
|
102
|
+
if (path === "design.md") {
|
|
103
|
+
if (STRUCTURE_LEDGER_ID_RE.test(anchor)) {
|
|
104
|
+
const ledger = parseStructureChangeLedger(content);
|
|
105
|
+
if (ledger.present && ledger.entries.some(entry => entry.id === anchor)) {
|
|
106
|
+
return { ok: true, value: { raw: ref, kind: "structure", short: anchor } };
|
|
107
|
+
}
|
|
108
|
+
return { ok: false, reason: `design.md 结构变更清单中不存在 ${anchor}` };
|
|
109
|
+
}
|
|
110
|
+
if (!headingExists(content, anchor))
|
|
111
|
+
return { ok: false, reason: `design.md 中不存在标题「${anchor}」` };
|
|
112
|
+
return { ok: true, value: { raw: ref, kind: "design", short: anchor } };
|
|
113
|
+
}
|
|
114
|
+
if (path === "proposal.md") {
|
|
115
|
+
if (!headingExists(content, anchor))
|
|
116
|
+
return { ok: false, reason: `proposal.md 中不存在标题「${anchor}」` };
|
|
117
|
+
return { ok: true, value: { raw: ref, kind: "proposal", short: anchor } };
|
|
118
|
+
}
|
|
119
|
+
if (/^specs\/[^/]+\/spec\.md$/.test(path)) {
|
|
120
|
+
const requirementTitle = anchor.startsWith("Requirement:")
|
|
121
|
+
? anchor.slice("Requirement:".length).trim()
|
|
122
|
+
: "";
|
|
123
|
+
if (!requirementTitle)
|
|
124
|
+
return { ok: false, reason: `spec 锚点必须以 Requirement: 开头:${ref}` };
|
|
125
|
+
if (!headingExists(content, `Requirement: ${requirementTitle}`)) {
|
|
126
|
+
return { ok: false, reason: `${path} 中不存在 Requirement「${requirementTitle}」` };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
ok: true,
|
|
130
|
+
value: { raw: ref, kind: "requirement", short: `Requirement: ${requirementTitle}` },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { ok: false, reason: `只支持 tasks.md、design.md、proposal.md、specs/*/spec.md 与 test-contract.md 的锚点:${ref}` };
|
|
134
|
+
}
|
|
135
|
+
export function resolveApprovedRefs(changeRoot, refs) {
|
|
136
|
+
if (!Array.isArray(refs) || refs.length === 0) {
|
|
137
|
+
return { ok: false, reasons: ["approved_refs 必须是非空字符串数组"] };
|
|
138
|
+
}
|
|
139
|
+
const values = [];
|
|
140
|
+
const reasons = [];
|
|
141
|
+
for (const raw of refs) {
|
|
142
|
+
const resolved = resolveApprovedRef(changeRoot, raw);
|
|
143
|
+
if (!resolved.ok)
|
|
144
|
+
reasons.push(resolved.reason);
|
|
145
|
+
else
|
|
146
|
+
values.push(resolved.value);
|
|
147
|
+
}
|
|
148
|
+
if (reasons.length > 0)
|
|
149
|
+
return { ok: false, reasons };
|
|
150
|
+
return { ok: true, values };
|
|
151
|
+
}
|
|
152
|
+
export function hasBehaviorAnchor(values) {
|
|
153
|
+
return values.some(value => value.kind === "test" || value.kind === "requirement");
|
|
154
|
+
}
|
|
155
|
+
export function reviewFixReason(claimKind, refs) {
|
|
156
|
+
const shorts = refs.map(shortApprovedRef).filter(Boolean);
|
|
157
|
+
const target = shorts.length > 0 ? shorts.join("、") : "已批准行为";
|
|
158
|
+
return `兑现 ${target}(${claimKind})`;
|
|
159
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -557,6 +557,8 @@ function topLevelHelp() {
|
|
|
557
557
|
update [--hosts codex,omp] 升级 CLI 到 npm latest 并同步已选宿主入口
|
|
558
558
|
version 版本号
|
|
559
559
|
|
|
560
|
+
工作流配置见 .superspec/config.json(workflow.mode、workflow.budget)。
|
|
561
|
+
|
|
560
562
|
transition 子命令:
|
|
561
563
|
init / explore / sync / next / propose-ready / start-apply
|
|
562
564
|
task-start --task <T> / task-complete --task <T> [--input -]
|
package/dist/code_review.d.ts
CHANGED
|
@@ -69,12 +69,19 @@ export declare function codeReviewPacketDigest(input: {
|
|
|
69
69
|
packet_context?: JobPacketContext;
|
|
70
70
|
previous_rejection?: ReviewPreviousRejection;
|
|
71
71
|
}): string;
|
|
72
|
+
export declare function addedCodePathsForScope(projectRoot: string, scope: CodeReviewScope): string[];
|
|
72
73
|
export declare function codeReviewPacketContext(changeRoot: string, projectRoot: string, scope: CodeReviewScope, events: Event[]): JobPacketContext;
|
|
73
74
|
export declare function effectiveCoverageExemptionRefsFromEvents(events: Event[]): CoverageExemptionRef[];
|
|
74
75
|
export declare function missingCoverageExemptionTestIds(changeRoot: string, events: Event[]): string[];
|
|
75
76
|
/** Read-only execution evidence projected for code review and final verification. */
|
|
76
77
|
export declare function taskExecutionIndexForReview(projectRoot: string, events: Event[]): TaskExecutionIndexEntry[];
|
|
77
78
|
export declare function codeReviewDecisionScope(jobId: string, findingId: string): string;
|
|
79
|
+
/** 自当前 Apply round 的 start-apply 起,带 review_fix_of 的 reopen 次数。 */
|
|
80
|
+
export declare function countReviewFixReopensSinceStartApply(events: readonly Event[]): number;
|
|
81
|
+
/** 上限是否生效按 Apply round 在 start-apply 冻结的档位判断(minimal 忽略),未冻结时回落项目配置。 */
|
|
82
|
+
export declare function isReviewFixCapReached(projectRoot: string, events: readonly Event[]): boolean;
|
|
83
|
+
/** spec / mixed 问题始终交使用者决策;implementation 问题只在自动修复触顶后交使用者决策。 */
|
|
84
|
+
export declare function codeReviewFindingNeedsUserDecision(type: string | undefined, reviewFixCapReached: boolean): boolean;
|
|
78
85
|
export interface CodeReviewDecisionScopeRef {
|
|
79
86
|
jobId: string;
|
|
80
87
|
findingId: string;
|
package/dist/code_review.js
CHANGED
|
@@ -4,7 +4,9 @@ import { join } from "node:path";
|
|
|
4
4
|
import { findLatestEvent, sha256File, sha256Text } from "./store.js";
|
|
5
5
|
import { REVIEW_CODE_REVIEW_GATE } from "./review_job_gates.js";
|
|
6
6
|
import { codeFileContentSha, currentGitHead, diffFingerprints, dirtyCodeFiles, dirtyCodePaths, gitLines, isCodeLikePath, projectHasReadableDirectory, walkCodeFiles, } from "./git_state.js";
|
|
7
|
-
import { parseExecutionRequirements, parseTestContractEntries } from "./format.js";
|
|
7
|
+
import { parseExecutionRequirements, parseTestContractEntries, parseStructureChangeLedger } from "./format.js";
|
|
8
|
+
import { planningValidationProfileForCurrentRound } from "./propose_round.js";
|
|
9
|
+
import { workflowBudgetForRisk, workflowRiskForApplyRound, workflowRiskForProject } from "./workflow_config.js";
|
|
8
10
|
export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
|
|
9
11
|
export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
|
|
10
12
|
export const TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX = "test_coverage_exemption:";
|
|
@@ -360,6 +362,51 @@ export function codeReviewJobStaleReason(projectRoot, job, currentPaths, events,
|
|
|
360
362
|
export function codeReviewPacketDigest(input) {
|
|
361
363
|
return sha256Text(JSON.stringify(input));
|
|
362
364
|
}
|
|
365
|
+
export function addedCodePathsForScope(projectRoot, scope) {
|
|
366
|
+
if (!scope.scope_reliable)
|
|
367
|
+
return [];
|
|
368
|
+
const added = new Set();
|
|
369
|
+
let baseHead = scope.base_head;
|
|
370
|
+
if (!baseHead && scope.current_head) {
|
|
371
|
+
// 首轮 start-apply 前仓库还没有提交:以空树为基点,让 Apply 期间产生的首个提交也进入新增清单。
|
|
372
|
+
const emptyTree = gitLines(projectRoot, ["hash-object", "-t", "tree", "/dev/null"]);
|
|
373
|
+
if (emptyTree.ok)
|
|
374
|
+
baseHead = emptyTree.lines[0] ?? null;
|
|
375
|
+
}
|
|
376
|
+
if (baseHead && scope.current_head) {
|
|
377
|
+
const committed = gitLines(projectRoot, [
|
|
378
|
+
"diff", "--no-renames", "--diff-filter=A", "--name-only", `${baseHead}..${scope.current_head}`,
|
|
379
|
+
]);
|
|
380
|
+
if (committed.ok) {
|
|
381
|
+
for (const path of committed.lines) {
|
|
382
|
+
if (isCodeLikePath(path))
|
|
383
|
+
added.add(path);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const dirty = dirtyCodeFiles(projectRoot);
|
|
388
|
+
if (dirty.ok) {
|
|
389
|
+
for (const file of dirty.files) {
|
|
390
|
+
if (file.status === "added" && isCodeLikePath(file.path))
|
|
391
|
+
added.add(file.path);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const path of scope.untracked_paths) {
|
|
395
|
+
if (isCodeLikePath(path))
|
|
396
|
+
added.add(path);
|
|
397
|
+
}
|
|
398
|
+
return [...added].sort();
|
|
399
|
+
}
|
|
400
|
+
function structureLedgerForCodeReview(changeRoot, events) {
|
|
401
|
+
// 只有 v2 planning round 才有清单契约;v1 change 不写该字段,保持旧 packet 形态。
|
|
402
|
+
if (planningValidationProfileForCurrentRound(events)?.design?.schema_version !== 2)
|
|
403
|
+
return undefined;
|
|
404
|
+
const designPath = join(changeRoot, "design.md");
|
|
405
|
+
if (!existsSync(designPath))
|
|
406
|
+
return undefined;
|
|
407
|
+
const ledger = parseStructureChangeLedger(readFileSync(designPath, "utf8"));
|
|
408
|
+
return ledger.present ? ledger : undefined;
|
|
409
|
+
}
|
|
363
410
|
export function codeReviewPacketContext(changeRoot, projectRoot, scope, events) {
|
|
364
411
|
const taskExecutionIndex = taskExecutionIndexFromEvents(projectRoot, events);
|
|
365
412
|
// changed_paths 未知(快照缺失)或不完整(committed 段 diff 失败)的 task
|
|
@@ -373,12 +420,15 @@ export function codeReviewPacketContext(changeRoot, projectRoot, scope, events)
|
|
|
373
420
|
for (const path of item.changed_paths ?? [])
|
|
374
421
|
attributedPaths.add(path);
|
|
375
422
|
}
|
|
423
|
+
const structureLedger = structureLedgerForCodeReview(changeRoot, events);
|
|
376
424
|
return {
|
|
377
425
|
code_review_scope: scope,
|
|
378
426
|
coverage_exemption_refs: coverageExemptionRefs(changeRoot, events),
|
|
379
427
|
task_execution_index: taskExecutionIndex,
|
|
380
428
|
unattributed_paths: scope.review_paths.filter(path => !attributedPaths.has(path)).sort(),
|
|
381
429
|
unknown_attribution_tasks: unknownAttributionTasks,
|
|
430
|
+
added_code_paths: addedCodePathsForScope(projectRoot, scope),
|
|
431
|
+
...(structureLedger ? { structure_ledger: structureLedger } : {}),
|
|
382
432
|
};
|
|
383
433
|
}
|
|
384
434
|
export function effectiveCoverageExemptionRefsFromEvents(events) {
|
|
@@ -571,6 +621,47 @@ function codeReviewResultKind(value) {
|
|
|
571
621
|
export function codeReviewDecisionScope(jobId, findingId) {
|
|
572
622
|
return `${CODE_REVIEW_DECISION_SCOPE_PREFIX}${jobId}#${findingId}`;
|
|
573
623
|
}
|
|
624
|
+
// ===== review-fix 自动修复上限 =====
|
|
625
|
+
//
|
|
626
|
+
// next / reopen --review-fix / record user-decision 三处必须得出同一个结论,
|
|
627
|
+
// 否则会出现 next 要求使用者决策而 CLI 直调仍自动放行的分叉。这里是唯一判定入口。
|
|
628
|
+
/** 自当前 Apply round 的 start-apply 起,带 review_fix_of 的 reopen 次数。 */
|
|
629
|
+
export function countReviewFixReopensSinceStartApply(events) {
|
|
630
|
+
let startIndex = -1;
|
|
631
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
632
|
+
const ev = events[i];
|
|
633
|
+
if (ev.event_type !== "transition_commit")
|
|
634
|
+
continue;
|
|
635
|
+
const payload = ev.payload;
|
|
636
|
+
if (payload.transition === "start-apply" && payload.to_state === "apply") {
|
|
637
|
+
startIndex = i;
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
if (startIndex < 0)
|
|
642
|
+
return 0;
|
|
643
|
+
let count = 0;
|
|
644
|
+
for (const ev of events.slice(startIndex + 1)) {
|
|
645
|
+
if (ev.event_type !== "transition_commit")
|
|
646
|
+
continue;
|
|
647
|
+
const payload = ev.payload;
|
|
648
|
+
if (typeof payload.review_fix_of === "string" && payload.review_fix_of.trim() !== "")
|
|
649
|
+
count++;
|
|
650
|
+
}
|
|
651
|
+
return count;
|
|
652
|
+
}
|
|
653
|
+
/** 上限是否生效按 Apply round 在 start-apply 冻结的档位判断(minimal 忽略),未冻结时回落项目配置。 */
|
|
654
|
+
export function isReviewFixCapReached(projectRoot, events) {
|
|
655
|
+
const risk = workflowRiskForApplyRound(events, workflowRiskForProject(projectRoot));
|
|
656
|
+
const budget = workflowBudgetForRisk(projectRoot, risk);
|
|
657
|
+
if (!budget || budget.review_fix_rounds === null)
|
|
658
|
+
return false;
|
|
659
|
+
return countReviewFixReopensSinceStartApply(events) >= budget.review_fix_rounds;
|
|
660
|
+
}
|
|
661
|
+
/** spec / mixed 问题始终交使用者决策;implementation 问题只在自动修复触顶后交使用者决策。 */
|
|
662
|
+
export function codeReviewFindingNeedsUserDecision(type, reviewFixCapReached) {
|
|
663
|
+
return type === "spec" || type === "mixed" || (type === "implementation" && reviewFixCapReached);
|
|
664
|
+
}
|
|
574
665
|
/** 只接受状态机生成的 <job>#<finding> 决策范围。 */
|
|
575
666
|
export function parseCodeReviewDecisionScope(scope) {
|
|
576
667
|
if (!scope.startsWith(CODE_REVIEW_DECISION_SCOPE_PREFIX))
|
package/dist/format.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ExecutionContract, type ExecutionPolicy } from "./types.ts";
|
|
1
|
+
import { type ExecutionContract, type ExecutionPolicy, type StructureChangeLedger } from "./types.ts";
|
|
2
2
|
export declare const EXPLORE_OPEN_QUESTION_SCOPE_PREFIX = "explore_open_question:";
|
|
3
3
|
/**
|
|
4
4
|
* Discovery 的未确认项是用户决策的唯一候选来源。这里保留原有的“仅指定段落内
|
|
@@ -93,6 +93,30 @@ export declare function collectProposeOpenQuestions(changeRoot: string): {
|
|
|
93
93
|
openCount: number;
|
|
94
94
|
files: ProposeOpenQuestionFile[];
|
|
95
95
|
};
|
|
96
|
+
export declare const STRUCTURE_CHANGE_CATEGORIES: readonly ["新增持久化结构", "迁移或回填", "功能开关", "新增公共接口", "删除既有路径", "改既有公共签名", "改变既有数据语义", "新增公共类型"];
|
|
97
|
+
export type StructureChangeCategory = (typeof STRUCTURE_CHANGE_CATEGORIES)[number];
|
|
98
|
+
export declare const STRUCTURE_DECISION_REQUIRED_CATEGORIES: ReadonlySet<string>;
|
|
99
|
+
export declare function isStructureChangeCategory(value: string): value is StructureChangeCategory;
|
|
100
|
+
export declare function parseStructureChangeLedger(content: string): StructureChangeLedger;
|
|
101
|
+
export interface StructureChangeLedgerValidation {
|
|
102
|
+
ok: boolean;
|
|
103
|
+
errors: string[];
|
|
104
|
+
}
|
|
105
|
+
/** Markdown 标题是否存在(任意层级,允许闭合 #)。供文档引用解析共用。 */
|
|
106
|
+
export declare function headingExists(content: string, title: string): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* 结构变更清单"需求依据"的唯一解析入口。与 approved_refs 的解析分开:
|
|
109
|
+
* 这里接受 discovery 标题、拒绝 proposal.md#Impact,且按类别收紧允许集。
|
|
110
|
+
*/
|
|
111
|
+
export declare function resolveStructureBasisRef(changeRoot: string, raw: unknown, category: string): {
|
|
112
|
+
ok: true;
|
|
113
|
+
value: string;
|
|
114
|
+
} | {
|
|
115
|
+
ok: false;
|
|
116
|
+
reason: string;
|
|
117
|
+
};
|
|
118
|
+
export declare function validateStructureChangeLedger(changeRoot: string, ledger: StructureChangeLedger): StructureChangeLedgerValidation;
|
|
119
|
+
export declare function formatStructureChangeLedgerSummary(ledger: StructureChangeLedger): string | null;
|
|
96
120
|
export interface ParsedTask {
|
|
97
121
|
taskId: string;
|
|
98
122
|
lineIdx: number;
|