rcf-lite 0.19.0 → 0.20.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/CHANGELOG.md +15 -0
- package/bin/rcf.js +5 -0
- package/fixtures/canary-manifest.json +6 -6
- package/package.json +2 -2
- package/rcf/code-nodes/cn-098.json +13 -0
- package/rcf/code-nodes/cn-099.json +13 -0
- package/rcf/code-nodes/cn-100.json +13 -0
- package/rcf/code-nodes/cn-101.json +13 -0
- package/rcf/code-nodes/cn-102.json +13 -0
- package/rcf/code-nodes/cn-103.json +13 -0
- package/rcf/code-nodes/cn-104.json +13 -0
- package/rcf/code-nodes/cn-105.json +13 -0
- package/rcf/evals/eval-001.json +55 -0
- package/rcf/fbs/fbs-035.json +18 -0
- package/rcf/requirements/req-016.json +37 -0
- package/rcf/test-suites/ts-045.json +46 -0
- package/rcf/user-stories/us-1601.json +37 -0
- package/releases/releases.yaml +10 -1
- package/src/cli/create.js +14 -0
- package/src/cli/eval-coverage.js +221 -0
- package/src/cli/eval.js +43 -0
- package/src/cli/finalise.js +64 -0
- package/src/cli/help.js +4 -0
- package/src/core/store/ids.js +5 -1
- package/src/core/store/init.js +4 -0
- package/src/core/store/loader.js +4 -0
- package/src/core/store/validator.js +8 -1
- package/src/core/store/walker.js +71 -2
- package/src/core/store/writer.js +4 -0
- package/src/eval/judge.js +338 -0
- package/src/finalise/index.js +8 -0
- package/src/finalise/ingest.js +27 -0
- package/src/finalise/ship-without-eval.js +123 -0
- package/src/query/eval-coverage.js +162 -0
- package/src/verify/chain/index.js +67 -0
- package/src/verify/cli/run.js +15 -0
- package/src/verify/engine/index.js +10 -0
- package/src/verify/verdict/index.js +46 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// LLM-as-judge run path for an EVAL. rcf-eval-node spec 2026-09-04
|
|
2
|
+
// section 6: `judge.type: llmJudge` runs by spawning `claude` (or
|
|
3
|
+
// `codex`) on PATH under subscription auth, capturing structured JSON
|
|
4
|
+
// on stdout, validating against the declared response schema, and
|
|
5
|
+
// appending a runRecord[] entry on the EVAL document.
|
|
6
|
+
//
|
|
7
|
+
// Estate rule (banked): every model call goes through the `claude` or
|
|
8
|
+
// `codex` CLI. No API keys, no HTTP fetches into rcf-lite. The verify
|
|
9
|
+
// launcher demonstrates the shape today (spawn `claude` by bare name).
|
|
10
|
+
//
|
|
11
|
+
// This module is invocation-only: the spawn contract, stdout capture,
|
|
12
|
+
// schema validation, and runRecord append. Case orchestration
|
|
13
|
+
// (walking cases[], rolling up per-case scores, applying critical-must-
|
|
14
|
+
// pass) is intentionally minimal at v1 (spec: v1 ships one canonical
|
|
15
|
+
// invocation, not a full runner).
|
|
16
|
+
|
|
17
|
+
import { readFile } from 'node:fs/promises';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
|
|
20
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
21
|
+
import addFormats from 'ajv-formats';
|
|
22
|
+
|
|
23
|
+
import { rcfError } from '#core/errors';
|
|
24
|
+
|
|
25
|
+
const RESPONSE_ENVELOPE_HINT = {
|
|
26
|
+
type: 'object',
|
|
27
|
+
required: ['perCriterion'],
|
|
28
|
+
properties: {
|
|
29
|
+
perCriterion: {
|
|
30
|
+
type: 'array',
|
|
31
|
+
items: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
required: ['id', 'score'],
|
|
34
|
+
properties: {
|
|
35
|
+
id: { type: 'string' },
|
|
36
|
+
score: { type: 'number', minimum: 0, maximum: 1 },
|
|
37
|
+
rationale: { type: 'string' },
|
|
38
|
+
criticalPass: { type: 'boolean' },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
aggregateScore: { type: 'number', minimum: 0, maximum: 1 },
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} RunOneCaseArgs
|
|
48
|
+
* @property {object} evalDoc the EVAL document
|
|
49
|
+
* @property {object} caseDoc one entry from evalDoc.cases[]
|
|
50
|
+
* @property {string} projectRoot absolute project root
|
|
51
|
+
* @property {string} [systemPromptText] the resolved system prompt text
|
|
52
|
+
* @property {object} [responseSchema] the resolved response schema
|
|
53
|
+
* @property {function} [spawnImpl] seam for tests
|
|
54
|
+
* @property {number} [timeoutMs] default 60_000
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Load the harness system prompt and response schema for an EVAL, from
|
|
59
|
+
* `judge.harness.systemPromptPath` and `judge.harness.responseSchemaPath`
|
|
60
|
+
* (paths relative to the project root). Returns a structured error
|
|
61
|
+
* when either cannot be read or the schema is not valid JSON.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} args
|
|
64
|
+
* @param {object} args.evalDoc
|
|
65
|
+
* @param {string} args.projectRoot
|
|
66
|
+
* @returns {Promise<{ systemPromptText: string|null, responseSchema: object|null } | import('#core/errors').RcfError>}
|
|
67
|
+
*/
|
|
68
|
+
export async function loadJudgeHarness({ evalDoc, projectRoot }) {
|
|
69
|
+
const harness = evalDoc?.judge?.harness;
|
|
70
|
+
if (!harness) {
|
|
71
|
+
return { systemPromptText: null, responseSchema: RESPONSE_ENVELOPE_HINT };
|
|
72
|
+
}
|
|
73
|
+
let systemPromptText = null;
|
|
74
|
+
if (typeof harness.systemPromptPath === 'string') {
|
|
75
|
+
try {
|
|
76
|
+
systemPromptText = await readFile(`${projectRoot}/${harness.systemPromptPath}`, 'utf8');
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return rcfError({
|
|
79
|
+
kind: 'ioFailure',
|
|
80
|
+
message: `eval judge: could not read systemPromptPath ${harness.systemPromptPath}: ${err.message}`,
|
|
81
|
+
filePath: harness.systemPromptPath,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let responseSchema = null;
|
|
86
|
+
if (typeof harness.responseSchemaPath === 'string') {
|
|
87
|
+
try {
|
|
88
|
+
const raw = await readFile(`${projectRoot}/${harness.responseSchemaPath}`, 'utf8');
|
|
89
|
+
responseSchema = JSON.parse(raw);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return rcfError({
|
|
92
|
+
kind: 'parseFailure',
|
|
93
|
+
message: `eval judge: could not read/parse responseSchemaPath ${harness.responseSchemaPath}: ${err.message}`,
|
|
94
|
+
filePath: harness.responseSchemaPath,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { systemPromptText, responseSchema: responseSchema ?? RESPONSE_ENVELOPE_HINT };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run one case through the LLM judge. Spawns the invoker CLI (`claude`
|
|
103
|
+
* by default; `codex` for consumers on the codex leg) with the system
|
|
104
|
+
* prompt via `-p` and the case payload on stdin. Captures stdout,
|
|
105
|
+
* validates against the response schema, and returns the parsed graded
|
|
106
|
+
* result. Model-hint is passed via `--model` when supplied by the
|
|
107
|
+
* harness; otherwise the CLI's default model resolves.
|
|
108
|
+
*
|
|
109
|
+
* @param {RunOneCaseArgs} args
|
|
110
|
+
* @returns {Promise<{ graded: object, invoker: string, modelHintUsed: string|null, rawStdout: string } | import('#core/errors').RcfError>}
|
|
111
|
+
*/
|
|
112
|
+
export async function runOneCase(args) {
|
|
113
|
+
const {
|
|
114
|
+
evalDoc, caseDoc,
|
|
115
|
+
systemPromptText = null,
|
|
116
|
+
responseSchema = RESPONSE_ENVELOPE_HINT,
|
|
117
|
+
spawnImpl = spawn,
|
|
118
|
+
timeoutMs = 60_000,
|
|
119
|
+
} = args;
|
|
120
|
+
const harness = evalDoc?.judge?.harness;
|
|
121
|
+
if (!harness) {
|
|
122
|
+
return rcfError({
|
|
123
|
+
kind: 'usage',
|
|
124
|
+
message: 'eval judge: judge.harness is required for llmJudge',
|
|
125
|
+
documentId: evalDoc?.id,
|
|
126
|
+
field: 'judge.harness',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const invoker = harness.invoker;
|
|
130
|
+
if (invoker !== 'claude' && invoker !== 'codex') {
|
|
131
|
+
return rcfError({
|
|
132
|
+
kind: 'usage',
|
|
133
|
+
message: `eval judge: unsupported invoker '${invoker}' (expected 'claude' or 'codex')`,
|
|
134
|
+
documentId: evalDoc?.id,
|
|
135
|
+
field: 'judge.harness.invoker',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = {
|
|
140
|
+
caseId: caseDoc?.id ?? null,
|
|
141
|
+
input: caseDoc?.input ?? null,
|
|
142
|
+
...(caseDoc?.expected !== undefined ? { expected: caseDoc.expected } : {}),
|
|
143
|
+
criteria: (evalDoc.criteria ?? []).map((c) => ({
|
|
144
|
+
id: c.id,
|
|
145
|
+
description: c.description,
|
|
146
|
+
critical: Boolean(c.critical),
|
|
147
|
+
})),
|
|
148
|
+
criteriaIds: caseDoc?.criteriaIds ?? [],
|
|
149
|
+
notes: caseDoc?.notes ?? '',
|
|
150
|
+
};
|
|
151
|
+
const stdinText = JSON.stringify(payload);
|
|
152
|
+
const argv = [];
|
|
153
|
+
if (systemPromptText) argv.push('-p', systemPromptText);
|
|
154
|
+
if (typeof harness.modelHint === 'string' && harness.modelHint.length > 0) {
|
|
155
|
+
argv.push('--model', harness.modelHint);
|
|
156
|
+
}
|
|
157
|
+
argv.push('--output-format', 'json');
|
|
158
|
+
|
|
159
|
+
const outcome = await spawnAndCollect(invoker, argv, stdinText, { spawnImpl, timeoutMs });
|
|
160
|
+
if ('kind' in outcome && outcome.kind === 'ioFailure') return outcome;
|
|
161
|
+
|
|
162
|
+
let graded;
|
|
163
|
+
try {
|
|
164
|
+
graded = JSON.parse(outcome.stdout.trim());
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return rcfError({
|
|
167
|
+
kind: 'parseFailure',
|
|
168
|
+
message: `eval judge: invoker '${invoker}' stdout did not parse as JSON: ${err.message}`,
|
|
169
|
+
documentId: evalDoc.id,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
173
|
+
addFormats(ajv);
|
|
174
|
+
const validate = ajv.compile(responseSchema);
|
|
175
|
+
if (!validate(graded)) {
|
|
176
|
+
const first = (validate.errors ?? [])[0];
|
|
177
|
+
return rcfError({
|
|
178
|
+
kind: 'validation',
|
|
179
|
+
message: `eval judge: response failed schema at ${first?.instancePath ?? '/'} ${first?.message ?? 'invalid'}`,
|
|
180
|
+
documentId: evalDoc.id,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
graded,
|
|
185
|
+
invoker,
|
|
186
|
+
modelHintUsed: harness.modelHint ?? null,
|
|
187
|
+
rawStdout: outcome.stdout,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Roll up per-case graded outputs into a runRecord[] entry. Aggregate
|
|
193
|
+
* score is the mean of perCriterion.score across cases (weighted by
|
|
194
|
+
* criterion.weight when supplied). Critical failures are aggregated
|
|
195
|
+
* from any case's perCriterion entry with criticalPass=false on a
|
|
196
|
+
* criterion declared critical.
|
|
197
|
+
*
|
|
198
|
+
* @param {object} args
|
|
199
|
+
* @param {object} args.evalDoc
|
|
200
|
+
* @param {Array<{ caseId: string, graded: object, modelHintUsed: string|null }>} args.cases
|
|
201
|
+
* @param {string} args.runner
|
|
202
|
+
* @param {Date} [args.now]
|
|
203
|
+
* @returns {object} runRecord entry
|
|
204
|
+
*/
|
|
205
|
+
export function composeRunRecord({ evalDoc, cases, runner, now = new Date() }) {
|
|
206
|
+
const criteriaById = new Map((evalDoc.criteria ?? []).map((c) => [c.id, c]));
|
|
207
|
+
const perCaseScores = [];
|
|
208
|
+
const criticalFailures = [];
|
|
209
|
+
let totalWeight = 0;
|
|
210
|
+
let weightedSum = 0;
|
|
211
|
+
for (const c of cases) {
|
|
212
|
+
const graded = c.graded ?? {};
|
|
213
|
+
const perCrit = Array.isArray(graded.perCriterion) ? graded.perCriterion : [];
|
|
214
|
+
let caseSum = 0;
|
|
215
|
+
let caseWeight = 0;
|
|
216
|
+
for (const entry of perCrit) {
|
|
217
|
+
const crit = criteriaById.get(entry.id);
|
|
218
|
+
const weight = typeof crit?.weight === 'number' && crit.weight > 0 ? crit.weight : 1;
|
|
219
|
+
const score = typeof entry.score === 'number' ? Math.max(0, Math.min(1, entry.score)) : 0;
|
|
220
|
+
caseWeight += weight;
|
|
221
|
+
caseSum += weight * score;
|
|
222
|
+
if (crit?.critical && entry.criticalPass === false) {
|
|
223
|
+
criticalFailures.push({ caseId: c.caseId, criterionId: entry.id });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const caseAggregate = caseWeight > 0 ? caseSum / caseWeight : 0;
|
|
227
|
+
// rcf-schemas 0.6.0 perCaseScore shape: { caseId, score } with
|
|
228
|
+
// additionalProperties false. Score is the weighted aggregate 0..1.
|
|
229
|
+
perCaseScores.push({ caseId: c.caseId, score: caseAggregate });
|
|
230
|
+
totalWeight += caseWeight;
|
|
231
|
+
weightedSum += caseSum;
|
|
232
|
+
}
|
|
233
|
+
const aggregateScore = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
234
|
+
const passThreshold = evalDoc.passThreshold ?? {};
|
|
235
|
+
const criticalMustPass = passThreshold.criticalMustPass !== false;
|
|
236
|
+
const meetsAggregate = aggregateScore >= (typeof passThreshold.aggregateScore === 'number' ? passThreshold.aggregateScore : 0.85);
|
|
237
|
+
const criticalOk = !(criticalMustPass && criticalFailures.length > 0);
|
|
238
|
+
const verdict = meetsAggregate && criticalOk ? 'pass' : 'fail';
|
|
239
|
+
const modelPinned = cases.find((c) => c.modelHintUsed)?.modelHintUsed ?? null;
|
|
240
|
+
const record = {
|
|
241
|
+
runId: `${now.toISOString()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
242
|
+
runAt: now.toISOString(),
|
|
243
|
+
runner,
|
|
244
|
+
aggregateScore,
|
|
245
|
+
criticalFailures,
|
|
246
|
+
perCaseScores,
|
|
247
|
+
verdict,
|
|
248
|
+
};
|
|
249
|
+
if (modelPinned) record.modelPinned = modelPinned;
|
|
250
|
+
return record;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Append a runRecord[] entry to an EVAL document.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} args
|
|
257
|
+
* @param {object} args.evalDoc
|
|
258
|
+
* @param {object} args.runRecord
|
|
259
|
+
* @returns {object} updated evalDoc (new object; caller writes it)
|
|
260
|
+
*/
|
|
261
|
+
export function appendRunRecord({ evalDoc, runRecord }) {
|
|
262
|
+
const runs = Array.isArray(evalDoc.runRecord) ? evalDoc.runRecord : [];
|
|
263
|
+
return {
|
|
264
|
+
...evalDoc,
|
|
265
|
+
runRecord: [...runs, runRecord],
|
|
266
|
+
updatedAt: new Date().toISOString(),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Spawn the invoker and collect stdout / stderr. Returns
|
|
272
|
+
* `{ stdout, stderr, code }` on success, or a rcfError on failure.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} invoker
|
|
275
|
+
* @param {string[]} argv
|
|
276
|
+
* @param {string} stdinText
|
|
277
|
+
* @param {{ spawnImpl?: function, timeoutMs?: number }} opts
|
|
278
|
+
* @returns {Promise<{ stdout: string, stderr: string, code: number|null } | import('#core/errors').RcfError>}
|
|
279
|
+
*/
|
|
280
|
+
export function spawnAndCollect(invoker, argv, stdinText, { spawnImpl = spawn, timeoutMs = 60_000 } = {}) {
|
|
281
|
+
return new Promise((resolve) => {
|
|
282
|
+
let child;
|
|
283
|
+
try {
|
|
284
|
+
child = spawnImpl(invoker, argv, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
285
|
+
} catch (err) {
|
|
286
|
+
resolve(rcfError({
|
|
287
|
+
kind: 'ioFailure',
|
|
288
|
+
message: `eval judge: failed to spawn '${invoker}': ${err.message}`,
|
|
289
|
+
}));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let stdout = '';
|
|
293
|
+
let stderr = '';
|
|
294
|
+
let done = false;
|
|
295
|
+
const timer = setTimeout(() => {
|
|
296
|
+
done = true;
|
|
297
|
+
try { child.kill('SIGTERM'); } catch { /* ignore */ }
|
|
298
|
+
resolve(rcfError({
|
|
299
|
+
kind: 'ioFailure',
|
|
300
|
+
message: `eval judge: invoker '${invoker}' timed out after ${timeoutMs}ms`,
|
|
301
|
+
}));
|
|
302
|
+
}, timeoutMs);
|
|
303
|
+
child.stdout.on('data', (chunk) => { stdout += String(chunk); });
|
|
304
|
+
child.stderr.on('data', (chunk) => { stderr += String(chunk); });
|
|
305
|
+
child.on('error', (err) => {
|
|
306
|
+
if (done) return;
|
|
307
|
+
done = true;
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
resolve(rcfError({
|
|
310
|
+
kind: 'ioFailure',
|
|
311
|
+
message: `eval judge: invoker '${invoker}' error: ${err.message}`,
|
|
312
|
+
}));
|
|
313
|
+
});
|
|
314
|
+
child.on('close', (code) => {
|
|
315
|
+
if (done) return;
|
|
316
|
+
done = true;
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
if (code !== 0) {
|
|
319
|
+
resolve(rcfError({
|
|
320
|
+
kind: 'ioFailure',
|
|
321
|
+
message: `eval judge: invoker '${invoker}' exited with code ${code}: ${stderr.slice(0, 400)}`,
|
|
322
|
+
}));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
resolve({ stdout, stderr, code });
|
|
326
|
+
});
|
|
327
|
+
try {
|
|
328
|
+
child.stdin.end(stdinText);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
done = true;
|
|
331
|
+
clearTimeout(timer);
|
|
332
|
+
resolve(rcfError({
|
|
333
|
+
kind: 'ioFailure',
|
|
334
|
+
message: `eval judge: writing stdin to '${invoker}' failed: ${err.message}`,
|
|
335
|
+
}));
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
package/src/finalise/index.js
CHANGED
|
@@ -22,9 +22,17 @@ export {
|
|
|
22
22
|
// 0.8.0 slug-train car 4: NV-BL-GATE-01 pull-in of the profile-vs-AC
|
|
23
23
|
// scope-mismatch check into REVIEW.
|
|
24
24
|
findScopeMismatchAcs, reportHasScopeMismatch,
|
|
25
|
+
// rcf-eval-node spec 2026-09-04 sections 5.2 + 8: EVAL-MISSING and
|
|
26
|
+
// EVAL-BELOW-THRESHOLD refusal on `rcf finalise`.
|
|
27
|
+
findEvalRefusalAcs, reportHasEvalRefusal,
|
|
25
28
|
} from './ingest.js';
|
|
26
29
|
export {
|
|
27
30
|
composeShipWithoutVerifiedRecord,
|
|
28
31
|
nextShipWithoutVerifiedId,
|
|
29
32
|
writeShipWithoutVerifiedRecord,
|
|
30
33
|
} from './ship-without-verified.js';
|
|
34
|
+
export {
|
|
35
|
+
composeShipWithoutEvalRecord,
|
|
36
|
+
nextShipWithoutEvalId,
|
|
37
|
+
writeShipWithoutEvalRecord,
|
|
38
|
+
} from './ship-without-eval.js';
|
package/src/finalise/ingest.js
CHANGED
|
@@ -158,3 +158,30 @@ export function findScopeMismatchAcs(report) {
|
|
|
158
158
|
export function reportHasScopeMismatch(report) {
|
|
159
159
|
return findScopeMismatchAcs(report).length > 0;
|
|
160
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* rcf-eval-node spec 2026-09-04 sections 5.2 + 8: extract per-AC
|
|
164
|
+
* verdicts in {EVAL-MISSING, EVAL-BELOW-THRESHOLD} from a verify
|
|
165
|
+
* report. Both refuse `rcf finalise` promotion to `verified` unless
|
|
166
|
+
* the operator opts out via `--ship-without-eval "<reason>"`.
|
|
167
|
+
*
|
|
168
|
+
* @param {object} report
|
|
169
|
+
* @returns {Array<{ acId: string, verdict: string, reason?: string }>}
|
|
170
|
+
*/
|
|
171
|
+
export function findEvalRefusalAcs(report) {
|
|
172
|
+
const perAc = Array.isArray(report?.perAcVerdicts) ? report.perAcVerdicts : [];
|
|
173
|
+
return perAc
|
|
174
|
+
.filter((e) => e && (e.verdict === 'EVAL-MISSING' || e.verdict === 'EVAL-BELOW-THRESHOLD'))
|
|
175
|
+
.map((e) => ({ acId: e.acId, verdict: e.verdict, reason: e.reason }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when a verify report carries at least one EVAL-MISSING or
|
|
180
|
+
* EVAL-BELOW-THRESHOLD verdict.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} report
|
|
183
|
+
* @returns {boolean}
|
|
184
|
+
*/
|
|
185
|
+
export function reportHasEvalRefusal(report) {
|
|
186
|
+
return findEvalRefusalAcs(report).length > 0;
|
|
187
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Ship-without-eval acknowledgement writer. Sister of
|
|
2
|
+
// ship-without-verified.js. rcf-eval-node spec 2026-09-04 sections 5.2
|
|
3
|
+
// and 8: `rcf finalise --ship-without-eval "<reason>"` acknowledges
|
|
4
|
+
// EVAL-MISSING / EVAL-BELOW-THRESHOLD per-AC verdicts on a verify
|
|
5
|
+
// report and lets finalise proceed with an audit-log entry.
|
|
6
|
+
//
|
|
7
|
+
// Landing shape mirrors ship-without-verified: an optional
|
|
8
|
+
// `shipWithoutEval[]` array on the manifest, with monotonic ids
|
|
9
|
+
// `swe-<fbsId>-<n>`, the operator's reason string, the declared AC
|
|
10
|
+
// verdicts, the report path, and an ISO timestamp. rcf-schemas 0.6.0
|
|
11
|
+
// does not yet declare this field on the manifest schema; consumers
|
|
12
|
+
// treat its absence as "no acks" and its presence as data.
|
|
13
|
+
|
|
14
|
+
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { rcfError } from '#core/errors';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} ShipWithoutEvalDeclaredAc
|
|
21
|
+
* @property {string} acId
|
|
22
|
+
* @property {'EVAL-MISSING'|'EVAL-BELOW-THRESHOLD'} verdict
|
|
23
|
+
* @property {string} [reason] reason as reported by the verdict layer
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} ShipWithoutEvalRecord
|
|
28
|
+
* @property {string} id `swe-<fbsId>-<n>` monotonic per FBS.
|
|
29
|
+
* @property {string} fbsId
|
|
30
|
+
* @property {string} ackedAt ISO timestamp.
|
|
31
|
+
* @property {string} reason operator-supplied reason (--ship-without-eval "…").
|
|
32
|
+
* @property {ShipWithoutEvalDeclaredAc[]} declaredAcs minItems 1.
|
|
33
|
+
* @property {string} reportPath
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Monotonic id allocator for the shipWithoutEval array. Mirrors
|
|
38
|
+
* `nextShipWithoutVerifiedId` (ship-without-verified.js).
|
|
39
|
+
*
|
|
40
|
+
* @param {object|null} manifest
|
|
41
|
+
* @param {string} fbsId
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function nextShipWithoutEvalId(manifest, fbsId) {
|
|
45
|
+
const prefix = `swe-${fbsId}-`;
|
|
46
|
+
const existing = Array.isArray(manifest?.shipWithoutEval) ? manifest.shipWithoutEval : [];
|
|
47
|
+
let maxN = 0;
|
|
48
|
+
for (const rec of existing) {
|
|
49
|
+
if (typeof rec?.id !== 'string' || !rec.id.startsWith(prefix)) continue;
|
|
50
|
+
const n = Number.parseInt(rec.id.slice(prefix.length), 10);
|
|
51
|
+
if (Number.isFinite(n) && n > maxN) maxN = n;
|
|
52
|
+
}
|
|
53
|
+
return `${prefix}${maxN + 1}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compose the ack record.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} args
|
|
60
|
+
* @param {object|null} args.manifest
|
|
61
|
+
* @param {string} args.fbsId
|
|
62
|
+
* @param {string} args.reason
|
|
63
|
+
* @param {ShipWithoutEvalDeclaredAc[]} args.declaredAcs
|
|
64
|
+
* @param {string} args.reportPath
|
|
65
|
+
* @param {Date} [args.now]
|
|
66
|
+
* @returns {ShipWithoutEvalRecord}
|
|
67
|
+
*/
|
|
68
|
+
export function composeShipWithoutEvalRecord({
|
|
69
|
+
manifest, fbsId, reason, declaredAcs, reportPath, now = new Date(),
|
|
70
|
+
}) {
|
|
71
|
+
return {
|
|
72
|
+
id: nextShipWithoutEvalId(manifest, fbsId),
|
|
73
|
+
fbsId,
|
|
74
|
+
ackedAt: now.toISOString(),
|
|
75
|
+
reason,
|
|
76
|
+
declaredAcs: declaredAcs.map((a) => {
|
|
77
|
+
const out = { acId: a.acId, verdict: a.verdict };
|
|
78
|
+
if (typeof a.reason === 'string' && a.reason.length > 0) out.reason = a.reason;
|
|
79
|
+
return out;
|
|
80
|
+
}),
|
|
81
|
+
reportPath,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Persist a ship-without-eval acknowledgement onto the manifest.
|
|
87
|
+
* Manifest write is atomic (tmp + rename). rcf-schemas 0.6.0's manifest
|
|
88
|
+
* schema does not declare this field, so the write path skips the
|
|
89
|
+
* validator (spec section 9: extensions land at a later minor).
|
|
90
|
+
*
|
|
91
|
+
* @param {object} args
|
|
92
|
+
* @param {string} args.projectRoot
|
|
93
|
+
* @param {object} args.tree
|
|
94
|
+
* @param {ShipWithoutEvalRecord} args.record
|
|
95
|
+
* @returns {Promise<{ record: ShipWithoutEvalRecord } | import('#core/errors').RcfError>}
|
|
96
|
+
*/
|
|
97
|
+
export async function writeShipWithoutEvalRecord({ projectRoot, tree, record }) {
|
|
98
|
+
const manifest = tree.manifest ?? {};
|
|
99
|
+
const nextManifest = { ...manifest };
|
|
100
|
+
const existing = Array.isArray(nextManifest.shipWithoutEval) ? nextManifest.shipWithoutEval : [];
|
|
101
|
+
nextManifest.shipWithoutEval = [...existing, record];
|
|
102
|
+
|
|
103
|
+
const absPath = join(projectRoot, 'rcf', 'manifest.json');
|
|
104
|
+
try {
|
|
105
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
106
|
+
const tmp = `${absPath}.tmp`;
|
|
107
|
+
await writeFile(tmp, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
|
|
108
|
+
try {
|
|
109
|
+
await rename(tmp, absPath);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
try { await unlink(tmp); } catch { /* ignore */ }
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return rcfError({
|
|
116
|
+
kind: 'ioFailure',
|
|
117
|
+
message: `finalise: ship-without-eval manifest write failed: ${err.message}`,
|
|
118
|
+
filePath: 'rcf/manifest.json',
|
|
119
|
+
stack: err.stack,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return { record };
|
|
123
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Pure EVAL-coverage compute. Given a walker-produced TreeModel, walk
|
|
2
|
+
// the REQ chain (PRD -> REQ -> US -> AC) and report, per AC, whether
|
|
3
|
+
// its `determinism` classification requires an EVAL, whether an EVAL
|
|
4
|
+
// binds it, and whether that EVAL is "resolving" per the spec (a
|
|
5
|
+
// non-superseded EVAL whose most recent runRecord[] entry is not
|
|
6
|
+
// `pending`).
|
|
7
|
+
//
|
|
8
|
+
// spec: `projects/rcf-lite-wsd/specs/rcf-eval-node-spec-2026-09-04.md`
|
|
9
|
+
// sections 2.3 and 4. Determinism absence resolves to `deterministic`;
|
|
10
|
+
// deterministic ACs are never gated by this audit. `--strict` refuses
|
|
11
|
+
// only when a nonDeterministic AC lacks a resolving EVAL. Presence of
|
|
12
|
+
// an EVAL on a deterministic AC is reported as `covered-optional`,
|
|
13
|
+
// never as a defect.
|
|
14
|
+
//
|
|
15
|
+
// This module is pure and synchronous; the CLI layer walks the tree
|
|
16
|
+
// and formats.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {import('#core/store/walker.js').TreeModel} TreeModel
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {'deterministic'|'nonDeterministic'} Determinism
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {'resolving'|'pending'|'superseded'|'absent'} EvalStatus
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} AcEvalStatus
|
|
32
|
+
* @property {string} acId
|
|
33
|
+
* @property {string} usId
|
|
34
|
+
* @property {Determinism} determinism - absence resolves to 'deterministic'
|
|
35
|
+
* @property {EvalStatus} evalStatus
|
|
36
|
+
* @property {string|null} evalId - the resolving EVAL, if any; else null
|
|
37
|
+
* @property {string[]} evalIds - all EVALs whose acIds[] name this AC
|
|
38
|
+
* @property {'covered'|'covered-optional'|'missing'|'not-required'} outcome
|
|
39
|
+
* - 'covered': nonDeterministic + resolving EVAL
|
|
40
|
+
* - 'covered-optional': deterministic + resolving EVAL (informational)
|
|
41
|
+
* - 'missing': nonDeterministic + no resolving EVAL
|
|
42
|
+
* - 'not-required': deterministic + no EVAL
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} EvalCoverageReport
|
|
47
|
+
* @property {AcEvalStatus[]} acs - flat per-AC entries within scope
|
|
48
|
+
* @property {number} nonDeterministicCount
|
|
49
|
+
* @property {number} coveredCount - nonDeterministic with resolving EVAL
|
|
50
|
+
* @property {number} missingCount - nonDeterministic without resolving EVAL
|
|
51
|
+
* @property {boolean} ok - true when strict-gate passes: missingCount === 0
|
|
52
|
+
* @property {string|null} scopeId - null when tree-wide
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Determine the resolving status for one EVAL doc.
|
|
57
|
+
*
|
|
58
|
+
* Per spec 2.3, a "resolving" EVAL is:
|
|
59
|
+
* - status is not 'superseded'
|
|
60
|
+
* - runRecord[] has at least one entry whose verdict is not 'pending'
|
|
61
|
+
*
|
|
62
|
+
* @param {object} evalDoc
|
|
63
|
+
* @returns {EvalStatus}
|
|
64
|
+
*/
|
|
65
|
+
export function classifyEvalDoc(evalDoc) {
|
|
66
|
+
if (!evalDoc || typeof evalDoc !== 'object') return 'absent';
|
|
67
|
+
if (evalDoc.status === 'superseded') return 'superseded';
|
|
68
|
+
const records = Array.isArray(evalDoc.runRecord) ? evalDoc.runRecord : [];
|
|
69
|
+
if (records.length === 0) return 'pending';
|
|
70
|
+
// Latest record wins. `runAt` sorts lexicographically for ISO timestamps.
|
|
71
|
+
const sorted = [...records].sort((a, b) => (a?.runAt ?? '').localeCompare(b?.runAt ?? ''));
|
|
72
|
+
const latest = sorted[sorted.length - 1];
|
|
73
|
+
if (!latest || latest.verdict === 'pending') return 'pending';
|
|
74
|
+
return 'resolving';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {TreeModel} tree
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {string|null} [opts.scopeId] - optional PRD / REQ / US id to scope
|
|
81
|
+
* @returns {EvalCoverageReport}
|
|
82
|
+
*/
|
|
83
|
+
export function computeEvalCoverage(tree, { scopeId = null } = {}) {
|
|
84
|
+
const usIds = collectScopedUsIds(tree, scopeId);
|
|
85
|
+
const acs = [];
|
|
86
|
+
for (const usId of usIds) {
|
|
87
|
+
const us = tree.byId.get(usId);
|
|
88
|
+
if (!us) continue;
|
|
89
|
+
for (const ac of us.acceptanceCriteria ?? []) {
|
|
90
|
+
const determinism = ac.determinism === 'nonDeterministic'
|
|
91
|
+
? 'nonDeterministic'
|
|
92
|
+
: 'deterministic';
|
|
93
|
+
const evalIds = tree.evalByAcId?.get(ac.id) ?? [];
|
|
94
|
+
let outcome;
|
|
95
|
+
let evalStatus;
|
|
96
|
+
let resolvingId = null;
|
|
97
|
+
if (evalIds.length === 0) {
|
|
98
|
+
evalStatus = 'absent';
|
|
99
|
+
outcome = determinism === 'nonDeterministic' ? 'missing' : 'not-required';
|
|
100
|
+
} else {
|
|
101
|
+
// Pick the best status across bound EVALs. `resolving` beats
|
|
102
|
+
// `pending` beats `superseded`.
|
|
103
|
+
let bestRank = -1;
|
|
104
|
+
for (const evalId of evalIds) {
|
|
105
|
+
const evalDoc = tree.byId.get(evalId);
|
|
106
|
+
const cls = classifyEvalDoc(evalDoc);
|
|
107
|
+
const rank = cls === 'resolving' ? 3 : cls === 'pending' ? 2 : cls === 'superseded' ? 1 : 0;
|
|
108
|
+
if (rank > bestRank) {
|
|
109
|
+
bestRank = rank;
|
|
110
|
+
evalStatus = cls;
|
|
111
|
+
if (cls === 'resolving') resolvingId = evalId;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (evalStatus === 'resolving') {
|
|
115
|
+
outcome = determinism === 'nonDeterministic' ? 'covered' : 'covered-optional';
|
|
116
|
+
} else {
|
|
117
|
+
outcome = determinism === 'nonDeterministic' ? 'missing' : 'not-required';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
acs.push({
|
|
121
|
+
acId: ac.id,
|
|
122
|
+
usId,
|
|
123
|
+
determinism,
|
|
124
|
+
evalStatus,
|
|
125
|
+
evalId: resolvingId,
|
|
126
|
+
evalIds,
|
|
127
|
+
outcome,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const nonDeterministicCount = acs.filter((a) => a.determinism === 'nonDeterministic').length;
|
|
132
|
+
const missingCount = acs.filter((a) => a.outcome === 'missing').length;
|
|
133
|
+
const coveredCount = nonDeterministicCount - missingCount;
|
|
134
|
+
return {
|
|
135
|
+
acs,
|
|
136
|
+
nonDeterministicCount,
|
|
137
|
+
coveredCount,
|
|
138
|
+
missingCount,
|
|
139
|
+
ok: missingCount === 0,
|
|
140
|
+
scopeId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the scope positional to the US ids it covers.
|
|
146
|
+
*
|
|
147
|
+
* @param {TreeModel} tree
|
|
148
|
+
* @param {string|null} scopeId
|
|
149
|
+
* @returns {string[]} US ids sorted ascending
|
|
150
|
+
*/
|
|
151
|
+
function collectScopedUsIds(tree, scopeId) {
|
|
152
|
+
const allUs = tree.userStories ?? [];
|
|
153
|
+
if (!scopeId) return allUs.map((u) => u.usId).sort();
|
|
154
|
+
const kind = tree.kindById.get(scopeId);
|
|
155
|
+
if (kind === 'userStory') return [scopeId];
|
|
156
|
+
if (kind === 'req') return allUs.filter((u) => u.reqId === scopeId).map((u) => u.usId).sort();
|
|
157
|
+
if (kind === 'prd') {
|
|
158
|
+
const reqIds = new Set((tree.requirements ?? []).filter((r) => r.prdId === scopeId).map((r) => r.reqId));
|
|
159
|
+
return allUs.filter((u) => reqIds.has(u.reqId)).map((u) => u.usId).sort();
|
|
160
|
+
}
|
|
161
|
+
return [];
|
|
162
|
+
}
|