Files
callstack__agent-device/scripts/fuzz/targets.ts
Michał Pierzchała b9509fe006 refactor: extract the .ad script codec into packages/ad-script (#1478) (#1536)
* refactor: extract the .ad script codec into packages/ad-script

Moves the mutually-coupled .ad read/write codec (script.ts, script-utils.ts,
script-formatting.ts, open-script.ts) plus the target-v1 annotation SERDE
slice of target-identity.ts into a new private leaf package,
@agent-device/ad-script, exporting only `.`. This is option 1 from the P5
scoping dossier on #1478: the codec is shared by the daemon's session-script
publication writer, the future replay engine, the CLI's `replay export`, and
Maestro's failure-label formatting, so it can no longer live in root src/
once packages/ad-replay lands (R11 forbids a package reaching into root src),
and a second export subpath or writer-half duplication are both ruled out by
existing gates/tests.

target-identity.ts keeps only the record/replay-shared classification core
(classifyTargetBindingMatch, local-identity/ancestry-prefix matching),
importing its shared types from the new package. Every real consumer
(re-derived by grep, not the dossier's list alone) is rewired to
@agent-device/ad-script.

Refs #1478

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: trim the ad-script façade to real consumers, lock the one-export boundary

- packages/ad-script/src/index.ts: drop parseReplaySeriesFlags,
  formatTargetAnnotationCommentLine, parseTargetAnnotationCommentLine,
  TargetAnnotationLineParseResult, and TargetRect from the public façade —
  none has a consumer outside the package (re-swept every remaining export
  by grep; everything else kept has at least one real external importer).
  The functions/types stay exported from their declaring internal modules
  for the package's own internal use (script.ts, script-formatting.ts).
- scripts/layering/package-boundaries.test.ts: add the parallel R11
  assertions "the real tree parses, declares, and passes R11" already makes
  for maestro/provider-webdriver/provider-limrun/xml — ad-script exports
  exactly `.`, depends on exactly contracts+kernel, and is declared in root
  package.json — plus ad-script entries in the deep-resolution rejection
  coverage. Verified the lock catches a regression: temporarily added a
  fake `./codec` export to packages/ad-script/package.json and confirmed
  both the export-key-list assertion and the deep-resolution-rejection
  assertion fail; removed the plant and reconfirmed green.

Refs #1478

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: remove polynomial-redos ambiguity from the target-v1 annotation line regex

CodeQL js/polynomial-redos flagged TARGET_ANNOTATION_LINE_RE
(packages/ad-script/src/internal/target-annotation-serde.ts): the payload
group's `\s+(.*)` let `\s+` and the unconstrained `.*` both match whitespace,
so a run of separator whitespace that ultimately fails to complete the match
has many `\s+`/`.*` splits to backtrack through before concluding failure.

Anchor the payload group on `\S` (the exact complement of `\s`), so the
mandatory `\s+` separator and the payload's first character can never
overlap — the split point becomes unique and no backtracking is possible.

Behavior-preserving: the only caller (parseTargetAnnotationCommentLine)
always matches against an already-.trim()-ed line, whose last character
(whenever the tag matches at all) is never whitespace — so a payload section
`\S.*` would reject (content that is entirely whitespace) can never reach
this regex through the real call path. Verified against the frozen
replay-compat corpus and the full serde/parser test suites, unmodified.

Added a regression test with the exact adversarial shape CodeQL/the reviewer
cited (many tab pairs after the version digits), asserting sub-second parse.

Refs #1478

Co-Authored-By: Claude <noreply@anthropic.com>

* test(ad-script): pin the annotation-line pattern's linear rejection directly

The entry-point adversarial case matched greedily even with the retired
regex (trim strips edge whitespace and per-line input carries no newline),
so it proved nothing about the pattern. The regression surface is the
pattern itself: an interior tab run with an x-newline tail fails the match,
which the retired form re-split quadratically (3.7s at 100k tabs) and the
\S anchor rejects in one attempt.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 20:21:38 +02:00

111 lines
4.0 KiB
TypeScript

// Parser fuzz targets (#1414).
//
// Every target takes one string case and calls a real parser. The harness owns the
// invariant (typed AppError with a non-empty hint, no hang); a target only says how a
// case string reaches its parser, and supplies the seed inputs the mutator chews on.
//
// Keep targets pure and synchronous: the hang watchdog (scripts/fuzz/worker.ts) can only
// attribute a stall to a case if the case runs to completion in one tick.
import { parseArgs } from '../../src/cli/parser/args.ts';
import { parseSelectorChain } from '../../src/selectors/parse.ts';
import { parseReplayScriptDetailed } from '@agent-device/ad-script';
import { readCliBatchStepsJson } from '../../src/cli/batch-steps.ts';
import { inspectMaestroFlow } from '@agent-device/maestro';
import type { FuzzTarget } from './target-types.ts';
// argv is carried as one string so a case (and its corpus entry, artifact, and repro
// command) stays a single copy-pasteable value. Splitting on spaces is deliberate: the
// fuzzer wants odd tokens, not a faithful shell grammar.
function toArgv(input: string): string[] {
return input.split(' ').filter((token) => token.length > 0);
}
export const FUZZ_TARGETS: readonly FuzzTarget[] = [
{
name: 'cli-args',
description: 'parseArgs (strict flags)',
run: (input) => void parseArgs(toArgv(input), { strictFlags: true }),
seeds: [
'click --selector text=Login',
'open com.example.app --platform ios --json',
'snapshot --depth 3 --format text',
'fill @e1 --text hello --submit',
'batch --steps [] --timeout 1000',
'devices --platform android --json --debug',
'wait --selector role=button --timeout-ms 500',
'test replays -- --raw --passthrough',
'click --selector',
'--json',
'',
],
},
{
name: 'selector',
description: 'parseSelectorChain',
run: (input) => void parseSelectorChain(input),
seeds: [
'text=Login',
'label="Sign in" && role=button',
'text=Save || label=Done',
'id=com.example:id/button[2]',
'role=button and is=enabled',
'text~=partial',
'label="quoted \\"inner\\" value"',
'@e1',
'text=🚀',
'text=',
'',
],
},
{
name: 'replay-script',
description: 'parseReplayScriptDetailed (.ad scripts)',
run: (input) => void parseReplayScriptDetailed(input),
seeds: [
'open com.example.app\nclick text=Login\nclose\n',
'# context platform=ios target=mobile\nopen com.example.app\n',
'# context timeoutMs=1000 retries=2\nsnapshot\n',
'# target-v1 role=button label=Login\nclick @e1\n',
'fill @e1 --text "hello world"\nassert text=Welcome\n',
'swipe 10 20 30 40\nwait 250\n',
'env FOO=bar\nclick text=${FOO}\n',
'screenshot --quality low\n',
'# target-v1 role=button\n\nclick @e1\n',
'',
],
},
{
name: 'batch-steps',
description: 'readCliBatchStepsJson (batch --steps)',
run: (input) => void readCliBatchStepsJson(input),
seeds: [
'[{"command":"snapshot","input":{}}]',
'[{"command":"click","input":{"selector":"text=Login"}}]',
'[{"command":"open","positionals":["com.example.app"],"flags":{"json":true}}]',
'[{"command":"snapshot","input":{}},{"command":"close","input":{}}]',
'[{"command":"wait","input":{"timeoutMs":250}}]',
'[]',
'{}',
'not json',
'',
],
},
{
name: 'maestro',
description: 'parseMaestroProgram (Maestro compat)',
run: (input) => void inspectMaestroFlow(input, 'fuzz.yaml'),
seeds: [
'appId: com.example.app\n---\n- launchApp\n- tapOn: "Login"\n',
'appId: com.example.app\n---\n- tapOn:\n id: "login"\n',
'appId: com.example.app\n---\n- inputText: "hello"\n- assertVisible: "Welcome"\n',
'appId: com.example.app\n---\n- swipe:\n direction: UP\n',
'appId: com.example.app\n---\n- runFlow: other.yaml\n',
'appId: com.example.app\n---\n- repeat:\n times: 2\n commands:\n - back\n',
'- launchApp\n',
'appId: com.example.app\n---\n',
'',
],
},
];