@luckychen1993/pm-mcp 0.1.1
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/LICENSE +21 -0
- package/README.md +304 -0
- package/dist/acceptance-evaluator.js +330 -0
- package/dist/acceptance-model.js +440 -0
- package/dist/acceptance-report.js +335 -0
- package/dist/acceptance-tools.js +318 -0
- package/dist/audit-tools.js +61 -0
- package/dist/audit.js +364 -0
- package/dist/budget.js +71 -0
- package/dist/dashboard.js +241 -0
- package/dist/governance-audit.js +126 -0
- package/dist/governance-model.js +306 -0
- package/dist/governance-tools.js +240 -0
- package/dist/health.js +58 -0
- package/dist/index-db.js +97 -0
- package/dist/index-store.js +381 -0
- package/dist/index.js +210 -0
- package/dist/init.js +40 -0
- package/dist/knowledge-tools.js +204 -0
- package/dist/language-adapters.js +450 -0
- package/dist/language-dependencies.js +351 -0
- package/dist/license.js +261 -0
- package/dist/paths.js +51 -0
- package/dist/polyglot-ast.js +329 -0
- package/dist/portfolio.js +349 -0
- package/dist/project-fingerprint.js +83 -0
- package/dist/project-tools.js +217 -0
- package/dist/quality-store.js +77 -0
- package/dist/registry.js +38 -0
- package/dist/roadmap.js +96 -0
- package/dist/scan.js +271 -0
- package/dist/search.js +312 -0
- package/dist/security.js +269 -0
- package/dist/semantic-evidence-store.js +133 -0
- package/dist/semantic-evidence.js +100 -0
- package/dist/semantic-graph-algorithms.js +86 -0
- package/dist/semantic-graph.js +390 -0
- package/dist/semantic-parsers.js +311 -0
- package/dist/store.js +185 -0
- package/dist/task-tools.js +198 -0
- package/dist/tool-base.js +32 -0
- package/dist/tools.js +21 -0
- package/dist/types.js +208 -0
- package/dist/typescript-semantic.js +282 -0
- package/package.json +70 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ACCEPTANCE_SCHEMA_VERSION, BaselineApprovalSchema, FrozenEvidencePointerSchema, ISO_25010_CHARACTERISTICS, ISO_25040_STAGES, MachineAssertionSchema, RISK_LEVELS, } from "./acceptance-model.js";
|
|
6
|
+
import { pmPath } from "./paths.js";
|
|
7
|
+
import { atomicWrite, withLedgerLock } from "./store.js";
|
|
8
|
+
export const ACCEPTANCE_STANDARD_BASIS = [
|
|
9
|
+
"ISO/IEC 25010:2023",
|
|
10
|
+
"ISO/IEC 25023:2016",
|
|
11
|
+
"ISO/IEC 25030:2019",
|
|
12
|
+
"ISO/IEC 25040:2024",
|
|
13
|
+
"ISO/IEC/IEEE 29119-2:2021",
|
|
14
|
+
];
|
|
15
|
+
const IdSchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
|
|
16
|
+
const TextSchema = z.string().trim().min(1).max(8_000);
|
|
17
|
+
const TimestampSchema = z.string().datetime({ offset: true });
|
|
18
|
+
const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/i);
|
|
19
|
+
function requireUniqueSet(values, expected, pathName, ctx) {
|
|
20
|
+
const actual = new Set(values);
|
|
21
|
+
if (actual.size !== values.length) {
|
|
22
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [pathName], message: `${pathName} 包含重复项` });
|
|
23
|
+
}
|
|
24
|
+
if (expected) {
|
|
25
|
+
for (const item of expected) {
|
|
26
|
+
if (!actual.has(item))
|
|
27
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [pathName], message: `${pathName} 缺少 "${item}"` });
|
|
28
|
+
}
|
|
29
|
+
if (values.length !== expected.length)
|
|
30
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [pathName], message: `${pathName} 数量必须为 ${expected.length}` });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const AcceptanceFindingSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
code: IdSchema,
|
|
36
|
+
severity: z.enum(["error", "warning"]),
|
|
37
|
+
subject: TextSchema,
|
|
38
|
+
message: TextSchema,
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
export const AcceptanceReportSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
schema_version: z.literal(ACCEPTANCE_SCHEMA_VERSION),
|
|
44
|
+
report_id: IdSchema,
|
|
45
|
+
evaluation_id: IdSchema,
|
|
46
|
+
report_generated_at: TimestampSchema,
|
|
47
|
+
product: TextSchema,
|
|
48
|
+
scope: TextSchema,
|
|
49
|
+
baseline: z
|
|
50
|
+
.object({
|
|
51
|
+
id: IdSchema,
|
|
52
|
+
version: z.string().min(1),
|
|
53
|
+
title: TextSchema,
|
|
54
|
+
fingerprint_sha256: Sha256Schema,
|
|
55
|
+
approval: BaselineApprovalSchema,
|
|
56
|
+
})
|
|
57
|
+
.strict(),
|
|
58
|
+
evaluator: z
|
|
59
|
+
.object({
|
|
60
|
+
name: TextSchema,
|
|
61
|
+
organization: TextSchema,
|
|
62
|
+
role: TextSchema,
|
|
63
|
+
independent: z.boolean(),
|
|
64
|
+
})
|
|
65
|
+
.strict(),
|
|
66
|
+
standard_basis: z.tuple([
|
|
67
|
+
z.literal(ACCEPTANCE_STANDARD_BASIS[0]),
|
|
68
|
+
z.literal(ACCEPTANCE_STANDARD_BASIS[1]),
|
|
69
|
+
z.literal(ACCEPTANCE_STANDARD_BASIS[2]),
|
|
70
|
+
z.literal(ACCEPTANCE_STANDARD_BASIS[3]),
|
|
71
|
+
z.literal(ACCEPTANCE_STANDARD_BASIS[4]),
|
|
72
|
+
]),
|
|
73
|
+
verdict: z.enum(["accepted", "rejected"]),
|
|
74
|
+
summary: z
|
|
75
|
+
.object({
|
|
76
|
+
applicable_characteristics: z.number().int().min(0),
|
|
77
|
+
accepted_characteristics: z.number().int().min(0),
|
|
78
|
+
requirements_total: z.number().int().min(0),
|
|
79
|
+
requirements_passed: z.number().int().min(0),
|
|
80
|
+
tests_total: z.number().int().min(0),
|
|
81
|
+
tests_passed: z.number().int().min(0),
|
|
82
|
+
risks_total: z.number().int().min(0),
|
|
83
|
+
risks_controlled: z.number().int().min(0),
|
|
84
|
+
stages_total: z.number().int().min(0),
|
|
85
|
+
stages_completed: z.number().int().min(0),
|
|
86
|
+
errors: z.number().int().min(0),
|
|
87
|
+
warnings: z.number().int().min(0),
|
|
88
|
+
})
|
|
89
|
+
.strict(),
|
|
90
|
+
stage_results: z.array(z.object({
|
|
91
|
+
stage: z.enum(ISO_25040_STAGES),
|
|
92
|
+
status: z.enum(["completed", "blocked", "not_started"]),
|
|
93
|
+
passed: z.boolean(),
|
|
94
|
+
evidence_ids: z.array(IdSchema),
|
|
95
|
+
result_note: TextSchema,
|
|
96
|
+
}).strict()),
|
|
97
|
+
characteristic_results: z.array(z.object({
|
|
98
|
+
id: z.enum(ISO_25010_CHARACTERISTICS),
|
|
99
|
+
status: z.enum(["accepted", "rejected", "tailored_out"]),
|
|
100
|
+
requirement_ids: z.array(IdSchema),
|
|
101
|
+
tailoring_reason: TextSchema.nullable(),
|
|
102
|
+
}).strict()),
|
|
103
|
+
requirement_results: z.array(z.object({
|
|
104
|
+
id: IdSchema,
|
|
105
|
+
characteristic: z.enum(ISO_25010_CHARACTERISTICS),
|
|
106
|
+
metric_name: TextSchema,
|
|
107
|
+
unit: TextSchema,
|
|
108
|
+
direction: z.enum(["at_least", "at_most", "equal"]),
|
|
109
|
+
threshold: z.number().finite(),
|
|
110
|
+
tolerance: z.number().finite().min(0),
|
|
111
|
+
observed_value: z.number().finite().nullable(),
|
|
112
|
+
measurement_source: FrozenEvidencePointerSchema,
|
|
113
|
+
passed: z.boolean(),
|
|
114
|
+
risk_ids: z.array(IdSchema),
|
|
115
|
+
test_ids: z.array(IdSchema),
|
|
116
|
+
evidence_ids: z.array(IdSchema),
|
|
117
|
+
}).strict()),
|
|
118
|
+
test_results: z.array(z.object({
|
|
119
|
+
id: IdSchema,
|
|
120
|
+
status: z.enum(["passed", "failed", "blocked", "not_run", "missing"]),
|
|
121
|
+
verification_mode: z.enum(["automated", "manual"]),
|
|
122
|
+
assertion: MachineAssertionSchema.nullable(),
|
|
123
|
+
passed: z.boolean(),
|
|
124
|
+
evidence_ids: z.array(IdSchema),
|
|
125
|
+
}).strict()),
|
|
126
|
+
risk_results: z.array(z.object({
|
|
127
|
+
id: IdSchema,
|
|
128
|
+
owner: TextSchema,
|
|
129
|
+
likelihood: z.enum(["rare", "unlikely", "possible", "likely", "almost_certain"]),
|
|
130
|
+
impact: z.enum(["negligible", "minor", "moderate", "major", "severe"]),
|
|
131
|
+
inherent_level: z.enum(RISK_LEVELS),
|
|
132
|
+
compensating_controls: z.array(TextSchema).min(1),
|
|
133
|
+
residual_level: z.enum(RISK_LEVELS).nullable(),
|
|
134
|
+
disposition: z.enum(["mitigated", "accepted", "open", "missing"]),
|
|
135
|
+
accepted_by: TextSchema.nullable(),
|
|
136
|
+
evidence_ids: z.array(IdSchema),
|
|
137
|
+
controlled: z.boolean(),
|
|
138
|
+
}).strict()),
|
|
139
|
+
traceability_matrix: z.array(z.object({
|
|
140
|
+
requirement_id: IdSchema,
|
|
141
|
+
risk_ids: z.array(IdSchema),
|
|
142
|
+
test_ids: z.array(IdSchema),
|
|
143
|
+
evidence_ids: z.array(IdSchema),
|
|
144
|
+
complete: z.boolean(),
|
|
145
|
+
}).strict()),
|
|
146
|
+
findings: z.array(AcceptanceFindingSchema),
|
|
147
|
+
conclusion: TextSchema,
|
|
148
|
+
})
|
|
149
|
+
.strict()
|
|
150
|
+
.superRefine((report, ctx) => {
|
|
151
|
+
requireUniqueSet(report.stage_results.map((item) => item.stage), ISO_25040_STAGES, "stage_results", ctx);
|
|
152
|
+
requireUniqueSet(report.characteristic_results.map((item) => item.id), ISO_25010_CHARACTERISTICS, "characteristic_results", ctx);
|
|
153
|
+
requireUniqueSet(report.requirement_results.map((item) => item.id), null, "requirement_results", ctx);
|
|
154
|
+
requireUniqueSet(report.test_results.map((item) => item.id), null, "test_results", ctx);
|
|
155
|
+
requireUniqueSet(report.risk_results.map((item) => item.id), null, "risk_results", ctx);
|
|
156
|
+
requireUniqueSet(report.traceability_matrix.map((item) => item.requirement_id), null, "traceability_matrix", ctx);
|
|
157
|
+
const requirementIds = new Set(report.requirement_results.map((item) => item.id));
|
|
158
|
+
const traceIds = new Set(report.traceability_matrix.map((item) => item.requirement_id));
|
|
159
|
+
for (const id of requirementIds) {
|
|
160
|
+
if (!traceIds.has(id))
|
|
161
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["traceability_matrix"], message: `追踪矩阵缺少需求 "${id}"` });
|
|
162
|
+
}
|
|
163
|
+
for (const id of traceIds) {
|
|
164
|
+
if (!requirementIds.has(id))
|
|
165
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["traceability_matrix"], message: `追踪矩阵包含未知需求 "${id}"` });
|
|
166
|
+
}
|
|
167
|
+
const errors = report.findings.filter((finding) => finding.severity === "error").length;
|
|
168
|
+
const warnings = report.findings.filter((finding) => finding.severity === "warning").length;
|
|
169
|
+
const expected = {
|
|
170
|
+
applicable_characteristics: report.characteristic_results.filter((item) => item.status !== "tailored_out").length,
|
|
171
|
+
accepted_characteristics: report.characteristic_results.filter((item) => item.status === "accepted").length,
|
|
172
|
+
requirements_total: report.requirement_results.length,
|
|
173
|
+
requirements_passed: report.requirement_results.filter((item) => item.passed).length,
|
|
174
|
+
tests_total: report.test_results.length,
|
|
175
|
+
tests_passed: report.test_results.filter((item) => item.passed).length,
|
|
176
|
+
risks_total: report.risk_results.length,
|
|
177
|
+
risks_controlled: report.risk_results.filter((item) => item.controlled).length,
|
|
178
|
+
stages_total: report.stage_results.length,
|
|
179
|
+
stages_completed: report.stage_results.filter((item) => item.passed).length,
|
|
180
|
+
errors,
|
|
181
|
+
warnings,
|
|
182
|
+
};
|
|
183
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
184
|
+
if (report.summary[key] !== value) {
|
|
185
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["summary", key], message: `汇总值应为 ${value}` });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if ((errors === 0) !== (report.verdict === "accepted")) {
|
|
189
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["verdict"], message: "verdict 必须与 error finding 一致" });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
export const AcceptanceReportManifestSchema = z
|
|
193
|
+
.object({
|
|
194
|
+
schema_version: z.literal(ACCEPTANCE_SCHEMA_VERSION),
|
|
195
|
+
report_id: IdSchema,
|
|
196
|
+
generated_at: TimestampSchema,
|
|
197
|
+
json_sha256: Sha256Schema,
|
|
198
|
+
markdown_sha256: Sha256Schema,
|
|
199
|
+
})
|
|
200
|
+
.strict();
|
|
201
|
+
function md(value) {
|
|
202
|
+
return String(value ?? "").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
|
|
203
|
+
}
|
|
204
|
+
function list(values) {
|
|
205
|
+
return values.length === 0 ? "—" : values.map(md).join(", ");
|
|
206
|
+
}
|
|
207
|
+
export function renderAcceptanceReportMarkdown(input) {
|
|
208
|
+
const report = AcceptanceReportSchema.parse(input);
|
|
209
|
+
const lines = [
|
|
210
|
+
`# 产品质量正式评价报告:${report.product}`,
|
|
211
|
+
"",
|
|
212
|
+
`- 报告 ID:${report.report_id}`,
|
|
213
|
+
`- 评价 ID:${report.evaluation_id}`,
|
|
214
|
+
`- 基线:${report.baseline.id}@${report.baseline.version}`,
|
|
215
|
+
`- 基线 SHA-256:\`${report.baseline.fingerprint_sha256}\``,
|
|
216
|
+
`- 基线审批:${report.baseline.approval.status}`,
|
|
217
|
+
`- 评价人:${report.evaluator.name}(${report.evaluator.organization},${report.evaluator.role})`,
|
|
218
|
+
`- 独立评价:${report.evaluator.independent ? "是" : "否"}`,
|
|
219
|
+
`- 报告生成时间:${report.report_generated_at}`,
|
|
220
|
+
`- 最终结论:**${report.verdict === "accepted" ? "通过" : "不通过"}**`,
|
|
221
|
+
"",
|
|
222
|
+
"## 适用标准",
|
|
223
|
+
"",
|
|
224
|
+
...report.standard_basis.map((standard) => `- ${standard}`),
|
|
225
|
+
"",
|
|
226
|
+
"## 评价摘要",
|
|
227
|
+
"",
|
|
228
|
+
"| 项目 | 通过 / 总数 |",
|
|
229
|
+
"|---|---:|",
|
|
230
|
+
`| ISO/IEC 25010 适用质量特性 | ${report.summary.accepted_characteristics} / ${report.summary.applicable_characteristics} |`,
|
|
231
|
+
`| 量化质量需求 | ${report.summary.requirements_passed} / ${report.summary.requirements_total} |`,
|
|
232
|
+
`| 验收测试 | ${report.summary.tests_passed} / ${report.summary.tests_total} |`,
|
|
233
|
+
`| 残余风险受控 | ${report.summary.risks_controlled} / ${report.summary.risks_total} |`,
|
|
234
|
+
`| ISO/IEC 25040 评价阶段 | ${report.summary.stages_completed} / ${report.summary.stages_total} |`,
|
|
235
|
+
`| 错误 / 警告 | ${report.summary.errors} / ${report.summary.warnings} |`,
|
|
236
|
+
"",
|
|
237
|
+
"## ISO/IEC 25040 阶段记录",
|
|
238
|
+
"",
|
|
239
|
+
"| 阶段 | 状态 | 证据 | 结论 |",
|
|
240
|
+
"|---|---|---|---|",
|
|
241
|
+
...report.stage_results.map((item) => `| ${item.stage} | ${item.status} | ${list(item.evidence_ids)} | ${md(item.result_note)} |`),
|
|
242
|
+
"",
|
|
243
|
+
"## ISO/IEC 25010 质量特性",
|
|
244
|
+
"",
|
|
245
|
+
"| 特性 | 状态 | 需求 | 裁剪理由 |",
|
|
246
|
+
"|---|---|---|---|",
|
|
247
|
+
...report.characteristic_results.map((item) => `| ${item.id} | ${item.status} | ${list(item.requirement_ids)} | ${md(item.tailoring_reason ?? "—")} |`),
|
|
248
|
+
"",
|
|
249
|
+
"## 量化需求结果",
|
|
250
|
+
"",
|
|
251
|
+
"| 需求 | 特性 | 指标 | 阈值 | 实测 | 冻结数据源 | 结果 | 证据 |",
|
|
252
|
+
"|---|---|---|---:|---:|---|---|---|",
|
|
253
|
+
...report.requirement_results.map((item) => {
|
|
254
|
+
const operator = item.direction === "at_least" ? ">=" : item.direction === "at_most" ? "<=" : "=";
|
|
255
|
+
return `| ${item.id} | ${item.characteristic} | ${md(item.metric_name)} (${md(item.unit)}) | ${operator} ${item.threshold} | ${item.observed_value ?? "—"} | ${item.measurement_source.evidence_id}#${md(item.measurement_source.json_pointer)} | ${item.passed ? "通过" : "不通过"} | ${list(item.evidence_ids)} |`;
|
|
256
|
+
}),
|
|
257
|
+
"",
|
|
258
|
+
"## 验收测试与冻结断言",
|
|
259
|
+
"",
|
|
260
|
+
"| 测试 | 验证模式 | 冻结断言 | 状态 | 结果 | 证据 |",
|
|
261
|
+
"|---|---|---|---|---|---|",
|
|
262
|
+
...report.test_results.map((item) => {
|
|
263
|
+
const assertion = item.assertion
|
|
264
|
+
? `${item.assertion.evidence_id}#${item.assertion.json_pointer} ${item.assertion.operator} ${JSON.stringify(item.assertion.expected)}`
|
|
265
|
+
: "人工评价";
|
|
266
|
+
return `| ${item.id} | ${item.verification_mode} | ${md(assertion)} | ${item.status} | ${item.passed ? "通过" : "不通过"} | ${list(item.evidence_ids)} |`;
|
|
267
|
+
}),
|
|
268
|
+
"",
|
|
269
|
+
"## 需求—风险—测试—证据追踪矩阵",
|
|
270
|
+
"",
|
|
271
|
+
"| 需求 | 风险 | 测试 | 证据 | 完整 |",
|
|
272
|
+
"|---|---|---|---|---|",
|
|
273
|
+
...report.traceability_matrix.map((item) => `| ${item.requirement_id} | ${list(item.risk_ids)} | ${list(item.test_ids)} | ${list(item.evidence_ids)} | ${item.complete ? "是" : "否"} |`),
|
|
274
|
+
"",
|
|
275
|
+
"## 残余风险",
|
|
276
|
+
"",
|
|
277
|
+
"| 风险 | Owner | 可能性 | 影响 | 固有等级 | 补偿控制 | 残余等级 | 处置 | 接受人 | 受控 | 证据 |",
|
|
278
|
+
"|---|---|---|---|---|---|---|---|---|---|---|",
|
|
279
|
+
...report.risk_results.map((item) => `| ${item.id} | ${md(item.owner)} | ${item.likelihood} | ${item.impact} | ${item.inherent_level} | ${list(item.compensating_controls)} | ${item.residual_level ?? "—"} | ${item.disposition} | ${md(item.accepted_by ?? "—")} | ${item.controlled ? "是" : "否"} | ${list(item.evidence_ids)} |`),
|
|
280
|
+
"",
|
|
281
|
+
"## 发现项",
|
|
282
|
+
"",
|
|
283
|
+
];
|
|
284
|
+
if (report.findings.length === 0) {
|
|
285
|
+
lines.push("无阻断项或警告。", "");
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
lines.push("| 严重度 | 代码 | 对象 | 说明 |", "|---|---|---|---|", ...report.findings.map((item) => `| ${item.severity} | ${item.code} | ${md(item.subject)} | ${md(item.message)} |`), "");
|
|
289
|
+
}
|
|
290
|
+
lines.push("## 正式结论", "", report.conclusion, "");
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
export function acceptanceReportPaths(root, reportId) {
|
|
294
|
+
const safe = IdSchema.parse(reportId);
|
|
295
|
+
const dir = pmPath(path.resolve(root), "acceptance", "reports");
|
|
296
|
+
return {
|
|
297
|
+
json_file: path.join(dir, `${safe}.json`),
|
|
298
|
+
markdown_file: path.join(dir, `${safe}.md`),
|
|
299
|
+
manifest_file: path.join(dir, `${safe}.sha256.json`),
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function sha256(content) {
|
|
303
|
+
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
304
|
+
}
|
|
305
|
+
export function writeAcceptanceReport(root, input) {
|
|
306
|
+
const report = AcceptanceReportSchema.parse(input);
|
|
307
|
+
const files = acceptanceReportPaths(root, report.report_id);
|
|
308
|
+
const jsonContent = JSON.stringify(report, null, 2) + "\n";
|
|
309
|
+
const markdownContent = renderAcceptanceReportMarkdown(report);
|
|
310
|
+
const manifest = AcceptanceReportManifestSchema.parse({
|
|
311
|
+
schema_version: ACCEPTANCE_SCHEMA_VERSION,
|
|
312
|
+
report_id: report.report_id,
|
|
313
|
+
generated_at: report.report_generated_at,
|
|
314
|
+
json_sha256: sha256(jsonContent),
|
|
315
|
+
markdown_sha256: sha256(markdownContent),
|
|
316
|
+
});
|
|
317
|
+
const manifestContent = JSON.stringify(manifest, null, 2) + "\n";
|
|
318
|
+
return withLedgerLock(path.resolve(root), () => {
|
|
319
|
+
fs.mkdirSync(path.dirname(files.json_file), { recursive: true });
|
|
320
|
+
const existing = [files.json_file, files.markdown_file, files.manifest_file].filter((file) => fs.existsSync(file));
|
|
321
|
+
if (existing.length > 0) {
|
|
322
|
+
const allMatch = existing.length === 3
|
|
323
|
+
&& fs.readFileSync(files.json_file, "utf8") === jsonContent
|
|
324
|
+
&& fs.readFileSync(files.markdown_file, "utf8") === markdownContent
|
|
325
|
+
&& fs.readFileSync(files.manifest_file, "utf8") === manifestContent;
|
|
326
|
+
if (!allMatch)
|
|
327
|
+
throw new Error(`正式验收报告不可覆盖或修补(三件套):${report.report_id};请使用新的 report_id。`);
|
|
328
|
+
return files;
|
|
329
|
+
}
|
|
330
|
+
atomicWrite(files.json_file, jsonContent);
|
|
331
|
+
atomicWrite(files.markdown_file, markdownContent);
|
|
332
|
+
atomicWrite(files.manifest_file, manifestContent);
|
|
333
|
+
return files;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { AcceptanceBaselineSchema, AcceptanceEvaluationSchema, JsonPointerSchema, acceptanceBaselinePath, loadAcceptanceBaseline, saveAcceptanceBaseline, } from "./acceptance-model.js";
|
|
6
|
+
import { acceptanceBaselineFingerprint, evaluateAcceptance } from "./acceptance-evaluator.js";
|
|
7
|
+
import { writeAcceptanceReport } from "./acceptance-report.js";
|
|
8
|
+
import { requireInitialized } from "./paths.js";
|
|
9
|
+
import { loadJson, saveJson, withLedgerLock } from "./store.js";
|
|
10
|
+
import { tool } from "./tool-base.js";
|
|
11
|
+
import { refreshDerived } from "./dashboard.js";
|
|
12
|
+
const IdSchema = z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
|
|
13
|
+
const SemVerSchema = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/);
|
|
14
|
+
const JsonPathSchema = z.string().trim().min(1).max(1_000);
|
|
15
|
+
const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/i);
|
|
16
|
+
function inside(parent, child) {
|
|
17
|
+
const relative = path.relative(parent, child);
|
|
18
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
19
|
+
}
|
|
20
|
+
export function acceptanceDataRoot(root) {
|
|
21
|
+
return path.resolve(root, ".pm", "acceptance");
|
|
22
|
+
}
|
|
23
|
+
/** Resolve an existing ordinary file without permitting lexical or symlink escape. */
|
|
24
|
+
export function resolveAcceptanceFile(root, locator, requireJson = false) {
|
|
25
|
+
const projectRoot = path.resolve(root);
|
|
26
|
+
const allowedRoot = acceptanceDataRoot(projectRoot);
|
|
27
|
+
const candidate = path.resolve(projectRoot, locator);
|
|
28
|
+
if (!inside(allowedRoot, candidate))
|
|
29
|
+
throw new Error(`路径必须位于项目内 .pm/acceptance:${locator}`);
|
|
30
|
+
if (requireJson && path.extname(candidate).toLowerCase() !== ".json")
|
|
31
|
+
throw new Error(`只允许读取 JSON 文件:${locator}`);
|
|
32
|
+
if (!fs.existsSync(allowedRoot))
|
|
33
|
+
throw new Error(`验收目录不存在:${allowedRoot}`);
|
|
34
|
+
if (!fs.existsSync(candidate))
|
|
35
|
+
throw new Error(`文件不存在:${locator}`);
|
|
36
|
+
if (fs.lstatSync(candidate).isSymbolicLink())
|
|
37
|
+
throw new Error(`不允许使用符号链接作为验收文件:${locator}`);
|
|
38
|
+
const realAllowedRoot = fs.realpathSync(allowedRoot);
|
|
39
|
+
const realCandidate = fs.realpathSync(candidate);
|
|
40
|
+
if (!inside(realAllowedRoot, realCandidate))
|
|
41
|
+
throw new Error(`文件真实路径逃逸 .pm/acceptance:${locator}`);
|
|
42
|
+
if (!fs.statSync(realCandidate).isFile())
|
|
43
|
+
throw new Error(`验收路径必须是普通文件:${locator}`);
|
|
44
|
+
return realCandidate;
|
|
45
|
+
}
|
|
46
|
+
export function readAcceptanceJson(root, locator, schema) {
|
|
47
|
+
const file = resolveAcceptanceFile(root, locator, true);
|
|
48
|
+
const parsed = loadJson(file, schema);
|
|
49
|
+
if (parsed === undefined)
|
|
50
|
+
throw new Error(`文件不存在:${locator}`);
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
export function sha256AcceptanceFile(file) {
|
|
54
|
+
const digest = createHash("sha256");
|
|
55
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
56
|
+
const handle = fs.openSync(file, "r");
|
|
57
|
+
try {
|
|
58
|
+
const before = fs.fstatSync(handle);
|
|
59
|
+
let offset = 0;
|
|
60
|
+
while (offset < before.size) {
|
|
61
|
+
const read = fs.readSync(handle, buffer, 0, Math.min(buffer.length, before.size - offset), offset);
|
|
62
|
+
if (read === 0)
|
|
63
|
+
break;
|
|
64
|
+
digest.update(buffer.subarray(0, read));
|
|
65
|
+
offset += read;
|
|
66
|
+
}
|
|
67
|
+
const after = fs.fstatSync(handle);
|
|
68
|
+
if (offset !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
69
|
+
throw new Error(`证据文件在摘要计算期间发生变化:${file}`);
|
|
70
|
+
}
|
|
71
|
+
return digest.digest("hex");
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
fs.closeSync(handle);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function verifyEvaluationEvidence(root, evaluation) {
|
|
78
|
+
for (const evidence of evaluation.evidence) {
|
|
79
|
+
const file = resolveAcceptanceFile(root, evidence.locator, false);
|
|
80
|
+
const actual = sha256AcceptanceFile(file);
|
|
81
|
+
if (actual.toLowerCase() !== evidence.sha256.toLowerCase()) {
|
|
82
|
+
throw new Error(`证据摘要不一致:${evidence.id} 声明 ${evidence.sha256},实际 ${actual}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const FORBIDDEN_POINTER_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
|
|
87
|
+
/** Safe RFC 6901 resolver: own properties only, canonical array indexes only. */
|
|
88
|
+
export function resolveJsonPointer(document, pointerInput) {
|
|
89
|
+
const pointer = JsonPointerSchema.parse(pointerInput);
|
|
90
|
+
if (pointer === "")
|
|
91
|
+
return document;
|
|
92
|
+
let current = document;
|
|
93
|
+
for (const encoded of pointer.slice(1).split("/")) {
|
|
94
|
+
const token = encoded.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
95
|
+
if (FORBIDDEN_POINTER_SEGMENTS.has(token))
|
|
96
|
+
throw new Error(`JSON Pointer 包含禁止字段:${token}`);
|
|
97
|
+
if (Array.isArray(current)) {
|
|
98
|
+
if (!/^(0|[1-9]\d*)$/.test(token))
|
|
99
|
+
throw new Error(`JSON Pointer 数组索引非法:${token}`);
|
|
100
|
+
const index = Number(token);
|
|
101
|
+
if (!Number.isSafeInteger(index) || index >= current.length)
|
|
102
|
+
throw new Error(`JSON Pointer 数组越界:${token}`);
|
|
103
|
+
current = current[index];
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (current !== null && typeof current === "object") {
|
|
107
|
+
if (!Object.prototype.hasOwnProperty.call(current, token))
|
|
108
|
+
throw new Error(`JSON Pointer 字段不存在:${token}`);
|
|
109
|
+
current = current[token];
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`JSON Pointer 无法继续解析:${token}`);
|
|
113
|
+
}
|
|
114
|
+
return current;
|
|
115
|
+
}
|
|
116
|
+
function readVerifiedJsonEvidence(root, evidence) {
|
|
117
|
+
const file = resolveAcceptanceFile(root, evidence.locator, false);
|
|
118
|
+
const bytes = fs.readFileSync(file);
|
|
119
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
120
|
+
if (digest.toLowerCase() !== evidence.sha256.toLowerCase()) {
|
|
121
|
+
throw new Error(`证据摘要不一致:${evidence.id} 声明 ${evidence.sha256},实际 ${digest}`);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
throw new Error(`机器断言证据不是合法 JSON:${evidence.id}: ${error.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function machineAssertionPassed(actual, assertion) {
|
|
131
|
+
if (assertion.operator === "at_least" || assertion.operator === "at_most") {
|
|
132
|
+
if (typeof actual !== "number" || !Number.isFinite(actual) || typeof assertion.expected !== "number") {
|
|
133
|
+
throw new Error(`${assertion.operator} 断言的实际值和期望值必须是有限数字`);
|
|
134
|
+
}
|
|
135
|
+
return assertion.operator === "at_least" ? actual >= assertion.expected : actual <= assertion.expected;
|
|
136
|
+
}
|
|
137
|
+
const equal = Object.is(actual, assertion.expected);
|
|
138
|
+
return assertion.operator === "equal" ? equal : !equal;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Reconstruct observed values and automated test outcomes from the approved
|
|
142
|
+
* baseline's frozen JSON evidence pointers. Evaluation fields are assertions
|
|
143
|
+
* about evidence, never an alternative source of truth.
|
|
144
|
+
*/
|
|
145
|
+
export function verifyFrozenAcceptanceAssertions(root, baseline, evaluation) {
|
|
146
|
+
const evidenceById = new Map(evaluation.evidence.map((item) => [item.id, item]));
|
|
147
|
+
const measurementById = new Map(evaluation.measurements.map((item) => [item.requirement_id, item]));
|
|
148
|
+
const testResultById = new Map(evaluation.test_results.map((item) => [item.test_id, item]));
|
|
149
|
+
const documents = new Map();
|
|
150
|
+
const documentFor = (evidenceId) => {
|
|
151
|
+
const evidence = evidenceById.get(evidenceId);
|
|
152
|
+
if (!evidence)
|
|
153
|
+
throw new Error(`基线冻结的机器证据不存在:${evidenceId}`);
|
|
154
|
+
if (!documents.has(evidenceId))
|
|
155
|
+
documents.set(evidenceId, readVerifiedJsonEvidence(root, evidence));
|
|
156
|
+
return documents.get(evidenceId);
|
|
157
|
+
};
|
|
158
|
+
for (const requirement of baseline.requirements) {
|
|
159
|
+
const measurement = measurementById.get(requirement.id);
|
|
160
|
+
if (!measurement)
|
|
161
|
+
throw new Error(`缺少基线需求的测量结果:${requirement.id}`);
|
|
162
|
+
const source = requirement.measurement_source;
|
|
163
|
+
if (!measurement.evidence_ids.includes(source.evidence_id)) {
|
|
164
|
+
throw new Error(`测量 ${requirement.id} 未引用冻结证据 ${source.evidence_id}`);
|
|
165
|
+
}
|
|
166
|
+
const actual = resolveJsonPointer(documentFor(source.evidence_id), source.json_pointer);
|
|
167
|
+
if (typeof actual !== "number" || !Number.isFinite(actual)) {
|
|
168
|
+
throw new Error(`测量源 ${requirement.id}${source.json_pointer} 不是有限数字`);
|
|
169
|
+
}
|
|
170
|
+
if (!Object.is(actual, measurement.observed_value)) {
|
|
171
|
+
throw new Error(`伪造或过期测量值:${requirement.id} 填写 ${measurement.observed_value},证据实际 ${actual}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const definition of baseline.tests) {
|
|
175
|
+
if (definition.verification_mode === "manual")
|
|
176
|
+
continue;
|
|
177
|
+
const assertion = definition.assertion;
|
|
178
|
+
const result = testResultById.get(definition.id);
|
|
179
|
+
if (!result)
|
|
180
|
+
throw new Error(`缺少基线测试结果:${definition.id}`);
|
|
181
|
+
if (!result.evidence_ids.includes(assertion.evidence_id)) {
|
|
182
|
+
throw new Error(`测试 ${definition.id} 未引用冻结断言证据 ${assertion.evidence_id}`);
|
|
183
|
+
}
|
|
184
|
+
const actual = resolveJsonPointer(documentFor(assertion.evidence_id), assertion.json_pointer);
|
|
185
|
+
const passed = machineAssertionPassed(actual, assertion);
|
|
186
|
+
const expectedStatus = passed ? "passed" : "failed";
|
|
187
|
+
if (result.status !== expectedStatus) {
|
|
188
|
+
throw new Error(`伪造或不一致测试状态:${definition.id} 填写 ${result.status},机器断言结果为 ${expectedStatus}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export function listAcceptanceBaselines(root) {
|
|
193
|
+
const base = path.join(acceptanceDataRoot(root), "baselines");
|
|
194
|
+
if (!fs.existsSync(base))
|
|
195
|
+
return [];
|
|
196
|
+
const summaries = [];
|
|
197
|
+
for (const idEntry of fs.readdirSync(base, { withFileTypes: true })) {
|
|
198
|
+
if (!idEntry.isDirectory() || idEntry.isSymbolicLink())
|
|
199
|
+
continue;
|
|
200
|
+
const id = IdSchema.parse(idEntry.name);
|
|
201
|
+
const idDir = path.join(base, idEntry.name);
|
|
202
|
+
for (const versionEntry of fs.readdirSync(idDir, { withFileTypes: true })) {
|
|
203
|
+
if (!versionEntry.isFile() || path.extname(versionEntry.name).toLowerCase() !== ".json")
|
|
204
|
+
continue;
|
|
205
|
+
const version = SemVerSchema.parse(path.basename(versionEntry.name, ".json"));
|
|
206
|
+
const value = loadAcceptanceBaseline(root, id, version);
|
|
207
|
+
summaries.push({
|
|
208
|
+
id: value.baseline_id,
|
|
209
|
+
version: value.baseline_version,
|
|
210
|
+
title: value.title,
|
|
211
|
+
status: value.approval.status,
|
|
212
|
+
fingerprint_sha256: acceptanceBaselineFingerprint(value),
|
|
213
|
+
requirements: value.requirements.length,
|
|
214
|
+
risks: value.risks.length,
|
|
215
|
+
tests: value.tests.length,
|
|
216
|
+
file: path.relative(path.resolve(root), acceptanceBaselinePath(root, id, version)).replace(/\\/g, "/"),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return summaries.sort((left, right) => `${left.id}@${left.version}`.localeCompare(`${right.id}@${right.version}`, "en"));
|
|
221
|
+
}
|
|
222
|
+
export function saveDraftAcceptanceBaselineFile(root, locator) {
|
|
223
|
+
const baseline = readAcceptanceJson(root, locator, AcceptanceBaselineSchema);
|
|
224
|
+
if (baseline.approval.status !== "draft")
|
|
225
|
+
throw new Error("save_acceptance_baseline_draft 只接受 approval.status=draft 的基线。");
|
|
226
|
+
return saveAcceptanceBaseline(root, baseline);
|
|
227
|
+
}
|
|
228
|
+
/** Atomic compare-fingerprint-and-approve transaction; never nests the model lock. */
|
|
229
|
+
export function approveAcceptanceBaseline(root, input) {
|
|
230
|
+
const parsed = {
|
|
231
|
+
baseline_id: IdSchema.parse(input.baseline_id),
|
|
232
|
+
baseline_version: SemVerSchema.parse(input.baseline_version),
|
|
233
|
+
expected_fingerprint_sha256: Sha256Schema.parse(input.expected_fingerprint_sha256).toLowerCase(),
|
|
234
|
+
approved_by: z.string().trim().min(1).parse(input.approved_by),
|
|
235
|
+
approval_rationale: z.string().trim().min(8).parse(input.approval_rationale),
|
|
236
|
+
approved_at: input.approved_at ? z.string().datetime({ offset: true }).parse(input.approved_at) : new Date().toISOString(),
|
|
237
|
+
};
|
|
238
|
+
return withLedgerLock(path.resolve(root), () => {
|
|
239
|
+
const current = loadAcceptanceBaseline(root, parsed.baseline_id, parsed.baseline_version);
|
|
240
|
+
if (current.approval.status !== "draft")
|
|
241
|
+
throw new Error(`只有 draft 基线可批准;当前状态为 ${current.approval.status}。`);
|
|
242
|
+
const fingerprint = acceptanceBaselineFingerprint(current);
|
|
243
|
+
if (fingerprint.toLowerCase() !== parsed.expected_fingerprint_sha256) {
|
|
244
|
+
throw new Error(`基线 fingerprint 已变化:期望 ${parsed.expected_fingerprint_sha256},当前 ${fingerprint};请重新审阅,修改内容必须升版。`);
|
|
245
|
+
}
|
|
246
|
+
if (Date.parse(parsed.approved_at) < Date.parse(current.created_at))
|
|
247
|
+
throw new Error("批准时间不得早于基线创建时间。");
|
|
248
|
+
const approved = AcceptanceBaselineSchema.parse({
|
|
249
|
+
...current,
|
|
250
|
+
approval: {
|
|
251
|
+
status: "approved",
|
|
252
|
+
approved_by: parsed.approved_by,
|
|
253
|
+
approved_at: parsed.approved_at,
|
|
254
|
+
rationale: parsed.approval_rationale,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
saveJson(acceptanceBaselinePath(root, approved.baseline_id, approved.baseline_version), approved);
|
|
258
|
+
return approved;
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
export function evaluateAcceptanceFile(root, input) {
|
|
262
|
+
const baseline = loadAcceptanceBaseline(root, IdSchema.parse(input.baseline_id), SemVerSchema.parse(input.baseline_version));
|
|
263
|
+
const evaluation = readAcceptanceJson(root, input.evaluation_file, AcceptanceEvaluationSchema);
|
|
264
|
+
verifyEvaluationEvidence(root, evaluation);
|
|
265
|
+
verifyFrozenAcceptanceAssertions(root, baseline, evaluation);
|
|
266
|
+
const report = evaluateAcceptance(baseline, evaluation);
|
|
267
|
+
const files = writeAcceptanceReport(root, report);
|
|
268
|
+
refreshDerived(root);
|
|
269
|
+
return { report, ...files };
|
|
270
|
+
}
|
|
271
|
+
export function registerAcceptanceTools(server, root) {
|
|
272
|
+
tool(server, "list_acceptance_baselines", "列出版本化验收基线、审批状态、当前 fingerprint 和规模。", {}, () => {
|
|
273
|
+
requireInitialized(root);
|
|
274
|
+
return JSON.stringify(listAcceptanceBaselines(root), null, 2);
|
|
275
|
+
});
|
|
276
|
+
tool(server, "get_acceptance_baseline", "读取一个验收基线;默认返回摘要,full=true 返回严格 JSON。", {
|
|
277
|
+
baseline_id: IdSchema,
|
|
278
|
+
baseline_version: SemVerSchema,
|
|
279
|
+
full: z.boolean().optional(),
|
|
280
|
+
}, (args) => {
|
|
281
|
+
requireInitialized(root);
|
|
282
|
+
const baseline = loadAcceptanceBaseline(root, args.baseline_id, args.baseline_version);
|
|
283
|
+
if (args.full)
|
|
284
|
+
return JSON.stringify(baseline, null, 2);
|
|
285
|
+
return JSON.stringify(listAcceptanceBaselines(root).find((item) => item.id === args.baseline_id && item.version === args.baseline_version), null, 2);
|
|
286
|
+
});
|
|
287
|
+
tool(server, "save_acceptance_baseline_draft", "从项目内 .pm/acceptance 下的严格 JSON 保存 draft 基线;批准后的同版本不可覆盖。", {
|
|
288
|
+
baseline_file: JsonPathSchema,
|
|
289
|
+
}, (args) => {
|
|
290
|
+
requireInitialized(root);
|
|
291
|
+
const baseline = saveDraftAcceptanceBaselineFile(root, args.baseline_file);
|
|
292
|
+
return `✅ draft ${baseline.baseline_id}@${baseline.baseline_version} 已保存;fingerprint=${acceptanceBaselineFingerprint(baseline)}`;
|
|
293
|
+
});
|
|
294
|
+
tool(server, "approve_acceptance_baseline", "按当前 SHA-256 fingerprint 原子批准基线;fingerprint 变化即拒绝,批准后修改必须升版。", {
|
|
295
|
+
baseline_id: IdSchema,
|
|
296
|
+
baseline_version: SemVerSchema,
|
|
297
|
+
expected_fingerprint_sha256: Sha256Schema,
|
|
298
|
+
approved_by: z.string().trim().min(1),
|
|
299
|
+
approval_rationale: z.string().trim().min(8),
|
|
300
|
+
approved_at: z.string().datetime({ offset: true }).optional(),
|
|
301
|
+
}, (args) => {
|
|
302
|
+
requireInitialized(root);
|
|
303
|
+
const approved = approveAcceptanceBaseline(root, args);
|
|
304
|
+
return `✅ baseline ${approved.baseline_id}@${approved.baseline_version} 已批准;approved fingerprint=${acceptanceBaselineFingerprint(approved)}`;
|
|
305
|
+
});
|
|
306
|
+
tool(server, "evaluate_acceptance", "读取项目内 .pm/acceptance evaluation JSON,逐项复算证据 SHA-256,再生成不可手填、不可覆盖的正式报告。", {
|
|
307
|
+
baseline_id: IdSchema,
|
|
308
|
+
baseline_version: SemVerSchema,
|
|
309
|
+
evaluation_file: JsonPathSchema,
|
|
310
|
+
}, (args) => {
|
|
311
|
+
requireInitialized(root);
|
|
312
|
+
const result = evaluateAcceptanceFile(root, args);
|
|
313
|
+
const relativeJson = path.relative(path.resolve(root), result.json_file).replace(/\\/g, "/");
|
|
314
|
+
const relativeMarkdown = path.relative(path.resolve(root), result.markdown_file).replace(/\\/g, "/");
|
|
315
|
+
const relativeManifest = path.relative(path.resolve(root), result.manifest_file).replace(/\\/g, "/");
|
|
316
|
+
return `${result.report.verdict === "accepted" ? "✅ PASS" : "🚩 FAIL"} ${result.report.report_id} · errors=${result.report.summary.errors} · JSON=${relativeJson} · Markdown=${relativeMarkdown} · SHA256=${relativeManifest}`;
|
|
317
|
+
});
|
|
318
|
+
}
|