@spexcode/spec-eval 0.6.5

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.
@@ -0,0 +1,650 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { join, relative, basename } from 'node:path';
5
+ import { mintIds, parseFrontmatter } from '@spexcode/spec-core';
6
+ import { parseRelation } from '@spexcode/spec-core';
7
+ import { treeTextFiles } from '@spexcode/spec-core';
8
+ export const EVAL_FILE = 'eval.md';
9
+ export const SIDECAR_FILE = 'evals.ndjson';
10
+ export const SCENARIO_PROJECTION = 'spex.eval.scenario-index';
11
+ export const SCENARIO_SCHEMA_VERSION = 1;
12
+ const SCENARIO_KEYS = ['name', 'description', 'expected', 'tags', 'test', 'code', 'related'];
13
+ const LIST_KEYS = ['tags', 'code', 'related'];
14
+ const TEST_KEYS = ['path', 'name'];
15
+ const leadingIndent = (line) => line.match(/^[ \t]*/)?.[0] ?? '';
16
+ // Keep parsing and validation on the same structural walk.
17
+ function walkScenarios(src) {
18
+ const normalized = src.replace(/\r\n?/g, '\n');
19
+ const m = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
20
+ if (!m)
21
+ return { hasFrontmatter: false, hasKey: false, items: [], malformed: [] };
22
+ const lines = m[1].split('\n');
23
+ const scenarioKeys = lines.flatMap((line, index) => /^scenarios:\s*$/.test(line) ? [index] : []);
24
+ let i = scenarioKeys[0] ?? -1;
25
+ if (i < 0)
26
+ return { hasFrontmatter: true, hasKey: false, items: [], malformed: [] };
27
+ const items = [];
28
+ const malformed = scenarioKeys.length > 1
29
+ ? [`duplicate top-level \`scenarios:\` key (${scenarioKeys.length}×) — eval.md must have exactly one declaration list`]
30
+ : [];
31
+ let cur = null;
32
+ let itemIndent = -1; // the indent of the `- ` that starts each scenario (set by the first one)
33
+ for (i++; i < lines.length; i++) {
34
+ const line = lines[i];
35
+ if (!line.trim())
36
+ continue;
37
+ const prefix = leadingIndent(line);
38
+ if (prefix.includes('\t'))
39
+ malformed.push(`line ${i + 2}: tab indentation is not valid in an eval.md scenario mapping`);
40
+ const indent = prefix.length;
41
+ if (indent === 0)
42
+ break; // dedented to another top-level key — scenarios block is done
43
+ const trimmed = line.trim();
44
+ const dash = trimmed.startsWith('- ') || trimmed === '-';
45
+ if (dash && (itemIndent < 0 || indent <= itemIndent)) {
46
+ cur = {
47
+ fields: {}, unknownKeys: [], duplicateKeys: [], malformed: [], locations: {},
48
+ ...(trimmed.slice(1).trim() ? { fieldIndent: `${prefix} ` } : {}),
49
+ };
50
+ items.push(cur);
51
+ itemIndent = indent;
52
+ const inline = trimmed.slice(1).trim(); // text after the dash
53
+ if (inline)
54
+ i = assignField(cur, inline, lines, i, indent, true);
55
+ continue;
56
+ }
57
+ if (!cur) {
58
+ if (!trimmed.startsWith('#'))
59
+ malformed.push(`invalid scenarios entry \`${trimmed}\` before the first scenario`);
60
+ continue;
61
+ }
62
+ if (!trimmed.startsWith('#')) {
63
+ if (cur.fieldIndent === undefined)
64
+ cur.fieldIndent = prefix;
65
+ else if (prefix !== cur.fieldIndent) {
66
+ cur.malformed.push(`inconsistent scenario field indentation: expected ${cur.fieldIndent.length} spaces, got ${prefix.length}`);
67
+ }
68
+ }
69
+ i = assignField(cur, trimmed, lines, i, indent);
70
+ }
71
+ return { hasFrontmatter: true, hasKey: true, items, malformed };
72
+ }
73
+ function assignField(cur, kv, lines, idx, keyIndent, inline = false) {
74
+ const f = kv.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
75
+ if (!f) {
76
+ if (!kv.startsWith('#'))
77
+ cur.malformed.push(`invalid scenario entry \`${kv}\``);
78
+ return idx;
79
+ }
80
+ const key = f[1];
81
+ const scenarioKey = SCENARIO_KEYS.includes(key) ? key : null;
82
+ const finish = (parserEnd, locationEnd = parserEnd) => {
83
+ if (scenarioKey) {
84
+ if (cur.locations[scenarioKey])
85
+ cur.duplicateKeys.push(key);
86
+ else
87
+ cur.locations[scenarioKey] = {
88
+ startLine: idx,
89
+ endLine: locationEnd,
90
+ indent: `${leadingIndent(lines[idx])}${inline ? ' ' : ''}`,
91
+ };
92
+ }
93
+ return parserEnd;
94
+ };
95
+ if (key === 'test') {
96
+ const raw = f[2].trim();
97
+ if (!raw) {
98
+ const parsed = emptyTestObject();
99
+ let childIndent = -1;
100
+ let lastChild = idx;
101
+ let j = idx + 1;
102
+ for (; j < lines.length; j++) {
103
+ const line = lines[j];
104
+ if (!line.trim())
105
+ continue;
106
+ const prefix = leadingIndent(line);
107
+ if (prefix.includes('\t'))
108
+ parsed.malformed.push(`tab indentation is not valid in nested \`test\` metadata`);
109
+ const indent = prefix.length;
110
+ if (indent <= keyIndent)
111
+ break;
112
+ lastChild = j;
113
+ if (childIndent < 0)
114
+ childIndent = indent;
115
+ if (indent !== childIndent) {
116
+ parsed.malformed.push(`invalid nested test object entry \`${line.trim()}\``);
117
+ continue;
118
+ }
119
+ assignTestField(parsed, line.trim());
120
+ }
121
+ cur.testObject = parsed;
122
+ return finish(j - 1, lastChild);
123
+ }
124
+ if (raw.startsWith('{') || raw.endsWith('}')) {
125
+ cur.testObject = parseFlowTestObject(raw);
126
+ return finish(idx);
127
+ }
128
+ }
129
+ if (LIST_KEYS.includes(key) && f[2].trim() === '') {
130
+ const items = [];
131
+ let lastItem = idx;
132
+ let j = idx + 1;
133
+ for (; j < lines.length; j++) {
134
+ const l = lines[j];
135
+ if (!l.trim())
136
+ continue;
137
+ const prefix = leadingIndent(l);
138
+ if (prefix.includes('\t'))
139
+ cur.malformed.push(`tab indentation is not valid in \`${key}\` metadata`);
140
+ const ind = prefix.length;
141
+ if (ind <= keyIndent)
142
+ break;
143
+ const it = l.trim().match(/^-\s*(.+)$/);
144
+ if (!it)
145
+ break;
146
+ items.push(unquote(it[1]));
147
+ lastItem = j;
148
+ }
149
+ if (items.length) {
150
+ cur.fields[key] = items.join(',');
151
+ return finish(j - 1, lastItem);
152
+ }
153
+ }
154
+ let value;
155
+ let end = idx;
156
+ const block = f[2].match(/^([|>])[+-]?\s*$/);
157
+ if (block) {
158
+ const fold = block[1] === '>';
159
+ const body = [];
160
+ let base = -1, j = idx + 1;
161
+ for (; j < lines.length; j++) {
162
+ const l = lines[j];
163
+ const spaces = l.match(/^ */)?.[0] ?? '';
164
+ const tabBeforeContent = l[spaces.length] === '\t';
165
+ const requiredIndent = base < 0 ? keyIndent + 1 : base;
166
+ const tabInIndent = tabBeforeContent && spaces.length < requiredIndent;
167
+ if (tabInIndent)
168
+ cur.malformed.push(`tab indentation is not valid in block scalar \`${key}\``);
169
+ if (!l.trim() && !tabBeforeContent) {
170
+ body.push('');
171
+ continue;
172
+ }
173
+ // A tab after the required indent is scalar content, not indentation.
174
+ const ind = tabInIndent ? leadingIndent(l).length : spaces.length;
175
+ if (ind <= keyIndent)
176
+ break; // dedented to a sibling field / next item → the block is done
177
+ if (base < 0)
178
+ base = ind;
179
+ else if (ind < base)
180
+ cur.malformed.push(`inconsistent block scalar indentation in \`${key}\`: expected at least ${base} spaces, got ${ind}`);
181
+ body.push(l.slice(base));
182
+ }
183
+ while (body.length && body[body.length - 1] === '')
184
+ body.pop(); // strip trailing blanks
185
+ value = fold ? body.join(' ').replace(/\s+/g, ' ').trim() : body.join('\n');
186
+ end = j - 1;
187
+ }
188
+ else {
189
+ value = key === 'test' ? testValue(f[2]) : unquote(f[2]);
190
+ }
191
+ if (SCENARIO_KEYS.includes(key))
192
+ cur.fields[key] = value;
193
+ else
194
+ cur.unknownKeys.push(key);
195
+ return finish(end);
196
+ }
197
+ const unquote = (s) => s.replace(/^["'](.*)["']$/, '$1').trim();
198
+ const emptyTestObject = () => ({ fields: {}, unknownKeys: [], duplicateKeys: [], malformed: [] });
199
+ function testValue(raw, opaque = false) {
200
+ const value = raw.trim();
201
+ if (value.startsWith('"') && value.endsWith('"')) {
202
+ try {
203
+ return JSON.parse(value);
204
+ }
205
+ catch { /* fall through to the parser's legacy quote handling */ }
206
+ }
207
+ const quoted = value.match(/^(["'])([\s\S]*)\1$/);
208
+ return quoted ? quoted[2] : opaque ? value : unquote(value);
209
+ }
210
+ function assignTestField(obj, entry) {
211
+ const f = entry.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
212
+ if (!f) {
213
+ obj.malformed.push(`invalid test object entry \`${entry}\``);
214
+ return;
215
+ }
216
+ const key = f[1];
217
+ if (!TEST_KEYS.includes(key)) {
218
+ obj.unknownKeys.push(key);
219
+ return;
220
+ }
221
+ if (key in obj.fields)
222
+ obj.duplicateKeys.push(key);
223
+ obj.fields[key] = testValue(f[2], key === 'name');
224
+ }
225
+ // Test names may contain commas inside quotes.
226
+ function parseFlowTestObject(raw) {
227
+ const obj = emptyTestObject();
228
+ if (!raw.startsWith('{') || !raw.endsWith('}')) {
229
+ obj.malformed.push('`test` object must be a mapping with exactly `path` and `name`');
230
+ return obj;
231
+ }
232
+ const body = raw.slice(1, -1).trim();
233
+ if (!body)
234
+ return obj;
235
+ const entries = [];
236
+ let start = 0;
237
+ let quote = '';
238
+ let escaped = false;
239
+ for (let i = 0; i < body.length; i++) {
240
+ const ch = body[i];
241
+ if (quote) {
242
+ if (escaped) {
243
+ escaped = false;
244
+ continue;
245
+ }
246
+ if (ch === '\\') {
247
+ escaped = true;
248
+ continue;
249
+ }
250
+ if (ch === quote)
251
+ quote = '';
252
+ continue;
253
+ }
254
+ if (ch === '"' || ch === "'") {
255
+ quote = ch;
256
+ continue;
257
+ }
258
+ if (ch === ',') {
259
+ entries.push(body.slice(start, i).trim());
260
+ start = i + 1;
261
+ }
262
+ }
263
+ if (quote)
264
+ obj.malformed.push('`test` object has an unterminated quoted value');
265
+ entries.push(body.slice(start).trim());
266
+ for (const entry of entries)
267
+ assignTestField(obj, entry);
268
+ return obj;
269
+ }
270
+ function normalizedTest(it) {
271
+ if (it.testObject) {
272
+ const path = it.testObject.fields.path;
273
+ if (!path)
274
+ return undefined;
275
+ const name = it.testObject.fields.name;
276
+ return { path, ...(name ? { name } : {}) };
277
+ }
278
+ const path = it.fields.test;
279
+ return path ? { path } : undefined;
280
+ }
281
+ const normSemantic = (s) => s.replace(/\s+/g, ' ').trim();
282
+ export function scenarioHash(s) {
283
+ return createHash('sha256').update(`${normSemantic(s.description)}\n${normSemantic(s.expected)}`, 'utf8').digest('hex');
284
+ }
285
+ export function scenarioCodeAxis(scenarioCode, nodeCode = []) {
286
+ const parsed = scenarioCode?.length
287
+ ? parseRelation([...scenarioCode], 'code')
288
+ : nodeCode.length && typeof nodeCode[0] !== 'string'
289
+ ? { entries: nodeCode.map((e) => ({ path: e.path, selectors: [...e.selectors] })), problems: [] }
290
+ : parseRelation([...nodeCode], 'code');
291
+ const { entries, problems } = parsed;
292
+ return { entries, paths: entries.map((e) => e.path), problems };
293
+ }
294
+ function parseCodeList(raw) {
295
+ return raw.replace(/^\[|\]$/g, '').split(',').map((s) => unquote(s.trim())).filter(Boolean);
296
+ }
297
+ export function parseScenarios(src) {
298
+ return walkScenarios(src).items
299
+ .map((it) => {
300
+ const tags = it.fields.tags ? parseCodeList(it.fields.tags) : [];
301
+ const code = it.fields.code ? parseCodeList(it.fields.code) : [];
302
+ const related = it.fields.related ? parseCodeList(it.fields.related) : [];
303
+ const test = normalizedTest(it);
304
+ return {
305
+ name: it.fields.name ?? '',
306
+ description: it.fields.description ?? '',
307
+ expected: it.fields.expected ?? '',
308
+ ...(tags.length ? { tags } : {}),
309
+ ...(test ? { test } : {}),
310
+ ...(code.length ? { code } : {}),
311
+ ...(related.length ? { related } : {}),
312
+ };
313
+ })
314
+ .filter((s) => s.name);
315
+ }
316
+ const compareStable = (a, b) => a < b ? -1 : a > b ? 1 : 0;
317
+ const relationRows = (raw, relation) => parseRelation([...(raw ?? [])], relation).entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] }));
318
+ const hashProjection = (value) => createHash('sha256').update(JSON.stringify(value), 'utf8').digest('hex');
319
+ const semanticOnly = (row) => row.semantic;
320
+ const frontmatterList = (value) => Array.isArray(value) ? value : value ? [value] : [];
321
+ function projectionNode(node) {
322
+ const fm = node.specSource ? parseFrontmatter(node.specSource).fm : {};
323
+ const code = parseRelation(frontmatterList(fm.code), 'code');
324
+ const related = parseRelation(frontmatterList(fm.related), 'related');
325
+ const problems = [...code.problems, ...related.problems];
326
+ if (problems.length) {
327
+ throw new Error(`node '${node.id}' has malformed spec relations:\n${problems.map((e) => ` - ${e}`).join('\n')}`);
328
+ }
329
+ return {
330
+ id: node.id,
331
+ code: code.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
332
+ related: related.entries.map((entry) => ({ path: entry.path, selectors: [...entry.selectors] })),
333
+ };
334
+ }
335
+ export function scenarioProjection(nodes, provenance = {}) {
336
+ const nodeRows = nodes.map(projectionNode).sort((a, b) => compareStable(a.id, b.id));
337
+ const rows = [];
338
+ for (const node of nodes) {
339
+ if ('evalSource' in node && node.evalSource !== undefined) {
340
+ const schemaErrors = validateScenarios(node.evalSource);
341
+ const relationErrors = node.scenarios.flatMap((scenario) => [
342
+ ...parseRelation(scenario.code ?? [], 'code').problems,
343
+ ...parseRelation(scenario.related ?? [], 'related').problems,
344
+ ]);
345
+ const errors = [...schemaErrors, ...relationErrors];
346
+ if (errors.length)
347
+ throw new Error(`node '${node.id}' has malformed eval.md:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
348
+ }
349
+ for (const scenario of node.scenarios)
350
+ rows.push({
351
+ semantic: {
352
+ node: node.id,
353
+ name: scenario.name,
354
+ description: scenario.description,
355
+ expected: scenario.expected,
356
+ scenarioHash: scenarioHash(scenario),
357
+ code: relationRows(scenario.code, 'code'),
358
+ related: relationRows(scenario.related, 'related'),
359
+ tags: [...(scenario.tags ?? [])],
360
+ },
361
+ measurement: { test: scenario.test ? { ...scenario.test } : null },
362
+ });
363
+ }
364
+ rows.sort((a, b) => {
365
+ const node = compareStable(a.semantic.node, b.semantic.node);
366
+ return node || compareStable(a.semantic.name, b.semantic.name);
367
+ });
368
+ const semanticRows = rows.map(semanticOnly);
369
+ return {
370
+ projection: SCENARIO_PROJECTION,
371
+ schemaVersion: SCENARIO_SCHEMA_VERSION,
372
+ provenance: { head: provenance.head ?? null, treeSha: provenance.treeSha ?? null },
373
+ nodes: nodeRows,
374
+ semanticIndexHash: hashProjection(semanticRows),
375
+ fullIndexHash: hashProjection(rows),
376
+ planningIndexHash: hashProjection({ nodes: nodeRows, rows }),
377
+ rows,
378
+ };
379
+ }
380
+ export function validateScenarios(src, tagLibrary = [], pathRoot) {
381
+ const { hasFrontmatter, hasKey, items, malformed } = walkScenarios(src);
382
+ if (!hasFrontmatter)
383
+ return ['no frontmatter block — an eval.md must declare a `scenarios:` list'];
384
+ if (!hasKey)
385
+ return ['frontmatter has no `scenarios:` key — declare at least one scenario'];
386
+ if (!items.length)
387
+ return ['`scenarios:` declares no scenarios — add one (name + description + expected)'];
388
+ const errs = [...malformed];
389
+ const counts = new Map();
390
+ const lib = tagLibrary.length ? ` (library: ${tagLibrary.join(', ')})` : '';
391
+ items.forEach((it, idx) => {
392
+ const label = it.fields.name ? `scenario '${it.fields.name}'` : `scenario #${idx + 1}`;
393
+ for (const k of ['name', 'description', 'expected']) {
394
+ if (!it.fields[k]?.trim())
395
+ errs.push(`${label}: missing required field \`${k}\``);
396
+ }
397
+ const tags = it.fields.tags ? parseCodeList(it.fields.tags) : [];
398
+ if (!tags.length) {
399
+ errs.push(`${label}: missing required field \`tags\` — every scenario needs ≥1 tag from the library${lib}; pick one, or add a new tag to lint.scenarioTags in spexcode.json to create it`);
400
+ }
401
+ else if (tagLibrary.length) {
402
+ for (const t of tags)
403
+ if (!tagLibrary.includes(t)) {
404
+ errs.push(`${label}: tag \`${t}\` is not in the configured tag library${lib} — use an existing tag, or add \`${t}\` to lint.scenarioTags in spexcode.json to create it`);
405
+ }
406
+ }
407
+ for (const entry of it.malformed)
408
+ errs.push(`${label}: ${entry}`);
409
+ for (const d of it.duplicateKeys)
410
+ errs.push(`${label}: duplicate field \`${d}\``);
411
+ for (const u of it.unknownKeys)
412
+ errs.push(`${label}: unknown field \`${u}\` (allowed: ${SCENARIO_KEYS.join(', ')})`);
413
+ if (it.testObject) {
414
+ for (const u of it.testObject.unknownKeys)
415
+ errs.push(`${label}: unknown \`test\` field \`${u}\` (allowed: ${TEST_KEYS.join(', ')})`);
416
+ for (const d of it.testObject.duplicateKeys)
417
+ errs.push(`${label}: duplicate \`test.${d}\` field`);
418
+ for (const e of it.testObject.malformed)
419
+ errs.push(`${label}: ${e}`);
420
+ for (const k of TEST_KEYS)
421
+ if (!it.testObject.fields[k]?.length)
422
+ errs.push(`${label}: \`test\` object missing required field \`${k}\``);
423
+ }
424
+ else if ('test' in it.fields && !it.fields.test?.length) {
425
+ errs.push(`${label}: \`test\` scalar path must not be empty`);
426
+ }
427
+ const test = normalizedTest(it);
428
+ if (test && pathRoot && !existsSync(join(pathRoot, test.path))) {
429
+ errs.push(`${label}: \`test.path\` not found: ${test.path}`);
430
+ }
431
+ if (it.fields.name)
432
+ counts.set(it.fields.name, (counts.get(it.fields.name) ?? 0) + 1);
433
+ });
434
+ for (const [n, c] of counts)
435
+ if (c > 1)
436
+ errs.push(`duplicate scenario name '${n}' (${c}×) — names must be unique within an eval.md`);
437
+ return errs;
438
+ }
439
+ const recordOf = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
440
+ function parseMetadataMutation(value) {
441
+ const mutation = recordOf(value);
442
+ if (!mutation || typeof mutation.scenario !== 'string' || !mutation.scenario.trim()) {
443
+ throw new Error('a metadata mutation must name exactly one scenario with a non-empty `scenario` string');
444
+ }
445
+ const unknown = Object.keys(mutation).filter((key) => !['scenario', 'insert', 'delete'].includes(key));
446
+ if (unknown.length)
447
+ throw new Error(`metadata mutation has unknown field(s): ${unknown.join(', ')}`);
448
+ const actions = ['insert', 'delete'].filter((key) => key in mutation);
449
+ if (actions.length !== 1)
450
+ throw new Error('a metadata mutation must contain exactly one action: `insert` or `delete`');
451
+ if ('delete' in mutation) {
452
+ if (mutation.delete !== 'test')
453
+ throw new Error('`delete` must name exactly one measurement field: `test`');
454
+ return { scenario: mutation.scenario, action: 'delete' };
455
+ }
456
+ const insert = recordOf(mutation.insert);
457
+ if (!insert || Object.keys(insert).length !== 1 || !('test' in insert)) {
458
+ throw new Error('`insert` must contain exactly one measurement field: `test`');
459
+ }
460
+ if (typeof insert.test === 'string') {
461
+ if (!insert.test.trim())
462
+ throw new Error('`insert.test` path must be a non-empty string');
463
+ return { scenario: mutation.scenario, action: 'insert', test: { path: insert.test } };
464
+ }
465
+ const test = recordOf(insert.test);
466
+ if (!test || Object.keys(test).sort().join(',') !== 'name,path'
467
+ || typeof test.path !== 'string' || !test.path.trim()
468
+ || typeof test.name !== 'string' || !test.name.trim()) {
469
+ throw new Error('`insert.test` must be a path string or an exact `{path,name}` string mapping');
470
+ }
471
+ return { scenario: mutation.scenario, action: 'insert', test: { path: test.path, name: test.name } };
472
+ }
473
+ function declarationLineEnding(source) {
474
+ const withoutCrlf = source.replace(/\r\n/g, '');
475
+ if (withoutCrlf.includes('\r'))
476
+ throw new Error('eval.md uses unsupported bare CR line endings');
477
+ if (source.includes('\r\n') && withoutCrlf.includes('\n')) {
478
+ throw new Error('eval.md mixes LF and CRLF line endings; normalize it before applying metadata');
479
+ }
480
+ return source.includes('\r\n') ? '\r\n' : '\n';
481
+ }
482
+ function malformedDeclaration(errors) {
483
+ return new Error(`malformed eval.md:\n${errors.map((error) => ` - ${error}`).join('\n')}`);
484
+ }
485
+ export function writeScenarioMeasurementMetadata(source, request) {
486
+ const mutation = parseMetadataMutation(request);
487
+ const beforeErrors = validateScenarios(source);
488
+ if (beforeErrors.length)
489
+ throw malformedDeclaration(beforeErrors);
490
+ const walked = walkScenarios(source);
491
+ const matches = walked.items.filter((item) => item.fields.name === mutation.scenario);
492
+ if (matches.length !== 1) {
493
+ throw new Error(matches.length
494
+ ? `scenario '${mutation.scenario}' is ambiguous (${matches.length} declarations)`
495
+ : `scenario '${mutation.scenario}' was not found in eval.md`);
496
+ }
497
+ const item = matches[0];
498
+ const lineEnding = declarationLineEnding(source);
499
+ const lines = source.split(lineEnding);
500
+ if (mutation.action === 'insert') {
501
+ if (item.locations.test)
502
+ throw new Error(`scenario '${mutation.scenario}' already has \`test\`; refusing to overwrite authoritative metadata`);
503
+ const tags = item.locations.tags;
504
+ if (!tags)
505
+ throw new Error(`scenario '${mutation.scenario}' has no structural \`tags\` field`);
506
+ const keyIndent = tags.indent;
507
+ const childIndent = `${tags.indent} `;
508
+ const rendered = mutation.test.name === undefined
509
+ ? [`${keyIndent}test: ${JSON.stringify(mutation.test.path)}`]
510
+ : [
511
+ `${keyIndent}test:`,
512
+ `${childIndent}path: ${JSON.stringify(mutation.test.path)}`,
513
+ `${childIndent}name: ${JSON.stringify(mutation.test.name)}`,
514
+ ];
515
+ lines.splice(tags.endLine + 2, 0, ...rendered);
516
+ }
517
+ else {
518
+ const test = item.locations.test;
519
+ if (!test)
520
+ throw new Error(`scenario '${mutation.scenario}' has no \`test\` field to delete`);
521
+ lines.splice(test.startLine + 1, test.endLine - test.startLine + 1);
522
+ }
523
+ const proposed = lines.join(lineEnding);
524
+ const afterErrors = validateScenarios(proposed);
525
+ if (afterErrors.length)
526
+ throw new Error(`metadata mutation produced ${malformedDeclaration(afterErrors).message}`);
527
+ const after = parseScenarios(proposed).filter((scenario) => scenario.name === mutation.scenario);
528
+ if (after.length !== 1)
529
+ throw new Error(`metadata mutation lost the unique scenario '${mutation.scenario}'`);
530
+ if (mutation.action === 'insert') {
531
+ if (JSON.stringify(after[0].test) !== JSON.stringify(mutation.test)) {
532
+ throw new Error(`metadata mutation did not round-trip the exact requested \`test\` mapping for scenario '${mutation.scenario}'`);
533
+ }
534
+ }
535
+ else if (after[0].test !== undefined) {
536
+ throw new Error(`metadata mutation did not delete \`test\` from scenario '${mutation.scenario}'`);
537
+ }
538
+ return proposed;
539
+ }
540
+ function assembleNodes(root, specDirs, hits) {
541
+ const specBase = join(root, '.spec');
542
+ const ids = mintIds(specDirs.map((d) => relative(specBase, d).split(/[/\\]/)));
543
+ const idByDir = new Map(specDirs.map((d, i) => [d, ids[i]]));
544
+ return hits
545
+ .map(({ dir, src, specSource }) => ({
546
+ id: idByDir.get(dir) ?? basename(dir),
547
+ dir,
548
+ evalPath: relative(root, join(dir, EVAL_FILE)),
549
+ sidecarPath: join(dir, SIDECAR_FILE),
550
+ scenarios: parseScenarios(src),
551
+ evalSource: src,
552
+ ...(specSource !== undefined ? { specSource } : {}),
553
+ }))
554
+ .sort((a, b) => a.id.localeCompare(b.id));
555
+ }
556
+ export function evalNodes(root) {
557
+ const specDir = join(root, '.spec');
558
+ const specDirs = [];
559
+ const hits = [];
560
+ const stack = existsSync(specDir) ? [specDir] : [];
561
+ while (stack.length) {
562
+ const dir = stack.pop();
563
+ let ents;
564
+ try {
565
+ ents = readdirSync(dir, { withFileTypes: true });
566
+ }
567
+ catch {
568
+ continue;
569
+ }
570
+ if (existsSync(join(dir, 'spec.md')))
571
+ specDirs.push(dir);
572
+ if (existsSync(join(dir, EVAL_FILE))) {
573
+ const specPath = join(dir, 'spec.md');
574
+ hits.push({
575
+ dir,
576
+ src: readFileSync(join(dir, EVAL_FILE), 'utf8'),
577
+ ...(existsSync(specPath) ? { specSource: readFileSync(specPath, 'utf8') } : {}),
578
+ });
579
+ }
580
+ for (const e of ents)
581
+ if (e.isDirectory())
582
+ stack.push(join(dir, e.name));
583
+ }
584
+ return assembleNodes(root, specDirs, hits);
585
+ }
586
+ export function evalNodesAt(root, tip) {
587
+ const files = treeTextFiles(root, tip, '.spec');
588
+ const paths = [...files.keys()];
589
+ const specDirs = paths
590
+ .filter((path) => path.endsWith('/spec.md'))
591
+ .map((path) => join(root, path.slice(0, -'/spec.md'.length)));
592
+ const hits = paths
593
+ .filter((path) => path.endsWith(`/${EVAL_FILE}`))
594
+ .map((path) => {
595
+ const relDir = path.slice(0, -`/${EVAL_FILE}`.length);
596
+ const specSource = files.get(`${relDir}/spec.md`);
597
+ return {
598
+ dir: join(root, relDir),
599
+ src: files.get(path),
600
+ ...(specSource !== undefined ? { specSource } : {}),
601
+ };
602
+ });
603
+ return assembleNodes(root, specDirs, hits);
604
+ }
605
+ // async twin of evalNodes for the HOT board build ([[graph-cache]]): reading each eval.md through
606
+ // fs/promises YIELDS the event loop between files, so the walk no longer stalls a `/health` probe in one
607
+ // ~600ms uninterrupted stretch. Same output (canonical ids, id-sorted) as evalNodes; only buildBoard uses
608
+ // it, other callers keep the sync form.
609
+ export async function evalNodesAsync(root) {
610
+ const specDir = join(root, '.spec');
611
+ const specDirs = [];
612
+ const hits = [];
613
+ const stack = existsSync(specDir) ? [specDir] : [];
614
+ while (stack.length) {
615
+ const dir = stack.pop();
616
+ let ents;
617
+ try {
618
+ ents = await readdir(dir, { withFileTypes: true });
619
+ }
620
+ catch {
621
+ continue;
622
+ }
623
+ if (existsSync(join(dir, 'spec.md')))
624
+ specDirs.push(dir);
625
+ if (existsSync(join(dir, EVAL_FILE))) {
626
+ const specPath = join(dir, 'spec.md');
627
+ hits.push({
628
+ dir,
629
+ src: await readFile(join(dir, EVAL_FILE), 'utf8'),
630
+ ...(existsSync(specPath) ? { specSource: await readFile(specPath, 'utf8') } : {}),
631
+ });
632
+ }
633
+ for (const e of ents)
634
+ if (e.isDirectory())
635
+ stack.push(join(dir, e.name));
636
+ }
637
+ return assembleNodes(root, specDirs, hits);
638
+ }
639
+ export function resolveEvalNode(nodes, ref) {
640
+ const exact = nodes.find((n) => n.id === ref);
641
+ if (exact)
642
+ return { ok: true, node: exact };
643
+ const byLeaf = nodes.filter((n) => basename(n.dir) === ref);
644
+ if (byLeaf.length === 1)
645
+ return { ok: true, node: byLeaf[0] };
646
+ if (byLeaf.length > 1) {
647
+ return { ok: false, ambiguous: true, error: `'${ref}' is ambiguous — ${byLeaf.length} measurable nodes share that leaf name; use a canonical id: ${byLeaf.map((n) => n.id).sort().join(', ')}` };
648
+ }
649
+ return { ok: false, ambiguous: false, error: `no measurable node '${ref}' (a node needs an eval.md)` };
650
+ }