@tea-agent/loop-agent 0.33.1 → 0.33.2

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/workflows/dag/retry-policy.js +5 -0
  3. package/package.json +1 -1
  4. package/skills/analyze-product-dependencies/SKILL.md +74 -33
  5. package/skills/analyze-product-dependencies/references/api-documentation-schema.md +16 -11
  6. package/skills/analyze-product-dependencies/references/dependency-analysis-schema.md +20 -10
  7. package/skills/analyze-product-dependencies/references/example.md +9 -9
  8. package/skills/analyze-product-dependencies/references/forward-test-cases.md +93 -18
  9. package/skills/analyze-product-dependencies/references/input-contract.md +27 -4
  10. package/skills/analyze-product-dependencies/references/kb-integration.md +64 -0
  11. package/skills/analyze-product-dependencies/references/scouting-rules.md +25 -10
  12. package/skills/analyze-product-dependencies/scripts/test-validators.mjs +247 -54
  13. package/skills/analyze-product-dependencies/scripts/validate-api-documentation.mjs +122 -29
  14. package/skills/analyze-product-dependencies/scripts/validate-dependency-analysis.mjs +94 -36
  15. package/skills/analyze-product-dependencies/scripts/validate-product-requirement-input.mjs +37 -23
  16. package/skills/analyze-product-dependencies/scripts/validation-helpers.mjs +35 -43
  17. package/skills/analyze-product-requirements/SKILL.md +98 -43
  18. package/skills/analyze-product-requirements/references/acceptance-criteria.md +8 -12
  19. package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +28 -14
  20. package/skills/analyze-product-requirements/references/example.md +24 -6
  21. package/skills/analyze-product-requirements/references/forward-test-cases.md +87 -9
  22. package/skills/analyze-product-requirements/references/kb-integration.md +56 -0
  23. package/skills/analyze-product-requirements/references/product-analysis-schema.md +19 -10
  24. package/skills/analyze-product-requirements/references/product-requirement-schema.md +21 -12
  25. package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +45 -14
  26. package/skills/analyze-product-requirements/scripts/compute-source-identity.mjs +35 -0
  27. package/skills/analyze-product-requirements/scripts/test-validators.mjs +337 -29
  28. package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +38 -7
  29. package/skills/analyze-product-requirements/scripts/validate-product-requirement.mjs +41 -29
  30. package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +33 -22
  31. package/skills/analyze-product-requirements/scripts/validation-helpers.mjs +43 -24
  32. package/skills/analyze-product-dependencies/agents/openai.yaml +0 -4
  33. package/skills/analyze-product-requirements/agents/openai.yaml +0 -4
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, statSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
5
- import { apiBlocks, assertHeadings, field, headings, load, metadata, print, scopeIncludes, section, storyBlocks, tableValue, validateArtifactLocation } from "./validation-helpers.mjs";
3
+ import { dirname } from "node:path";
4
+ import { apiBlocks, assertHeadings, headings, load, metadata, print, scopeIncludes, section, tableValue, validateArtifactLocation, validateFrontmatter } from "./validation-helpers.mjs";
6
5
 
7
6
  const args = process.argv.slice(2);
8
7
  const targetIndex = args.indexOf("--target");
