Narrow gherkin mutation runner-worker to the changed scenario

The DB soft Gherkin mutation re-ran the whole feature for every mutant;
with a fresh LevelDB world per example, a 48-mutation feature exceeded
900s. The runner-worker now diffs the mutated IR against the base feature
at <work>/base/feature.json (derived from the mutation path, since
job.work_dir may be mutation-specific) and runs only the changed
scenario/example. Safe because each scenario uses an isolated world.
The same feature now completes in ~74s.

By architect.
This commit is contained in:
Chris Troutner
2026-09-17 10:27:06 -07:00
parent 0072b81aac
commit d94aed8e64
2 changed files with 50 additions and 1 deletions
+12
View File
@@ -157,6 +157,18 @@ the captured output before the tool's `System/exit`; the JSON report
with no spaces in the path. The tool writes its manifest (and, when clean, a
`# mutation-stamp`) into the feature file; commit that tool-written change.
- **DB soft Gherkin mutation was impractically slow until the runner-worker
narrowed its scope.** Each mutant re-ran the whole feature (12 examples), and
DB acceptance creates a fresh LevelDB world per example, so a 48-mutation
feature took >900s (only 32 mutations reached after 15 min). `runner-worker.js`
now diffs the mutated IR against `<work>/base/feature.json` — derived from the
mutation path `<work>/mutations/<id>/feature.json`, because the mutator's
`job.work_dir` may be the mutation-specific directory — and runs only the
changed scenario/example. This is safe because every scenario uses an isolated
world. The same 48-mutation feature now completes in ~74s. The indexer and
client runner-workers still run the full feature; apply the same pattern if
their soft runs get slow.
- **`mutate-file.sh` works from every component dir, including the DB.** It
defaults `MUTATE4JS_BIN` to the client's installed
`node_modules/mutate4javascript`, and the tool's `npm test` baseline runs in
+38 -1
View File
@@ -22,11 +22,43 @@
import readline from 'node:readline'
import fs from 'node:fs'
import path from 'node:path'
console.log = (...args) => console.error('[worker]', ...args)
const { runFeature } = await import('./runtime.js')
// A gherkin mutation changes one example value in one scenario. Every scenario
// runs in its own isolated LevelDB world, so the other scenarios cannot be
// affected by the mutation. Narrow the IR to the changed scenario/example so a
// mutant does not re-run the whole feature. Falls back to the full IR when the
// base feature is missing or its shape differs.
function narrowToChanged (ir, basePath) {
try {
const base = JSON.parse(fs.readFileSync(basePath, 'utf8'))
if (!base || !Array.isArray(base.scenarios) || base.scenarios.length !== ir.scenarios.length) return ir
for (let i = 0; i < ir.scenarios.length; i++) {
const scenario = ir.scenarios[i]
const original = base.scenarios[i]
if (JSON.stringify(scenario) === JSON.stringify(original)) continue
const examples = Array.isArray(scenario.examples) ? scenario.examples : []
const originalExamples = Array.isArray(original.examples) ? original.examples : []
if (examples.length === originalExamples.length) {
const changed = examples.filter((ex, j) => JSON.stringify(ex) !== JSON.stringify(originalExamples[j]))
if (changed.length > 0) {
return { ...ir, scenarios: [{ ...scenario, examples: changed }] }
}
}
return { ...ir, scenarios: [scenario] }
}
return ir
} catch (err) {
return ir
}
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
@@ -49,7 +81,12 @@ rl.on('line', async (line) => {
try {
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
const report = await runFeature(ir)
// The mutated feature lives at <work>/mutations/<id>/feature.json, so the
// base feature is two levels up. Do not trust job.work_dir (the mutator may
// pass the mutation-specific directory).
const basePath = path.resolve(path.dirname(job.feature_json), '..', '..', 'base', 'feature.json')
const narrowed = narrowToChanged(ir, basePath)
const report = await runFeature(narrowed)
respond({
id: job.id,
outcome: report.failures === 0 ? 'test_success' : 'test_failure',