@openprd/cli 0.1.0 → 0.1.8

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 (138) hide show
  1. package/.openprd/README.md +43 -69
  2. package/.openprd/README_EN.md +84 -0
  3. package/.openprd/benchmarks/index.md +7 -0
  4. package/.openprd/benchmarks/sources.yaml +25 -3
  5. package/.openprd/discovery/config.json +16 -2
  6. package/.openprd/engagements/active/flows.md +19 -14
  7. package/.openprd/engagements/active/handoff.md +11 -4
  8. package/.openprd/engagements/active/prd.md +99 -71
  9. package/.openprd/engagements/active/review.html +4 -4
  10. package/.openprd/engagements/active/roles.md +9 -8
  11. package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +4 -4
  12. package/.openprd/engagements/work-units/wu-20260602113956-a99b5b88.json +18 -0
  13. package/.openprd/engagements/work-units/wu-20260602122244-78656aaf.json +18 -0
  14. package/.openprd/engagements/work-units/wu-20260602122442-e96489e2.json +18 -0
  15. package/.openprd/engagements/work-units/wu-20260602132835-695429e8.json +18 -0
  16. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/candidate.json +78 -0
  17. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/diagnostic-report.json +129 -0
  18. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/root-cause-candidates.json +41 -0
  19. package/.openprd/knowledge/candidates/candidate-turn-1780116203372-5f266a79e968c758/timeline.json +14 -0
  20. package/.openprd/knowledge/drafts/openprd-experience-diagnostic-candidate-turn-1780116203372-5f266a79e968c758/SKILL.md +49 -0
  21. package/.openprd/knowledge/index.json +44 -4
  22. package/.openprd/reviews/v0001.html +195 -129
  23. package/.openprd/reviews/v0002.html +1150 -0
  24. package/.openprd/reviews/v0003.html +1150 -0
  25. package/.openprd/reviews/v0004.html +1150 -0
  26. package/.openprd/reviews/v0005.html +1150 -0
  27. package/.openprd/standards/config.json +12 -9
  28. package/.openprd/state/changes.json +17 -2
  29. package/.openprd/state/current.json +399 -63
  30. package/.openprd/state/release-ledger.json +344 -0
  31. package/.openprd/state/version-index.json +52 -0
  32. package/.openprd/state/versions/v0002.json +264 -0
  33. package/.openprd/state/versions/v0002.md +183 -0
  34. package/.openprd/state/versions/v0003.json +269 -0
  35. package/.openprd/state/versions/v0003.md +188 -0
  36. package/.openprd/state/versions/v0004.json +274 -0
  37. package/.openprd/state/versions/v0004.md +193 -0
  38. package/.openprd/state/versions/v0005.json +299 -0
  39. package/.openprd/state/versions/v0005.md +189 -0
  40. package/.openprd/templates/agent/intake.md +5 -4
  41. package/.openprd/templates/b2b/intake.md +5 -4
  42. package/.openprd/templates/base/intake.md +10 -4
  43. package/.openprd/templates/company/README.md +9 -7
  44. package/.openprd/templates/company/README_EN.md +12 -0
  45. package/.openprd/templates/consumer/intake.md +5 -4
  46. package/.openprd/templates/industry/README.md +12 -10
  47. package/.openprd/templates/industry/README_EN.md +18 -0
  48. package/.openprd/templates/project/README.md +11 -9
  49. package/.openprd/templates/project/README_EN.md +16 -0
  50. package/.openprd/templates/session/README.md +11 -9
  51. package/.openprd/templates/session/README_EN.md +16 -0
  52. package/AGENTS.md +12 -8
  53. package/README.md +402 -441
  54. package/README_CN.md +4 -578
  55. package/README_EN.md +850 -0
  56. package/docs/assets/openprd-requirement-routing-en.png +0 -0
  57. package/docs/assets/openprd-requirement-routing-en.svg +102 -0
  58. package/docs/assets/openprd-requirement-routing-zh-refined.png +0 -0
  59. package/docs/assets/openprd-requirement-routing-zh.png +0 -0
  60. package/docs/assets/openprd-requirement-routing-zh.svg +102 -0
  61. package/package.json +6 -2
  62. package/scripts/dev-check-wrapup-copy.mjs +110 -0
  63. package/scripts/openprd-github-release-notes.mjs +99 -0
  64. package/scripts/quality-perf-check.mjs +203 -0
  65. package/skills/openprd-benchmark-router/SKILL.md +1 -0
  66. package/skills/openprd-benchmark-router/references/benchmark-sources.md +1 -0
  67. package/skills/openprd-benchmark-router/references/source-policy.md +2 -0
  68. package/skills/openprd-discovery-loop/SKILL.md +2 -2
  69. package/skills/openprd-harness/SKILL.md +46 -24
  70. package/skills/openprd-harness/references/workflow-gates.md +15 -0
  71. package/skills/openprd-quality/SKILL.md +10 -4
  72. package/skills/openprd-requirement-intake/SKILL.md +39 -23
  73. package/skills/openprd-requirement-intake/references/prd-template-lenses.md +6 -6
  74. package/skills/openprd-requirement-intake/references/routing-rubric.md +22 -8
  75. package/skills/openprd-router/SKILL.md +2 -2
  76. package/skills/openprd-shared/SKILL.md +51 -23
  77. package/skills/openprd-standards/SKILL.md +2 -1
  78. package/src/agent-integration.js +265 -65
  79. package/src/benchmark/constants.js +107 -0
  80. package/src/benchmark/operations.js +235 -0
  81. package/src/benchmark/registry.js +64 -0
  82. package/src/benchmark/render.js +115 -0
  83. package/src/benchmark/source.js +617 -0
  84. package/src/benchmark/storage.js +121 -0
  85. package/src/benchmark/verify.js +235 -0
  86. package/src/benchmark.js +50 -851
  87. package/src/change-summary.js +339 -0
  88. package/src/cli/args.js +67 -6
  89. package/src/cli/basic-print.js +365 -0
  90. package/src/cli/benchmark-print.js +91 -0
  91. package/src/cli/change-print.js +221 -0
  92. package/src/cli/doctor-print.js +268 -0
  93. package/src/cli/growth-print.js +176 -0
  94. package/src/cli/print.js +73 -1384
  95. package/src/cli/quality-print.js +284 -0
  96. package/src/cli/run-print.js +297 -0
  97. package/src/cli/shared-print.js +127 -0
  98. package/src/cli/workflow-print.js +195 -0
  99. package/src/codex-hook-runner-template.mjs +639 -117
  100. package/src/codex-runtime.js +324 -0
  101. package/src/dev-standards.js +178 -5
  102. package/src/diagram-core.js +5 -5
  103. package/src/discovery.js +2 -1
  104. package/src/execution-strategy.js +369 -0
  105. package/src/fleet.js +4 -0
  106. package/src/github-release.js +156 -0
  107. package/src/growth.js +311 -13
  108. package/src/html-artifact-utils.js +25 -0
  109. package/src/html-artifacts.js +157 -1596
  110. package/src/knowledge.js +1176 -75
  111. package/src/language-policy.js +2 -112
  112. package/src/learning-html-artifact.js +1031 -0
  113. package/src/learning-review.js +3 -2
  114. package/src/loop.js +280 -9
  115. package/src/openprd.js +341 -38
  116. package/src/openspec/change-validate.js +0 -9
  117. package/src/openspec/execute.js +79 -3
  118. package/src/openspec/generate.js +33 -20
  119. package/src/openspec/tasks.js +33 -2
  120. package/src/prd-core.js +10 -9
  121. package/src/product-type-copy.js +69 -0
  122. package/src/quality-html-artifact.js +108 -9
  123. package/src/quality-learning.js +30 -0
  124. package/src/quality-visual-review.js +237 -0
  125. package/src/quality.js +329 -43
  126. package/src/registry-hygiene.js +54 -0
  127. package/src/release-ledger.js +413 -0
  128. package/src/review-presentation.js +12 -6
  129. package/src/run-harness.js +722 -48
  130. package/src/self-update.js +1 -1
  131. package/src/session-binding.js +40 -3
  132. package/src/session-registry.js +159 -0
  133. package/src/standards.js +5 -3
  134. package/src/test-strategy.js +386 -0
  135. package/src/visual-compare.js +915 -34
  136. package/src/work-unit-migration.js +5 -1
  137. package/src/workspace-core.js +343 -19
  138. package/src/workspace-workflow.js +538 -134
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
 