@@ -17,10 +16,11 @@ try { requirement = load(positional[0], "Product Requirement"); api = load(posit
17
16
  catch (error) { console.error(error.message); process.exit(2); }
18
17
 
19
18
  const errors = [];
19
+ validateFrontmatter(api.text, ["artifact_version", "artifact_type", "requirement_id", "api_status", "analysis_scope"], errors, "API Documentation");
20
20
  validateArtifactLocation(api, errors, "api-documentation.md");
21
21
  const scope = metadata(api.text, "analysis_scope");
22
22
  const upstreamScope = metadata(requirement.text, "analysis_scope");
23
- if (metadata(api.text, "artifact_version") !== "3.0") errors.push("artifact_version must be 3.0.");
23
+ if (metadata(api.text, "artifact_version") !== "4.0") errors.push("artifact_version must be 4.0.");
24
24
  if (metadata(api.text, "artifact_type") !== "api-documentation") errors.push("artifact_type must be api-documentation.");
25
25
  if (metadata(api.text, "api_status") !== "complete") errors.push("api_status must be complete.");
26
26
  if (!["backend", "both"].includes(scope)) errors.push("analysis_scope must be backend or both for API Documentation.");
@@ -28,25 +28,66 @@ if (targetIndex >= 0 && !["backend", "both"].includes(expectedTarget)) errors.pu
28
28
  else if (expectedTarget && scope !== expectedTarget) errors.push(`analysis_scope ${scope} does not match requested target ${expectedTarget}.`);
29
29
  if (!scopeIncludes(upstreamScope, scope)) errors.push("analysis_scope must be included in Product Requirement scope.");
30
30
  if (metadata(api.text, "requirement_id") !== metadata(requirement.text, "requirement_id")) errors.push("requirement_id must match Product Requirement.");
31
- const source = metadata(api.text, "source_product_requirement");
32
- if (!source || resolve(dirname(api.path), source) !== requirement.path) errors.push("source_product_requirement must reference the supplied Product Requirement.");
33
31
  if (dirname(requirement.path) !== dirname(api.path)) errors.push("API Documentation and Product Requirement must be in the same requirement directory.");
34
- const repo = metadata(api.text, "repository_root");
35
- const repoPath = repo ? resolve(dirname(api.path), repo) : "";
36
- if (!repoPath || !existsSync(repoPath) || !statSync(repoPath).isDirectory()) errors.push("repository_root must be an existing readable directory.");
37
- const projectRoot = resolve(dirname(api.path), metadata(api.text, "project_root") ?? "");
38
- if (repoPath && projectRoot && repoPath !== projectRoot) errors.push("repository_root must resolve to project_root.");
39
32
 
40
33
  assertHeadings(api.text, 2, ["通用约定", "API 索引", "API 详情", "数据模型", "错误码"], errors, "API Documentation");
41
34
  const conventions = section(api.text, 2, "通用约定") ?? "";
42
- assertHeadings(conventions, 3, ["Base URL", "统一响应结构", "错误响应结构", "分页约定", "时间和标识符规范"], errors, "通用约定");
35
+ assertHeadings(conventions, 3, ["Base URL", "成功响应结构", "公共错误响应结构", "时间和标识符规范"], errors, "通用约定");
36
+ const paginationConventions = headings(conventions, 3).filter((item) => item.title.replace(/^\d+(?:\.\d+)*[.、]?[ \t]*/, "").trim() === "分页约定");
37
+ const publicError = section(conventions, 3, "公共错误响应结构") ?? "";
38
+ const publicErrorExample = publicError.match(/```json\s*([\s\S]*?)```/)?.[1];
39
+ if (!publicErrorExample) errors.push("公共错误响应结构 must include a JSON example.");
40
+ else try {
41
+ const parsed = JSON.parse(publicErrorExample);
42
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.hasOwn(parsed, "code")) errors.push("公共错误响应结构 JSON example must contain top-level code.");
43
+ } catch { errors.push("公共错误响应结构 JSON example must be valid."); }
43
44
  if (/(?:认证方式|认证要求|鉴权方式|鉴权要求|权限要求|权限规则|访问控制要求|Authorization|Bearer|X-API-Key|access[_-]?token|id[_-]?token|业务规则|业务逻辑|实现逻辑|处理逻辑|分支逻辑|数据读写逻辑|实现算法)/i.test(api.text)) errors.push("API Documentation must not contain authentication, permission, business-rule, or implementation-logic content.");
44
45
  if (/(?:复用检查|通用定义复用)/.test(api.text)) errors.push("API Documentation must not contain reuse-check sections or search evidence.");
46
+ if (/\b(?:BE-US|AC-BE)-\d{3,}\b/.test(api.text) || /^\|[^\r\n]*(?:用户故事|\bAC\b)[^\r\n]*\|$/m.test(api.text)) errors.push("API Documentation must not contain user-story or acceptance-criteria traceability.");
45
47
  const index = section(api.text, 2, "API 索引") ?? "";
48
+ const indexHeader = index.split(/\r?\n/).find((line) => /^\|/.test(line));
49
+ const indexColumns = indexHeader?.split("|").slice(1, -1).map((cell) => cell.trim()) ?? [];
50
+ if (indexColumns.join("|") !== ["API ID", "Method + Path", "Operation ID", "变更类型"].join("|")) {
51
+ errors.push("API index columns must be API ID, Method + Path, Operation ID, and 变更类型.");
52
+ }
53
+ const indexRows = index.split(/\r?\n/)
54
+ .filter((line) => /^\|/.test(line))
55
+ .slice(1)
56
+ .filter((line) => !/^\|?[-:| ]+\|?$/.test(line.trim()))
57
+ .map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
58
+ .map((cells) => ({ cells, id: cells[0], signature: cells[1], operation: cells[2], changeType: cells[3] }));
59
+ const indexIds = new Set();
60
+ const indexOperations = new Set();
61
+ for (const row of indexRows) {
62
+ if (row.cells.length !== 4 || !/^API-\d{3,}$/.test(row.id ?? "") || !/^(?:GET|POST|PUT|PATCH|DELETE)\s+\/\S+$/i.test(row.signature ?? "") || !row.operation || !["新增", "修改", "复用"].includes(row.changeType)) {
63
+ errors.push("Each API index row must contain a valid API ID, Method + Path, Operation ID, and 变更类型.");
64
+ continue;
65
+ }
66
+ if (indexIds.has(row.id)) errors.push(`Duplicate API ID in index: ${row.id}.`);
67
+ indexIds.add(row.id);
68
+ if (indexOperations.has(row.operation)) errors.push(`Duplicate Operation ID in index: ${row.operation}.`);
69
+ indexOperations.add(row.operation);
70
+ }
46
71
  const blocks = apiBlocks(api.text);
47
72
  if (!blocks.length) errors.push("API Documentation must contain at least one API-* detail.");
48
73
  const ids = new Set();
49
74
  const operations = new Set();
75
+ const paginationParameters = [];
76
+ const numericSet = (value) => [...new Set((value.match(/\b\d+\b/g) ?? []).map(Number))].sort((a, b) => a - b);
77
+ const sameNumbers = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
78
+ function markdownTableRows(block) {
79
+ const lines = block.split(/\r?\n/).filter((line) => /^\s*\|/.test(line));
80
+ if (lines.length < 2) return { headers: [], rows: [] };
81
+ const cells = (line) => line.trim()
82
+ .split(/(?<!\\)\|/)
83
+ .slice(1, -1)
84
+ .map((cell) => cell.replace(/\\\|/g, "|").trim());
85
+ const headers = cells(lines[0]);
86
+ const rows = lines.slice(1)
87
+ .filter((line) => !/^\s*\|?[-:| ]+\|?\s*$/.test(line))
88
+ .map(cells);
89
+ return { headers, rows };
90
+ }
50
91
  for (const block of blocks) {
51
92
  if (ids.has(block.id)) errors.push(`Duplicate API ID: ${block.id}.`);
52
93
  ids.add(block.id);
@@ -60,14 +101,26 @@ for (const block of blocks) {
60
101
  const signature = block.text.match(/^>\s*`(GET|POST|PUT|PATCH|DELETE)\s+(\/[^`]+)`/m);
61
102
  if (!signature) errors.push(`${block.id} is missing a Swagger-style method and path signature.`);
62
103
  for (const title of ["基本信息", "成功响应", "错误响应"]) if (!headings(block.text, 4).some((item) => item.title.trim() === title)) errors.push(`${block.id} is missing section ${title}.`);
104
+ for (const title of ["Header 参数", "Path 参数", "Query 参数", "Request Body"]) {
105
+ const optional = section(block.text, 4, title);
106
+ if (optional && !optional.split(/\r?\n/).slice(1).some((line) => line.trim() && !/^\|?[-:| ]+\|?$/.test(line.trim()))) {
107
+ errors.push(`${block.id} must omit empty ${title} sections.`);
108
+ }
109
+ }
63
110
  const query = section(block.text, 4, "Query 参数") ?? "";
64
- if (/\b(?:pageSize|page_size|limit|cursor|page)\b/i.test(query)) {
65
- const pageSizeRow = query.split(/\r?\n/).find((line) => /^\|\s*(?:pageSize|page_size|limit)\s*\|/i.test(line));
66
- if (!pageSizeRow) errors.push(`${block.id} paginated API must define an optional page-size parameter.`);
67
- else {
68
- if (!/^\|\s*(?:pageSize|page_size|limit)\s*\|\s*[^|]+\|\s*(?:否|可选)\s*\|/i.test(pageSizeRow)) errors.push(`${block.id} page-size parameter must be optional.`);
69
- const values = [...new Set(pageSizeRow.match(/\b\d+\b/g) ?? [])].sort((a, b) => Number(a) - Number(b));
70
- if (values.join(",") !== "10,20,50,100") errors.push(`${block.id} page-size values must be exactly 10, 20, 50, and 100.`);
111
+ if (query) {
112
+ const { headers, rows } = markdownTableRows(query);
113
+ const nameIndex = headers.findIndex((name) => /^(?:参数|名称|字段)$/.test(name));
114
+ const allowedIndex = headers.findIndex((name) => /^(?:允许值|可选值|枚举)$/.test(name));
115
+ for (const row of rows) {
116
+ const name = row[nameIndex] ?? "";
117
+ if (!/^(?:pageSize|page_size|limit)$/.test(name)) continue;
118
+ const allowed = allowedIndex >= 0 ? row[allowedIndex] ?? "" : "";
119
+ const values = numericSet(allowed);
120
+ paginationParameters.push({ apiId: block.id, name, values });
121
+ if (allowedIndex < 0 || !allowed || allowed === "-" || !values.length) {
122
+ errors.push(`${block.id} pagination parameter ${name} must declare numeric 允许值/可选值/枚举.`);
123
+ }
71
124
  }
72
125
  }
73
126
  if (signature) {
@@ -81,21 +134,61 @@ for (const block of blocks) {
81
134
  }
82
135
  const success = section(block.text, 4, "成功响应") ?? "";
83
136
  const error = section(block.text, 4, "错误响应") ?? "";
84
- if (!/HTTP(?: 状态码)?[::]\s*2\d\d/.test(success)) errors.push(`${block.id} must define a 2xx success response.`);
85
- if (!/^\|\s*[45]\d\d\s*\|/m.test(error)) errors.push(`${block.id} must define at least one 4xx or 5xx error response.`);
137
+ if (!/HTTP(?: 状态码)?[::]\s*200\b/.test(success)) errors.push(`${block.id} success response must use HTTP 200.`);
138
+ const errorRows = [...error.matchAll(/^\|\s*(\d{3})\s*\|\s*([^|]+?)\s*\|/gm)];
139
+ if (!errorRows.length) errors.push(`${block.id} must define at least one error response.`);
140
+ else if (errorRows.some((row) => row[1] !== "200")) errors.push(`${block.id} error responses must use HTTP 200.`);
141
+ const examples = {};
86
142
  for (const [content, label] of [[success, "success"], [error, "error"]]) {
87
143
  const example = content.match(/```json\s*([\s\S]*?)```/)?.[1];
88
144
  if (!example) errors.push(`${block.id} must include a JSON ${label} example.`);
89
- else try { JSON.parse(example); } catch { errors.push(`${block.id} ${label} example must be valid JSON.`); }
145
+ else try {
146
+ const parsed = JSON.parse(example);
147
+ examples[label] = parsed;
148
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.hasOwn(parsed, "code")) errors.push(`${block.id} ${label} example must contain top-level code.`);
149
+ } catch { errors.push(`${block.id} ${label} example must be valid JSON.`); }
150
+ }
151
+ if (examples.success && examples.error && examples.success.code === examples.error.code) errors.push(`${block.id} success and error codes must be different.`);
152
+ if (examples.error && Object.hasOwn(examples.error, "code") && errorRows.length && !errorRows.some((row) => row[2].trim() === String(examples.error.code))) {
153
+ errors.push(`${block.id} error example code must be listed in the error response table.`);
154
+ }
155
+ const indexRow = indexRows.find((row) => row.id === block.id);
156
+ if (!indexRow) errors.push(`${block.id} is missing from API index.`);
157
+ else {
158
+ if (operation && indexRow.operation !== operation) errors.push(`${block.id} Operation ID does not match API index.`);
159
+ if (signature && indexRow.signature !== `${signature[1]} ${signature[2]}`) errors.push(`${block.id} method and path do not match API index.`);
160
+ if (tableValue(block.text, "变更类型") && indexRow.changeType !== tableValue(block.text, "变更类型")) errors.push(`${block.id} 变更类型 does not match API index.`);
90
161
  }
91
- if (!index.includes(block.id)) errors.push(`${block.id} is missing from API index.`);
92
- if (operation && !index.includes(operation)) errors.push(`${block.id} Operation ID is missing from API index.`);
93
- if (signature && !index.includes(`${signature[1]} ${signature[2]}`)) errors.push(`${block.id} method and path are missing from API index.`);
94
162
  }
163
+ for (const row of indexRows) if (/^API-\d{3,}$/.test(row.id ?? "") && !ids.has(row.id)) errors.push(`${row.id} appears in API index without a matching API detail.`);
95
164
 
96
- const requiredApiStories = storyBlocks(section(requirement.text, 2, "后端用户故事"), "BE-US").filter((story) => /\bAPI\b/i.test(field(story.text, "触发方式") ?? ""));
97
- for (const story of requiredApiStories) {
98
- if (!index.includes(story.id)) errors.push(`API index does not cover API-triggered story ${story.id}.`);
99
- for (const ac of field(story.text, "验收标准")?.match(/AC-BE-\d{3,}/g) ?? []) if (!index.includes(ac)) errors.push(`API index does not trace ${ac}.`);
165
+ if (paginationParameters.length) {
166
+ if (paginationConventions.length !== 1) {
167
+ errors.push("API Documentation with pageSize/page_size/limit Query parameters must contain exactly one 分页约定 section.");
168
+ } else {
169
+ const conventionBody = paginationConventions[0].text.split(/\r?\n/).slice(1).join("\n");
170
+ const conventionValues = numericSet(conventionBody);
171
+ if (!conventionValues.length) errors.push("分页约定 must declare numeric page-size allowed values.");
172
+ for (const parameter of paginationParameters) {
173
+ if (parameter.values.length && conventionValues.length && !sameNumbers(parameter.values, conventionValues)) {
174
+ errors.push(`${parameter.apiId} ${parameter.name} allowed values must match 分页约定.`);
175
+ }
176
+ }
177
+ }
178
+ } else if (paginationConventions.length) {
179
+ errors.push("Non-paginated API Documentation must not contain 分页约定.");
100
180
  }
181
+
182
+ const requirementPaginationRules = [...requirement.text.matchAll(
183
+ /^(?=.*(?:pageSize|page_size|limit|每页条数))(?=.*(?:允许值|允许集合|取值范围))(.+)$/gmi,
184
+ )].map((match) => numericSet(match[1])).filter((values) => values.length);
185
+ if (requirementPaginationRules.length && paginationParameters.length) {
186
+ const upstreamValues = requirementPaginationRules[0];
187
+ for (const parameter of paginationParameters) {
188
+ if (parameter.values.length && !sameNumbers(parameter.values, upstreamValues)) {
189
+ errors.push(`${parameter.apiId} ${parameter.name} allowed values must match Product Requirement.`);
190
+ }
191
+ }
192
+ }
193
+
101
194
  print("API Documentation", api.path, errors);
@@ -1,39 +1,39 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, statSync } from "node:fs";
4
- import { basename, dirname, resolve } from "node:path";
5
- import { apiBlocks, assertHeadings, canonical, field, fieldBlock, headings, load, metadata, print, scopeIncludes, section, storyBlocks, tableValue, validateArtifactLocation } from "./validation-helpers.mjs";
3
+ import { basename, dirname } from "node:path";
4
+ import { apiBlocks, assertHeadings, canonical, field, fieldBlock, headings, load, metadata, print, scopeIncludes, section, storyBlocks, tableValue, validateArtifactLocation, validateFrontmatter } from "./validation-helpers.mjs";
6
5
 
7
6
  const args = process.argv.slice(2);
8
7
  const targetIndex = args.indexOf("--target");
9
8
  const expectedTarget = targetIndex >= 0 ? args[targetIndex + 1] : undefined;
10
- const positional = args.filter((_, index) => targetIndex < 0 || (index !== targetIndex && index !== targetIndex + 1));
9
+ const apiDocIndex = args.indexOf("--api-doc");
10
+ const externalApiArg = apiDocIndex >= 0 ? args[apiDocIndex + 1] : undefined;
11
+ const positional = args.filter((_, index) =>
12
+ (targetIndex < 0 || (index !== targetIndex && index !== targetIndex + 1))
13
+ && (apiDocIndex < 0 || (index !== apiDocIndex && index !== apiDocIndex + 1))
14
+ );
11
15
  const [requirementArg, dependencyArg, apiArg] = positional;
12
16
  if (!requirementArg || !dependencyArg) {
13
- console.error("Usage: node validate-dependency-analysis.mjs <product-requirement.md|none> <dependency-analysis.md> [api-documentation.md] [--target frontend|backend|both]");
17
+ console.error("Usage: node validate-dependency-analysis.mjs <product-requirement.md|none> <dependency-analysis.md> [api-documentation.md] [--api-doc external-api.md] [--target frontend|backend|both]");
14
18
  process.exit(2);
15
19
  }
16
- let requirement, dependency, api;
20
+ let requirement, dependency, api, externalApi;
17
21
  try {
18
22
  if (requirementArg !== "none") requirement = load(requirementArg, "Product Requirement");
19
23
  dependency = load(dependencyArg, "Dependency Analysis");
20
24
  if (apiArg) api = load(apiArg, "API Documentation");
25
+ if (externalApiArg) externalApi = load(externalApiArg, "external API Documentation");
21
26
  } catch (error) { console.error(error.message); process.exit(2); }
22
27
 
23
28
  const errors = [];
29
+ validateFrontmatter(dependency.text, ["artifact_version", "artifact_type", "requirement_id", "analysis_scope", "analysis_status", "blocked_on"], errors, "Dependency Analysis");
24
30
  validateArtifactLocation(dependency, errors, "dependency-analysis.md");
25
31
  const scope = metadata(dependency.text, "analysis_scope");
26
32
  const status = metadata(dependency.text, "analysis_status");
27
33
  const blockedOn = metadata(dependency.text, "blocked_on") ?? "";
28
34
  const blockedReasons = blockedOn.split(/[\s,,]+/).filter(Boolean);
29
- const source = metadata(dependency.text, "source_product_requirement");
30
- const sourceApi = metadata(dependency.text, "source_api_documentation");
31
- const repo = metadata(dependency.text, "repository_root");
32
- const repoPath = repo && repo !== "none" ? resolve(dirname(dependency.path), repo) : "";
33
- const repoExists = Boolean(repoPath && existsSync(repoPath) && statSync(repoPath).isDirectory());
34
- const projectRoot = resolve(dirname(dependency.path), metadata(dependency.text, "project_root") ?? "");
35
35
 
36
- if (metadata(dependency.text, "artifact_version") !== "3.0") errors.push("artifact_version must be 3.0.");
36
+ if (metadata(dependency.text, "artifact_version") !== "4.0") errors.push("artifact_version must be 4.0.");
37
37
  if (metadata(dependency.text, "artifact_type") !== "dependency-analysis") errors.push("artifact_type must be dependency-analysis.");
38
38
  if (!["frontend", "backend", "both"].includes(scope)) errors.push("analysis_scope must be frontend, backend, or both.");
39
39
  if (targetIndex >= 0 && !["frontend", "backend", "both"].includes(expectedTarget)) errors.push("--target must be frontend, backend, or both.");
@@ -45,21 +45,17 @@ function validateRequirementLink({ allowScopeMismatch = false } = {}) {
45
45
  if (basename(requirement.path) !== "product-requirement.md") errors.push("Product Requirement filename must be product-requirement.md.");
46
46
  if (!allowScopeMismatch && !scopeIncludes(metadata(requirement.text, "analysis_scope"), scope)) errors.push("analysis_scope must be included in Product Requirement scope.");
47
47
  if (metadata(dependency.text, "requirement_id") !== metadata(requirement.text, "requirement_id")) errors.push("requirement_id must match Product Requirement.");
48
- if (!source || resolve(dirname(dependency.path), source) !== requirement.path) errors.push("source_product_requirement must reference the supplied Product Requirement.");
49
48
  if (dirname(requirement.path) !== dirname(dependency.path)) errors.push("Dependency Analysis and Product Requirement must be in the same requirement directory.");
50
49
  }
51
50
 
52
51
  if (status === "blocked") {
53
- const allowed = new Set(["requirement-missing", "requirement-invalid", "requirement-not-complete", "repository-missing", "repository-unreadable", "scope-mismatch", "missing-stories", "missing-acceptance", "api-business-contract-incomplete"]);
52
+ const allowed = new Set(["requirement-missing", "requirement-invalid", "requirement-not-complete", "repository-missing", "repository-unreadable", "scope-mismatch", "missing-stories", "missing-acceptance", "api-business-contract-incomplete", "api-surface-unconfirmed"]);
54
53
  if (!blockedReasons.length || blockedOn === "none") errors.push("blocked analysis must declare blocked_on reasons.");
55
54
  for (const reason of blockedReasons) if (!allowed.has(reason)) errors.push(`Unknown blocked_on reason: ${reason}.`);
56
55
  if (requirement) validateRequirementLink({ allowScopeMismatch: blockedReasons.includes("scope-mismatch") });
57
56
  else {
58
57
  if (!blockedReasons.includes("requirement-missing")) errors.push("A missing Product Requirement requires blocked_on: requirement-missing.");
59
- if (source !== "none") errors.push("Missing Product Requirement must use source_product_requirement: none.");
60
58
  }
61
- if (!blockedReasons.some((reason) => ["repository-missing", "repository-unreadable"].includes(reason)) && !repoExists) errors.push("repository_root must be readable unless blocked on repository availability.");
62
- if (sourceApi !== "none") errors.push("Blocked Dependency Analysis must use source_api_documentation: none.");
63
59
  const required = ["分析范围", "输入与代码基线", "阻断原因", "恢复条件"];
64
60
  assertHeadings(dependency.text, 2, required, errors, "Blocked Dependency Analysis");
65
61
  for (const heading of headings(dependency.text, 2)) if (!required.includes(canonical(heading.title))) errors.push(`Blocked Dependency Analysis must not contain section ${canonical(heading.title)}.`);
@@ -70,13 +66,21 @@ if (status === "blocked") {
70
66
  if (!requirement) errors.push("Complete Dependency Analysis requires Product Requirement input.");
71
67
  else validateRequirementLink();
72
68
  if (blockedOn !== "none") errors.push("complete analysis must use blocked_on: none.");
73
- if (!repoExists) errors.push("repository_root must be an existing readable directory.");
74
- if (repoPath && repoPath !== projectRoot) errors.push("repository_root must resolve to project_root.");
75
- assertHeadings(dependency.text, 2, ["输入与代码基线", "用户故事覆盖矩阵", "跨故事共享依赖", "风险与未定位项"], errors, "Dependency Analysis");
76
- if (/^##\s+(?:\d+[.、]?\s*)?(?:分析范围|追溯汇总)\s*$/m.test(dependency.text)) errors.push("Complete V3 Dependency Analysis must not contain 分析范围 or 追溯汇总 sections.");
69
+ assertHeadings(dependency.text, 2, ["输入与代码基线", "用户故事覆盖矩阵", "API 实现映射", "跨故事共享依赖", "跨故事风险与未定位项"], errors, "Dependency Analysis");
70
+ const baseline = section(dependency.text, 2, "输入与代码基线") ?? "";
71
+ const knowledgeStatuses = baseline.match(/\b(?:executed-hit|executed-no-match|unavailable)\b/g) ?? [];
72
+ if (/\bnot-executed\b/.test(baseline)) errors.push("输入与代码基线 must not use not-executed; every dependency analysis requires a real knowledge search.");
73
+ if (knowledgeStatuses.length !== 1) errors.push("输入与代码基线 must contain exactly one knowledge status: executed-hit, executed-no-match, or unavailable.");
74
+ if (knowledgeStatuses[0] === "executed-hit" && !/\bKB-EVIDENCE-\d+\b/.test(baseline)) errors.push("executed-hit 输入与代码基线 must contain at least one KB-EVIDENCE-* marker.");
75
+ if (knowledgeStatuses[0] !== "executed-hit" && /\bKB-EVIDENCE-\d+\b/.test(baseline)) errors.push("KB-EVIDENCE-* markers require executed-hit knowledge status.");
76
+ if (knowledgeStatuses[0] === "unavailable" && !/^[ \t]*-[ \t]*用户处置[::][ \t]*已确认跳过知识库\s*$/m.test(baseline)) errors.push("unavailable 输入与代码基线 must record 用户处置:已确认跳过知识库.");
77
+ if (/^##\s+(?:\d+[.、]?\s*)?(?:分析范围|追溯汇总)\s*$/m.test(dependency.text)) errors.push("Complete V4 Dependency Analysis must not contain 分析范围 or 追溯汇总 sections.");
78
+ if (/^##\s+(?:\d+[.、]?\s*)?(?:全局文件索引|影响文件索引)\s*$/m.test(dependency.text)) errors.push("Dependency Analysis must not contain a global file index.");
79
+ if (/^####\s+(?:Header|Path|Query|Request Body|成功响应|错误响应)/mi.test(dependency.text) || /```json/i.test(dependency.text)) errors.push("Dependency Analysis must not copy full API contract sections or JSON examples.");
80
+ if (/^Given[::]|^When[::]|^Then[::]/m.test(dependency.text)) errors.push("Dependency Analysis must not copy Given/When/Then acceptance text.");
77
81
  if (scope === "frontend" || scope === "both") assertHeadings(dependency.text, 2, ["前端依赖详情"], errors, "Dependency Analysis");
78
- if (scope === "backend" || scope === "both") assertHeadings(dependency.text, 2, ["后端依赖详情", "API 实现映射"], errors, "Dependency Analysis");
79
- if (scope === "frontend" && /(?:^##\s+.*(?:后端依赖详情|API 实现映射)|\bBE-US-\d{3,}\b|\bAC-BE-\d{3,}\b|\bAPI-\d{3,}\b)/m.test(dependency.text)) errors.push("frontend scope must not contain backend dependency details or API mappings.");
82
+ if (scope === "backend" || scope === "both") assertHeadings(dependency.text, 2, ["后端依赖详情"], errors, "Dependency Analysis");
83
+ if (scope === "frontend" && /(?:^##\s+.*后端依赖详情|\bBE-US-\d{3,}\b|\bAC-BE-\d{3,}\b)/m.test(dependency.text)) errors.push("frontend scope must not contain backend dependency details.");
80
84
  if (scope === "backend" && /(?:^##\s+.*前端依赖详情|\bFE-US-\d{3,}\b|\bAC-FE-\d{3,}\b)/m.test(dependency.text)) errors.push("backend scope must not contain frontend dependency details.");
81
85
 
82
86
  const frontendStories = requirement && (scope === "frontend" || scope === "both") ? storyBlocks(section(requirement.text, 2, "前端用户故事"), "FE-US") : [];
@@ -84,27 +88,39 @@ const backendStories = requirement && (scope === "backend" || scope === "both")
84
88
  const apiRequired = backendStories.some((story) => /\bAPI\b/i.test(field(story.text, "触发方式") ?? ""));
85
89
  if (apiRequired) {
86
90
  if (!api) errors.push("API-triggered backend stories require api-documentation.md.");
87
- if (!sourceApi || sourceApi === "none") errors.push("API-triggered backend stories require source_api_documentation.");
88
- if (api && resolve(dirname(dependency.path), sourceApi) !== api.path) errors.push("source_api_documentation must reference the supplied API Documentation.");
89
- } else {
90
- if (api) errors.push("API Documentation must not be supplied when no selected backend story uses API.");
91
- if (sourceApi !== "none") errors.push("Non-API analysis must use source_api_documentation: none.");
92
- if ((scope === "backend" || scope === "both") && !/不适用.*不涉及 HTTP API/s.test(section(dependency.text, 2, "API 实现映射") ?? "")) errors.push("Non-API analysis must mark API 实现映射 not applicable.");
93
91
  }
92
+ if (!api && !externalApi && !/不适用.*不涉及 HTTP API/s.test(section(dependency.text, 2, "API 实现映射") ?? "")) errors.push("Analysis without API Documentation must mark API 实现映射 not applicable.");
93
+
94
+ const documentedApiIds = new Set(api ? apiBlocks(api.text).map((item) => item.id) : []);
95
+ const externalSignatures = new Set(
96
+ externalApi
97
+ ? [...externalApi.text.matchAll(/\b(GET|POST|PUT|PATCH|DELETE)\s+(\/[^\s`|]+)/gi)].map((item) => `${item[1].toUpperCase()} ${item[2]}`)
98
+ : []
99
+ );
100
+ const storyApiRefs = new Map();
101
+ const localRisks = [];
102
+ const frontendSpecs = requirement ? storyBlocks(section(requirement.text, 2, "前端输出规范"), "FE-US") : [];
94
103
 
95
104
  function validateDetails(frontend, expectedStories) {
96
105
  const title = frontend ? "前端依赖详情" : "后端依赖详情";
97
106
  const prefix = frontend ? "FE-US" : "BE-US";
98
107
  const acPrefix = frontend ? "AC-FE" : "AC-BE";
99
108
  const requiredFields = frontend
100
- ? ["验收标准", "页面/路由", "组件", "状态", "API client/类型", "状态与边界落点", "定位证据", "风险", "置信度"]
109
+ ? ["验收标准", "API 文档引用", "页面/路由", "组件", "状态", "API client/类型", "状态与边界落点", "定位证据", "风险", "置信度"]
101
110
  : ["验收标准", "API 文档引用", "路由/入口", "Controller/Handler", "Service/领域逻辑", "DTO/Schema", "数据依赖", "权限依赖", "错误/日志/审计", "测试落点", "定位证据", "风险", "置信度"];
102
111
  const details = storyBlocks(section(dependency.text, 2, title), prefix);
103
- for (const story of expectedStories) if (!details.some((item) => item.id === story.id)) errors.push(`Dependency Analysis is missing ${title} for ${story.id}.`);
112
+ for (const story of expectedStories) {
113
+ const count = details.filter((item) => item.id === story.id).length;
114
+ if (count === 0) errors.push(`Dependency Analysis is missing ${title} for ${story.id}.`);
115
+ if (count > 1) errors.push(`Dependency Analysis contains duplicate ${title} for ${story.id}.`);
116
+ }
104
117
  for (const detail of details) {
105
118
  const story = expectedStories.find((item) => item.id === detail.id);
106
119
  if (!story) errors.push(`Dependency Analysis contains unselected story ${detail.id}.`);
107
- for (const name of requiredFields) if (!field(detail.text, name)) errors.push(`${detail.id} dependency detail is missing field ${name}.`);
120
+ for (const name of requiredFields) {
121
+ const value = name === "页面/路由" ? fieldBlock(detail.text, name) : field(detail.text, name);
122
+ if (!value) errors.push(`${detail.id} dependency detail is missing field ${name}.`);
123
+ }
108
124
  const impact = fieldBlock(detail.text, "影响文件");
109
125
  if (!impact) errors.push(`${detail.id} dependency detail is missing field 影响文件.`);
110
126
  const declarations = [...impact.matchAll(/^\s*-\s*(F\d+)\s+(add|modify|reuse|新增|修改|复用)\s+`?([^`\r\n]+)`?/gmi)];
@@ -117,7 +133,35 @@ function validateDetails(frontend, expectedStories) {
117
133
  const expected = field(story.text, "验收标准")?.match(new RegExp(`${acPrefix}-\\d{3,}`, "g")) ?? [];
118
134
  const actual = field(detail.text, "验收标准")?.match(new RegExp(`${acPrefix}-\\d{3,}`, "g")) ?? [];
119
135
  if ([...expected].sort().join() !== [...actual].sort().join()) errors.push(`${detail.id} acceptance references must match Product Requirement.`);
136
+ const reference = field(detail.text, "API 文档引用") ?? "";
137
+ const refs = frontend
138
+ ? [...new Set([...reference.matchAll(/\b(GET|POST|PUT|PATCH|DELETE)\s+(\/[^\s`|,,;;]+)/gi)].map((item) => `${item[1].toUpperCase()} ${item[2]}`))]
139
+ : [...new Set(reference.match(/API-\d{3,}/g) ?? [])];
140
+ storyApiRefs.set(detail.id, refs);
141
+ const trigger = field(story.text, "触发方式") ?? "";
142
+ if (!frontend && /\bAPI\b/i.test(trigger) && !refs.length) errors.push(`${detail.id} API-triggered story must reference one or more API IDs.`);
143
+ if (!frontend && /^API[((]Web\s*\+\s*Remote[))]$/i.test(trigger) && refs.length < 2) errors.push(`${detail.id} API(Web + Remote) story must reference at least two API IDs.`);
144
+ if (!frontend && !/\bAPI\b/i.test(trigger) && !new RegExp(`^不适用;.*触发方式为\\s*${trigger.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`).test(reference)) {
145
+ errors.push(`${detail.id} non-API story must use 不适用;触发方式为 ${trigger}.`);
146
+ }
147
+ if (frontend && !refs.length && !/^不适用;.+/.test(reference)) errors.push(`${detail.id} frontend story must list Method + Path or use 不适用; and explain why.`);
148
+ if (frontend && refs.length && !externalApi) errors.push(`${detail.id} lists Method + Path but no --api-doc input was supplied.`);
149
+ if (frontend) for (const signature of refs) if (!externalSignatures.has(signature)) errors.push(`${detail.id} references Method + Path not found in external API Documentation: ${signature}.`);
150
+ if (!frontend) for (const id of refs) if (!documentedApiIds.has(id)) errors.push(`${detail.id} references unknown API ID ${id}.`);
151
+ if (frontend) {
152
+ const upstreamSpec = frontendSpecs.find((item) => item.id === detail.id);
153
+ const targetRoute = field(upstreamSpec?.text ?? "", "页面路由") ?? "";
154
+ const routeDetail = fieldBlock(detail.text, "页面/路由");
155
+ if (/^不适用;/.test(targetRoute)) {
156
+ if (!/不适用/.test(routeDetail)) errors.push(`${detail.id} must preserve the Product Requirement non-route designation.`);
157
+ } else {
158
+ const path = targetRoute.match(/\/[^`\s;;,,]+/)?.[0];
159
+ if (path && !routeDetail.includes(path)) errors.push(`${detail.id} page route must match Product Requirement target ${path}.`);
160
+ }
161
+ }
120
162
  }
163
+ const risk = field(detail.text, "风险");
164
+ if (risk && !/^(?:无|不适用)$/.test(risk)) localRisks.push({ story: detail.id, text: risk });
121
165
  }
122
166
  }
123
167
 
@@ -127,6 +171,14 @@ const coverage = section(dependency.text, 2, "用户故事覆盖矩阵") ?? "";
127
171
  for (const story of [...frontendStories, ...backendStories]) {
128
172
  if (!coverage.includes(story.id)) errors.push(`Coverage matrix is missing ${story.id}.`);
129
173
  for (const ac of field(story.text, "验收标准")?.match(/AC-(?:FE|BE)-\d{3,}/g) ?? []) if (!coverage.includes(ac)) errors.push(`Coverage matrix is missing ${ac}.`);
174
+ for (const apiRef of storyApiRefs.get(story.id) ?? []) if (!coverage.includes(apiRef)) errors.push(`Coverage matrix is missing ${story.id} to ${apiRef} mapping.`);
175
+ }
176
+
177
+ if (externalApi && (scope === "frontend" || scope === "both")) {
178
+ const mapping = section(dependency.text, 2, "API 实现映射") ?? "";
179
+ for (const signature of [...storyApiRefs.entries()].filter(([id]) => id.startsWith("FE-US-")).flatMap(([, refs]) => refs)) {
180
+ if (!mapping.includes(signature)) errors.push(`API 实现映射 is missing frontend Method + Path ${signature}.`);
181
+ }
130
182
  }
131
183
 
132
184
  if (api) {
@@ -134,9 +186,15 @@ if (api) {
134
186
  for (const item of apiBlocks(api.text)) {
135
187
  const operation = tableValue(item.text, "Operation ID");
136
188
  const signature = item.text.match(/^>\s*`((?:GET|POST|PUT|PATCH|DELETE)\s+\/[^`]+)`/m)?.[1];
137
- if (!mapping.includes(item.id)) errors.push(`API 实现映射 is missing ${item.id}.`);
138
- if (operation && !mapping.includes(operation)) errors.push(`API 实现映射 is missing Operation ID ${operation}.`);
139
- if (signature && !mapping.includes(signature)) errors.push(`API 实现映射 is missing ${signature}.`);
189
+ const mappingRow = mapping.split(/\r?\n/).find((line) => new RegExp(`^\\|\\s*${item.id}\\s*\\|`).test(line));
190
+ const cells = mappingRow?.split("|").slice(1, -1).map((cell) => cell.trim()) ?? [];
191
+ if (!mappingRow) errors.push(`API 实现映射 is missing ${item.id}.`);
192
+ if (operation && cells[1] !== operation) errors.push(`API 实现映射 is missing Operation ID ${operation}.`);
193
+ if (signature && cells[2] !== signature) errors.push(`API 实现映射 is missing ${signature}.`);
194
+ if (cells.length < 4 || !cells[3] || /^(?:F\d+|未知|未定位|N\/A|不适用)$/i.test(cells[3])) errors.push(`${item.id} must map to a concrete code entry.`);
195
+ if (![...storyApiRefs.values()].flat().includes(item.id)) errors.push(`${item.id} is orphaned; at least one API-backed story must reference it.`);
140
196
  }
141
197
  }
198
+ const crossRisk = section(dependency.text, 2, "跨故事风险与未定位项") ?? "";
199
+ for (const risk of localRisks) if (risk.text.length >= 6 && crossRisk.includes(risk.text)) errors.push(`${risk.story} risk is duplicated in 跨故事风险与未定位项.`);
142
200
  print("Dependency Analysis", dependency.path, errors);
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { acceptanceBlocks, assertHeadings, containsUnresolvedBlockingPriority, field, load, metadata, outputSpecBlocks, print, scopeIncludes, section, storyBlocks, validateAcceptance, validateArtifactLocation } from "./validation-helpers.mjs";
3
+ import { acceptanceBlocks, assertHeadings, field, load, metadata, outputSpecBlocks, print, scopeIncludes, section, storyBlocks, validateArtifactLocation, validateFrontmatter } from "./validation-helpers.mjs";
4
4
 
5
5
  const args = process.argv.slice(2);
6
6
  const fileArg = args[0];
@@ -15,62 +15,76 @@ try { artifact = load(fileArg, "Product Requirement"); }
15
15
  catch (error) { console.error(error.message); process.exit(2); }
16
16
 
17
17
  const errors = [];
18
+ validateFrontmatter(artifact.text, ["artifact_version", "artifact_type", "requirement_id", "requirement_status", "analysis_scope"], errors, "Product Requirement");
18
19
  validateArtifactLocation(artifact, errors, "product-requirement.md");
19
20
  const version = metadata(artifact.text, "artifact_version");
20
21
  const upstreamScope = metadata(artifact.text, "analysis_scope");
21
22
  const scope = target ?? upstreamScope;
22
- if (!["2.0", "3.0"].includes(version)) errors.push("artifact_version must be 2.0 or 3.0.");
23
+ if (!["2.0", "3.0", "4.0"].includes(version)) errors.push("artifact_version must be 2.0, 3.0, or 4.0.");
23
24
  if (metadata(artifact.text, "artifact_type") !== "product-requirement") errors.push("artifact_type must be product-requirement.");
24
25
  if (metadata(artifact.text, "requirement_status") !== "complete") errors.push("requirement_status must be complete.");
25
26
  if (!["frontend", "backend", "both"].includes(upstreamScope)) errors.push("analysis_scope must be frontend, backend, or both.");
26
27
  if (!["frontend", "backend", "both"].includes(scope)) errors.push("target must be frontend, backend, or both.");
27
28
  else if (!scopeIncludes(upstreamScope, scope)) errors.push(`target ${scope} is not included in upstream scope ${upstreamScope}.`);
28
29
 
29
- if (version === "3.0") {
30
- assertHeadings(artifact.text, 2, ["需求概述", "业务目标", "需求范围", "业务规则", "决策追溯"], errors, "Product Requirement");
31
- if (/^##\s+(?:\d+[.、]?\s*)?(?:用户角色|验收标准汇总)\s*$/m.test(artifact.text)) errors.push("V3 Product Requirement must not contain standalone 用户角色 or 验收标准汇总 sections.");
32
- } else {
33
- assertHeadings(artifact.text, 2, ["需求背景", "需求摘要", "业务目标", "用户角色", "需求范围", "业务规则", "验收标准汇总", "决策追溯"], errors, "V2 Product Requirement");
30
+ const allAcceptanceIds = [...artifact.text.matchAll(/^####\s+(AC-(?:FE|BE)-\d{3,})\s+/gm)].map((match) => match[1]);
31
+ for (const id of new Set(allAcceptanceIds)) {
32
+ if (allAcceptanceIds.filter((value) => value === id).length > 1) errors.push(`Duplicate acceptance criterion ID: ${id}.`);
34
33
  }
35
- const range = section(artifact.text, 2, "需求范围") ?? "";
36
- assertHeadings(range, 3, ["已确认需求", "非目标", "默认假设", "未决事项"], errors, "需求范围");
37
- if (containsUnresolvedBlockingPriority(section(range, 3, "未决事项") ?? "")) errors.push("Product Requirement contains unresolved P0/P1 items.");
38
-
39
34
  function validateDomain(name) {
40
35
  const frontend = name === "frontend";
41
36
  const title = frontend ? "前端" : "后端";
42
37
  const prefix = frontend ? "FE-US" : "BE-US";
43
38
  const acPrefix = frontend ? "AC-FE" : "AC-BE";
44
- const storyFields = version === "3.0"
45
- ? (frontend ? ["角色", "目标", "价值", "入口", "验收标准"] : ["系统能力", "使用方", "业务价值", "触发方式", "验收标准"])
46
- : (frontend ? ["用户角色", "用户目标", "页面/入口", "页面/组件", "展示内容", "交互动作", "UI 状态", "权限可见性", "前端边界处理", "验收标准"] : ["系统能力", "使用方", "触发方式", "输入语义", "输出语义", "数据读写", "权限规则", "业务规则", "安全要求", "后端边界处理", "验收标准"]);
47
- assertHeadings(artifact.text, 2, [`${title}用户故事`, `${title}输出规范`], errors, "Product Requirement");
39
+ const structured = version === "3.0" || version === "4.0";
40
+ if (structured) assertHeadings(artifact.text, 2, [`${title}用户故事`, `${title}输出规范`], errors, "Product Requirement");
41
+ else assertHeadings(artifact.text, 2, [`${title}用户故事`], errors, "Product Requirement");
48
42
  const stories = storyBlocks(section(artifact.text, 2, `${title}用户故事`), prefix);
49
43
  if (!stories.length) errors.push(`Missing ${prefix}-* stories.`);
50
- const specs = version === "3.0" ? outputSpecBlocks(section(artifact.text, 2, `${title}输出规范`), prefix) : [];
51
- const specFields = frontend ? ["页面/组件", "展示内容", "交互动作", "UI 状态", "表单校验", "权限可见性", "边界处理"] : ["输入语义", "输出语义", "数据读写", "权限规则", "业务规则", "安全要求", "幂等与并发", "错误与边界"];
44
+ const specs = structured ? outputSpecBlocks(section(artifact.text, 2, `${title}输出规范`), prefix) : [];
52
45
  const seen = new Set();
53
46
  for (const story of stories) {
54
47
  if (seen.has(story.id)) errors.push(`Duplicate story ID: ${story.id}.`);
55
48
  seen.add(story.id);
56
- for (const fieldName of storyFields) if (!field(story.text, fieldName)) errors.push(`${story.id} is missing field ${fieldName}.`);
57
- if (!frontend && !/^(?:API|定时任务|事件|消息|内部调用|数据迁移)(?:$|[、,,/])/i.test(field(story.text, "触发方式") ?? "")) errors.push(`${story.id} 触发方式 is invalid.`);
58
- const container = version === "3.0" ? specs.find((spec) => spec.id === story.id) : story;
49
+ if (!field(story.text, "验收标准")) errors.push(`${story.id} is missing field 验收标准.`);
50
+ const trigger = field(story.text, "触发方式") ?? "";
51
+ const apiTrigger = /^API[((](?:Web|Remote|Web\s*\+\s*Remote)[))]$/i.test(trigger);
52
+ if (!frontend && !apiTrigger && !/^(?:定时任务|事件|消息|内部调用|数据迁移)$/i.test(trigger)) errors.push(`${story.id} 触发方式 is invalid; API must declare Web, Remote, or Web + Remote.`);
53
+ const container = structured ? specs.find((spec) => spec.id === story.id) : story;
59
54
  if (!container) {
60
55
  errors.push(`Missing output specification for ${story.id}.`);
61
56
  continue;
62
57
  }
63
- if (version === "3.0") for (const fieldName of specFields) if (!field(container.text, fieldName)) errors.push(`${story.id} output specification is missing field ${fieldName}.`);
58
+ if (structured && frontend) {
59
+ const route = field(container.text, "页面路由") ?? "";
60
+ if (!route) errors.push(`${story.id} output specification is missing field 页面路由.`);
61
+ else if (!/^`?\/[^`\s]+`?(?:\s|$|[;;,,])/.test(route) && !/^不适用;.+`?\/[^`\s]+`?.*页面/.test(route)) {
62
+ errors.push(`${story.id} 页面路由 must start with / or use 不适用; and name its containing page.`);
63
+ }
64
+ }
65
+ if (structured && !frontend && apiTrigger) {
66
+ for (const fieldName of ["输入语义", "输出语义", "权限规则", "业务规则", "安全要求", "错误与边界"]) {
67
+ if (!field(container.text, fieldName)) errors.push(`${story.id} API business contract is missing field ${fieldName}.`);
68
+ }
69
+ }
64
70
  const criteria = acceptanceBlocks(container, acPrefix);
65
71
  if (!criteria.length) errors.push(`${story.id} is missing embedded acceptance criteria.`);
66
72
  const actual = criteria.map((item) => item.id).sort();
67
73
  const referenced = [...(field(story.text, "验收标准") ?? "").matchAll(new RegExp(`${acPrefix}-\\d{3,}`, "g"))].map((item) => item[0]).sort();
68
74
  if (actual.join() !== referenced.join()) errors.push(`${story.id} acceptance references must match its embedded criteria.`);
69
- for (const item of criteria) validateAcceptance(item.text, item.id, errors);
70
75
  }
71
- if (version === "3.0") for (const spec of specs) if (!stories.some((story) => story.id === spec.id)) errors.push(`Output specification references unknown story ${spec.id}.`);
76
+ if (structured) for (const spec of specs) if (!stories.some((story) => story.id === spec.id)) errors.push(`Output specification references unknown story ${spec.id}.`);
72
77
  }
73
78
 
74
79
  if (scope === "frontend" || scope === "both") validateDomain("frontend");
75
80
  if (scope === "backend" || scope === "both") validateDomain("backend");
81
+
82
+ if (version === "2.0") {
83
+ const backendStories = storyBlocks(section(artifact.text, 2, "后端用户故事"), "BE-US");
84
+ const hasApi = backendStories.some((story) => /\bAPI\b/i.test(field(story.text, "触发方式") ?? ""));
85
+ if ((scope === "backend" || scope === "both") && hasApi) {
86
+ errors.push("V2 Product Requirement with API-triggered stories must be upgraded to V4 before dependency/API analysis.");
87
+ }
88
+ }
89
+
76
90
  print("Product Requirement input", artifact.path, errors);