Step 9 of v5.1.0 humanizer Wave 4. Adds tests/scenario-read-test.mjs
runner, tests/scenario-read-test.test.mjs wrapper, and 5 scenario
fixtures in tests/scenarios/ that feed deterministic raw findings
through humanizeFinding and assert the humanized
title/description/recommendation match brief-owner-approved regex
patterns encoding the ground-truth what/why/whatNext answers.
Corpus selection (per brief criteria):
- 01-tok-cascade.json - TOK/CPS category (token efficiency)
- 02-cps-volatile.json - TOK/CPS category (cache prefix stability)
- 03-cnf-conflict.json - CNF category (conflicts)
- 04-gap-no-claude-md.json - GAP category (feature gap)
- 05-set-invalid-json.json - SET category, AND its v5.0.0 title +
description carry tier1 'invalid' (the brief criterion 'one finding
whose v5.0.0 description uses a forbidden word').
Runner mechanics:
- Loads scenarios matching ^\\d{2}-[a-z0-9-]+\\.json$ in sorted order.
- Calls humanizeFinding(scannerInput) and matches each humanized field
against its declared pattern (case-insensitive regex).
- Verifies humanizer-added structural fields (userImpactCategory,
userActionLanguage, relevanceContext) are non-empty strings.
- Per session decision (1a) acceptance is deterministic regex matching
without a runtime human approval gate.
Wrapper adds 3 tests: scenario-match (binds runner to node --test),
category-coverage (TOK/CPS, CNF, GAP, SET all present), and
tier1-presence (at least one v5.0.0 title or description contains a
tier1 forbidden word).
Tests: 736 to 739 (+3 SC-4 tests). Full suite passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
141 lines
4.5 KiB
JavaScript
141 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* SC-4 scenario read-test runner.
|
|
*
|
|
* Loads each scenario in tests/scenarios/0[1-9]-*.json, feeds the
|
|
* `scannerInput` into `humanizeFinding`, and asserts that humanized
|
|
* `title` / `description` / `recommendation` match the regex patterns
|
|
* declared in `expectedHumanized`. The patterns encode the
|
|
* brief-owner-approved ground-truth answers ("what / why / what next")
|
|
* so that passing the deterministic regex match is equivalent to the
|
|
* humanized output answering the three questions a reader would ask.
|
|
*
|
|
* Per brief-owner decision (1a) the gate is deterministic regex
|
|
* matching — no human-in-the-loop step at runtime.
|
|
*
|
|
* Exit 0 = PASS (all scenarios match), exit 1 = FAIL.
|
|
*
|
|
* Usage:
|
|
* node tests/scenario-read-test.mjs
|
|
*/
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import { resolve, dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { humanizeFinding } from '../scanners/lib/humanizer.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SCENARIOS_DIR = resolve(__dirname, 'scenarios');
|
|
|
|
async function loadScenarios() {
|
|
const entries = await readdir(SCENARIOS_DIR);
|
|
const files = entries
|
|
.filter((f) => /^\d{2}-[a-z0-9-]+\.json$/.test(f))
|
|
.sort();
|
|
const scenarios = [];
|
|
for (const f of files) {
|
|
const raw = await readFile(join(SCENARIOS_DIR, f), 'utf8');
|
|
scenarios.push({ file: f, body: JSON.parse(raw) });
|
|
}
|
|
return scenarios;
|
|
}
|
|
|
|
function checkPattern(field, value, pattern) {
|
|
if (typeof value !== 'string') {
|
|
return { ok: false, reason: `${field} is not a string (got ${typeof value})` };
|
|
}
|
|
let re;
|
|
try {
|
|
re = new RegExp(pattern, 'i');
|
|
} catch (err) {
|
|
return { ok: false, reason: `${field} pattern is not a valid regex: ${err.message}` };
|
|
}
|
|
if (!re.test(value)) {
|
|
return {
|
|
ok: false,
|
|
reason: `${field} did not match /${pattern}/i\n actual: ${JSON.stringify(value)}`,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Run one scenario through humanizeFinding and return per-scenario result.
|
|
*/
|
|
export function runOne(scenario) {
|
|
const { findingId, scannerInput, expectedHumanized } = scenario.body;
|
|
const humanized = humanizeFinding(scannerInput);
|
|
|
|
const failures = [];
|
|
for (const [field, key] of [
|
|
['title', 'titlePattern'],
|
|
['description', 'descriptionPattern'],
|
|
['recommendation', 'recommendationPattern'],
|
|
]) {
|
|
const pattern = expectedHumanized?.[key];
|
|
if (typeof pattern !== 'string' || pattern.length === 0) {
|
|
failures.push({ field, reason: `missing or empty pattern key "${key}"` });
|
|
continue;
|
|
}
|
|
const r = checkPattern(field, humanized?.[field], pattern);
|
|
if (!r.ok) failures.push({ field, reason: r.reason });
|
|
}
|
|
|
|
// Sanity: humanizer-added structural fields must be present
|
|
for (const sysField of ['userImpactCategory', 'userActionLanguage', 'relevanceContext']) {
|
|
if (typeof humanized?.[sysField] !== 'string' || humanized[sysField].length === 0) {
|
|
failures.push({
|
|
field: sysField,
|
|
reason: `expected non-empty string from humanizer; got ${JSON.stringify(humanized?.[sysField])}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { file: scenario.file, findingId, humanized, failures };
|
|
}
|
|
|
|
/**
|
|
* Run every scenario, returning aggregate results.
|
|
*/
|
|
export async function runAll() {
|
|
const scenarios = await loadScenarios();
|
|
const results = scenarios.map(runOne);
|
|
const failed = results.filter((r) => r.failures.length > 0);
|
|
return { scenarios: results, failed, passed: results.length - failed.length, total: results.length };
|
|
}
|
|
|
|
async function main() {
|
|
const { scenarios, failed, passed, total } = await runAll();
|
|
|
|
if (total === 0) {
|
|
process.stderr.write('SC-4 FAIL: no scenarios found in tests/scenarios/\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (failed.length === 0) {
|
|
process.stderr.write(
|
|
`SC-4 PASS: ${passed}/${total} scenarios match humanizer output\n`,
|
|
);
|
|
for (const r of scenarios) {
|
|
process.stderr.write(` ${r.file} (${r.findingId}) - OK\n`);
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
process.stderr.write(`SC-4 FAIL: ${failed.length}/${total} scenarios did not match\n`);
|
|
for (const r of failed) {
|
|
process.stderr.write(`\n ${r.file} (${r.findingId})\n`);
|
|
for (const f of r.failures) {
|
|
process.stderr.write(` [${f.field}] ${f.reason}\n`);
|
|
}
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
const isDirectRun =
|
|
process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
|
|
if (isDirectRun) {
|
|
main().catch((err) => {
|
|
process.stderr.write(`Scenario runner error: ${err.message}\n`);
|
|
process.exit(2);
|
|
});
|
|
}
|