skill-family-harness-node 0.1.2 → 0.2.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/CONTRIBUTING.md +13 -67
- package/README.md +20 -4
- package/SECURITY.md +4 -9
- package/package.json +4 -4
- package/src/errors.mjs +16 -0
- package/src/host.mjs +245 -0
- package/src/index.mjs +51 -0
- package/src/report.mjs +618 -0
- package/src/state-store.mjs +1050 -0
package/src/report.mjs
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
canonicalJson,
|
|
4
|
+
digestDocument,
|
|
5
|
+
findSchemaByObject,
|
|
6
|
+
isRegisteredErrorCode,
|
|
7
|
+
} from "skill-family-contracts";
|
|
8
|
+
import { digestBytes } from "./closure.mjs";
|
|
9
|
+
import { HARNESS_ERROR_KINDS, mechanismError } from "./errors.mjs";
|
|
10
|
+
import { validateContractDocument } from "./validation.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic report mechanism (FND-ADR-005 / FND-DES-004).
|
|
14
|
+
*
|
|
15
|
+
* The caller owns every report fact and submits a registered report-model.
|
|
16
|
+
* Harness only validates that model, verifies its source binding, renders
|
|
17
|
+
* neutral Markdown, and checks the rendered bytes. It never interprets an
|
|
18
|
+
* operation's outputs, derives an execution status, or fills missing facts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PACKAGE_META = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
22
|
+
|
|
23
|
+
export const REPORT_RENDERER_NAME = "skill-family-harness-node/report";
|
|
24
|
+
export const REPORT_RENDERER_VERSION = PACKAGE_META.version;
|
|
25
|
+
export const SUPPORTED_REPORT_LOCALES = Object.freeze(["zh-CN", "en-US"]);
|
|
26
|
+
export const EXECUTION_STATUSES = Object.freeze([
|
|
27
|
+
"SUCCEEDED",
|
|
28
|
+
"SUCCEEDED_WITH_WARNINGS",
|
|
29
|
+
"FAILED",
|
|
30
|
+
"BLOCKED",
|
|
31
|
+
"NEEDS_INPUT",
|
|
32
|
+
"SKIPPED",
|
|
33
|
+
"CANCELLED",
|
|
34
|
+
]);
|
|
35
|
+
export const REPORT_AUDIENCES = Object.freeze(["operator", "reviewer", "consumer"]);
|
|
36
|
+
export const RESULT_STATE_EXECUTION_STATUSES = Object.freeze({
|
|
37
|
+
succeeded: Object.freeze(["SUCCEEDED", "SUCCEEDED_WITH_WARNINGS"]),
|
|
38
|
+
failed: Object.freeze(["FAILED"]),
|
|
39
|
+
rejected: Object.freeze(["BLOCKED", "NEEDS_INPUT", "SKIPPED", "CANCELLED"]),
|
|
40
|
+
});
|
|
41
|
+
export const REPORT_STYLE_RULES = Object.freeze([
|
|
42
|
+
"sentence-too-long",
|
|
43
|
+
"duplicate-paragraph",
|
|
44
|
+
"translationese",
|
|
45
|
+
"unexplained-term",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
const SENTENCE_MAX_CHARS = 80;
|
|
49
|
+
const TRANSLATESE_ZH = Object.freeze([
|
|
50
|
+
"进行一个",
|
|
51
|
+
"的一个情况",
|
|
52
|
+
"被完成",
|
|
53
|
+
"值得注意的是",
|
|
54
|
+
"就我们而言",
|
|
55
|
+
]);
|
|
56
|
+
const TRANSLATESE_EN = Object.freeze([
|
|
57
|
+
"in order to",
|
|
58
|
+
"it is important to note",
|
|
59
|
+
"leverage",
|
|
60
|
+
"utilize",
|
|
61
|
+
]);
|
|
62
|
+
const UNEXPLAINED_TERMS = Object.freeze(["SPI", "DSL", "SDK"]);
|
|
63
|
+
|
|
64
|
+
const REPORT_MODEL_SCHEMA_ID = findSchemaByObject("report-model").$id;
|
|
65
|
+
const REPORT_BINDING_SCHEMA_ID = findSchemaByObject("report-binding").$id;
|
|
66
|
+
const OPERATION_RESULT_SCHEMA_ID = findSchemaByObject("operation-result").$id;
|
|
67
|
+
|
|
68
|
+
const TEXT = Object.freeze({
|
|
69
|
+
"zh-CN": {
|
|
70
|
+
title: "运行报告",
|
|
71
|
+
audience: "读者",
|
|
72
|
+
stage: "阶段",
|
|
73
|
+
locale: "语言",
|
|
74
|
+
timeRange: "时间范围",
|
|
75
|
+
status: "执行状态",
|
|
76
|
+
source: "机器结果摘要(sha256)",
|
|
77
|
+
sourceKind: "机器结果类型",
|
|
78
|
+
renderer: "渲染器",
|
|
79
|
+
blocking: "阻塞原因",
|
|
80
|
+
reference: "引用",
|
|
81
|
+
note: "备注",
|
|
82
|
+
errorPath: "位置",
|
|
83
|
+
errorDetails: "详情",
|
|
84
|
+
sections: {
|
|
85
|
+
conclusion: "结论",
|
|
86
|
+
errors: "错误",
|
|
87
|
+
changes: "变更",
|
|
88
|
+
evidence: "证据",
|
|
89
|
+
risks: "风险",
|
|
90
|
+
unresolved: "未决问题",
|
|
91
|
+
nextActions: "后续行动",
|
|
92
|
+
appendices: "附录",
|
|
93
|
+
},
|
|
94
|
+
empty: {
|
|
95
|
+
errors: "(机器结果未申报错误。)",
|
|
96
|
+
changes: "(本次运行无申报变更。)",
|
|
97
|
+
risks: "(本次运行未申报风险。)",
|
|
98
|
+
unresolved: "(本次运行无未决问题。)",
|
|
99
|
+
nextActions: "(本次运行无后续行动。)",
|
|
100
|
+
appendices: "(本次运行无附录。)",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"en-US": {
|
|
104
|
+
title: "Run Report",
|
|
105
|
+
audience: "Audience",
|
|
106
|
+
stage: "Stage",
|
|
107
|
+
locale: "Locale",
|
|
108
|
+
timeRange: "Time range",
|
|
109
|
+
status: "Execution status",
|
|
110
|
+
source: "Machine result digest (sha256)",
|
|
111
|
+
sourceKind: "Machine result kind",
|
|
112
|
+
renderer: "Renderer",
|
|
113
|
+
blocking: "Blocking reason",
|
|
114
|
+
reference: "ref",
|
|
115
|
+
note: "note",
|
|
116
|
+
errorPath: "path",
|
|
117
|
+
errorDetails: "details",
|
|
118
|
+
sections: {
|
|
119
|
+
conclusion: "Conclusion",
|
|
120
|
+
errors: "Errors",
|
|
121
|
+
changes: "Changes",
|
|
122
|
+
evidence: "Evidence",
|
|
123
|
+
risks: "Risks",
|
|
124
|
+
unresolved: "Unresolved",
|
|
125
|
+
nextActions: "Next Actions",
|
|
126
|
+
appendices: "Appendices",
|
|
127
|
+
},
|
|
128
|
+
empty: {
|
|
129
|
+
errors: "(The machine result declared no errors.)",
|
|
130
|
+
changes: "(No declared changes for this run.)",
|
|
131
|
+
risks: "(No declared risks for this run.)",
|
|
132
|
+
unresolved: "(No unresolved questions for this run.)",
|
|
133
|
+
nextActions: "(No follow-up actions for this run.)",
|
|
134
|
+
appendices: "(No appendices for this run.)",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
function isPlainObject(value) {
|
|
140
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hardFailure(code, message, details) {
|
|
144
|
+
return { code, message, details };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Converts untrusted prose to one Markdown text line. Newlines become visible
|
|
149
|
+
* escape sequences and every ASCII punctuation character is backslash-escaped,
|
|
150
|
+
* so headings, links, HTML, lists, block quotes and code spans remain literal.
|
|
151
|
+
*/
|
|
152
|
+
function literalMarkdown(value) {
|
|
153
|
+
return String(value)
|
|
154
|
+
.replace(/\\/g, "\\\\")
|
|
155
|
+
.replace(/\r\n|\r|\n/g, "\\n")
|
|
156
|
+
.replace(/[\u0000-\u001f\u007f]/g, (character) =>
|
|
157
|
+
`\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`)
|
|
158
|
+
.replace(/([!"#$%&'()*+,\-./:;<=>?@[\]^_`{|}~])/g, "\\$1");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function countFailures(model) {
|
|
162
|
+
const expected = {
|
|
163
|
+
changes: model.changes.length,
|
|
164
|
+
evidence: model.evidence.length,
|
|
165
|
+
risks: model.risks.length,
|
|
166
|
+
unresolved: model.unresolved.length,
|
|
167
|
+
nextActions: model.nextActions.length,
|
|
168
|
+
appendices: model.appendices.length,
|
|
169
|
+
};
|
|
170
|
+
const failures = [];
|
|
171
|
+
for (const [field, count] of Object.entries(expected)) {
|
|
172
|
+
if (model.outcome.counts[field] !== count) {
|
|
173
|
+
failures.push(hardFailure(
|
|
174
|
+
"SFC3003",
|
|
175
|
+
`report model count mismatch: ${field}`,
|
|
176
|
+
{ field: `outcome.counts.${field}`, expected: count, observed: model.outcome.counts[field] },
|
|
177
|
+
));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return failures;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validates one caller-authored report model. If resultDocument is supplied,
|
|
185
|
+
* source digest and the complete coded-error array must match it exactly.
|
|
186
|
+
*/
|
|
187
|
+
export function validateReportModel(reportModel, { resultDocument } = {}) {
|
|
188
|
+
const hardFailures = [];
|
|
189
|
+
if (!isPlainObject(reportModel)) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
hardFailures: [hardFailure("SFC3002", "missing or invalid report model", { element: "report-model" })],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const modelValidation = validateContractDocument(reportModel, {
|
|
197
|
+
schemaId: REPORT_MODEL_SCHEMA_ID,
|
|
198
|
+
policy: "strict",
|
|
199
|
+
});
|
|
200
|
+
if (!modelValidation.valid) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
hardFailures: [hardFailure(
|
|
204
|
+
"SFC3002",
|
|
205
|
+
"report model fails its registered contract",
|
|
206
|
+
{ element: "report-model", validationErrors: modelValidation.errors },
|
|
207
|
+
)],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
reportModel.renderer.name !== REPORT_RENDERER_NAME ||
|
|
213
|
+
reportModel.renderer.version !== REPORT_RENDERER_VERSION
|
|
214
|
+
) {
|
|
215
|
+
hardFailures.push(hardFailure(
|
|
216
|
+
"SFC3003",
|
|
217
|
+
"report model names a different renderer",
|
|
218
|
+
{
|
|
219
|
+
field: "renderer",
|
|
220
|
+
expected: { name: REPORT_RENDERER_NAME, version: REPORT_RENDERER_VERSION },
|
|
221
|
+
observed: reportModel.renderer,
|
|
222
|
+
},
|
|
223
|
+
));
|
|
224
|
+
}
|
|
225
|
+
hardFailures.push(...countFailures(reportModel));
|
|
226
|
+
for (const [index, entry] of reportModel.errors.entries()) {
|
|
227
|
+
if (!isRegisteredErrorCode(entry.code)) {
|
|
228
|
+
hardFailures.push(hardFailure(
|
|
229
|
+
"SFC3002",
|
|
230
|
+
"report model contains an unregistered machine error code",
|
|
231
|
+
{ element: "registered-error-code", index, code: entry.code },
|
|
232
|
+
));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (resultDocument !== undefined) {
|
|
237
|
+
const resultValidation = validateContractDocument(resultDocument, {
|
|
238
|
+
schemaId: OPERATION_RESULT_SCHEMA_ID,
|
|
239
|
+
policy: "strict",
|
|
240
|
+
});
|
|
241
|
+
if (!resultValidation.valid || !resultDocument.errors.every((entry) => isRegisteredErrorCode(entry.code))) {
|
|
242
|
+
hardFailures.push(hardFailure(
|
|
243
|
+
"SFC3002",
|
|
244
|
+
"source machine result is invalid or contains an unregistered error code",
|
|
245
|
+
{ element: "machine-result", validationErrors: resultValidation.errors },
|
|
246
|
+
));
|
|
247
|
+
} else {
|
|
248
|
+
const expectedDigest = computeResultDigest(resultDocument);
|
|
249
|
+
if (reportModel.source.resultDigest !== expectedDigest) {
|
|
250
|
+
hardFailures.push(hardFailure(
|
|
251
|
+
"SFC3001",
|
|
252
|
+
"report model source digest does not match the machine result",
|
|
253
|
+
{ field: "source.resultDigest", expected: expectedDigest, observed: reportModel.source.resultDigest },
|
|
254
|
+
));
|
|
255
|
+
}
|
|
256
|
+
if (reportModel.source.resultState !== resultDocument.state) {
|
|
257
|
+
hardFailures.push(hardFailure(
|
|
258
|
+
"SFC3003",
|
|
259
|
+
"report model source state does not match the machine result",
|
|
260
|
+
{ field: "source.resultState", expected: resultDocument.state, observed: reportModel.source.resultState },
|
|
261
|
+
));
|
|
262
|
+
}
|
|
263
|
+
const allowedStatuses = RESULT_STATE_EXECUTION_STATUSES[resultDocument.state] ?? [];
|
|
264
|
+
if (!allowedStatuses.includes(reportModel.outcome.executionStatus)) {
|
|
265
|
+
hardFailures.push(hardFailure(
|
|
266
|
+
"SFC3003",
|
|
267
|
+
"report execution status is incompatible with the machine result state",
|
|
268
|
+
{
|
|
269
|
+
field: "outcome.executionStatus",
|
|
270
|
+
resultState: resultDocument.state,
|
|
271
|
+
expected: allowedStatuses,
|
|
272
|
+
observed: reportModel.outcome.executionStatus,
|
|
273
|
+
},
|
|
274
|
+
));
|
|
275
|
+
}
|
|
276
|
+
if (canonicalJson(reportModel.errors) !== canonicalJson(resultDocument.errors)) {
|
|
277
|
+
hardFailures.push(hardFailure(
|
|
278
|
+
"SFC3003",
|
|
279
|
+
"report model errors do not losslessly match machine result errors",
|
|
280
|
+
{ field: "errors", expected: resultDocument.errors, observed: reportModel.errors },
|
|
281
|
+
));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { ok: hardFailures.length === 0, hardFailures };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Deterministically renders a validated model; it never reads other state. */
|
|
290
|
+
export function renderReportMarkdown(model) {
|
|
291
|
+
const validation = validateReportModel(model);
|
|
292
|
+
if (!validation.ok) {
|
|
293
|
+
throw mechanismError(
|
|
294
|
+
HARNESS_ERROR_KINDS.INVALID_RESULT,
|
|
295
|
+
"report model is not renderable",
|
|
296
|
+
{ hardFailures: validation.hardFailures },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const text = TEXT[model.identity.locale];
|
|
300
|
+
const lines = [];
|
|
301
|
+
lines.push(`# ${text.title}: ${literalMarkdown(model.identity.skillFamily)} / ${literalMarkdown(model.identity.runId)}`);
|
|
302
|
+
lines.push("");
|
|
303
|
+
lines.push(`- ${text.audience}: ${literalMarkdown(model.identity.audience)}`);
|
|
304
|
+
if (typeof model.identity.stageId === "string") {
|
|
305
|
+
lines.push(`- ${text.stage}: ${literalMarkdown(model.identity.stageId)}`);
|
|
306
|
+
}
|
|
307
|
+
lines.push(`- ${text.locale}: ${literalMarkdown(model.identity.locale)}`);
|
|
308
|
+
lines.push(`- ${text.timeRange}: ${literalMarkdown(model.timeRange.startedAt)} — ${literalMarkdown(model.timeRange.completedAt)}`);
|
|
309
|
+
lines.push(`- ${text.status}: ${literalMarkdown(model.outcome.executionStatus)}`);
|
|
310
|
+
lines.push(`- ${text.source}: ${literalMarkdown(model.source.resultDigest)}`);
|
|
311
|
+
if (typeof model.source.resultKind === "string") {
|
|
312
|
+
lines.push(`- ${text.sourceKind}: ${literalMarkdown(model.source.resultKind)}`);
|
|
313
|
+
}
|
|
314
|
+
lines.push(`- ${text.renderer}: ${literalMarkdown(model.renderer.name)}@${literalMarkdown(model.renderer.version)}`);
|
|
315
|
+
lines.push("");
|
|
316
|
+
|
|
317
|
+
lines.push(`## ${text.sections.conclusion}`, "", literalMarkdown(model.outcome.summary));
|
|
318
|
+
if (typeof model.outcome.blockingReason === "string" && model.outcome.blockingReason.length > 0) {
|
|
319
|
+
lines.push("", `> ${text.blocking}: ${literalMarkdown(model.outcome.blockingReason)}`);
|
|
320
|
+
}
|
|
321
|
+
lines.push("");
|
|
322
|
+
|
|
323
|
+
lines.push(`## ${text.sections.errors}`, "");
|
|
324
|
+
if (model.errors.length === 0) {
|
|
325
|
+
lines.push(text.empty.errors);
|
|
326
|
+
} else {
|
|
327
|
+
for (const error of model.errors) {
|
|
328
|
+
lines.push(`- ${literalMarkdown(error.code)}: ${literalMarkdown(error.message)}`);
|
|
329
|
+
if (typeof error.path === "string") {
|
|
330
|
+
lines.push(` - ${text.errorPath}: ${literalMarkdown(error.path)}`);
|
|
331
|
+
}
|
|
332
|
+
if (error.details !== undefined) {
|
|
333
|
+
lines.push(` - ${text.errorDetails}: ${literalMarkdown(canonicalJson(error.details))}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
lines.push("");
|
|
338
|
+
|
|
339
|
+
lines.push(`## ${text.sections.changes}`, "");
|
|
340
|
+
if (model.changes.length === 0) lines.push(text.empty.changes);
|
|
341
|
+
for (const change of model.changes) {
|
|
342
|
+
lines.push(`- ${literalMarkdown(change.path)}: ${literalMarkdown(change.description)}`);
|
|
343
|
+
}
|
|
344
|
+
lines.push("");
|
|
345
|
+
|
|
346
|
+
lines.push(`## ${text.sections.evidence}`, "");
|
|
347
|
+
for (const entry of model.evidence) {
|
|
348
|
+
lines.push(`- ${literalMarkdown(entry.id)}: ${literalMarkdown(entry.title)}`);
|
|
349
|
+
lines.push(` - ${text.reference}: ${literalMarkdown(entry.reference)}`);
|
|
350
|
+
if (typeof entry.note === "string" && entry.note.length > 0) {
|
|
351
|
+
lines.push(` - ${text.note}: ${literalMarkdown(entry.note)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
lines.push("");
|
|
355
|
+
|
|
356
|
+
for (const [field, section] of [
|
|
357
|
+
["risks", "risks"],
|
|
358
|
+
["unresolved", "unresolved"],
|
|
359
|
+
["nextActions", "nextActions"],
|
|
360
|
+
]) {
|
|
361
|
+
lines.push(`## ${text.sections[section]}`, "");
|
|
362
|
+
if (model[field].length === 0) lines.push(text.empty[field]);
|
|
363
|
+
for (const item of model[field]) lines.push(`- ${literalMarkdown(item)}`);
|
|
364
|
+
lines.push("");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
lines.push(`## ${text.sections.appendices}`, "");
|
|
368
|
+
if (model.appendices.length === 0) {
|
|
369
|
+
lines.push(text.empty.appendices);
|
|
370
|
+
} else {
|
|
371
|
+
model.appendices.forEach((appendix, index) => {
|
|
372
|
+
if (index > 0) lines.push("");
|
|
373
|
+
lines.push(`### ${literalMarkdown(appendix.title)}`, "", literalMarkdown(appendix.body));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return `${lines.join("\n")}\n`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function computeResultDigest(resultDocument) {
|
|
380
|
+
return digestDocument(resultDocument);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function computeModelDigest(reportModel) {
|
|
384
|
+
return digestDocument(reportModel);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function digestReport(reportMarkdown) {
|
|
388
|
+
if (typeof reportMarkdown !== "string") {
|
|
389
|
+
throw new TypeError("digestReport: reportMarkdown must be a string");
|
|
390
|
+
}
|
|
391
|
+
return digestBytes(Buffer.from(reportMarkdown, "utf8"));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Builds a binding only after model/result consistency has been proved. */
|
|
395
|
+
export function buildBinding(reportModel, resultDocument, reportMarkdown) {
|
|
396
|
+
const validation = validateReportModel(reportModel, { resultDocument });
|
|
397
|
+
if (!validation.ok) {
|
|
398
|
+
throw mechanismError(
|
|
399
|
+
HARNESS_ERROR_KINDS.INVALID_RESULT,
|
|
400
|
+
"report model does not match its source result",
|
|
401
|
+
{ hardFailures: validation.hardFailures },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
const canonical = renderReportMarkdown(reportModel);
|
|
405
|
+
if (canonical !== reportMarkdown) {
|
|
406
|
+
throw mechanismError(
|
|
407
|
+
HARNESS_ERROR_KINDS.INVALID_RESULT,
|
|
408
|
+
"report Markdown is not the canonical render of its model",
|
|
409
|
+
{
|
|
410
|
+
hardFailures: [hardFailure(
|
|
411
|
+
"SFC3003",
|
|
412
|
+
"report bytes diverge from the deterministic render of the submitted report model",
|
|
413
|
+
{ expectedDigest: digestReport(canonical), observedDigest: digestReport(reportMarkdown) },
|
|
414
|
+
)],
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return {
|
|
419
|
+
schemaVersion: 1,
|
|
420
|
+
kind: "skill-family.report-binding",
|
|
421
|
+
runId: reportModel.identity.runId,
|
|
422
|
+
modelDigest: computeModelDigest(reportModel),
|
|
423
|
+
resultDigest: computeResultDigest(resultDocument),
|
|
424
|
+
reportDigest: digestReport(reportMarkdown),
|
|
425
|
+
rendererVersion: REPORT_RENDERER_VERSION,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function verifyBinding(binding, { reportModel, resultDocument, reportMarkdown } = {}) {
|
|
430
|
+
const mismatches = [];
|
|
431
|
+
if (!isPlainObject(binding)) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
code: "SFC3001",
|
|
435
|
+
mismatches: [{ field: "binding", expected: "skill-family.report-binding object", observed: typeof binding }],
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const contract = validateContractDocument(binding, {
|
|
439
|
+
schemaId: REPORT_BINDING_SCHEMA_ID,
|
|
440
|
+
policy: "strict",
|
|
441
|
+
});
|
|
442
|
+
if (!contract.valid) {
|
|
443
|
+
return {
|
|
444
|
+
ok: false,
|
|
445
|
+
code: "SFC3001",
|
|
446
|
+
mismatches: [{ field: "binding-schema", expected: "valid report-binding", observed: contract.errors }],
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (!isPlainObject(reportModel)) {
|
|
450
|
+
mismatches.push({ field: "reportModel", expected: "skill-family.report-model object", observed: typeof reportModel });
|
|
451
|
+
}
|
|
452
|
+
if (!isPlainObject(resultDocument)) {
|
|
453
|
+
mismatches.push({ field: "resultDocument", expected: "skill-family.operation-result object", observed: typeof resultDocument });
|
|
454
|
+
}
|
|
455
|
+
if (typeof reportMarkdown !== "string") {
|
|
456
|
+
mismatches.push({ field: "reportMarkdown", expected: "canonical Markdown string", observed: typeof reportMarkdown });
|
|
457
|
+
}
|
|
458
|
+
const modelValidation = isPlainObject(reportModel) && isPlainObject(resultDocument)
|
|
459
|
+
? validateReportModel(reportModel, { resultDocument })
|
|
460
|
+
: { ok: false, hardFailures: [] };
|
|
461
|
+
if (!modelValidation.ok && isPlainObject(reportModel) && isPlainObject(resultDocument)) {
|
|
462
|
+
mismatches.push({ field: "reportModel", expected: "model bound to supplied result", observed: modelValidation.hardFailures });
|
|
463
|
+
}
|
|
464
|
+
if (isPlainObject(reportModel)) {
|
|
465
|
+
const expected = computeModelDigest(reportModel);
|
|
466
|
+
if (binding.modelDigest !== expected) {
|
|
467
|
+
mismatches.push({ field: "modelDigest", expected, observed: binding.modelDigest });
|
|
468
|
+
}
|
|
469
|
+
if (binding.runId !== reportModel.identity?.runId) {
|
|
470
|
+
mismatches.push({ field: "runId", expected: reportModel.identity?.runId, observed: binding.runId });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (resultDocument !== undefined) {
|
|
474
|
+
const expected = computeResultDigest(resultDocument);
|
|
475
|
+
if (binding.resultDigest !== expected) {
|
|
476
|
+
mismatches.push({ field: "resultDigest", expected, observed: binding.resultDigest });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (typeof reportMarkdown === "string") {
|
|
480
|
+
const expected = digestReport(reportMarkdown);
|
|
481
|
+
if (binding.reportDigest !== expected) {
|
|
482
|
+
mismatches.push({ field: "reportDigest", expected, observed: binding.reportDigest });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (binding.rendererVersion !== REPORT_RENDERER_VERSION) {
|
|
486
|
+
mismatches.push({
|
|
487
|
+
field: "rendererVersion",
|
|
488
|
+
expected: REPORT_RENDERER_VERSION,
|
|
489
|
+
observed: binding.rendererVersion,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
if (modelValidation.ok && typeof reportMarkdown === "string") {
|
|
493
|
+
const canonical = renderReportMarkdown(reportModel);
|
|
494
|
+
if (canonical !== reportMarkdown) {
|
|
495
|
+
mismatches.push({
|
|
496
|
+
field: "canonicalReport",
|
|
497
|
+
expected: digestReport(canonical),
|
|
498
|
+
observed: digestReport(reportMarkdown),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return mismatches.length === 0
|
|
503
|
+
? { ok: true, code: null, mismatches: [] }
|
|
504
|
+
: { ok: false, code: "SFC3001", mismatches };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Graded check: contract/digest/fact failures block; style remains advisory. */
|
|
508
|
+
export function checkReport({ reportMarkdown, reportModel, resultDocument, binding } = {}) {
|
|
509
|
+
if (typeof reportMarkdown !== "string") {
|
|
510
|
+
throw new TypeError("checkReport: reportMarkdown must be a string");
|
|
511
|
+
}
|
|
512
|
+
const validated = validateReportModel(reportModel, { resultDocument });
|
|
513
|
+
const hardFailures = [...validated.hardFailures];
|
|
514
|
+
if (validated.ok) {
|
|
515
|
+
if (binding === undefined) {
|
|
516
|
+
hardFailures.push(hardFailure(
|
|
517
|
+
"SFC3002",
|
|
518
|
+
"missing report element: binding",
|
|
519
|
+
{ element: "binding" },
|
|
520
|
+
));
|
|
521
|
+
} else {
|
|
522
|
+
const verification = verifyBinding(binding, {
|
|
523
|
+
reportModel,
|
|
524
|
+
resultDocument,
|
|
525
|
+
reportMarkdown,
|
|
526
|
+
});
|
|
527
|
+
for (const mismatch of verification.mismatches) {
|
|
528
|
+
hardFailures.push(hardFailure(
|
|
529
|
+
"SFC3001",
|
|
530
|
+
`report binding mismatch: ${mismatch.field}`,
|
|
531
|
+
mismatch,
|
|
532
|
+
));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const canonical = renderReportMarkdown(reportModel);
|
|
536
|
+
if (canonical !== reportMarkdown) {
|
|
537
|
+
hardFailures.push(hardFailure(
|
|
538
|
+
"SFC3003",
|
|
539
|
+
"report bytes diverge from the deterministic render of the submitted report model",
|
|
540
|
+
{ expectedDigest: digestReport(canonical), observedDigest: digestReport(reportMarkdown) },
|
|
541
|
+
));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return {
|
|
545
|
+
ok: hardFailures.length === 0,
|
|
546
|
+
hardFailures,
|
|
547
|
+
styleWarnings: collectStyleWarnings(reportMarkdown),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Frozen deterministic style rules; advisory only, never blocking. */
|
|
552
|
+
export function collectStyleWarnings(reportMarkdown) {
|
|
553
|
+
if (typeof reportMarkdown !== "string") {
|
|
554
|
+
throw new TypeError("collectStyleWarnings: reportMarkdown must be a string");
|
|
555
|
+
}
|
|
556
|
+
const lines = reportMarkdown.split("\n");
|
|
557
|
+
const warnings = [];
|
|
558
|
+
const seen = new Set();
|
|
559
|
+
const record = (rule, line, message) => {
|
|
560
|
+
const key = `${rule}|${line}|${message}`;
|
|
561
|
+
if (seen.has(key)) return;
|
|
562
|
+
seen.add(key);
|
|
563
|
+
warnings.push({ rule, line, message });
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
lines.forEach((raw, index) => {
|
|
567
|
+
for (const sentence of raw.split(/(?<=[。!?!?;;])/)) {
|
|
568
|
+
const trimmed = sentence.trim();
|
|
569
|
+
if (trimmed.length > SENTENCE_MAX_CHARS) {
|
|
570
|
+
record("sentence-too-long", index + 1, `sentence exceeds ${SENTENCE_MAX_CHARS} characters (${trimmed.length})`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const lineCounts = new Map();
|
|
576
|
+
lines.forEach((raw, index) => {
|
|
577
|
+
const trimmed = raw.trim();
|
|
578
|
+
if (trimmed.length === 0) return;
|
|
579
|
+
const entry = lineCounts.get(trimmed) ?? { count: 0, first: index + 1 };
|
|
580
|
+
entry.count += 1;
|
|
581
|
+
lineCounts.set(trimmed, entry);
|
|
582
|
+
});
|
|
583
|
+
for (const [content, entry] of lineCounts) {
|
|
584
|
+
if (entry.count > 1) {
|
|
585
|
+
record("duplicate-paragraph", entry.first, `line repeats ${entry.count} times: ${truncate(content)}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
lines.forEach((raw, index) => {
|
|
590
|
+
for (const phrase of TRANSLATESE_ZH) {
|
|
591
|
+
if (raw.includes(phrase)) record("translationese", index + 1, `translationese phrase: ${phrase}`);
|
|
592
|
+
}
|
|
593
|
+
const lowered = raw.toLowerCase();
|
|
594
|
+
for (const phrase of TRANSLATESE_EN) {
|
|
595
|
+
if (lowered.includes(phrase)) record("translationese", index + 1, `translationese phrase: ${phrase}`);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
for (const term of UNEXPLAINED_TERMS) {
|
|
600
|
+
let usedAt = null;
|
|
601
|
+
let explained = false;
|
|
602
|
+
lines.forEach((raw, index) => {
|
|
603
|
+
if (raw.includes(term)) {
|
|
604
|
+
if (usedAt === null) usedAt = index + 1;
|
|
605
|
+
if (raw.includes(`${term}:`) || raw.includes(`${term}:`)) explained = true;
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
if (usedAt !== null && !explained) {
|
|
609
|
+
record("unexplained-term", usedAt, `term used without inline explanation: ${term}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
warnings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule) || a.message.localeCompare(b.message));
|
|
613
|
+
return warnings;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function truncate(content) {
|
|
617
|
+
return content.length <= 40 ? content : `${content.slice(0, 40)}…`;
|
|
618
|
+
}
|