cclaw-cli 0.23.1 → 0.24.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/dist/cli.js +1 -1
- package/dist/eval/corpus.d.ts +11 -0
- package/dist/eval/corpus.js +162 -7
- package/dist/eval/runner.js +78 -32
- package/dist/eval/types.d.ts +68 -3
- package/dist/eval/verifiers/rules.d.ts +24 -0
- package/dist/eval/verifiers/rules.js +218 -0
- package/dist/eval/verifiers/traceability.d.ts +23 -0
- package/dist/eval/verifiers/traceability.js +84 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -58,7 +58,7 @@ Commands:
|
|
|
58
58
|
Flags: --stage=<id> Limit to one flow stage (${FLOW_STAGES.join("|")}).
|
|
59
59
|
--tier=<A|B|C> Fidelity tier (A=single-shot, B=tools, C=workflow).
|
|
60
60
|
--schema-only Run only structural verifiers (default).
|
|
61
|
-
--rules
|
|
61
|
+
--rules Also run rule-based verifiers (keywords, regex, counts, uniqueness, traceability).
|
|
62
62
|
--judge Include LLM judging (not wired yet; requires API key).
|
|
63
63
|
--dry-run Validate config + corpus, print summary, do not execute.
|
|
64
64
|
--json Emit machine-readable JSON on stdout.
|
package/dist/eval/corpus.d.ts
CHANGED
|
@@ -17,3 +17,14 @@ export declare function fixturePathFor(projectRoot: string, caseEntry: EvalCase)
|
|
|
17
17
|
* the case but not on disk — structural fixtures ship alongside cases.
|
|
18
18
|
*/
|
|
19
19
|
export declare function readFixtureArtifact(projectRoot: string, caseEntry: EvalCase): Promise<string | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve an entry from `extraFixtures` to an absolute filesystem path,
|
|
22
|
+
* relative to the case's stage directory (same convention as `fixture`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function extraFixturePath(projectRoot: string, caseEntry: EvalCase, label: string): string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Read every declared extra fixture for a case into a `{ label → text }`
|
|
27
|
+
* map. Missing files throw so authoring mistakes surface immediately rather
|
|
28
|
+
* than being silently skipped by cross-artifact verifiers.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readExtraFixtures(projectRoot: string, caseEntry: EvalCase): Promise<Record<string, string>>;
|
package/dist/eval/corpus.js
CHANGED
|
@@ -58,6 +58,128 @@ function parseStructural(filePath, raw) {
|
|
|
58
58
|
structural.maxChars = maxChars;
|
|
59
59
|
return structural;
|
|
60
60
|
}
|
|
61
|
+
function parseRegexRule(filePath, context, value) {
|
|
62
|
+
if (typeof value === "string") {
|
|
63
|
+
return { pattern: value };
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(value)) {
|
|
66
|
+
throw corpusError(filePath, `"${context}" entries must be either a string or a mapping with "pattern"`);
|
|
67
|
+
}
|
|
68
|
+
const pattern = value.pattern;
|
|
69
|
+
if (typeof pattern !== "string" || pattern.length === 0) {
|
|
70
|
+
throw corpusError(filePath, `"${context}" mapping entry must include a non-empty "pattern" string`);
|
|
71
|
+
}
|
|
72
|
+
const flags = value.flags;
|
|
73
|
+
if (flags !== undefined && typeof flags !== "string") {
|
|
74
|
+
throw corpusError(filePath, `"${context}" flags must be a string`);
|
|
75
|
+
}
|
|
76
|
+
const description = value.description;
|
|
77
|
+
if (description !== undefined && typeof description !== "string") {
|
|
78
|
+
throw corpusError(filePath, `"${context}" description must be a string`);
|
|
79
|
+
}
|
|
80
|
+
const rule = { pattern };
|
|
81
|
+
if (flags !== undefined)
|
|
82
|
+
rule.flags = flags;
|
|
83
|
+
if (description !== undefined)
|
|
84
|
+
rule.description = description;
|
|
85
|
+
return rule;
|
|
86
|
+
}
|
|
87
|
+
function parseRegexRules(filePath, context, value) {
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (!Array.isArray(value)) {
|
|
91
|
+
throw corpusError(filePath, `"${context}" must be an array`);
|
|
92
|
+
}
|
|
93
|
+
return value.map((entry, index) => parseRegexRule(filePath, `${context}[${index}]`, entry));
|
|
94
|
+
}
|
|
95
|
+
function parseOccurrenceBounds(filePath, context, value) {
|
|
96
|
+
if (value === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
if (!isRecord(value)) {
|
|
99
|
+
throw corpusError(filePath, `"${context}" must be a mapping of phrase → integer`);
|
|
100
|
+
}
|
|
101
|
+
const out = {};
|
|
102
|
+
for (const [phrase, count] of Object.entries(value)) {
|
|
103
|
+
if (typeof count !== "number" || !Number.isFinite(count) || !Number.isInteger(count) || count < 0) {
|
|
104
|
+
throw corpusError(filePath, `"${context}.${phrase}" must be a non-negative integer`);
|
|
105
|
+
}
|
|
106
|
+
out[phrase] = count;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
function parseRules(filePath, raw) {
|
|
111
|
+
if (raw === undefined)
|
|
112
|
+
return undefined;
|
|
113
|
+
if (!isRecord(raw)) {
|
|
114
|
+
throw corpusError(filePath, `"expected.rules" must be a mapping`);
|
|
115
|
+
}
|
|
116
|
+
const mustContain = readStringArray(filePath, "expected.rules.must_contain", raw.must_contain ?? raw.mustContain);
|
|
117
|
+
const mustNotContain = readStringArray(filePath, "expected.rules.must_not_contain", raw.must_not_contain ?? raw.mustNotContain);
|
|
118
|
+
const regexRequired = parseRegexRules(filePath, "expected.rules.regex_required", raw.regex_required ?? raw.regexRequired);
|
|
119
|
+
const regexForbidden = parseRegexRules(filePath, "expected.rules.regex_forbidden", raw.regex_forbidden ?? raw.regexForbidden);
|
|
120
|
+
const minOccurrences = parseOccurrenceBounds(filePath, "expected.rules.min_occurrences", raw.min_occurrences ?? raw.minOccurrences);
|
|
121
|
+
const maxOccurrences = parseOccurrenceBounds(filePath, "expected.rules.max_occurrences", raw.max_occurrences ?? raw.maxOccurrences);
|
|
122
|
+
const uniqueBulletsInSection = readStringArray(filePath, "expected.rules.unique_bullets_in_section", raw.unique_bullets_in_section ?? raw.uniqueBulletsInSection);
|
|
123
|
+
const rules = {};
|
|
124
|
+
if (mustContain)
|
|
125
|
+
rules.mustContain = mustContain;
|
|
126
|
+
if (mustNotContain)
|
|
127
|
+
rules.mustNotContain = mustNotContain;
|
|
128
|
+
if (regexRequired)
|
|
129
|
+
rules.regexRequired = regexRequired;
|
|
130
|
+
if (regexForbidden)
|
|
131
|
+
rules.regexForbidden = regexForbidden;
|
|
132
|
+
if (minOccurrences)
|
|
133
|
+
rules.minOccurrences = minOccurrences;
|
|
134
|
+
if (maxOccurrences)
|
|
135
|
+
rules.maxOccurrences = maxOccurrences;
|
|
136
|
+
if (uniqueBulletsInSection)
|
|
137
|
+
rules.uniqueBulletsInSection = uniqueBulletsInSection;
|
|
138
|
+
return Object.keys(rules).length === 0 ? undefined : rules;
|
|
139
|
+
}
|
|
140
|
+
function parseTraceability(filePath, raw) {
|
|
141
|
+
if (raw === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
if (!isRecord(raw)) {
|
|
144
|
+
throw corpusError(filePath, `"expected.traceability" must be a mapping`);
|
|
145
|
+
}
|
|
146
|
+
const idPattern = raw.id_pattern ?? raw.idPattern;
|
|
147
|
+
if (typeof idPattern !== "string" || idPattern.length === 0) {
|
|
148
|
+
throw corpusError(filePath, `"expected.traceability.id_pattern" must be a non-empty regex source`);
|
|
149
|
+
}
|
|
150
|
+
const idFlags = raw.id_flags ?? raw.idFlags;
|
|
151
|
+
if (idFlags !== undefined && typeof idFlags !== "string") {
|
|
152
|
+
throw corpusError(filePath, `"expected.traceability.id_flags" must be a string`);
|
|
153
|
+
}
|
|
154
|
+
const source = raw.source;
|
|
155
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
156
|
+
throw corpusError(filePath, `"expected.traceability.source" must be "self" or an extra_fixtures label`);
|
|
157
|
+
}
|
|
158
|
+
const requireInRaw = raw.require_in ?? raw.requireIn;
|
|
159
|
+
const requireIn = readStringArray(filePath, "expected.traceability.require_in", requireInRaw);
|
|
160
|
+
if (!requireIn || requireIn.length === 0) {
|
|
161
|
+
throw corpusError(filePath, `"expected.traceability.require_in" must be a non-empty array`);
|
|
162
|
+
}
|
|
163
|
+
const out = { idPattern, source, requireIn };
|
|
164
|
+
if (idFlags !== undefined)
|
|
165
|
+
out.idFlags = idFlags;
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
function parseExtraFixtures(filePath, raw) {
|
|
169
|
+
if (raw === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
if (!isRecord(raw)) {
|
|
172
|
+
throw corpusError(filePath, `"extra_fixtures" must be a mapping of label → path`);
|
|
173
|
+
}
|
|
174
|
+
const out = {};
|
|
175
|
+
for (const [label, value] of Object.entries(raw)) {
|
|
176
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
177
|
+
throw corpusError(filePath, `"extra_fixtures.${label}" must be a non-empty path string`);
|
|
178
|
+
}
|
|
179
|
+
out[label] = value;
|
|
180
|
+
}
|
|
181
|
+
return Object.keys(out).length === 0 ? undefined : out;
|
|
182
|
+
}
|
|
61
183
|
function parseExpected(filePath, raw) {
|
|
62
184
|
if (raw === undefined)
|
|
63
185
|
return undefined;
|
|
@@ -68,12 +190,12 @@ function parseExpected(filePath, raw) {
|
|
|
68
190
|
const structural = parseStructural(filePath, raw.structural);
|
|
69
191
|
if (structural)
|
|
70
192
|
shape.structural = structural;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
193
|
+
const rules = parseRules(filePath, raw.rules);
|
|
194
|
+
if (rules)
|
|
195
|
+
shape.rules = rules;
|
|
196
|
+
const traceability = parseTraceability(filePath, raw.traceability);
|
|
197
|
+
if (traceability)
|
|
198
|
+
shape.traceability = traceability;
|
|
77
199
|
if (raw.judge !== undefined) {
|
|
78
200
|
if (!isRecord(raw.judge)) {
|
|
79
201
|
throw corpusError(filePath, `"expected.judge" must be a mapping`);
|
|
@@ -101,13 +223,15 @@ function validateCase(filePath, raw) {
|
|
|
101
223
|
const contextFiles = readStringArray(filePath, "context_files", raw.context_files ?? raw.contextFiles);
|
|
102
224
|
const expected = parseExpected(filePath, raw.expected);
|
|
103
225
|
const fixture = typeof raw.fixture === "string" ? raw.fixture : undefined;
|
|
226
|
+
const extraFixtures = parseExtraFixtures(filePath, raw.extra_fixtures ?? raw.extraFixtures);
|
|
104
227
|
return {
|
|
105
228
|
id: id.trim(),
|
|
106
229
|
stage: stageRaw,
|
|
107
230
|
inputPrompt: inputPrompt.trim(),
|
|
108
231
|
contextFiles,
|
|
109
232
|
expected,
|
|
110
|
-
fixture
|
|
233
|
+
fixture,
|
|
234
|
+
extraFixtures
|
|
111
235
|
};
|
|
112
236
|
}
|
|
113
237
|
/**
|
|
@@ -173,3 +297,34 @@ export async function readFixtureArtifact(projectRoot, caseEntry) {
|
|
|
173
297
|
}
|
|
174
298
|
return fs.readFile(fixturePath, "utf8");
|
|
175
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Resolve an entry from `extraFixtures` to an absolute filesystem path,
|
|
302
|
+
* relative to the case's stage directory (same convention as `fixture`).
|
|
303
|
+
*/
|
|
304
|
+
export function extraFixturePath(projectRoot, caseEntry, label) {
|
|
305
|
+
const value = caseEntry.extraFixtures?.[label];
|
|
306
|
+
if (!value)
|
|
307
|
+
return undefined;
|
|
308
|
+
return path.resolve(projectRoot, EVALS_ROOT, "corpus", caseEntry.stage, value);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Read every declared extra fixture for a case into a `{ label → text }`
|
|
312
|
+
* map. Missing files throw so authoring mistakes surface immediately rather
|
|
313
|
+
* than being silently skipped by cross-artifact verifiers.
|
|
314
|
+
*/
|
|
315
|
+
export async function readExtraFixtures(projectRoot, caseEntry) {
|
|
316
|
+
const out = {};
|
|
317
|
+
if (!caseEntry.extraFixtures)
|
|
318
|
+
return out;
|
|
319
|
+
for (const label of Object.keys(caseEntry.extraFixtures)) {
|
|
320
|
+
const filePath = extraFixturePath(projectRoot, caseEntry, label);
|
|
321
|
+
if (!filePath)
|
|
322
|
+
continue;
|
|
323
|
+
if (!(await exists(filePath))) {
|
|
324
|
+
throw new Error(`Extra fixture missing for ${caseEntry.stage}/${caseEntry.id} ` +
|
|
325
|
+
`(label="${label}"): ${filePath}`);
|
|
326
|
+
}
|
|
327
|
+
out[label] = await fs.readFile(filePath, "utf8");
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
package/dist/eval/runner.js
CHANGED
|
@@ -2,9 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { CCLAW_VERSION } from "../constants.js";
|
|
3
3
|
import { FLOW_STAGES } from "../types.js";
|
|
4
4
|
import { compareAgainstBaselines, loadBaselinesByStage } from "./baseline.js";
|
|
5
|
-
import { loadCorpus, readFixtureArtifact } from "./corpus.js";
|
|
5
|
+
import { loadCorpus, readExtraFixtures, readFixtureArtifact } from "./corpus.js";
|
|
6
6
|
import { loadEvalConfig } from "./config-loader.js";
|
|
7
|
+
import { verifyRules } from "./verifiers/rules.js";
|
|
7
8
|
import { verifyStructural } from "./verifiers/structural.js";
|
|
9
|
+
import { verifyTraceability } from "./verifiers/traceability.js";
|
|
8
10
|
function groupByStage(cases) {
|
|
9
11
|
return cases.reduce((acc, item) => {
|
|
10
12
|
acc[item.stage] = (acc[item.stage] ?? 0) + 1;
|
|
@@ -21,33 +23,65 @@ function skeletonVerifierResult(message, details) {
|
|
|
21
23
|
...(details !== undefined ? { details } : {})
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* --schema-only narrows to structural. --rules opens up rules + traceability
|
|
28
|
+
* on top of structural (traceability is a rule-family verifier even though
|
|
29
|
+
* it lives in its own module). Default (no flag) matches --schema-only for
|
|
30
|
+
* backwards compatibility with the Step 1 gate.
|
|
31
|
+
*/
|
|
32
|
+
function resolveRunFlags(options) {
|
|
33
|
+
const rulesRequested = options.rules === true;
|
|
34
|
+
const schemaOnly = options.schemaOnly === true;
|
|
35
|
+
return {
|
|
36
|
+
runStructural: true,
|
|
37
|
+
runRules: rulesRequested && !schemaOnly,
|
|
38
|
+
runTraceability: rulesRequested && !schemaOnly
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function loadArtifactOrRecord(projectRoot, caseEntry, verifierResults) {
|
|
42
|
+
try {
|
|
43
|
+
return await readFixtureArtifact(projectRoot, caseEntry);
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
verifierResults.push({
|
|
47
|
+
kind: "structural",
|
|
48
|
+
id: "structural:fixture:missing",
|
|
49
|
+
ok: false,
|
|
50
|
+
score: 0,
|
|
51
|
+
message: err instanceof Error ? err.message : String(err),
|
|
52
|
+
details: { fixture: caseEntry.fixture }
|
|
53
|
+
});
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function runCase(projectRoot, caseEntry, plannedTier, flags) {
|
|
25
58
|
const started = Date.now();
|
|
26
|
-
const structuralExpected = caseEntry.expected?.structural;
|
|
27
59
|
const verifierResults = [];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
artifact = await readFixtureArtifact(projectRoot, caseEntry);
|
|
38
|
-
}
|
|
39
|
-
catch (err) {
|
|
60
|
+
const expected = caseEntry.expected;
|
|
61
|
+
const hasStructural = !!expected?.structural && Object.keys(expected.structural).length > 0;
|
|
62
|
+
const hasRules = flags.runRules && !!expected?.rules && Object.keys(expected.rules).length > 0;
|
|
63
|
+
const hasTraceability = flags.runTraceability && !!expected?.traceability;
|
|
64
|
+
const needsArtifact = hasStructural || hasRules || hasTraceability;
|
|
65
|
+
let artifact;
|
|
66
|
+
if (needsArtifact) {
|
|
67
|
+
artifact = await loadArtifactOrRecord(projectRoot, caseEntry, verifierResults);
|
|
68
|
+
if (artifact === undefined && verifierResults.length === 0) {
|
|
40
69
|
verifierResults.push({
|
|
41
70
|
kind: "structural",
|
|
42
|
-
id: "structural:fixture:
|
|
71
|
+
id: "structural:fixture:absent",
|
|
43
72
|
ok: false,
|
|
44
73
|
score: 0,
|
|
45
|
-
message:
|
|
46
|
-
details: {
|
|
74
|
+
message: "Expectations declared but no fixture path provided. Add `fixture: ./<id>/fixture.md`.",
|
|
75
|
+
details: { fixtureProvided: false }
|
|
47
76
|
});
|
|
48
77
|
}
|
|
49
|
-
|
|
50
|
-
|
|
78
|
+
}
|
|
79
|
+
if (flags.runStructural) {
|
|
80
|
+
if (!hasStructural) {
|
|
81
|
+
verifierResults.push(skeletonVerifierResult("No structural expectations declared for this case; structural verifier skipped.", { skipped: true }));
|
|
82
|
+
}
|
|
83
|
+
else if (artifact !== undefined) {
|
|
84
|
+
const results = verifyStructural(artifact, expected.structural);
|
|
51
85
|
if (results.length === 0) {
|
|
52
86
|
verifierResults.push(skeletonVerifierResult("Structural expectations parsed but produced zero checks.", { skipped: true }));
|
|
53
87
|
}
|
|
@@ -55,18 +89,32 @@ async function runCaseStructural(projectRoot, caseEntry, plannedTier) {
|
|
|
55
89
|
verifierResults.push(...results);
|
|
56
90
|
}
|
|
57
91
|
}
|
|
58
|
-
|
|
92
|
+
}
|
|
93
|
+
if (hasRules && artifact !== undefined) {
|
|
94
|
+
const results = verifyRules(artifact, expected.rules);
|
|
95
|
+
verifierResults.push(...results);
|
|
96
|
+
}
|
|
97
|
+
if (hasTraceability && artifact !== undefined) {
|
|
98
|
+
try {
|
|
99
|
+
const extras = await readExtraFixtures(projectRoot, caseEntry);
|
|
100
|
+
const results = verifyTraceability(artifact, extras, expected.traceability);
|
|
101
|
+
verifierResults.push(...results);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
59
104
|
verifierResults.push({
|
|
60
|
-
kind: "
|
|
61
|
-
id: "
|
|
105
|
+
kind: "rules",
|
|
106
|
+
id: "traceability:fixture:missing",
|
|
62
107
|
ok: false,
|
|
63
108
|
score: 0,
|
|
64
|
-
message:
|
|
65
|
-
details: {
|
|
109
|
+
message: err instanceof Error ? err.message : String(err),
|
|
110
|
+
details: { extraFixtures: Object.keys(caseEntry.extraFixtures ?? {}) }
|
|
66
111
|
});
|
|
67
112
|
}
|
|
68
113
|
}
|
|
69
|
-
const
|
|
114
|
+
const nonSkippedResults = verifierResults.filter((r) => r.details?.skipped !== true);
|
|
115
|
+
const allOk = nonSkippedResults.length === 0
|
|
116
|
+
? verifierResults.every((r) => r.ok)
|
|
117
|
+
: nonSkippedResults.every((r) => r.ok);
|
|
70
118
|
return {
|
|
71
119
|
caseId: caseEntry.id,
|
|
72
120
|
stage: caseEntry.stage,
|
|
@@ -125,12 +173,10 @@ export async function runEval(options) {
|
|
|
125
173
|
if (corpus.length === 0) {
|
|
126
174
|
notes.push("Corpus is empty. Seed cases live under `.cclaw/evals/corpus/<stage>/*.yaml`.");
|
|
127
175
|
}
|
|
128
|
-
if (options.rules) {
|
|
129
|
-
notes.push("--rules is accepted; rule verifiers are not wired yet.");
|
|
130
|
-
}
|
|
131
176
|
if (options.judge) {
|
|
132
177
|
notes.push("--judge is accepted; LLM judging is not wired yet.");
|
|
133
178
|
}
|
|
179
|
+
const flags = resolveRunFlags(options);
|
|
134
180
|
if (options.dryRun === true) {
|
|
135
181
|
const summary = {
|
|
136
182
|
kind: "dry-run",
|
|
@@ -142,8 +188,8 @@ export async function runEval(options) {
|
|
|
142
188
|
},
|
|
143
189
|
plannedTier,
|
|
144
190
|
verifiersAvailable: {
|
|
145
|
-
structural:
|
|
146
|
-
rules:
|
|
191
|
+
structural: flags.runStructural,
|
|
192
|
+
rules: flags.runRules,
|
|
147
193
|
judge: false,
|
|
148
194
|
workflow: false
|
|
149
195
|
},
|
|
@@ -154,7 +200,7 @@ export async function runEval(options) {
|
|
|
154
200
|
const now = new Date().toISOString();
|
|
155
201
|
const caseResults = [];
|
|
156
202
|
for (const item of corpus) {
|
|
157
|
-
caseResults.push(await
|
|
203
|
+
caseResults.push(await runCase(options.projectRoot, item, plannedTier, flags));
|
|
158
204
|
}
|
|
159
205
|
const stages = stagesInResults(caseResults);
|
|
160
206
|
const baselines = await loadBaselinesByStage(options.projectRoot, stages);
|
package/dist/eval/types.d.ts
CHANGED
|
@@ -58,11 +58,69 @@ export interface StructuralExpected {
|
|
|
58
58
|
*/
|
|
59
59
|
requiredFrontmatterKeys?: string[];
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Rule-based expectations — zero-LLM content checks that are richer than
|
|
63
|
+
* structural (regex, numeric bounds, uniqueness). Introduced in Step 2.
|
|
64
|
+
*
|
|
65
|
+
* Every array field is optional; an empty `RulesExpected` produces zero
|
|
66
|
+
* verifier results so authors can enable rules incrementally.
|
|
67
|
+
*/
|
|
68
|
+
export interface RulesExpected {
|
|
69
|
+
/** Case-insensitive substrings the body must include at least once. */
|
|
70
|
+
mustContain?: string[];
|
|
71
|
+
/** Case-insensitive substrings the body must NOT include. */
|
|
72
|
+
mustNotContain?: string[];
|
|
73
|
+
/** Regex patterns that must match the body at least once. */
|
|
74
|
+
regexRequired?: RuleRegex[];
|
|
75
|
+
/** Regex patterns that must NOT match the body. */
|
|
76
|
+
regexForbidden?: RuleRegex[];
|
|
77
|
+
/** For each substring key, the body must contain at least N occurrences. */
|
|
78
|
+
minOccurrences?: Record<string, number>;
|
|
79
|
+
/** For each substring key, the body must contain at most N occurrences. */
|
|
80
|
+
maxOccurrences?: Record<string, number>;
|
|
81
|
+
/**
|
|
82
|
+
* For each named section (case-insensitive heading substring), every bullet
|
|
83
|
+
* (`- ...`) directly under the section must be unique. Catches duplicated
|
|
84
|
+
* decisions or repeated risks.
|
|
85
|
+
*/
|
|
86
|
+
uniqueBulletsInSection?: string[];
|
|
87
|
+
}
|
|
88
|
+
export interface RuleRegex {
|
|
89
|
+
/** Source of the regex. Parsed with `new RegExp(pattern, flags)`. */
|
|
90
|
+
pattern: string;
|
|
91
|
+
/** Optional regex flags; defaults to `"i"` for case-insensitive matching. */
|
|
92
|
+
flags?: string;
|
|
93
|
+
/** Human-readable label rendered in verifier messages and slugged into the id. */
|
|
94
|
+
description?: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Cross-stage traceability expectations — assert every ID extracted from
|
|
98
|
+
* `source` also appears in `self` and/or named `extra_fixtures`. Introduced
|
|
99
|
+
* in Step 2.
|
|
100
|
+
*/
|
|
101
|
+
export interface TraceabilityExpected {
|
|
102
|
+
/** Regex applied to the `source` fixture to collect the authoritative ID set. */
|
|
103
|
+
idPattern: string;
|
|
104
|
+
/** Optional regex flags (defaults to `"g"`). */
|
|
105
|
+
idFlags?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Where to read the authoritative ID set from. Either `"self"` (the case's
|
|
108
|
+
* primary `fixture`) or a label present in the case's `extraFixtures` map.
|
|
109
|
+
*/
|
|
110
|
+
source: string;
|
|
111
|
+
/**
|
|
112
|
+
* Where every source ID must also appear. Each entry is `"self"` or an
|
|
113
|
+
* `extraFixtures` label. Order is preserved for deterministic result ids.
|
|
114
|
+
*/
|
|
115
|
+
requireIn: string[];
|
|
116
|
+
}
|
|
117
|
+
/** Superset of per-verifier expectation shapes. */
|
|
62
118
|
export interface ExpectedShape {
|
|
63
119
|
structural?: StructuralExpected;
|
|
64
|
-
/** Rule-based (keyword/regex/
|
|
65
|
-
rules?:
|
|
120
|
+
/** Rule-based (keyword/regex/count/uniqueness) checks — Step 2. */
|
|
121
|
+
rules?: RulesExpected;
|
|
122
|
+
/** Cross-stage ID propagation checks — Step 2. */
|
|
123
|
+
traceability?: TraceabilityExpected;
|
|
66
124
|
/** LLM-judge rubrics — Step 3. */
|
|
67
125
|
judge?: Record<string, unknown>;
|
|
68
126
|
}
|
|
@@ -89,6 +147,13 @@ export interface EvalCase {
|
|
|
89
147
|
* Step 1 development aid.
|
|
90
148
|
*/
|
|
91
149
|
fixture?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Additional fixture paths loaded alongside the primary `fixture`, keyed
|
|
152
|
+
* by a free-form label. Consumed by cross-artifact verifiers (e.g.,
|
|
153
|
+
* traceability) introduced in Step 2. Paths are resolved relative to the
|
|
154
|
+
* case's stage directory, just like `fixture`.
|
|
155
|
+
*/
|
|
156
|
+
extraFixtures?: Record<string, string>;
|
|
92
157
|
}
|
|
93
158
|
/** Result of one verifier applied to one case. */
|
|
94
159
|
export interface VerifierResult {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-based verifier: deterministic, zero-LLM checks that are richer than
|
|
3
|
+
* structural heading/length assertions. Each rule produces exactly one
|
|
4
|
+
* `VerifierResult` so baselines diff at the check level, and authoring a
|
|
5
|
+
* rule sideways in YAML never silently skips.
|
|
6
|
+
*
|
|
7
|
+
* Semantics:
|
|
8
|
+
*
|
|
9
|
+
* - All substring matching is case-insensitive. Regex matching uses the
|
|
10
|
+
* flags declared on the rule (default `"i"`).
|
|
11
|
+
* - Rules operate on the artifact BODY (frontmatter stripped), mirroring
|
|
12
|
+
* the structural verifier so min/max counts and length checks agree on
|
|
13
|
+
* what "body" means.
|
|
14
|
+
* - `uniqueBulletsInSection` scans every section (heading, case-insensitive
|
|
15
|
+
* substring match) and flags duplicate top-level bullets ("- item"). The
|
|
16
|
+
* search stops at the next heading of equal or lower depth.
|
|
17
|
+
*/
|
|
18
|
+
import type { RulesExpected, VerifierResult } from "../types.js";
|
|
19
|
+
/**
|
|
20
|
+
* Run every configured rule check against the artifact body. Returns `[]`
|
|
21
|
+
* when `expected` is undefined or empty so the runner can distinguish
|
|
22
|
+
* "no rules declared" from "all rules passed".
|
|
23
|
+
*/
|
|
24
|
+
export declare function verifyRules(artifact: string, expected: RulesExpected | undefined): VerifierResult[];
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { splitFrontmatter } from "./structural.js";
|
|
2
|
+
function slugify(input) {
|
|
3
|
+
return (input
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
6
|
+
.replace(/(^-|-$)/g, "")
|
|
7
|
+
.slice(0, 64) || "rule");
|
|
8
|
+
}
|
|
9
|
+
function result(id, ok, message, details) {
|
|
10
|
+
return {
|
|
11
|
+
kind: "rules",
|
|
12
|
+
id,
|
|
13
|
+
ok,
|
|
14
|
+
score: ok ? 1 : 0,
|
|
15
|
+
message,
|
|
16
|
+
...(details !== undefined ? { details } : {})
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function countOccurrences(haystack, needle) {
|
|
20
|
+
if (needle.length === 0)
|
|
21
|
+
return 0;
|
|
22
|
+
let index = 0;
|
|
23
|
+
let count = 0;
|
|
24
|
+
while (true) {
|
|
25
|
+
const at = haystack.indexOf(needle, index);
|
|
26
|
+
if (at < 0)
|
|
27
|
+
return count;
|
|
28
|
+
count += 1;
|
|
29
|
+
index = at + needle.length;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function compileRegex(rule) {
|
|
33
|
+
const flags = rule.flags ?? "i";
|
|
34
|
+
try {
|
|
35
|
+
return new RegExp(rule.pattern, flags);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
throw new Error(`Invalid regex for rule "${rule.description ?? rule.pattern}" ` +
|
|
39
|
+
`(pattern=${JSON.stringify(rule.pattern)}, flags=${JSON.stringify(flags)}): ` +
|
|
40
|
+
(err instanceof Error ? err.message : String(err)));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function ruleLabel(rule) {
|
|
44
|
+
return rule.description?.trim() || rule.pattern;
|
|
45
|
+
}
|
|
46
|
+
function checkMustContain(needles, body) {
|
|
47
|
+
const bodyLower = body.toLowerCase();
|
|
48
|
+
return needles.map((needle) => {
|
|
49
|
+
const found = bodyLower.includes(needle.toLowerCase());
|
|
50
|
+
return result(`rules:contains:${slugify(needle)}`, found, found
|
|
51
|
+
? `Required phrase "${needle}" present.`
|
|
52
|
+
: `Required phrase "${needle}" missing from body.`, { phrase: needle });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function checkMustNotContain(needles, body) {
|
|
56
|
+
const bodyLower = body.toLowerCase();
|
|
57
|
+
return needles.map((needle) => {
|
|
58
|
+
const lowered = needle.toLowerCase();
|
|
59
|
+
const occurrences = countOccurrences(bodyLower, lowered);
|
|
60
|
+
const ok = occurrences === 0;
|
|
61
|
+
return result(`rules:not-contains:${slugify(needle)}`, ok, ok
|
|
62
|
+
? `Forbidden phrase "${needle}" absent (as required).`
|
|
63
|
+
: `Forbidden phrase "${needle}" appears ${occurrences} time(s).`, { phrase: needle, occurrences });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
function checkRegexRequired(rules, body) {
|
|
67
|
+
return rules.map((rule) => {
|
|
68
|
+
const label = ruleLabel(rule);
|
|
69
|
+
const regex = compileRegex(rule);
|
|
70
|
+
const matches = body.match(new RegExp(regex.source, withGlobal(regex.flags)));
|
|
71
|
+
const count = matches ? matches.length : 0;
|
|
72
|
+
const ok = count > 0;
|
|
73
|
+
return result(`rules:regex-required:${slugify(label)}`, ok, ok
|
|
74
|
+
? `Required pattern /${rule.pattern}/ matched ${count} time(s).`
|
|
75
|
+
: `Required pattern /${rule.pattern}/ did not match.`, { pattern: rule.pattern, flags: rule.flags ?? "i", matches: count });
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function checkRegexForbidden(rules, body) {
|
|
79
|
+
return rules.map((rule) => {
|
|
80
|
+
const label = ruleLabel(rule);
|
|
81
|
+
const regex = compileRegex(rule);
|
|
82
|
+
const matches = body.match(new RegExp(regex.source, withGlobal(regex.flags)));
|
|
83
|
+
const count = matches ? matches.length : 0;
|
|
84
|
+
const ok = count === 0;
|
|
85
|
+
return result(`rules:regex-forbidden:${slugify(label)}`, ok, ok
|
|
86
|
+
? `Forbidden pattern /${rule.pattern}/ absent.`
|
|
87
|
+
: `Forbidden pattern /${rule.pattern}/ matched ${count} time(s).`, { pattern: rule.pattern, flags: rule.flags ?? "i", matches: count });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function withGlobal(flags) {
|
|
91
|
+
return flags.includes("g") ? flags : `${flags}g`;
|
|
92
|
+
}
|
|
93
|
+
function checkMinOccurrences(bounds, body) {
|
|
94
|
+
const bodyLower = body.toLowerCase();
|
|
95
|
+
return Object.entries(bounds).map(([needle, min]) => {
|
|
96
|
+
const occurrences = countOccurrences(bodyLower, needle.toLowerCase());
|
|
97
|
+
const ok = occurrences >= min;
|
|
98
|
+
return result(`rules:min-occurrences:${slugify(needle)}`, ok, ok
|
|
99
|
+
? `Phrase "${needle}" appears ${occurrences} time(s) (>= ${min}).`
|
|
100
|
+
: `Phrase "${needle}" appears ${occurrences} time(s); expected at least ${min}.`, { phrase: needle, occurrences, min });
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function checkMaxOccurrences(bounds, body) {
|
|
104
|
+
const bodyLower = body.toLowerCase();
|
|
105
|
+
return Object.entries(bounds).map(([needle, max]) => {
|
|
106
|
+
const occurrences = countOccurrences(bodyLower, needle.toLowerCase());
|
|
107
|
+
const ok = occurrences <= max;
|
|
108
|
+
return result(`rules:max-occurrences:${slugify(needle)}`, ok, ok
|
|
109
|
+
? `Phrase "${needle}" appears ${occurrences} time(s) (<= ${max}).`
|
|
110
|
+
: `Phrase "${needle}" appears ${occurrences} time(s); expected at most ${max}.`, { phrase: needle, occurrences, max });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function sliceBySection(body) {
|
|
114
|
+
const lines = body.split(/\r?\n/);
|
|
115
|
+
const slices = [];
|
|
116
|
+
let current = null;
|
|
117
|
+
for (const rawLine of lines) {
|
|
118
|
+
const line = rawLine.trimStart();
|
|
119
|
+
const match = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
120
|
+
if (match) {
|
|
121
|
+
if (current) {
|
|
122
|
+
slices.push({
|
|
123
|
+
heading: current.heading,
|
|
124
|
+
depth: current.depth,
|
|
125
|
+
body: current.body.join("\n")
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
current = { heading: match[2].trim(), depth: match[1].length, body: [] };
|
|
129
|
+
}
|
|
130
|
+
else if (current) {
|
|
131
|
+
current.body.push(rawLine);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (current) {
|
|
135
|
+
slices.push({
|
|
136
|
+
heading: current.heading,
|
|
137
|
+
depth: current.depth,
|
|
138
|
+
body: current.body.join("\n")
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return slices;
|
|
142
|
+
}
|
|
143
|
+
function extractTopLevelBullets(sectionBody) {
|
|
144
|
+
const bullets = [];
|
|
145
|
+
for (const rawLine of sectionBody.split(/\r?\n/)) {
|
|
146
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
147
|
+
const leading = line.match(/^(\s*)[-*]\s+(.+)$/);
|
|
148
|
+
if (!leading)
|
|
149
|
+
continue;
|
|
150
|
+
if (leading[1].length > 0)
|
|
151
|
+
continue;
|
|
152
|
+
bullets.push(leading[2].trim());
|
|
153
|
+
}
|
|
154
|
+
return bullets;
|
|
155
|
+
}
|
|
156
|
+
function checkUniqueBulletsInSection(sections, body) {
|
|
157
|
+
const slices = sliceBySection(body);
|
|
158
|
+
return sections.map((needle) => {
|
|
159
|
+
const lowerNeedle = needle.toLowerCase();
|
|
160
|
+
const slice = slices.find((s) => s.heading.toLowerCase().includes(lowerNeedle));
|
|
161
|
+
if (!slice) {
|
|
162
|
+
return result(`rules:unique-in-section:${slugify(needle)}`, false, `Section matching "${needle}" not found; cannot check uniqueness.`, { section: needle, found: false });
|
|
163
|
+
}
|
|
164
|
+
const bullets = extractTopLevelBullets(slice.body);
|
|
165
|
+
const seen = new Map();
|
|
166
|
+
for (const bullet of bullets) {
|
|
167
|
+
const key = bullet.toLowerCase();
|
|
168
|
+
seen.set(key, (seen.get(key) ?? 0) + 1);
|
|
169
|
+
}
|
|
170
|
+
const duplicates = [...seen.entries()]
|
|
171
|
+
.filter(([, count]) => count > 1)
|
|
172
|
+
.map(([entry, count]) => ({ entry, count }));
|
|
173
|
+
const ok = duplicates.length === 0;
|
|
174
|
+
return result(`rules:unique-in-section:${slugify(needle)}`, ok, ok
|
|
175
|
+
? `Section "${slice.heading}" has ${bullets.length} unique bullet(s).`
|
|
176
|
+
: `Section "${slice.heading}" has duplicate bullet(s): ${duplicates
|
|
177
|
+
.map((d) => `"${d.entry}" x${d.count}`)
|
|
178
|
+
.join(", ")}.`, {
|
|
179
|
+
section: slice.heading,
|
|
180
|
+
bullets: bullets.length,
|
|
181
|
+
duplicates
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Run every configured rule check against the artifact body. Returns `[]`
|
|
187
|
+
* when `expected` is undefined or empty so the runner can distinguish
|
|
188
|
+
* "no rules declared" from "all rules passed".
|
|
189
|
+
*/
|
|
190
|
+
export function verifyRules(artifact, expected) {
|
|
191
|
+
if (!expected)
|
|
192
|
+
return [];
|
|
193
|
+
const split = splitFrontmatter(artifact);
|
|
194
|
+
const body = split.body;
|
|
195
|
+
const results = [];
|
|
196
|
+
if (expected.mustContain?.length) {
|
|
197
|
+
results.push(...checkMustContain(expected.mustContain, body));
|
|
198
|
+
}
|
|
199
|
+
if (expected.mustNotContain?.length) {
|
|
200
|
+
results.push(...checkMustNotContain(expected.mustNotContain, body));
|
|
201
|
+
}
|
|
202
|
+
if (expected.regexRequired?.length) {
|
|
203
|
+
results.push(...checkRegexRequired(expected.regexRequired, body));
|
|
204
|
+
}
|
|
205
|
+
if (expected.regexForbidden?.length) {
|
|
206
|
+
results.push(...checkRegexForbidden(expected.regexForbidden, body));
|
|
207
|
+
}
|
|
208
|
+
if (expected.minOccurrences && Object.keys(expected.minOccurrences).length) {
|
|
209
|
+
results.push(...checkMinOccurrences(expected.minOccurrences, body));
|
|
210
|
+
}
|
|
211
|
+
if (expected.maxOccurrences && Object.keys(expected.maxOccurrences).length) {
|
|
212
|
+
results.push(...checkMaxOccurrences(expected.maxOccurrences, body));
|
|
213
|
+
}
|
|
214
|
+
if (expected.uniqueBulletsInSection?.length) {
|
|
215
|
+
results.push(...checkUniqueBulletsInSection(expected.uniqueBulletsInSection, body));
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-stage traceability verifier: extract a set of IDs from a source
|
|
3
|
+
* fixture (e.g. `D-\d+` decisions declared during scope) and assert every
|
|
4
|
+
* ID appears in the artifact-under-test and/or in other linked fixtures.
|
|
5
|
+
*
|
|
6
|
+
* The verifier is intentionally source-agnostic: the caller passes the
|
|
7
|
+
* primary artifact plus a label → text map for any extra fixtures declared
|
|
8
|
+
* on the case. `source` and entries in `requireIn` are either the string
|
|
9
|
+
* `"self"` (the primary artifact) or labels present in the extras map.
|
|
10
|
+
*
|
|
11
|
+
* Result ids follow `traceability:<source>->:<target>:<reason>` so baselines
|
|
12
|
+
* diff at the per-link granularity. A missing link produces one result with
|
|
13
|
+
* a list of missing IDs in its `details` payload.
|
|
14
|
+
*/
|
|
15
|
+
import type { TraceabilityExpected, VerifierResult } from "../types.js";
|
|
16
|
+
export declare const SELF_LABEL = "self";
|
|
17
|
+
/**
|
|
18
|
+
* Run traceability checks. Returns `[]` when expectations are undefined.
|
|
19
|
+
* Emits a single "source-missing" result when the declared source fixture
|
|
20
|
+
* has zero IDs (authoring error), and one result per `requireIn` target
|
|
21
|
+
* listing any IDs absent in that fixture.
|
|
22
|
+
*/
|
|
23
|
+
export declare function verifyTraceability(primaryArtifact: string, extraFixtures: Record<string, string>, expected: TraceabilityExpected | undefined): VerifierResult[];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { splitFrontmatter } from "./structural.js";
|
|
2
|
+
export const SELF_LABEL = "self";
|
|
3
|
+
function result(id, ok, message, details) {
|
|
4
|
+
return {
|
|
5
|
+
kind: "rules",
|
|
6
|
+
id,
|
|
7
|
+
ok,
|
|
8
|
+
score: ok ? 1 : 0,
|
|
9
|
+
message,
|
|
10
|
+
...(details !== undefined ? { details } : {})
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function compileIdRegex(expected) {
|
|
14
|
+
const flags = expected.idFlags ?? "g";
|
|
15
|
+
const normalized = flags.includes("g") ? flags : `${flags}g`;
|
|
16
|
+
try {
|
|
17
|
+
return new RegExp(expected.idPattern, normalized);
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
throw new Error(`Invalid traceability id_pattern ${JSON.stringify(expected.idPattern)} ` +
|
|
21
|
+
`(flags=${JSON.stringify(normalized)}): ` +
|
|
22
|
+
(err instanceof Error ? err.message : String(err)));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function bodyOf(text) {
|
|
26
|
+
return splitFrontmatter(text).body;
|
|
27
|
+
}
|
|
28
|
+
function extractIds(text, regex) {
|
|
29
|
+
const body = bodyOf(text);
|
|
30
|
+
const found = new Set();
|
|
31
|
+
for (const match of body.matchAll(regex)) {
|
|
32
|
+
found.add(match[0]);
|
|
33
|
+
}
|
|
34
|
+
return [...found].sort();
|
|
35
|
+
}
|
|
36
|
+
function resolveFixture(label, primary, extraFixtures) {
|
|
37
|
+
if (label === SELF_LABEL)
|
|
38
|
+
return primary;
|
|
39
|
+
return extraFixtures[label];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Run traceability checks. Returns `[]` when expectations are undefined.
|
|
43
|
+
* Emits a single "source-missing" result when the declared source fixture
|
|
44
|
+
* has zero IDs (authoring error), and one result per `requireIn` target
|
|
45
|
+
* listing any IDs absent in that fixture.
|
|
46
|
+
*/
|
|
47
|
+
export function verifyTraceability(primaryArtifact, extraFixtures, expected) {
|
|
48
|
+
if (!expected)
|
|
49
|
+
return [];
|
|
50
|
+
const regex = compileIdRegex(expected);
|
|
51
|
+
const sourceText = resolveFixture(expected.source, primaryArtifact, extraFixtures);
|
|
52
|
+
if (sourceText === undefined) {
|
|
53
|
+
return [
|
|
54
|
+
result(`traceability:source:${expected.source}:missing`, false, `Traceability source fixture "${expected.source}" not loaded.`, { source: expected.source })
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
const sourceIds = extractIds(sourceText, regex);
|
|
58
|
+
if (sourceIds.length === 0) {
|
|
59
|
+
return [
|
|
60
|
+
result(`traceability:source:${expected.source}:empty`, false, `Source "${expected.source}" yielded zero ids for pattern /${expected.idPattern}/.`, { source: expected.source, pattern: expected.idPattern })
|
|
61
|
+
];
|
|
62
|
+
}
|
|
63
|
+
const results = [];
|
|
64
|
+
for (const target of expected.requireIn) {
|
|
65
|
+
const targetText = resolveFixture(target, primaryArtifact, extraFixtures);
|
|
66
|
+
if (targetText === undefined) {
|
|
67
|
+
results.push(result(`traceability:target:${target}:missing`, false, `Traceability target fixture "${target}" not loaded.`, { target }));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const targetBody = bodyOf(targetText);
|
|
71
|
+
const missing = sourceIds.filter((id) => !targetBody.includes(id));
|
|
72
|
+
const ok = missing.length === 0;
|
|
73
|
+
results.push(result(`traceability:${expected.source}->${target}`, ok, ok
|
|
74
|
+
? `Every id (${sourceIds.length}) from "${expected.source}" appears in "${target}".`
|
|
75
|
+
: `Target "${target}" is missing ${missing.length}/${sourceIds.length} id(s): ${missing.join(", ")}.`, {
|
|
76
|
+
source: expected.source,
|
|
77
|
+
target,
|
|
78
|
+
sourceIds,
|
|
79
|
+
missing,
|
|
80
|
+
pattern: expected.idPattern
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|