@dzhechkov/harness-core 0.8.2 → 0.8.6
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/.dz-manifest.json +146 -58
- package/README.md +72 -2
- package/dist/cmd-usage.d.ts +148 -0
- package/dist/cmd-usage.d.ts.map +1 -0
- package/dist/cmd-usage.js +548 -0
- package/dist/cmd-usage.js.map +1 -0
- package/dist/compounding.d.ts +4 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +6 -0
- package/dist/compounding.js.map +1 -1
- package/dist/contract-checklist.d.ts +123 -0
- package/dist/contract-checklist.d.ts.map +1 -0
- package/dist/contract-checklist.js +700 -0
- package/dist/contract-checklist.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +11 -2
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +37 -2
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +58 -23
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +208 -59
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +25 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +59 -1
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +8 -8
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/loop-plan.d.ts +13 -1
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +15 -1
- package/dist/loop-plan.js.map +1 -1
- package/dist/loop-render.d.ts.map +1 -1
- package/dist/loop-render.js +51 -6
- package/dist/loop-render.js.map +1 -1
- package/dist/loop-trace.d.ts +20 -1
- package/dist/loop-trace.d.ts.map +1 -1
- package/dist/loop-trace.js +83 -1
- package/dist/loop-trace.js.map +1 -1
- package/dist/model-recommender.d.ts +8 -0
- package/dist/model-recommender.d.ts.map +1 -1
- package/dist/model-recommender.js +31 -4
- package/dist/model-recommender.js.map +1 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +9 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/restart-advisor.d.ts +103 -0
- package/dist/restart-advisor.d.ts.map +1 -0
- package/dist/restart-advisor.js +445 -0
- package/dist/restart-advisor.js.map +1 -0
- package/dist/slop-lint.d.ts +128 -0
- package/dist/slop-lint.d.ts.map +1 -0
- package/dist/slop-lint.js +607 -0
- package/dist/slop-lint.js.map +1 -0
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +18 -12
- package/dist/workflow-run.js.map +1 -1
- package/package.json +19 -15
- package/sbom.json +277 -57
- package/src/cmd-usage.ts +720 -0
- package/src/compounding.ts +13 -0
- package/src/contract-checklist.ts +973 -0
- package/src/deadwood-allowlist.json +80 -0
- package/src/feature-adr-checkpoints.ts +38 -2
- package/src/feature-adr-routing.ts +238 -55
- package/src/guard.ts +79 -1
- package/src/index.ts +81 -1
- package/src/loop-blobs.generated.ts +8 -8
- package/src/loop-plan.ts +36 -3
- package/src/loop-render.ts +50 -6
- package/src/loop-trace.ts +91 -2
- package/src/model-recommender.ts +35 -4
- package/src/qe-bridge.ts +9 -0
- package/src/restart-advisor.ts +579 -0
- package/src/slop-lint.ts +762 -0
- package/src/slop-markers.json +71 -0
- package/src/workflow-run.ts +18 -11
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure contract-checklist policy for feature-ADR artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Markdown/JSON text and injected evidence reads go in; typed decisions come out. Filesystem
|
|
5
|
+
* discovery, realpath confinement, rendering to a terminal, and process exits belong to the CLI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const CONTRACT_CHECKLIST_SCHEMA = 'contract-checklist/1' as const;
|
|
9
|
+
export const CONTRACT_VERDICT_SCHEMA = 'contract-checklist-verdict/1' as const;
|
|
10
|
+
|
|
11
|
+
export type ContractSourceKind = 'acceptance-criterion' | 'adr-confirmation';
|
|
12
|
+
export type ContractVerdict = 'met' | 'unmet' | 'not-testable';
|
|
13
|
+
export type ContractObservedOutcome = 'pass' | 'fail' | 'not-testable';
|
|
14
|
+
export type ContractGrade = 'A' | 'B' | 'C' | 'D';
|
|
15
|
+
|
|
16
|
+
export interface ContractSourceArtifact {
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ContractChecklistSource {
|
|
22
|
+
readonly requirements: ContractSourceArtifact;
|
|
23
|
+
readonly adrs: readonly ContractSourceArtifact[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ContractItem {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly sourceId: string;
|
|
29
|
+
readonly sourceKind: ContractSourceKind;
|
|
30
|
+
readonly statement: string;
|
|
31
|
+
readonly sourcePath: string;
|
|
32
|
+
readonly sourceLine: number;
|
|
33
|
+
readonly requiredAutomatedCheck?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ContractChecklist {
|
|
37
|
+
readonly schema: typeof CONTRACT_CHECKLIST_SCHEMA;
|
|
38
|
+
readonly items: readonly ContractItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ContractDiagnostic {
|
|
42
|
+
readonly code: string;
|
|
43
|
+
readonly message: string;
|
|
44
|
+
readonly artifact?: string;
|
|
45
|
+
readonly section?: string;
|
|
46
|
+
readonly sourceId?: string;
|
|
47
|
+
readonly contractId?: string;
|
|
48
|
+
readonly observed?: string | number | readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ContractChecklistResult =
|
|
52
|
+
| { readonly ok: true; readonly checklist: ContractChecklist; readonly diagnostics: readonly [] }
|
|
53
|
+
| { readonly ok: false; readonly diagnostics: readonly ContractDiagnostic[] };
|
|
54
|
+
|
|
55
|
+
export interface ContractVerdictEvidence {
|
|
56
|
+
readonly artifact: string;
|
|
57
|
+
readonly quote: string;
|
|
58
|
+
readonly observedOutcome: ContractObservedOutcome;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ContractVerdictItem {
|
|
62
|
+
readonly id: string;
|
|
63
|
+
readonly verdict: ContractVerdict;
|
|
64
|
+
readonly evidence: ContractVerdictEvidence;
|
|
65
|
+
readonly reason?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ContractVerdictReport {
|
|
69
|
+
readonly schema: typeof CONTRACT_VERDICT_SCHEMA;
|
|
70
|
+
readonly overallGrade: ContractGrade;
|
|
71
|
+
readonly items: readonly ContractVerdictItem[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ContractVerdictParseResult =
|
|
75
|
+
| {
|
|
76
|
+
readonly ok: true;
|
|
77
|
+
readonly report: ContractVerdictReport;
|
|
78
|
+
readonly humanGrade: ContractGrade;
|
|
79
|
+
readonly diagnostics: readonly [];
|
|
80
|
+
}
|
|
81
|
+
| {
|
|
82
|
+
readonly ok: false;
|
|
83
|
+
/** False only when no canonical verdict section exists at all. */
|
|
84
|
+
readonly established: boolean;
|
|
85
|
+
readonly diagnostics: readonly ContractDiagnostic[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type ContractEvidenceReadResult =
|
|
89
|
+
| { readonly ok: true; readonly text: string }
|
|
90
|
+
| { readonly ok: false; readonly code: string; readonly detail: string };
|
|
91
|
+
|
|
92
|
+
export interface ContractEvidenceReader {
|
|
93
|
+
/** Repository-relative QE report path, used to reject self-citation before reading. */
|
|
94
|
+
readonly reportArtifact?: string;
|
|
95
|
+
read(artifact: string): ContractEvidenceReadResult;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ContractItemVerification {
|
|
99
|
+
readonly id: string;
|
|
100
|
+
readonly verdict: ContractVerdict | null;
|
|
101
|
+
readonly evidence: 'valid' | 'invalid' | 'not-checked';
|
|
102
|
+
readonly reason?: string;
|
|
103
|
+
readonly diagnostics: readonly ContractDiagnostic[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ContractVerificationCounts {
|
|
107
|
+
readonly contractItems: number;
|
|
108
|
+
readonly verdictItems: number;
|
|
109
|
+
readonly met: number;
|
|
110
|
+
readonly unmet: number;
|
|
111
|
+
readonly notTestable: number;
|
|
112
|
+
readonly missing: number;
|
|
113
|
+
readonly duplicate: number;
|
|
114
|
+
readonly orphan: number;
|
|
115
|
+
readonly invalidEvidence: number;
|
|
116
|
+
readonly gradeConflicts: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ContractVerification {
|
|
120
|
+
readonly outcome: 'pass' | 'fail';
|
|
121
|
+
readonly exitCode: 0 | 1;
|
|
122
|
+
/** Null only when an untyped runtime caller bypasses the parser with an invalid report object. */
|
|
123
|
+
readonly overallGrade: ContractGrade | null;
|
|
124
|
+
readonly items: readonly ContractItemVerification[];
|
|
125
|
+
readonly counts: ContractVerificationCounts;
|
|
126
|
+
readonly diagnostics: readonly ContractDiagnostic[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const REQUIREMENTS_HEADING = '## Acceptance criteria';
|
|
130
|
+
const CONFIRMATION_HEADING = '## Confirmation';
|
|
131
|
+
const VERDICT_HEADING = '## Contract checklist';
|
|
132
|
+
const REQUIREMENTS_FORMAT_LINE = 'Format: Every acceptance criterion below is exactly one physical line matching `^AC-([1-9][0-9]*): (\\S.*)$`; identifiers are contiguous from `AC-1`, and only the literal H2 `## Acceptance criteria` establishes this source section.';
|
|
133
|
+
const ADR_BASENAME = /^([0-9]{3})-[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.md$/;
|
|
134
|
+
const ACCEPTANCE_ROW = /^AC-([1-9][0-9]*): (\S.*)$/;
|
|
135
|
+
const LOAD_PROPERTY = /^- Load-bearing property:(?: (.*))?$/;
|
|
136
|
+
const REQUIRED_CHECK = /^- Required automated check:(?: (.*))?$/;
|
|
137
|
+
|
|
138
|
+
function linesOf(text: string): string[] {
|
|
139
|
+
return text.replace(/\r\n?/g, '\n').split('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function h2Indexes(lines: readonly string[], heading: string): number[] {
|
|
143
|
+
const indexes: number[] = [];
|
|
144
|
+
for (let index = 0; index < lines.length; index++) {
|
|
145
|
+
if (lines[index] === heading) indexes.push(index);
|
|
146
|
+
}
|
|
147
|
+
return indexes;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function h2End(lines: readonly string[], start: number): number {
|
|
151
|
+
for (let index = start + 1; index < lines.length; index++) {
|
|
152
|
+
if (/^## (?!#)\S/.test(lines[index] ?? '')) return index;
|
|
153
|
+
}
|
|
154
|
+
return lines.length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function diagnostic(
|
|
158
|
+
code: string,
|
|
159
|
+
message: string,
|
|
160
|
+
fields: Omit<ContractDiagnostic, 'code' | 'message'> = {},
|
|
161
|
+
): ContractDiagnostic {
|
|
162
|
+
return { code, message, ...fields };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface PendingContractItem {
|
|
166
|
+
readonly sourceId: string;
|
|
167
|
+
readonly sourceKind: ContractSourceKind;
|
|
168
|
+
readonly statement: string;
|
|
169
|
+
readonly sourcePath: string;
|
|
170
|
+
readonly sourceLine: number;
|
|
171
|
+
readonly requiredAutomatedCheck?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function acceptanceItems(
|
|
175
|
+
artifact: ContractSourceArtifact,
|
|
176
|
+
diagnostics: ContractDiagnostic[],
|
|
177
|
+
): PendingContractItem[] {
|
|
178
|
+
const lines = linesOf(artifact.text);
|
|
179
|
+
const headings = h2Indexes(lines, REQUIREMENTS_HEADING);
|
|
180
|
+
if (headings.length !== 1) {
|
|
181
|
+
diagnostics.push(diagnostic(
|
|
182
|
+
headings.length === 0 ? 'requirements-section-missing' : 'requirements-section-duplicate',
|
|
183
|
+
`expected exactly one ${REQUIREMENTS_HEADING} section; found ${headings.length}`,
|
|
184
|
+
{ artifact: artifact.path, section: REQUIREMENTS_HEADING, observed: headings.length },
|
|
185
|
+
));
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const start = headings[0] as number;
|
|
190
|
+
const end = h2End(lines, start);
|
|
191
|
+
const out: PendingContractItem[] = [];
|
|
192
|
+
for (let index = start + 1; index < end; index++) {
|
|
193
|
+
const line = lines[index] ?? '';
|
|
194
|
+
if (line.trim() === '' || line === REQUIREMENTS_FORMAT_LINE) continue;
|
|
195
|
+
const match = ACCEPTANCE_ROW.exec(line);
|
|
196
|
+
if (!match) {
|
|
197
|
+
diagnostics.push(diagnostic(
|
|
198
|
+
'acceptance-row-malformed',
|
|
199
|
+
`non-canonical acceptance content at line ${index + 1}`,
|
|
200
|
+
{ artifact: artifact.path, section: REQUIREMENTS_HEADING, observed: line },
|
|
201
|
+
));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const number = Number(match[1]);
|
|
205
|
+
out.push({
|
|
206
|
+
sourceId: `AC-${number}`,
|
|
207
|
+
sourceKind: 'acceptance-criterion',
|
|
208
|
+
statement: match[2] as string,
|
|
209
|
+
sourcePath: artifact.path,
|
|
210
|
+
sourceLine: index + 1,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateAcceptanceIds(
|
|
217
|
+
items: readonly PendingContractItem[],
|
|
218
|
+
artifact: ContractSourceArtifact,
|
|
219
|
+
diagnostics: ContractDiagnostic[],
|
|
220
|
+
): void {
|
|
221
|
+
if (items.length === 0) {
|
|
222
|
+
diagnostics.push(diagnostic(
|
|
223
|
+
'acceptance-items-empty',
|
|
224
|
+
'the Acceptance criteria section contains zero canonical rows',
|
|
225
|
+
{ artifact: artifact.path, section: REQUIREMENTS_HEADING, observed: 0 },
|
|
226
|
+
));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
for (let index = 0; index < items.length; index++) {
|
|
231
|
+
const item = items[index] as PendingContractItem;
|
|
232
|
+
if (seen.has(item.sourceId)) {
|
|
233
|
+
diagnostics.push(diagnostic(
|
|
234
|
+
'acceptance-id-duplicate',
|
|
235
|
+
`duplicate acceptance identity ${item.sourceId}`,
|
|
236
|
+
{ artifact: artifact.path, section: REQUIREMENTS_HEADING, sourceId: item.sourceId },
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
seen.add(item.sourceId);
|
|
240
|
+
const expected = `AC-${index + 1}`;
|
|
241
|
+
if (item.sourceId !== expected) {
|
|
242
|
+
diagnostics.push(diagnostic(
|
|
243
|
+
'acceptance-id-noncontiguous',
|
|
244
|
+
`expected ${expected} at contract position ${index + 1}; found ${item.sourceId}`,
|
|
245
|
+
{ artifact: artifact.path, section: REQUIREMENTS_HEADING, sourceId: item.sourceId, observed: expected },
|
|
246
|
+
));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function fieldContinuation(
|
|
252
|
+
lines: readonly string[],
|
|
253
|
+
fieldIndex: number,
|
|
254
|
+
end: number,
|
|
255
|
+
): { line: number; text: string } | null {
|
|
256
|
+
const nextIndex = fieldIndex + 1;
|
|
257
|
+
if (nextIndex >= end) return null;
|
|
258
|
+
const next = lines[nextIndex] ?? '';
|
|
259
|
+
if (next.trim() === '' || /^- \S/.test(next)) return null;
|
|
260
|
+
return { line: nextIndex + 1, text: next };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function confirmationItem(
|
|
264
|
+
artifact: ContractSourceArtifact,
|
|
265
|
+
diagnostics: ContractDiagnostic[],
|
|
266
|
+
): PendingContractItem | null {
|
|
267
|
+
const basename = artifact.path.replace(/\\/g, '/').split('/').at(-1) ?? '';
|
|
268
|
+
const filename = ADR_BASENAME.exec(basename);
|
|
269
|
+
if (!filename) {
|
|
270
|
+
diagnostics.push(diagnostic(
|
|
271
|
+
'adr-filename-noncanonical',
|
|
272
|
+
`direct ADR Markdown file ${basename} is not a canonical NNN-lowercase-kebab filename`,
|
|
273
|
+
{ artifact: artifact.path, observed: basename },
|
|
274
|
+
));
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const lines = linesOf(artifact.text);
|
|
278
|
+
const headings = h2Indexes(lines, CONFIRMATION_HEADING);
|
|
279
|
+
if (headings.length !== 1) {
|
|
280
|
+
diagnostics.push(diagnostic(
|
|
281
|
+
headings.length === 0 ? 'confirmation-section-missing' : 'confirmation-section-duplicate',
|
|
282
|
+
`expected exactly one ${CONFIRMATION_HEADING} section; found ${headings.length}`,
|
|
283
|
+
{ artifact: artifact.path, section: CONFIRMATION_HEADING, observed: headings.length },
|
|
284
|
+
));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const start = headings[0] as number;
|
|
288
|
+
const end = h2End(lines, start);
|
|
289
|
+
const properties: { index: number; value: string }[] = [];
|
|
290
|
+
const checks: { index: number; value: string }[] = [];
|
|
291
|
+
for (let index = start + 1; index < end; index++) {
|
|
292
|
+
const line = lines[index] ?? '';
|
|
293
|
+
const property = LOAD_PROPERTY.exec(line);
|
|
294
|
+
const check = REQUIRED_CHECK.exec(line);
|
|
295
|
+
if (line.startsWith('- Load-bearing property:')) properties.push({ index, value: property?.[1] ?? '' });
|
|
296
|
+
if (line.startsWith('- Required automated check:')) checks.push({ index, value: check?.[1] ?? '' });
|
|
297
|
+
}
|
|
298
|
+
const propertyValid = properties.length === 1 && /^\S.*$/.test(properties[0]?.value ?? '');
|
|
299
|
+
const checkValid = checks.length === 1 && /^\S.*$/.test(checks[0]?.value ?? '');
|
|
300
|
+
if (!propertyValid) {
|
|
301
|
+
const observed = properties.length === 1 ? 0 : properties.length;
|
|
302
|
+
diagnostics.push(diagnostic(
|
|
303
|
+
'confirmation-property-count',
|
|
304
|
+
`expected one non-empty Load-bearing property field; found ${observed}`,
|
|
305
|
+
{ artifact: artifact.path, section: CONFIRMATION_HEADING, observed },
|
|
306
|
+
));
|
|
307
|
+
}
|
|
308
|
+
if (!checkValid) {
|
|
309
|
+
const observed = checks.length === 1 ? 0 : checks.length;
|
|
310
|
+
diagnostics.push(diagnostic(
|
|
311
|
+
'confirmation-check-count',
|
|
312
|
+
`expected one non-empty Required automated check field; found ${observed}`,
|
|
313
|
+
{ artifact: artifact.path, section: CONFIRMATION_HEADING, observed },
|
|
314
|
+
));
|
|
315
|
+
}
|
|
316
|
+
if (!propertyValid || !checkValid) return null;
|
|
317
|
+
const property = properties[0] as { index: number; value: string };
|
|
318
|
+
const check = checks[0] as { index: number; value: string };
|
|
319
|
+
let wrapped = false;
|
|
320
|
+
for (const [label, field] of [['Load-bearing property', property], ['Required automated check', check]] as const) {
|
|
321
|
+
const continuation = fieldContinuation(lines, field.index, end);
|
|
322
|
+
if (continuation !== null) {
|
|
323
|
+
wrapped = true;
|
|
324
|
+
diagnostics.push(diagnostic(
|
|
325
|
+
'confirmation-field-wrapped',
|
|
326
|
+
`${label} must occupy one physical line; continuation found at line ${continuation.line}`,
|
|
327
|
+
{
|
|
328
|
+
artifact: artifact.path,
|
|
329
|
+
section: CONFIRMATION_HEADING,
|
|
330
|
+
sourceId: `ADR-${filename[1]}-CONFIRMATION`,
|
|
331
|
+
observed: continuation.text,
|
|
332
|
+
},
|
|
333
|
+
));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (wrapped) return null;
|
|
337
|
+
return {
|
|
338
|
+
sourceId: `ADR-${filename[1]}-CONFIRMATION`,
|
|
339
|
+
sourceKind: 'adr-confirmation',
|
|
340
|
+
statement: property.value,
|
|
341
|
+
requiredAutomatedCheck: check.value,
|
|
342
|
+
sourcePath: artifact.path,
|
|
343
|
+
sourceLine: property.index + 1,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function extractContractChecklist(input: ContractChecklistSource): ContractChecklistResult {
|
|
348
|
+
const diagnostics: ContractDiagnostic[] = [];
|
|
349
|
+
const requirements = acceptanceItems(input.requirements, diagnostics);
|
|
350
|
+
validateAcceptanceIds(requirements, input.requirements, diagnostics);
|
|
351
|
+
|
|
352
|
+
if (input.adrs.length === 0) {
|
|
353
|
+
diagnostics.push(diagnostic(
|
|
354
|
+
'adr-input-empty',
|
|
355
|
+
'at least one direct canonical ADR Markdown file is required',
|
|
356
|
+
{ section: CONFIRMATION_HEADING, observed: 0 },
|
|
357
|
+
));
|
|
358
|
+
}
|
|
359
|
+
const adrBasename = (path: string): string => path.replace(/\\/g, '/').split('/').at(-1) ?? '';
|
|
360
|
+
const sortedAdrs = [...input.adrs].sort((a, b) => {
|
|
361
|
+
const aName = adrBasename(a.path);
|
|
362
|
+
const bName = adrBasename(b.path);
|
|
363
|
+
if (aName !== bName) return aName < bName ? -1 : 1;
|
|
364
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
365
|
+
});
|
|
366
|
+
const confirmations: PendingContractItem[] = [];
|
|
367
|
+
for (const artifact of sortedAdrs) {
|
|
368
|
+
const item = confirmationItem(artifact, diagnostics);
|
|
369
|
+
if (item !== null) confirmations.push(item);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const pending = [...requirements, ...confirmations];
|
|
373
|
+
const seen = new Set<string>();
|
|
374
|
+
for (const item of pending) {
|
|
375
|
+
if (seen.has(item.sourceId)) {
|
|
376
|
+
diagnostics.push(diagnostic(
|
|
377
|
+
'contract-source-id-duplicate',
|
|
378
|
+
`duplicate emitted source identity ${item.sourceId}`,
|
|
379
|
+
{ artifact: item.sourcePath, sourceId: item.sourceId },
|
|
380
|
+
));
|
|
381
|
+
}
|
|
382
|
+
seen.add(item.sourceId);
|
|
383
|
+
}
|
|
384
|
+
if (pending.length === 0) {
|
|
385
|
+
diagnostics.push(diagnostic('contract-empty', 'the combined contract contains zero items', { observed: 0 }));
|
|
386
|
+
}
|
|
387
|
+
if (diagnostics.length > 0) return { ok: false, diagnostics };
|
|
388
|
+
|
|
389
|
+
const items: ContractItem[] = pending.map((item, index) => ({ id: `CC-${index + 1}`, ...item }));
|
|
390
|
+
return {
|
|
391
|
+
ok: true,
|
|
392
|
+
checklist: { schema: CONTRACT_CHECKLIST_SCHEMA, items },
|
|
393
|
+
diagnostics: [],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function renderContractChecklist(checklist: ContractChecklist): string {
|
|
398
|
+
return `\`\`\`contract-checklist\n${JSON.stringify(checklist, null, 2)}\n\`\`\``;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
class JsonDecodeError extends Error {
|
|
402
|
+
constructor(readonly code: string, message: string) {
|
|
403
|
+
super(message);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Strict JSON decoder whose object parser sees duplicate members before a value can overwrite one. */
|
|
408
|
+
class DuplicateSafeJsonDecoder {
|
|
409
|
+
private index = 0;
|
|
410
|
+
|
|
411
|
+
constructor(private readonly text: string) {}
|
|
412
|
+
|
|
413
|
+
decode(): unknown {
|
|
414
|
+
const value = this.value();
|
|
415
|
+
this.space();
|
|
416
|
+
if (this.index !== this.text.length) throw new JsonDecodeError('json-trailing-content', `unexpected content at byte ${this.index}`);
|
|
417
|
+
return value;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private space(): void {
|
|
421
|
+
while (/[\t\n\r ]/.test(this.text[this.index] ?? '')) this.index++;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private value(): unknown {
|
|
425
|
+
this.space();
|
|
426
|
+
const char = this.text[this.index];
|
|
427
|
+
if (char === '{') return this.object();
|
|
428
|
+
if (char === '[') return this.array();
|
|
429
|
+
if (char === '"') return this.string();
|
|
430
|
+
if (char === '-' || (char !== undefined && /[0-9]/.test(char))) return this.number();
|
|
431
|
+
for (const [token, value] of [['true', true], ['false', false], ['null', null]] as const) {
|
|
432
|
+
if (this.text.startsWith(token, this.index)) {
|
|
433
|
+
this.index += token.length;
|
|
434
|
+
return value;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
throw new JsonDecodeError('json-invalid', `expected a JSON value at byte ${this.index}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private object(): Record<string, unknown> {
|
|
441
|
+
this.index++;
|
|
442
|
+
// A null prototype makes decoded member names inert data. Assigning `__proto__` into `{}`
|
|
443
|
+
// invokes the legacy prototype setter and would hide that member from Object.keys/closed-schema
|
|
444
|
+
// validation instead of reporting it as unknown.
|
|
445
|
+
const value = Object.create(null) as Record<string, unknown>;
|
|
446
|
+
const keys = new Set<string>();
|
|
447
|
+
this.space();
|
|
448
|
+
if (this.text[this.index] === '}') {
|
|
449
|
+
this.index++;
|
|
450
|
+
return value;
|
|
451
|
+
}
|
|
452
|
+
while (true) {
|
|
453
|
+
this.space();
|
|
454
|
+
if (this.text[this.index] !== '"') throw new JsonDecodeError('json-invalid', `expected an object key at byte ${this.index}`);
|
|
455
|
+
const key = this.string();
|
|
456
|
+
if (keys.has(key)) throw new JsonDecodeError('json-duplicate-member', `duplicate JSON member ${JSON.stringify(key)}`);
|
|
457
|
+
keys.add(key);
|
|
458
|
+
this.space();
|
|
459
|
+
if (this.text[this.index] !== ':') throw new JsonDecodeError('json-invalid', `expected ':' after ${JSON.stringify(key)}`);
|
|
460
|
+
this.index++;
|
|
461
|
+
value[key] = this.value();
|
|
462
|
+
this.space();
|
|
463
|
+
const delimiter = this.text[this.index];
|
|
464
|
+
if (delimiter === '}') {
|
|
465
|
+
this.index++;
|
|
466
|
+
return value;
|
|
467
|
+
}
|
|
468
|
+
if (delimiter !== ',') throw new JsonDecodeError('json-invalid', `expected ',' or '}' at byte ${this.index}`);
|
|
469
|
+
this.index++;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private array(): unknown[] {
|
|
474
|
+
this.index++;
|
|
475
|
+
const value: unknown[] = [];
|
|
476
|
+
this.space();
|
|
477
|
+
if (this.text[this.index] === ']') {
|
|
478
|
+
this.index++;
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
while (true) {
|
|
482
|
+
value.push(this.value());
|
|
483
|
+
this.space();
|
|
484
|
+
const delimiter = this.text[this.index];
|
|
485
|
+
if (delimiter === ']') {
|
|
486
|
+
this.index++;
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
if (delimiter !== ',') throw new JsonDecodeError('json-invalid', `expected ',' or ']' at byte ${this.index}`);
|
|
490
|
+
this.index++;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private string(): string {
|
|
495
|
+
const start = this.index;
|
|
496
|
+
this.index++;
|
|
497
|
+
let escaped = false;
|
|
498
|
+
while (this.index < this.text.length) {
|
|
499
|
+
const char = this.text[this.index] as string;
|
|
500
|
+
if (!escaped && char === '"') {
|
|
501
|
+
this.index++;
|
|
502
|
+
try {
|
|
503
|
+
return JSON.parse(this.text.slice(start, this.index)) as string;
|
|
504
|
+
} catch {
|
|
505
|
+
throw new JsonDecodeError('json-invalid-string', `invalid JSON string at byte ${start}`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!escaped && char.charCodeAt(0) < 0x20) throw new JsonDecodeError('json-invalid-string', `control character at byte ${this.index}`);
|
|
509
|
+
if (!escaped && char === '\\') escaped = true;
|
|
510
|
+
else escaped = false;
|
|
511
|
+
this.index++;
|
|
512
|
+
}
|
|
513
|
+
throw new JsonDecodeError('json-invalid-string', `unterminated JSON string at byte ${start}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private number(): number {
|
|
517
|
+
const match = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(this.text.slice(this.index));
|
|
518
|
+
if (!match || match.index !== 0) throw new JsonDecodeError('json-invalid-number', `invalid number at byte ${this.index}`);
|
|
519
|
+
this.index += match[0].length;
|
|
520
|
+
const value = Number(match[0]);
|
|
521
|
+
if (!Number.isFinite(value)) throw new JsonDecodeError('json-invalid-number', `non-finite number at byte ${this.index}`);
|
|
522
|
+
return value;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
527
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function exactKeys(
|
|
531
|
+
value: Record<string, unknown>,
|
|
532
|
+
allowed: readonly string[],
|
|
533
|
+
required: readonly string[],
|
|
534
|
+
location: string,
|
|
535
|
+
diagnostics: ContractDiagnostic[],
|
|
536
|
+
fields: Omit<ContractDiagnostic, 'code' | 'message' | 'observed'> = {},
|
|
537
|
+
): void {
|
|
538
|
+
const allowedSet = new Set(allowed);
|
|
539
|
+
for (const key of Object.keys(value)) {
|
|
540
|
+
if (!allowedSet.has(key)) diagnostics.push(diagnostic('verdict-unknown-member', `${location} contains unknown member ${key}`, { ...fields, observed: key }));
|
|
541
|
+
}
|
|
542
|
+
for (const key of required) {
|
|
543
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
544
|
+
diagnostics.push(diagnostic('verdict-member-missing', `${location} is missing required member ${key}`, { ...fields, observed: key }));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function nonEmptyString(value: unknown): value is string {
|
|
550
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function validateEvidence(
|
|
554
|
+
value: unknown,
|
|
555
|
+
location: string,
|
|
556
|
+
diagnostics: ContractDiagnostic[],
|
|
557
|
+
contractId?: string,
|
|
558
|
+
): ContractVerdictEvidence | null {
|
|
559
|
+
const fields = contractId === undefined ? {} : { contractId };
|
|
560
|
+
if (!isRecord(value)) {
|
|
561
|
+
diagnostics.push(diagnostic('verdict-evidence-type', `${location}.evidence must be an object`, fields));
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
exactKeys(value, ['artifact', 'quote', 'observedOutcome'], ['artifact', 'quote', 'observedOutcome'], `${location}.evidence`, diagnostics, fields);
|
|
565
|
+
if (!nonEmptyString(value['artifact'])) diagnostics.push(diagnostic('verdict-artifact-type', `${location}.evidence.artifact must be a non-empty string`, fields));
|
|
566
|
+
if (!nonEmptyString(value['quote'])) diagnostics.push(diagnostic('verdict-quote-type', `${location}.evidence.quote must be a non-empty string`, fields));
|
|
567
|
+
const observed = value['observedOutcome'];
|
|
568
|
+
if (observed !== 'pass' && observed !== 'fail' && observed !== 'not-testable') {
|
|
569
|
+
diagnostics.push(diagnostic('verdict-observed-outcome', `${location}.evidence.observedOutcome is not canonical`, { ...fields, observed: String(observed) }));
|
|
570
|
+
}
|
|
571
|
+
if (!nonEmptyString(value['artifact']) || !nonEmptyString(value['quote'])
|
|
572
|
+
|| (observed !== 'pass' && observed !== 'fail' && observed !== 'not-testable')) return null;
|
|
573
|
+
return { artifact: value['artifact'], quote: value['quote'], observedOutcome: observed };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function validateVerdictItem(
|
|
577
|
+
value: unknown,
|
|
578
|
+
index: number,
|
|
579
|
+
diagnostics: ContractDiagnostic[],
|
|
580
|
+
): ContractVerdictItem | null {
|
|
581
|
+
const location = `items[${index}]`;
|
|
582
|
+
if (!isRecord(value)) {
|
|
583
|
+
diagnostics.push(diagnostic('verdict-row-type', `${location} must be an object`, { observed: Array.isArray(value) ? 'array' : typeof value }));
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
const contractId = typeof value['id'] === 'string' ? value['id'] : undefined;
|
|
587
|
+
const fields = contractId === undefined ? {} : { contractId };
|
|
588
|
+
exactKeys(value, ['id', 'verdict', 'evidence', 'reason'], ['id', 'verdict', 'evidence'], location, diagnostics, fields);
|
|
589
|
+
if (!nonEmptyString(value['id'])) diagnostics.push(diagnostic('verdict-id-type', `${location}.id must be a non-empty string`, fields));
|
|
590
|
+
const verdict = value['verdict'];
|
|
591
|
+
if (verdict !== 'met' && verdict !== 'unmet' && verdict !== 'not-testable') {
|
|
592
|
+
diagnostics.push(diagnostic('verdict-status-invalid', `${location}.verdict is not canonical`, { ...fields, observed: String(verdict) }));
|
|
593
|
+
}
|
|
594
|
+
const evidence = validateEvidence(value['evidence'], location, diagnostics, contractId);
|
|
595
|
+
const reason = value['reason'];
|
|
596
|
+
if ((verdict === 'unmet' || verdict === 'not-testable') && !nonEmptyString(reason)) {
|
|
597
|
+
diagnostics.push(diagnostic(
|
|
598
|
+
'verdict-reason-required',
|
|
599
|
+
`${location}.reason must be non-empty for ${verdict}`,
|
|
600
|
+
fields,
|
|
601
|
+
));
|
|
602
|
+
}
|
|
603
|
+
if (reason !== undefined && !nonEmptyString(reason)) {
|
|
604
|
+
diagnostics.push(diagnostic('verdict-reason-type', `${location}.reason must be a non-empty string when present`, fields));
|
|
605
|
+
}
|
|
606
|
+
if (!nonEmptyString(value['id']) || (verdict !== 'met' && verdict !== 'unmet' && verdict !== 'not-testable') || evidence === null) return null;
|
|
607
|
+
if ((verdict === 'unmet' || verdict === 'not-testable') && !nonEmptyString(reason)) return null;
|
|
608
|
+
return {
|
|
609
|
+
id: value['id'],
|
|
610
|
+
verdict,
|
|
611
|
+
evidence,
|
|
612
|
+
...(nonEmptyString(reason) ? { reason } : {}),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function validateVerdictPayload(value: unknown):
|
|
617
|
+
| { ok: true; report: ContractVerdictReport }
|
|
618
|
+
| { ok: false; diagnostics: ContractDiagnostic[] } {
|
|
619
|
+
const diagnostics: ContractDiagnostic[] = [];
|
|
620
|
+
if (!isRecord(value)) {
|
|
621
|
+
return { ok: false, diagnostics: [diagnostic('verdict-payload-type', 'the verdict payload must be a JSON object')] };
|
|
622
|
+
}
|
|
623
|
+
exactKeys(value, ['schema', 'overallGrade', 'items'], ['schema', 'overallGrade', 'items'], 'payload', diagnostics);
|
|
624
|
+
if (value['schema'] !== CONTRACT_VERDICT_SCHEMA) {
|
|
625
|
+
diagnostics.push(diagnostic('verdict-schema-unsupported', `unsupported verdict schema ${String(value['schema'])}`, { observed: String(value['schema']) }));
|
|
626
|
+
}
|
|
627
|
+
const grade = value['overallGrade'];
|
|
628
|
+
if (grade !== 'A' && grade !== 'B' && grade !== 'C' && grade !== 'D') {
|
|
629
|
+
diagnostics.push(diagnostic('verdict-grade-invalid', 'overallGrade must be A, B, C, or D', { observed: String(grade) }));
|
|
630
|
+
}
|
|
631
|
+
const rawItems = value['items'];
|
|
632
|
+
if (!Array.isArray(rawItems)) {
|
|
633
|
+
diagnostics.push(diagnostic('verdict-items-type', 'payload.items must be an array'));
|
|
634
|
+
}
|
|
635
|
+
const items: ContractVerdictItem[] = [];
|
|
636
|
+
if (Array.isArray(rawItems)) {
|
|
637
|
+
for (let index = 0; index < rawItems.length; index++) {
|
|
638
|
+
const item = validateVerdictItem(rawItems[index], index, diagnostics);
|
|
639
|
+
if (item !== null) items.push(item);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (diagnostics.length > 0 || value['schema'] !== CONTRACT_VERDICT_SCHEMA
|
|
643
|
+
|| (grade !== 'A' && grade !== 'B' && grade !== 'C' && grade !== 'D') || !Array.isArray(rawItems)) {
|
|
644
|
+
return { ok: false, diagnostics };
|
|
645
|
+
}
|
|
646
|
+
return { ok: true, report: { schema: CONTRACT_VERDICT_SCHEMA, overallGrade: grade, items } };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function parseHumanGrade(text: string):
|
|
650
|
+
| { ok: true; grade: ContractGrade }
|
|
651
|
+
| { ok: false; diagnostic: ContractDiagnostic } {
|
|
652
|
+
const lines = linesOf(text);
|
|
653
|
+
const grades: ContractGrade[] = [];
|
|
654
|
+
for (let index = 0; index < lines.length; index++) {
|
|
655
|
+
const heading = /^## Grade: \*\*([A-D])\*\*$/.exec(lines[index] ?? '');
|
|
656
|
+
if (heading) grades.push(heading[1] as ContractGrade);
|
|
657
|
+
if (lines[index] === '## Grade') {
|
|
658
|
+
const end = h2End(lines, index);
|
|
659
|
+
for (let cursor = index + 1; cursor < end; cursor++) {
|
|
660
|
+
const field = /^\*\*Grade: ([A-D])\*\*$/.exec(lines[cursor] ?? '');
|
|
661
|
+
if (field) grades.push(field[1] as ContractGrade);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (grades.length !== 1) {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
diagnostic: diagnostic(
|
|
669
|
+
'report-grade-ambiguous',
|
|
670
|
+
`expected exactly one human Grade A-D value; found ${grades.length}`,
|
|
671
|
+
{ section: '## Grade', observed: grades },
|
|
672
|
+
),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return { ok: true, grade: grades[0] as ContractGrade };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export function parseContractVerdictReport(text: string): ContractVerdictParseResult {
|
|
679
|
+
const lines = linesOf(text);
|
|
680
|
+
const headings = h2Indexes(lines, VERDICT_HEADING);
|
|
681
|
+
if (headings.length === 0) {
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
established: false,
|
|
685
|
+
diagnostics: [diagnostic('verdict-section-missing', `missing canonical ${VERDICT_HEADING} section`, { section: VERDICT_HEADING })],
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
if (headings.length !== 1) {
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
established: true,
|
|
692
|
+
diagnostics: [diagnostic(
|
|
693
|
+
'verdict-section-duplicate',
|
|
694
|
+
`expected exactly one ${VERDICT_HEADING} section; found ${headings.length}`,
|
|
695
|
+
{ section: VERDICT_HEADING, observed: headings.length },
|
|
696
|
+
)],
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const start = headings[0] as number;
|
|
700
|
+
const section = lines.slice(start + 1, h2End(lines, start)).join('\n').trim();
|
|
701
|
+
const fence = /^```json\n([\s\S]*)\n```$/.exec(section);
|
|
702
|
+
if (!fence) {
|
|
703
|
+
return {
|
|
704
|
+
ok: false,
|
|
705
|
+
established: true,
|
|
706
|
+
diagnostics: [diagnostic(
|
|
707
|
+
'verdict-fence-invalid',
|
|
708
|
+
'the Contract checklist section must contain exactly one fenced json object and no other content',
|
|
709
|
+
{ section: VERDICT_HEADING },
|
|
710
|
+
)],
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
let decoded: unknown;
|
|
714
|
+
try {
|
|
715
|
+
decoded = new DuplicateSafeJsonDecoder(fence[1] as string).decode();
|
|
716
|
+
} catch (error) {
|
|
717
|
+
const jsonError = error instanceof JsonDecodeError ? error : new JsonDecodeError('json-invalid', String(error));
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
established: true,
|
|
721
|
+
diagnostics: [diagnostic(jsonError.code, jsonError.message, { section: VERDICT_HEADING })],
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
const payload = validateVerdictPayload(decoded);
|
|
725
|
+
if (!payload.ok) {
|
|
726
|
+
return {
|
|
727
|
+
ok: false,
|
|
728
|
+
established: true,
|
|
729
|
+
diagnostics: payload.diagnostics.map((entry) => ({ ...entry, section: entry.section ?? VERDICT_HEADING })),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const human = parseHumanGrade(text);
|
|
733
|
+
if (!human.ok) return { ok: false, established: true, diagnostics: [human.diagnostic] };
|
|
734
|
+
if (human.grade !== payload.report.overallGrade) {
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
established: true,
|
|
738
|
+
diagnostics: [diagnostic(
|
|
739
|
+
'report-grade-mismatch',
|
|
740
|
+
`human Grade ${human.grade} disagrees with payload overallGrade ${payload.report.overallGrade}`,
|
|
741
|
+
{ section: '## Grade', observed: [human.grade, payload.report.overallGrade] },
|
|
742
|
+
)],
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
return { ok: true, report: payload.report, humanGrade: human.grade, diagnostics: [] };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function contractIdNumber(id: string): number | null {
|
|
749
|
+
const match = /^CC-([1-9][0-9]*)$/.exec(id);
|
|
750
|
+
return match ? Number(match[1]) : null;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function quoteOccurrences(text: string, quote: string): number {
|
|
754
|
+
let count = 0;
|
|
755
|
+
let index = 0;
|
|
756
|
+
while (index <= text.length - quote.length) {
|
|
757
|
+
const found = text.indexOf(quote, index);
|
|
758
|
+
if (found < 0) break;
|
|
759
|
+
count++;
|
|
760
|
+
index = found + 1;
|
|
761
|
+
}
|
|
762
|
+
return count;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function safeEvidenceArtifact(artifact: string): string | null {
|
|
766
|
+
if (artifact.startsWith('/') || artifact.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(artifact)) return 'absolute paths are forbidden';
|
|
767
|
+
if (artifact.includes('\\')) return 'backslash path separators are forbidden';
|
|
768
|
+
const parts = artifact.split('/');
|
|
769
|
+
if (parts.some((part) => part === '' || part === '.' || part === '..')) return 'empty, dot, and traversal path segments are forbidden';
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function baseCounts(checklist: ContractChecklist, report: unknown): ContractVerificationCounts {
|
|
774
|
+
// `verifyContractVerdicts` is a public runtime boundary even though TypeScript callers receive a
|
|
775
|
+
// typed signature. Count defensively before the closed-schema validator reports malformed rows.
|
|
776
|
+
const rows: readonly unknown[] = isRecord(report) && Array.isArray(report['items']) ? report['items'] : [];
|
|
777
|
+
const hasVerdict = (value: unknown, verdict: ContractVerdict): boolean => isRecord(value) && value['verdict'] === verdict;
|
|
778
|
+
return {
|
|
779
|
+
contractItems: checklist.items.length,
|
|
780
|
+
verdictItems: rows.length,
|
|
781
|
+
met: rows.filter((item) => hasVerdict(item, 'met')).length,
|
|
782
|
+
unmet: rows.filter((item) => hasVerdict(item, 'unmet')).length,
|
|
783
|
+
notTestable: rows.filter((item) => hasVerdict(item, 'not-testable')).length,
|
|
784
|
+
missing: 0,
|
|
785
|
+
duplicate: 0,
|
|
786
|
+
orphan: 0,
|
|
787
|
+
invalidEvidence: 0,
|
|
788
|
+
gradeConflicts: 0,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function failedVerification(
|
|
793
|
+
checklist: ContractChecklist,
|
|
794
|
+
report: unknown,
|
|
795
|
+
diagnostics: readonly ContractDiagnostic[],
|
|
796
|
+
counts: ContractVerificationCounts,
|
|
797
|
+
items?: readonly ContractItemVerification[],
|
|
798
|
+
): ContractVerification {
|
|
799
|
+
return {
|
|
800
|
+
outcome: 'fail',
|
|
801
|
+
exitCode: 1,
|
|
802
|
+
overallGrade: isRecord(report)
|
|
803
|
+
&& (report['overallGrade'] === 'A' || report['overallGrade'] === 'B'
|
|
804
|
+
|| report['overallGrade'] === 'C' || report['overallGrade'] === 'D')
|
|
805
|
+
? report['overallGrade']
|
|
806
|
+
: null,
|
|
807
|
+
items: items ?? checklist.items.map((item) => ({
|
|
808
|
+
id: item.id,
|
|
809
|
+
verdict: null,
|
|
810
|
+
evidence: 'not-checked',
|
|
811
|
+
diagnostics: diagnostics.filter((entry) => entry.contractId === item.id),
|
|
812
|
+
})),
|
|
813
|
+
counts,
|
|
814
|
+
diagnostics,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
export function verifyContractVerdicts(
|
|
819
|
+
checklist: ContractChecklist,
|
|
820
|
+
report: ContractVerdictReport,
|
|
821
|
+
evidence: ContractEvidenceReader,
|
|
822
|
+
): ContractVerification {
|
|
823
|
+
const runtime = validateVerdictPayload(report);
|
|
824
|
+
let counts = baseCounts(checklist, report);
|
|
825
|
+
if (!runtime.ok) return failedVerification(checklist, report, runtime.diagnostics, counts);
|
|
826
|
+
report = runtime.report;
|
|
827
|
+
counts = baseCounts(checklist, report);
|
|
828
|
+
|
|
829
|
+
const diagnostics: ContractDiagnostic[] = [];
|
|
830
|
+
if (checklist.schema !== CONTRACT_CHECKLIST_SCHEMA || checklist.items.length === 0) {
|
|
831
|
+
diagnostics.push(diagnostic(
|
|
832
|
+
checklist.schema !== CONTRACT_CHECKLIST_SCHEMA ? 'contract-schema-unsupported' : 'contract-empty',
|
|
833
|
+
checklist.schema !== CONTRACT_CHECKLIST_SCHEMA ? `unsupported contract schema ${String(checklist.schema)}` : 'the contract contains zero items',
|
|
834
|
+
));
|
|
835
|
+
return failedVerification(checklist, report, diagnostics, counts);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const expectedIds = checklist.items.map((item) => item.id);
|
|
839
|
+
const reportedIds = report.items.map((item) => item.id);
|
|
840
|
+
const frequencies = new Map<string, number>();
|
|
841
|
+
for (const id of reportedIds) frequencies.set(id, (frequencies.get(id) ?? 0) + 1);
|
|
842
|
+
|
|
843
|
+
let duplicate = 0;
|
|
844
|
+
for (const [id, frequency] of frequencies) {
|
|
845
|
+
if (frequency > 1) {
|
|
846
|
+
duplicate += frequency - 1;
|
|
847
|
+
diagnostics.push(diagnostic(
|
|
848
|
+
'verdict-id-duplicate',
|
|
849
|
+
`verdict id ${id} occurs ${frequency} times`,
|
|
850
|
+
{ contractId: id, observed: frequency },
|
|
851
|
+
));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const reportedSet = new Set(reportedIds);
|
|
855
|
+
const expectedSet = new Set(expectedIds);
|
|
856
|
+
const missing = expectedIds.filter((id) => !reportedSet.has(id));
|
|
857
|
+
const orphan = [...reportedSet].filter((id) => !expectedSet.has(id));
|
|
858
|
+
for (const id of missing) diagnostics.push(diagnostic('verdict-id-missing', `contract id ${id} has no verdict`, { contractId: id }));
|
|
859
|
+
for (const id of orphan) diagnostics.push(diagnostic(
|
|
860
|
+
contractIdNumber(id) === null ? 'verdict-id-malformed' : 'verdict-id-orphan',
|
|
861
|
+
`reported verdict id ${id} is not an expected contract id`,
|
|
862
|
+
{ contractId: id },
|
|
863
|
+
));
|
|
864
|
+
if (missing.length === 0 && orphan.length === 0 && duplicate === 0
|
|
865
|
+
&& expectedIds.some((id, index) => reportedIds[index] !== id)) {
|
|
866
|
+
diagnostics.push(diagnostic(
|
|
867
|
+
'verdict-id-order',
|
|
868
|
+
'verdict ids contain the expected set but not in contract order',
|
|
869
|
+
{ observed: reportedIds },
|
|
870
|
+
));
|
|
871
|
+
}
|
|
872
|
+
counts = { ...counts, missing: missing.length, duplicate, orphan: orphan.length };
|
|
873
|
+
if (diagnostics.length > 0) return failedVerification(checklist, report, diagnostics, counts);
|
|
874
|
+
|
|
875
|
+
const itemResults: ContractItemVerification[] = [];
|
|
876
|
+
let invalidEvidence = 0;
|
|
877
|
+
for (let index = 0; index < checklist.items.length; index++) {
|
|
878
|
+
const contract = checklist.items[index] as ContractItem;
|
|
879
|
+
const verdict = report.items[index] as ContractVerdictItem;
|
|
880
|
+
const itemDiagnostics: ContractDiagnostic[] = [];
|
|
881
|
+
const pathProblem = safeEvidenceArtifact(verdict.evidence.artifact);
|
|
882
|
+
if (pathProblem !== null) {
|
|
883
|
+
itemDiagnostics.push(diagnostic(
|
|
884
|
+
'evidence-path-unsafe',
|
|
885
|
+
`${verdict.evidence.artifact}: ${pathProblem}`,
|
|
886
|
+
{ artifact: verdict.evidence.artifact, contractId: contract.id },
|
|
887
|
+
));
|
|
888
|
+
} else if (evidence.reportArtifact !== undefined && verdict.evidence.artifact === evidence.reportArtifact) {
|
|
889
|
+
itemDiagnostics.push(diagnostic(
|
|
890
|
+
'evidence-self-citation',
|
|
891
|
+
`${contract.id} cites the QE verdict payload itself`,
|
|
892
|
+
{ artifact: verdict.evidence.artifact, contractId: contract.id },
|
|
893
|
+
));
|
|
894
|
+
} else {
|
|
895
|
+
const read = evidence.read(verdict.evidence.artifact);
|
|
896
|
+
if (!read.ok) {
|
|
897
|
+
itemDiagnostics.push(diagnostic(
|
|
898
|
+
read.code,
|
|
899
|
+
read.detail,
|
|
900
|
+
{ artifact: verdict.evidence.artifact, contractId: contract.id },
|
|
901
|
+
));
|
|
902
|
+
} else {
|
|
903
|
+
const matches = quoteOccurrences(read.text, verdict.evidence.quote);
|
|
904
|
+
if (matches !== 1) {
|
|
905
|
+
itemDiagnostics.push(diagnostic(
|
|
906
|
+
'evidence-quote-count',
|
|
907
|
+
`${contract.id} evidence quote occurs ${matches} times; expected exactly once`,
|
|
908
|
+
{ artifact: verdict.evidence.artifact, contractId: contract.id, observed: matches },
|
|
909
|
+
));
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
const expectedOutcome: Record<ContractVerdict, ContractObservedOutcome> = {
|
|
914
|
+
met: 'pass',
|
|
915
|
+
unmet: 'fail',
|
|
916
|
+
'not-testable': 'not-testable',
|
|
917
|
+
};
|
|
918
|
+
if (verdict.evidence.observedOutcome !== expectedOutcome[verdict.verdict]) {
|
|
919
|
+
itemDiagnostics.push(diagnostic(
|
|
920
|
+
'evidence-outcome-polarity',
|
|
921
|
+
`${contract.id} verdict ${verdict.verdict} requires observedOutcome ${expectedOutcome[verdict.verdict]}`,
|
|
922
|
+
{
|
|
923
|
+
artifact: verdict.evidence.artifact,
|
|
924
|
+
contractId: contract.id,
|
|
925
|
+
observed: verdict.evidence.observedOutcome,
|
|
926
|
+
},
|
|
927
|
+
));
|
|
928
|
+
}
|
|
929
|
+
if (verdict.verdict === 'unmet') {
|
|
930
|
+
itemDiagnostics.push(diagnostic(
|
|
931
|
+
'contract-item-unmet',
|
|
932
|
+
`${contract.id} is unmet: ${verdict.reason ?? 'no reason recorded'}`,
|
|
933
|
+
{ contractId: contract.id },
|
|
934
|
+
));
|
|
935
|
+
} else if (verdict.verdict === 'not-testable') {
|
|
936
|
+
itemDiagnostics.push(diagnostic(
|
|
937
|
+
'contract-item-not-testable',
|
|
938
|
+
`${contract.id} is not-testable: ${verdict.reason ?? 'no reason recorded'}`,
|
|
939
|
+
{ contractId: contract.id },
|
|
940
|
+
));
|
|
941
|
+
}
|
|
942
|
+
const evidenceErrors = itemDiagnostics.filter((entry) => entry.code.startsWith('evidence-')).length;
|
|
943
|
+
if (evidenceErrors > 0) invalidEvidence++;
|
|
944
|
+
diagnostics.push(...itemDiagnostics);
|
|
945
|
+
itemResults.push({
|
|
946
|
+
id: contract.id,
|
|
947
|
+
verdict: verdict.verdict,
|
|
948
|
+
evidence: evidenceErrors > 0 ? 'invalid' : 'valid',
|
|
949
|
+
...(verdict.reason !== undefined ? { reason: verdict.reason } : {}),
|
|
950
|
+
diagnostics: itemDiagnostics,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const conflicts = report.items.filter((item) => item.verdict === 'unmet').map((item) => item.id);
|
|
955
|
+
const gradeConflicts = (report.overallGrade === 'A' || report.overallGrade === 'B') ? conflicts : [];
|
|
956
|
+
if (gradeConflicts.length > 0) {
|
|
957
|
+
diagnostics.push(diagnostic(
|
|
958
|
+
'grade-unmet-conflict',
|
|
959
|
+
`overall grade ${report.overallGrade} cannot coexist with unmet items: ${gradeConflicts.join(', ')}`,
|
|
960
|
+
{ observed: gradeConflicts },
|
|
961
|
+
));
|
|
962
|
+
}
|
|
963
|
+
counts = { ...counts, invalidEvidence, gradeConflicts: gradeConflicts.length };
|
|
964
|
+
if (diagnostics.length > 0) return failedVerification(checklist, report, diagnostics, counts, itemResults);
|
|
965
|
+
return {
|
|
966
|
+
outcome: 'pass',
|
|
967
|
+
exitCode: 0,
|
|
968
|
+
overallGrade: report.overallGrade,
|
|
969
|
+
items: itemResults,
|
|
970
|
+
counts,
|
|
971
|
+
diagnostics: [],
|
|
972
|
+
};
|
|
973
|
+
}
|