Files
devin-ai-integration[bot] 5a33f8bfc7 feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293) (#1342)
* feat(maestro): numeric option fields accept ${VAR} lookup interpolation (#1293)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro): validate resolved numeric strings before coercion

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro): address review feedback on numeric  resolution

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(maestro-conformance): preserve unresolved ${VAR} numeric tokens in canonical model

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-20 18:06:04 +02:00

104 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
// Rebuilds corpus/manifest.json from the flows on disk. `regenerate.mjs` runs
// this first, so adding a corpus flow is just: drop the .yaml in, add a note
// below if it needs one, and regenerate.
//
// The manifest is the corpus's provenance record: vendored upstream flows carry
// their upstream repo path and sha256 so a silently-edited flow is detectable;
// authored flows say why they exist.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const CORPUS_DIR = path.join(HERE, 'corpus');
const UPSTREAM_REPO_DIR = 'maestro-test/src/test/resources';
const NOTES = {
'bug-classes/percent-decimal-swipe': 'Bug class 1: decimal percentage rejection at parse.',
'bug-classes/target-swipe-missing-direction': 'Bug class 2: target swipe requires an explicit direction.',
'bug-classes/retry-over-cap': 'Bug class 3: retry maxRetries parses verbatim; the clamp is a layer-2 vector.',
'bug-classes/settle-after-tap': 'Bug class 4: plain tap; settle ordering is a layer-3 differential.',
'authored/runscript': 'Coverage: runScript file command (no self-contained upstream flow).',
'authored/runflow-main': 'Coverage: runFlow file include + provenance.',
'authored/runflow-child': 'Include target for runflow-main (not parsed as a top-level flow).',
'authored/doubletap': 'Coverage: doubleTapOn (upstream 101 uses the unsupported retryTapIfNoChange option).',
'authored/scroll-until-visible': 'Coverage: scrollUntilVisible (upstream 079 uses unsupported speed/visibilityPercentage).',
'authored/extended-wait': 'Coverage: extendedWaitUntil (upstream 042 interpolates ${TIMEOUT} from a flow env block).',
'authored/repeat': 'Coverage: repeat.times (upstream 053 also uses the unsupported evalScript command).',
'authored/presskey': 'Coverage: pressKey supported keys (upstream 034 exercises many unsupported keycodes).',
'authored/numeric-variable-tap': 'Coverage: tapOn repeat/delay/index numeric option fields accept ${VAR} tokens (upstream rejects repeat/delay as integer-typed; agent-device is lenient).',
'authored/numeric-variable-doubletap': 'Coverage: doubleTapOn delay accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-swipe': 'Coverage: swipe duration accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-erase': 'Coverage: eraseText charactersToErase accepts a ${VAR} token (upstream rejects as integer-typed; agent-device is lenient).',
'authored/numeric-variable-tap-index': 'Coverage: tapOn index accepts a ${VAR} token and projects identically through the canonical model.',
'authored/numeric-variable-wait': 'Coverage: waitForAnimationToEnd timeout accepts a ${VAR} token and projects identically through the canonical model.',
'invalid/bad-swipe-direction': 'Lenient-guard: unknown SwipeDirection enum value.',
'invalid/unknown-command': 'Lenient-guard: unknown command name (tapOn typo).',
'invalid/malformed-selector': 'Lenient-guard: selector given as a sequence.',
'invalid/duplicate-keys': 'Lenient-guard: duplicate YAML mapping keys.',
'invalid/unknown-selector-field': 'Lenient-guard: unknown field inside a selector map.',
'invalid/commands-not-a-list': 'Lenient-guard: command document is not a sequence.',
};
const sha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const yamlIn = (dir) =>
fs.existsSync(path.join(CORPUS_DIR, dir))
? fs.readdirSync(path.join(CORPUS_DIR, dir)).filter((n) => n.endsWith('.yaml')).sort()
: [];
function entriesFor(dir, kind) {
return yamlIn(dir).map((name) => {
const stem = name.slice(0, -'.yaml'.length);
const id = `${dir}/${stem}`;
const entry = { id, file: `${dir}/${name}` };
if (kind === 'upstream') {
entry.origin = {
kind: 'upstream',
repoPath: `${UPSTREAM_REPO_DIR}/${name}`,
sha256: sha256(path.join(CORPUS_DIR, dir, name)),
};
} else {
entry.origin = { kind: 'authored', note: NOTES[id] ?? '' };
}
if (dir === 'bug-classes') entry.bugClass = stem;
if (id === 'authored/runflow-child') entry.includeTargetOnly = true;
return entry;
});
}
export function buildManifest(pin) {
return {
description:
'Corpus of Maestro flows driven through the upstream parser to generate layer-1 fixtures. Upstream flows are vendored verbatim from the pinned commit; sha256 records provenance. Authored flows fill coverage gaps, encode the four #1217 bug classes, and (invalid/) give the never-accept-what-upstream-rejects guard teeth. Generated by build-manifest.mjs — do not hand-edit.',
upstreamCommit: pin.commit,
flows: [
...entriesFor('upstream', 'upstream'),
...entriesFor('bug-classes', 'authored'),
...entriesFor('invalid', 'authored'),
...entriesFor('authored', 'authored'),
],
};
}
export function writeManifest(pin) {
const manifest = buildManifest(pin);
fs.writeFileSync(
path.join(CORPUS_DIR, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
);
const missing = manifest.flows.filter((f) => f.origin.kind === 'authored' && !f.origin.note);
for (const flow of missing) {
console.warn(`warning: ${flow.id} has no note — add one to NOTES in build-manifest.mjs`);
}
return manifest;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const pin = JSON.parse(fs.readFileSync(path.join(HERE, 'pinned-upstream.json'), 'utf8'));
const manifest = writeManifest(pin);
console.log(`wrote corpus/manifest.json (${manifest.flows.length} flows)`);
}