@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
package/src/quality.js
ADDED
|
@@ -0,0 +1,1262 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { cjoin, exists, readJson, readText, writeJson, writeText } from './fs-utils.js';
|
|
4
|
+
import { renderQualityEvalArtifact } from './html-artifacts.js';
|
|
5
|
+
import { renderExperienceSkill, resolveQualityLearningSource } from './quality-learning.js';
|
|
6
|
+
import {
|
|
7
|
+
deriveKnowledgeNames,
|
|
8
|
+
ensureKnowledgeWorkspace,
|
|
9
|
+
KNOWLEDGE_CANDIDATES_DIR,
|
|
10
|
+
KNOWLEDGE_DRAFTS_DIR,
|
|
11
|
+
markKnowledgeCandidatePromoted,
|
|
12
|
+
OPENPRD_HARNESS_TURN_STATE,
|
|
13
|
+
recordKnowledgeReviewSignal,
|
|
14
|
+
reviewKnowledgeWorkspace,
|
|
15
|
+
} from './knowledge.js';
|
|
16
|
+
import { timestamp } from './time.js';
|
|
17
|
+
|
|
18
|
+
const QUALITY_DIR = cjoin('.openprd', 'quality');
|
|
19
|
+
const QUALITY_REPORTS_DIR = cjoin(QUALITY_DIR, 'reports');
|
|
20
|
+
const QUALITY_CONFIG = cjoin(QUALITY_DIR, 'config.json');
|
|
21
|
+
const QUALITY_INDEX = cjoin(QUALITY_REPORTS_DIR, 'index.json');
|
|
22
|
+
const QUALITY_LATEST = cjoin(QUALITY_REPORTS_DIR, 'latest.json');
|
|
23
|
+
const KNOWLEDGE_DIR = cjoin('.openprd', 'knowledge');
|
|
24
|
+
const KNOWLEDGE_INDEX = cjoin(KNOWLEDGE_DIR, 'index.json');
|
|
25
|
+
|
|
26
|
+
const IGNORE_DIRS = new Set([
|
|
27
|
+
'.git',
|
|
28
|
+
'.codex',
|
|
29
|
+
'.claude',
|
|
30
|
+
'.cursor',
|
|
31
|
+
'.openprd',
|
|
32
|
+
'.tmp',
|
|
33
|
+
'.wrangler',
|
|
34
|
+
'node_modules',
|
|
35
|
+
'dist',
|
|
36
|
+
'build',
|
|
37
|
+
'coverage',
|
|
38
|
+
'.next',
|
|
39
|
+
'.turbo',
|
|
40
|
+
'out',
|
|
41
|
+
'release',
|
|
42
|
+
'test-results',
|
|
43
|
+
'tmp',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
47
|
+
'.js',
|
|
48
|
+
'.mjs',
|
|
49
|
+
'.cjs',
|
|
50
|
+
'.ts',
|
|
51
|
+
'.tsx',
|
|
52
|
+
'.jsx',
|
|
53
|
+
'.py',
|
|
54
|
+
'.go',
|
|
55
|
+
'.rs',
|
|
56
|
+
'.java',
|
|
57
|
+
'.kt',
|
|
58
|
+
'.swift',
|
|
59
|
+
'.css',
|
|
60
|
+
'.html',
|
|
61
|
+
'.md',
|
|
62
|
+
'.json',
|
|
63
|
+
'.yaml',
|
|
64
|
+
'.yml',
|
|
65
|
+
'.toml',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const IGNORE_FILES = new Set([
|
|
69
|
+
'AGENTS.md',
|
|
70
|
+
'CLAUDE.md',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
const QUALITY_GATE_IDS = [
|
|
74
|
+
'traceability',
|
|
75
|
+
'redaction',
|
|
76
|
+
'business-guardrails',
|
|
77
|
+
'smoke',
|
|
78
|
+
'feature-coverage',
|
|
79
|
+
'normal-performance',
|
|
80
|
+
'extreme-performance',
|
|
81
|
+
'knowledge',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const EVIDENCE_EXTENSIONS = new Set(['.json', '.md', '.txt', '.log', '.xml', '.html', '.csv']);
|
|
85
|
+
|
|
86
|
+
const EVIDENCE_TOKENS = {
|
|
87
|
+
traceability: ['trace_id', 'span_id', 'request_id', 'task_id', 'error_id', 'trace verified', '链路', '追踪'],
|
|
88
|
+
redaction: ['redaction', 'redact', 'mask', 'masked', 'pii', 'secret', 'token redacted', '脱敏', '敏感字段'],
|
|
89
|
+
'business-guardrails': ['quota', 'rate limit', 'abuse', 'budget', 'kill switch', 'cost_usd', '额度', '限流', '滥用', '止损'],
|
|
90
|
+
smoke: ['smoke', 'e2e', 'playwright', 'cypress', 'main flow', 'happy path', '冒烟', '主流程'],
|
|
91
|
+
'feature-coverage': ['feature coverage', 'acceptance', 'tasks done', 'openprd tasks', '验收', '功能覆盖', '任务完成'],
|
|
92
|
+
'normal-performance': ['performance', 'perf', 'benchmark', 'latency', 'p95', 'lighthouse', 'k6', '性能', '耗时'],
|
|
93
|
+
'extreme-performance': ['extreme', 'stress', 'load test', 'large-data', 'pressure', 'k6', '压力', '极端', '大数据'],
|
|
94
|
+
knowledge: ['quality learn', 'incident', 'pattern', 'skill', '复盘', '经验', '沉淀'],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function qualityPath(projectRoot, relativePath) {
|
|
98
|
+
return cjoin(projectRoot, relativePath);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function reportId() {
|
|
102
|
+
return `eval-${timestamp().replace(/[-: ]/g, '').slice(0, 15)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function defaultQualityConfig() {
|
|
106
|
+
return {
|
|
107
|
+
version: 1,
|
|
108
|
+
schema: 'openprd.quality.v1',
|
|
109
|
+
updatedAt: timestamp(),
|
|
110
|
+
enforcement: 'blocking',
|
|
111
|
+
observability: {
|
|
112
|
+
centralizedLoggingRequired: true,
|
|
113
|
+
requiredSignals: ['logs', 'traces', 'metrics', 'errors'],
|
|
114
|
+
requiredCorrelationFields: ['trace_id', 'span_id', 'request_id', 'task_id', 'user_session_id', 'error_id'],
|
|
115
|
+
requiredSurfaces: ['frontend', 'backend', 'agent'],
|
|
116
|
+
redactionRequired: true,
|
|
117
|
+
retentionDays: 30,
|
|
118
|
+
samplingPolicy: 'errors and high-risk flows are never sampled out; routine success logs may be sampled',
|
|
119
|
+
queryExamplesRequired: true,
|
|
120
|
+
},
|
|
121
|
+
evalHarness: {
|
|
122
|
+
smokeRequired: true,
|
|
123
|
+
featureCoverageRequired: true,
|
|
124
|
+
normalPerformanceRequired: true,
|
|
125
|
+
extremePerformanceRequired: true,
|
|
126
|
+
currentEvidenceRequired: true,
|
|
127
|
+
evidenceSources: [
|
|
128
|
+
'.openprd/harness/test-reports',
|
|
129
|
+
'.openprd/quality/evidence',
|
|
130
|
+
'test-results',
|
|
131
|
+
'tests/reports',
|
|
132
|
+
'coverage',
|
|
133
|
+
],
|
|
134
|
+
scenarioProfiles: {
|
|
135
|
+
core: ['smoke', 'feature-coverage'],
|
|
136
|
+
frontend: ['smoke'],
|
|
137
|
+
desktop: ['smoke'],
|
|
138
|
+
backend: ['smoke', 'traceability'],
|
|
139
|
+
businessCost: ['business-guardrails'],
|
|
140
|
+
security: ['redaction'],
|
|
141
|
+
performance: ['normal-performance'],
|
|
142
|
+
extreme: ['extreme-performance'],
|
|
143
|
+
release: ['traceability', 'redaction', 'business-guardrails', 'smoke', 'feature-coverage', 'normal-performance', 'extreme-performance', 'knowledge'],
|
|
144
|
+
},
|
|
145
|
+
reportFormat: 'html',
|
|
146
|
+
baselinePolicy: 'agent-initialized average baseline; user may override; agent ratchet may only tighten thresholds',
|
|
147
|
+
projectBaseline: {
|
|
148
|
+
normal: {
|
|
149
|
+
cpuPercentP95Max: 70,
|
|
150
|
+
memoryMBP95Max: 512,
|
|
151
|
+
pageLoadMsP95Max: 2500,
|
|
152
|
+
apiLatencyMsP95Max: 500,
|
|
153
|
+
errorRatePercentMax: 0.1,
|
|
154
|
+
},
|
|
155
|
+
extreme: {
|
|
156
|
+
cpuPercentP95Max: 85,
|
|
157
|
+
memoryMBP95Max: 1024,
|
|
158
|
+
pageLoadMsP95Max: 5000,
|
|
159
|
+
apiLatencyMsP95Max: 1200,
|
|
160
|
+
errorRatePercentMax: 1,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
businessGuardrails: {
|
|
165
|
+
enabled: true,
|
|
166
|
+
riskIndicators: [
|
|
167
|
+
'free user',
|
|
168
|
+
'free tier',
|
|
169
|
+
'trial',
|
|
170
|
+
'quota',
|
|
171
|
+
'usage limit',
|
|
172
|
+
'rate limit',
|
|
173
|
+
'credit',
|
|
174
|
+
'token',
|
|
175
|
+
'metered',
|
|
176
|
+
'cost',
|
|
177
|
+
'spend',
|
|
178
|
+
'third-party cost',
|
|
179
|
+
'third-party api',
|
|
180
|
+
'ai generation',
|
|
181
|
+
'model call',
|
|
182
|
+
'openai',
|
|
183
|
+
'anthropic',
|
|
184
|
+
'免费用户',
|
|
185
|
+
'免费额度',
|
|
186
|
+
'试用',
|
|
187
|
+
'额度',
|
|
188
|
+
'用量',
|
|
189
|
+
'限流',
|
|
190
|
+
'积分',
|
|
191
|
+
'点数',
|
|
192
|
+
'令牌',
|
|
193
|
+
'成本',
|
|
194
|
+
'消耗',
|
|
195
|
+
'第三方成本',
|
|
196
|
+
'第三方调用',
|
|
197
|
+
'第三方 API',
|
|
198
|
+
'大模型',
|
|
199
|
+
'模型调用',
|
|
200
|
+
'AI 生成',
|
|
201
|
+
'图像生成',
|
|
202
|
+
'内容生成',
|
|
203
|
+
'薅',
|
|
204
|
+
'滥用',
|
|
205
|
+
],
|
|
206
|
+
requiredEvidence: {
|
|
207
|
+
usageLimits: ['quota', 'usage limit', 'rate limit', 'daily limit', 'monthly limit', 'allowance', 'free tier', '额度', '用量限制', '限流', '每日上限', '月度上限', '免费额度'],
|
|
208
|
+
abusePrevention: ['abuse', 'bypass', 'fraud', 'replay', 'concurrent', 'unauthorized', 'negative test', '越权', '滥用', '绕过', '并发', '重复请求', '负向测试'],
|
|
209
|
+
monitoringSignals: ['usage metric', 'cost metric', 'billing metric', 'tokens_used', 'cost_usd', 'spend', 'dashboard', 'monitor', '监控', '指标', '用量', '成本', '看板'],
|
|
210
|
+
alertThresholds: ['alert', 'alarm', 'threshold', 'budget', 'anomaly', '报警', '告警', '阈值', '预算', '异常增长'],
|
|
211
|
+
stopLossActions: ['kill switch', 'feature flag', 'circuit breaker', 'disable', 'degrade', 'pause', 'shutdown', '止损', '关闭', '降级', '暂停', '熔断'],
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
knowledge: {
|
|
215
|
+
enabled: true,
|
|
216
|
+
skillGenerationRequiredFor: ['repeated-issue', 'high-impact-fix', 'hidden-debug-knowledge', 'agent-misjudgment'],
|
|
217
|
+
skillDir: '.openprd/knowledge/skills',
|
|
218
|
+
candidateDir: '.openprd/knowledge/candidates',
|
|
219
|
+
draftDir: '.openprd/knowledge/drafts',
|
|
220
|
+
abstractionRequired: true,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function normalizeQualityConfig(config = {}) {
|
|
226
|
+
const defaults = defaultQualityConfig();
|
|
227
|
+
return {
|
|
228
|
+
...defaults,
|
|
229
|
+
...config,
|
|
230
|
+
observability: {
|
|
231
|
+
...defaults.observability,
|
|
232
|
+
...(config.observability ?? {}),
|
|
233
|
+
},
|
|
234
|
+
evalHarness: {
|
|
235
|
+
...defaults.evalHarness,
|
|
236
|
+
...(config.evalHarness ?? {}),
|
|
237
|
+
scenarioProfiles: {
|
|
238
|
+
...defaults.evalHarness.scenarioProfiles,
|
|
239
|
+
...(config.evalHarness?.scenarioProfiles ?? {}),
|
|
240
|
+
},
|
|
241
|
+
projectBaseline: {
|
|
242
|
+
...defaults.evalHarness.projectBaseline,
|
|
243
|
+
...(config.evalHarness?.projectBaseline ?? {}),
|
|
244
|
+
normal: {
|
|
245
|
+
...defaults.evalHarness.projectBaseline.normal,
|
|
246
|
+
...(config.evalHarness?.projectBaseline?.normal ?? {}),
|
|
247
|
+
},
|
|
248
|
+
extreme: {
|
|
249
|
+
...defaults.evalHarness.projectBaseline.extreme,
|
|
250
|
+
...(config.evalHarness?.projectBaseline?.extreme ?? {}),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
businessGuardrails: {
|
|
255
|
+
...defaults.businessGuardrails,
|
|
256
|
+
...(config.businessGuardrails ?? {}),
|
|
257
|
+
requiredEvidence: {
|
|
258
|
+
...defaults.businessGuardrails.requiredEvidence,
|
|
259
|
+
...(config.businessGuardrails?.requiredEvidence ?? {}),
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
knowledge: {
|
|
263
|
+
...defaults.knowledge,
|
|
264
|
+
...(config.knowledge ?? {}),
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function ensureQualityDirs(projectRoot) {
|
|
270
|
+
await fs.mkdir(qualityPath(projectRoot, QUALITY_REPORTS_DIR), { recursive: true });
|
|
271
|
+
await ensureKnowledgeWorkspace(projectRoot);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function mergeQualityConfig(projectRoot, options = {}) {
|
|
275
|
+
await ensureQualityDirs(projectRoot);
|
|
276
|
+
const configPath = qualityPath(projectRoot, QUALITY_CONFIG);
|
|
277
|
+
const current = await readJson(configPath).catch(() => null);
|
|
278
|
+
const next = normalizeQualityConfig({ ...(current ?? {}), updatedAt: timestamp() });
|
|
279
|
+
if (options.force || current === null) {
|
|
280
|
+
await writeJson(configPath, next);
|
|
281
|
+
return { config: next, changed: current === null ? 'created' : 'updated' };
|
|
282
|
+
}
|
|
283
|
+
return { config: next, changed: 'unchanged' };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function walkProject(projectRoot, dir = projectRoot, collected = []) {
|
|
287
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
const fullPath = cjoin(dir, entry.name);
|
|
290
|
+
const relativePath = path.relative(projectRoot, fullPath);
|
|
291
|
+
if (entry.isDirectory()) {
|
|
292
|
+
const normalized = relativePath.split(path.sep).join('/');
|
|
293
|
+
if ([...IGNORE_DIRS].some((ignored) => normalized === ignored || normalized.startsWith(`${ignored}/`))) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
await walkProject(projectRoot, fullPath, collected);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (!entry.isFile()) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (IGNORE_FILES.has(entry.name)) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const ext = path.extname(entry.name);
|
|
306
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
307
|
+
collected.push({ path: relativePath, ext });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return collected;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function readProjectTexts(projectRoot, files) {
|
|
314
|
+
const texts = [];
|
|
315
|
+
for (const file of files.slice(0, 500)) {
|
|
316
|
+
const fullPath = cjoin(projectRoot, file.path);
|
|
317
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
318
|
+
if (!stat || stat.size > 512 * 1024) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const text = await readText(fullPath).catch(() => '');
|
|
322
|
+
texts.push({ ...file, text: text.slice(0, 200000) });
|
|
323
|
+
}
|
|
324
|
+
return texts;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function collectEvidenceFile(projectRoot, source, fullPath, collected) {
|
|
328
|
+
if (collected.length >= 120) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
332
|
+
if (!stat || !stat.isFile() || stat.size > 512 * 1024) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const ext = path.extname(fullPath);
|
|
336
|
+
if (!EVIDENCE_EXTENSIONS.has(ext)) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const text = await readText(fullPath).catch(() => '');
|
|
340
|
+
collected.push({
|
|
341
|
+
path: path.relative(projectRoot, fullPath),
|
|
342
|
+
source,
|
|
343
|
+
size: stat.size,
|
|
344
|
+
text: text.slice(0, 120000),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function walkEvidenceSource(projectRoot, source, dir, collected) {
|
|
349
|
+
if (collected.length >= 120) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
const fullPath = cjoin(dir, entry.name);
|
|
355
|
+
if (entry.isDirectory()) {
|
|
356
|
+
if (['.git', 'node_modules', '.next', 'dist', 'build'].includes(entry.name)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
await walkEvidenceSource(projectRoot, source, fullPath, collected);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
await collectEvidenceFile(projectRoot, source, fullPath, collected);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function readEvidenceFiles(projectRoot, config) {
|
|
367
|
+
const sources = Array.isArray(config.evalHarness.evidenceSources)
|
|
368
|
+
? config.evalHarness.evidenceSources
|
|
369
|
+
: defaultQualityConfig().evalHarness.evidenceSources;
|
|
370
|
+
const collected = [];
|
|
371
|
+
for (const source of sources) {
|
|
372
|
+
const fullPath = cjoin(projectRoot, source);
|
|
373
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
374
|
+
if (!stat) {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (stat.isDirectory()) {
|
|
378
|
+
await walkEvidenceSource(projectRoot, source, fullPath, collected);
|
|
379
|
+
} else {
|
|
380
|
+
await collectEvidenceFile(projectRoot, source, fullPath, collected);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return collected;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function packageSignals(packageJson) {
|
|
387
|
+
const scripts = packageJson?.scripts ?? {};
|
|
388
|
+
const dependencies = {
|
|
389
|
+
...(packageJson?.dependencies ?? {}),
|
|
390
|
+
...(packageJson?.devDependencies ?? {}),
|
|
391
|
+
...(packageJson?.peerDependencies ?? {}),
|
|
392
|
+
};
|
|
393
|
+
const dependencyNames = Object.keys(dependencies);
|
|
394
|
+
return { scripts, dependencyNames };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function includesAny(text, tokens) {
|
|
398
|
+
const normalized = String(text ?? '').toLowerCase();
|
|
399
|
+
return tokens.some((token) => normalized.includes(token.toLowerCase()));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function readActiveChangeContext(projectRoot, activeChange) {
|
|
403
|
+
if (!activeChange) {
|
|
404
|
+
return { activeChange: null, files: [], text: '' };
|
|
405
|
+
}
|
|
406
|
+
const roots = [
|
|
407
|
+
cjoin('openprd', 'changes', activeChange),
|
|
408
|
+
cjoin('openspec', 'changes', activeChange),
|
|
409
|
+
];
|
|
410
|
+
const files = [];
|
|
411
|
+
async function walk(root, dir) {
|
|
412
|
+
if (files.length >= 80) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
416
|
+
for (const entry of entries) {
|
|
417
|
+
const fullPath = cjoin(dir, entry.name);
|
|
418
|
+
if (entry.isDirectory()) {
|
|
419
|
+
await walk(root, fullPath);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (!entry.isFile()) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
const ext = path.extname(entry.name);
|
|
426
|
+
if (!['.md', '.json', '.yaml', '.yml', '.txt'].includes(ext)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const stat = await fs.stat(fullPath).catch(() => null);
|
|
430
|
+
if (!stat || stat.size > 512 * 1024) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const text = await readText(fullPath).catch(() => '');
|
|
434
|
+
files.push({
|
|
435
|
+
path: path.relative(projectRoot, fullPath),
|
|
436
|
+
text: text.slice(0, 120000),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const root of roots) {
|
|
441
|
+
await walk(root, cjoin(projectRoot, root));
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
activeChange,
|
|
445
|
+
files,
|
|
446
|
+
text: files.map((file) => `# ${file.path}\n${file.text}`).join('\n'),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function detectScenarioTags({ activeChangeContext, activeTasks, businessGuardrails }) {
|
|
451
|
+
const haystack = [
|
|
452
|
+
activeChangeContext.text,
|
|
453
|
+
activeChangeContext.files.map((file) => file.path).join('\n'),
|
|
454
|
+
activeTasks.tasks.map((task) => task.title).join('\n'),
|
|
455
|
+
].join('\n');
|
|
456
|
+
const tags = new Set(['core']);
|
|
457
|
+
if (includesAny(haystack, ['frontend', 'user interface', 'ui screen', 'ui flow', 'screen', 'component', 'modal', 'button', 'form field', 'stylesheet', '.css', '.tsx', '.jsx', '文案', '界面', '前端', '交互', '页面', '组件', '国际化', 'i18n'])) {
|
|
458
|
+
tags.add('frontend');
|
|
459
|
+
}
|
|
460
|
+
if (includesAny(haystack, ['electron', 'desktop', 'preload', 'main process', 'renderer', 'macos', 'windows', '桌面端', '客户端'])) {
|
|
461
|
+
tags.add('desktop');
|
|
462
|
+
}
|
|
463
|
+
if (includesAny(haystack, ['backend service', 'api endpoint', 'api route', 'http api', 'server route', 'request handler', 'worker', 'database', 'migration', 'queue', '后端接口', '后端服务', '服务端', '数据库', '队列'])) {
|
|
464
|
+
tags.add('backend');
|
|
465
|
+
}
|
|
466
|
+
if (businessGuardrails.riskDetected || includesAny(haystack, ['free tier', 'quota', 'rate limit', 'billing', 'cost', 'token', 'third-party api', 'ai generation', '免费', '额度', '限流', '成本', '用量', '第三方', '模型调用'])) {
|
|
467
|
+
tags.add('businessCost');
|
|
468
|
+
}
|
|
469
|
+
if (includesAny(haystack, ['auth', 'permission', 'privacy', 'secret', 'token', 'credential', 'redaction', 'pii', '安全', '权限', '隐私', '凭证', '脱敏', '敏感'])) {
|
|
470
|
+
tags.add('security');
|
|
471
|
+
}
|
|
472
|
+
if (includesAny(haystack, ['performance', 'latency', 'p95', 'benchmark', 'lighthouse', 'load time', '性能', '耗时', '延迟', '基线'])) {
|
|
473
|
+
tags.add('performance');
|
|
474
|
+
}
|
|
475
|
+
if (includesAny(haystack, ['extreme', 'stress', 'load test', 'large-data', 'batch', 'concurrency', 'pressure', '极端', '压力', '大数据', '批量', '并发'])) {
|
|
476
|
+
tags.add('extreme');
|
|
477
|
+
}
|
|
478
|
+
if (includesAny(haystack, ['release', 'publish', 'deploy', 'production rollout', 'production release', 'go-live', '上线', '发布', '部署', '投产'])) {
|
|
479
|
+
tags.add('release');
|
|
480
|
+
}
|
|
481
|
+
return [...tags];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function buildQualityPolicy({ config, activeChangeContext, activeTasks, businessGuardrails }) {
|
|
485
|
+
const scenarioTags = detectScenarioTags({ activeChangeContext, activeTasks, businessGuardrails });
|
|
486
|
+
const profiles = config.evalHarness.scenarioProfiles ?? defaultQualityConfig().evalHarness.scenarioProfiles;
|
|
487
|
+
const required = new Set();
|
|
488
|
+
for (const tag of scenarioTags) {
|
|
489
|
+
for (const gate of profiles[tag] ?? []) {
|
|
490
|
+
required.add(gate);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (businessGuardrails.riskDetected) {
|
|
494
|
+
required.add('business-guardrails');
|
|
495
|
+
}
|
|
496
|
+
if (!config.evalHarness.smokeRequired) {
|
|
497
|
+
required.delete('smoke');
|
|
498
|
+
}
|
|
499
|
+
if (!config.evalHarness.featureCoverageRequired) {
|
|
500
|
+
required.delete('feature-coverage');
|
|
501
|
+
}
|
|
502
|
+
if (!config.evalHarness.normalPerformanceRequired) {
|
|
503
|
+
required.delete('normal-performance');
|
|
504
|
+
}
|
|
505
|
+
if (!config.evalHarness.extremePerformanceRequired) {
|
|
506
|
+
required.delete('extreme-performance');
|
|
507
|
+
}
|
|
508
|
+
if (!config.knowledge.enabled) {
|
|
509
|
+
required.delete('knowledge');
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
scenarioTags,
|
|
513
|
+
requiredGates: QUALITY_GATE_IDS.filter((gate) => required.has(gate)),
|
|
514
|
+
optionalGates: QUALITY_GATE_IDS.filter((gate) => !required.has(gate)),
|
|
515
|
+
evidenceRequired: config.evalHarness.currentEvidenceRequired !== false,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function buildEvidenceLedger({ evidenceFiles, activeTasks, observability, businessGuardrails, knowledge }) {
|
|
520
|
+
const ledger = Object.fromEntries(QUALITY_GATE_IDS.map((gate) => {
|
|
521
|
+
const tokens = EVIDENCE_TOKENS[gate] ?? [];
|
|
522
|
+
const matches = evidenceFiles
|
|
523
|
+
.filter((file) => includesAny(`${file.path}\n${file.text}`, tokens))
|
|
524
|
+
.slice(0, 12)
|
|
525
|
+
.map((file) => ({ path: file.path, source: file.source }));
|
|
526
|
+
return [gate, {
|
|
527
|
+
present: matches.length > 0,
|
|
528
|
+
sources: matches,
|
|
529
|
+
summary: matches.length > 0 ? `${matches.length} 个证据文件` : '未找到本次执行证据',
|
|
530
|
+
}];
|
|
531
|
+
}));
|
|
532
|
+
|
|
533
|
+
if (activeTasks.total === 0 || activeTasks.pending === 0) {
|
|
534
|
+
ledger['feature-coverage'] = {
|
|
535
|
+
...ledger['feature-coverage'],
|
|
536
|
+
present: true,
|
|
537
|
+
sources: [
|
|
538
|
+
...ledger['feature-coverage'].sources,
|
|
539
|
+
{ path: activeTasks.activeChange ? `${activeTasks.activeChange}/tasks.md` : 'no-active-change', source: 'openprd-tasks' },
|
|
540
|
+
].slice(0, 12),
|
|
541
|
+
summary: activeTasks.total === 0 ? '当前没有激活任务清单' : `任务清单已完成 ${activeTasks.done}/${activeTasks.total}`,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
if (observability.status === 'pass') {
|
|
545
|
+
const observabilitySignals = [
|
|
546
|
+
...observability.centralizedTools,
|
|
547
|
+
...(observability.diagnosticSurfaces ?? []),
|
|
548
|
+
];
|
|
549
|
+
ledger.traceability = {
|
|
550
|
+
...ledger.traceability,
|
|
551
|
+
present: true,
|
|
552
|
+
sources: [
|
|
553
|
+
...ledger.traceability.sources,
|
|
554
|
+
{ path: 'project-observability-signals', source: observabilitySignals.join(', ') || 'observability' },
|
|
555
|
+
].slice(0, 12),
|
|
556
|
+
summary: `检测到 ${observability.correlationFields.length} 个链路关联字段${observability.diagnosticSurfaces?.length ? `;诊断面: ${observability.diagnosticSurfaces.join(', ')}` : ''}`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (!businessGuardrails.riskDetected || businessGuardrails.status === 'pass') {
|
|
560
|
+
ledger['business-guardrails'] = {
|
|
561
|
+
...ledger['business-guardrails'],
|
|
562
|
+
present: true,
|
|
563
|
+
sources: [
|
|
564
|
+
...ledger['business-guardrails'].sources,
|
|
565
|
+
{ path: businessGuardrails.riskDetected ? 'business-guardrails-evidence' : 'no-cost-risk-detected', source: 'project-scan' },
|
|
566
|
+
].slice(0, 12),
|
|
567
|
+
summary: businessGuardrails.riskDetected ? '成本与滥用护栏证据完整' : '当前场景未检测到成本风险',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (knowledge.skills.length > 0) {
|
|
571
|
+
ledger.knowledge = {
|
|
572
|
+
...ledger.knowledge,
|
|
573
|
+
present: true,
|
|
574
|
+
sources: [
|
|
575
|
+
...ledger.knowledge.sources,
|
|
576
|
+
...knowledge.skills.slice(0, 6).map((skill) => ({ path: skill, source: 'openprd-knowledge' })),
|
|
577
|
+
...knowledge.candidates.slice(0, 3).map((candidate) => ({ path: candidate, source: 'openprd-knowledge-candidate' })),
|
|
578
|
+
].slice(0, 12),
|
|
579
|
+
summary: knowledge.candidates.length > 0
|
|
580
|
+
? `已有 ${knowledge.skills.length} 个项目经验 Skill,另有 ${knowledge.candidates.length} 个待确认 candidate`
|
|
581
|
+
: `已有 ${knowledge.skills.length} 个项目经验 Skill`,
|
|
582
|
+
};
|
|
583
|
+
} else if (knowledge.candidates.length > 0) {
|
|
584
|
+
ledger.knowledge = {
|
|
585
|
+
...ledger.knowledge,
|
|
586
|
+
present: true,
|
|
587
|
+
sources: [
|
|
588
|
+
...ledger.knowledge.sources,
|
|
589
|
+
...knowledge.candidates.slice(0, 6).map((candidate) => ({ path: candidate, source: 'openprd-knowledge-candidate' })),
|
|
590
|
+
].slice(0, 12),
|
|
591
|
+
summary: `已有 ${knowledge.candidates.length} 个待确认 knowledge candidate`,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return ledger;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function detectObservability({ config, files, texts, packageJson }) {
|
|
598
|
+
const { dependencyNames } = packageSignals(packageJson);
|
|
599
|
+
const haystack = [
|
|
600
|
+
...dependencyNames,
|
|
601
|
+
...files.map((file) => file.path),
|
|
602
|
+
...texts.map((file) => file.text),
|
|
603
|
+
].join('\n');
|
|
604
|
+
const centralizedTools = [
|
|
605
|
+
'@opentelemetry/',
|
|
606
|
+
'opentelemetry',
|
|
607
|
+
'sentry',
|
|
608
|
+
'datadog',
|
|
609
|
+
'newrelic',
|
|
610
|
+
'rollbar',
|
|
611
|
+
'logtail',
|
|
612
|
+
'grafana',
|
|
613
|
+
'loki',
|
|
614
|
+
'elastic-apm',
|
|
615
|
+
'cloudwatch',
|
|
616
|
+
'azure monitor',
|
|
617
|
+
].filter((token) => includesAny(haystack, [token]));
|
|
618
|
+
const diagnosticSurfaces = [
|
|
619
|
+
{ id: 'runtime-events', tokens: ['runtime-events', 'appendruntimeevent', 'events.jsonl', 'runtime event'] },
|
|
620
|
+
{ id: 'timeline', tokens: ['timeline', 'event timeline', 'diagnostic timeline'] },
|
|
621
|
+
{ id: 'root-cause-candidates', tokens: ['root-cause-candidates', 'root cause candidate', 'root cause'] },
|
|
622
|
+
{ id: 'diagnostic-report', tokens: ['diagnostic-report', 'framework-runtime-diagnostics', 'exportdiagnostics', 'export diagnostics'] },
|
|
623
|
+
]
|
|
624
|
+
.filter((surface) => includesAny(haystack, surface.tokens))
|
|
625
|
+
.map((surface) => surface.id);
|
|
626
|
+
const localLoggers = ['pino', 'winston', 'bunyan', 'log4js', 'console.'].filter((token) => includesAny(haystack, [token]));
|
|
627
|
+
const correlationFields = config.observability.requiredCorrelationFields
|
|
628
|
+
.filter((field) => includesAny(haystack, [field, field.replace(/_/g, ''), field.replace(/_id$/, 'Id')]));
|
|
629
|
+
const surfaces = {
|
|
630
|
+
frontend: files.some((file) => /\.(tsx|jsx|css|html)$/.test(file.path) || /src\/(app|pages|components|ui)\//.test(file.path)),
|
|
631
|
+
backend: files.some((file) => /\.(js|ts|py|go|rs|java|kt)$/.test(file.path) && /(server|api|route|controller|service|worker|handler)/i.test(file.path)),
|
|
632
|
+
agent: files.some((file) => /(agent|harness|tool|skill|prompt|workflow)/i.test(file.path)),
|
|
633
|
+
};
|
|
634
|
+
const warnings = [];
|
|
635
|
+
if (centralizedTools.length === 0 && diagnosticSurfaces.length === 0) {
|
|
636
|
+
warnings.push('未检测到中心化日志/追踪/错误系统依赖或配置;需要确认是否由平台层统一提供。');
|
|
637
|
+
}
|
|
638
|
+
if (localLoggers.length > 0 && centralizedTools.length === 0 && diagnosticSurfaces.length === 0) {
|
|
639
|
+
warnings.push('检测到本地日志调用,但未看到中心化采集出口。');
|
|
640
|
+
}
|
|
641
|
+
const missingCorrelation = config.observability.requiredCorrelationFields.filter((field) => !correlationFields.includes(field));
|
|
642
|
+
if (missingCorrelation.length > 0) {
|
|
643
|
+
warnings.push(`链路关联字段缺失或未显式出现: ${missingCorrelation.join(', ')}。`);
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
status: (centralizedTools.length > 0 || diagnosticSurfaces.length > 0) && missingCorrelation.length === 0 ? 'pass' : 'needs-attention',
|
|
647
|
+
centralizedTools,
|
|
648
|
+
diagnosticSurfaces,
|
|
649
|
+
localLoggers,
|
|
650
|
+
correlationFields,
|
|
651
|
+
missingCorrelation,
|
|
652
|
+
surfaces,
|
|
653
|
+
requiredSurfaces: config.observability.requiredSurfaces,
|
|
654
|
+
redactionRequired: config.observability.redactionRequired,
|
|
655
|
+
recommendations: [
|
|
656
|
+
'为前端交互、后端入口、异步任务、Agent 工具调用统一注入 trace/request/task/error 关联字段。',
|
|
657
|
+
'错误日志必须能回查用户会话、任务、请求、下游调用和异常栈,敏感字段默认脱敏。',
|
|
658
|
+
'关键路径默认沉淀 runtime-events、timeline、root-cause-candidates、diagnostic-report 四类诊断证据。',
|
|
659
|
+
'每个新增功能在实现阶段都要自评是否需要新增结构化日志、查询样例或诊断导出入口。',
|
|
660
|
+
],
|
|
661
|
+
warnings,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function detectEvalHarness({ config, files, texts, packageJson, activeTasks }) {
|
|
666
|
+
const { scripts, dependencyNames } = packageSignals(packageJson);
|
|
667
|
+
const scriptEntries = Object.entries(scripts);
|
|
668
|
+
const commandText = scriptEntries.map(([name, command]) => `${name}: ${command}`).join('\n');
|
|
669
|
+
const hasTest = scriptEntries.some(([name]) => /(^|:)(test|check)$/.test(name)) || includesAny(commandText, ['node --test', 'vitest', 'jest', 'pytest']);
|
|
670
|
+
const smokeCommands = scriptEntries
|
|
671
|
+
.filter(([name, command]) => /smoke|e2e|playwright|cypress|test:ui/i.test(`${name} ${command}`))
|
|
672
|
+
.map(([name, command]) => `${name}: ${command}`);
|
|
673
|
+
const perfCommands = scriptEntries
|
|
674
|
+
.filter(([name, command]) => /perf|performance|load|stress|k6|lighthouse|autocannon|wrk/i.test(`${name} ${command}`))
|
|
675
|
+
.map(([name, command]) => `${name}: ${command}`);
|
|
676
|
+
const hasSmoke = smokeCommands.length > 0
|
|
677
|
+
|| files.some((file) => /playwright\.config|cypress\.config|e2e|smoke/i.test(file.path))
|
|
678
|
+
|| dependencyNames.some((name) => /playwright|cypress/i.test(name));
|
|
679
|
+
const hasPerf = perfCommands.length > 0
|
|
680
|
+
|| files.some((file) => /k6|lighthouse|load|stress|performance/i.test(file.path))
|
|
681
|
+
|| dependencyNames.some((name) => /k6|lighthouse|autocannon/i.test(name));
|
|
682
|
+
const hasExtremeFixtures = files.some((file) => /fixtures|extreme|stress|load|large-data|seed/i.test(file.path));
|
|
683
|
+
const normalBaseline = config.evalHarness.projectBaseline.normal;
|
|
684
|
+
const extremeBaseline = config.evalHarness.projectBaseline.extreme;
|
|
685
|
+
const warnings = [];
|
|
686
|
+
if (!hasSmoke) {
|
|
687
|
+
warnings.push('未检测到明确的冒烟/e2e 测试体系。');
|
|
688
|
+
}
|
|
689
|
+
if (!hasPerf) {
|
|
690
|
+
warnings.push('未检测到性能/压力测试命令或脚本。');
|
|
691
|
+
}
|
|
692
|
+
if (!hasExtremeFixtures) {
|
|
693
|
+
warnings.push('未检测到极端数据 fixtures 或压力场景数据。');
|
|
694
|
+
}
|
|
695
|
+
if (activeTasks.total > 0 && activeTasks.pending > 0) {
|
|
696
|
+
warnings.push(`当前任务清单仍有 ${activeTasks.pending} 个未完成条目,功能覆盖不能判定为完整。`);
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
status: hasSmoke && hasPerf && hasExtremeFixtures && activeTasks.pending === 0 ? 'pass' : 'needs-attention',
|
|
700
|
+
hasUnitOrCommandTests: hasTest,
|
|
701
|
+
smoke: {
|
|
702
|
+
present: hasSmoke,
|
|
703
|
+
commands: smokeCommands,
|
|
704
|
+
},
|
|
705
|
+
performance: {
|
|
706
|
+
present: hasPerf,
|
|
707
|
+
commands: perfCommands,
|
|
708
|
+
normalBaseline,
|
|
709
|
+
extremeBaseline,
|
|
710
|
+
ratchetPolicy: config.evalHarness.baselinePolicy,
|
|
711
|
+
},
|
|
712
|
+
extremeData: {
|
|
713
|
+
present: hasExtremeFixtures,
|
|
714
|
+
evidence: files.filter((file) => /fixtures|extreme|stress|load|large-data|seed/i.test(file.path)).slice(0, 20).map((file) => file.path),
|
|
715
|
+
},
|
|
716
|
+
featureCoverage: {
|
|
717
|
+
activeTasks,
|
|
718
|
+
requiredFlows: ['主流程', '异常流程', '逆向流程', '边界条件'],
|
|
719
|
+
},
|
|
720
|
+
warnings,
|
|
721
|
+
recommendations: [
|
|
722
|
+
'如果项目没有冒烟测试,优先补一组最短主流程用例,并让后续功能持续补充。',
|
|
723
|
+
'每次阶段性开发结束都要把任务清单映射到主流程、异常流程、逆向流程和边界条件。',
|
|
724
|
+
'性能标准从项目级 baseline 开始,功能级可以加严;Agent 自动更新阈值时只能更严格。',
|
|
725
|
+
],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function detectBusinessGuardrails({ config, files, texts, packageJson }) {
|
|
730
|
+
const { scripts, dependencyNames } = packageSignals(packageJson);
|
|
731
|
+
const haystack = [
|
|
732
|
+
...dependencyNames,
|
|
733
|
+
...Object.entries(scripts).map(([name, command]) => `${name}: ${command}`),
|
|
734
|
+
...files.map((file) => file.path),
|
|
735
|
+
...texts.map((file) => file.text),
|
|
736
|
+
].join('\n');
|
|
737
|
+
const businessConfig = config.businessGuardrails ?? defaultQualityConfig().businessGuardrails;
|
|
738
|
+
const riskIndicators = businessConfig.riskIndicators ?? [];
|
|
739
|
+
const matchedRiskIndicators = riskIndicators.filter((token) => includesAny(haystack, [token]));
|
|
740
|
+
const riskDetected = matchedRiskIndicators.length > 0;
|
|
741
|
+
const evidenceConfig = businessConfig.requiredEvidence ?? {};
|
|
742
|
+
const evidence = Object.fromEntries(
|
|
743
|
+
Object.entries(evidenceConfig).map(([key, tokens]) => {
|
|
744
|
+
const matched = (Array.isArray(tokens) ? tokens : []).filter((token) => includesAny(haystack, [token]));
|
|
745
|
+
return [key, { present: matched.length > 0, matched }];
|
|
746
|
+
}),
|
|
747
|
+
);
|
|
748
|
+
const missingEvidence = Object.entries(evidence)
|
|
749
|
+
.filter(([, value]) => !value.present)
|
|
750
|
+
.map(([key]) => key);
|
|
751
|
+
const warnings = [];
|
|
752
|
+
if (riskDetected && missingEvidence.includes('usageLimits')) {
|
|
753
|
+
warnings.push('检测到消耗型或免费额度风险,但未看到明确的额度、频率、并发或总量限制证据。');
|
|
754
|
+
}
|
|
755
|
+
if (riskDetected && missingEvidence.includes('abusePrevention')) {
|
|
756
|
+
warnings.push('检测到消耗型或免费额度风险,但未看到滥用、越权、重复请求或并发绕过的负向验证证据。');
|
|
757
|
+
}
|
|
758
|
+
if (riskDetected && missingEvidence.includes('monitoringSignals')) {
|
|
759
|
+
warnings.push('检测到消耗型或免费额度风险,但未看到用量、成本、调用量或异常行为监控信号。');
|
|
760
|
+
}
|
|
761
|
+
if (riskDetected && missingEvidence.includes('alertThresholds')) {
|
|
762
|
+
warnings.push('检测到消耗型或免费额度风险,但未看到成本、用量或异常增长报警阈值。');
|
|
763
|
+
}
|
|
764
|
+
if (riskDetected && missingEvidence.includes('stopLossActions')) {
|
|
765
|
+
warnings.push('检测到消耗型或免费额度风险,但未看到关闭、降级、暂停或熔断等止损动作。');
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
status: !businessConfig.enabled || !riskDetected || missingEvidence.length === 0 ? 'pass' : 'needs-attention',
|
|
769
|
+
enabled: businessConfig.enabled,
|
|
770
|
+
riskDetected,
|
|
771
|
+
matchedRiskIndicators,
|
|
772
|
+
evidence,
|
|
773
|
+
missingEvidence,
|
|
774
|
+
recommendations: [
|
|
775
|
+
'涉及免费用户、额度、AI 调用、第三方 API、生成、存储或下载时,先写明成本来源和用户级限制。',
|
|
776
|
+
'为免费、试用或低权限用户覆盖额度绕过、并发请求、重复请求、越权身份和异常恢复等负向场景。',
|
|
777
|
+
'上线前明确成本/用量指标、报警阈值、负责人和可执行止损动作。',
|
|
778
|
+
],
|
|
779
|
+
warnings,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function readActiveTasks(projectRoot) {
|
|
784
|
+
const state = await readJson(cjoin(projectRoot, '.openprd', 'state', 'changes.json')).catch(() => null);
|
|
785
|
+
const activeChange = state?.activeChange ?? null;
|
|
786
|
+
if (!activeChange) {
|
|
787
|
+
return { activeChange: null, total: 0, done: 0, pending: 0, blocked: 0, tasks: [] };
|
|
788
|
+
}
|
|
789
|
+
const taskFiles = [];
|
|
790
|
+
for (const root of [cjoin('openprd', 'changes', activeChange), cjoin('openspec', 'changes', activeChange)]) {
|
|
791
|
+
const dir = cjoin(projectRoot, root);
|
|
792
|
+
const entries = await fs.readdir(dir).catch(() => []);
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
if (/^tasks.*\.md$/.test(entry)) {
|
|
795
|
+
taskFiles.push(cjoin(root, entry));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const tasks = [];
|
|
800
|
+
for (const relativePath of taskFiles) {
|
|
801
|
+
const text = await readText(cjoin(projectRoot, relativePath)).catch(() => '');
|
|
802
|
+
const lines = text.split(/\r?\n/);
|
|
803
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
804
|
+
const match = lines[index].match(/^\s*-\s+\[([ xX~-])\]\s+(.+)$/);
|
|
805
|
+
if (match) {
|
|
806
|
+
const done = /x/i.test(match[1]);
|
|
807
|
+
const blocked = match[1] === '~' || /blocked|阻塞/i.test(match[2]);
|
|
808
|
+
tasks.push({
|
|
809
|
+
title: match[2].trim(),
|
|
810
|
+
done,
|
|
811
|
+
blocked,
|
|
812
|
+
source: relativePath,
|
|
813
|
+
line: index + 1,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return {
|
|
819
|
+
activeChange,
|
|
820
|
+
total: tasks.length,
|
|
821
|
+
done: tasks.filter((task) => task.done).length,
|
|
822
|
+
pending: tasks.filter((task) => !task.done && !task.blocked).length,
|
|
823
|
+
blocked: tasks.filter((task) => task.blocked).length,
|
|
824
|
+
tasks: tasks.slice(0, 50),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function listKnowledgeFiles(projectRoot) {
|
|
829
|
+
const knowledgeRoot = cjoin(projectRoot, '.openprd', 'knowledge');
|
|
830
|
+
const collected = [];
|
|
831
|
+
async function walk(dir) {
|
|
832
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
833
|
+
for (const entry of entries) {
|
|
834
|
+
const fullPath = cjoin(dir, entry.name);
|
|
835
|
+
if (entry.isDirectory()) {
|
|
836
|
+
await walk(fullPath);
|
|
837
|
+
} else if (entry.isFile()) {
|
|
838
|
+
collected.push({ path: path.relative(projectRoot, fullPath) });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
await walk(knowledgeRoot);
|
|
843
|
+
return collected;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function detectKnowledge({ config, knowledgeFiles }) {
|
|
847
|
+
const skillDir = config.knowledge.skillDir ?? '.openprd/knowledge/skills';
|
|
848
|
+
const candidateDir = config.knowledge.candidateDir ?? KNOWLEDGE_CANDIDATES_DIR;
|
|
849
|
+
const draftDir = config.knowledge.draftDir ?? KNOWLEDGE_DRAFTS_DIR;
|
|
850
|
+
const skills = knowledgeFiles
|
|
851
|
+
.filter((file) => file.path.startsWith(skillDir.replace(/\//g, path.sep)) || file.path.startsWith(skillDir))
|
|
852
|
+
.filter((file) => file.path.endsWith('SKILL.md'))
|
|
853
|
+
.map((file) => file.path);
|
|
854
|
+
const candidates = knowledgeFiles
|
|
855
|
+
.filter((file) => file.path.startsWith(candidateDir.replace(/\//g, path.sep)) || file.path.startsWith(candidateDir))
|
|
856
|
+
.filter((file) => file.path.endsWith('candidate.json'))
|
|
857
|
+
.map((file) => file.path);
|
|
858
|
+
const drafts = knowledgeFiles
|
|
859
|
+
.filter((file) => file.path.startsWith(draftDir.replace(/\//g, path.sep)) || file.path.startsWith(draftDir))
|
|
860
|
+
.filter((file) => file.path.endsWith('SKILL.md'))
|
|
861
|
+
.map((file) => file.path);
|
|
862
|
+
const incidents = knowledgeFiles.filter((file) => /\.openprd[\\/]knowledge[\\/]incidents[\\/].+\.json$/.test(file.path));
|
|
863
|
+
const warnings = [];
|
|
864
|
+
if (config.knowledge.enabled && skills.length === 0) {
|
|
865
|
+
warnings.push('项目级经验 skill 库尚为空;首次问题修复后应沉淀抽象经验。');
|
|
866
|
+
}
|
|
867
|
+
if (config.knowledge.enabled && candidates.length > 0) {
|
|
868
|
+
warnings.push(`当前有 ${candidates.length} 个待确认 knowledge candidate;本轮收工前应决定是否 promote 为正式项目经验。`);
|
|
869
|
+
}
|
|
870
|
+
return {
|
|
871
|
+
status: !config.knowledge.enabled || skills.length > 0 ? 'pass' : 'needs-attention',
|
|
872
|
+
enabled: config.knowledge.enabled,
|
|
873
|
+
skillDir,
|
|
874
|
+
candidateDir,
|
|
875
|
+
draftDir,
|
|
876
|
+
skills,
|
|
877
|
+
candidates,
|
|
878
|
+
drafts,
|
|
879
|
+
incidents: incidents.map((file) => file.path),
|
|
880
|
+
recommendations: [
|
|
881
|
+
'每次问题修复后记录症状、排查路径、根因模式、修复方式、验证证据和下次触发条件。',
|
|
882
|
+
'只有重复、高影响、隐性知识或 Agent 误判类问题才升级为项目级 skill。',
|
|
883
|
+
'生成 skill 时按抽象模式写触发条件和验证步骤,避免只记录一次性事故流水账。',
|
|
884
|
+
],
|
|
885
|
+
warnings,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function buildGate({ id, label, baseStatus, baseWarnings, policy, evidenceLedger }) {
|
|
890
|
+
const required = policy.requiredGates.includes(id);
|
|
891
|
+
const evidence = evidenceLedger[id] ?? { present: false, sources: [], summary: '未找到本次执行证据' };
|
|
892
|
+
let status = baseStatus;
|
|
893
|
+
const warnings = [...baseWarnings];
|
|
894
|
+
if (!required && status !== 'pass') {
|
|
895
|
+
status = 'advisory';
|
|
896
|
+
warnings.push('当前场景未要求阻断此门禁;若准备发布或该风险进入范围,需要补齐证据。');
|
|
897
|
+
}
|
|
898
|
+
if (required && policy.evidenceRequired && status === 'pass' && !evidence.present) {
|
|
899
|
+
status = 'needs-evidence';
|
|
900
|
+
warnings.push('当前场景要求此门禁,但未找到本次执行或明确豁免证据。');
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
id,
|
|
904
|
+
label,
|
|
905
|
+
status,
|
|
906
|
+
required,
|
|
907
|
+
evidence,
|
|
908
|
+
warnings,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function buildGates({ observability, evalHarness, businessGuardrails, knowledge, policy, evidenceLedger }) {
|
|
913
|
+
return [
|
|
914
|
+
buildGate({
|
|
915
|
+
id: 'traceability',
|
|
916
|
+
label: '日志链路可追踪',
|
|
917
|
+
baseStatus: observability.status,
|
|
918
|
+
baseWarnings: observability.warnings,
|
|
919
|
+
policy,
|
|
920
|
+
evidenceLedger,
|
|
921
|
+
}),
|
|
922
|
+
buildGate({
|
|
923
|
+
id: 'redaction',
|
|
924
|
+
label: '日志脱敏策略',
|
|
925
|
+
baseStatus: observability.redactionRequired ? (evidenceLedger.redaction?.present ? 'pass' : 'needs-evidence') : 'pass',
|
|
926
|
+
baseWarnings: observability.redactionRequired ? ['需要在项目文档、平台配置或本次测试证据中确认敏感字段脱敏策略。'] : [],
|
|
927
|
+
policy,
|
|
928
|
+
evidenceLedger,
|
|
929
|
+
}),
|
|
930
|
+
buildGate({
|
|
931
|
+
id: 'business-guardrails',
|
|
932
|
+
label: '业务成本与滥用护栏',
|
|
933
|
+
baseStatus: businessGuardrails.status,
|
|
934
|
+
baseWarnings: businessGuardrails.warnings,
|
|
935
|
+
policy,
|
|
936
|
+
evidenceLedger,
|
|
937
|
+
}),
|
|
938
|
+
buildGate({
|
|
939
|
+
id: 'smoke',
|
|
940
|
+
label: '冒烟测试体系',
|
|
941
|
+
baseStatus: evalHarness.smoke.present ? 'pass' : 'needs-attention',
|
|
942
|
+
baseWarnings: evalHarness.smoke.present ? [] : ['缺少冒烟/e2e 体系或本次冒烟验证入口。'],
|
|
943
|
+
policy,
|
|
944
|
+
evidenceLedger,
|
|
945
|
+
}),
|
|
946
|
+
buildGate({
|
|
947
|
+
id: 'feature-coverage',
|
|
948
|
+
label: '任务与功能覆盖',
|
|
949
|
+
baseStatus: evalHarness.featureCoverage.activeTasks.pending === 0 ? 'pass' : 'needs-attention',
|
|
950
|
+
baseWarnings: evalHarness.featureCoverage.activeTasks.pending === 0 ? [] : ['仍有未完成任务或缺少任务覆盖证据。'],
|
|
951
|
+
policy,
|
|
952
|
+
evidenceLedger,
|
|
953
|
+
}),
|
|
954
|
+
buildGate({
|
|
955
|
+
id: 'normal-performance',
|
|
956
|
+
label: '正常性能基线',
|
|
957
|
+
baseStatus: evalHarness.performance.present ? 'pass' : 'needs-attention',
|
|
958
|
+
baseWarnings: evalHarness.performance.present ? [] : ['缺少性能测试命令或正常性能基线证据。'],
|
|
959
|
+
policy,
|
|
960
|
+
evidenceLedger,
|
|
961
|
+
}),
|
|
962
|
+
buildGate({
|
|
963
|
+
id: 'extreme-performance',
|
|
964
|
+
label: '极端场景压力',
|
|
965
|
+
baseStatus: evalHarness.extremeData.present ? 'pass' : 'needs-attention',
|
|
966
|
+
baseWarnings: evalHarness.extremeData.present ? [] : ['缺少极端数据或压力场景。'],
|
|
967
|
+
policy,
|
|
968
|
+
evidenceLedger,
|
|
969
|
+
}),
|
|
970
|
+
buildGate({
|
|
971
|
+
id: 'knowledge',
|
|
972
|
+
label: '经验 Skill 沉淀',
|
|
973
|
+
baseStatus: knowledge.status,
|
|
974
|
+
baseWarnings: knowledge.warnings,
|
|
975
|
+
policy,
|
|
976
|
+
evidenceLedger,
|
|
977
|
+
}),
|
|
978
|
+
];
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function loadPackageJson(projectRoot) {
|
|
982
|
+
return readJson(cjoin(projectRoot, 'package.json')).catch(() => null);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function buildQualityReport(projectRoot, config) {
|
|
986
|
+
const id = reportId();
|
|
987
|
+
const normalizedConfig = normalizeQualityConfig(config);
|
|
988
|
+
const files = await walkProject(projectRoot);
|
|
989
|
+
const texts = await readProjectTexts(projectRoot, files);
|
|
990
|
+
const packageJson = await loadPackageJson(projectRoot);
|
|
991
|
+
const activeTasks = await readActiveTasks(projectRoot);
|
|
992
|
+
const activeChangeContext = await readActiveChangeContext(projectRoot, activeTasks.activeChange);
|
|
993
|
+
const evidenceFiles = await readEvidenceFiles(projectRoot, normalizedConfig);
|
|
994
|
+
const knowledgeFiles = await listKnowledgeFiles(projectRoot);
|
|
995
|
+
const observability = detectObservability({ config: normalizedConfig, files, texts, packageJson });
|
|
996
|
+
const evalHarness = detectEvalHarness({ config: normalizedConfig, files, texts, packageJson, activeTasks });
|
|
997
|
+
const businessGuardrails = detectBusinessGuardrails({ config: normalizedConfig, files, texts, packageJson });
|
|
998
|
+
const knowledge = detectKnowledge({ config: normalizedConfig, knowledgeFiles });
|
|
999
|
+
const policy = buildQualityPolicy({ config: normalizedConfig, activeChangeContext, activeTasks, businessGuardrails });
|
|
1000
|
+
const evidenceLedger = buildEvidenceLedger({ evidenceFiles, activeTasks, observability, businessGuardrails, knowledge });
|
|
1001
|
+
const gates = buildGates({ observability, evalHarness, businessGuardrails, knowledge, policy, evidenceLedger });
|
|
1002
|
+
const blockingStatuses = new Set(['fail']);
|
|
1003
|
+
const attentionStatuses = new Set(['needs-attention', 'needs-evidence']);
|
|
1004
|
+
const readiness = {
|
|
1005
|
+
ok: !gates.some((gate) => blockingStatuses.has(gate.status)),
|
|
1006
|
+
productionReady: !gates.some((gate) => attentionStatuses.has(gate.status) || blockingStatuses.has(gate.status)),
|
|
1007
|
+
enforcement: config.enforcement,
|
|
1008
|
+
failingGates: gates.filter((gate) => blockingStatuses.has(gate.status)).map((gate) => gate.id),
|
|
1009
|
+
attentionGates: gates.filter((gate) => attentionStatuses.has(gate.status)).map((gate) => gate.id),
|
|
1010
|
+
};
|
|
1011
|
+
evalHarness.executionEvidence = {
|
|
1012
|
+
sources: evidenceFiles.map((file) => ({ path: file.path, source: file.source, size: file.size })).slice(0, 120),
|
|
1013
|
+
ledger: Object.fromEntries(['smoke', 'feature-coverage', 'normal-performance', 'extreme-performance'].map((gate) => [gate, evidenceLedger[gate]])),
|
|
1014
|
+
};
|
|
1015
|
+
return {
|
|
1016
|
+
version: 1,
|
|
1017
|
+
schema: 'openprd.eval-report.v1',
|
|
1018
|
+
id,
|
|
1019
|
+
generatedAt: timestamp(),
|
|
1020
|
+
projectRoot,
|
|
1021
|
+
summary: {
|
|
1022
|
+
status: readiness.productionReady ? 'production-ready' : 'needs-attention',
|
|
1023
|
+
filesScanned: files.length,
|
|
1024
|
+
activeChange: activeTasks.activeChange,
|
|
1025
|
+
gateCount: gates.length,
|
|
1026
|
+
attentionCount: readiness.attentionGates.length,
|
|
1027
|
+
},
|
|
1028
|
+
readiness,
|
|
1029
|
+
qualityPolicy: policy,
|
|
1030
|
+
evidenceLedger,
|
|
1031
|
+
gates,
|
|
1032
|
+
observability,
|
|
1033
|
+
evalHarness,
|
|
1034
|
+
businessGuardrails,
|
|
1035
|
+
knowledge,
|
|
1036
|
+
configSnapshot: normalizedConfig,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function writeReport(projectRoot, report) {
|
|
1041
|
+
await ensureQualityDirs(projectRoot);
|
|
1042
|
+
const reportBase = report.id.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
1043
|
+
const jsonPath = qualityPath(projectRoot, cjoin(QUALITY_REPORTS_DIR, `${reportBase}.json`));
|
|
1044
|
+
const htmlPath = qualityPath(projectRoot, cjoin(QUALITY_REPORTS_DIR, `${reportBase}.html`));
|
|
1045
|
+
await writeJson(jsonPath, report);
|
|
1046
|
+
await writeText(htmlPath, renderQualityEvalArtifact({ report }));
|
|
1047
|
+
await writeJson(qualityPath(projectRoot, QUALITY_LATEST), {
|
|
1048
|
+
reportId: report.id,
|
|
1049
|
+
jsonPath,
|
|
1050
|
+
htmlPath,
|
|
1051
|
+
generatedAt: report.generatedAt,
|
|
1052
|
+
status: report.summary.status,
|
|
1053
|
+
});
|
|
1054
|
+
const indexPath = qualityPath(projectRoot, QUALITY_INDEX);
|
|
1055
|
+
const index = await readJson(indexPath).catch(() => ({ version: 1, reports: [] }));
|
|
1056
|
+
const reports = [
|
|
1057
|
+
{ reportId: report.id, jsonPath, htmlPath, generatedAt: report.generatedAt, status: report.summary.status },
|
|
1058
|
+
...(Array.isArray(index.reports) ? index.reports.filter((item) => item.reportId !== report.id) : []),
|
|
1059
|
+
].slice(0, 100);
|
|
1060
|
+
await writeJson(indexPath, { version: 1, updatedAt: timestamp(), reports });
|
|
1061
|
+
return { jsonPath, htmlPath, indexPath };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
export async function initQualityWorkspace(projectRoot, options = {}) {
|
|
1065
|
+
const { config, changed } = await mergeQualityConfig(projectRoot, { force: Boolean(options.force) });
|
|
1066
|
+
const knowledgeIndexPath = qualityPath(projectRoot, KNOWLEDGE_INDEX);
|
|
1067
|
+
if (!(await exists(knowledgeIndexPath))) {
|
|
1068
|
+
await writeJson(knowledgeIndexPath, {
|
|
1069
|
+
version: 1,
|
|
1070
|
+
updatedAt: timestamp(),
|
|
1071
|
+
incidents: [],
|
|
1072
|
+
patterns: [],
|
|
1073
|
+
skills: [],
|
|
1074
|
+
candidates: [],
|
|
1075
|
+
drafts: [],
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
action: 'quality-init',
|
|
1081
|
+
projectRoot,
|
|
1082
|
+
config,
|
|
1083
|
+
changed,
|
|
1084
|
+
files: {
|
|
1085
|
+
config: qualityPath(projectRoot, QUALITY_CONFIG),
|
|
1086
|
+
reportsDir: qualityPath(projectRoot, QUALITY_REPORTS_DIR),
|
|
1087
|
+
knowledgeIndex: knowledgeIndexPath,
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
export async function verifyQualityWorkspace(projectRoot, options = {}) {
|
|
1093
|
+
const configPath = qualityPath(projectRoot, QUALITY_CONFIG);
|
|
1094
|
+
if (!(await exists(configPath))) {
|
|
1095
|
+
return {
|
|
1096
|
+
ok: false,
|
|
1097
|
+
action: 'quality-verify',
|
|
1098
|
+
projectRoot,
|
|
1099
|
+
errors: [`${QUALITY_CONFIG} is required. Run: openprd quality . --init`],
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
const config = normalizeQualityConfig(await readJson(configPath));
|
|
1103
|
+
await ensureQualityDirs(projectRoot);
|
|
1104
|
+
const report = await buildQualityReport(projectRoot, config);
|
|
1105
|
+
const paths = await writeReport(projectRoot, report);
|
|
1106
|
+
const knowledgeSignal = {
|
|
1107
|
+
kind: 'quality-verify',
|
|
1108
|
+
ok: report.readiness.productionReady,
|
|
1109
|
+
productionReady: report.readiness.productionReady,
|
|
1110
|
+
attentionGates: report.readiness.attentionGates,
|
|
1111
|
+
summary: `quality ${report.summary.status}`,
|
|
1112
|
+
};
|
|
1113
|
+
await recordKnowledgeReviewSignal(projectRoot, knowledgeSignal).catch(() => null);
|
|
1114
|
+
const reviewSource = (await exists(qualityPath(projectRoot, OPENPRD_HARNESS_TURN_STATE)))
|
|
1115
|
+
? OPENPRD_HARNESS_TURN_STATE
|
|
1116
|
+
: paths.jsonPath;
|
|
1117
|
+
const knowledgeReview = await reviewKnowledgeWorkspace(projectRoot, {
|
|
1118
|
+
from: reviewSource,
|
|
1119
|
+
signal: knowledgeSignal,
|
|
1120
|
+
requiredCorrelationFields: config.observability.requiredCorrelationFields,
|
|
1121
|
+
}).catch((error) => ({
|
|
1122
|
+
ok: false,
|
|
1123
|
+
action: 'quality-knowledge-review',
|
|
1124
|
+
skipped: false,
|
|
1125
|
+
errors: [error instanceof Error ? error.message : String(error)],
|
|
1126
|
+
}));
|
|
1127
|
+
const strict = options.strict === true;
|
|
1128
|
+
const blocking = (strict || config.enforcement === 'blocking') && !report.readiness.productionReady;
|
|
1129
|
+
return {
|
|
1130
|
+
ok: !blocking,
|
|
1131
|
+
action: 'quality-verify',
|
|
1132
|
+
projectRoot,
|
|
1133
|
+
report,
|
|
1134
|
+
reportPath: paths.jsonPath,
|
|
1135
|
+
htmlPath: paths.htmlPath,
|
|
1136
|
+
indexPath: paths.indexPath,
|
|
1137
|
+
knowledgeReview,
|
|
1138
|
+
errors: blocking ? ['Quality readiness is not production-ready; one or more required gates need evidence or attention.'] : [],
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export async function learnQualityWorkspace(projectRoot, options = {}) {
|
|
1143
|
+
await ensureQualityDirs(projectRoot);
|
|
1144
|
+
const configPath = qualityPath(projectRoot, QUALITY_CONFIG);
|
|
1145
|
+
const config = normalizeQualityConfig(await readJson(configPath).catch(() => defaultQualityConfig()));
|
|
1146
|
+
const latest = await readJson(qualityPath(projectRoot, QUALITY_LATEST)).catch(() => null);
|
|
1147
|
+
const resolved = await resolveQualityLearningSource(projectRoot, {
|
|
1148
|
+
from: options.from,
|
|
1149
|
+
latestReportPath: latest?.jsonPath ?? null,
|
|
1150
|
+
requiredCorrelationFields: config.observability.requiredCorrelationFields,
|
|
1151
|
+
});
|
|
1152
|
+
if (!resolved.ok) {
|
|
1153
|
+
return {
|
|
1154
|
+
ok: false,
|
|
1155
|
+
action: 'quality-learn',
|
|
1156
|
+
projectRoot,
|
|
1157
|
+
errors: [resolved.error],
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
const source = resolved.source;
|
|
1161
|
+
const { incidentId, patternId, skillName } = deriveKnowledgeNames(source);
|
|
1162
|
+
const incidentPath = qualityPath(projectRoot, cjoin(KNOWLEDGE_DIR, 'incidents', `${incidentId}.json`));
|
|
1163
|
+
const patternPath = qualityPath(projectRoot, cjoin(KNOWLEDGE_DIR, 'patterns', `${patternId}.json`));
|
|
1164
|
+
const skillDir = qualityPath(projectRoot, cjoin(KNOWLEDGE_DIR, 'skills', skillName));
|
|
1165
|
+
const skillPath = cjoin(skillDir, 'SKILL.md');
|
|
1166
|
+
await writeJson(incidentPath, {
|
|
1167
|
+
version: 1,
|
|
1168
|
+
incidentId,
|
|
1169
|
+
sourceKind: source.kind,
|
|
1170
|
+
sourceRef: source.sourceId,
|
|
1171
|
+
sourcePath: source.sourcePath,
|
|
1172
|
+
sourcePaths: source.sourcePaths,
|
|
1173
|
+
capturedAt: timestamp(),
|
|
1174
|
+
title: source.title,
|
|
1175
|
+
status: source.status,
|
|
1176
|
+
symptoms: source.symptoms,
|
|
1177
|
+
attentionGates: source.attentionGates,
|
|
1178
|
+
correlationFields: source.correlationFields,
|
|
1179
|
+
extraContextFields: source.extraContextFields,
|
|
1180
|
+
missingCorrelationFields: source.missingCorrelationFields,
|
|
1181
|
+
eventNames: source.eventNames,
|
|
1182
|
+
evidenceSources: source.evidenceSources,
|
|
1183
|
+
rootCauseCandidates: source.rootCauseCandidates,
|
|
1184
|
+
queryExamples: source.queryExamples,
|
|
1185
|
+
verification: {
|
|
1186
|
+
fixed: false,
|
|
1187
|
+
evidence: [],
|
|
1188
|
+
recommendedSteps: source.verificationSteps,
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
await writeJson(patternPath, {
|
|
1192
|
+
version: 1,
|
|
1193
|
+
patternId,
|
|
1194
|
+
sourceKind: source.kind,
|
|
1195
|
+
sourceRef: source.sourceId,
|
|
1196
|
+
abstractPattern: source.abstractPattern,
|
|
1197
|
+
triggers: source.triggers,
|
|
1198
|
+
requiredCorrelationFields: source.correlationFields,
|
|
1199
|
+
missingCorrelationFields: source.missingCorrelationFields,
|
|
1200
|
+
preferredEvidenceOrder: source.evidenceSources.map((item) => item.kind),
|
|
1201
|
+
keyEvents: source.eventNames,
|
|
1202
|
+
rootCauseLabels: source.rootCauseCandidates.map((item) => item.title),
|
|
1203
|
+
prevention: source.prevention,
|
|
1204
|
+
verificationSteps: source.verificationSteps,
|
|
1205
|
+
updatedAt: timestamp(),
|
|
1206
|
+
});
|
|
1207
|
+
await writeText(skillPath, renderExperienceSkill({ skillName, source }));
|
|
1208
|
+
const indexPath = qualityPath(projectRoot, KNOWLEDGE_INDEX);
|
|
1209
|
+
const index = await readJson(indexPath).catch(() => ({ version: 1, incidents: [], patterns: [], skills: [], candidates: [], drafts: [] }));
|
|
1210
|
+
const upsert = (items, key, value) => [value, ...(Array.isArray(items) ? items.filter((item) => item[key] !== value[key]) : [])].slice(0, 200);
|
|
1211
|
+
await writeJson(indexPath, {
|
|
1212
|
+
version: 1,
|
|
1213
|
+
updatedAt: timestamp(),
|
|
1214
|
+
incidents: upsert(index.incidents, 'incidentId', { incidentId, path: incidentPath, sourceKind: source.kind, sourceRef: source.sourceId }),
|
|
1215
|
+
patterns: upsert(index.patterns, 'patternId', { patternId, path: patternPath, sourceKind: source.kind, sourceRef: source.sourceId }),
|
|
1216
|
+
skills: upsert(index.skills, 'skillName', { skillName, path: skillPath, sourceKind: source.kind, sourceRef: source.sourceId }),
|
|
1217
|
+
candidates: Array.isArray(index.candidates) ? index.candidates : [],
|
|
1218
|
+
drafts: Array.isArray(index.drafts) ? index.drafts : [],
|
|
1219
|
+
});
|
|
1220
|
+
await markKnowledgeCandidatePromoted(projectRoot, {
|
|
1221
|
+
sourcePath: source.sourcePath,
|
|
1222
|
+
sourcePaths: source.sourcePaths,
|
|
1223
|
+
skillPath,
|
|
1224
|
+
incidentPath,
|
|
1225
|
+
patternPath,
|
|
1226
|
+
}).catch(() => null);
|
|
1227
|
+
return {
|
|
1228
|
+
ok: true,
|
|
1229
|
+
action: 'quality-learn',
|
|
1230
|
+
projectRoot,
|
|
1231
|
+
sourceKind: source.kind,
|
|
1232
|
+
sourcePath: source.sourcePath,
|
|
1233
|
+
sourcePaths: source.sourcePaths,
|
|
1234
|
+
sourceReportPath: source.kind === 'quality-report' ? source.sourcePath : null,
|
|
1235
|
+
incidentId,
|
|
1236
|
+
patternId,
|
|
1237
|
+
skillName,
|
|
1238
|
+
files: {
|
|
1239
|
+
incident: incidentPath,
|
|
1240
|
+
pattern: patternPath,
|
|
1241
|
+
skill: skillPath,
|
|
1242
|
+
index: indexPath,
|
|
1243
|
+
},
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
export async function qualityWorkspace(projectRoot, options = {}) {
|
|
1248
|
+
if (options.init) {
|
|
1249
|
+
return initQualityWorkspace(projectRoot, options);
|
|
1250
|
+
}
|
|
1251
|
+
if (options.learn && options.review) {
|
|
1252
|
+
const config = normalizeQualityConfig(await readJson(qualityPath(projectRoot, QUALITY_CONFIG)).catch(() => defaultQualityConfig()));
|
|
1253
|
+
return reviewKnowledgeWorkspace(projectRoot, {
|
|
1254
|
+
from: options.from,
|
|
1255
|
+
requiredCorrelationFields: config.observability.requiredCorrelationFields,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (options.learn) {
|
|
1259
|
+
return learnQualityWorkspace(projectRoot, options);
|
|
1260
|
+
}
|
|
1261
|
+
return verifyQualityWorkspace(projectRoot, options);
|
|
1262
|
+
}
|