@openprd/cli 0.1.0
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/.openprd/README.md +82 -0
- package/.openprd/benchmarks/evidence/milvus-io-ai-code-review-gets-better-when-models-debate-claude-vs-gemini-vs-code.md +14 -0
- package/.openprd/benchmarks/evidence/nolanlawson-com-using-ai-to-write-better-code-more-slowly.md +14 -0
- package/.openprd/benchmarks/index.md +37 -0
- package/.openprd/benchmarks/sources.yaml +56 -0
- package/.openprd/config.yaml +50 -0
- package/.openprd/discovery/config.json +21 -0
- package/.openprd/engagements/active/flows.md +30 -0
- package/.openprd/engagements/active/handoff.md +9 -0
- package/.openprd/engagements/active/intake.md +15 -0
- package/.openprd/engagements/active/prd.md +161 -0
- package/.openprd/engagements/active/review.html +61 -0
- package/.openprd/engagements/active/roles.md +21 -0
- package/.openprd/engagements/work-units/wu-20260524015648-6d33ded7.json +23 -0
- package/.openprd/exports/.gitkeep +0 -0
- package/.openprd/knowledge/index.json +7 -0
- package/.openprd/quality/config.json +229 -0
- package/.openprd/reviews/v0001.html +1256 -0
- package/.openprd/schema/diagram-architecture.schema.yaml +49 -0
- package/.openprd/schema/diagram-product-flow.schema.yaml +52 -0
- package/.openprd/schema/prd.schema.yaml +121 -0
- package/.openprd/sessions/.gitkeep +0 -0
- package/.openprd/standards/config.json +88 -0
- package/.openprd/standards/file-manual-template.md +28 -0
- package/.openprd/standards/folder-readme-template.md +28 -0
- package/.openprd/state/.gitkeep +0 -0
- package/.openprd/state/changes.json +12 -0
- package/.openprd/state/current.json +169 -0
- package/.openprd/state/version-index.json +15 -0
- package/.openprd/state/versions/.gitkeep +0 -0
- package/.openprd/state/versions/v0001.json +121 -0
- package/.openprd/state/versions/v0001.md +161 -0
- package/.openprd/templates/agent/intake.md +6 -0
- package/.openprd/templates/agent/prd.md +21 -0
- package/.openprd/templates/b2b/intake.md +6 -0
- package/.openprd/templates/b2b/prd.md +24 -0
- package/.openprd/templates/base/intake.md +18 -0
- package/.openprd/templates/base/prd.md +67 -0
- package/.openprd/templates/company/README.md +10 -0
- package/.openprd/templates/consumer/intake.md +6 -0
- package/.openprd/templates/consumer/prd.md +19 -0
- package/.openprd/templates/diagram/architecture.contract.json +53 -0
- package/.openprd/templates/diagram/product-flow.contract.json +76 -0
- package/.openprd/templates/industry/README.md +16 -0
- package/.openprd/templates/manifest.yaml +27 -0
- package/.openprd/templates/project/README.md +14 -0
- package/.openprd/templates/session/README.md +14 -0
- package/AGENTS.md +44 -0
- package/CONTRIBUTING.md +30 -0
- package/LICENSE +21 -0
- package/README.md +727 -0
- package/README_CN.md +583 -0
- package/SECURITY.md +23 -0
- package/bin/openprd.js +5 -0
- package/docs/assets/openprd-capability-overview-en.png +0 -0
- package/docs/assets/openprd-capability-overview-zh.png +0 -0
- package/docs/assets/openprd-learning-html.png +0 -0
- package/docs/assets/openprd-quality-html.png +0 -0
- package/docs/assets/openprd-review-html.png +0 -0
- package/docs/assets/openprd-scenario-overview.png +0 -0
- package/docs/assets/openprd-scenario-overview.svg +114 -0
- package/docs/assets/openprd-self-evolving-mechanisms-en.png +0 -0
- package/docs/assets/openprd-self-evolving-mechanisms-zh.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-en.png +0 -0
- package/docs/assets/openprd-visual-compare-case-study-zh.png +0 -0
- package/package.json +59 -0
- package/scripts/openprd-dev-check.mjs +5 -0
- package/scripts/openprd-review-presentation.mjs +82 -0
- package/skills/openprd-benchmark-router/SKILL.md +92 -0
- package/skills/openprd-benchmark-router/agents/openai.yaml +4 -0
- package/skills/openprd-benchmark-router/references/benchmark-sources.md +74 -0
- package/skills/openprd-benchmark-router/references/evaluation-lenses.md +66 -0
- package/skills/openprd-benchmark-router/references/source-policy.md +35 -0
- package/skills/openprd-diagram-review/SKILL.md +91 -0
- package/skills/openprd-diagram-review/agents/openai.yaml +4 -0
- package/skills/openprd-diagram-review/examples/architecture-zh.md +8 -0
- package/skills/openprd-diagram-review/examples/product-flow-zh.md +7 -0
- package/skills/openprd-diagram-review/references/cocoon-patterns.md +17 -0
- package/skills/openprd-diagram-review/references/diagram-contracts.md +126 -0
- package/skills/openprd-diagram-review/references/review-checklist.md +10 -0
- package/skills/openprd-discovery-loop/SKILL.md +196 -0
- package/skills/openprd-discovery-loop/agents/openai.yaml +3 -0
- package/skills/openprd-harness/SKILL.md +179 -0
- package/skills/openprd-harness/agents/openai.yaml +4 -0
- package/skills/openprd-harness/examples/full-workflow-zh.md +9 -0
- package/skills/openprd-harness/references/command-map.md +71 -0
- package/skills/openprd-harness/references/examples.md +26 -0
- package/skills/openprd-harness/references/usage-guide.md +335 -0
- package/skills/openprd-harness/references/workflow-gates.md +51 -0
- package/skills/openprd-learning-review/SKILL.md +75 -0
- package/skills/openprd-learning-review/agents/openai.yaml +4 -0
- package/skills/openprd-learning-review/references/content-contract.md +125 -0
- package/skills/openprd-learning-review/references/ebook-reader.md +46 -0
- package/skills/openprd-learning-review/references/evidence-manifest.md +55 -0
- package/skills/openprd-learning-review/references/genre-library.md +43 -0
- package/skills/openprd-learning-review/references/prompt-engineering.md +71 -0
- package/skills/openprd-learning-review/references/quality-rubric.md +28 -0
- package/skills/openprd-learning-review/references/retrieval-worked-example.md +40 -0
- package/skills/openprd-learning-review/references/style-packs/xianxia-cultivation.prompt.md +67 -0
- package/skills/openprd-quality/SKILL.md +101 -0
- package/skills/openprd-requirement-intake/SKILL.md +76 -0
- package/skills/openprd-requirement-intake/agents/openai.yaml +4 -0
- package/skills/openprd-requirement-intake/references/prd-template-lenses.md +105 -0
- package/skills/openprd-requirement-intake/references/routing-rubric.md +64 -0
- package/skills/openprd-router/SKILL.md +40 -0
- package/skills/openprd-shared/SKILL.md +142 -0
- package/skills/openprd-shared/agents/openai.yaml +4 -0
- package/skills/openprd-shared/references/language-and-review.md +50 -0
- package/skills/openprd-shared/references/operating-rules.md +65 -0
- package/skills/openprd-shared/references/skill-architecture.md +70 -0
- package/skills/openprd-standards/SKILL.md +79 -0
- package/skills/openprd-standards/agents/openai.yaml +4 -0
- package/src/agent-integration.js +1717 -0
- package/src/benchmark.js +873 -0
- package/src/cli/args.js +460 -0
- package/src/cli/print.js +1423 -0
- package/src/codex-hook-runner-template.mjs +2422 -0
- package/src/dev-standards.js +372 -0
- package/src/diagram-core.js +1047 -0
- package/src/diagram-workspace.js +262 -0
- package/src/discovery.js +709 -0
- package/src/fleet.js +531 -0
- package/src/fs-utils.js +83 -0
- package/src/growth.js +545 -0
- package/src/html-artifacts.js +3803 -0
- package/src/knowledge.js +668 -0
- package/src/language-policy.js +142 -0
- package/src/learning-review.js +1655 -0
- package/src/loop.js +1290 -0
- package/src/openprd.js +1136 -0
- package/src/openspec/change-lifecycle.js +359 -0
- package/src/openspec/change-validate.js +248 -0
- package/src/openspec/constants.js +12 -0
- package/src/openspec/execute.js +300 -0
- package/src/openspec/generate.js +692 -0
- package/src/openspec/paths.js +111 -0
- package/src/openspec/tasks.js +352 -0
- package/src/prd-core.js +656 -0
- package/src/quality-html-artifact.js +1414 -0
- package/src/quality-learning.js +658 -0
- package/src/quality.js +1262 -0
- package/src/review-presentation.js +240 -0
- package/src/run-harness.js +1470 -0
- package/src/self-update.js +329 -0
- package/src/session-binding.js +140 -0
- package/src/source-inventory.js +224 -0
- package/src/standards.js +914 -0
- package/src/time.js +33 -0
- package/src/visual-compare.js +216 -0
- package/src/work-unit-migration.js +232 -0
- package/src/work-unit.js +88 -0
- package/src/workspace-core.js +1706 -0
- package/src/workspace-registry.js +162 -0
- package/src/workspace-workflow.js +1797 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
import { timestamp } from './time.js';
|
|
2
|
+
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return `${value ?? ''}`
|
|
5
|
+
.replace(/&/g, '&')
|
|
6
|
+
.replace(/</g, '<')
|
|
7
|
+
.replace(/>/g, '>')
|
|
8
|
+
.replace(/"/g, '"')
|
|
9
|
+
.replace(/'/g, ''');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function trimText(value, max = 96) {
|
|
13
|
+
const text = `${value ?? ''}`.trim();
|
|
14
|
+
if (!text) return '待补充';
|
|
15
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mermaidId(value, fallback = 'node') {
|
|
19
|
+
const text = `${value ?? ''}`.trim();
|
|
20
|
+
const normalized = text
|
|
21
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
22
|
+
.replace(/^_+|_+$/g, '')
|
|
23
|
+
.replace(/_+/g, '_');
|
|
24
|
+
const id = normalized || fallback;
|
|
25
|
+
return /^[a-zA-Z_]/.test(id) ? id : `${fallback}_${id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mermaidText(value, max = 64) {
|
|
29
|
+
return trimText(value, max)
|
|
30
|
+
.replace(/["`]/g, "'")
|
|
31
|
+
.replace(/[|<>]/g, ' ')
|
|
32
|
+
.replace(/\r?\n/g, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mermaidNodeLabel(primary, secondary) {
|
|
38
|
+
const title = mermaidText(primary, 34);
|
|
39
|
+
const subtitle = mermaidText(secondary, 64);
|
|
40
|
+
return subtitle && subtitle !== '待补充' ? `${title}<br/>${subtitle}` : title;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mermaidNodeDeclaration(id, label, type) {
|
|
44
|
+
if (type === 'decision') {
|
|
45
|
+
return ` ${id}{"${label}"}`;
|
|
46
|
+
}
|
|
47
|
+
if (type === 'success') {
|
|
48
|
+
return ` ${id}(["${label}"])`;
|
|
49
|
+
}
|
|
50
|
+
if (type === 'error_path') {
|
|
51
|
+
return ` ${id}[["${label}"]]`;
|
|
52
|
+
}
|
|
53
|
+
return ` ${id}["${label}"]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mermaidEdge(source, target, label, type = 'standard') {
|
|
57
|
+
const cleanLabel = mermaidText(label, 42);
|
|
58
|
+
const arrow = type === 'security' || type === 'error_path' ? '-.->' : '-->';
|
|
59
|
+
return cleanLabel && cleanLabel !== '待补充'
|
|
60
|
+
? ` ${source} ${arrow}|"${cleanLabel}"| ${target}`
|
|
61
|
+
: ` ${source} ${arrow} ${target}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeList(value, fallback = []) {
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
const items = value.map((item) => `${item ?? ''}`.trim()).filter(Boolean);
|
|
67
|
+
return items.length > 0 ? items : fallback;
|
|
68
|
+
}
|
|
69
|
+
const text = `${value ?? ''}`.trim();
|
|
70
|
+
return text ? [text] : fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function takeList(value, count, fallback = []) {
|
|
74
|
+
return normalizeList(value, fallback).slice(0, count);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function joinList(value, fallback = '待补充', separator = ' · ') {
|
|
78
|
+
const items = normalizeList(value);
|
|
79
|
+
return items.length > 0 ? items.join(separator) : fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickValue(primary, fallback) {
|
|
83
|
+
if (primary === null || primary === undefined) return fallback;
|
|
84
|
+
if (typeof primary === 'string' && primary.trim() === '') return fallback;
|
|
85
|
+
if (Array.isArray(primary) && primary.length === 0) return fallback;
|
|
86
|
+
return primary;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function theme(type) {
|
|
90
|
+
const themes = {
|
|
91
|
+
frontend: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
|
|
92
|
+
backend: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
|
|
93
|
+
database: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
|
|
94
|
+
cloud: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
|
|
95
|
+
security: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
|
|
96
|
+
external: { fill: '#0f172a', stroke: '#94a3b8', title: '#e2e8f0' },
|
|
97
|
+
user_action: { fill: '#0f172a', stroke: '#22d3ee', title: '#67e8f9' },
|
|
98
|
+
system_process: { fill: '#0f172a', stroke: '#34d399', title: '#6ee7b7' },
|
|
99
|
+
decision: { fill: '#0f172a', stroke: '#f59e0b', title: '#fcd34d' },
|
|
100
|
+
error_path: { fill: '#0f172a', stroke: '#fb7185', title: '#fda4af' },
|
|
101
|
+
success: { fill: '#0f172a', stroke: '#c084fc', title: '#d8b4fe' },
|
|
102
|
+
};
|
|
103
|
+
return themes[type] ?? themes.external;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeCard(card, fallbackTitle = '摘要', fallbackColor = 'external') {
|
|
107
|
+
return {
|
|
108
|
+
title: pickValue(card?.title, fallbackTitle),
|
|
109
|
+
color: pickValue(card?.color, fallbackColor),
|
|
110
|
+
items: normalizeList(card?.items, ['待补充']),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizePanel(panel, fallbackTitle = '评审备注', fallbackColor = 'external') {
|
|
115
|
+
return {
|
|
116
|
+
title: pickValue(panel?.title, fallbackTitle),
|
|
117
|
+
color: pickValue(panel?.color, fallbackColor),
|
|
118
|
+
items: normalizeList(panel?.items, ['待补充']),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeLocale(contract) {
|
|
123
|
+
return pickValue(contract?.locale, contract?.lang ?? 'zh-CN');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeReviewStatus(value) {
|
|
127
|
+
return pickValue(value, 'pending-confirmation');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function hasCjk(text) {
|
|
131
|
+
return /[\u3400-\u9fff]/.test(text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function englishWords(text) {
|
|
135
|
+
return `${text ?? ''}`.match(/[A-Za-z][A-Za-z0-9+_.-]*/g) ?? [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isEnglishHeavyText(text) {
|
|
139
|
+
const value = `${text ?? ''}`.trim();
|
|
140
|
+
if (!value || hasCjk(value)) return false;
|
|
141
|
+
const words = englishWords(value);
|
|
142
|
+
return words.length >= 4;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function collectDiagramTexts(model) {
|
|
146
|
+
const entries = [];
|
|
147
|
+
const push = (path, value) => {
|
|
148
|
+
if (typeof value === 'string' && value.trim()) {
|
|
149
|
+
entries.push({ path, value: value.trim() });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
push('title', model.title);
|
|
153
|
+
push('subtitle', model.subtitle);
|
|
154
|
+
push('metadata.projectName', model.metadata?.projectName);
|
|
155
|
+
for (const [index, component] of (model.components ?? []).entries()) {
|
|
156
|
+
push(`components.${index}.name`, component.name);
|
|
157
|
+
push(`components.${index}.subtitle`, component.subtitle);
|
|
158
|
+
for (const [detailIndex, detail] of (component.details ?? []).entries()) {
|
|
159
|
+
push(`components.${index}.details.${detailIndex}`, detail);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const [index, flow] of (model.flows ?? []).entries()) {
|
|
163
|
+
push(`flows.${index}.label`, flow.label);
|
|
164
|
+
}
|
|
165
|
+
for (const [index, card] of (model.summaryCards ?? []).entries()) {
|
|
166
|
+
push(`summaryCards.${index}.title`, card.title);
|
|
167
|
+
for (const [itemIndex, item] of (card.items ?? []).entries()) {
|
|
168
|
+
push(`summaryCards.${index}.items.${itemIndex}`, item);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const [index, panel] of (model.sidePanels ?? []).entries()) {
|
|
172
|
+
push(`sidePanels.${index}.title`, panel.title);
|
|
173
|
+
for (const [itemIndex, item] of (panel.items ?? []).entries()) {
|
|
174
|
+
push(`sidePanels.${index}.items.${itemIndex}`, item);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const [index, instruction] of (model.reviewInstructions ?? []).entries()) {
|
|
178
|
+
push(`reviewInstructions.${index}`, instruction);
|
|
179
|
+
}
|
|
180
|
+
return entries;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function validateDiagramLanguage(model) {
|
|
184
|
+
const locale = `${model?.locale ?? 'zh-CN'}`.toLowerCase();
|
|
185
|
+
if (!locale.startsWith('zh')) {
|
|
186
|
+
return { valid: true, errors: [] };
|
|
187
|
+
}
|
|
188
|
+
const offenders = collectDiagramTexts(model)
|
|
189
|
+
.filter((entry) => isEnglishHeavyText(entry.value))
|
|
190
|
+
.slice(0, 12);
|
|
191
|
+
return {
|
|
192
|
+
valid: offenders.length === 0,
|
|
193
|
+
errors: offenders.map((entry) => (
|
|
194
|
+
`${entry.path} 应使用简体中文表达,当前内容偏英文: ${trimText(entry.value, 96)}`
|
|
195
|
+
)),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getAtPath(root, path) {
|
|
200
|
+
return path.split('.').reduce((acc, key) => (acc === null || acc === undefined ? undefined : acc[key]), root);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hasValue(value) {
|
|
204
|
+
if (value === null || value === undefined) return false;
|
|
205
|
+
if (typeof value === 'string') return value.trim() !== '';
|
|
206
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
207
|
+
if (typeof value === 'object') return Object.keys(value).length > 0;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderShell({ lang = 'zh-CN', title, subtitle, projectName, svgMarkup, summaryCards, sidePanels, footer }) {
|
|
212
|
+
const cards = summaryCards.map((card) => {
|
|
213
|
+
const cardTheme = theme(card.color);
|
|
214
|
+
const items = (card.items ?? []).map((item) => `<li>${escapeHtml(trimText(item, 132))}</li>`).join('');
|
|
215
|
+
return `
|
|
216
|
+
<div class="card">
|
|
217
|
+
<div class="card-header">
|
|
218
|
+
<span class="dot" style="background:${cardTheme.stroke}"></span>
|
|
219
|
+
<span>${escapeHtml(card.title)}</span>
|
|
220
|
+
</div>
|
|
221
|
+
<ul>${items}</ul>
|
|
222
|
+
</div>
|
|
223
|
+
`;
|
|
224
|
+
}).join('\n');
|
|
225
|
+
|
|
226
|
+
const panels = sidePanels.map((panel) => {
|
|
227
|
+
const panelTheme = theme(panel.color);
|
|
228
|
+
const items = (panel.items ?? []).map((item) => `<li>${escapeHtml(trimText(item, 120))}</li>`).join('');
|
|
229
|
+
return `
|
|
230
|
+
<div class="card">
|
|
231
|
+
<div class="card-header">
|
|
232
|
+
<span class="dot" style="background:${panelTheme.stroke}"></span>
|
|
233
|
+
<span>${escapeHtml(panel.title)}</span>
|
|
234
|
+
</div>
|
|
235
|
+
<ul>${items}</ul>
|
|
236
|
+
</div>
|
|
237
|
+
`;
|
|
238
|
+
}).join('\n');
|
|
239
|
+
|
|
240
|
+
return `<!DOCTYPE html>
|
|
241
|
+
<html lang="${escapeHtml(lang)}">
|
|
242
|
+
<head>
|
|
243
|
+
<meta charset="UTF-8" />
|
|
244
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
245
|
+
<title>${escapeHtml(title)}</title>
|
|
246
|
+
<style>
|
|
247
|
+
:root {
|
|
248
|
+
color-scheme: dark;
|
|
249
|
+
--bg: #020617;
|
|
250
|
+
--panel: rgba(15, 23, 42, 0.92);
|
|
251
|
+
--text: #e2e8f0;
|
|
252
|
+
--muted: #94a3b8;
|
|
253
|
+
--grid: rgba(148, 163, 184, 0.12);
|
|
254
|
+
}
|
|
255
|
+
* { box-sizing: border-box; }
|
|
256
|
+
body {
|
|
257
|
+
margin: 0;
|
|
258
|
+
font-family: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
|
|
259
|
+
color: var(--text);
|
|
260
|
+
background:
|
|
261
|
+
linear-gradient(var(--grid) 1px, transparent 1px),
|
|
262
|
+
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
|
|
263
|
+
radial-gradient(circle at top, rgba(34, 211, 238, 0.12), transparent 30%),
|
|
264
|
+
var(--bg);
|
|
265
|
+
background-size: 40px 40px, 40px 40px, 100% 100%, auto;
|
|
266
|
+
}
|
|
267
|
+
.page { max-width: 1240px; margin: 0 auto; padding: 32px 24px 48px; }
|
|
268
|
+
.header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
|
269
|
+
.header-copy { display: flex; flex-direction: column; gap: 4px; }
|
|
270
|
+
.pulse {
|
|
271
|
+
width: 12px; height: 12px; border-radius: 999px; background: #22d3ee;
|
|
272
|
+
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); animation: pulse 2s infinite;
|
|
273
|
+
}
|
|
274
|
+
@keyframes pulse {
|
|
275
|
+
0% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.7); }
|
|
276
|
+
70% { box-shadow: 0 0 0 10px rgba(34, 211, 238, 0); }
|
|
277
|
+
100% { box-shadow: 0 0 0 0 rgba(34, 211, 238, 0); }
|
|
278
|
+
}
|
|
279
|
+
.project-chip {
|
|
280
|
+
display: inline-flex;
|
|
281
|
+
align-items: center;
|
|
282
|
+
gap: 8px;
|
|
283
|
+
width: fit-content;
|
|
284
|
+
padding: 4px 10px;
|
|
285
|
+
border-radius: 999px;
|
|
286
|
+
border: 1px solid rgba(148, 163, 184, 0.24);
|
|
287
|
+
background: rgba(15, 23, 42, 0.72);
|
|
288
|
+
color: #cbd5e1;
|
|
289
|
+
font-size: 11px;
|
|
290
|
+
}
|
|
291
|
+
h1 { margin: 0; font-size: 28px; }
|
|
292
|
+
.subtitle-block { margin: 6px 0 0 24px; color: var(--muted); font-size: 13px; }
|
|
293
|
+
.diagram-shell {
|
|
294
|
+
margin-top: 24px; border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 20px;
|
|
295
|
+
padding: 20px; background: rgba(2, 6, 23, 0.72); backdrop-filter: blur(6px);
|
|
296
|
+
}
|
|
297
|
+
svg { width: 100%; height: auto; display: block; }
|
|
298
|
+
.node-title { font-size: 13px; font-weight: 700; }
|
|
299
|
+
.node-subtitle, .detail, .flow-label, .legend-label, .footer { font-size: 10px; fill: #cbd5e1; }
|
|
300
|
+
.detail { fill: #94a3b8; }
|
|
301
|
+
.summary-grid, .side-grid { display: grid; gap: 16px; margin-top: 24px; }
|
|
302
|
+
.summary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
303
|
+
.side-grid { grid-template-columns: 1fr 1fr; }
|
|
304
|
+
.card {
|
|
305
|
+
border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 16px;
|
|
306
|
+
background: var(--panel); padding: 14px 16px;
|
|
307
|
+
}
|
|
308
|
+
.card-header { display: flex; align-items: center; gap: 10px; font-size: 12px; margin-bottom: 8px; }
|
|
309
|
+
.dot { width: 10px; height: 10px; border-radius: 999px; }
|
|
310
|
+
ul { padding-left: 18px; margin: 0; color: #cbd5e1; font-size: 12px; line-height: 1.65; }
|
|
311
|
+
.footer { margin-top: 18px; color: var(--muted); }
|
|
312
|
+
</style>
|
|
313
|
+
</head>
|
|
314
|
+
<body>
|
|
315
|
+
<div class="page">
|
|
316
|
+
<div class="header">
|
|
317
|
+
<div class="pulse"></div>
|
|
318
|
+
<div class="header-copy">
|
|
319
|
+
<div class="project-chip">${escapeHtml(projectName ?? title)}</div>
|
|
320
|
+
<h1>${escapeHtml(title)}</h1>
|
|
321
|
+
<p class="subtitle-block">${escapeHtml(subtitle)}</p>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<div class="diagram-shell">${svgMarkup}</div>
|
|
325
|
+
<div class="summary-grid">${cards}</div>
|
|
326
|
+
<div class="side-grid">${panels}</div>
|
|
327
|
+
<div class="footer">${escapeHtml(footer)}</div>
|
|
328
|
+
</div>
|
|
329
|
+
</body>
|
|
330
|
+
</html>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderBox(node, layout) {
|
|
334
|
+
const nodeTheme = theme(node.type);
|
|
335
|
+
const detailLines = (node.details ?? []).slice(0, 4);
|
|
336
|
+
const detailMarkup = detailLines.map((line, index) => (
|
|
337
|
+
`<text x="${layout.x + 16}" y="${layout.y + 54 + (index * 16)}" class="detail">${escapeHtml(trimText(line, 42))}</text>`
|
|
338
|
+
)).join('');
|
|
339
|
+
|
|
340
|
+
return `
|
|
341
|
+
<g>
|
|
342
|
+
<rect x="${layout.x}" y="${layout.y}" width="${layout.width}" height="${layout.height}" rx="14" fill="${nodeTheme.fill}" fill-opacity="0.92" stroke="${nodeTheme.stroke}" stroke-width="1.5"></rect>
|
|
343
|
+
<text x="${layout.x + 16}" y="${layout.y + 28}" class="node-title" fill="${nodeTheme.title}">${escapeHtml(node.name)}</text>
|
|
344
|
+
<text x="${layout.x + 16}" y="${layout.y + 44}" class="node-subtitle">${escapeHtml(trimText(node.subtitle, 48))}</text>
|
|
345
|
+
${detailMarkup}
|
|
346
|
+
</g>
|
|
347
|
+
`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function renderArrow(def) {
|
|
351
|
+
const dashed = def.type === 'security' || def.type === 'error_path' ? 'stroke-dasharray="6,4"' : '';
|
|
352
|
+
const stroke = def.type === 'security' || def.type === 'error_path' ? '#fb7185' : '#7dd3fc';
|
|
353
|
+
return `<path d="${def.path}" fill="none" stroke="${stroke}" stroke-width="2" ${dashed} marker-end="url(#arrowhead)"></path>
|
|
354
|
+
<text x="${def.labelX}" y="${def.labelY}" class="flow-label">${escapeHtml(def.label)}</text>`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function resolveProductLayerTitle(productType) {
|
|
358
|
+
if (productType === 'consumer') return '消费端体验层';
|
|
359
|
+
if (productType === 'b2b') return 'B2B 工作流层';
|
|
360
|
+
if (productType === 'agent') return 'Agent 运行层';
|
|
361
|
+
return '产品体验层';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildArchitectureComponents(snapshot) {
|
|
365
|
+
const { sections } = snapshot;
|
|
366
|
+
const reviewTarget = sections.handoff.targetSystem ?? 'OpenSpec';
|
|
367
|
+
return [
|
|
368
|
+
{
|
|
369
|
+
id: 'users',
|
|
370
|
+
name: '主要用户',
|
|
371
|
+
type: 'external',
|
|
372
|
+
subtitle: joinList(sections.users.primaryUsers, '用户'),
|
|
373
|
+
details: takeList(sections.users.stakeholders, 3, ['相关方需要确认']),
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
id: 'experience',
|
|
377
|
+
name: resolveProductLayerTitle(snapshot.productType),
|
|
378
|
+
type: 'frontend',
|
|
379
|
+
subtitle: trimText(joinList(sections.scenarios.primaryFlows, sections.meta.title)),
|
|
380
|
+
details: takeList(sections.scope.inScope, 3, ['范围仍需细化']),
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
id: 'core',
|
|
384
|
+
name: '核心产品逻辑',
|
|
385
|
+
type: 'backend',
|
|
386
|
+
subtitle: trimText(sections.problem.problemStatement ?? '核心逻辑待澄清'),
|
|
387
|
+
details: takeList(sections.requirements.functional, 3, ['功能需求待补充']),
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: 'integrations',
|
|
391
|
+
name: '依赖与集成',
|
|
392
|
+
type: 'cloud',
|
|
393
|
+
subtitle: trimText(joinList(sections.constraints.dependencies, '暂无外部依赖记录')),
|
|
394
|
+
details: takeList(sections.constraints.dependencies, 4, ['依赖尚未确认']),
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
id: 'governance',
|
|
398
|
+
name: '约束与可靠性',
|
|
399
|
+
type: 'security',
|
|
400
|
+
subtitle: trimText(joinList(sections.constraints.compliance, joinList(sections.requirements.nonFunctional, '暂无明确约束'))),
|
|
401
|
+
details: [
|
|
402
|
+
...takeList(sections.constraints.compliance, 2),
|
|
403
|
+
...takeList(sections.requirements.nonFunctional, 2),
|
|
404
|
+
].slice(0, 4),
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
id: 'delivery',
|
|
408
|
+
name: '验证与交接',
|
|
409
|
+
type: 'database',
|
|
410
|
+
subtitle: trimText(joinList(sections.goals.successMetrics, '成功指标待确认')),
|
|
411
|
+
details: [
|
|
412
|
+
`目标: ${reviewTarget}`,
|
|
413
|
+
`下一步: ${trimText(sections.handoff.nextStep ?? '确认下一步', 48)}`,
|
|
414
|
+
...takeList(sections.goals.acceptanceGoals, 2),
|
|
415
|
+
].slice(0, 4),
|
|
416
|
+
},
|
|
417
|
+
];
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function buildArchitectureDiagramModel(snapshot) {
|
|
421
|
+
const scopeIn = takeList(snapshot.sections.scope.inScope, 3, ['范围待澄清']);
|
|
422
|
+
const scopeOut = takeList(snapshot.sections.scope.outOfScope, 2, ['范围外内容尚未明确']);
|
|
423
|
+
const assumptions = takeList(snapshot.sections.risks.assumptions, 4, ['假设仍需评审']);
|
|
424
|
+
const openQuestions = takeList(snapshot.sections.risks.openQuestions, 4, ['暂无开放问题记录']);
|
|
425
|
+
const primaryFlows = takeList(snapshot.sections.scenarios.primaryFlows, 3, ['主流程仍需确认']);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
type: 'architecture',
|
|
429
|
+
version: 1,
|
|
430
|
+
generatedAt: timestamp(),
|
|
431
|
+
locale: 'zh-CN',
|
|
432
|
+
title: '架构评审',
|
|
433
|
+
subtitle: '在需求定稿前评审系统边界、依赖和交接形态。',
|
|
434
|
+
components: buildArchitectureComponents(snapshot),
|
|
435
|
+
flows: [
|
|
436
|
+
{ source: 'users', target: 'experience', label: trimText(primaryFlows[0] ?? '用户进入产品流程', 40), type: 'standard' },
|
|
437
|
+
{ source: 'experience', target: 'core', label: '产品动作与编排', type: 'standard' },
|
|
438
|
+
{ source: 'core', target: 'integrations', label: '依赖与外部服务', type: 'standard' },
|
|
439
|
+
{ source: 'core', target: 'governance', label: '策略、可靠性与合规', type: 'security' },
|
|
440
|
+
{ source: 'core', target: 'delivery', label: '成功标准与交接', type: 'standard' },
|
|
441
|
+
{ source: 'integrations', target: 'delivery', label: '运营就绪', type: 'standard' },
|
|
442
|
+
{ source: 'governance', target: 'delivery', label: '评审与确认', type: 'security' },
|
|
443
|
+
],
|
|
444
|
+
summaryCards: [
|
|
445
|
+
{
|
|
446
|
+
title: '范围',
|
|
447
|
+
color: 'frontend',
|
|
448
|
+
items: [
|
|
449
|
+
`范围内: ${scopeIn.join(' / ')}`,
|
|
450
|
+
`范围外: ${scopeOut.join(' / ')}`,
|
|
451
|
+
`主流程: ${primaryFlows.join(' / ')}`,
|
|
452
|
+
],
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
title: '架构检查',
|
|
456
|
+
color: 'backend',
|
|
457
|
+
items: [
|
|
458
|
+
`核心逻辑: ${takeList(snapshot.sections.requirements.functional, 2, ['功能需求待补充']).join(' / ')}`,
|
|
459
|
+
`依赖: ${takeList(snapshot.sections.constraints.dependencies, 2, ['依赖待补充']).join(' / ')}`,
|
|
460
|
+
`约束: ${takeList(snapshot.sections.constraints.compliance, 2, takeList(snapshot.sections.requirements.nonFunctional, 2, ['约束待补充'])).join(' / ')}`,
|
|
461
|
+
],
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
title: '评审重点',
|
|
465
|
+
color: 'cloud',
|
|
466
|
+
items: [
|
|
467
|
+
`确认缺失假设: ${assumptions.join(' / ')}`,
|
|
468
|
+
`开放问题: ${openQuestions.join(' / ')}`,
|
|
469
|
+
'在需求定稿前请用户确认模块、边界和缺失系统。',
|
|
470
|
+
],
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
sidePanels: [
|
|
474
|
+
{ title: '假设', color: 'database', items: assumptions },
|
|
475
|
+
{
|
|
476
|
+
title: '评审说明',
|
|
477
|
+
color: 'cloud',
|
|
478
|
+
items: [
|
|
479
|
+
'确认这些模块是否反映澄清后的目标架构。',
|
|
480
|
+
'标记缺失系统、边界或外部依赖。',
|
|
481
|
+
'在需求定稿前验证可靠性、合规和交接预期。',
|
|
482
|
+
],
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
metadata: {
|
|
486
|
+
projectName: snapshot.title,
|
|
487
|
+
productType: snapshot.productType ?? '未分类',
|
|
488
|
+
owner: snapshot.owner,
|
|
489
|
+
versionId: snapshot.versionId,
|
|
490
|
+
targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenSpec',
|
|
491
|
+
reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function renderArchitectureDiagramHtml(model) {
|
|
497
|
+
const layouts = {
|
|
498
|
+
users: { x: 390, y: 48, width: 300, height: 96 },
|
|
499
|
+
experience: { x: 70, y: 228, width: 290, height: 120 },
|
|
500
|
+
core: { x: 395, y: 228, width: 290, height: 120 },
|
|
501
|
+
delivery: { x: 720, y: 228, width: 290, height: 120 },
|
|
502
|
+
integrations: { x: 180, y: 448, width: 290, height: 120 },
|
|
503
|
+
governance: { x: 610, y: 448, width: 290, height: 120 },
|
|
504
|
+
};
|
|
505
|
+
const fallbackLayouts = [
|
|
506
|
+
{ x: 390, y: 48, width: 300, height: 96 },
|
|
507
|
+
{ x: 70, y: 228, width: 290, height: 120 },
|
|
508
|
+
{ x: 395, y: 228, width: 290, height: 120 },
|
|
509
|
+
{ x: 720, y: 228, width: 290, height: 120 },
|
|
510
|
+
{ x: 180, y: 448, width: 290, height: 120 },
|
|
511
|
+
{ x: 610, y: 448, width: 290, height: 120 },
|
|
512
|
+
];
|
|
513
|
+
|
|
514
|
+
const arrows = [
|
|
515
|
+
{ path: 'M 540 144 C 540 182, 215 176, 215 228', label: model.flows[0]?.label ?? '用户流程', labelX: 312, labelY: 176, type: model.flows[0]?.type ?? 'standard' },
|
|
516
|
+
{ path: 'M 360 288 L 395 288', label: model.flows[1]?.label ?? '产品动作', labelX: 366, labelY: 276, type: model.flows[1]?.type ?? 'standard' },
|
|
517
|
+
{ path: 'M 685 288 L 720 288', label: model.flows[4]?.label ?? '成功标准', labelX: 694, labelY: 276, type: model.flows[4]?.type ?? 'standard' },
|
|
518
|
+
{ path: 'M 540 348 C 540 392, 325 396, 325 448', label: model.flows[2]?.label ?? '依赖', labelX: 300, labelY: 392, type: model.flows[2]?.type ?? 'standard' },
|
|
519
|
+
{ path: 'M 540 348 C 540 392, 755 396, 755 448', label: model.flows[3]?.label ?? '约束', labelX: 692, labelY: 392, type: model.flows[3]?.type ?? 'security' },
|
|
520
|
+
{ path: 'M 470 568 C 470 610, 820 610, 820 348', label: model.flows[5]?.label ?? '运营就绪', labelX: 596, labelY: 614, type: model.flows[5]?.type ?? 'standard' },
|
|
521
|
+
{ path: 'M 820 568 C 920 612, 920 416, 865 348', label: model.flows[6]?.label ?? '评审确认', labelX: 850, labelY: 612, type: model.flows[6]?.type ?? 'security' },
|
|
522
|
+
];
|
|
523
|
+
|
|
524
|
+
const componentMarkup = model.components
|
|
525
|
+
.map((component, index) => renderBox(component, layouts[component.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
|
|
526
|
+
.join('\n');
|
|
527
|
+
const arrowMarkup = arrows.map(renderArrow).join('\n');
|
|
528
|
+
const svgMarkup = `
|
|
529
|
+
<svg viewBox="0 0 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
|
|
530
|
+
<defs>
|
|
531
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
|
532
|
+
<polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
|
|
533
|
+
</marker>
|
|
534
|
+
</defs>
|
|
535
|
+
<rect x="40" y="172" width="1000" height="430" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.55" stroke-width="1.5" stroke-dasharray="8,5"></rect>
|
|
536
|
+
<text x="58" y="194" class="legend-label">方案边界</text>
|
|
537
|
+
${arrowMarkup}
|
|
538
|
+
${componentMarkup}
|
|
539
|
+
<g>
|
|
540
|
+
<text x="54" y="652" class="legend-label">图例</text>
|
|
541
|
+
<rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">体验</text>
|
|
542
|
+
<rect x="182" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="202" y="676" class="legend-label">核心逻辑</text>
|
|
543
|
+
<rect x="330" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="350" y="676" class="legend-label">验证</text>
|
|
544
|
+
<rect x="476" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="496" y="676" class="legend-label">依赖</text>
|
|
545
|
+
<rect x="640" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="660" y="676" class="legend-label">约束</text>
|
|
546
|
+
<rect x="798" y="666" width="12" height="12" rx="3" fill="#94a3b8"></rect><text x="818" y="676" class="legend-label">外部/用户</text>
|
|
547
|
+
</g>
|
|
548
|
+
</svg>
|
|
549
|
+
`;
|
|
550
|
+
|
|
551
|
+
return renderShell({
|
|
552
|
+
lang: model.locale ?? 'zh-CN',
|
|
553
|
+
title: model.title,
|
|
554
|
+
subtitle: model.subtitle,
|
|
555
|
+
projectName: model.metadata?.projectName ?? model.title,
|
|
556
|
+
svgMarkup,
|
|
557
|
+
summaryCards: model.summaryCards,
|
|
558
|
+
sidePanels: model.sidePanels,
|
|
559
|
+
footer: `负责人: ${model.metadata.owner} · 版本: ${model.metadata.versionId} · 目标: ${model.metadata.targetSystem} · 生成时间: ${model.generatedAt}`,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function buildProductFlowDiagramModel(snapshot) {
|
|
564
|
+
const primaryUsers = takeList(snapshot.sections.users.primaryUsers, 2, ['主要用户']);
|
|
565
|
+
const primaryFlows = takeList(snapshot.sections.scenarios.primaryFlows, 4, ['主流程仍需确认']);
|
|
566
|
+
const edgeCases = takeList(snapshot.sections.scenarios.edgeCases, 3, ['边界情况仍需澄清']);
|
|
567
|
+
const failureModes = takeList(snapshot.sections.scenarios.failureModes, 3, ['失败路径仍需澄清']);
|
|
568
|
+
const goals = takeList(snapshot.sections.goals.goals, 2, ['目标仍需确认']);
|
|
569
|
+
const successMetrics = takeList(snapshot.sections.goals.successMetrics, 2, ['成功指标仍需确认']);
|
|
570
|
+
const openQuestions = takeList(snapshot.sections.risks.openQuestions, 4, ['暂无开放问题记录']);
|
|
571
|
+
const steps = [
|
|
572
|
+
{
|
|
573
|
+
id: 'entry',
|
|
574
|
+
name: '入口触发',
|
|
575
|
+
type: 'user_action',
|
|
576
|
+
lane: primaryUsers[0],
|
|
577
|
+
subtitle: trimText(primaryFlows[0] ?? '用户进入流程'),
|
|
578
|
+
details: takeList(snapshot.sections.scope.inScope, 2, ['范围仍需细化']),
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
id: 'experience',
|
|
582
|
+
name: '产品内步骤',
|
|
583
|
+
type: 'system_process',
|
|
584
|
+
lane: '产品',
|
|
585
|
+
subtitle: trimText(primaryFlows[1] ?? snapshot.sections.problem.problemStatement ?? '核心产品步骤'),
|
|
586
|
+
details: takeList(snapshot.sections.requirements.functional, 2, ['功能需求待补充']),
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
id: 'decision',
|
|
590
|
+
name: '决策点',
|
|
591
|
+
type: 'decision',
|
|
592
|
+
lane: '决策',
|
|
593
|
+
subtitle: trimText(edgeCases[0] ?? '决策标准待澄清'),
|
|
594
|
+
details: [
|
|
595
|
+
`目标: ${trimText(goals[0], 40)}`,
|
|
596
|
+
`指标: ${trimText(successMetrics[0], 40)}`,
|
|
597
|
+
],
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: 'success',
|
|
601
|
+
name: '成功结果',
|
|
602
|
+
type: 'success',
|
|
603
|
+
lane: '结果',
|
|
604
|
+
subtitle: trimText(successMetrics[0] ?? '成功结果仍需确认'),
|
|
605
|
+
details: takeList(snapshot.sections.goals.acceptanceGoals, 2, ['验收目标待补充']),
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
id: 'failure',
|
|
609
|
+
name: '失败与恢复',
|
|
610
|
+
type: 'error_path',
|
|
611
|
+
lane: '结果',
|
|
612
|
+
subtitle: trimText(failureModes[0] ?? '失败路径待澄清'),
|
|
613
|
+
details: [
|
|
614
|
+
...failureModes.slice(0, 2),
|
|
615
|
+
...openQuestions.slice(0, 2),
|
|
616
|
+
].slice(0, 4),
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
type: 'product-flow',
|
|
622
|
+
version: 1,
|
|
623
|
+
generatedAt: timestamp(),
|
|
624
|
+
locale: 'zh-CN',
|
|
625
|
+
title: '产品流程评审',
|
|
626
|
+
subtitle: '在需求定稿前评审主要旅程、决策点和恢复路径。',
|
|
627
|
+
actors: primaryUsers,
|
|
628
|
+
steps,
|
|
629
|
+
transitions: [
|
|
630
|
+
{ from: 'entry', to: 'experience', label: primaryFlows[0] ?? '开始旅程', type: 'standard' },
|
|
631
|
+
{ from: 'experience', to: 'decision', label: primaryFlows[1] ?? '系统处理请求', type: 'standard' },
|
|
632
|
+
{ from: 'decision', to: 'success', label: goals[0] ?? '成功路径', type: 'standard' },
|
|
633
|
+
{ from: 'decision', to: 'failure', label: failureModes[0] ?? '失败路径', type: 'error_path' },
|
|
634
|
+
],
|
|
635
|
+
summaryCards: [
|
|
636
|
+
{
|
|
637
|
+
title: '参与者与范围',
|
|
638
|
+
color: 'user_action',
|
|
639
|
+
items: [
|
|
640
|
+
`参与者: ${primaryUsers.join(' / ')}`,
|
|
641
|
+
`范围内: ${takeList(snapshot.sections.scope.inScope, 2, ['范围待补充']).join(' / ')}`,
|
|
642
|
+
`范围外: ${takeList(snapshot.sections.scope.outOfScope, 2, ['范围外内容待补充']).join(' / ')}`,
|
|
643
|
+
],
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
title: '流程检查',
|
|
647
|
+
color: 'system_process',
|
|
648
|
+
items: [
|
|
649
|
+
`主流程: ${primaryFlows.join(' / ')}`,
|
|
650
|
+
`边界情况: ${edgeCases.join(' / ')}`,
|
|
651
|
+
`失败模式: ${failureModes.join(' / ')}`,
|
|
652
|
+
],
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
title: '评审重点',
|
|
656
|
+
color: 'decision',
|
|
657
|
+
items: [
|
|
658
|
+
`目标: ${goals.join(' / ')}`,
|
|
659
|
+
`成功指标: ${successMetrics.join(' / ')}`,
|
|
660
|
+
'在需求定稿前确认步骤、决策点和缺失的恢复路径。',
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
],
|
|
664
|
+
sidePanels: [
|
|
665
|
+
{ title: '开放问题', color: 'error_path', items: openQuestions },
|
|
666
|
+
{
|
|
667
|
+
title: '评审说明',
|
|
668
|
+
color: 'decision',
|
|
669
|
+
items: [
|
|
670
|
+
'确认用户旅程和系统响应顺序是否正确。',
|
|
671
|
+
'标记缺失的决策点、失败路径和恢复步骤。',
|
|
672
|
+
'确认该流程是否足以支持进入实现前确认。',
|
|
673
|
+
],
|
|
674
|
+
},
|
|
675
|
+
],
|
|
676
|
+
metadata: {
|
|
677
|
+
projectName: snapshot.title,
|
|
678
|
+
productType: snapshot.productType ?? '未分类',
|
|
679
|
+
owner: snapshot.owner,
|
|
680
|
+
versionId: snapshot.versionId,
|
|
681
|
+
targetSystem: snapshot.sections.handoff.targetSystem ?? 'OpenSpec',
|
|
682
|
+
reviewStatus: normalizeReviewStatus(snapshot?.reviewStatus),
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export function renderProductFlowDiagramHtml(model) {
|
|
688
|
+
const layouts = {
|
|
689
|
+
entry: { x: 90, y: 250, width: 190, height: 112 },
|
|
690
|
+
experience: { x: 330, y: 250, width: 210, height: 112 },
|
|
691
|
+
decision: { x: 590, y: 245, width: 180, height: 122 },
|
|
692
|
+
success: { x: 820, y: 140, width: 180, height: 112 },
|
|
693
|
+
failure: { x: 820, y: 360, width: 180, height: 122 },
|
|
694
|
+
};
|
|
695
|
+
const fallbackLayouts = [
|
|
696
|
+
{ x: 90, y: 250, width: 190, height: 112 },
|
|
697
|
+
{ x: 330, y: 250, width: 210, height: 112 },
|
|
698
|
+
{ x: 590, y: 245, width: 180, height: 122 },
|
|
699
|
+
{ x: 820, y: 140, width: 180, height: 112 },
|
|
700
|
+
{ x: 820, y: 360, width: 180, height: 122 },
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
const laneMarkup = [
|
|
704
|
+
{ y: 118, label: '用户/触发' },
|
|
705
|
+
{ y: 220, label: '核心流程' },
|
|
706
|
+
{ y: 438, label: '结果/恢复' },
|
|
707
|
+
].map((lane) => `
|
|
708
|
+
<g>
|
|
709
|
+
<line x1="70" y1="${lane.y}" x2="1020" y2="${lane.y}" stroke="#334155" stroke-width="1" stroke-dasharray="6,4"></line>
|
|
710
|
+
<text x="74" y="${lane.y - 8}" class="legend-label">${escapeHtml(lane.label)}</text>
|
|
711
|
+
</g>
|
|
712
|
+
`).join('\n');
|
|
713
|
+
|
|
714
|
+
const stepMarkup = model.steps
|
|
715
|
+
.map((step, index) => renderBox(step, layouts[step.id] ?? fallbackLayouts[index] ?? fallbackLayouts.at(-1)))
|
|
716
|
+
.join('\n');
|
|
717
|
+
const transitions = [
|
|
718
|
+
{ path: 'M 280 306 L 330 306', label: model.transitions[0]?.label ?? '开始', labelX: 288, labelY: 294, type: model.transitions[0]?.type ?? 'standard' },
|
|
719
|
+
{ path: 'M 540 306 L 590 306', label: model.transitions[1]?.label ?? '核心步骤', labelX: 546, labelY: 294, type: model.transitions[1]?.type ?? 'standard' },
|
|
720
|
+
{ path: 'M 770 282 C 800 240, 820 220, 820 196', label: model.transitions[2]?.label ?? '成功路径', labelX: 786, labelY: 226, type: model.transitions[2]?.type ?? 'standard' },
|
|
721
|
+
{ path: 'M 770 330 C 800 370, 820 400, 820 420', label: model.transitions[3]?.label ?? '失败路径', labelX: 786, labelY: 388, type: model.transitions[3]?.type ?? 'error_path' },
|
|
722
|
+
].map(renderArrow).join('\n');
|
|
723
|
+
|
|
724
|
+
const svgMarkup = `
|
|
725
|
+
<svg viewBox="0 0 1080 720" role="img" aria-label="${escapeHtml(model.title)}">
|
|
726
|
+
<defs>
|
|
727
|
+
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto">
|
|
728
|
+
<polygon points="0 0, 10 5, 0 10" fill="#7dd3fc"></polygon>
|
|
729
|
+
</marker>
|
|
730
|
+
</defs>
|
|
731
|
+
<rect x="56" y="92" width="968" height="520" rx="18" fill="none" stroke="#f59e0b" stroke-opacity="0.45" stroke-width="1.5" stroke-dasharray="8,5"></rect>
|
|
732
|
+
<text x="74" y="118" class="legend-label">产品流程边界</text>
|
|
733
|
+
${laneMarkup}
|
|
734
|
+
${transitions}
|
|
735
|
+
${stepMarkup}
|
|
736
|
+
<g>
|
|
737
|
+
<text x="54" y="652" class="legend-label">图例</text>
|
|
738
|
+
<rect x="54" y="666" width="12" height="12" rx="3" fill="#22d3ee"></rect><text x="74" y="676" class="legend-label">用户动作</text>
|
|
739
|
+
<rect x="196" y="666" width="12" height="12" rx="3" fill="#34d399"></rect><text x="216" y="676" class="legend-label">系统处理</text>
|
|
740
|
+
<rect x="372" y="666" width="12" height="12" rx="3" fill="#f59e0b"></rect><text x="392" y="676" class="legend-label">决策</text>
|
|
741
|
+
<rect x="516" y="666" width="12" height="12" rx="3" fill="#c084fc"></rect><text x="536" y="676" class="legend-label">成功</text>
|
|
742
|
+
<rect x="648" y="666" width="12" height="12" rx="3" fill="#fb7185"></rect><text x="668" y="676" class="legend-label">错误/恢复</text>
|
|
743
|
+
</g>
|
|
744
|
+
</svg>
|
|
745
|
+
`;
|
|
746
|
+
|
|
747
|
+
return renderShell({
|
|
748
|
+
lang: model.locale ?? 'zh-CN',
|
|
749
|
+
title: model.title,
|
|
750
|
+
subtitle: model.subtitle,
|
|
751
|
+
projectName: model.metadata?.projectName ?? model.title,
|
|
752
|
+
svgMarkup,
|
|
753
|
+
summaryCards: model.summaryCards,
|
|
754
|
+
sidePanels: model.sidePanels,
|
|
755
|
+
footer: `负责人: ${model.metadata.owner} · 版本: ${model.metadata.versionId} · 目标: ${model.metadata.targetSystem} · 生成时间: ${model.generatedAt}`,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function renderProductFlowMermaid(model) {
|
|
760
|
+
const steps = Array.isArray(model.steps) ? model.steps : [];
|
|
761
|
+
const idMap = new Map();
|
|
762
|
+
const declarations = steps.map((step, index) => {
|
|
763
|
+
const id = mermaidId(step.id, `step_${index + 1}`);
|
|
764
|
+
idMap.set(step.id, id);
|
|
765
|
+
const label = mermaidNodeLabel(step.name, step.subtitle ?? step.description);
|
|
766
|
+
return mermaidNodeDeclaration(id, label, step.type);
|
|
767
|
+
});
|
|
768
|
+
const edges = (Array.isArray(model.transitions) ? model.transitions : [])
|
|
769
|
+
.map((transition) => {
|
|
770
|
+
const from = idMap.get(transition.from) ?? mermaidId(transition.from, 'from');
|
|
771
|
+
const to = idMap.get(transition.to) ?? mermaidId(transition.to, 'to');
|
|
772
|
+
return mermaidEdge(from, to, transition.label, transition.type);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
return [
|
|
776
|
+
'flowchart LR',
|
|
777
|
+
...declarations,
|
|
778
|
+
...edges,
|
|
779
|
+
].join('\n');
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export function renderArchitectureMermaid(model) {
|
|
783
|
+
const components = Array.isArray(model.components) ? model.components : [];
|
|
784
|
+
const idMap = new Map();
|
|
785
|
+
const external = [];
|
|
786
|
+
const internal = [];
|
|
787
|
+
|
|
788
|
+
for (const [index, component] of components.entries()) {
|
|
789
|
+
const id = mermaidId(component.id, `component_${index + 1}`);
|
|
790
|
+
idMap.set(component.id, id);
|
|
791
|
+
const label = mermaidNodeLabel(component.name, component.subtitle ?? component.description);
|
|
792
|
+
const declaration = mermaidNodeDeclaration(id, label, component.type === 'security' ? 'error_path' : 'system_process');
|
|
793
|
+
if (component.type === 'external') {
|
|
794
|
+
external.push(declaration);
|
|
795
|
+
} else {
|
|
796
|
+
internal.push(declaration);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const edges = (Array.isArray(model.flows) ? model.flows : [])
|
|
801
|
+
.map((flow) => {
|
|
802
|
+
const source = idMap.get(flow.source) ?? mermaidId(flow.source, 'source');
|
|
803
|
+
const target = idMap.get(flow.target) ?? mermaidId(flow.target, 'target');
|
|
804
|
+
return mermaidEdge(source, target, flow.label, flow.type);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
return [
|
|
808
|
+
'flowchart LR',
|
|
809
|
+
...external,
|
|
810
|
+
' subgraph solution["方案边界"]',
|
|
811
|
+
...internal.map((line) => ` ${line}`),
|
|
812
|
+
' end',
|
|
813
|
+
...edges,
|
|
814
|
+
].join('\n');
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function renderDiagramMermaidFromModel(type, model) {
|
|
818
|
+
if (type === 'product-flow') {
|
|
819
|
+
return renderProductFlowMermaid(model);
|
|
820
|
+
}
|
|
821
|
+
return renderArchitectureMermaid(model);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
export function buildDiagramArtifact(snapshot, options = {}) {
|
|
825
|
+
const type = options.type ?? 'architecture';
|
|
826
|
+
const contract = options.contract ?? null;
|
|
827
|
+
|
|
828
|
+
if (type === 'product-flow' && contract) {
|
|
829
|
+
const base = buildProductFlowDiagramModel(snapshot);
|
|
830
|
+
const model = {
|
|
831
|
+
...base,
|
|
832
|
+
...contract,
|
|
833
|
+
type: 'product-flow',
|
|
834
|
+
locale: normalizeLocale(contract),
|
|
835
|
+
title: pickValue(contract.title, base.title),
|
|
836
|
+
subtitle: pickValue(contract.subtitle, base.subtitle),
|
|
837
|
+
actors: normalizeList(contract.actors, base.actors),
|
|
838
|
+
steps: Array.isArray(contract.steps) && contract.steps.length > 0
|
|
839
|
+
? contract.steps.map((step, index) => ({
|
|
840
|
+
id: pickValue(step?.id, `step-${index + 1}`),
|
|
841
|
+
name: pickValue(step?.name, `步骤 ${index + 1}`),
|
|
842
|
+
type: pickValue(step?.type, 'system_process'),
|
|
843
|
+
lane: pickValue(step?.lane, '流程'),
|
|
844
|
+
subtitle: pickValue(step?.subtitle, step?.description ?? '待补充'),
|
|
845
|
+
details: normalizeList(step?.details ?? step?.notes ?? step?.data_involved, ['待补充']),
|
|
846
|
+
}))
|
|
847
|
+
: base.steps,
|
|
848
|
+
transitions: Array.isArray(contract.transitions) && contract.transitions.length > 0
|
|
849
|
+
? contract.transitions.map((transition) => ({
|
|
850
|
+
from: pickValue(transition?.from, transition?.from_step_id),
|
|
851
|
+
to: pickValue(transition?.to, transition?.to_step_id),
|
|
852
|
+
label: pickValue(transition?.label, transition?.condition ?? '流转'),
|
|
853
|
+
type: pickValue(transition?.type, 'standard'),
|
|
854
|
+
}))
|
|
855
|
+
: base.transitions,
|
|
856
|
+
summaryCards: Array.isArray(contract.summaryCards) && contract.summaryCards.length > 0
|
|
857
|
+
? contract.summaryCards.map((card, index) => normalizeCard(card, `摘要 ${index + 1}`, 'system_process'))
|
|
858
|
+
: base.summaryCards,
|
|
859
|
+
sidePanels: Array.isArray(contract.sidePanels) && contract.sidePanels.length > 0
|
|
860
|
+
? contract.sidePanels.map((panel, index) => normalizePanel(panel, `面板 ${index + 1}`, 'decision'))
|
|
861
|
+
: [
|
|
862
|
+
normalizePanel({
|
|
863
|
+
title: contract.openQuestionsTitle ?? '开放问题',
|
|
864
|
+
color: 'error_path',
|
|
865
|
+
items: contract.openQuestions,
|
|
866
|
+
}),
|
|
867
|
+
normalizePanel({
|
|
868
|
+
title: contract.reviewInstructionsTitle ?? '评审说明',
|
|
869
|
+
color: 'decision',
|
|
870
|
+
items: contract.reviewInstructions,
|
|
871
|
+
}),
|
|
872
|
+
],
|
|
873
|
+
metadata: {
|
|
874
|
+
...base.metadata,
|
|
875
|
+
...(contract.metadata ?? {}),
|
|
876
|
+
projectName: pickValue(contract?.metadata?.projectName, pickValue(contract.title, base.metadata.projectName)),
|
|
877
|
+
versionId: pickValue(contract?.metadata?.versionId, base.metadata.versionId),
|
|
878
|
+
owner: pickValue(contract?.metadata?.owner, base.metadata.owner),
|
|
879
|
+
targetSystem: pickValue(contract?.metadata?.targetSystem, base.metadata.targetSystem),
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
return { type, model, html: renderProductFlowDiagramHtml(model) };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (type === 'architecture' && contract) {
|
|
887
|
+
const base = buildArchitectureDiagramModel(snapshot);
|
|
888
|
+
const model = {
|
|
889
|
+
...base,
|
|
890
|
+
...contract,
|
|
891
|
+
type: 'architecture',
|
|
892
|
+
locale: normalizeLocale(contract),
|
|
893
|
+
title: pickValue(contract.title, base.title),
|
|
894
|
+
subtitle: pickValue(contract.subtitle, base.subtitle),
|
|
895
|
+
components: Array.isArray(contract.components) && contract.components.length > 0
|
|
896
|
+
? contract.components.map((component, index) => ({
|
|
897
|
+
id: pickValue(component?.id, `component-${index + 1}`),
|
|
898
|
+
name: pickValue(component?.name, `组件 ${index + 1}`),
|
|
899
|
+
type: pickValue(component?.type, 'external'),
|
|
900
|
+
subtitle: pickValue(component?.subtitle, component?.description ?? '待补充'),
|
|
901
|
+
details: normalizeList(component?.details, ['待补充']),
|
|
902
|
+
}))
|
|
903
|
+
: base.components,
|
|
904
|
+
flows: Array.isArray(contract.flows) && contract.flows.length > 0
|
|
905
|
+
? contract.flows.map((flow) => ({
|
|
906
|
+
source: pickValue(flow?.source, 'source'),
|
|
907
|
+
target: pickValue(flow?.target, 'target'),
|
|
908
|
+
label: pickValue(flow?.label, '流程'),
|
|
909
|
+
type: pickValue(flow?.type, 'standard'),
|
|
910
|
+
}))
|
|
911
|
+
: base.flows,
|
|
912
|
+
summaryCards: Array.isArray(contract.summaryCards) && contract.summaryCards.length > 0
|
|
913
|
+
? contract.summaryCards.map((card, index) => normalizeCard(card, `摘要 ${index + 1}`, 'frontend'))
|
|
914
|
+
: base.summaryCards,
|
|
915
|
+
sidePanels: Array.isArray(contract.sidePanels) && contract.sidePanels.length > 0
|
|
916
|
+
? contract.sidePanels.map((panel, index) => normalizePanel(panel, `面板 ${index + 1}`, 'database'))
|
|
917
|
+
: [
|
|
918
|
+
normalizePanel({
|
|
919
|
+
title: contract.assumptionsTitle ?? '假设',
|
|
920
|
+
color: 'database',
|
|
921
|
+
items: contract.assumptions,
|
|
922
|
+
}),
|
|
923
|
+
normalizePanel({
|
|
924
|
+
title: contract.reviewInstructionsTitle ?? '评审说明',
|
|
925
|
+
color: 'cloud',
|
|
926
|
+
items: contract.reviewInstructions,
|
|
927
|
+
}),
|
|
928
|
+
],
|
|
929
|
+
metadata: {
|
|
930
|
+
...base.metadata,
|
|
931
|
+
...(contract.metadata ?? {}),
|
|
932
|
+
projectName: pickValue(contract?.metadata?.projectName, pickValue(contract.title, base.metadata.projectName)),
|
|
933
|
+
versionId: pickValue(contract?.metadata?.versionId, base.metadata.versionId),
|
|
934
|
+
owner: pickValue(contract?.metadata?.owner, base.metadata.owner),
|
|
935
|
+
targetSystem: pickValue(contract?.metadata?.targetSystem, base.metadata.targetSystem),
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
return { type, model, html: renderArchitectureDiagramHtml(model) };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (type === 'product-flow') {
|
|
943
|
+
const model = buildProductFlowDiagramModel(snapshot);
|
|
944
|
+
return {
|
|
945
|
+
type,
|
|
946
|
+
model,
|
|
947
|
+
html: renderProductFlowDiagramHtml(model),
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const model = buildArchitectureDiagramModel(snapshot);
|
|
952
|
+
return {
|
|
953
|
+
type: 'architecture',
|
|
954
|
+
model,
|
|
955
|
+
html: renderArchitectureDiagramHtml(model),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export function renderDiagramArtifactFromModel(type, model) {
|
|
960
|
+
if (type === 'product-flow') {
|
|
961
|
+
return renderProductFlowDiagramHtml(model);
|
|
962
|
+
}
|
|
963
|
+
return renderArchitectureDiagramHtml(model);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function validateDiagramContract(contract, schema) {
|
|
967
|
+
const errors = [];
|
|
968
|
+
if (!schema || typeof schema !== 'object') {
|
|
969
|
+
return { valid: false, errors: ['Missing diagram schema'] };
|
|
970
|
+
}
|
|
971
|
+
if (!contract || typeof contract !== 'object' || Array.isArray(contract)) {
|
|
972
|
+
return { valid: false, errors: ['Diagram contract must be a JSON object'] };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
const requiredFields = Array.isArray(schema.requiredFields) ? schema.requiredFields : [];
|
|
976
|
+
for (const field of requiredFields) {
|
|
977
|
+
if (!hasValue(getAtPath(contract, field))) {
|
|
978
|
+
errors.push(`Missing required field: ${field}`);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const requiredArrays = schema.requiredArrays ?? {};
|
|
983
|
+
for (const [field, minItems] of Object.entries(requiredArrays)) {
|
|
984
|
+
const value = getAtPath(contract, field);
|
|
985
|
+
if (!Array.isArray(value)) {
|
|
986
|
+
errors.push(`Field must be an array: ${field}`);
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
if (value.length < Number(minItems)) {
|
|
990
|
+
errors.push(`Field requires at least ${minItems} item(s): ${field}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const itemRequiredFields = schema.itemRequiredFields ?? {};
|
|
995
|
+
for (const [field, nestedFields] of Object.entries(itemRequiredFields)) {
|
|
996
|
+
const list = getAtPath(contract, field);
|
|
997
|
+
if (!Array.isArray(list)) continue;
|
|
998
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
999
|
+
const item = list[index];
|
|
1000
|
+
for (const nestedField of nestedFields) {
|
|
1001
|
+
const aliases = nestedField.split('|');
|
|
1002
|
+
const ok = aliases.some((alias) => hasValue(item?.[alias]));
|
|
1003
|
+
if (!ok) {
|
|
1004
|
+
errors.push(`Missing required field in ${field}[${index}]: ${nestedField}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const allowedValues = schema.allowedValues ?? {};
|
|
1011
|
+
for (const [field, allowed] of Object.entries(allowedValues)) {
|
|
1012
|
+
const value = getAtPath(contract, field);
|
|
1013
|
+
if (value === undefined) continue;
|
|
1014
|
+
if (Array.isArray(value)) {
|
|
1015
|
+
for (const item of value) {
|
|
1016
|
+
if (!allowed.includes(item)) {
|
|
1017
|
+
errors.push(`Unsupported value in ${field}: ${item}`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
if (!allowed.includes(value)) {
|
|
1023
|
+
errors.push(`Unsupported value for ${field}: ${value}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const itemAllowedValues = schema.itemAllowedValues ?? {};
|
|
1028
|
+
for (const [field, mapping] of Object.entries(itemAllowedValues)) {
|
|
1029
|
+
const list = getAtPath(contract, field);
|
|
1030
|
+
if (!Array.isArray(list)) continue;
|
|
1031
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
1032
|
+
const item = list[index];
|
|
1033
|
+
for (const [nestedField, allowed] of Object.entries(mapping)) {
|
|
1034
|
+
const value = item?.[nestedField];
|
|
1035
|
+
if (value === undefined || value === null || value === '') continue;
|
|
1036
|
+
if (!allowed.includes(value)) {
|
|
1037
|
+
errors.push(`Unsupported value for ${field}[${index}].${nestedField}: ${value}`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
valid: errors.length === 0,
|
|
1045
|
+
errors,
|
|
1046
|
+
};
|
|
1047
|
+
}
|