6
- export const DEFAULT_SELF_UPDATE_SOURCE = 'git+https://github.com/mileson/openprd.git';
6
+ export const DEFAULT_SELF_UPDATE_SOURCE = '@openprd/cli@latest';
7
7
 
8
8
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
9
9
  const DEFAULT_PACKAGE_ROOT = path.resolve(MODULE_DIR, '..');
@@ -1,10 +1,12 @@
1
1
  import path from 'node:path';
2
2
  import { readJson, writeJson } from './fs-utils.js';
3
+ import { upsertSessionRegistryEntry } from './session-registry.js';
3
4
  import { timestamp } from './time.js';
4
5
 
5
6
  const OPENPRD_HARNESS_DIR = path.join('.openprd', 'harness');
6
7
  const OPENPRD_HARNESS_REQUIREMENT_GATE = path.join(OPENPRD_HARNESS_DIR, 'requirement-gate.json');
7
8
  const OPENPRD_HARNESS_SESSION_BINDINGS_DIR = path.join(OPENPRD_HARNESS_DIR, 'session-bindings');
9
+ const OPENPRD_STATE_CURRENT = path.join('.openprd', 'state', 'current.json');
8
10
 
9
11
  function normalizeSessionId(sessionId) {
10
12
  const text = String(sessionId ?? '').trim();
@@ -27,6 +29,20 @@ async function readLegacyRequirementGate(projectRoot) {
27
29
  return readJson(path.join(projectRoot, OPENPRD_HARNESS_REQUIREMENT_GATE)).catch(() => null);
28
30
  }
29
31
 
32
+ async function readWorkspaceCurrentState(projectRoot) {
33
+ return readJson(path.join(projectRoot, OPENPRD_STATE_CURRENT)).catch(() => null);
34
+ }
35
+
36
+ async function resolveBindingSessionId(projectRoot, options = {}, legacyGate = null) {
37
+ const workspaceCurrentState = await readWorkspaceCurrentState(projectRoot);
38
+ return normalizeSessionId(
39
+ options.sessionId
40
+ ?? options.currentSessionId
41
+ ?? workspaceCurrentState?.laneSessionId
42
+ ?? legacyGate?.sessionId
43
+ );
44
+ }
45
+
30
46
  async function readSessionBinding(projectRoot, sessionId) {
31
47
  const filePath = sessionBindingPath(projectRoot, sessionId);
32
48
  if (!filePath) {
@@ -52,6 +68,27 @@ async function upsertSessionBinding(projectRoot, sessionId, patch = {}) {
52
68
  updatedAt: patch.updatedAt ?? timestamp(),
53
69
  };
54
70
  await writeJson(filePath, next);
71
+ await upsertSessionRegistryEntry(projectRoot, normalized, {
72
+ laneKind: patch.laneKind ?? previous?.laneKind ?? 'requirement',
73
+ tool: patch.tool ?? previous?.tool ?? 'codex',
74
+ threadId: patch.threadId ?? previous?.threadId ?? null,
75
+ changeId: patch.changeId ?? next.changeId ?? null,
76
+ taskHandle: patch.taskHandle ?? next.taskHandle ?? null,
77
+ workUnitId: patch.workUnitId ?? next.workUnitId ?? null,
78
+ versionId: patch.versionId ?? next.versionId ?? null,
79
+ digest: patch.digest ?? next.digest ?? null,
80
+ title: patch.title ?? next.title ?? null,
81
+ targetRoot: patch.targetRoot ?? next.targetRoot ?? null,
82
+ promptPreview: patch.promptPreview ?? next.promptPreview ?? null,
83
+ reviewStatus: patch.reviewStatus ?? next.reviewStatus ?? null,
84
+ gateStatus: patch.gateStatus ?? next.gateStatus ?? null,
85
+ gateActive: patch.gateActive ?? next.gateActive ?? false,
86
+ bindingPath: filePath,
87
+ statePath: normalized
88
+ ? path.join(projectRoot, '.openprd', 'harness', 'session-states', `${normalized.replace(/[^A-Za-z0-9._-]/g, '_')}.json`)
89
+ : null,
90
+ updatedAt: next.updatedAt,
91
+ });
55
92
  return {
56
93
  ...next,
57
94
  path: filePath,
@@ -60,7 +97,7 @@ async function upsertSessionBinding(projectRoot, sessionId, patch = {}) {
60
97
 
61
98
  async function syncSessionBindingFromSnapshot(projectRoot, snapshot, options = {}) {
62
99
  const legacyGate = await readLegacyRequirementGate(projectRoot);
63
- const sessionId = normalizeSessionId(options.sessionId ?? legacyGate?.sessionId);
100
+ const sessionId = await resolveBindingSessionId(projectRoot, options, legacyGate);
64
101
  if (!sessionId || !snapshot?.versionId) {
65
102
  return null;
66
103
  }
@@ -84,7 +121,7 @@ async function syncSessionBindingFromSnapshot(projectRoot, snapshot, options = {
84
121
 
85
122
  async function syncSessionBindingFromReview(projectRoot, snapshot, options = {}) {
86
123
  const legacyGate = await readLegacyRequirementGate(projectRoot);
87
- const sessionId = normalizeSessionId(options.sessionId ?? legacyGate?.sessionId);
124
+ const sessionId = await resolveBindingSessionId(projectRoot, options, legacyGate);
88
125
  if (!sessionId || !snapshot?.versionId) {
89
126
  return null;
90
127
  }
@@ -107,7 +144,7 @@ async function syncSessionBindingFromReview(projectRoot, snapshot, options = {})
107
144
 
108
145
  async function syncSessionBindingFromChange(projectRoot, changeId, options = {}) {
109
146
  const legacyGate = await readLegacyRequirementGate(projectRoot);
110
- const sessionId = normalizeSessionId(options.sessionId ?? legacyGate?.sessionId);
147
+ const sessionId = await resolveBindingSessionId(projectRoot, options, legacyGate);
111
148
  if (!sessionId || !changeId) {
112
149
  return null;
113
150
  }
@@ -0,0 +1,159 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { appendJsonl, cjoin, exists, readJsonl } from './fs-utils.js';
4
+ import { timestamp } from './time.js';
5
+ import { resolveOpenPrdHome } from './workspace-registry.js';
6
+
7
+ const OPENPRD_SESSION_REGISTRY = cjoin('registry', 'sessions.jsonl');
8
+
9
+ function normalizeSessionId(sessionId) {
10
+ const text = String(sessionId ?? '').trim();
11
+ return text || null;
12
+ }
13
+
14
+ function sessionRegistryFilePath(options = {}) {
15
+ return cjoin(resolveOpenPrdHome(options), OPENPRD_SESSION_REGISTRY);
16
+ }
17
+
18
+ function normalizeSessionRegistryEntry(entry) {
19
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
20
+ return null;
21
+ }
22
+ const sessionId = normalizeSessionId(entry.sessionId);
23
+ const workspaceRoot = entry.workspaceRoot ? path.resolve(String(entry.workspaceRoot)) : null;
24
+ if (!sessionId || !workspaceRoot) {
25
+ return null;
26
+ }
27
+ const realpath = entry.realpath ? path.resolve(String(entry.realpath)) : workspaceRoot;
28
+ const lastUpdatedAt = entry.lastUpdatedAt ?? entry.updatedAt ?? entry.recordedAt ?? null;
29
+ const firstRegisteredAt = entry.firstRegisteredAt ?? entry.recordedAt ?? lastUpdatedAt;
30
+ return {
31
+ version: 1,
32
+ sessionId,
33
+ workspaceRoot,
34
+ realpath,
35
+ laneKind: entry.laneKind ? String(entry.laneKind) : 'requirement',
36
+ tool: entry.tool ? String(entry.tool) : null,
37
+ threadId: entry.threadId ? String(entry.threadId) : null,
38
+ changeId: entry.changeId ? String(entry.changeId) : null,
39
+ taskHandle: entry.taskHandle ? String(entry.taskHandle) : null,
40
+ workUnitId: entry.workUnitId ? String(entry.workUnitId) : null,
41
+ versionId: entry.versionId ? String(entry.versionId) : null,
42
+ digest: entry.digest ? String(entry.digest) : null,
43
+ title: entry.title ? String(entry.title) : null,
44
+ targetRoot: entry.targetRoot ? path.resolve(String(entry.targetRoot)) : null,
45
+ promptPreview: entry.promptPreview ? String(entry.promptPreview) : null,
46
+ reviewStatus: entry.reviewStatus ? String(entry.reviewStatus) : null,
47
+ gateStatus: entry.gateStatus ? String(entry.gateStatus) : null,
48
+ gateActive: entry.gateActive === true,
49
+ statePath: entry.statePath ? path.resolve(String(entry.statePath)) : null,
50
+ bindingPath: entry.bindingPath ? path.resolve(String(entry.bindingPath)) : null,
51
+ firstRegisteredAt,
52
+ lastRegisteredAt: entry.lastRegisteredAt ?? entry.recordedAt ?? lastUpdatedAt,
53
+ lastUpdatedAt,
54
+ };
55
+ }
56
+
57
+ async function readSessionRegistry(options = {}) {
58
+ const home = resolveOpenPrdHome(options);
59
+ const registryPath = sessionRegistryFilePath({ openprdHome: home });
60
+ const events = await readJsonl(registryPath).catch(() => []);
61
+ const currentBySession = new Map();
62
+ for (const event of events) {
63
+ const entry = normalizeSessionRegistryEntry(event);
64
+ if (!entry) {
65
+ continue;
66
+ }
67
+ currentBySession.set(entry.sessionId, entry);
68
+ }
69
+ const entries = Array.from(currentBySession.values())
70
+ .sort((left, right) => left.sessionId.localeCompare(right.sessionId));
71
+ const staleEntries = [];
72
+ for (const entry of entries) {
73
+ const workspaceExists = await exists(entry.workspaceRoot);
74
+ if (!workspaceExists) {
75
+ staleEntries.push({
76
+ ...entry,
77
+ reason: 'missing-workspace',
78
+ });
79
+ continue;
80
+ }
81
+ const markerExists = await exists(cjoin(entry.workspaceRoot, '.openprd'));
82
+ if (!markerExists) {
83
+ staleEntries.push({
84
+ ...entry,
85
+ reason: 'missing-openprd-marker',
86
+ });
87
+ }
88
+ }
89
+ return {
90
+ home,
91
+ registryPath,
92
+ totalEvents: events.length,
93
+ entries,
94
+ staleEntries,
95
+ };
96
+ }
97
+
98
+ async function readSessionRegistryEntry(sessionId, options = {}) {
99
+ const normalized = normalizeSessionId(sessionId);
100
+ if (!normalized) {
101
+ return null;
102
+ }
103
+ const registry = await readSessionRegistry(options);
104
+ return registry.entries.find((entry) => entry.sessionId === normalized) ?? null;
105
+ }
106
+
107
+ async function upsertSessionRegistryEntry(projectRoot, sessionId, patch = {}, options = {}) {
108
+ const normalized = normalizeSessionId(sessionId);
109
+ if (!normalized) {
110
+ return null;
111
+ }
112
+ const registry = await readSessionRegistry(options);
113
+ const workspaceRoot = path.resolve(projectRoot);
114
+ const realpath = await fs.realpath(workspaceRoot).catch(() => workspaceRoot);
115
+ const existing = registry.entries.find((entry) => entry.sessionId === normalized) ?? null;
116
+ const recordedAt = options.recordedAt ?? timestamp();
117
+ const entry = normalizeSessionRegistryEntry({
118
+ ...existing,
119
+ sessionId: normalized,
120
+ workspaceRoot,
121
+ realpath,
122
+ laneKind: patch.laneKind ?? existing?.laneKind ?? 'requirement',
123
+ tool: patch.tool ?? existing?.tool ?? null,
124
+ threadId: patch.threadId ?? existing?.threadId ?? null,
125
+ changeId: patch.changeId ?? existing?.changeId ?? null,
126
+ taskHandle: patch.taskHandle ?? existing?.taskHandle ?? null,
127
+ workUnitId: patch.workUnitId ?? existing?.workUnitId ?? null,
128
+ versionId: patch.versionId ?? existing?.versionId ?? null,
129
+ digest: patch.digest ?? existing?.digest ?? null,
130
+ title: patch.title ?? existing?.title ?? null,
131
+ targetRoot: patch.targetRoot ?? existing?.targetRoot ?? null,
132
+ promptPreview: patch.promptPreview ?? existing?.promptPreview ?? null,
133
+ reviewStatus: patch.reviewStatus ?? existing?.reviewStatus ?? null,
134
+ gateStatus: patch.gateStatus ?? existing?.gateStatus ?? null,
135
+ gateActive: patch.gateActive ?? existing?.gateActive ?? false,
136
+ statePath: patch.statePath ?? existing?.statePath ?? null,
137
+ bindingPath: patch.bindingPath ?? existing?.bindingPath ?? null,
138
+ firstRegisteredAt: existing?.firstRegisteredAt ?? recordedAt,
139
+ lastRegisteredAt: recordedAt,
140
+ lastUpdatedAt: patch.updatedAt ?? recordedAt,
141
+ recordedAt,
142
+ });
143
+ await appendJsonl(registry.registryPath, entry);
144
+ return {
145
+ home: registry.home,
146
+ registryPath: registry.registryPath,
147
+ entry,
148
+ status: existing ? 'updated' : 'created',
149
+ knownTotal: existing ? registry.entries.length : registry.entries.length + 1,
150
+ };
151
+ }
152
+
153
+ export {
154
+ normalizeSessionId,
155
+ readSessionRegistry,
156
+ readSessionRegistryEntry,
157
+ sessionRegistryFilePath,
158
+ upsertSessionRegistryEntry,
159
+ };
package/src/standards.js CHANGED
@@ -815,10 +815,12 @@ export async function checkStandardsWorkspace(projectRoot, options = {}) {
815
815
  continue;
816
816
  }
817
817
  const text = await readText(docPath);
818
- if (!text.includes(`# ${doc.title}`)) {
818
+ if (options.docsContent !== false && !text.includes(`# ${doc.title}`)) {
819
819
  errors.push(`${relativePath} 缺少标题: ${doc.title}`);
820
820
  }
821
- validateTextSections(relativePath, text, doc.sections, errors);
821
+ if (options.docsContent !== false) {
822
+ validateTextSections(relativePath, text, doc.sections, errors);
823
+ }
822
824
  if (hasProjectSource && !openPrdToolProject && options.docsContent !== false && DOC_PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text))) {
823
825
  errors.push(`${relativePath} 仍包含模板占位内容,必须更新为当前项目事实。`);
824
826
  }
@@ -861,7 +863,7 @@ export async function checkStandardsWorkspace(projectRoot, options = {}) {
861
863
  checks.push(`Manual templates: ${templateFiles.filter((file) => file.exists).length}/${STANDARD_TEMPLATE_FILES.length}.`);
862
864
  const lineConfig = config?.developmentStandards?.codeFileLines ?? DEFAULT_DEVELOPMENT_STANDARDS.codeFileLines;
863
865
  checks.push(`Development standards: code files ok <= ${lineConfig.okMax} lines, attention <= ${lineConfig.attentionMax} lines.`);
864
- checks.push(`Growth layer: ${config?.growth?.enabled === false ? 'disabled' : 'enabled'} with user review required before applying candidates.`);
866
+ checks.push(`Growth layer: ${config?.growth?.enabled === false ? 'disabled' : 'enabled'}; code-extension candidates can auto-apply, remaining candidates stay in wrap-up review.`);
865
867
 
866
868
  return {
867
869
  ok: errors.length === 0,
@@ -0,0 +1,386 @@
1
+ const TEST_LAYER_LABELS = {
2
+ unit: '单元测试',
3
+ integration: '集成测试',
4
+ e2e: '端到端测试',
5
+ manual: '人工审查',
6
+ smoke: '冒烟测试',
7
+ visual: '视觉对比',
8
+ performance: '性能测试',
9
+ security: '安全验证',
10
+ weapp: '小程序实测',
11
+ none: '无需专项测试',
12
+ };
13
+
14
+ const TEST_SIZE_LABELS = {
15
+ small: '小规模',
16
+ medium: '中规模',
17
+ large: '大规模',
18
+ manual: '人工',
19
+ advisory: '提示型',
20
+ none: '无',
21
+ };
22
+
23
+ const TEST_SCOPE_LABELS = {
24
+ isolated: '局部逻辑',
25
+ module: '模块边界',
26
+ contract: '契约边界',
27
+ 'cli-contract': '命令行契约',
28
+ 'api-contract': '接口契约',
29
+ 'user-flow': '用户主路径',
30
+ 'visual-flow': '视觉路径',
31
+ 'weapp-runtime': '小程序运行态',
32
+ performance: '性能基线',
33
+ security: '安全边界',
34
+ governance: '治理流程',
35
+ docs: '文档审查',
36
+ none: '无',
37
+ };
38
+
39
+ export const TEST_STRATEGY_METADATA_KEYS = [
40
+ 'test-layer',
41
+ 'test-size',
42
+ 'test-scope',
43
+ 'evidence',
44
+ 'evidence-plan',
45
+ 'upgrade-reason',
46
+ 'waiver',
47
+ 'waiver-reason',
48
+ ];
49
+
50
+ export const TEST_LAYER_VALUES = Object.keys(TEST_LAYER_LABELS);
51
+ export const TEST_SIZE_VALUES = Object.keys(TEST_SIZE_LABELS);
52
+ export const TEST_SCOPE_VALUES = Object.keys(TEST_SCOPE_LABELS);
53
+
54
+ function normalizeToken(value) {
55
+ return String(value ?? '').trim().toLowerCase();
56
+ }
57
+
58
+ function splitValues(value) {
59
+ return String(value ?? '')
60
+ .split(',')
61
+ .map((item) => normalizeToken(item))
62
+ .filter(Boolean);
63
+ }
64
+
65
+ function includesAny(text, patterns) {
66
+ return patterns.some((pattern) => pattern.test(text));
67
+ }
68
+
69
+ const WEAPP_MENTION_PATTERNS = [/weapp|微信小程序|小程序|微信开发者工具/];
70
+ const WEAPP_VALIDATION_ACTION_PATTERNS = [
71
+ /测试|验证|实测|复现|截图|日志|抓日志|抓包|网络请求|network|运行态|开发者工具自动化|从\s*0\s*到\s*1|冷启动|全流程/,
72
+ ];
73
+
74
+ function firstKnown(values, allowed, fallback) {
75
+ return values.find((value) => allowed.includes(value)) ?? fallback;
76
+ }
77
+
78
+ export function normalizeTestLayers(value) {
79
+ const layers = splitValues(value).filter((layer) => TEST_LAYER_VALUES.includes(layer));
80
+ return [...new Set(layers)];
81
+ }
82
+
83
+ export function labelTestLayer(layer) {
84
+ return TEST_LAYER_LABELS[normalizeToken(layer)] ?? layer ?? '未指定';
85
+ }
86
+
87
+ export function labelTestSize(size) {
88
+ return TEST_SIZE_LABELS[normalizeToken(size)] ?? size ?? '未指定';
89
+ }
90
+
91
+ export function labelTestScope(scope) {
92
+ return TEST_SCOPE_LABELS[normalizeToken(scope)] ?? scope ?? '未指定';
93
+ }
94
+
95
+ export function describeTestStrategy(strategy) {
96
+ const layers = (strategy.layers ?? []).map(labelTestLayer).join(' + ') || '未指定';
97
+ const size = labelTestSize(strategy.size);
98
+ const scope = labelTestScope(strategy.scope);
99
+ const evidence = strategy.evidencePlan || strategy.evidence || '未指定证据';
100
+ return `${layers} / ${size} / ${scope};证据:${evidence}`;
101
+ }
102
+
103
+ export function inferTestStrategyForTask(task = {}) {
104
+ const type = normalizeToken(task.type ?? task.metadata?.type ?? task.metadata?.category ?? task.metadata?.kind);
105
+ const phase = normalizeToken(task.phase);
106
+ const text = [
107
+ task.id,
108
+ task.title,
109
+ task.done,
110
+ task.verify,
111
+ task.metadata?.done,
112
+ task.metadata?.verify,
113
+ ].map((value) => String(value ?? '')).join('\n').toLowerCase();
114
+
115
+ if (type === 'governance' || phase.includes('governance')) {
116
+ return {
117
+ layers: ['manual'],
118
+ size: 'manual',
119
+ scope: 'governance',
120
+ evidencePlan: task.verify ?? task.metadata?.verify ?? 'openprd change . --validate --change <change-id>',
121
+ upgradeReason: '治理任务以结构校验、评审确认和变更状态证据为主',
122
+ inferred: true,
123
+ };
124
+ }
125
+
126
+ if (type === 'documentation' || /docs\/basic|readme|文档|说明书|documentation|docs/i.test(text)) {
127
+ return {
128
+ layers: ['manual'],
129
+ size: 'manual',
130
+ scope: 'docs',
131
+ evidencePlan: task.verify ?? task.metadata?.verify ?? 'openprd standards . --verify',
132
+ upgradeReason: '文档任务以标准校验和人工审查证据为主',
133
+ inferred: true,
134
+ };
135
+ }
136
+
137
+ if (includesAny(text, WEAPP_MENTION_PATTERNS) && includesAny(text, WEAPP_VALIDATION_ACTION_PATTERNS)) {
138
+ return {
139
+ layers: ['integration', 'weapp'],
140
+ size: 'large',
141
+ scope: 'weapp-runtime',
142
+ evidencePlan: '小程序运行态截图、日志、网络请求或其他本地验证证据 + 本任务 verify 命令',
143
+ upgradeReason: '明确要求小程序运行态证据,需要本地运行态验证',
144
+ inferred: true,
145
+ };
146
+ }
147
+
148
+ if (includesAny(text, [/visual|视觉|截图|界面|页面|组件|样式|browser|playwright|cypress|e2e|端到端|用户主路径|主流程/])) {
149
+ return {
150
+ layers: ['integration', 'e2e'],
151
+ size: 'large',
152
+ scope: includesAny(text, [/visual|视觉|截图|样式/]) ? 'visual-flow' : 'user-flow',
153
+ evidencePlan: '主流程自动化、截图或 visual-compare 证据 + 本任务 verify 命令',
154
+ upgradeReason: '触达用户可见路径,需要端到端或视觉级证据',
155
+ inferred: true,
156
+ };
157
+ }
158
+
159
+ if (includesAny(text, [/perf|performance|性能|压力|stress|load|baseline|基线/])) {
160
+ return {
161
+ layers: ['integration', 'performance'],
162
+ size: 'large',
163
+ scope: 'performance',
164
+ evidencePlan: '性能基线、压力数据或 benchmark 报告 + 本任务 verify 命令',
165
+ upgradeReason: '触达性能或容量边界,需要专项验证证据',
166
+ inferred: true,
167
+ };
168
+ }
169
+
170
+ if (includesAny(text, [/security|权限|越权|身份|token|secret|敏感|安全|额度|滥用|成本|限流|并发/])) {
171
+ return {
172
+ layers: ['integration', 'security'],
173
+ size: 'medium',
174
+ scope: 'security',
175
+ evidencePlan: '正向路径 + 越权、额度、并发或敏感信息负向验证',
176
+ upgradeReason: '触达权限、安全、成本或滥用边界,需要至少集成级证据',
177
+ inferred: true,
178
+ };
179
+ }
180
+
181
+ if (includesAny(text, [/api|cli|command|契约|contract|schema|json|输出|参数|quality|loop|tasks|agent|hook|skill|生成物|报告/])) {
182
+ return {
183
+ layers: ['unit', 'integration'],
184
+ size: 'medium',
185
+ scope: includesAny(text, [/api|接口/]) ? 'api-contract' : 'cli-contract',
186
+ evidencePlan: '单元测试锁定规则 + 集成或命令行契约验证',
187
+ upgradeReason: '触达 CLI/API/Agent 契约或生成物,需要中间层验证',
188
+ inferred: true,
189
+ };
190
+ }
191
+
192
+ if (type === 'verification' || phase.includes('verification') || includesAny(text, [/验证|测试|回归|边界|失败|异常|验收/])) {
193
+ return {
194
+ layers: ['integration'],
195
+ size: 'medium',
196
+ scope: 'module',
197
+ evidencePlan: task.verify ?? task.metadata?.verify ?? '本任务 verify 命令与测试报告',
198
+ upgradeReason: '验证任务默认需要能覆盖模块边界的证据',
199
+ inferred: true,
200
+ };
201
+ }
202
+
203
+ return {
204
+ layers: ['unit'],
205
+ size: 'small',
206
+ scope: 'isolated',
207
+ evidencePlan: task.verify ?? task.metadata?.verify ?? '最小相关单元测试或局部命令验证',
208
+ upgradeReason: '默认从最小足够证据开始,触达用户主路径或契约时再升级',
209
+ inferred: true,
210
+ };
211
+ }
212
+
213
+ export function taskTestStrategy(task = {}) {
214
+ const metadata = task.metadata ?? {};
215
+ const inferred = inferTestStrategyForTask(task);
216
+ const explicitLayers = normalizeTestLayers(metadata['test-layer']);
217
+ const rawSize = normalizeToken(metadata['test-size']);
218
+ const rawScope = normalizeToken(metadata['test-scope']);
219
+ return {
220
+ layers: explicitLayers.length > 0 ? explicitLayers : inferred.layers,
221
+ size: TEST_SIZE_VALUES.includes(rawSize) ? rawSize : inferred.size,
222
+ scope: TEST_SCOPE_VALUES.includes(rawScope) ? rawScope : inferred.scope,
223
+ evidence: metadata.evidence ?? null,
224
+ evidencePlan: metadata['evidence-plan'] ?? inferred.evidencePlan,
225
+ upgradeReason: metadata['upgrade-reason'] ?? inferred.upgradeReason,
226
+ waiver: metadata.waiver ?? metadata['waiver-reason'] ?? null,
227
+ inferred: explicitLayers.length === 0 || !TEST_SIZE_VALUES.includes(rawSize) || !TEST_SCOPE_VALUES.includes(rawScope),
228
+ };
229
+ }
230
+
231
+ export function formatTaskTestStrategyMetadata(task = {}) {
232
+ const strategy = inferTestStrategyForTask(task);
233
+ return [
234
+ `test-layer: ${strategy.layers.join(', ')}`,
235
+ `test-size: ${strategy.size}`,
236
+ `test-scope: ${strategy.scope}`,
237
+ `evidence-plan: ${strategy.evidencePlan}`,
238
+ `upgrade-reason: ${strategy.upgradeReason}`,
239
+ ];
240
+ }
241
+
242
+ export function validateTaskTestStrategy(task = {}) {
243
+ const metadata = task.metadata ?? {};
244
+ const errors = [];
245
+ if (metadata['test-layer']) {
246
+ const rawLayers = splitValues(metadata['test-layer']);
247
+ const invalid = rawLayers.filter((layer) => !TEST_LAYER_VALUES.includes(layer));
248
+ if (invalid.length > 0) {
249
+ errors.push(`test-layer 包含无效取值 ${invalid.join(', ')};允许值: ${TEST_LAYER_VALUES.join(', ')}`);
250
+ }
251
+ }
252
+ if (metadata['test-size']) {
253
+ const size = normalizeToken(metadata['test-size']);
254
+ if (!TEST_SIZE_VALUES.includes(size)) {
255
+ errors.push(`test-size 无效: ${metadata['test-size']};允许值: ${TEST_SIZE_VALUES.join(', ')}`);
256
+ }
257
+ }
258
+ if (metadata['test-scope']) {
259
+ const scope = normalizeToken(metadata['test-scope']);
260
+ if (!TEST_SCOPE_VALUES.includes(scope)) {
261
+ errors.push(`test-scope 无效: ${metadata['test-scope']};允许值: ${TEST_SCOPE_VALUES.join(', ')}`);
262
+ }
263
+ }
264
+ const layers = normalizeTestLayers(metadata['test-layer']);
265
+ const hasWaiver = Boolean(metadata.waiver || metadata['waiver-reason']);
266
+ const hasEvidencePlan = Boolean(metadata['evidence-plan'] || metadata.evidence);
267
+ if ((layers.length > 0 || metadata['test-size'] || metadata['test-scope']) && !hasEvidencePlan && !hasWaiver) {
268
+ errors.push('已声明测试策略,但缺少 evidence-plan/evidence 或 waiver/waiver-reason。');
269
+ }
270
+ return errors;
271
+ }
272
+
273
+ export function summarizeTaskTestStrategies(tasks = []) {
274
+ const layerCounts = Object.fromEntries(TEST_LAYER_VALUES.map((layer) => [layer, 0]));
275
+ const sizeCounts = Object.fromEntries(TEST_SIZE_VALUES.map((size) => [size, 0]));
276
+ const scopeCounts = Object.fromEntries(TEST_SCOPE_VALUES.map((scope) => [scope, 0]));
277
+ const taskStrategies = [];
278
+ let explicit = 0;
279
+ let inferred = 0;
280
+ let evidencePlanned = 0;
281
+ let evidencePresent = 0;
282
+ let waiverCount = 0;
283
+
284
+ for (const task of tasks) {
285
+ const strategy = taskTestStrategy(task);
286
+ if (strategy.inferred) {
287
+ inferred += 1;
288
+ } else {
289
+ explicit += 1;
290
+ }
291
+ if (strategy.evidencePlan) {
292
+ evidencePlanned += 1;
293
+ }
294
+ if (strategy.evidence) {
295
+ evidencePresent += 1;
296
+ }
297
+ if (strategy.waiver) {
298
+ waiverCount += 1;
299
+ }
300
+ for (const layer of strategy.layers) {
301
+ layerCounts[layer] = (layerCounts[layer] ?? 0) + 1;
302
+ }
303
+ sizeCounts[strategy.size] = (sizeCounts[strategy.size] ?? 0) + 1;
304
+ scopeCounts[strategy.scope] = (scopeCounts[strategy.scope] ?? 0) + 1;
305
+ taskStrategies.push({
306
+ id: task.id ?? null,
307
+ title: task.title ?? null,
308
+ done: Boolean(task.checked ?? task.done),
309
+ source: task.relativePath ?? task.source ?? null,
310
+ line: task.lineNumber ?? task.line ?? null,
311
+ layers: strategy.layers,
312
+ size: strategy.size,
313
+ scope: strategy.scope,
314
+ evidence: strategy.evidence,
315
+ evidencePlan: strategy.evidencePlan,
316
+ waiver: strategy.waiver,
317
+ upgradeReason: strategy.upgradeReason,
318
+ inferred: strategy.inferred,
319
+ description: describeTestStrategy(strategy),
320
+ });
321
+ }
322
+
323
+ const total = taskStrategies.length;
324
+ const e2eLike = (layerCounts.e2e ?? 0) + (layerCounts.visual ?? 0) + (layerCounts.weapp ?? 0);
325
+ const midLayer = (layerCounts.integration ?? 0) + (layerCounts.smoke ?? 0) + (layerCounts.security ?? 0);
326
+ const unitLayer = layerCounts.unit ?? 0;
327
+ const warnings = [];
328
+ if (total > 0 && e2eLike > 0 && unitLayer === 0 && midLayer === 0) {
329
+ warnings.push('检测到端到端/视觉/小程序证据,但缺少单元或集成层承接,存在倒金字塔风险。');
330
+ }
331
+ if (total > 0 && explicit === 0) {
332
+ warnings.push('当前任务尚未显式声明测试策略,quality 将使用风险推导结果。');
333
+ }
334
+ if (total > 0 && evidencePlanned < total && waiverCount === 0) {
335
+ warnings.push('部分任务缺少 evidence-plan/evidence 或明确豁免理由。');
336
+ }
337
+
338
+ return {
339
+ total,
340
+ explicit,
341
+ inferred,
342
+ evidencePlanned,
343
+ evidencePresent,
344
+ waiverCount,
345
+ layerCounts,
346
+ sizeCounts,
347
+ scopeCounts,
348
+ tasks: taskStrategies,
349
+ warnings,
350
+ recommendations: [
351
+ '小范围逻辑优先使用单元测试,触达 CLI/API/Agent 契约时升级到集成或契约验证。',
352
+ '触达用户主路径、页面、小程序、发布或跨系统链路时,保留端到端、截图或运行态证据。',
353
+ '70/20/10 只作为健康形状参考,不作为硬性比例门禁;实际以风险和本次证据为准。',
354
+ ],
355
+ };
356
+ }
357
+
358
+ export function detectTestStrategyCapabilities({ scripts = {}, files = [], dependencyNames = [] } = {}) {
359
+ const scriptEntries = Object.entries(scripts);
360
+ const commandText = scriptEntries.map(([name, command]) => `${name}: ${command}`).join('\n');
361
+ const fileText = files.map((file) => file.path ?? file).join('\n');
362
+ const depsText = dependencyNames.join('\n');
363
+ const haystack = `${commandText}\n${fileText}\n${depsText}`.toLowerCase();
364
+ const commandNames = {
365
+ unit: [/unit|node --test|vitest|jest|mocha|pytest/],
366
+ integration: [/integration|contract|api:test|test:api|test:int/],
367
+ e2e: [/e2e|playwright|cypress|test:ui/],
368
+ smoke: [/smoke/],
369
+ visual: [/visual|screenshot|storybook|chromatic/],
370
+ performance: [/perf|performance|load|stress|k6|lighthouse|autocannon|wrk/],
371
+ security: [/security|audit|sast|semgrep|trivy/],
372
+ weapp: [/weapp|微信小程序|小程序/],
373
+ };
374
+ return Object.fromEntries(Object.entries(commandNames).map(([layer, patterns]) => {
375
+ const commands = scriptEntries
376
+ .filter(([name, command]) => patterns.some((pattern) => pattern.test(`${name}: ${command}`.toLowerCase())))
377
+ .map(([name, command]) => `${name}: ${command}`);
378
+ const present = commands.length > 0 || patterns.some((pattern) => pattern.test(haystack));
379
+ return [layer, { present, commands }];
380
+ }));
381
+ }
382
+
383
+ export function choosePrimaryTestLayer(task = {}) {
384
+ const strategy = taskTestStrategy(task);
385
+ return firstKnown(strategy.layers, TEST_LAYER_VALUES, 'unit');
386
+ }