@peterxiaoyang/superspec 0.1.38 → 0.1.40
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/dist/cli.js +19 -2
- package/dist/code_review.d.ts +17 -2
- package/dist/code_review.js +471 -80
- package/dist/format.d.ts +46 -4
- package/dist/format.js +311 -26
- package/dist/git_state.d.ts +36 -0
- package/dist/git_state.js +174 -0
- package/dist/job_validity.d.ts +1 -0
- package/dist/job_validity.js +11 -8
- package/dist/next.js +46 -314
- package/dist/phase_plan.d.ts +97 -0
- package/dist/phase_plan.js +582 -0
- package/dist/record.js +56 -7
- package/dist/review.d.ts +3 -2
- package/dist/review.js +64 -32
- package/dist/review_job_gates.js +1 -0
- package/dist/store.d.ts +10 -0
- package/dist/store.js +53 -3
- package/dist/sync.js +5 -5
- package/dist/task.js +87 -9
- package/dist/task_evidence.js +80 -0
- package/dist/transition.d.ts +1 -1
- package/dist/transition.js +301 -210
- package/dist/types.d.ts +77 -1
- package/package.json +1 -1
- package/templates/workflow/agents/explore.toml +1 -1
- package/templates/workflow/prompts/architect.md +17 -27
- package/templates/workflow/prompts/code-reviewer.md +13 -2
- package/templates/workflow/prompts/critic.md +62 -61
- package/templates/workflow/prompts/executor.md +1 -1
- package/templates/workflow/prompts/explore.md +38 -26
- package/templates/workflow/prompts/test-engineer.md +18 -32
- package/templates/workflow/prompts/verifier.md +5 -3
- package/templates/workflow/skills/superspec-apply/SKILL.md +34 -11
- package/templates/workflow/skills/superspec-explore/SKILL.md +66 -64
- package/templates/workflow/skills/superspec-propose/SKILL.md +67 -44
- package/templates/workflow/skills/superspec-review/SKILL.md +1 -1
package/dist/code_review.js
CHANGED
|
@@ -1,93 +1,22 @@
|
|
|
1
1
|
// SuperSpec code-reviewer gate helpers.
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { join, extname } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
5
4
|
import { findLatestEvent, sha256File, sha256Text } from "./store.js";
|
|
6
5
|
import { REVIEW_CODE_REVIEW_GATE } from "./review_job_gates.js";
|
|
6
|
+
import { codeFileContentSha, currentGitHead, diffFingerprints, dirtyCodePaths, gitLines, isCodeLikePath, projectHasReadableDirectory, walkCodeFiles, } from "./git_state.js";
|
|
7
|
+
import { parseExecutionRequirements, parseTestContractEntries } from "./format.js";
|
|
7
8
|
export const CODE_REVIEW_REPAIR_SCOPE_PREFIX = "code_reviewer_report_repair:";
|
|
8
9
|
export const CODE_REVIEW_DECISION_SCOPE_PREFIX = "code_review_decision:";
|
|
10
|
+
export const TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX = "test_coverage_exemption:";
|
|
9
11
|
export const CODE_REVIEW_DECISION_ANSWER_LABELS = {
|
|
10
12
|
reopen_propose: "回到计划阶段",
|
|
11
13
|
reopen_apply: "回到实现阶段",
|
|
12
14
|
dismiss: "驳回该问题",
|
|
13
15
|
};
|
|
14
|
-
const PROCESS_DOC_RE = /^(?:openspec\/changes\/[^/]+\/)?(?:proposal|design|tasks)\.md$/;
|
|
15
|
-
const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|business-invariants|test-contract)\.md$/;
|
|
16
|
-
const CODE_EXTENSIONS = new Set([
|
|
17
|
-
".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx",
|
|
18
|
-
".json", ".kt", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql",
|
|
19
|
-
".swift", ".toml", ".ts", ".tsx", ".yaml", ".yml",
|
|
20
|
-
]);
|
|
21
|
-
const CODE_BASENAMES = new Set([
|
|
22
|
-
"Dockerfile", "Makefile", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
|
|
23
|
-
"tsconfig.json", "tsconfig.build.json", "eslint.config.js", "vite.config.ts", "webpack.config.js",
|
|
24
|
-
]);
|
|
25
|
-
const WALK_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".superspec", ".omx"]);
|
|
26
|
-
function normalizeGitPath(rawPath) {
|
|
27
|
-
const trimmed = rawPath.trim();
|
|
28
|
-
const unquoted = trimmed.startsWith('"') && trimmed.endsWith('"')
|
|
29
|
-
? trimmed.slice(1, -1).replace(/\\"/g, '"')
|
|
30
|
-
: trimmed;
|
|
31
|
-
const renamed = unquoted.includes(" -> ") ? unquoted.split(" -> ").pop() ?? unquoted : unquoted;
|
|
32
|
-
return renamed.replace(/\\/g, "/");
|
|
33
|
-
}
|
|
34
|
-
function gitChangedPaths(projectRoot) {
|
|
35
|
-
try {
|
|
36
|
-
const output = execFileSync("git", ["-C", projectRoot, "status", "--porcelain", "--untracked-files=all"], {
|
|
37
|
-
encoding: "utf8",
|
|
38
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
39
|
-
});
|
|
40
|
-
const paths = output
|
|
41
|
-
.split("\n")
|
|
42
|
-
.map(line => line.trimEnd())
|
|
43
|
-
.filter(Boolean)
|
|
44
|
-
.map(line => normalizeGitPath(line.slice(3)))
|
|
45
|
-
.filter(Boolean);
|
|
46
|
-
return { ok: true, paths: [...new Set(paths)].sort() };
|
|
47
|
-
}
|
|
48
|
-
catch (err) {
|
|
49
|
-
return { ok: false, reason: err instanceof Error ? err.message : "git status failed" };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
function isProcessOrOrdinaryDoc(path) {
|
|
53
|
-
if (path.startsWith(".superspec/") || path.startsWith(".omx/"))
|
|
54
|
-
return true;
|
|
55
|
-
if (path.includes("/.superspec/") || path.includes("/.omx/"))
|
|
56
|
-
return true;
|
|
57
|
-
if (PROCESS_DOC_RE.test(path) || PROCESS_ARTIFACT_RE.test(path))
|
|
58
|
-
return true;
|
|
59
|
-
return extname(path).toLowerCase() === ".md";
|
|
60
|
-
}
|
|
61
|
-
export function isCodeLikePath(path) {
|
|
62
|
-
const normalized = path.replace(/\\/g, "/");
|
|
63
|
-
if (!normalized || isProcessOrOrdinaryDoc(normalized))
|
|
64
|
-
return false;
|
|
65
|
-
const base = normalized.split("/").pop() ?? normalized;
|
|
66
|
-
if (CODE_BASENAMES.has(base))
|
|
67
|
-
return true;
|
|
68
|
-
return CODE_EXTENSIONS.has(extname(base).toLowerCase());
|
|
69
|
-
}
|
|
70
|
-
function walkCodeFiles(root, dir = root, out = []) {
|
|
71
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
72
|
-
if (entry.isDirectory()) {
|
|
73
|
-
if (WALK_SKIP_DIRS.has(entry.name))
|
|
74
|
-
continue;
|
|
75
|
-
walkCodeFiles(root, join(dir, entry.name), out);
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (!entry.isFile())
|
|
79
|
-
continue;
|
|
80
|
-
const full = join(dir, entry.name);
|
|
81
|
-
const rel = full.slice(root.length + 1).replace(/\\/g, "/");
|
|
82
|
-
if (isCodeLikePath(rel))
|
|
83
|
-
out.push(rel);
|
|
84
|
-
}
|
|
85
|
-
return out;
|
|
86
|
-
}
|
|
87
16
|
export function scanCodeChanges(projectRoot) {
|
|
88
|
-
const git =
|
|
17
|
+
const git = dirtyCodePaths(projectRoot);
|
|
89
18
|
if (!git.ok) {
|
|
90
|
-
const paths =
|
|
19
|
+
const paths = projectHasReadableDirectory(projectRoot)
|
|
91
20
|
? walkCodeFiles(projectRoot).sort()
|
|
92
21
|
: [];
|
|
93
22
|
return {
|
|
@@ -105,8 +34,168 @@ export function scanCodeChanges(projectRoot) {
|
|
|
105
34
|
reason: paths.length > 0 ? "检测到代码类改动" : "没有代码类改动",
|
|
106
35
|
};
|
|
107
36
|
}
|
|
37
|
+
export function currentCodeReviewWorkingPaths(projectRoot, events, extraIgnoredPaths = []) {
|
|
38
|
+
const ignored = new Set(extraIgnoredPaths.map(path => path.replace(/\\/g, "/")));
|
|
39
|
+
for (const path of knownCodeReviewReportPaths(projectRoot, events))
|
|
40
|
+
ignored.add(path);
|
|
41
|
+
return scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path));
|
|
42
|
+
}
|
|
43
|
+
function uniqSorted(paths) {
|
|
44
|
+
return [...new Set(paths)].filter(isCodeLikePath).sort();
|
|
45
|
+
}
|
|
46
|
+
function normalizeKnownPath(path) {
|
|
47
|
+
return path.trim().replace(/\\/g, "/");
|
|
48
|
+
}
|
|
49
|
+
export function knownCodeReviewReportPaths(projectRoot, events) {
|
|
50
|
+
const paths = new Set();
|
|
51
|
+
for (const ev of events) {
|
|
52
|
+
if (ev.event_type !== "job_accepted" && ev.event_type !== "job_rejected")
|
|
53
|
+
continue;
|
|
54
|
+
const payload = ev.payload;
|
|
55
|
+
if (payload.role !== "code-reviewer")
|
|
56
|
+
continue;
|
|
57
|
+
if (typeof payload.report_digest !== "string" || payload.report_digest.trim() === "")
|
|
58
|
+
continue;
|
|
59
|
+
if (typeof payload.report_path === "string" && payload.report_path.trim() !== "") {
|
|
60
|
+
const reportPath = normalizeKnownPath(payload.report_path);
|
|
61
|
+
if (sha256File(join(projectRoot, reportPath)) === payload.report_digest) {
|
|
62
|
+
paths.add(reportPath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return paths;
|
|
67
|
+
}
|
|
68
|
+
function excludeKnownPaths(paths, ignored) {
|
|
69
|
+
if (ignored.size === 0)
|
|
70
|
+
return paths;
|
|
71
|
+
return paths.filter(path => !ignored.has(path));
|
|
72
|
+
}
|
|
73
|
+
function firstStartApplyHead(events) {
|
|
74
|
+
for (const ev of events) {
|
|
75
|
+
if (ev.event_type !== "transition_commit")
|
|
76
|
+
continue;
|
|
77
|
+
const payload = ev.payload;
|
|
78
|
+
if (payload.transition !== "start-apply")
|
|
79
|
+
continue;
|
|
80
|
+
if (!Object.prototype.hasOwnProperty.call(payload, "apply_start_head")) {
|
|
81
|
+
return { present: false, head: null, reason: "missing_apply_start_head" };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
present: true,
|
|
85
|
+
head: typeof payload.apply_start_head === "string" ? payload.apply_start_head : null,
|
|
86
|
+
reason: typeof payload.apply_start_head_reason === "string" ? payload.apply_start_head_reason : "missing_apply_start_head",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { present: false, head: null, reason: "没有 apply_start_head" };
|
|
90
|
+
}
|
|
91
|
+
function latestReviewedHead(events) {
|
|
92
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
93
|
+
const ev = events[i];
|
|
94
|
+
if (ev.event_type !== "transition_commit")
|
|
95
|
+
continue;
|
|
96
|
+
const payload = ev.payload;
|
|
97
|
+
const gate = payload.code_review_gate;
|
|
98
|
+
if (!gate)
|
|
99
|
+
continue;
|
|
100
|
+
if (gate.decision === "passed") {
|
|
101
|
+
if (typeof gate.current_head !== "string" || gate.current_head.trim() === "")
|
|
102
|
+
continue;
|
|
103
|
+
return { present: true, head: gate.current_head };
|
|
104
|
+
}
|
|
105
|
+
if (gate.decision === "skipped") {
|
|
106
|
+
if (typeof gate.head !== "string" || gate.head.trim() === "")
|
|
107
|
+
continue;
|
|
108
|
+
return { present: true, head: gate.head };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { present: false, head: null };
|
|
112
|
+
}
|
|
113
|
+
export function selectCodeReviewBase(events) {
|
|
114
|
+
const reviewed = latestReviewedHead(events);
|
|
115
|
+
if (reviewed.present)
|
|
116
|
+
return { base_head: reviewed.head, kind: "reviewed", reason: "latest_code_review_gate" };
|
|
117
|
+
const firstStart = firstStartApplyHead(events);
|
|
118
|
+
if (!firstStart.present)
|
|
119
|
+
return { base_head: null, kind: "history_missing", reason: firstStart.reason };
|
|
120
|
+
if (firstStart.head == null)
|
|
121
|
+
return { base_head: null, kind: "empty_tree", reason: firstStart.reason };
|
|
122
|
+
return { base_head: firstStart.head, kind: "start_apply", reason: "first_start_apply" };
|
|
123
|
+
}
|
|
124
|
+
function scanCodeReviewScopeFromBase(projectRoot, base, ignoredPaths = new Set()) {
|
|
125
|
+
const currentHead = currentGitHead(projectRoot);
|
|
126
|
+
let scopeReliable = true;
|
|
127
|
+
let scopeReason = base.kind === "history_missing" ? base.reason : "ok";
|
|
128
|
+
let committedPaths = [];
|
|
129
|
+
if (base.kind !== "history_missing") {
|
|
130
|
+
if (base.base_head && currentHead.head) {
|
|
131
|
+
const diff = gitLines(projectRoot, ["diff", "--name-only", `${base.base_head}..HEAD`]);
|
|
132
|
+
if (diff.ok)
|
|
133
|
+
committedPaths = excludeKnownPaths(uniqSorted(diff.lines), ignoredPaths);
|
|
134
|
+
else {
|
|
135
|
+
scopeReliable = false;
|
|
136
|
+
scopeReason = `git diff ${base.base_head}..HEAD failed: ${diff.reason}`;
|
|
137
|
+
committedPaths = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (base.kind === "empty_tree" && currentHead.head) {
|
|
141
|
+
const tree = gitLines(projectRoot, ["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
142
|
+
if (tree.ok)
|
|
143
|
+
committedPaths = excludeKnownPaths(uniqSorted(tree.lines), ignoredPaths);
|
|
144
|
+
else {
|
|
145
|
+
scopeReliable = false;
|
|
146
|
+
scopeReason = `git ls-tree HEAD failed: ${tree.reason}`;
|
|
147
|
+
committedPaths = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const staged = gitLines(projectRoot, ["diff", "--name-only", "--cached"]);
|
|
152
|
+
const unstaged = gitLines(projectRoot, ["diff", "--name-only"]);
|
|
153
|
+
const untracked = gitLines(projectRoot, ["ls-files", "--others", "--exclude-standard"]);
|
|
154
|
+
if (!staged.ok || !unstaged.ok || !untracked.ok) {
|
|
155
|
+
scopeReliable = false;
|
|
156
|
+
scopeReason = [scopeReason, !staged.ok ? staged.reason : "", !unstaged.ok ? unstaged.reason : "", !untracked.ok ? untracked.reason : ""]
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join("; ");
|
|
159
|
+
}
|
|
160
|
+
const worktreePaths = excludeKnownPaths(uniqSorted([...(staged.ok ? staged.lines : []), ...(unstaged.ok ? unstaged.lines : [])]), ignoredPaths);
|
|
161
|
+
const untrackedPaths = excludeKnownPaths(uniqSorted(untracked.ok ? untracked.lines : []), ignoredPaths);
|
|
162
|
+
const fallbackPaths = projectHasReadableDirectory(projectRoot)
|
|
163
|
+
? excludeKnownPaths(walkCodeFiles(projectRoot).sort(), ignoredPaths)
|
|
164
|
+
: [];
|
|
165
|
+
const reviewPaths = committedPaths == null || !scopeReliable
|
|
166
|
+
? uniqSorted([...fallbackPaths, ...worktreePaths, ...untrackedPaths])
|
|
167
|
+
: uniqSorted([...committedPaths, ...worktreePaths, ...untrackedPaths]);
|
|
168
|
+
return {
|
|
169
|
+
base_head: base.base_head,
|
|
170
|
+
current_head: currentHead.head,
|
|
171
|
+
scope_reliable: scopeReliable,
|
|
172
|
+
scope_reason: scopeReason || base.reason,
|
|
173
|
+
committed_paths: committedPaths,
|
|
174
|
+
worktree_paths: worktreePaths,
|
|
175
|
+
untracked_paths: untrackedPaths,
|
|
176
|
+
review_paths: reviewPaths,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function scanCodeReviewScope(projectRoot, events) {
|
|
180
|
+
return scanCodeReviewScopeFromBase(projectRoot, selectCodeReviewBase(events), knownCodeReviewReportPaths(projectRoot, events));
|
|
181
|
+
}
|
|
182
|
+
export function scanCodeChangesForReview(projectRoot, events) {
|
|
183
|
+
const scope = scanCodeReviewScope(projectRoot, events);
|
|
184
|
+
const hasCodeChanges = !scope.scope_reliable ||
|
|
185
|
+
scope.committed_paths == null ||
|
|
186
|
+
scope.committed_paths.length > 0 ||
|
|
187
|
+
scope.worktree_paths.length > 0 ||
|
|
188
|
+
scope.untracked_paths.length > 0;
|
|
189
|
+
return {
|
|
190
|
+
reliable: scope.scope_reliable,
|
|
191
|
+
hasCodeChanges,
|
|
192
|
+
paths: scope.review_paths,
|
|
193
|
+
reason: hasCodeChanges ? "检测到代码类改动" : "没有代码类改动",
|
|
194
|
+
scope,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
108
197
|
export function codeReviewBoundFiles(projectRoot, paths) {
|
|
109
|
-
return paths.map(path => ({ path, sha:
|
|
198
|
+
return paths.map(path => ({ path, sha: codeFileContentSha(projectRoot, path) ?? "sha256:missing" }));
|
|
110
199
|
}
|
|
111
200
|
function samePathSet(left, right) {
|
|
112
201
|
const a = [...new Set(left)].sort();
|
|
@@ -116,13 +205,32 @@ function samePathSet(left, right) {
|
|
|
116
205
|
export function codeReviewJobStaleReason(projectRoot, job, currentPaths) {
|
|
117
206
|
if (!isCodeReviewerJob(job))
|
|
118
207
|
return null;
|
|
208
|
+
const frozenScope = job.packet_context?.code_review_scope;
|
|
209
|
+
if (frozenScope) {
|
|
210
|
+
const currentHead = currentGitHead(projectRoot);
|
|
211
|
+
if (frozenScope.current_head !== currentHead.head) {
|
|
212
|
+
return `代码审查创建后的 HEAD 已变化(原记录:${frozenScope.current_head ?? "<none>"};当前:${currentHead.head ?? "<none>"})`;
|
|
213
|
+
}
|
|
214
|
+
const currentWorkingPaths = currentPaths ?? scanCodeChanges(projectRoot).paths;
|
|
215
|
+
const frozenWorkingPaths = uniqSorted([...frozenScope.worktree_paths, ...frozenScope.untracked_paths]);
|
|
216
|
+
if (!samePathSet(frozenWorkingPaths, currentWorkingPaths)) {
|
|
217
|
+
return `代码审查范围已变化:工作区范围已变化(原范围:${frozenWorkingPaths.join(", ") || "<none>"};当前范围:${currentWorkingPaths.join(", ") || "<none>"})`;
|
|
218
|
+
}
|
|
219
|
+
for (const bound of job.boundFiles) {
|
|
220
|
+
const currentSha = codeFileContentSha(projectRoot, bound.path) ?? "sha256:missing";
|
|
221
|
+
if (currentSha !== bound.sha) {
|
|
222
|
+
return `代码审查范围内的文件 ${bound.path} 已变化(原记录:${bound.sha};当前:${currentSha})`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
119
227
|
const scanPaths = currentPaths ?? scanCodeChanges(projectRoot).paths;
|
|
120
228
|
const boundPaths = job.boundFiles.map(file => file.path);
|
|
121
229
|
if (!samePathSet(boundPaths, scanPaths)) {
|
|
122
230
|
return `代码审查范围已变化(原范围:${boundPaths.join(", ") || "<none>"};当前范围:${scanPaths.join(", ") || "<none>"})`;
|
|
123
231
|
}
|
|
124
232
|
for (const bound of job.boundFiles) {
|
|
125
|
-
const currentSha =
|
|
233
|
+
const currentSha = codeFileContentSha(projectRoot, bound.path) ?? "sha256:missing";
|
|
126
234
|
if (currentSha !== bound.sha) {
|
|
127
235
|
return `代码审查范围内的文件 ${bound.path} 已变化(原记录:${bound.sha};当前:${currentSha})`;
|
|
128
236
|
}
|
|
@@ -132,6 +240,196 @@ export function codeReviewJobStaleReason(projectRoot, job, currentPaths) {
|
|
|
132
240
|
export function codeReviewPacketDigest(input) {
|
|
133
241
|
return sha256Text(JSON.stringify(input));
|
|
134
242
|
}
|
|
243
|
+
export function codeReviewPacketContext(changeRoot, projectRoot, scope, events) {
|
|
244
|
+
const taskExecutionIndex = taskExecutionIndexFromEvents(projectRoot, events);
|
|
245
|
+
// changed_paths 未知(快照缺失)或不完整(committed 段 diff 失败)的 task
|
|
246
|
+
// 都进入 unknown_attribution_tasks,提示 code-reviewer 扩大对照范围
|
|
247
|
+
const unknownAttributionTasks = taskExecutionIndex
|
|
248
|
+
.filter(item => item.changed_paths == null || item.changed_paths_partial_reason != null)
|
|
249
|
+
.map(item => item.task_id)
|
|
250
|
+
.sort();
|
|
251
|
+
const attributedPaths = new Set();
|
|
252
|
+
for (const item of taskExecutionIndex) {
|
|
253
|
+
for (const path of item.changed_paths ?? [])
|
|
254
|
+
attributedPaths.add(path);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
code_review_scope: scope,
|
|
258
|
+
coverage_exemption_refs: coverageExemptionRefs(changeRoot, events),
|
|
259
|
+
task_execution_index: taskExecutionIndex,
|
|
260
|
+
unattributed_paths: scope.review_paths.filter(path => !attributedPaths.has(path)).sort(),
|
|
261
|
+
unknown_attribution_tasks: unknownAttributionTasks,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
export function effectiveCoverageExemptionRefsFromEvents(events) {
|
|
265
|
+
const latest = new Map();
|
|
266
|
+
for (const ev of events) {
|
|
267
|
+
if (ev.event_type !== "user_decision_recorded")
|
|
268
|
+
continue;
|
|
269
|
+
const payload = ev.payload;
|
|
270
|
+
if (payload.accepted === false)
|
|
271
|
+
continue;
|
|
272
|
+
if (typeof payload.scope !== "string" || !payload.scope.startsWith(TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX))
|
|
273
|
+
continue;
|
|
274
|
+
const testId = payload.scope.slice(TEST_COVERAGE_EXEMPTION_SCOPE_PREFIX.length);
|
|
275
|
+
if (!/^TEST-[A-Za-z0-9_-]+$/.test(testId))
|
|
276
|
+
continue;
|
|
277
|
+
if (typeof payload.answer !== "string" || payload.answer.trim() === "")
|
|
278
|
+
continue;
|
|
279
|
+
latest.set(testId, {
|
|
280
|
+
test_id: testId,
|
|
281
|
+
event_id: ev.event_id,
|
|
282
|
+
event_digest: ev.event_digest,
|
|
283
|
+
answer: payload.answer.trim(),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return [...latest.values()].sort((a, b) => a.test_id.localeCompare(b.test_id));
|
|
287
|
+
}
|
|
288
|
+
function currentTaskDeclaredTestIds(changeRoot) {
|
|
289
|
+
const tasksPath = join(changeRoot, "tasks.md");
|
|
290
|
+
if (!existsSync(tasksPath))
|
|
291
|
+
return new Set();
|
|
292
|
+
return new Set(parseExecutionRequirements(readFileSync(tasksPath, "utf8")).flatMap(item => item.contract.tests));
|
|
293
|
+
}
|
|
294
|
+
function currentTestContractIds(changeRoot) {
|
|
295
|
+
const testContractPath = join(changeRoot, ".superspec", "artifacts", "test-contract.md");
|
|
296
|
+
if (!existsSync(testContractPath))
|
|
297
|
+
return [];
|
|
298
|
+
const parsed = parseTestContractEntries(readFileSync(testContractPath, "utf8"));
|
|
299
|
+
return parsed.ok ? parsed.entries.map(entry => entry.test_id).sort() : [];
|
|
300
|
+
}
|
|
301
|
+
export function missingCoverageExemptionTestIds(changeRoot, events) {
|
|
302
|
+
const declared = currentTaskDeclaredTestIds(changeRoot);
|
|
303
|
+
const effective = new Set(effectiveCoverageExemptionRefsFromEvents(events).map(ref => ref.test_id));
|
|
304
|
+
return currentTestContractIds(changeRoot)
|
|
305
|
+
.filter(testId => !declared.has(testId) && !effective.has(testId))
|
|
306
|
+
.sort();
|
|
307
|
+
}
|
|
308
|
+
function coverageExemptionRefs(changeRoot, events) {
|
|
309
|
+
const declared = currentTaskDeclaredTestIds(changeRoot);
|
|
310
|
+
const unbound = new Set(currentTestContractIds(changeRoot).filter(testId => !declared.has(testId)));
|
|
311
|
+
return effectiveCoverageExemptionRefsFromEvents(events)
|
|
312
|
+
.filter(ref => unbound.has(ref.test_id))
|
|
313
|
+
.sort((a, b) => a.test_id.localeCompare(b.test_id));
|
|
314
|
+
}
|
|
315
|
+
function boundaryFromPayload(payload) {
|
|
316
|
+
const boundary = payload.boundary_snapshot;
|
|
317
|
+
if (!boundary || typeof boundary !== "object" || Array.isArray(boundary))
|
|
318
|
+
return null;
|
|
319
|
+
const obj = boundary;
|
|
320
|
+
if (!Array.isArray(obj.dirty_files))
|
|
321
|
+
return null;
|
|
322
|
+
if (typeof obj.dirty_files_reason === "string")
|
|
323
|
+
return null;
|
|
324
|
+
return {
|
|
325
|
+
head: typeof obj.head === "string" ? obj.head : null,
|
|
326
|
+
...(typeof obj.head_reason === "string" ? { head_reason: obj.head_reason } : {}),
|
|
327
|
+
dirty_files: obj.dirty_files,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function changedPathsBetweenSnapshots(projectRoot, start, completed) {
|
|
331
|
+
if (!start || !completed)
|
|
332
|
+
return null;
|
|
333
|
+
// 两端 dirty_files 用共享指纹原语对比,再过滤出代码类路径
|
|
334
|
+
const changed = new Set(diffFingerprints(start.dirty_files, completed.dirty_files).filter(isCodeLikePath));
|
|
335
|
+
let partialReason = null;
|
|
336
|
+
if (start.head && completed.head && start.head !== completed.head) {
|
|
337
|
+
const diff = gitLines(projectRoot, ["diff", "--name-only", `${start.head}..${completed.head}`]);
|
|
338
|
+
if (diff.ok) {
|
|
339
|
+
// 方案要求 committed 段只收代码文件,uniqSorted 内含 isCodeLikePath 过滤
|
|
340
|
+
for (const path of uniqSorted(diff.lines))
|
|
341
|
+
changed.add(path);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// diff 失败不丢弃 dirty 侧的确定事实,只标记 committed 段缺失
|
|
345
|
+
partialReason = `git diff ${start.head}..${completed.head} failed: ${diff.reason}`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { paths: [...changed].sort(), partial_reason: partialReason };
|
|
349
|
+
}
|
|
350
|
+
function testEvidenceForAttempt(events, attempt) {
|
|
351
|
+
const declaredTests = attempt.contract_mode === true ? attempt.contract?.tests ?? [] : [];
|
|
352
|
+
const eventsByTest = new Map();
|
|
353
|
+
for (const ev of events) {
|
|
354
|
+
if (ev.event_type !== "test_run_recorded")
|
|
355
|
+
continue;
|
|
356
|
+
const payload = ev.payload;
|
|
357
|
+
if (payload.attempt_id !== attempt.attempt_id || typeof payload.test_id !== "string")
|
|
358
|
+
continue;
|
|
359
|
+
if (declaredTests.length > 0 && !declaredTests.includes(payload.test_id))
|
|
360
|
+
continue;
|
|
361
|
+
const list = eventsByTest.get(payload.test_id) ?? [];
|
|
362
|
+
list.push(ev);
|
|
363
|
+
eventsByTest.set(payload.test_id, list);
|
|
364
|
+
}
|
|
365
|
+
const testIds = declaredTests.length > 0 ? declaredTests : [...eventsByTest.keys()].sort();
|
|
366
|
+
const evidence = [];
|
|
367
|
+
for (const testId of testIds) {
|
|
368
|
+
let red = null;
|
|
369
|
+
let green = null;
|
|
370
|
+
let pairedRed = null;
|
|
371
|
+
for (const ev of eventsByTest.get(testId) ?? []) {
|
|
372
|
+
const payload = ev.payload;
|
|
373
|
+
if (payload.semantic_status === "expected_failure" && typeof payload.exit_code === "number" && payload.exit_code !== 0) {
|
|
374
|
+
if (!red)
|
|
375
|
+
red = ev;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const isGreen = (payload.semantic_status === "expected_success" || payload.semantic_status === "characterization_pass") && payload.exit_code === 0;
|
|
379
|
+
if (isGreen && !green) {
|
|
380
|
+
green = ev;
|
|
381
|
+
pairedRed = red;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!green && !pairedRed)
|
|
385
|
+
continue;
|
|
386
|
+
evidence.push({
|
|
387
|
+
test_id: testId,
|
|
388
|
+
...(pairedRed ? { red_event_ref: pairedRed.event_id, red_event_digest: pairedRed.event_digest } : {}),
|
|
389
|
+
...(green ? { green_event_ref: green.event_id, green_event_digest: green.event_digest } : {}),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return evidence.sort((a, b) => String(a.test_id).localeCompare(String(b.test_id)));
|
|
393
|
+
}
|
|
394
|
+
function taskExecutionIndexFromEvents(projectRoot, events) {
|
|
395
|
+
const attempts = new Map();
|
|
396
|
+
const entries = [];
|
|
397
|
+
for (const ev of events) {
|
|
398
|
+
if (ev.event_type === "task_started") {
|
|
399
|
+
const attempt = ev.payload;
|
|
400
|
+
if (typeof attempt.attempt_id === "string") {
|
|
401
|
+
attempts.set(attempt.attempt_id, {
|
|
402
|
+
attempt,
|
|
403
|
+
boundary: boundaryFromPayload(ev.payload),
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
else if (ev.event_type === "task_completed") {
|
|
408
|
+
const payload = ev.payload;
|
|
409
|
+
if (typeof payload.task_id !== "string" || typeof payload.attempt_id !== "string")
|
|
410
|
+
continue;
|
|
411
|
+
const started = attempts.get(payload.attempt_id);
|
|
412
|
+
const attempt = started?.attempt;
|
|
413
|
+
const effectiveContract = attempt?.contract_mode === true ? attempt.contract ?? null : null;
|
|
414
|
+
const changedResult = changedPathsBetweenSnapshots(projectRoot, started?.boundary ?? null, boundaryFromPayload(ev.payload));
|
|
415
|
+
entries.push({
|
|
416
|
+
task_id: payload.task_id,
|
|
417
|
+
attempt_id: payload.attempt_id,
|
|
418
|
+
changed_paths: changedResult ? changedResult.paths : null,
|
|
419
|
+
...(changedResult?.partial_reason ? { changed_paths_partial_reason: changedResult.partial_reason } : {}),
|
|
420
|
+
contract: effectiveContract,
|
|
421
|
+
declared_tests: effectiveContract?.tests ?? [],
|
|
422
|
+
scope_note: payload.scope_note && typeof payload.scope_note === "object" && !Array.isArray(payload.scope_note)
|
|
423
|
+
? payload.scope_note
|
|
424
|
+
: null,
|
|
425
|
+
test_evidence: attempt ? testEvidenceForAttempt(events, attempt) : [],
|
|
426
|
+
task_completed_event_ref: ev.event_id,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
entries.sort((a, b) => a.task_id.localeCompare(b.task_id) || a.attempt_id.localeCompare(b.attempt_id));
|
|
431
|
+
return entries;
|
|
432
|
+
}
|
|
135
433
|
function isCodeReviewerJob(job) {
|
|
136
434
|
return job.role === "code-reviewer" && REVIEW_CODE_REVIEW_GATE.isJobForGate(job);
|
|
137
435
|
}
|
|
@@ -322,3 +620,96 @@ export function latestApplyDoneToReviewGate(events) {
|
|
|
322
620
|
export function requiresFinalVerifierForCurrentReview(events) {
|
|
323
621
|
return latestApplyDoneToReviewGate(events) != null;
|
|
324
622
|
}
|
|
623
|
+
function findJobInEvents(events, jobId) {
|
|
624
|
+
for (const ev of events) {
|
|
625
|
+
if (ev.event_type !== "transition_commit")
|
|
626
|
+
continue;
|
|
627
|
+
const jobs = ev.payload.new_jobs ?? [];
|
|
628
|
+
const job = jobs.find(item => item.job_id === jobId);
|
|
629
|
+
if (job)
|
|
630
|
+
return job;
|
|
631
|
+
}
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
function latestApplyDoneToReviewGatePayload(events) {
|
|
635
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
636
|
+
const ev = events[i];
|
|
637
|
+
if (ev.event_type !== "transition_commit")
|
|
638
|
+
continue;
|
|
639
|
+
const payload = ev.payload;
|
|
640
|
+
if (payload.transition !== "review-ready" || payload.from_state !== "apply_done" || payload.to_state !== "review")
|
|
641
|
+
continue;
|
|
642
|
+
const gate = payload.code_review_gate;
|
|
643
|
+
if (!gate || (gate.decision !== "passed" && gate.decision !== "skipped"))
|
|
644
|
+
return null;
|
|
645
|
+
return {
|
|
646
|
+
decision: gate.decision,
|
|
647
|
+
...(typeof gate.job_id === "string" ? { job_id: gate.job_id } : {}),
|
|
648
|
+
...(typeof gate.current_head === "string" || gate.current_head === null ? { current_head: gate.current_head } : {}),
|
|
649
|
+
...(typeof gate.head === "string" || gate.head === null ? { head: gate.head } : {}),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
export function computeCodeStateCheck(projectRoot, events, ignoredCodePaths = []) {
|
|
655
|
+
const gate = latestApplyDoneToReviewGatePayload(events);
|
|
656
|
+
const currentHead = currentGitHead(projectRoot);
|
|
657
|
+
const baselineHead = gate?.decision === "passed" ? gate.current_head ?? null : gate?.head ?? null;
|
|
658
|
+
const changed = new Set();
|
|
659
|
+
const ignored = new Set(ignoredCodePaths);
|
|
660
|
+
const reviewedJob = gate?.decision === "passed" && gate.job_id ? findJobInEvents(events, gate.job_id) : null;
|
|
661
|
+
for (const ev of events) {
|
|
662
|
+
if (ev.event_type !== "job_accepted" && ev.event_type !== "job_rejected")
|
|
663
|
+
continue;
|
|
664
|
+
const payload = ev.payload;
|
|
665
|
+
if (typeof payload.report_path === "string")
|
|
666
|
+
ignored.add(payload.report_path);
|
|
667
|
+
}
|
|
668
|
+
let scopeReason = gate ? "ok" : "missing_code_review_gate";
|
|
669
|
+
if (baselineHead && currentHead.head && baselineHead !== currentHead.head) {
|
|
670
|
+
const diff = gitLines(projectRoot, ["diff", "--name-only", `${baselineHead}..HEAD`]);
|
|
671
|
+
if (diff.ok) {
|
|
672
|
+
for (const path of uniqSorted(diff.lines))
|
|
673
|
+
changed.add(path);
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
scopeReason = `git diff ${baselineHead}..HEAD failed: ${diff.reason}`;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
else if (!baselineHead && gate && currentHead.head) {
|
|
680
|
+
const tree = gitLines(projectRoot, ["ls-tree", "-r", "--name-only", "HEAD"]);
|
|
681
|
+
if (tree.ok) {
|
|
682
|
+
for (const path of uniqSorted(tree.lines))
|
|
683
|
+
changed.add(path);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
scopeReason = `git ls-tree HEAD failed: ${tree.reason}`;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
// bound 基线与当前磁盘状态共用 {path,status,sha256} 指纹原语对比:
|
|
690
|
+
// 基线是 code review 时点的 bound 文件指纹;当前侧取 bound 路径与脏代码文件的并集。
|
|
691
|
+
// gate skipped 时基线为空,任何脏代码文件都会作为差异列出。
|
|
692
|
+
const baselineFingerprints = (reviewedJob?.boundFiles ?? [])
|
|
693
|
+
.map(bound => ({ path: bound.path, status: "modified", sha256: bound.sha }));
|
|
694
|
+
const baselinePaths = new Set(baselineFingerprints.map(file => file.path));
|
|
695
|
+
// ignored(审查报告文件等)只豁免 bound 集合之外的新脏文件,bound 文件本身的变化仍需暴露
|
|
696
|
+
const currentPaths = new Set([
|
|
697
|
+
...baselinePaths,
|
|
698
|
+
...scanCodeChanges(projectRoot).paths.filter(path => !ignored.has(path)),
|
|
699
|
+
]);
|
|
700
|
+
// 缺失文件用与 bound 基线一致的 "sha256:missing" 占位:review 时点就缺失、现在仍缺失的文件不算差异
|
|
701
|
+
const currentFingerprints = [...currentPaths].map(path => ({
|
|
702
|
+
path,
|
|
703
|
+
status: "modified",
|
|
704
|
+
sha256: codeFileContentSha(projectRoot, path) ?? "sha256:missing",
|
|
705
|
+
}));
|
|
706
|
+
for (const path of diffFingerprints(baselineFingerprints, currentFingerprints))
|
|
707
|
+
changed.add(path);
|
|
708
|
+
return {
|
|
709
|
+
baseline_head: baselineHead,
|
|
710
|
+
current_head: currentHead.head,
|
|
711
|
+
head_matches: baselineHead === currentHead.head,
|
|
712
|
+
changed_paths: [...changed].filter(isCodeLikePath).sort(),
|
|
713
|
+
scope_reason: scopeReason,
|
|
714
|
+
};
|
|
715
|
+
}
|
package/dist/format.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import type { ExecutionContract } from "./types.ts";
|
|
1
2
|
/** 从 discovery.md 提取"待确认问题"段内的未确认项数量 */
|
|
2
3
|
export declare function countDiscoveryOpenQuestions(content: string): number;
|
|
4
|
+
export interface DiscoveryChainCoverageCheck {
|
|
5
|
+
ok: boolean;
|
|
6
|
+
message: string;
|
|
7
|
+
present: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function splitMarkdownTableRow(line: string): string[];
|
|
10
|
+
/** 轻量校验 discovery.md 的链路五要素段。只校验结构和阻塞未知,不判断业务真假。 */
|
|
11
|
+
export declare function validateDiscoveryChainCoverage(content: string): DiscoveryChainCoverageCheck;
|
|
3
12
|
/** 完整校验 discovery.md:存在 + 非空 + 无未确认问题 */
|
|
4
13
|
export declare function validateDiscovery(changeRoot: string): {
|
|
5
14
|
ok: boolean;
|
|
@@ -22,18 +31,51 @@ export interface ParsedTask {
|
|
|
22
31
|
tddRequired: boolean;
|
|
23
32
|
noTddReason: string | null;
|
|
24
33
|
}
|
|
34
|
+
export interface ParsedExecutionRequirement {
|
|
35
|
+
taskId: string;
|
|
36
|
+
lineIdx: number;
|
|
37
|
+
contract: ExecutionContract;
|
|
38
|
+
errors: string[];
|
|
39
|
+
}
|
|
40
|
+
export interface TestContractEntry {
|
|
41
|
+
test_id: string;
|
|
42
|
+
scenario: string;
|
|
43
|
+
invariant: string;
|
|
44
|
+
}
|
|
45
|
+
export type TestContractParseResult = {
|
|
46
|
+
ok: true;
|
|
47
|
+
entries: TestContractEntry[];
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
entries: [];
|
|
51
|
+
message: string;
|
|
52
|
+
};
|
|
25
53
|
/** 解析 tasks.md 的全部任务行 */
|
|
26
54
|
export declare function parseTasksMd(content: string): ParsedTask[];
|
|
55
|
+
export declare function hasTaskBoundExecutionRequirements(content: string): boolean;
|
|
56
|
+
export declare function parseExecutionRequirements(content: string): ParsedExecutionRequirement[];
|
|
57
|
+
export declare function orphanExecutionRequirementErrors(content: string): string[];
|
|
58
|
+
export declare function executionRequirementForTask(content: string, taskId: string): ParsedExecutionRequirement | null;
|
|
59
|
+
export declare function adoptedContractForTask(content: string, taskId: string, contractMode: boolean): {
|
|
60
|
+
parsed: ParsedExecutionRequirement | null;
|
|
61
|
+
contract: ExecutionContract | null;
|
|
62
|
+
};
|
|
63
|
+
export declare function isReviewFixTaskId(taskId: string): boolean;
|
|
64
|
+
export declare function isCharacterizationTask(task: ParsedTask): boolean;
|
|
65
|
+
export declare function parseTestContractEntries(content: string): TestContractParseResult;
|
|
66
|
+
export interface ExecutionRequirementValidation {
|
|
67
|
+
ok: boolean;
|
|
68
|
+
mode: boolean;
|
|
69
|
+
contracts: ParsedExecutionRequirement[];
|
|
70
|
+
errors: string[];
|
|
71
|
+
}
|
|
72
|
+
export declare function validateExecutionRequirements(content: string, testContractContent: string | null): ExecutionRequirementValidation;
|
|
27
73
|
/** 返回未完成任务 */
|
|
28
74
|
export declare function pendingTasksInContent(content: string): ParsedTask[];
|
|
29
75
|
/** 在 tasks.md 中按 taskId 精确查找任务(词边界,不误判子串) */
|
|
30
76
|
export declare function findTaskInLines(lines: string[], taskId: string): number;
|
|
31
77
|
/** tasks.md 结构指纹(复选框归一化) */
|
|
32
78
|
export declare function tasksStructureDigest(content: string, sha256Text: (s: string) => string): string;
|
|
33
|
-
export declare function validateTestRunInput(tr: Record<string, unknown>): {
|
|
34
|
-
ok: boolean;
|
|
35
|
-
message: string;
|
|
36
|
-
};
|
|
37
79
|
export declare function validateUserDecision(d: Record<string, unknown>): {
|
|
38
80
|
ok: boolean;
|
|
39
81
|
message: string;
|