@peterxiaoyang/superspec 0.1.38 → 0.1.39
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/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 +65 -64
- package/templates/workflow/skills/superspec-propose/SKILL.md +64 -43
- package/templates/workflow/skills/superspec-review/SKILL.md +1 -1
package/dist/format.js
CHANGED
|
@@ -16,23 +16,90 @@ function escapeRegex(value) {
|
|
|
16
16
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
17
|
}
|
|
18
18
|
function countOpenChecklistItemsInSection(content, headings) {
|
|
19
|
+
const sectionBody = sectionBodyByHeadings(content, headings);
|
|
20
|
+
if (sectionBody == null)
|
|
21
|
+
return 0;
|
|
22
|
+
// 数未确认项
|
|
23
|
+
const matches = sectionBody.match(/^\s*-\s+\[ \]/gm);
|
|
24
|
+
return matches ? matches.length : 0;
|
|
25
|
+
}
|
|
26
|
+
function sectionBodyByHeadings(content, headings) {
|
|
19
27
|
const headingPattern = headings.map(escapeRegex).join("|");
|
|
20
28
|
const sectionMatch = new RegExp(`^#{1,6}\\s*(?:${headingPattern})\\s*$`, "im").exec(content);
|
|
21
29
|
if (!sectionMatch)
|
|
22
|
-
return
|
|
30
|
+
return null;
|
|
23
31
|
const sectionStart = sectionMatch.index + sectionMatch[0].length;
|
|
24
32
|
// 截取到下一个标题或文件末尾
|
|
25
33
|
const restContent = content.slice(sectionStart);
|
|
26
34
|
const nextHeadingMatch = restContent.match(/^#{1,6}\s+/m);
|
|
27
|
-
|
|
28
|
-
// 数未确认项
|
|
29
|
-
const matches = sectionBody.match(/^\s*-\s+\[ \]/gm);
|
|
30
|
-
return matches ? matches.length : 0;
|
|
35
|
+
return nextHeadingMatch ? restContent.slice(0, nextHeadingMatch.index) : restContent;
|
|
31
36
|
}
|
|
32
37
|
/** 从 discovery.md 提取"待确认问题"段内的未确认项数量 */
|
|
33
38
|
export function countDiscoveryOpenQuestions(content) {
|
|
34
39
|
return countOpenChecklistItemsInSection(content, ["待确认问题", "Open Questions", "Pending Questions"]);
|
|
35
40
|
}
|
|
41
|
+
const DISCOVERY_CHAIN_HEADINGS = ["链路五要素"];
|
|
42
|
+
const DISCOVERY_CHAIN_REQUIRED_COLUMNS = [
|
|
43
|
+
"ID",
|
|
44
|
+
"发现方式",
|
|
45
|
+
"上游来源",
|
|
46
|
+
"规则变形",
|
|
47
|
+
"持久化语义",
|
|
48
|
+
"下游消费者",
|
|
49
|
+
"视图差异",
|
|
50
|
+
"未知/排除",
|
|
51
|
+
"证据",
|
|
52
|
+
"状态",
|
|
53
|
+
];
|
|
54
|
+
export function splitMarkdownTableRow(line) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (!trimmed.startsWith("|") || !trimmed.endsWith("|"))
|
|
57
|
+
return [];
|
|
58
|
+
// GFM 表格中字面竖线写作 \|,按未转义竖线切分后还原
|
|
59
|
+
return trimmed.slice(1, -1).split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, "|"));
|
|
60
|
+
}
|
|
61
|
+
function isMarkdownTableSeparator(line) {
|
|
62
|
+
return /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line);
|
|
63
|
+
}
|
|
64
|
+
/** 轻量校验 discovery.md 的链路五要素段。只校验结构和阻塞未知,不判断业务真假。 */
|
|
65
|
+
export function validateDiscoveryChainCoverage(content) {
|
|
66
|
+
const sectionBody = sectionBodyByHeadings(content, DISCOVERY_CHAIN_HEADINGS);
|
|
67
|
+
if (sectionBody == null) {
|
|
68
|
+
// 兼容缺少链路五要素段的历史 discovery:不阻塞,实质要求由 critic 审查
|
|
69
|
+
return { ok: true, message: "discovery.md 未声明链路五要素", present: false };
|
|
70
|
+
}
|
|
71
|
+
const tableLines = sectionBody.split("\n").filter(line => line.trim().startsWith("|"));
|
|
72
|
+
if (tableLines.length < 2) {
|
|
73
|
+
return { ok: false, message: "链路五要素缺少 Markdown 表格", present: true };
|
|
74
|
+
}
|
|
75
|
+
const header = splitMarkdownTableRow(tableLines[0]);
|
|
76
|
+
if (header.length === 0 || !isMarkdownTableSeparator(tableLines[1])) {
|
|
77
|
+
return { ok: false, message: "链路五要素表格格式无效", present: true };
|
|
78
|
+
}
|
|
79
|
+
const missingColumns = DISCOVERY_CHAIN_REQUIRED_COLUMNS.filter(col => !header.includes(col));
|
|
80
|
+
if (missingColumns.length > 0) {
|
|
81
|
+
return { ok: false, message: `链路五要素缺少必需列:${missingColumns.join(", ")}`, present: true };
|
|
82
|
+
}
|
|
83
|
+
const rows = tableLines.slice(2).map(splitMarkdownTableRow).filter(cells => cells.length > 0);
|
|
84
|
+
if (rows.length === 0) {
|
|
85
|
+
return { ok: false, message: "链路五要素至少需要一行链路记录", present: true };
|
|
86
|
+
}
|
|
87
|
+
const statusIdx = header.indexOf("状态");
|
|
88
|
+
const requiredColumnIndexes = DISCOVERY_CHAIN_REQUIRED_COLUMNS.map(col => ({ col, idx: header.indexOf(col) }));
|
|
89
|
+
for (const [idx, cells] of rows.entries()) {
|
|
90
|
+
const rowNum = idx + 1;
|
|
91
|
+
for (const { col, idx: colIdx } of requiredColumnIndexes) {
|
|
92
|
+
if (!(cells[colIdx]?.trim())) {
|
|
93
|
+
return { ok: false, message: `链路五要素第 ${rowNum} 行缺少${col}`, present: true };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const status = cells[statusIdx]?.trim() ?? "";
|
|
97
|
+
if (status.includes("未知阻塞") && countDiscoveryOpenQuestions(content) === 0) {
|
|
98
|
+
return { ok: false, message: "链路五要素存在未知阻塞,但待确认问题中没有未解决项", present: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, message: "链路五要素就绪", present: true };
|
|
102
|
+
}
|
|
36
103
|
/** 完整校验 discovery.md:存在 + 非空 + 无未确认问题 */
|
|
37
104
|
export function validateDiscovery(changeRoot) {
|
|
38
105
|
const path = join(changeRoot, ".superspec", "artifacts", "discovery.md");
|
|
@@ -41,6 +108,9 @@ export function validateDiscovery(changeRoot) {
|
|
|
41
108
|
const content = readFileSync(path, "utf8");
|
|
42
109
|
if (!content.trim())
|
|
43
110
|
return { ok: false, message: "discovery.md 为空", openCount: -1 };
|
|
111
|
+
const chainCoverage = validateDiscoveryChainCoverage(content);
|
|
112
|
+
if (!chainCoverage.ok)
|
|
113
|
+
return { ok: false, message: chainCoverage.message, openCount: -1 };
|
|
44
114
|
const openCount = countDiscoveryOpenQuestions(content);
|
|
45
115
|
if (openCount > 0)
|
|
46
116
|
return { ok: false, message: `discovery.md 有 ${openCount} 个未确认问题`, openCount };
|
|
@@ -71,6 +141,21 @@ export function collectProposeOpenQuestions(changeRoot) {
|
|
|
71
141
|
};
|
|
72
142
|
}
|
|
73
143
|
const TASK_LINE_RE = /^(- \[([ xX])\])\s+(\S+)/;
|
|
144
|
+
// 块头独占一行(允许全角/半角冒号);PREFIX 变体用于识别"块头带尾部内容/误加 bullet"的格式错误
|
|
145
|
+
const EXECUTION_REQUIREMENT_LINE_RE = /^\s*执行依据[::]\s*$/;
|
|
146
|
+
const EXECUTION_REQUIREMENT_PREFIX_RE = /^\s*-?\s*执行依据[::]/;
|
|
147
|
+
const CONTRACT_FIELD_ALIASES = {
|
|
148
|
+
"测试": "tests",
|
|
149
|
+
"Tests": "tests",
|
|
150
|
+
"设计": "design",
|
|
151
|
+
"Design": "design",
|
|
152
|
+
"来源": "source",
|
|
153
|
+
"Source": "source",
|
|
154
|
+
"原因": "reason",
|
|
155
|
+
"Reason": "reason",
|
|
156
|
+
"边界": "guard",
|
|
157
|
+
"Guard": "guard",
|
|
158
|
+
};
|
|
74
159
|
/** 解析 tasks.md 的全部任务行 */
|
|
75
160
|
export function parseTasksMd(content) {
|
|
76
161
|
const lines = content.split("\n");
|
|
@@ -94,6 +179,221 @@ export function parseTasksMd(content) {
|
|
|
94
179
|
}
|
|
95
180
|
return tasks;
|
|
96
181
|
}
|
|
182
|
+
function isTopLevelTaskLine(line) {
|
|
183
|
+
return TASK_LINE_RE.test(line);
|
|
184
|
+
}
|
|
185
|
+
function splitSourceRefs(value) {
|
|
186
|
+
return value
|
|
187
|
+
.split(/[;;]/)
|
|
188
|
+
.map(item => item.trim())
|
|
189
|
+
.filter(Boolean);
|
|
190
|
+
}
|
|
191
|
+
function parseTestIds(value) {
|
|
192
|
+
const ids = value.match(/\bTEST-[A-Za-z0-9_-]+\b/g) ?? [];
|
|
193
|
+
return { ids: [...new Set(ids)].sort(), ok: ids.length > 0 || value.trim() === "" };
|
|
194
|
+
}
|
|
195
|
+
function emptyContract() {
|
|
196
|
+
return {
|
|
197
|
+
tests: [],
|
|
198
|
+
design: null,
|
|
199
|
+
source: [],
|
|
200
|
+
reason: null,
|
|
201
|
+
guard: null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
// 共享绑定判定:mode 判定与块解析都走这里,避免两处条件分裂。
|
|
205
|
+
// 从 task 行往下最多跳过 1 个纯空行(AI 生成时的常见格式波动,意图无歧义,直接接受);
|
|
206
|
+
// 间隔更多空行或夹了其他内容的块不绑定,由孤儿检测报错。
|
|
207
|
+
function executionRequirementHeaderIdx(lines, task) {
|
|
208
|
+
let index = task.lineIdx + 1;
|
|
209
|
+
if (index < lines.length && lines[index].trim() === "")
|
|
210
|
+
index += 1;
|
|
211
|
+
if (index < lines.length && EXECUTION_REQUIREMENT_LINE_RE.test(lines[index]))
|
|
212
|
+
return index;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
function parseExecutionRequirementBlock(lines, task, headerIdx) {
|
|
216
|
+
const contract = emptyContract();
|
|
217
|
+
const errors = [];
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
let index = headerIdx + 1;
|
|
220
|
+
while (index < lines.length) {
|
|
221
|
+
const line = lines[index];
|
|
222
|
+
if (isTopLevelTaskLine(line) || /^#{1,6}\s+/.test(line))
|
|
223
|
+
break;
|
|
224
|
+
if (/^\s*-\s+\[[ xX]\]/.test(line)) {
|
|
225
|
+
errors.push(`${task.taskId} 的执行依据内不能包含复选框`);
|
|
226
|
+
}
|
|
227
|
+
const fieldMatch = line.match(/^\s*-\s*(测试|Tests|设计|Design|来源|Source|原因|Reason|边界|Guard)\s*[::]\s*(.*)$/);
|
|
228
|
+
if (fieldMatch) {
|
|
229
|
+
const key = CONTRACT_FIELD_ALIASES[fieldMatch[1]];
|
|
230
|
+
const value = fieldMatch[2].trim();
|
|
231
|
+
if (seen.has(key)) {
|
|
232
|
+
errors.push(`${task.taskId} 的执行依据字段重复:${fieldMatch[1]}`);
|
|
233
|
+
}
|
|
234
|
+
seen.add(key);
|
|
235
|
+
if (key === "tests") {
|
|
236
|
+
const parsed = parseTestIds(value);
|
|
237
|
+
if (!parsed.ok)
|
|
238
|
+
errors.push(`${task.taskId} 的测试字段没有可解析的 TEST ID`);
|
|
239
|
+
contract.tests = parsed.ids;
|
|
240
|
+
}
|
|
241
|
+
else if (key === "source") {
|
|
242
|
+
contract.source = splitSourceRefs(value);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
contract[key] = value || null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
index += 1;
|
|
249
|
+
}
|
|
250
|
+
return { taskId: task.taskId, lineIdx: task.lineIdx, contract, errors };
|
|
251
|
+
}
|
|
252
|
+
export function hasTaskBoundExecutionRequirements(content) {
|
|
253
|
+
const lines = content.split("\n");
|
|
254
|
+
return parseTasksMd(content).some(task => executionRequirementHeaderIdx(lines, task) != null);
|
|
255
|
+
}
|
|
256
|
+
export function parseExecutionRequirements(content) {
|
|
257
|
+
const lines = content.split("\n");
|
|
258
|
+
const parsed = [];
|
|
259
|
+
for (const task of parseTasksMd(content)) {
|
|
260
|
+
const headerIdx = executionRequirementHeaderIdx(lines, task);
|
|
261
|
+
if (headerIdx != null)
|
|
262
|
+
parsed.push(parseExecutionRequirementBlock(lines, task, headerIdx));
|
|
263
|
+
}
|
|
264
|
+
return parsed;
|
|
265
|
+
}
|
|
266
|
+
// 孤儿执行依据检测:块头存在但没有绑定到任何 task(间隔过多空行、夹了其他内容、
|
|
267
|
+
// 或块头带尾部文字导致整行不匹配)。这些块会被引擎静默忽略,必须显式报错阻断,
|
|
268
|
+
// 否则全部悬空时契约模式静默失效(使用者是 AI,报错消息要给出可直接执行的修复动作)。
|
|
269
|
+
export function orphanExecutionRequirementErrors(content) {
|
|
270
|
+
const lines = content.split("\n");
|
|
271
|
+
const boundHeaderIdx = new Set();
|
|
272
|
+
for (const task of parseTasksMd(content)) {
|
|
273
|
+
const headerIdx = executionRequirementHeaderIdx(lines, task);
|
|
274
|
+
if (headerIdx != null)
|
|
275
|
+
boundHeaderIdx.add(headerIdx);
|
|
276
|
+
}
|
|
277
|
+
const errors = [];
|
|
278
|
+
for (let i = 0; i < lines.length; i++) {
|
|
279
|
+
if (boundHeaderIdx.has(i))
|
|
280
|
+
continue;
|
|
281
|
+
if (EXECUTION_REQUIREMENT_LINE_RE.test(lines[i])) {
|
|
282
|
+
errors.push(`第 ${i + 1} 行的"执行依据:"没有绑定到任何 task(块头与 task 行之间最多允许一个空行),该块不会生效;请把它紧贴到所属 task 行下方`);
|
|
283
|
+
}
|
|
284
|
+
else if (EXECUTION_REQUIREMENT_PREFIX_RE.test(lines[i])) {
|
|
285
|
+
errors.push(`第 ${i + 1} 行的"执行依据:"块头格式无法识别;块头必须独占一行(不带 bullet、冒号后不带内容),字段写在下方的 bullet 行`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return errors;
|
|
289
|
+
}
|
|
290
|
+
export function executionRequirementForTask(content, taskId) {
|
|
291
|
+
return parseExecutionRequirements(content).find(item => item.taskId === taskId) ?? null;
|
|
292
|
+
}
|
|
293
|
+
// 契约"解析"与"采纳"的统一出口:contract 非 null 当且仅当本轮是契约模式且该 task 有绑定块。
|
|
294
|
+
// 消费方(attempt/details/packet)一律从这里取值,禁止各自组合 parsed 与 mode——
|
|
295
|
+
// 否则 legacy 轮会输出"看似契约、实按 legacy 校验"的误导态。
|
|
296
|
+
export function adoptedContractForTask(content, taskId, contractMode) {
|
|
297
|
+
const parsed = executionRequirementForTask(content, taskId);
|
|
298
|
+
return { parsed, contract: contractMode ? parsed?.contract ?? null : null };
|
|
299
|
+
}
|
|
300
|
+
export function isReviewFixTaskId(taskId) {
|
|
301
|
+
return taskId.startsWith("REVIEW-FIX-");
|
|
302
|
+
}
|
|
303
|
+
export function isCharacterizationTask(task) {
|
|
304
|
+
return task.tddRequired === false && task.noTddReason === "characterization";
|
|
305
|
+
}
|
|
306
|
+
export function parseTestContractEntries(content) {
|
|
307
|
+
const lines = content.split("\n");
|
|
308
|
+
const entries = [];
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
let sawMatchingHeader = false;
|
|
311
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
312
|
+
const header = splitMarkdownTableRow(lines[i]);
|
|
313
|
+
if (header.length === 0)
|
|
314
|
+
continue;
|
|
315
|
+
const normalizedHeader = header.map(cell => cell.trim().toLowerCase());
|
|
316
|
+
const testIdIdx = normalizedHeader.indexOf("test_id");
|
|
317
|
+
const scenarioIdx = normalizedHeader.indexOf("scenario");
|
|
318
|
+
if (testIdIdx < 0 || scenarioIdx < 0)
|
|
319
|
+
continue;
|
|
320
|
+
if (!isMarkdownTableSeparator(lines[i + 1]))
|
|
321
|
+
continue;
|
|
322
|
+
sawMatchingHeader = true;
|
|
323
|
+
const invariantIdx = normalizedHeader.indexOf("invariant");
|
|
324
|
+
for (let rowIndex = i + 2; rowIndex < lines.length; rowIndex++) {
|
|
325
|
+
const row = splitMarkdownTableRow(lines[rowIndex]);
|
|
326
|
+
if (row.length === 0)
|
|
327
|
+
break;
|
|
328
|
+
const testId = (row[testIdIdx] ?? "").trim();
|
|
329
|
+
if (!testId.startsWith("TEST-"))
|
|
330
|
+
continue;
|
|
331
|
+
if (seen.has(testId)) {
|
|
332
|
+
return { ok: false, entries: [], message: `test-contract.md 中 TEST ID 重复:${testId}` };
|
|
333
|
+
}
|
|
334
|
+
seen.add(testId);
|
|
335
|
+
entries.push({
|
|
336
|
+
test_id: testId,
|
|
337
|
+
scenario: (row[scenarioIdx] ?? "").trim(),
|
|
338
|
+
invariant: invariantIdx >= 0 ? (row[invariantIdx] ?? "").trim() : "",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!sawMatchingHeader)
|
|
343
|
+
return { ok: false, entries: [], message: "test-contract.md 缺少包含测试 ID(test_id)和场景(scenario)的表格" };
|
|
344
|
+
if (entries.length === 0)
|
|
345
|
+
return { ok: false, entries: [], message: "test-contract.md 没有 TEST-* 行" };
|
|
346
|
+
return { ok: true, entries };
|
|
347
|
+
}
|
|
348
|
+
export function validateExecutionRequirements(content, testContractContent) {
|
|
349
|
+
const mode = hasTaskBoundExecutionRequirements(content);
|
|
350
|
+
const contracts = parseExecutionRequirements(content);
|
|
351
|
+
// 孤儿检测必须在 mode=false 的 early return 之前:全部块都悬空时 mode=false,
|
|
352
|
+
// 恰恰是最需要报错的场景(否则契约模式静默失效)
|
|
353
|
+
const errors = [...orphanExecutionRequirementErrors(content), ...contracts.flatMap(item => item.errors)];
|
|
354
|
+
if (!mode)
|
|
355
|
+
return { ok: errors.length === 0, mode, contracts, errors };
|
|
356
|
+
const tasks = parseTasksMd(content);
|
|
357
|
+
const contractsByTask = new Map(contracts.map(item => [item.taskId, item]));
|
|
358
|
+
const declaredTestIds = new Set();
|
|
359
|
+
let needsTestContract = false;
|
|
360
|
+
for (const task of tasks) {
|
|
361
|
+
const contract = contractsByTask.get(task.taskId);
|
|
362
|
+
if (task.tddRequired && !isReviewFixTaskId(task.taskId)) {
|
|
363
|
+
if (!contract) {
|
|
364
|
+
errors.push(`${task.taskId} 缺少执行依据`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (contract.contract.tests.length === 0) {
|
|
368
|
+
errors.push(`${task.taskId} 是普通 TDD 任务,执行依据缺少测试`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (contract && contract.contract.tests.length > 0) {
|
|
372
|
+
needsTestContract = true;
|
|
373
|
+
for (const testId of contract.contract.tests)
|
|
374
|
+
declaredTestIds.add(testId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (needsTestContract) {
|
|
378
|
+
if (testContractContent == null) {
|
|
379
|
+
errors.push("test-contract.md 不存在,无法校验执行依据测试引用");
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const parsed = parseTestContractEntries(testContractContent);
|
|
383
|
+
if (!parsed.ok) {
|
|
384
|
+
errors.push(parsed.message);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
const known = new Set(parsed.entries.map(entry => entry.test_id));
|
|
388
|
+
for (const testId of declaredTestIds) {
|
|
389
|
+
if (!known.has(testId))
|
|
390
|
+
errors.push(`执行依据引用了不存在的 TEST ID:${testId}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { ok: errors.length === 0, mode, contracts, errors };
|
|
396
|
+
}
|
|
97
397
|
/** 返回未完成任务 */
|
|
98
398
|
export function pendingTasksInContent(content) {
|
|
99
399
|
return parseTasksMd(content).filter(task => !task.done);
|
|
@@ -116,7 +416,7 @@ export function findTaskInLines(lines, taskId) {
|
|
|
116
416
|
export function tasksStructureDigest(content, sha256Text) {
|
|
117
417
|
return sha256Text(content.replace(/- \[[xX]\]/g, "- [ ]"));
|
|
118
418
|
}
|
|
119
|
-
// =====
|
|
419
|
+
// ===== 测试运行 JSON =====
|
|
120
420
|
//
|
|
121
421
|
// 格式(apply skill 定义):
|
|
122
422
|
// {
|
|
@@ -131,23 +431,8 @@ export function tasksStructureDigest(content, sha256Text) {
|
|
|
131
431
|
// "target_fingerprint": "sha256:..."
|
|
132
432
|
// }
|
|
133
433
|
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
if (!tr.test_id)
|
|
137
|
-
return { ok: false, message: "缺少 test_id" };
|
|
138
|
-
if (!tr.task_structure_digest)
|
|
139
|
-
return { ok: false, message: "缺少 task_structure_digest" };
|
|
140
|
-
if (tr.covers_task_ids !== undefined) {
|
|
141
|
-
if (!Array.isArray(tr.covers_task_ids))
|
|
142
|
-
return { ok: false, message: "covers_task_ids 必须是字符串数组" };
|
|
143
|
-
if (tr.covers_task_ids.length === 0)
|
|
144
|
-
return { ok: false, message: "covers_task_ids 不能是空数组" };
|
|
145
|
-
if (!tr.covers_task_ids.every(item => typeof item === "string" && item.trim().length > 0)) {
|
|
146
|
-
return { ok: false, message: "covers_task_ids 不能包含空字符串或非字符串" };
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return { ok: true, message: "" };
|
|
150
|
-
}
|
|
434
|
+
// 引擎校验:测试 ID(test_id)+ 任务结构指纹(task_structure_digest)必填,回归覆盖任务列表(covers_task_ids)如存在必须是非空字符串数组,其余可选
|
|
435
|
+
//(实际校验内联在 task.ts recordTestRunLoaded:契约模式走 validateContractTestRunInput,legacy 模式内联必填校验)
|
|
151
436
|
// ===== user-decision JSON =====
|
|
152
437
|
//
|
|
153
438
|
// 格式(explore skill 定义):
|
|
@@ -157,12 +442,12 @@ export function validateTestRunInput(tr) {
|
|
|
157
442
|
// "answer": "yes"
|
|
158
443
|
// }
|
|
159
444
|
//
|
|
160
|
-
//
|
|
445
|
+
// 引擎校验:决策范围(scope)+ 答复内容(answer)必填
|
|
161
446
|
export function validateUserDecision(d) {
|
|
162
447
|
if (!d.scope)
|
|
163
|
-
return { ok: false, message: "
|
|
448
|
+
return { ok: false, message: "决策文件缺少决策范围(scope)" };
|
|
164
449
|
if (!d.answer)
|
|
165
|
-
return { ok: false, message: "
|
|
450
|
+
return { ok: false, message: "决策文件缺少答复内容(answer)" };
|
|
166
451
|
return { ok: true, message: "" };
|
|
167
452
|
}
|
|
168
453
|
// ===== business-invariants.md =====
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DirtyFileFingerprint } from "./types.ts";
|
|
2
|
+
export interface GitHeadResult {
|
|
3
|
+
head: string | null;
|
|
4
|
+
reason: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function currentGitHead(projectRoot: string): GitHeadResult;
|
|
7
|
+
export declare function normalizeGitPath(rawPath: string): string;
|
|
8
|
+
export declare function gitLines(projectRoot: string, args: string[]): {
|
|
9
|
+
ok: true;
|
|
10
|
+
lines: string[];
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function isCodeLikePath(path: string): boolean;
|
|
16
|
+
export declare function walkCodeFiles(root: string, dir?: string, out?: string[]): string[];
|
|
17
|
+
export declare function codeFileContentSha(projectRoot: string, path: string): string | null;
|
|
18
|
+
export declare function codeFileFingerprint(projectRoot: string, path: string, status: DirtyFileFingerprint["status"]): DirtyFileFingerprint;
|
|
19
|
+
export declare function dirtyCodeFiles(projectRoot: string): {
|
|
20
|
+
ok: true;
|
|
21
|
+
files: DirtyFileFingerprint[];
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
files: DirtyFileFingerprint[];
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function diffFingerprints(left: DirtyFileFingerprint[], right: DirtyFileFingerprint[]): string[];
|
|
28
|
+
export declare function dirtyCodePaths(projectRoot: string): {
|
|
29
|
+
ok: true;
|
|
30
|
+
paths: string[];
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
paths: string[];
|
|
34
|
+
reason: string;
|
|
35
|
+
};
|
|
36
|
+
export declare function projectHasReadableDirectory(projectRoot: string): boolean;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readlinkSync, statSync } from "node:fs";
|
|
3
|
+
import { extname, join } from "node:path";
|
|
4
|
+
import { sha256File, sha256Text } from "./store.js";
|
|
5
|
+
const PROCESS_DOC_RE = /^(?:openspec\/changes\/[^/]+\/)?(?:proposal|design|tasks)\.md$/;
|
|
6
|
+
const PROCESS_ARTIFACT_RE = /^(?:openspec\/changes\/[^/]+\/)?\.superspec\/artifacts\/(?:discovery|business-invariants|test-contract)\.md$/;
|
|
7
|
+
const CODE_EXTENSIONS = new Set([
|
|
8
|
+
".c", ".cc", ".cpp", ".cs", ".css", ".go", ".h", ".hpp", ".html", ".java", ".js", ".jsx",
|
|
9
|
+
".json", ".kt", ".mjs", ".mts", ".php", ".py", ".rb", ".rs", ".scss", ".sh", ".sql",
|
|
10
|
+
".swift", ".toml", ".ts", ".tsx", ".yaml", ".yml",
|
|
11
|
+
]);
|
|
12
|
+
const CODE_BASENAMES = new Set([
|
|
13
|
+
"Dockerfile", "Makefile", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
|
|
14
|
+
"tsconfig.json", "tsconfig.build.json", "eslint.config.js", "vite.config.ts", "webpack.config.js",
|
|
15
|
+
]);
|
|
16
|
+
const WALK_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".superspec", ".omx"]);
|
|
17
|
+
export function currentGitHead(projectRoot) {
|
|
18
|
+
try {
|
|
19
|
+
const head = execFileSync("git", ["-C", projectRoot, "rev-parse", "--verify", "HEAD"], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
22
|
+
}).trim();
|
|
23
|
+
return { head: head || null, reason: head ? "ok" : "empty_head" };
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
return {
|
|
27
|
+
head: null,
|
|
28
|
+
reason: err instanceof Error ? err.message : "git rev-parse failed",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function normalizeGitPath(rawPath) {
|
|
33
|
+
const trimmed = rawPath.trim();
|
|
34
|
+
const unquoted = trimmed.startsWith('"') && trimmed.endsWith('"')
|
|
35
|
+
? trimmed.slice(1, -1).replace(/\\"/g, '"')
|
|
36
|
+
: trimmed;
|
|
37
|
+
const renamed = unquoted.includes(" -> ") ? unquoted.split(" -> ").pop() ?? unquoted : unquoted;
|
|
38
|
+
return renamed.replace(/\\/g, "/");
|
|
39
|
+
}
|
|
40
|
+
export function gitLines(projectRoot, args) {
|
|
41
|
+
try {
|
|
42
|
+
const output = execFileSync("git", ["-C", projectRoot, ...args], {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
lines: output.split("\n").map(line => normalizeGitPath(line)).filter(Boolean),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return { ok: false, reason: err instanceof Error ? err.message : "git command failed" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function isProcessOrOrdinaryDoc(path) {
|
|
56
|
+
if (path.startsWith(".superspec/") || path.startsWith(".omx/"))
|
|
57
|
+
return true;
|
|
58
|
+
if (path.includes("/.superspec/") || path.includes("/.omx/"))
|
|
59
|
+
return true;
|
|
60
|
+
if (PROCESS_DOC_RE.test(path) || PROCESS_ARTIFACT_RE.test(path))
|
|
61
|
+
return true;
|
|
62
|
+
return extname(path).toLowerCase() === ".md";
|
|
63
|
+
}
|
|
64
|
+
export function isCodeLikePath(path) {
|
|
65
|
+
const normalized = path.replace(/\\/g, "/");
|
|
66
|
+
if (!normalized || isProcessOrOrdinaryDoc(normalized))
|
|
67
|
+
return false;
|
|
68
|
+
const base = normalized.split("/").pop() ?? normalized;
|
|
69
|
+
if (CODE_BASENAMES.has(base))
|
|
70
|
+
return true;
|
|
71
|
+
return CODE_EXTENSIONS.has(extname(base).toLowerCase());
|
|
72
|
+
}
|
|
73
|
+
export function walkCodeFiles(root, dir = root, out = []) {
|
|
74
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
if (WALK_SKIP_DIRS.has(entry.name))
|
|
77
|
+
continue;
|
|
78
|
+
walkCodeFiles(root, join(dir, entry.name), out);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!entry.isFile())
|
|
82
|
+
continue;
|
|
83
|
+
const full = join(dir, entry.name);
|
|
84
|
+
const rel = full.slice(root.length + 1).replace(/\\/g, "/");
|
|
85
|
+
if (isCodeLikePath(rel))
|
|
86
|
+
out.push(rel);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
export function codeFileContentSha(projectRoot, path) {
|
|
91
|
+
try {
|
|
92
|
+
const full = join(projectRoot, path);
|
|
93
|
+
const stat = lstatSync(full);
|
|
94
|
+
if (stat.isSymbolicLink())
|
|
95
|
+
return sha256Text(readlinkSync(full));
|
|
96
|
+
return sha256File(full);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function codeFileFingerprint(projectRoot, path, status) {
|
|
103
|
+
if (status === "deleted")
|
|
104
|
+
return { path, status, sha256: null };
|
|
105
|
+
return { path, status, sha256: codeFileContentSha(projectRoot, path) ?? "sha256:missing" };
|
|
106
|
+
}
|
|
107
|
+
function unquoteGitPath(path) {
|
|
108
|
+
return path.trim().replace(/^"|"$/g, "").replace(/\\"/g, '"').replace(/\\/g, "/");
|
|
109
|
+
}
|
|
110
|
+
export function dirtyCodeFiles(projectRoot) {
|
|
111
|
+
let output;
|
|
112
|
+
try {
|
|
113
|
+
output = execFileSync("git", ["-C", projectRoot, "status", "--porcelain", "--untracked-files=all"], {
|
|
114
|
+
encoding: "utf8",
|
|
115
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
files: [],
|
|
122
|
+
reason: err instanceof Error ? err.message : "git status failed",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const files = [];
|
|
126
|
+
for (const rawLine of output.split("\n")) {
|
|
127
|
+
if (!rawLine.trim())
|
|
128
|
+
continue;
|
|
129
|
+
const statusCode = rawLine.slice(0, 2);
|
|
130
|
+
const rawPath = rawLine.slice(3);
|
|
131
|
+
if (statusCode.includes("R") && rawPath.includes(" -> ")) {
|
|
132
|
+
const [oldRaw, newRaw] = rawPath.split(" -> ");
|
|
133
|
+
const oldPath = unquoteGitPath(oldRaw);
|
|
134
|
+
const newPath = unquoteGitPath(newRaw);
|
|
135
|
+
if (isCodeLikePath(oldPath))
|
|
136
|
+
files.push(codeFileFingerprint(projectRoot, oldPath, "deleted"));
|
|
137
|
+
if (isCodeLikePath(newPath))
|
|
138
|
+
files.push(codeFileFingerprint(projectRoot, newPath, "added"));
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const normalizedPath = unquoteGitPath(rawPath);
|
|
142
|
+
if (!isCodeLikePath(normalizedPath))
|
|
143
|
+
continue;
|
|
144
|
+
const deleted = statusCode.includes("D");
|
|
145
|
+
const added = statusCode.includes("A") || statusCode === "??";
|
|
146
|
+
files.push(codeFileFingerprint(projectRoot, normalizedPath, deleted ? "deleted" : added ? "added" : "modified"));
|
|
147
|
+
}
|
|
148
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
149
|
+
return { ok: true, files };
|
|
150
|
+
}
|
|
151
|
+
// 共享文件指纹原语:boundary_snapshot 与 code_state_check 的差异对比都走这里,
|
|
152
|
+
// 语义统一为「path 相同且 status、sha256 都一致才算未变化」,结果按路径字典序稳定输出。
|
|
153
|
+
export function diffFingerprints(left, right) {
|
|
154
|
+
const leftMap = new Map(left.map(file => [file.path, file]));
|
|
155
|
+
const rightMap = new Map(right.map(file => [file.path, file]));
|
|
156
|
+
const changed = [];
|
|
157
|
+
for (const path of new Set([...leftMap.keys(), ...rightMap.keys()])) {
|
|
158
|
+
const before = leftMap.get(path);
|
|
159
|
+
const after = rightMap.get(path);
|
|
160
|
+
if (!before || !after || before.status !== after.status || before.sha256 !== after.sha256) {
|
|
161
|
+
changed.push(path);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return changed.sort();
|
|
165
|
+
}
|
|
166
|
+
export function dirtyCodePaths(projectRoot) {
|
|
167
|
+
const dirty = dirtyCodeFiles(projectRoot);
|
|
168
|
+
if (!dirty.ok)
|
|
169
|
+
return { ok: false, paths: [], reason: dirty.reason };
|
|
170
|
+
return { ok: true, paths: [...new Set(dirty.files.map(file => file.path))].sort() };
|
|
171
|
+
}
|
|
172
|
+
export function projectHasReadableDirectory(projectRoot) {
|
|
173
|
+
return existsSync(projectRoot) && statSync(projectRoot).isDirectory();
|
|
174
|
+
}
|
package/dist/job_validity.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare function invalidReasonForSnapshot(input: {
|
|
|
10
10
|
job: Job;
|
|
11
11
|
projectRoot: string;
|
|
12
12
|
changeRoot: string;
|
|
13
|
+
events: Event[];
|
|
13
14
|
currentReviewEvidenceDigest: string;
|
|
14
15
|
}): string | null;
|
|
15
16
|
export declare function invalidReasonForSubmittedReport(job: Job, context: SubmittedReportValidityContext): string | null;
|
package/dist/job_validity.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { reviewEvidenceDigest, reviewVerifierStaleReason, } from "./review.js";
|
|
2
|
-
import { codeReviewJobStaleReason,
|
|
2
|
+
import { codeReviewJobStaleReason, currentCodeReviewWorkingPaths, } from "./code_review.js";
|
|
3
3
|
const MISSING_REVIEW_EVIDENCE_REASON = "最终验证工作项缺少执行证据版本";
|
|
4
4
|
function reviewReadyVerifierWithoutEvidenceReason(job) {
|
|
5
5
|
if (job.role === "verifier" &&
|
|
@@ -12,23 +12,26 @@ function reviewReadyVerifierWithoutEvidenceReason(job) {
|
|
|
12
12
|
}
|
|
13
13
|
export function invalidReasonForSnapshot(input) {
|
|
14
14
|
if (input.job.role === "code-reviewer") {
|
|
15
|
-
return codeReviewJobStaleReason(input.projectRoot, input.job);
|
|
15
|
+
return codeReviewJobStaleReason(input.projectRoot, input.job, currentCodeReviewWorkingPaths(input.projectRoot, input.events));
|
|
16
16
|
}
|
|
17
17
|
const invalidReviewReadyVerifier = reviewReadyVerifierWithoutEvidenceReason(input.job);
|
|
18
18
|
if (invalidReviewReadyVerifier)
|
|
19
19
|
return invalidReviewReadyVerifier;
|
|
20
|
-
return reviewVerifierStaleReason(input.job, input.changeRoot, input.currentReviewEvidenceDigest);
|
|
20
|
+
return reviewVerifierStaleReason(input.job, input.changeRoot, input.currentReviewEvidenceDigest, input.projectRoot, input.events);
|
|
21
21
|
}
|
|
22
22
|
export function invalidReasonForSubmittedReport(job, context) {
|
|
23
23
|
if (job.role === "code-reviewer") {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
const currentPaths = currentCodeReviewWorkingPaths(context.projectRoot, context.events, [
|
|
25
|
+
...(context.ignoredCodePaths ?? []),
|
|
26
|
+
...(context.reportPath ? [context.reportPath] : []),
|
|
27
|
+
]);
|
|
28
28
|
return codeReviewJobStaleReason(context.projectRoot, job, currentPaths);
|
|
29
29
|
}
|
|
30
30
|
const invalidReviewReadyVerifier = reviewReadyVerifierWithoutEvidenceReason(job);
|
|
31
31
|
if (invalidReviewReadyVerifier)
|
|
32
32
|
return invalidReviewReadyVerifier;
|
|
33
|
-
|
|
33
|
+
const ignored = new Set(context.ignoredCodePaths ?? []);
|
|
34
|
+
if (context.reportPath)
|
|
35
|
+
ignored.add(context.reportPath);
|
|
36
|
+
return reviewVerifierStaleReason(job, context.changeRoot, reviewEvidenceDigest(context.events), context.projectRoot, context.events, [...ignored]);
|
|
34
37
|
}
|