From c6dd8f5c929a903776c3d1dbb3047e3c99f971b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 31 Jul 2026 22:16:24 +0200 Subject: [PATCH 1/4] 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 --- package.json | 3 +- packages/ad-script/package.json | 18 + packages/ad-script/src/index.ts | 62 ++++ .../src/internal}/__tests__/script.test.ts | 32 +- .../__tests__/target-annotation-serde.test.ts | 314 ++++++++++++++++ .../ad-script/src/internal}/open-script.ts | 0 .../src/internal}/script-formatting.ts | 2 +- .../ad-script/src/internal}/script-utils.ts | 0 .../ad-script/src/internal}/script.ts | 13 +- .../src/internal/target-annotation-serde.ts | 335 +++++++++++++++++ packages/ad-script/tsconfig.json | 12 + pnpm-lock.yaml | 224 ++++++------ scripts/fuzz/targets.ts | 2 +- scripts/layering/model.ts | 1 + .../kernel/platform-collapse-parity.test.ts | 2 +- .../__tests__/replay-maestro-export.test.ts | 2 +- src/cli/commands/replay.ts | 2 +- .../interaction/runtime/selector-wait.ts | 6 +- src/compat/replay-input.ts | 2 +- .../session-script-active-publication.test.ts | 2 +- .../__tests__/session-script-writer.test.ts | 2 +- src/daemon/__tests__/session-store.test.ts | 3 +- .../__tests__/session-target-evidence.test.ts | 2 +- .../session-replay-repair-acceptance.test.ts | 2 +- .../session-replay-repair-hint.test.ts | 2 +- ...ion-replay-repair-record-exclusion.test.ts | 2 +- .../session-replay-repair-transaction.test.ts | 2 +- .../session-replay-repair.fixtures.ts | 2 +- ...n-replay-target-classification-fixtures.ts | 2 +- ...ssion-replay-target-classification.test.ts | 2 +- .../session-replay-target-guard.test.ts | 2 +- .../session-script-publication.test.ts | 2 +- .../__tests__/wait-landmark-recording.test.ts | 2 +- .../handlers/session-replay-divergence.ts | 2 +- src/daemon/handlers/session-replay-heal.ts | 2 +- .../session-replay-maestro-failure.ts | 2 +- .../handlers/session-replay-repair-hint.ts | 7 +- ...session-replay-runtime-failure-response.ts | 2 +- .../handlers/session-replay-runtime-plan.ts | 2 +- src/daemon/handlers/session-replay-runtime.ts | 2 +- .../session-replay-target-classification.ts | 2 +- .../handlers/session-replay-target-token.ts | 2 +- .../session-replay-target-verification.ts | 8 +- src/daemon/handlers/session-replay-vars.ts | 2 +- src/daemon/handlers/session-replay.ts | 2 +- .../handlers/session-test-source-discovery.ts | 2 +- src/daemon/parameterized-recorded-fill.ts | 2 +- src/daemon/selector-runtime.ts | 2 +- src/daemon/session-action-recorder.ts | 2 +- .../session-script-active-publication.ts | 2 +- src/daemon/session-script-writer.ts | 10 +- src/daemon/session-target-evidence.ts | 6 +- src/daemon/types.ts | 2 +- .../__tests__/ad-script-round-trip.test.ts | 48 +++ src/replay/__tests__/target-identity.test.ts | 319 +---------------- src/replay/__tests__/vars.test.ts | 2 +- src/replay/target-evidence-tree.ts | 2 +- src/replay/target-identity-node.ts | 4 +- src/replay/target-identity.ts | 338 +----------------- src/replay/vars.ts | 5 +- .../live-device-e2e/replay-evidence.ts | 2 +- .../smoke-ios-simulator-coverage.test.ts | 2 +- 62 files changed, 1006 insertions(+), 840 deletions(-) create mode 100644 packages/ad-script/package.json create mode 100644 packages/ad-script/src/index.ts rename {src/replay => packages/ad-script/src/internal}/__tests__/script.test.ts (94%) create mode 100644 packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts rename {src/replay => packages/ad-script/src/internal}/open-script.ts (100%) rename {src/replay => packages/ad-script/src/internal}/script-formatting.ts (95%) rename {src/replay => packages/ad-script/src/internal}/script-utils.ts (100%) rename {src/replay => packages/ad-script/src/internal}/script.ts (97%) create mode 100644 packages/ad-script/src/internal/target-annotation-serde.ts create mode 100644 packages/ad-script/tsconfig.json create mode 100644 src/replay/__tests__/ad-script-round-trip.test.ts diff --git a/package.json b/package.json index 167fc274d9..c482f70fe7 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", - "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", + "typecheck": "tsc -b packages/xml packages/kernel packages/contracts packages/ad-script packages/maestro packages/replay-test packages/provider-webdriver packages/provider-limrun && tsc -p tsconfig.json && tsc -p examples/sdk/tsconfig.json", "test-app:install": "pnpm install --dir examples/test-app", "test-app:start": "pnpm --dir examples/test-app start", "test-app:ios": "pnpm --dir examples/test-app ios", @@ -245,6 +245,7 @@ "yaml": "^2.9.0" }, "devDependencies": { + "@agent-device/ad-script": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", "@agent-device/maestro": "workspace:*", diff --git a/packages/ad-script/package.json b/packages/ad-script/package.json new file mode 100644 index 0000000000..9524facb10 --- /dev/null +++ b/packages/ad-script/package.json @@ -0,0 +1,18 @@ +{ + "name": "@agent-device/ad-script", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "description": "Private canonical .ad replay script codec (read + write) for agent-device.", + "dependencies": { + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + } +} diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts new file mode 100644 index 0000000000..976e541983 --- /dev/null +++ b/packages/ad-script/src/index.ts @@ -0,0 +1,62 @@ +/** + * The `.ad` script codec façade (#1478 P5 scoping dossier, "the codec seam"). + * + * The canonical `.ad` replay script format — read half (parsing a script into + * actions) and write half (formatting actions back into script lines) of one + * artifact, mutually coupled (`script-formatting.ts` calls into the `open` + * action's writer; `script.ts` calls into its parser). Shared by the daemon's + * session-script publication writer, the replay engine's script reader, the + * CLI's `replay export`, and Maestro's failure-label formatting. + * + * Also owns the `# agent-device:target-v1` annotation SERDE (wire type, + * canonical field order, normalization, size caps, payload parsing). The + * companion classification core (`classifyTargetBindingMatch`, local-identity + * + ancestry-prefix matching) is NOT part of this codec — it stays in + * `src/replay/target-identity.ts`, which imports the types below. + */ + +export { + parseReplayScriptDetailed, + readReplayScriptMetadata, + REPLAY_VAR_KEY_RE, +} from './internal/script.ts'; +export type { ParsedReplayScript, ReplayScriptMetadata } from './internal/script.ts'; + +export { + appendScriptSeriesFlags, + formatDivergenceActionLabel, + formatScriptArg, + formatScriptStringLiteral, + isClickLikeCommand, + isTouchTargetCommand, + parseReplaySeriesFlags, + stripRecordedRefGeneration, +} from './internal/script-utils.ts'; + +export { + formatPortableActionLine, + formatTargetAnnotationLines, +} from './internal/script-formatting.ts'; + +export { + formatTargetAnnotationCommentLine, + normalizeIdentifierField, + normalizeLabelField, + normalizeRoleField, + parseTargetAnnotationCommentLine, + parseTargetAnnotationV1Payload, + serializeTargetAnnotationV1, + truncateToUtf8Bytes, + utf8ByteLength, + TARGET_ANNOTATION_MAX_ANCESTRY, + TARGET_ANNOTATION_MAX_FIELD_BYTES, + TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, +} from './internal/target-annotation-serde.ts'; +export type { + TargetAncestryEntry, + TargetAnnotationLineParseResult, + TargetAnnotationV1, + TargetRect, + TargetScrollRegion, + TargetVerification, +} from './internal/target-annotation-serde.ts'; diff --git a/src/replay/__tests__/script.test.ts b/packages/ad-script/src/internal/__tests__/script.test.ts similarity index 94% rename from src/replay/__tests__/script.test.ts rename to packages/ad-script/src/internal/__tests__/script.test.ts index 901748712b..dafb8dfe7b 100644 --- a/src/replay/__tests__/script.test.ts +++ b/packages/ad-script/src/internal/__tests__/script.test.ts @@ -1,7 +1,5 @@ -import fc from 'fast-check'; import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { PROPERTY_RUNS, replayScriptArb } from '../../__tests__/test-utils/index.ts'; import { AppError } from '@agent-device/kernel/errors'; import { parseReplayScriptDetailed, @@ -9,7 +7,7 @@ import { REPLAY_METADATA_PLATFORMS, } from '../script.ts'; import { formatPortableActionLine, formatTargetAnnotationLines } from '../script-formatting.ts'; -import type { TargetAnnotationV1 } from '../target-identity.ts'; +import type { TargetAnnotationV1 } from '../target-annotation-serde.ts'; import type { SessionAction } from '@agent-device/contracts/session'; // `writeReplayScript` (the `--update` heal-and-rewrite serializer) was @@ -624,24 +622,10 @@ test('formatDivergenceActionLabel categorically drops fill/type text but keeps t ); }); -// Property, not another example: `.ad` scripts are written by hand, recorded, -// and rewritten, so the parser and the line formatter must agree on ONE -// canonical form — re-serializing a parsed script has to be a fixed point. -// Generated lines come from the shared `.ad` generator, so a new command shape -// extends the generator rather than adding another pinned script here. -test('serializing a parsed script is a fixed point for generated scripts', () => { - fc.assert( - fc.property(replayScriptArb, (script) => { - const parsed = parseReplayScriptDetailed(script).actions; - const canonical = formatReplayScriptForTest(parsed); - const reparsed = parseReplayScriptDetailed(canonical).actions; - assert.equal(formatReplayScriptForTest(reparsed), canonical); - // The action identity survives the rewrite: same commands, same targets. - assert.deepEqual( - reparsed.map((action) => [action.command, action.positionals]), - parsed.map((action) => [action.command, action.positionals]), - ); - }), - { numRuns: PROPERTY_RUNS }, - ); -}); +// The property test asserting "serializing a parsed script is a fixed point +// for generated scripts" stays at `src/replay/__tests__/ad-script-round-trip.test.ts`: +// its script generator (`replayScriptArb`) is derived from the root command +// catalog and selector grammar (`src/__tests__/test-utils/property-arbitraries.ts`), +// which this package cannot import without an R11 package→root-src escape +// (#1478 P5 scoping dossier §5d). It exercises this package's exports via +// the `@agent-device/ad-script` specifier instead of duplicating the codec. diff --git a/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts new file mode 100644 index 0000000000..d04ddf6b97 --- /dev/null +++ b/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts @@ -0,0 +1,314 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; +import { + formatTargetAnnotationCommentLine, + normalizeLabelField, + parseTargetAnnotationCommentLine, + parseTargetAnnotationV1Payload, + serializeTargetAnnotationV1, + truncateToUtf8Bytes, + TARGET_ANNOTATION_MAX_ANCESTRY, + TARGET_ANNOTATION_MAX_FIELD_BYTES, + TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, + type TargetAnnotationV1, +} from '../target-annotation-serde.ts'; + +function baseEvidence(overrides: Partial = {}): TargetAnnotationV1 { + return { + id: 'save', + role: 'button', + label: 'Save', + ancestry: [{ role: 'toolbar', label: 'Editor' }, { role: 'window' }], + sibling: 0, + viewportOrder: 0, + scrollRegion: { role: 'scrollview', id: 'editor-scroll' }, + verification: 'verified', + ...overrides, + }; +} + +function assertInvalidArgs(fn: () => unknown, messagePattern?: RegExp): void { + assert.throws( + fn, + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + (!messagePattern || messagePattern.test(error.message)), + ); +} + +// --------------------------------------------------------------------------- +// Canonical serialization / field order +// --------------------------------------------------------------------------- + +test('serializeTargetAnnotationV1 uses the exact canonical field order from decision 3', () => { + const json = serializeTargetAnnotationV1(baseEvidence()); + assert.equal( + json, + '{"id":"save","role":"button","label":"Save","ancestry":[{"role":"toolbar","label":"Editor"},{"role":"window"}],"sibling":0,"viewportOrder":0,"scrollRegion":{"role":"scrollview","id":"editor-scroll"},"verification":"verified"}', + ); +}); + +test('formatTargetAnnotationCommentLine emits the ASCII # agent-device:target-v1 prefix', () => { + const line = formatTargetAnnotationCommentLine(baseEvidence()); + assert.ok(line.startsWith('# agent-device:target-v1 {')); +}); + +// --------------------------------------------------------------------------- +// Parse-write-parse round trip / semantic equality +// --------------------------------------------------------------------------- + +test('parse(serialize(evidence)) round trips to a semantically equal object', () => { + const evidence = baseEvidence({ rect: { x: 1, y: 2, width: 3, height: 4 } }); + const parsedBack = parseTargetAnnotationV1Payload(serializeTargetAnnotationV1(evidence)); + assert.deepEqual(parsedBack, evidence); +}); + +test('parseTargetAnnotationCommentLine accepts known fields in any JSON key order', () => { + const line = + '# agent-device:target-v1 {"verification":"verified","sibling":0,"role":"button","viewportOrder":2,"ancestry":[],"id":"save"}'; + const result = parseTargetAnnotationCommentLine(line); + assert.equal(result.kind, 'v1'); + if (result.kind !== 'v1') throw new Error('unreachable'); + assert.deepEqual(result.evidence, { + id: 'save', + role: 'button', + ancestry: [], + sibling: 0, + viewportOrder: 2, + verification: 'verified', + }); +}); + +test('parseTargetAnnotationCommentLine ignores unknown fields', () => { + const line = + '# agent-device:target-v1 {"role":"button","verification":"verified","futureField":{"nested":true}}'; + const result = parseTargetAnnotationCommentLine(line); + assert.equal(result.kind, 'v1'); + if (result.kind !== 'v1') throw new Error('unreachable'); + assert.equal((result.evidence as Record).futureField, undefined); +}); + +test('an unknown future target-vN annotation is an ordinary comment to a v1 reader', () => { + const result = parseTargetAnnotationCommentLine('# agent-device:target-v2 {"anything":"goes"}'); + assert.deepEqual(result, { kind: 'future-version' }); +}); + +test('a line that merely mentions the tag in prose is an ordinary comment', () => { + assert.deepEqual(parseTargetAnnotationCommentLine('# see agent-device:target-v1 docs'), { + kind: 'none', + }); + assert.deepEqual(parseTargetAnnotationCommentLine('# just a comment'), { kind: 'none' }); +}); + +// --------------------------------------------------------------------------- +// Normalization: NFC, label trim/collapse, normalized-role source +// --------------------------------------------------------------------------- + +test('normalizeLabelField NFC-normalizes, trims, and collapses internal whitespace', () => { + // "é" as e + combining acute (NFD) must normalize to the precomposed (NFC) form. + const nfd = 'Café'; + assert.equal(normalizeLabelField(` ${nfd} au lait `), 'Café au lait'); +}); + +test('normalizeLabelField treats a whitespace-only label as absent', () => { + assert.equal(normalizeLabelField(' '), undefined); +}); + +test('embedded quotes and backslashes in labels round trip losslessly', () => { + const evidence = baseEvidence({ label: 'Say "hi" \\ backslash', id: undefined }); + const json = serializeTargetAnnotationV1(evidence); + assert.ok(json.includes('\\"hi\\"')); + const parsed = parseTargetAnnotationV1Payload(json); + assert.equal(parsed.label, 'Say "hi" \\ backslash'); +}); + +test('Unicode labels (including astral code points) round trip losslessly', () => { + const evidence = baseEvidence({ label: '\u{1F600} café résumé', id: undefined }); + const parsed = parseTargetAnnotationV1Payload(serializeTargetAnnotationV1(evidence)); + assert.equal(parsed.label, '\u{1F600} café résumé'); +}); + +// --------------------------------------------------------------------------- +// Old/new reader compatibility +// --------------------------------------------------------------------------- + +test('a v1 reader treats an annotation with only role + verification as valid, defaulting the rest', () => { + const result = parseTargetAnnotationCommentLine( + '# agent-device:target-v1 {"role":"button","verification":"verified"}', + ); + assert.equal(result.kind, 'v1'); + if (result.kind !== 'v1') throw new Error('unreachable'); + assert.deepEqual(result.evidence, { + role: 'button', + ancestry: [], + sibling: 0, + viewportOrder: 0, + verification: 'verified', + }); +}); + +// --------------------------------------------------------------------------- +// Bounds: 256-byte fields, 4 KiB payload, 8-entry ancestry — parser REJECTS, +// never truncates. +// --------------------------------------------------------------------------- + +test('parser rejects a string field exceeding the 256-byte cap', () => { + const oversizedLabel = 'x'.repeat(TARGET_ANNOTATION_MAX_FIELD_BYTES + 1); + assertInvalidArgs( + () => + parseTargetAnnotationV1Payload( + JSON.stringify({ role: 'button', label: oversizedLabel, verification: 'verified' }), + ), + /256-byte field cap/, + ); +}); + +test('parser rejects a payload exceeding the 4 KiB cap', () => { + // Every individual field stays within the 256-byte field cap, but 8 + // maxed-out ancestry entries plus maxed top-level/scrollRegion fields blow + // the 4 KiB payload ceiling collectively. + const maxLabel = 'x'.repeat(TARGET_ANNOTATION_MAX_FIELD_BYTES); + const ancestry = Array.from({ length: TARGET_ANNOTATION_MAX_ANCESTRY }, () => ({ + role: maxLabel, + label: maxLabel, + })); + const json = JSON.stringify({ + id: maxLabel, + role: maxLabel, + label: maxLabel, + ancestry, + scrollRegion: { role: maxLabel, id: maxLabel, label: maxLabel }, + verification: 'verified', + }); + assert.ok(Buffer.byteLength(json, 'utf8') > TARGET_ANNOTATION_MAX_PAYLOAD_BYTES); + assertInvalidArgs(() => parseTargetAnnotationV1Payload(json), /4096-byte payload cap/); +}); + +test('parser rejects more than 8 ancestry entries', () => { + const ancestry = Array.from({ length: TARGET_ANNOTATION_MAX_ANCESTRY + 1 }, () => ({ + role: 'view', + })); + assertInvalidArgs( + () => + parseTargetAnnotationV1Payload( + JSON.stringify({ role: 'button', ancestry, verification: 'verified' }), + ), + /8-entry cap/, + ); +}); + +test('truncateToUtf8Bytes never splits a surrogate pair', () => { + const emoji = '\u{1F600}'; // 4 UTF-8 bytes, a surrogate pair in UTF-16 + const truncated = truncateToUtf8Bytes(`ab${emoji}`, 3); + assert.equal(Buffer.byteLength(truncated, 'utf8') <= 3, true); + // The budget (3 bytes) fits "ab" but not the 4-byte emoji — the whole + // surrogate pair must be dropped together, never split. + assert.equal(truncated, 'ab'); + assert.equal(/[\ud800-\udbff]$/.test(truncated), false); +}); + +// --------------------------------------------------------------------------- +// Malformed / unbound annotations +// --------------------------------------------------------------------------- + +test('parser rejects non-JSON payloads', () => { + assertInvalidArgs(() => parseTargetAnnotationV1Payload('{not json'), /valid JSON/); +}); + +test('parser rejects a JSON array or scalar payload', () => { + assertInvalidArgs(() => parseTargetAnnotationV1Payload('[]')); + assertInvalidArgs(() => parseTargetAnnotationV1Payload('"button"')); +}); + +test('parser rejects a wrong-typed known field', () => { + assertInvalidArgs(() => + parseTargetAnnotationV1Payload(JSON.stringify({ role: 42, verification: 'verified' })), + ); +}); + +test('parser rejects an invalid verification value', () => { + assertInvalidArgs(() => + parseTargetAnnotationV1Payload(JSON.stringify({ role: 'button', verification: 'maybe' })), + ); +}); + +test('parser rejects a negative or non-integer sibling/viewportOrder', () => { + assertInvalidArgs(() => + parseTargetAnnotationV1Payload( + JSON.stringify({ role: 'button', sibling: -1, verification: 'verified' }), + ), + ); + assertInvalidArgs(() => + parseTargetAnnotationV1Payload( + JSON.stringify({ role: 'button', viewportOrder: 1.5, verification: 'verified' }), + ), + ); +}); + +// --------------------------------------------------------------------------- +// rect is diagnostic only: parsed, bounded, but never a comparison input at +// this parser layer (there is no comparator here yet — decision 3's +// enforcement lands in a later migration step — this just proves the parser +// accepts/round-trips it as inert data). +// --------------------------------------------------------------------------- + +test('rect parses and round trips but carries no comparison semantics here', () => { + const evidence = baseEvidence({ rect: { x: 10, y: 20, width: 30, height: 40 } }); + const parsed = parseTargetAnnotationV1Payload(serializeTargetAnnotationV1(evidence)); + assert.deepEqual(parsed.rect, { x: 10, y: 20, width: 30, height: 40 }); +}); + +test('parser rejects a malformed rect', () => { + assertInvalidArgs(() => + parseTargetAnnotationV1Payload( + JSON.stringify({ role: 'button', rect: { x: 1, y: 2 }, verification: 'verified' }), + ), + ); +}); + +// --------------------------------------------------------------------------- +// Role presence: the writer emits `role` unconditionally (top level, every +// ancestry entry, scrollRegion) — possibly as the empty string for a +// typeless node, which stays accepted. A MISSING role key can only come from +// a hand-edited/adversarial annotation and must be rejected, or step-4 +// enforcement could match anonymous wrapper nodes through an implicit +// empty-role identity. +// --------------------------------------------------------------------------- + +test('parser rejects a missing top-level role', () => { + assertInvalidArgs( + () => parseTargetAnnotationV1Payload(JSON.stringify({ verification: 'verified' })), + /"role" is required/, + ); +}); + +test('parser rejects a missing role in an ancestry entry and in scrollRegion', () => { + assertInvalidArgs( + () => + parseTargetAnnotationV1Payload( + JSON.stringify({ + role: 'button', + ancestry: [{ label: 'Editor' }], + verification: 'verified', + }), + ), + /"ancestry\[0\]\.role" is required/, + ); + assertInvalidArgs( + () => + parseTargetAnnotationV1Payload( + JSON.stringify({ role: 'button', scrollRegion: { id: 'list' }, verification: 'verified' }), + ), + /"scrollRegion\.role" is required/, + ); +}); + +test('parser accepts an explicit empty-string role (writer-legal for typeless nodes)', () => { + const parsed = parseTargetAnnotationV1Payload( + JSON.stringify({ role: '', ancestry: [{ role: '' }], verification: 'verified' }), + ); + assert.equal(parsed.role, ''); + assert.deepEqual(parsed.ancestry, [{ role: '' }]); +}); diff --git a/src/replay/open-script.ts b/packages/ad-script/src/internal/open-script.ts similarity index 100% rename from src/replay/open-script.ts rename to packages/ad-script/src/internal/open-script.ts diff --git a/src/replay/script-formatting.ts b/packages/ad-script/src/internal/script-formatting.ts similarity index 95% rename from src/replay/script-formatting.ts rename to packages/ad-script/src/internal/script-formatting.ts index dd75c90aeb..e5a37a1270 100644 --- a/src/replay/script-formatting.ts +++ b/packages/ad-script/src/internal/script-formatting.ts @@ -6,7 +6,7 @@ import { appendScreenshotActionScriptArgs, appendSnapshotActionScriptArgs, } from './script-utils.ts'; -import { formatTargetAnnotationCommentLine } from './target-identity.ts'; +import { formatTargetAnnotationCommentLine } from './target-annotation-serde.ts'; import type { SessionAction } from '@agent-device/contracts/session'; export function formatPortableActionLine( diff --git a/src/replay/script-utils.ts b/packages/ad-script/src/internal/script-utils.ts similarity index 100% rename from src/replay/script-utils.ts rename to packages/ad-script/src/internal/script-utils.ts diff --git a/src/replay/script.ts b/packages/ad-script/src/internal/script.ts similarity index 97% rename from src/replay/script.ts rename to packages/ad-script/src/internal/script.ts index 8906c18214..b4a16d581c 100644 --- a/src/replay/script.ts +++ b/packages/ad-script/src/internal/script.ts @@ -12,8 +12,17 @@ import { parseReplayRuntimeFlags, stripRecordedRefGeneration, } from './script-utils.ts'; -import { parseTargetAnnotationCommentLine } from './target-identity.ts'; -import { REPLAY_VAR_KEY_RE } from './vars.ts'; +import { parseTargetAnnotationCommentLine } from './target-annotation-serde.ts'; + +/** + * The `.ad` script env/var key shape: uppercase letters, digits, and + * underscores, leading with a letter or underscore. Canonical here because + * `env KEY=VALUE` directive parsing is script grammar; `src/replay/vars.ts` + * (runtime `${VAR}` resolution, outside this package) and + * `src/replay/recorded-input.ts` import it from this package rather than + * duplicating the rule. + */ +export const REPLAY_VAR_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; // Replay metadata `context platform=` lines support every accepted `--platform` // selector except 'web' (not yet a supported replay target). Legacy `ios`/`macos` diff --git a/packages/ad-script/src/internal/target-annotation-serde.ts b/packages/ad-script/src/internal/target-annotation-serde.ts new file mode 100644 index 0000000000..91aad00cce --- /dev/null +++ b/packages/ad-script/src/internal/target-annotation-serde.ts @@ -0,0 +1,335 @@ +/** + * ADR 0012 decision 3: versioned `.ad` target-binding evidence — the + * comment-line SERDE half shared by the writer + * (`src/daemon/session-target-evidence.ts`) and the parser + * (`packages/ad-script/src/internal/script.ts`). Owns the wire type, + * canonical field order, normalization, size caps, and payload + * parsing/validation. + * + * The record/replay-shared CLASSIFICATION core (`classifyTargetBindingMatch`, + * local-identity + ancestry-prefix matching) is not part of this codec — it + * stays in `src/replay/target-identity.ts`, which imports the types below + * from this package (#1478 P5 scoping dossier, "the codec seam"). + */ + +import { AppError } from '@agent-device/kernel/errors'; + +const TARGET_ANNOTATION_TAG = 'agent-device:target-v1'; +// Captures the rest of the line verbatim: a line claiming the tag with a +// garbage payload is a malformed v1 annotation, never an ordinary comment. +const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(.*))?$/; + +export const TARGET_ANNOTATION_MAX_FIELD_BYTES = 256; +export const TARGET_ANNOTATION_MAX_PAYLOAD_BYTES = 4096; +export const TARGET_ANNOTATION_MAX_ANCESTRY = 8; + +// The annotation SHAPE lives in contracts/ so the recorded-action type can be stated without +// depending on this zone; re-exported here for existing consumers. +export type { + TargetAncestryEntry, + TargetAnnotationV1, + TargetRect, + TargetScrollRegion, + TargetVerification, +} from '@agent-device/contracts/replay'; +import type { + TargetAncestryEntry, + TargetAnnotationV1, + TargetRect, + TargetScrollRegion, + TargetVerification, +} from '@agent-device/contracts/replay'; + +// --------------------------------------------------------------------------- +// Normalization (decision 3 "Normalization"): all strings NFC; `label` fields +// additionally trim and collapse internal whitespace runs. A string that is +// empty after normalization is omitted (writer) / treated as absent +// (comparator). Applies to every string field: top-level id/role/label, +// ancestry entry role/label, and scrollRegion role/id/label. +// --------------------------------------------------------------------------- + +function nfc(value: string): string { + return value.normalize('NFC'); +} + +/** id/role fields: NFC only (never trimmed/collapsed — see decision 3). */ +export function normalizeIdentifierField(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = nfc(value); + return normalized.length > 0 ? normalized : undefined; +} + +/** `role` is always required (may be the empty string per decision 3's ancestry note). */ +export function normalizeRoleField(value: string): string { + return nfc(value); +} + +/** label fields: NFC, trim, collapse internal whitespace runs to one space. */ +export function normalizeLabelField(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const collapsed = nfc(value).trim().replace(/\s+/g, ' '); + return collapsed.length > 0 ? collapsed : undefined; +} + +export function utf8ByteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +/** + * Writer-side field truncation to the 256-byte cap ("per-field truncation", + * decision 3's writer-parser invariant). Trims on a code-point boundary so a + * surrogate pair is never split. The parser never calls this — it REJECTS + * oversized fields instead (see `parseTargetAnnotationV1Payload`). + */ +export function truncateToUtf8Bytes(value: string, maxBytes: number): string { + if (utf8ByteLength(value) <= maxBytes) return value; + let end = value.length; + while (end > 0 && utf8ByteLength(value.slice(0, end)) > maxBytes) { + end -= 1; + } + if (end > 0) { + const code = value.charCodeAt(end - 1); + if (code >= 0xd8_00 && code <= 0xdb_ff) end -= 1; // don't split a surrogate pair + } + return value.slice(0, end); +} + +// --------------------------------------------------------------------------- +// Canonical serialization (decision 3's exact field order + nested-object +// key order from the example payload). +// --------------------------------------------------------------------------- + +function buildCanonicalTargetAnnotationObject( + evidence: TargetAnnotationV1, +): Record { + const obj: Record = {}; + if (evidence.id !== undefined) obj.id = evidence.id; + obj.role = evidence.role; + if (evidence.label !== undefined) obj.label = evidence.label; + obj.ancestry = evidence.ancestry.map(buildAncestryEntryObject); + obj.sibling = evidence.sibling; + obj.viewportOrder = evidence.viewportOrder; + if (evidence.scrollRegion) obj.scrollRegion = buildScrollRegionObject(evidence.scrollRegion); + if (evidence.rect) obj.rect = buildRectObject(evidence.rect); + obj.verification = evidence.verification; + return obj; +} + +function buildAncestryEntryObject(entry: TargetAncestryEntry): Record { + const obj: Record = { role: entry.role }; + if (entry.label !== undefined) obj.label = entry.label; + return obj; +} + +function buildScrollRegionObject(region: TargetScrollRegion): Record { + const obj: Record = { role: region.role }; + if (region.id !== undefined) obj.id = region.id; + if (region.label !== undefined) obj.label = region.label; + return obj; +} + +function buildRectObject(rect: TargetRect): Record { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; +} + +export function serializeTargetAnnotationV1(evidence: TargetAnnotationV1): string { + return JSON.stringify(buildCanonicalTargetAnnotationObject(evidence)); +} + +export function formatTargetAnnotationCommentLine(evidence: TargetAnnotationV1): string { + return `# ${TARGET_ANNOTATION_TAG} ${serializeTargetAnnotationV1(evidence)}`; +} + +// --------------------------------------------------------------------------- +// Parsing (decision 3's parser bullet + "Replay-time verification" intro). +// --------------------------------------------------------------------------- + +export type TargetAnnotationLineParseResult = + | { kind: 'none' } + | { kind: 'future-version' } + | { kind: 'v1'; evidence: TargetAnnotationV1 }; + +/** + * Recognizes a `# agent-device:target-vN {...}` comment line. `N !== 1` is an + * ordinary comment to this (v1) reader, per decision 3: "An unknown future + * `target-vN` comment is an ordinary comment to a v1 reader." Any other `#` + * line (including one that merely mentions the tag inside prose) is `none`. + */ +export function parseTargetAnnotationCommentLine(rawLine: string): TargetAnnotationLineParseResult { + const trimmed = rawLine.trim(); + if (!trimmed.startsWith('#')) return { kind: 'none' }; + const match = TARGET_ANNOTATION_LINE_RE.exec(trimmed); + if (!match) return { kind: 'none' }; + const version = Number(match[1]); + if (version !== 1) return { kind: 'future-version' }; + const evidence = parseTargetAnnotationV1Payload((match[2] ?? '').trim()); + return { kind: 'v1', evidence }; +} + +/** + * Parses and validates the JSON payload of a `target-v1` annotation. + * Accepts known fields in any order, ignores unknown fields, NFC-normalizes + * known strings, and rejects malformed/oversized payloads with + * `INVALID_ARGS` (decision 3: "The parser rejects a v1 annotation exceeding + * these bounds with INVALID_ARGS"). + */ +// fallow-ignore-next-line complexity +export function parseTargetAnnotationV1Payload(jsonText: string): TargetAnnotationV1 { + if (utf8ByteLength(jsonText) > TARGET_ANNOTATION_MAX_PAYLOAD_BYTES) { + throw new AppError( + 'INVALID_ARGS', + `target-v1 annotation exceeds the ${TARGET_ANNOTATION_MAX_PAYLOAD_BYTES}-byte payload cap.`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + throw new AppError('INVALID_ARGS', 'target-v1 annotation is not valid JSON.'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new AppError('INVALID_ARGS', 'target-v1 annotation must be a JSON object.'); + } + const raw = parsed as Record; + + const role = parseRequiredRoleField(raw.role, 'role'); + const id = parseOptionalIdentifierField(raw.id, 'id'); + const label = parseOptionalLabelField(raw.label, 'label'); + const ancestry = parseAncestryField(raw.ancestry); + const sibling = parseNonNegativeIntField(raw.sibling, 'sibling', 0); + const viewportOrder = parseNonNegativeIntField(raw.viewportOrder, 'viewportOrder', 0); + const scrollRegion = parseScrollRegionField(raw.scrollRegion); + const rect = parseRectField(raw.rect); + const verification = parseVerificationField(raw.verification); + + return { + ...(id !== undefined ? { id } : {}), + role, + ...(label !== undefined ? { label } : {}), + ancestry, + sibling, + viewportOrder, + ...(scrollRegion ? { scrollRegion } : {}), + ...(rect ? { rect } : {}), + verification, + }; +} + +/** + * The writer emits `role` unconditionally (possibly as the empty string for + * a typeless node), so a missing role key is always foreign input and is + * rejected rather than defaulted. + */ +function parseRequiredRoleField(value: unknown, field: string): string { + if (value === undefined) { + throw new AppError('INVALID_ARGS', `target-v1 "${field}" is required.`); + } + if (typeof value !== 'string') { + throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`); + } + return boundField(normalizeRoleField(value), field); +} + +function parseOptionalIdentifierField(value: unknown, field: 'id'): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`); + } + const normalized = normalizeIdentifierField(value); + return normalized === undefined ? undefined : boundField(normalized, field); +} + +function parseOptionalLabelField(value: unknown, field: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`); + } + const normalized = normalizeLabelField(value); + return normalized === undefined ? undefined : boundField(normalized, field); +} + +function boundField(value: string, field: string): string { + if (utf8ByteLength(value) > TARGET_ANNOTATION_MAX_FIELD_BYTES) { + throw new AppError( + 'INVALID_ARGS', + `target-v1 "${field}" exceeds the ${TARGET_ANNOTATION_MAX_FIELD_BYTES}-byte field cap.`, + ); + } + return value; +} + +function parseAncestryField(value: unknown): TargetAncestryEntry[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'target-v1 "ancestry" must be an array.'); + } + if (value.length > TARGET_ANNOTATION_MAX_ANCESTRY) { + throw new AppError( + 'INVALID_ARGS', + `target-v1 "ancestry" exceeds the ${TARGET_ANNOTATION_MAX_ANCESTRY}-entry cap.`, + ); + } + return value.map((entry, index) => parseAncestryEntry(entry, index)); +} + +function parseAncestryEntry(entry: unknown, index: number): TargetAncestryEntry { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + throw new AppError('INVALID_ARGS', `target-v1 "ancestry[${index}]" must be an object.`); + } + const record = entry as Record; + const role = parseRequiredRoleField(record.role, `ancestry[${index}].role`); + const label = parseOptionalLabelField(record.label, `ancestry[${index}].label`); + return { role, ...(label !== undefined ? { label } : {}) }; +} + +function parseNonNegativeIntField(value: unknown, field: string, fallback: number): number { + if (value === undefined) return fallback; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a non-negative integer.`); + } + return value; +} + +function parseScrollRegionField(value: unknown): TargetScrollRegion | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'target-v1 "scrollRegion" must be an object.'); + } + const record = value as Record; + const role = parseRequiredRoleField(record.role, 'scrollRegion.role'); + const id = parseOptionalIdentifierField(record.id, 'id'); + const label = parseOptionalLabelField(record.label, 'scrollRegion.label'); + return { + role, + ...(id !== undefined ? { id } : {}), + ...(label !== undefined ? { label } : {}), + }; +} + +function parseRectField(value: unknown): TargetRect | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', 'target-v1 "rect" must be an object.'); + } + const record = value as Record; + const x = parseFiniteNumberField(record.x, 'rect.x'); + const y = parseFiniteNumberField(record.y, 'rect.y'); + const width = parseFiniteNumberField(record.width, 'rect.width'); + const height = parseFiniteNumberField(record.height, 'rect.height'); + return { x, y, width, height }; +} + +function parseFiniteNumberField(value: unknown, field: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a finite number.`); + } + return value; +} + +function parseVerificationField(value: unknown): TargetVerification { + if (value === 'verified' || value === 'unverifiable') return value; + throw new AppError( + 'INVALID_ARGS', + 'target-v1 "verification" must be "verified" or "unverifiable".', + ); +} diff --git a/packages/ad-script/tsconfig.json b/packages/ad-script/tsconfig.json new file mode 100644 index 0000000000..935c871a4d --- /dev/null +++ b/packages/ad-script/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./dist-types", + "rootDir": "./src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a17ade5bf..cb7a50376b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@agent-device/ad-script': + specifier: workspace:* + version: link:packages/ad-script '@agent-device/contracts': specifier: workspace:* version: link:packages/contracts @@ -91,6 +94,15 @@ importers: specifier: ^4.1.8 version: 4.1.8(@types/node@22.19.21)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.19.21)(yaml@2.9.0)) + packages/ad-script: + dependencies: + '@agent-device/contracts': + specifier: workspace:* + version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel + packages/contracts: dependencies: '@agent-device/kernel': @@ -150,10 +162,10 @@ importers: devDependencies: '@callstack/rspress-preset': specifier: ^0.6.6 - version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@rspress/core': specifier: ^2.0.12 - version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + version: 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) packages: @@ -3199,7 +3211,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -3234,14 +3246,14 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) semver: 6.3.1 transitivePeerDependencies: @@ -3249,24 +3261,24 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + '@babel/helper-member-expression-to-functions@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: @@ -3278,16 +3290,16 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 '@babel/traverse': 7.29.7(supports-color@7.2.0) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@7.2.0) '@babel/types': 7.29.7 @@ -3317,10 +3329,10 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: @@ -3341,7 +3353,7 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 @@ -3349,41 +3361,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-typescript@7.28.5(@babel/core@7.29.7(supports-color@7.2.0))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) transitivePeerDependencies: - supports-color @@ -3417,11 +3429,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-preset@0.6.6(@rsbuild/core@2.0.11)(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) - '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)) + '@callstack/rspress-theme': 0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) + '@rspress/plugin-sitemap': 2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)) '@vercel/analytics': 2.0.1(react@19.2.7) rsbuild-plugin-open-graph: 1.1.2(@rsbuild/core@2.0.11) zod: 4.3.6 @@ -3437,9 +3449,9 @@ snapshots: - vue - vue-router - '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@callstack/rspress-theme@0.6.6(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -3654,7 +3666,7 @@ snapshots: - supports-color - utf-8-validate - '@mdx-js/mdx@3.1.1(supports-color@7.2.0)': + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -3666,14 +3678,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -4072,9 +4084,9 @@ snapshots: optionalDependencies: '@rspack/core': 2.0.6(@swc/helpers@0.5.23) - '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0)': + '@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2)': dependencies: - '@mdx-js/mdx': 3.1.1(supports-color@7.2.0) + '@mdx-js/mdx': 3.1.1 '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.7) '@rsbuild/core': 2.0.11 '@rsbuild/plugin-react': 2.0.0(@rsbuild/core@2.0.11)(@rspack/core@2.0.6(@swc/helpers@0.5.23)) @@ -4087,9 +4099,9 @@ snapshots: copy-to-clipboard: 3.3.3 flexsearch: 0.8.212 hast-util-heading-rank: 3.0.0 - hast-util-to-jsx-runtime: 2.3.6(supports-color@7.2.0) - mdast-util-mdx: 3.0.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + hast-util-to-jsx-runtime: 2.3.6 + mdast-util-mdx: 3.0.0 + mdast-util-mdxjs-esm: 2.0.1 medium-zoom: 1.1.0 nprogress: 0.2.0 react: 19.2.7 @@ -4100,11 +4112,11 @@ snapshots: react-router-dom: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) rehype-external-links: 3.0.0 rehype-raw: 7.0.0 - remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) - remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5) - remark-gfm: 4.0.1(supports-color@7.2.0) - remark-mdx: 3.1.1(supports-color@7.2.0) - remark-parse: 11.0.0(supports-color@7.2.0) + remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-gfm: 4.0.1 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 remark-stringify: 11.0.0 scroll-into-view-if-needed: 3.1.0 shiki: 4.0.2 @@ -4122,9 +4134,9 @@ snapshots: - micromark-util-types - supports-color - '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0))': + '@rspress/plugin-sitemap@2.0.8(@rspress/core@2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2))': dependencies: - '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(supports-color@7.2.0) + '@rspress/core': 2.0.12(@rspack/core@2.0.6(@swc/helpers@0.5.23))(@types/mdast@4.0.4)(@types/react@19.2.13)(micromark-util-types@2.0.2)(micromark@4.0.2) '@rspress/shared@2.0.12': dependencies: @@ -4234,9 +4246,9 @@ snapshots: '@babel/core': 7.29.7(supports-color@7.2.0) '@babel/generator': 7.29.7 '@babel/parser': 7.29.3 - '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7(supports-color@7.2.0)) '@stryker-mutator/api': 9.6.1 '@stryker-mutator/util': 9.6.1 angular-html-parser: 10.4.0 @@ -4867,7 +4879,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3(supports-color@7.2.0): + hast-util-to-estree@3.1.3: dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 @@ -4877,9 +4889,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -4902,7 +4914,7 @@ snapshots: stringify-entities: 4.0.4 zwitch: 2.0.4 - hast-util-to-jsx-runtime@2.3.6(supports-color@7.2.0): + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 @@ -4911,9 +4923,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -5109,14 +5121,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3(supports-color@7.2.0): + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -5134,67 +5146,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0(supports-color@7.2.0): + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0(supports-color@7.2.0): + mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0(supports-color@7.2.0): + mdast-util-gfm-table@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0(supports-color@7.2.0): + mdast-util-gfm-task-list-item@2.0.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0(supports-color@7.2.0): + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0(supports-color@7.2.0) - mdast-util-gfm-strikethrough: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-table: 2.0.0(supports-color@7.2.0) - mdast-util-gfm-task-list-item: 2.0.0(supports-color@7.2.0) + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1(supports-color@7.2.0): + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0(supports-color@7.2.0): + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -5202,7 +5214,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -5211,23 +5223,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0(supports-color@7.2.0): + mdast-util-mdx@3.0.0: dependencies: - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) - mdast-util-mdx-expression: 2.0.1(supports-color@7.2.0) - mdast-util-mdx-jsx: 3.2.0(supports-color@7.2.0) - mdast-util-mdxjs-esm: 2.0.1(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1(supports-color@7.2.0): + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -5286,11 +5298,11 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 get-east-asian-width: 1.5.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-character: 2.1.1 micromark-util-chunked: 2.0.1 @@ -5307,10 +5319,10 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 - micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)): + micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 - micromark: 4.0.2(supports-color@7.2.0) + micromark: 4.0.2 micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) micromark-util-chunked: 2.0.1 micromark-util-resolve-all: 2.0.1 @@ -5541,7 +5553,7 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2(supports-color@7.2.0): + micromark@4.0.2: dependencies: '@types/debug': 4.1.13 debug: 4.4.3(supports-color@7.2.0) @@ -5824,17 +5836,17 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0(supports-color@7.2.0): + rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.8 '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3(supports-color@7.2.0) + hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color - remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + remark-cjk-friendly-gfm-strikethrough@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -5842,9 +5854,9 @@ snapshots: - micromark - micromark-util-types - remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5): + remark-cjk-friendly@2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: - micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)) + micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) unified: 11.0.5 optionalDependencies: '@types/mdast': 4.0.4 @@ -5852,28 +5864,28 @@ snapshots: - micromark - micromark-util-types - remark-gfm@4.0.1(supports-color@7.2.0): + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0(supports-color@7.2.0) + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0(supports-color@7.2.0) + remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1(supports-color@7.2.0): + remark-mdx@3.1.1: dependencies: - mdast-util-mdx: 3.0.0(supports-color@7.2.0) + mdast-util-mdx: 3.0.0 micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0(supports-color@7.2.0): + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3(supports-color@7.2.0) + mdast-util-from-markdown: 2.0.3 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: diff --git a/scripts/fuzz/targets.ts b/scripts/fuzz/targets.ts index 4e49e77e55..b341469660 100644 --- a/scripts/fuzz/targets.ts +++ b/scripts/fuzz/targets.ts @@ -9,7 +9,7 @@ import { parseArgs } from '../../src/cli/parser/args.ts'; import { parseSelectorChain } from '../../src/selectors/parse.ts'; -import { parseReplayScriptDetailed } from '../../src/replay/script.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'; diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 8f6b0b7302..d8243aa7d3 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -31,6 +31,7 @@ export type BackEdgeMap = Record; // ranked here or listed as unranked — `unclassifiedZones` and `model.test.ts` guard // that no zone is silently unclassified. const TARGET_DAG_RANK = new Map([ + ['ad-script', 1], ['contracts', 1], ['maestro', 1], ['platforms', 1], diff --git a/src/__tests__/kernel/platform-collapse-parity.test.ts b/src/__tests__/kernel/platform-collapse-parity.test.ts index 3ecee487e5..121c3e2c5f 100644 --- a/src/__tests__/kernel/platform-collapse-parity.test.ts +++ b/src/__tests__/kernel/platform-collapse-parity.test.ts @@ -21,7 +21,7 @@ import { VISIONOS_SIMULATOR, WEB_DESKTOP_DEVICE, } from '../test-utils/device-fixtures.ts'; -import { readReplayScriptMetadata } from '../../replay/script.ts'; +import { readReplayScriptMetadata } from '@agent-device/ad-script'; // Parity gate for the ios/macos -> apple Platform collapse (issue #979, approach b). // Internal `DeviceInfo.platform` is `apple`; the daemon still ACCEPTS the legacy diff --git a/src/cli/commands/__tests__/replay-maestro-export.test.ts b/src/cli/commands/__tests__/replay-maestro-export.test.ts index 083bc6dd90..18de0d8b41 100644 --- a/src/cli/commands/__tests__/replay-maestro-export.test.ts +++ b/src/cli/commands/__tests__/replay-maestro-export.test.ts @@ -2,7 +2,7 @@ import { parseAllDocuments } from 'yaml'; import { describe, expect, test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { exportReplayActionsToMaestro, inspectMaestroFlow } from '@agent-device/maestro'; -import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../../../replay/script.ts'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '@agent-device/ad-script'; import { parseSelectorChain } from '../../../selectors/index.ts'; describe('exportReplayScriptToMaestro', () => { diff --git a/src/cli/commands/replay.ts b/src/cli/commands/replay.ts index ee2fc7146d..3bd43b4f73 100644 --- a/src/cli/commands/replay.ts +++ b/src/cli/commands/replay.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { exportReplayActionsToMaestro } from '@agent-device/maestro'; import { AppError } from '@agent-device/kernel/errors'; -import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../../replay/script.ts'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '@agent-device/ad-script'; import { parseSelectorChain } from '../../selectors/index.ts'; import { resolveUserPath } from '../../utils/path-resolution.ts'; import { writeCommandOutput } from './shared.ts'; diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index c5eab399e5..f6d9f40ef2 100644 --- a/src/commands/interaction/runtime/selector-wait.ts +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -10,10 +10,8 @@ import { buildIndexMap, filterIdentitySet, } from '../../../replay/target-evidence-tree.ts'; -import { - annotationLocalIdentity, - type TargetAnnotationV1, -} from '../../../replay/target-identity.ts'; +import { annotationLocalIdentity } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import type { PublicPlatform } from '@agent-device/kernel/device'; import { checkWaitText } from '../../../selectors/arguments.ts'; import { listSelectorChainMatches } from '../../../selectors/index.ts'; diff --git a/src/compat/replay-input.ts b/src/compat/replay-input.ts index b488f50971..39d4e62ebd 100644 --- a/src/compat/replay-input.ts +++ b/src/compat/replay-input.ts @@ -5,7 +5,7 @@ import { readReplayScriptMetadata, type ParsedReplayScript, type ReplayScriptMetadata, -} from '../replay/script.ts'; +} from '@agent-device/ad-script'; export type ParsedReplayInput = ParsedReplayScript & { metadata: ReplayScriptMetadata; diff --git a/src/daemon/__tests__/session-script-active-publication.test.ts b/src/daemon/__tests__/session-script-active-publication.test.ts index e2f8d23594..edc495802c 100644 --- a/src/daemon/__tests__/session-script-active-publication.test.ts +++ b/src/daemon/__tests__/session-script-active-publication.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import type { TargetAnnotationV1 } from '../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import type { SessionAction } from '../types.ts'; import { assertActivePublicationPortability, diff --git a/src/daemon/__tests__/session-script-writer.test.ts b/src/daemon/__tests__/session-script-writer.test.ts index 8960175040..359337dd3b 100644 --- a/src/daemon/__tests__/session-script-writer.test.ts +++ b/src/daemon/__tests__/session-script-writer.test.ts @@ -13,7 +13,7 @@ import { } from '../../__tests__/test-utils/session-factories.ts'; import { markRepairTransactionComplete } from '../session-replay-transaction.ts'; import { NO_SCRIPT_PUBLICATION, scriptTargetPath } from '../session-script-publication-state.ts'; -import { parseReplayScriptDetailed } from '../../replay/script.ts'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import type { SessionAction } from '../types.ts'; function action(overrides: Partial = {}): SessionAction { diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index f390e6fb97..47a204f11e 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -6,9 +6,8 @@ import path from 'node:path'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../types.ts'; import { buildRequestFinishedEvent } from '../session-event-log.ts'; -import type { TargetAnnotationV1 } from '../../replay/target-identity.ts'; import { HEAL_COMPLETE_SENTINEL } from '../session-script-writer.ts'; -import { parseReplayScriptDetailed } from '../../replay/script.ts'; +import { parseReplayScriptDetailed, type TargetAnnotationV1 } from '@agent-device/ad-script'; import { repairPublication } from '../../__tests__/test-utils/session-factories.ts'; type RecordActionEntry = Parameters[1]; diff --git a/src/daemon/__tests__/session-target-evidence.test.ts b/src/daemon/__tests__/session-target-evidence.test.ts index 343f118a13..262e3d4020 100644 --- a/src/daemon/__tests__/session-target-evidence.test.ts +++ b/src/daemon/__tests__/session-target-evidence.test.ts @@ -9,7 +9,7 @@ import { TARGET_ANNOTATION_MAX_ANCESTRY, TARGET_ANNOTATION_MAX_FIELD_BYTES, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, -} from '../../replay/target-identity.ts'; +} from '@agent-device/ad-script'; function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] { return raw.map((node, position) => ({ ...node, ref: `e${position + 1}` })); diff --git a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts index d5eacefe95..8e31bdd37e 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-acceptance.test.ts @@ -21,7 +21,7 @@ import { SessionStore } from '../../session-store.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import type { DaemonRequest } from '../../types.ts'; -import { parseReplayScriptDetailed } from '../../../replay/script.ts'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { baseReplayRequest as baseReq, writeReplayFile, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-hint.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-hint.test.ts index 081771a8fc..324dcd68e6 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-hint.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-hint.test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { computeReplayRepairHint, type ReplayRepairHintCapture, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts index 0aef43875d..cdfebcb04a 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-record-exclusion.test.ts @@ -60,7 +60,7 @@ import { SessionStore } from '../../session-store.ts'; import { LeaseRegistry } from '../../lease-registry.ts'; import { dispatchCommand } from '../../../core/dispatch.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import { parseReplayScriptDetailed } from '../../../replay/script.ts'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { baseReplayRequest as baseReq, writeReplayFile, diff --git a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts index b17a213def..91987eec9f 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts @@ -78,7 +78,7 @@ import { scriptTargetPath, scriptTargetForce, } from '../../session-script-publication-state.ts'; -import { parseReplayScriptDetailed } from '../../../replay/script.ts'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import type { DaemonRequest, SessionState } from '../../types.ts'; import { baseReplayRequest as baseReq, diff --git a/src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts b/src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts index d6f2e450f7..7bf6e22e7e 100644 --- a/src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts +++ b/src/daemon/handlers/__tests__/session-replay-repair.fixtures.ts @@ -10,7 +10,7 @@ import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../types. import { SessionStore } from '../../session-store.ts'; import { isInteractiveObservation } from '../../session-action-recorder.ts'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; export function freshEvidence(id: string, label: string): TargetAnnotationV1 { return { diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts b/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts index c1f2639a62..4d390ddb93 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-classification-fixtures.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; import { computeTargetEvidence } from '../../session-target-evidence.ts'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; export function toSnapshotNodes(raw: RawSnapshotNode[]): SnapshotNode[] { return raw.map((node, position) => ({ ...node, ref: `e${position + 1}` })); diff --git a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts b/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts index 7ad0a6625d..e557f6a1d6 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-classification.test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { computeTargetEvidence } from '../../session-target-evidence.ts'; import { buildSelectorChainForNode } from '../../../selectors/build.ts'; import { parseSelectorChain, resolveSelectorChain } from '../../../selectors/index.ts'; diff --git a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts index fab27ce257..f7127f6ea7 100644 --- a/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-target-guard.test.ts @@ -6,7 +6,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { readNodeLocalIdentity, readNodeStructuralDenotation, diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts index 5ad625e97e..e6e0bc6214 100644 --- a/src/daemon/handlers/__tests__/session-script-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -8,7 +8,7 @@ import { authoringPublication, repairPublication, } from '../../../__tests__/test-utils/session-factories.ts'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, SessionState } from '../../types.ts'; import { handleSessionScriptPublication } from '../session-script-publication.ts'; diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index 233406f6de..31bd131f2a 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -15,7 +15,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import { dispatchWaitViaRuntime } from '../../selector-runtime.ts'; import type { DaemonRequest } from '../../types.ts'; import { WAIT_LANDMARK_MISMATCH_REASON } from '../../../replay/target-identity-node.ts'; -import type { TargetAnnotationV1 } from '../../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; diff --git a/src/daemon/handlers/session-replay-divergence.ts b/src/daemon/handlers/session-replay-divergence.ts index 112e936721..ffe5363c8a 100644 --- a/src/daemon/handlers/session-replay-divergence.ts +++ b/src/daemon/handlers/session-replay-divergence.ts @@ -19,7 +19,7 @@ import { collectReplaySelectorCandidates } from './session-replay-heal.ts'; import { collectSettleChromeRefs } from '../../core/snapshot-chrome.ts'; import { buildAndPersistReplayDivergenceResume } from './session-replay-resume.ts'; import { rankAndDedupeReplaySuggestions } from './session-replay-suggestion-ranking.ts'; -import { formatDivergenceActionLabel, isTouchTargetCommand } from '../../replay/script-utils.ts'; +import { formatDivergenceActionLabel, isTouchTargetCommand } from '@agent-device/ad-script'; import { computeReplayRepairHint, type ReplayRepairHintCapture, diff --git a/src/daemon/handlers/session-replay-heal.ts b/src/daemon/handlers/session-replay-heal.ts index ed6a58d883..23cdf5111d 100644 --- a/src/daemon/handlers/session-replay-heal.ts +++ b/src/daemon/handlers/session-replay-heal.ts @@ -1,7 +1,7 @@ import { splitIsSelectorArgs, splitSelectorFromArgs } from '../../selectors/index.ts'; import { uniqueStrings } from '@agent-device/kernel/collections'; import type { ReplayReportAction } from './session-replay-report-action.ts'; -import { isTouchTargetCommand } from '../../replay/script-utils.ts'; +import { isTouchTargetCommand } from '@agent-device/ad-script'; /** * ADR 0012 decision 1 / migration step 6: `--update` retired as an actor — diff --git a/src/daemon/handlers/session-replay-maestro-failure.ts b/src/daemon/handlers/session-replay-maestro-failure.ts index 1fbf403d23..c47e2363e5 100644 --- a/src/daemon/handlers/session-replay-maestro-failure.ts +++ b/src/daemon/handlers/session-replay-maestro-failure.ts @@ -8,7 +8,7 @@ import { type ReplayDivergence, type ReplayVarScrubEntry, } from '@agent-device/contracts/divergence'; -import { formatScriptArg } from '../../replay/script-utils.ts'; +import { formatScriptArg } from '@agent-device/ad-script'; import { getRequestSignal } from '../../request/cancel.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; diff --git a/src/daemon/handlers/session-replay-repair-hint.ts b/src/daemon/handlers/session-replay-repair-hint.ts index 47815de8d3..27ecae2bdc 100644 --- a/src/daemon/handlers/session-replay-repair-hint.ts +++ b/src/daemon/handlers/session-replay-repair-hint.ts @@ -23,11 +23,8 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence'; -import { - matchesAncestryPrefix, - type TargetAnnotationV1, - type TargetScrollRegion, -} from '../../replay/target-identity.ts'; +import { matchesAncestryPrefix } from '../../replay/target-identity.ts'; +import type { TargetAnnotationV1, TargetScrollRegion } from '@agent-device/ad-script'; import { buildAncestryChain, buildIndexMap } from '../../replay/target-evidence-tree.ts'; import { computeScrollRegionKey, scrollRegionKeysEqual } from '../session-target-evidence.ts'; diff --git a/src/daemon/handlers/session-replay-runtime-failure-response.ts b/src/daemon/handlers/session-replay-runtime-failure-response.ts index 4c14e954e1..478913e58a 100644 --- a/src/daemon/handlers/session-replay-runtime-failure-response.ts +++ b/src/daemon/handlers/session-replay-runtime-failure-response.ts @@ -1,5 +1,5 @@ import { scrubReplayVarValues, type ReplayVarScrubEntry } from '@agent-device/contracts/divergence'; -import { formatDivergenceActionLabel } from '../../replay/script-utils.ts'; +import { formatDivergenceActionLabel } from '@agent-device/ad-script'; import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture'; import { buildDisplayPositionals } from '../session-event-action.ts'; import type { DaemonResponse, SessionAction } from '../types.ts'; diff --git a/src/daemon/handlers/session-replay-runtime-plan.ts b/src/daemon/handlers/session-replay-runtime-plan.ts index c03812f165..d9cf4c5953 100644 --- a/src/daemon/handlers/session-replay-runtime-plan.ts +++ b/src/daemon/handlers/session-replay-runtime-plan.ts @@ -1,6 +1,6 @@ import type { CommandFlags } from '../../core/dispatch.ts'; import type { ReplayPlanDigestMetadata } from '../../replay/plan-digest.ts'; -import type { ReplayScriptMetadata } from '../../replay/script.ts'; +import type { ReplayScriptMetadata } from '@agent-device/ad-script'; import type { DaemonResponse } from '../types.ts'; import { errorResponse } from './response.ts'; diff --git a/src/daemon/handlers/session-replay-runtime.ts b/src/daemon/handlers/session-replay-runtime.ts index 2ae914ed91..7d1853442d 100644 --- a/src/daemon/handlers/session-replay-runtime.ts +++ b/src/daemon/handlers/session-replay-runtime.ts @@ -12,7 +12,7 @@ import { SessionStore } from '../session-store.ts'; import { expandSessionPath } from '../session-paths.ts'; import { buildReplayScriptPlatformFlags } from '../replay-device-selection.ts'; import { computeReplayPlanDigest } from '../../replay/plan-digest.ts'; -import type { TargetAnnotationV1 } from '../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { errorResponse, noActiveSessionError } from './response.ts'; import { invokeReplayAction } from './session-replay-action-runtime.ts'; import { tryParseSelectorChain } from '../../selectors/index.ts'; diff --git a/src/daemon/handlers/session-replay-target-classification.ts b/src/daemon/handlers/session-replay-target-classification.ts index b49e647620..7f60314e89 100644 --- a/src/daemon/handlers/session-replay-target-classification.ts +++ b/src/daemon/handlers/session-replay-target-classification.ts @@ -56,8 +56,8 @@ import { annotationLocalIdentity, classifyTargetBindingMatch, type LocalIdentity, - type TargetAnnotationV1, } from '../../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence'; // --------------------------------------------------------------------------- diff --git a/src/daemon/handlers/session-replay-target-token.ts b/src/daemon/handlers/session-replay-target-token.ts index 36aef55b35..c0294783bd 100644 --- a/src/daemon/handlers/session-replay-target-token.ts +++ b/src/daemon/handlers/session-replay-target-token.ts @@ -1,4 +1,4 @@ -import { isTouchTargetCommand } from '../../replay/script-utils.ts'; +import { isTouchTargetCommand } from '@agent-device/ad-script'; import { splitIsSelectorArgs } from '../../selectors/index.ts'; import type { SessionAction } from '../types.ts'; diff --git a/src/daemon/handlers/session-replay-target-verification.ts b/src/daemon/handlers/session-replay-target-verification.ts index 13f23fa226..ee3c8a1da1 100644 --- a/src/daemon/handlers/session-replay-target-verification.ts +++ b/src/daemon/handlers/session-replay-target-verification.ts @@ -2,17 +2,13 @@ import type { ResponseLevel } from '@agent-device/kernel/contracts'; import type { DaemonError } from '@agent-device/kernel/errors'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { displayLabel, formatRole } from '../../snapshot/snapshot-lines.ts'; -import { formatDivergenceActionLabel } from '../../replay/script-utils.ts'; +import { formatDivergenceActionLabel, type TargetAnnotationV1 } from '@agent-device/ad-script'; import { collectReplayScrubbableVarValues, resolveReplayAction, type ReplayVarScope, } from '../../replay/vars.ts'; -import { - annotationLocalIdentity, - type LocalIdentity, - type TargetAnnotationV1, -} from '../../replay/target-identity.ts'; +import { annotationLocalIdentity, type LocalIdentity } from '../../replay/target-identity.ts'; import { createReplayDivergenceSanitizer, type ReplayDivergence, diff --git a/src/daemon/handlers/session-replay-vars.ts b/src/daemon/handlers/session-replay-vars.ts index 2a76678d19..05387b6534 100644 --- a/src/daemon/handlers/session-replay-vars.ts +++ b/src/daemon/handlers/session-replay-vars.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import type { DaemonRequest } from '../types.ts'; -import type { ReplayScriptMetadata } from '../../replay/script.ts'; +import type { ReplayScriptMetadata } from '@agent-device/ad-script'; export function buildReplayBuiltinVars(params: { req: DaemonRequest; diff --git a/src/daemon/handlers/session-replay.ts b/src/daemon/handlers/session-replay.ts index 73c7dfb815..51e53e62d8 100644 --- a/src/daemon/handlers/session-replay.ts +++ b/src/daemon/handlers/session-replay.ts @@ -1,5 +1,5 @@ import type { CommandFlags } from '../../core/dispatch.ts'; -import type { ReplayScriptMetadata } from '../../replay/script.ts'; +import type { ReplayScriptMetadata } from '@agent-device/ad-script'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { runReplayTestSuite } from '@agent-device/replay-test'; diff --git a/src/daemon/handlers/session-test-source-discovery.ts b/src/daemon/handlers/session-test-source-discovery.ts index 1d0d3f4254..05d80ae905 100644 --- a/src/daemon/handlers/session-test-source-discovery.ts +++ b/src/daemon/handlers/session-test-source-discovery.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { inspectMaestroFlow } from '@agent-device/maestro'; import { resolveReplayFormat } from '../../replay/format.ts'; -import { readReplayScriptMetadata } from '../../replay/script.ts'; +import { readReplayScriptMetadata } from '@agent-device/ad-script'; import { discoverReplaySourcePaths } from '../replay-source-discovery.ts'; import type { ReplayTestDiscoverSources, diff --git a/src/daemon/parameterized-recorded-fill.ts b/src/daemon/parameterized-recorded-fill.ts index 3e18ff8711..139cf29d61 100644 --- a/src/daemon/parameterized-recorded-fill.ts +++ b/src/daemon/parameterized-recorded-fill.ts @@ -1,4 +1,4 @@ -import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { tryParseSelectorChain } from '../selectors/parse.ts'; const VALUE_BEARING_SELECTOR_KEYS = new Set(['text', 'label', 'value']); diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index e1d379b489..b408858ef7 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -33,7 +33,7 @@ import { toDaemonWaitData, } from './selector-recording.ts'; import type { RecordedTargetCapture } from './session-target-evidence.ts'; -import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { maybeWaitTimeoutSurfaceResponse } from './wait-current-surface.ts'; import { withSystemSurfaceDisclosure } from './handlers/system-surface-disclosure.ts'; import { diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 3d8d92a597..6d4c62bf65 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -4,7 +4,7 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; import type { DaemonRequest, SessionAction, SessionRuntimeHints, SessionState } from './types.ts'; import { applyRecordedSaveScriptFlags } from './session-script-publication-capability.ts'; import { repairSessionBoundary } from './session-replay-transaction.ts'; -import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import { inferFillText } from './action-utils.ts'; import { recordedInputPlaceholder, diff --git a/src/daemon/session-script-active-publication.ts b/src/daemon/session-script-active-publication.ts index 61e5990869..0b748ebced 100644 --- a/src/daemon/session-script-active-publication.ts +++ b/src/daemon/session-script-active-publication.ts @@ -1,7 +1,7 @@ import { resolveCommandRecordingEffect } from '../core/command-descriptor/registry.ts'; import { parseWaitPositionals } from '../core/wait-positionals.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { isTouchTargetCommand } from '../replay/script-utils.ts'; +import { isTouchTargetCommand } from '@agent-device/ad-script'; import { tryParseSelectorChain } from '../selectors/parse.ts'; import type { SessionAction } from './types.ts'; diff --git a/src/daemon/session-script-writer.ts b/src/daemon/session-script-writer.ts index dceb0bcaba..564c4bc8e3 100644 --- a/src/daemon/session-script-writer.ts +++ b/src/daemon/session-script-writer.ts @@ -4,19 +4,17 @@ import { publicPlatformString } from '@agent-device/kernel/device'; import { inferFillText } from './action-utils.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { - formatPortableActionLine, - formatTargetAnnotationLines, -} from '../replay/script-formatting.ts'; -import { expandSessionPath, safeSessionName } from './session-paths.ts'; import { appendScriptSeriesFlags, + formatPortableActionLine, formatScriptArg, formatScriptStringLiteral, + formatTargetAnnotationLines, isClickLikeCommand, isTouchTargetCommand, stripRecordedRefGeneration, -} from '../replay/script-utils.ts'; +} from '@agent-device/ad-script'; +import { expandSessionPath, safeSessionName } from './session-paths.ts'; import type { SessionAction, SessionState } from './types.ts'; import { NO_SCRIPT_PUBLICATION, diff --git a/src/daemon/session-target-evidence.ts b/src/daemon/session-target-evidence.ts index cd9a42ecd1..51805b3693 100644 --- a/src/daemon/session-target-evidence.ts +++ b/src/daemon/session-target-evidence.ts @@ -30,16 +30,18 @@ import { import { classifyTargetBindingMatch, matchesLocalIdentity, + type LocalIdentity, +} from '../replay/target-identity.ts'; +import { serializeTargetAnnotationV1, utf8ByteLength, TARGET_ANNOTATION_MAX_ANCESTRY, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, - type LocalIdentity, type TargetAncestryEntry, type TargetAnnotationV1, type TargetScrollRegion, type TargetVerification, -} from '../replay/target-identity.ts'; +} from '@agent-device/ad-script'; /** ADR 0012 decision 3: the resolved winner and the tree it was resolved from. */ export type RecordedTargetCapture = { diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 440e8bd345..dccee7f48e 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -32,7 +32,7 @@ import type { AppleXctracePerfMode, } from '../platforms/apple/core/perf-xctrace.ts'; import type { ReplayTargetGuardDenotation } from '../replay/target-identity-node.ts'; -import type { TargetAnnotationV1 } from '../replay/target-identity.ts'; +import type { TargetAnnotationV1 } from '@agent-device/ad-script'; import type { AppLogFailure, AppLogState } from './app-log-process.ts'; import type { RefFrameScope, RefFrameState } from './ref-frame.ts'; export type DaemonInstallSource = PublicDaemonInstallSource; diff --git a/src/replay/__tests__/ad-script-round-trip.test.ts b/src/replay/__tests__/ad-script-round-trip.test.ts new file mode 100644 index 0000000000..4ac7ac52bf --- /dev/null +++ b/src/replay/__tests__/ad-script-round-trip.test.ts @@ -0,0 +1,48 @@ +import fc from 'fast-check'; +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { PROPERTY_RUNS, replayScriptArb } from '../../__tests__/test-utils/index.ts'; +import { + formatPortableActionLine, + formatTargetAnnotationLines, + parseReplayScriptDetailed, +} from '@agent-device/ad-script'; +import type { SessionAction } from '@agent-device/contracts/session'; + +// `@agent-device/ad-script` (#1478 P5) owns the parser and line formatter; +// this property lives at root because its generator (`replayScriptArb`) is +// derived from the root command catalog and selector grammar, which the +// codec package cannot import (R11 forbids a package reaching into root +// `src/`). See `packages/ad-script/src/internal/__tests__/script.test.ts` for +// this codec's example-based coverage. + +function formatReplayScriptForTest(actions: SessionAction[]): string { + const lines: string[] = []; + for (const action of actions) { + lines.push(...formatTargetAnnotationLines(action)); + lines.push(formatPortableActionLine(action, { runtimeIncludeAllPositionals: true })); + } + return `${lines.join('\n')}\n`; +} + +// Property, not another example: `.ad` scripts are written by hand, recorded, +// and rewritten, so the parser and the line formatter must agree on ONE +// canonical form — re-serializing a parsed script has to be a fixed point. +// Generated lines come from the shared `.ad` generator, so a new command shape +// extends the generator rather than adding another pinned script here. +test('serializing a parsed script is a fixed point for generated scripts', () => { + fc.assert( + fc.property(replayScriptArb, (script) => { + const parsed = parseReplayScriptDetailed(script).actions; + const canonical = formatReplayScriptForTest(parsed); + const reparsed = parseReplayScriptDetailed(canonical).actions; + assert.equal(formatReplayScriptForTest(reparsed), canonical); + // The action identity survives the rewrite: same commands, same targets. + assert.deepEqual( + reparsed.map((action) => [action.command, action.positionals]), + parsed.map((action) => [action.command, action.positionals]), + ); + }), + { numRuns: PROPERTY_RUNS }, + ); +}); diff --git a/src/replay/__tests__/target-identity.test.ts b/src/replay/__tests__/target-identity.test.ts index 052c78f491..18b711df32 100644 --- a/src/replay/__tests__/target-identity.test.ts +++ b/src/replay/__tests__/target-identity.test.ts @@ -1,155 +1,12 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { AppError } from '@agent-device/kernel/errors'; -import { - formatTargetAnnotationCommentLine, - matchesAncestryPrefix, - matchesLocalIdentity, - normalizeLabelField, - parseTargetAnnotationCommentLine, - parseTargetAnnotationV1Payload, - serializeTargetAnnotationV1, - truncateToUtf8Bytes, - TARGET_ANNOTATION_MAX_ANCESTRY, - TARGET_ANNOTATION_MAX_FIELD_BYTES, - TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, - type TargetAnnotationV1, -} from '../target-identity.ts'; +import { matchesAncestryPrefix, matchesLocalIdentity } from '../target-identity.ts'; -function baseEvidence(overrides: Partial = {}): TargetAnnotationV1 { - return { - id: 'save', - role: 'button', - label: 'Save', - ancestry: [{ role: 'toolbar', label: 'Editor' }, { role: 'window' }], - sibling: 0, - viewportOrder: 0, - scrollRegion: { role: 'scrollview', id: 'editor-scroll' }, - verification: 'verified', - ...overrides, - }; -} - -function assertInvalidArgs(fn: () => unknown, messagePattern?: RegExp): void { - assert.throws( - fn, - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - (!messagePattern || messagePattern.test(error.message)), - ); -} - -// --------------------------------------------------------------------------- -// Canonical serialization / field order -// --------------------------------------------------------------------------- - -test('serializeTargetAnnotationV1 uses the exact canonical field order from decision 3', () => { - const json = serializeTargetAnnotationV1(baseEvidence()); - assert.equal( - json, - '{"id":"save","role":"button","label":"Save","ancestry":[{"role":"toolbar","label":"Editor"},{"role":"window"}],"sibling":0,"viewportOrder":0,"scrollRegion":{"role":"scrollview","id":"editor-scroll"},"verification":"verified"}', - ); -}); - -test('formatTargetAnnotationCommentLine emits the ASCII # agent-device:target-v1 prefix', () => { - const line = formatTargetAnnotationCommentLine(baseEvidence()); - assert.ok(line.startsWith('# agent-device:target-v1 {')); -}); - -// --------------------------------------------------------------------------- -// Parse-write-parse round trip / semantic equality -// --------------------------------------------------------------------------- - -test('parse(serialize(evidence)) round trips to a semantically equal object', () => { - const evidence = baseEvidence({ rect: { x: 1, y: 2, width: 3, height: 4 } }); - const parsedBack = parseTargetAnnotationV1Payload(serializeTargetAnnotationV1(evidence)); - assert.deepEqual(parsedBack, evidence); -}); - -test('parseTargetAnnotationCommentLine accepts known fields in any JSON key order', () => { - const line = - '# agent-device:target-v1 {"verification":"verified","sibling":0,"role":"button","viewportOrder":2,"ancestry":[],"id":"save"}'; - const result = parseTargetAnnotationCommentLine(line); - assert.equal(result.kind, 'v1'); - if (result.kind !== 'v1') throw new Error('unreachable'); - assert.deepEqual(result.evidence, { - id: 'save', - role: 'button', - ancestry: [], - sibling: 0, - viewportOrder: 2, - verification: 'verified', - }); -}); - -test('parseTargetAnnotationCommentLine ignores unknown fields', () => { - const line = - '# agent-device:target-v1 {"role":"button","verification":"verified","futureField":{"nested":true}}'; - const result = parseTargetAnnotationCommentLine(line); - assert.equal(result.kind, 'v1'); - if (result.kind !== 'v1') throw new Error('unreachable'); - assert.equal((result.evidence as Record).futureField, undefined); -}); - -test('an unknown future target-vN annotation is an ordinary comment to a v1 reader', () => { - const result = parseTargetAnnotationCommentLine('# agent-device:target-v2 {"anything":"goes"}'); - assert.deepEqual(result, { kind: 'future-version' }); -}); - -test('a line that merely mentions the tag in prose is an ordinary comment', () => { - assert.deepEqual(parseTargetAnnotationCommentLine('# see agent-device:target-v1 docs'), { - kind: 'none', - }); - assert.deepEqual(parseTargetAnnotationCommentLine('# just a comment'), { kind: 'none' }); -}); - -// --------------------------------------------------------------------------- -// Normalization: NFC, label trim/collapse, normalized-role source -// --------------------------------------------------------------------------- - -test('normalizeLabelField NFC-normalizes, trims, and collapses internal whitespace', () => { - // "é" as e + combining acute (NFD) must normalize to the precomposed (NFC) form. - const nfd = 'Café'; - assert.equal(normalizeLabelField(` ${nfd} au lait `), 'Café au lait'); -}); - -test('normalizeLabelField treats a whitespace-only label as absent', () => { - assert.equal(normalizeLabelField(' '), undefined); -}); - -test('embedded quotes and backslashes in labels round trip losslessly', () => { - const evidence = baseEvidence({ label: 'Say "hi" \\ backslash', id: undefined }); - const json = serializeTargetAnnotationV1(evidence); - assert.ok(json.includes('\\"hi\\"')); - const parsed = parseTargetAnnotationV1Payload(json); - assert.equal(parsed.label, 'Say "hi" \\ backslash'); -}); - -test('Unicode labels (including astral code points) round trip losslessly', () => { - const evidence = baseEvidence({ label: '\u{1F600} café résumé', id: undefined }); - const parsed = parseTargetAnnotationV1Payload(serializeTargetAnnotationV1(evidence)); - assert.equal(parsed.label, '\u{1F600} café résumé'); -}); - -// --------------------------------------------------------------------------- -// Old/new reader compatibility -// --------------------------------------------------------------------------- - -test('a v1 reader treats an annotation with only role + verification as valid, defaulting the rest', () => { - const result = parseTargetAnnotationCommentLine( - '# agent-device:target-v1 {"role":"button","verification":"verified"}', - ); - assert.equal(result.kind, 'v1'); - if (result.kind !== 'v1') throw new Error('unreachable'); - assert.deepEqual(result.evidence, { - role: 'button', - ancestry: [], - sibling: 0, - viewportOrder: 0, - verification: 'verified', - }); -}); +// The `# agent-device:target-v1` SERDE (serialize/parse, normalization, +// bounds) moved to `@agent-device/ad-script` — see +// `packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts`. +// This file keeps only the record/replay-shared CLASSIFICATION core that +// stays in `src/replay/target-identity.ts` (#1478 P5 scoping dossier). // --------------------------------------------------------------------------- // Leaf-anchored ancestry prefix matching: root-side truncation + inserted @@ -200,167 +57,3 @@ test('matchesLocalIdentity: with no recorded id, role+label must both match, abs assert.equal(matchesLocalIdentity({ role: 'button' }, { role: 'button' }), true); assert.equal(matchesLocalIdentity({ role: 'button', label: 'Save' }, { role: 'button' }), false); }); - -// --------------------------------------------------------------------------- -// Bounds: 256-byte fields, 4 KiB payload, 8-entry ancestry — parser REJECTS, -// never truncates. -// --------------------------------------------------------------------------- - -test('parser rejects a string field exceeding the 256-byte cap', () => { - const oversizedLabel = 'x'.repeat(TARGET_ANNOTATION_MAX_FIELD_BYTES + 1); - assertInvalidArgs( - () => - parseTargetAnnotationV1Payload( - JSON.stringify({ role: 'button', label: oversizedLabel, verification: 'verified' }), - ), - /256-byte field cap/, - ); -}); - -test('parser rejects a payload exceeding the 4 KiB cap', () => { - // Every individual field stays within the 256-byte field cap, but 8 - // maxed-out ancestry entries plus maxed top-level/scrollRegion fields blow - // the 4 KiB payload ceiling collectively. - const maxLabel = 'x'.repeat(TARGET_ANNOTATION_MAX_FIELD_BYTES); - const ancestry = Array.from({ length: TARGET_ANNOTATION_MAX_ANCESTRY }, () => ({ - role: maxLabel, - label: maxLabel, - })); - const json = JSON.stringify({ - id: maxLabel, - role: maxLabel, - label: maxLabel, - ancestry, - scrollRegion: { role: maxLabel, id: maxLabel, label: maxLabel }, - verification: 'verified', - }); - assert.ok(Buffer.byteLength(json, 'utf8') > TARGET_ANNOTATION_MAX_PAYLOAD_BYTES); - assertInvalidArgs(() => parseTargetAnnotationV1Payload(json), /4096-byte payload cap/); -}); - -test('parser rejects more than 8 ancestry entries', () => { - const ancestry = Array.from({ length: TARGET_ANNOTATION_MAX_ANCESTRY + 1 }, () => ({ - role: 'view', - })); - assertInvalidArgs( - () => - parseTargetAnnotationV1Payload( - JSON.stringify({ role: 'button', ancestry, verification: 'verified' }), - ), - /8-entry cap/, - ); -}); - -test('truncateToUtf8Bytes never splits a surrogate pair', () => { - const emoji = '\u{1F600}'; // 4 UTF-8 bytes, a surrogate pair in UTF-16 - const truncated = truncateToUtf8Bytes(`ab${emoji}`, 3); - assert.equal(Buffer.byteLength(truncated, 'utf8') <= 3, true); - // The budget (3 bytes) fits "ab" but not the 4-byte emoji — the whole - // surrogate pair must be dropped together, never split. - assert.equal(truncated, 'ab'); - assert.equal(/[\ud800-\udbff]$/.test(truncated), false); -}); - -// --------------------------------------------------------------------------- -// Malformed / unbound annotations -// --------------------------------------------------------------------------- - -test('parser rejects non-JSON payloads', () => { - assertInvalidArgs(() => parseTargetAnnotationV1Payload('{not json'), /valid JSON/); -}); - -test('parser rejects a JSON array or scalar payload', () => { - assertInvalidArgs(() => parseTargetAnnotationV1Payload('[]')); - assertInvalidArgs(() => parseTargetAnnotationV1Payload('"button"')); -}); - -test('parser rejects a wrong-typed known field', () => { - assertInvalidArgs(() => - parseTargetAnnotationV1Payload(JSON.stringify({ role: 42, verification: 'verified' })), - ); -}); - -test('parser rejects an invalid verification value', () => { - assertInvalidArgs(() => - parseTargetAnnotationV1Payload(JSON.stringify({ role: 'button', verification: 'maybe' })), - ); -}); - -test('parser rejects a negative or non-integer sibling/viewportOrder', () => { - assertInvalidArgs(() => - parseTargetAnnotationV1Payload( - JSON.stringify({ role: 'button', sibling: -1, verification: 'verified' }), - ), - ); - assertInvalidArgs(() => - parseTargetAnnotationV1Payload( - JSON.stringify({ role: 'button', viewportOrder: 1.5, verification: 'verified' }), - ), - ); -}); - -// --------------------------------------------------------------------------- -// rect is diagnostic only: parsed, bounded, but never a comparison input at -// this parser layer (there is no comparator here yet — decision 3's -// enforcement lands in a later migration step — this just proves the parser -// accepts/round-trips it as inert data). -// --------------------------------------------------------------------------- - -test('rect parses and round trips but carries no comparison semantics here', () => { - const evidence = baseEvidence({ rect: { x: 10, y: 20, width: 30, height: 40 } }); - const parsed = parseTargetAnnotationV1Payload(serializeTargetAnnotationV1(evidence)); - assert.deepEqual(parsed.rect, { x: 10, y: 20, width: 30, height: 40 }); -}); - -test('parser rejects a malformed rect', () => { - assertInvalidArgs(() => - parseTargetAnnotationV1Payload( - JSON.stringify({ role: 'button', rect: { x: 1, y: 2 }, verification: 'verified' }), - ), - ); -}); - -// --------------------------------------------------------------------------- -// Role presence: the writer emits `role` unconditionally (top level, every -// ancestry entry, scrollRegion) — possibly as the empty string for a -// typeless node, which stays accepted. A MISSING role key can only come from -// a hand-edited/adversarial annotation and must be rejected, or step-4 -// enforcement could match anonymous wrapper nodes through an implicit -// empty-role identity. -// --------------------------------------------------------------------------- - -test('parser rejects a missing top-level role', () => { - assertInvalidArgs( - () => parseTargetAnnotationV1Payload(JSON.stringify({ verification: 'verified' })), - /"role" is required/, - ); -}); - -test('parser rejects a missing role in an ancestry entry and in scrollRegion', () => { - assertInvalidArgs( - () => - parseTargetAnnotationV1Payload( - JSON.stringify({ - role: 'button', - ancestry: [{ label: 'Editor' }], - verification: 'verified', - }), - ), - /"ancestry\[0\]\.role" is required/, - ); - assertInvalidArgs( - () => - parseTargetAnnotationV1Payload( - JSON.stringify({ role: 'button', scrollRegion: { id: 'list' }, verification: 'verified' }), - ), - /"scrollRegion\.role" is required/, - ); -}); - -test('parser accepts an explicit empty-string role (writer-legal for typeless nodes)', () => { - const parsed = parseTargetAnnotationV1Payload( - JSON.stringify({ role: '', ancestry: [{ role: '' }], verification: 'verified' }), - ); - assert.equal(parsed.role, ''); - assert.deepEqual(parsed.ancestry, [{ role: '' }]); -}); diff --git a/src/replay/__tests__/vars.test.ts b/src/replay/__tests__/vars.test.ts index 2cde1d78e3..14990073c7 100644 --- a/src/replay/__tests__/vars.test.ts +++ b/src/replay/__tests__/vars.test.ts @@ -8,7 +8,7 @@ import { resolveReplayAction, resolveReplayString, } from '../vars.ts'; -import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../script.ts'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '@agent-device/ad-script'; import type { SessionAction } from '@agent-device/contracts/session'; const LOC = { file: 'test.ad', line: 1 }; diff --git a/src/replay/target-evidence-tree.ts b/src/replay/target-evidence-tree.ts index 72671d8361..c7e792573a 100644 --- a/src/replay/target-evidence-tree.ts +++ b/src/replay/target-evidence-tree.ts @@ -15,8 +15,8 @@ import { matchesAncestryPrefix, matchesLocalIdentity, type LocalIdentity, - type TargetAncestryEntry, } from './target-identity.ts'; +import type { TargetAncestryEntry } from '@agent-device/ad-script'; export function buildIndexMap(nodes: readonly SnapshotNode[]): Map { const map = new Map(); diff --git a/src/replay/target-identity-node.ts b/src/replay/target-identity-node.ts index c537a7eef1..cf422dd60f 100644 --- a/src/replay/target-identity-node.ts +++ b/src/replay/target-identity-node.ts @@ -18,8 +18,8 @@ import { normalizeRoleField, truncateToUtf8Bytes, TARGET_ANNOTATION_MAX_FIELD_BYTES, - type LocalIdentity, -} from './target-identity.ts'; +} from '@agent-device/ad-script'; +import type { LocalIdentity } from './target-identity.ts'; type IdentityTreeNode = Pick; diff --git a/src/replay/target-identity.ts b/src/replay/target-identity.ts index 2b1fd2582f..adeb32b34f 100644 --- a/src/replay/target-identity.ts +++ b/src/replay/target-identity.ts @@ -1,333 +1,17 @@ /** - * ADR 0012 decision 3: versioned `.ad` target-binding evidence — the - * tree-agnostic spine shared by the writer - * (`src/daemon/session-target-evidence.ts`) and the parser - * (`src/replay/script.ts`). Owns the wire type, canonical field order, - * normalization, size caps, payload parsing/validation, and the record/ - * replay-shared classification core. Inert in migration step 3: nothing - * enforces parsed evidence at replay time until step 4. - */ - -import { AppError } from '@agent-device/kernel/errors'; - -const TARGET_ANNOTATION_TAG = 'agent-device:target-v1'; -// Captures the rest of the line verbatim: a line claiming the tag with a -// garbage payload is a malformed v1 annotation, never an ordinary comment. -const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(\S.*))?$/; - -export const TARGET_ANNOTATION_MAX_FIELD_BYTES = 256; -export const TARGET_ANNOTATION_MAX_PAYLOAD_BYTES = 4096; -export const TARGET_ANNOTATION_MAX_ANCESTRY = 8; - -// The annotation SHAPE lives in contracts/ so the recorded-action type can be stated without -// depending on this zone; re-exported here for existing consumers. -export type { - TargetAncestryEntry, - TargetAnnotationV1, - TargetScrollRegion, - TargetVerification, -} from '@agent-device/contracts/replay'; -import type { - TargetAncestryEntry, - TargetAnnotationV1, - TargetRect, - TargetScrollRegion, - TargetVerification, -} from '@agent-device/contracts/replay'; - -// --------------------------------------------------------------------------- -// Normalization (decision 3 "Normalization"): all strings NFC; `label` fields -// additionally trim and collapse internal whitespace runs. A string that is -// empty after normalization is omitted (writer) / treated as absent -// (comparator). Applies to every string field: top-level id/role/label, -// ancestry entry role/label, and scrollRegion role/id/label. -// --------------------------------------------------------------------------- - -function nfc(value: string): string { - return value.normalize('NFC'); -} - -/** id/role fields: NFC only (never trimmed/collapsed — see decision 3). */ -export function normalizeIdentifierField(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - const normalized = nfc(value); - return normalized.length > 0 ? normalized : undefined; -} - -/** `role` is always required (may be the empty string per decision 3's ancestry note). */ -export function normalizeRoleField(value: string): string { - return nfc(value); -} - -/** label fields: NFC, trim, collapse internal whitespace runs to one space. */ -export function normalizeLabelField(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - const collapsed = nfc(value).trim().replace(/\s+/g, ' '); - return collapsed.length > 0 ? collapsed : undefined; -} - -export function utf8ByteLength(value: string): number { - return Buffer.byteLength(value, 'utf8'); -} - -/** - * Writer-side field truncation to the 256-byte cap ("per-field truncation", - * decision 3's writer-parser invariant). Trims on a code-point boundary so a - * surrogate pair is never split. The parser never calls this — it REJECTS - * oversized fields instead (see `parseTargetAnnotationV1Payload`). - */ -export function truncateToUtf8Bytes(value: string, maxBytes: number): string { - if (utf8ByteLength(value) <= maxBytes) return value; - let end = value.length; - while (end > 0 && utf8ByteLength(value.slice(0, end)) > maxBytes) { - end -= 1; - } - if (end > 0) { - const code = value.charCodeAt(end - 1); - if (code >= 0xd8_00 && code <= 0xdb_ff) end -= 1; // don't split a surrogate pair - } - return value.slice(0, end); -} - -// --------------------------------------------------------------------------- -// Canonical serialization (decision 3's exact field order + nested-object -// key order from the example payload). -// --------------------------------------------------------------------------- - -function buildCanonicalTargetAnnotationObject( - evidence: TargetAnnotationV1, -): Record { - const obj: Record = {}; - if (evidence.id !== undefined) obj.id = evidence.id; - obj.role = evidence.role; - if (evidence.label !== undefined) obj.label = evidence.label; - obj.ancestry = evidence.ancestry.map(buildAncestryEntryObject); - obj.sibling = evidence.sibling; - obj.viewportOrder = evidence.viewportOrder; - if (evidence.scrollRegion) obj.scrollRegion = buildScrollRegionObject(evidence.scrollRegion); - if (evidence.rect) obj.rect = buildRectObject(evidence.rect); - obj.verification = evidence.verification; - return obj; -} - -function buildAncestryEntryObject(entry: TargetAncestryEntry): Record { - const obj: Record = { role: entry.role }; - if (entry.label !== undefined) obj.label = entry.label; - return obj; -} - -function buildScrollRegionObject(region: TargetScrollRegion): Record { - const obj: Record = { role: region.role }; - if (region.id !== undefined) obj.id = region.id; - if (region.label !== undefined) obj.label = region.label; - return obj; -} - -function buildRectObject(rect: TargetRect): Record { - return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; -} - -export function serializeTargetAnnotationV1(evidence: TargetAnnotationV1): string { - return JSON.stringify(buildCanonicalTargetAnnotationObject(evidence)); -} - -export function formatTargetAnnotationCommentLine(evidence: TargetAnnotationV1): string { - return `# ${TARGET_ANNOTATION_TAG} ${serializeTargetAnnotationV1(evidence)}`; -} - -// --------------------------------------------------------------------------- -// Parsing (decision 3's parser bullet + "Replay-time verification" intro). -// --------------------------------------------------------------------------- - -export type TargetAnnotationLineParseResult = - | { kind: 'none' } - | { kind: 'future-version' } - | { kind: 'v1'; evidence: TargetAnnotationV1 }; - -/** - * Recognizes a `# agent-device:target-vN {...}` comment line. `N !== 1` is an - * ordinary comment to this (v1) reader, per decision 3: "An unknown future - * `target-vN` comment is an ordinary comment to a v1 reader." Any other `#` - * line (including one that merely mentions the tag inside prose) is `none`. - */ -export function parseTargetAnnotationCommentLine(rawLine: string): TargetAnnotationLineParseResult { - const trimmed = rawLine.trim(); - if (!trimmed.startsWith('#')) return { kind: 'none' }; - const match = TARGET_ANNOTATION_LINE_RE.exec(trimmed); - if (!match) return { kind: 'none' }; - const version = Number(match[1]); - if (version !== 1) return { kind: 'future-version' }; - const evidence = parseTargetAnnotationV1Payload((match[2] ?? '').trim()); - return { kind: 'v1', evidence }; -} - -/** - * Parses and validates the JSON payload of a `target-v1` annotation. - * Accepts known fields in any order, ignores unknown fields, NFC-normalizes - * known strings, and rejects malformed/oversized payloads with - * `INVALID_ARGS` (decision 3: "The parser rejects a v1 annotation exceeding - * these bounds with INVALID_ARGS"). - */ -// fallow-ignore-next-line complexity -export function parseTargetAnnotationV1Payload(jsonText: string): TargetAnnotationV1 { - if (utf8ByteLength(jsonText) > TARGET_ANNOTATION_MAX_PAYLOAD_BYTES) { - throw new AppError( - 'INVALID_ARGS', - `target-v1 annotation exceeds the ${TARGET_ANNOTATION_MAX_PAYLOAD_BYTES}-byte payload cap.`, - ); - } - let parsed: unknown; - try { - parsed = JSON.parse(jsonText); - } catch { - throw new AppError('INVALID_ARGS', 'target-v1 annotation is not valid JSON.'); - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new AppError('INVALID_ARGS', 'target-v1 annotation must be a JSON object.'); - } - const raw = parsed as Record; - - const role = parseRequiredRoleField(raw.role, 'role'); - const id = parseOptionalIdentifierField(raw.id, 'id'); - const label = parseOptionalLabelField(raw.label, 'label'); - const ancestry = parseAncestryField(raw.ancestry); - const sibling = parseNonNegativeIntField(raw.sibling, 'sibling', 0); - const viewportOrder = parseNonNegativeIntField(raw.viewportOrder, 'viewportOrder', 0); - const scrollRegion = parseScrollRegionField(raw.scrollRegion); - const rect = parseRectField(raw.rect); - const verification = parseVerificationField(raw.verification); - - return { - ...(id !== undefined ? { id } : {}), - role, - ...(label !== undefined ? { label } : {}), - ancestry, - sibling, - viewportOrder, - ...(scrollRegion ? { scrollRegion } : {}), - ...(rect ? { rect } : {}), - verification, - }; -} - -/** - * The writer emits `role` unconditionally (possibly as the empty string for - * a typeless node), so a missing role key is always foreign input and is - * rejected rather than defaulted. + * ADR 0012 decision 3: the record/replay-shared CLASSIFICATION core over + * versioned `.ad` target-binding evidence — local-identity + ancestry-prefix + * matching, and `classifyTargetBindingMatch`'s replay-time verification + * paths 2-6. Inert in migration step 3: nothing enforces parsed evidence at + * replay time until step 4. + * + * The comment-line SERDE half (wire type, canonical field order, + * normalization, size caps, payload parsing/validation) moved to + * `@agent-device/ad-script` (#1478 P5 scoping dossier, "the codec seam") — + * this module imports the shared types from there rather than declaring them. */ -function parseRequiredRoleField(value: unknown, field: string): string { - if (value === undefined) { - throw new AppError('INVALID_ARGS', `target-v1 "${field}" is required.`); - } - if (typeof value !== 'string') { - throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`); - } - return boundField(normalizeRoleField(value), field); -} - -function parseOptionalIdentifierField(value: unknown, field: 'id'): string | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'string') { - throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`); - } - const normalized = normalizeIdentifierField(value); - return normalized === undefined ? undefined : boundField(normalized, field); -} - -function parseOptionalLabelField(value: unknown, field: string): string | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'string') { - throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`); - } - const normalized = normalizeLabelField(value); - return normalized === undefined ? undefined : boundField(normalized, field); -} -function boundField(value: string, field: string): string { - if (utf8ByteLength(value) > TARGET_ANNOTATION_MAX_FIELD_BYTES) { - throw new AppError( - 'INVALID_ARGS', - `target-v1 "${field}" exceeds the ${TARGET_ANNOTATION_MAX_FIELD_BYTES}-byte field cap.`, - ); - } - return value; -} - -function parseAncestryField(value: unknown): TargetAncestryEntry[] { - if (value === undefined) return []; - if (!Array.isArray(value)) { - throw new AppError('INVALID_ARGS', 'target-v1 "ancestry" must be an array.'); - } - if (value.length > TARGET_ANNOTATION_MAX_ANCESTRY) { - throw new AppError( - 'INVALID_ARGS', - `target-v1 "ancestry" exceeds the ${TARGET_ANNOTATION_MAX_ANCESTRY}-entry cap.`, - ); - } - return value.map((entry, index) => parseAncestryEntry(entry, index)); -} - -function parseAncestryEntry(entry: unknown, index: number): TargetAncestryEntry { - if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { - throw new AppError('INVALID_ARGS', `target-v1 "ancestry[${index}]" must be an object.`); - } - const record = entry as Record; - const role = parseRequiredRoleField(record.role, `ancestry[${index}].role`); - const label = parseOptionalLabelField(record.label, `ancestry[${index}].label`); - return { role, ...(label !== undefined ? { label } : {}) }; -} - -function parseNonNegativeIntField(value: unknown, field: string, fallback: number): number { - if (value === undefined) return fallback; - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { - throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a non-negative integer.`); - } - return value; -} - -function parseScrollRegionField(value: unknown): TargetScrollRegion | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new AppError('INVALID_ARGS', 'target-v1 "scrollRegion" must be an object.'); - } - const record = value as Record; - const role = parseRequiredRoleField(record.role, 'scrollRegion.role'); - const id = parseOptionalIdentifierField(record.id, 'id'); - const label = parseOptionalLabelField(record.label, 'scrollRegion.label'); - return { - role, - ...(id !== undefined ? { id } : {}), - ...(label !== undefined ? { label } : {}), - }; -} - -function parseRectField(value: unknown): TargetRect | undefined { - if (value === undefined) return undefined; - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new AppError('INVALID_ARGS', 'target-v1 "rect" must be an object.'); - } - const record = value as Record; - const x = parseFiniteNumberField(record.x, 'rect.x'); - const y = parseFiniteNumberField(record.y, 'rect.y'); - const width = parseFiniteNumberField(record.width, 'rect.width'); - const height = parseFiniteNumberField(record.height, 'rect.height'); - return { x, y, width, height }; -} - -function parseFiniteNumberField(value: unknown, field: string): number { - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a finite number.`); - } - return value; -} - -function parseVerificationField(value: unknown): TargetVerification { - if (value === 'verified' || value === 'unverifiable') return value; - throw new AppError( - 'INVALID_ARGS', - 'target-v1 "verification" must be "verified" or "unverifiable".', - ); -} +import type { TargetAncestryEntry, TargetAnnotationV1 } from '@agent-device/ad-script'; // --------------------------------------------------------------------------- // Local identity + ancestry-prefix matching (decision 3 "Local identity" / diff --git a/src/replay/vars.ts b/src/replay/vars.ts index 1bb915fb01..d454651c6b 100644 --- a/src/replay/vars.ts +++ b/src/replay/vars.ts @@ -1,5 +1,9 @@ import { AppError } from '@agent-device/kernel/errors'; import type { SessionAction } from '@agent-device/contracts/session'; +// The env/var key shape is `.ad` script grammar (env directive parsing lives +// in the codec package); re-exported here rather than duplicated (#1478 P5). +export { REPLAY_VAR_KEY_RE } from '@agent-device/ad-script'; +import { REPLAY_VAR_KEY_RE } from '@agent-device/ad-script'; export type ReplayVarScope = { values: Readonly>; @@ -14,7 +18,6 @@ export type ReplayVarSources = { cliEnv?: Record; }; -export const REPLAY_VAR_KEY_RE = /^[A-Z_][A-Z0-9_]*$/; const INTERPOLATION_RE = /(\\\$\{)|\$\{([A-Za-z_][A-Za-z0-9_.]*)(?::-((?:[^}\\]|\\.)*))?\}/g; const SHELL_PREFIX = 'AD_VAR_'; const RESERVED_NAMESPACE_PREFIX = 'AD_'; diff --git a/test/integration/live-device-e2e/replay-evidence.ts b/test/integration/live-device-e2e/replay-evidence.ts index c19b8072bc..ed7bc327e1 100644 --- a/test/integration/live-device-e2e/replay-evidence.ts +++ b/test/integration/live-device-e2e/replay-evidence.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { parseReplayScriptDetailed, readReplayScriptMetadata } from '../../../src/replay/script.ts'; +import { parseReplayScriptDetailed, readReplayScriptMetadata } from '@agent-device/ad-script'; const DEFAULT_REPLAY_TIMEOUT_MS = 90_000; const HOST_TIMEOUT_MARGIN_MS = 60_000; diff --git a/test/integration/smoke-ios-simulator-coverage.test.ts b/test/integration/smoke-ios-simulator-coverage.test.ts index 858e42a7e6..dac26848ee 100644 --- a/test/integration/smoke-ios-simulator-coverage.test.ts +++ b/test/integration/smoke-ios-simulator-coverage.test.ts @@ -15,7 +15,7 @@ import { isCommandSupportedOnDevice, unsupportedHintForDevice, } from '../../src/core/capabilities.ts'; -import { parseReplayScriptDetailed } from '../../src/replay/script.ts'; +import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { IOS_SIMULATOR_BEHAVIOR_COVERAGE } from './ios-simulator-e2e/behavior-coverage.ts'; import { IOS_SIMULATOR_E2E_COVERAGE, From 05699e2087d2b677279cd082a65630c9b3f5d482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 1 Aug 2026 08:22:02 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20trim=20the=20ad-script=20fa?= =?UTF-8?q?=C3=A7ade=20to=20real=20consumers,=20lock=20the=20one-export=20?= =?UTF-8?q?boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/ad-script/src/index.ts | 5 ----- scripts/layering/package-boundaries.test.ts | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/ad-script/src/index.ts b/packages/ad-script/src/index.ts index 976e541983..bcec253a56 100644 --- a/packages/ad-script/src/index.ts +++ b/packages/ad-script/src/index.ts @@ -29,7 +29,6 @@ export { formatScriptStringLiteral, isClickLikeCommand, isTouchTargetCommand, - parseReplaySeriesFlags, stripRecordedRefGeneration, } from './internal/script-utils.ts'; @@ -39,11 +38,9 @@ export { } from './internal/script-formatting.ts'; export { - formatTargetAnnotationCommentLine, normalizeIdentifierField, normalizeLabelField, normalizeRoleField, - parseTargetAnnotationCommentLine, parseTargetAnnotationV1Payload, serializeTargetAnnotationV1, truncateToUtf8Bytes, @@ -54,9 +51,7 @@ export { } from './internal/target-annotation-serde.ts'; export type { TargetAncestryEntry, - TargetAnnotationLineParseResult, TargetAnnotationV1, - TargetRect, TargetScrollRegion, TargetVerification, } from './internal/target-annotation-serde.ts'; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index da3a1578c0..c87255ff26 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -219,6 +219,15 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/contracts', '@agent-device/kernel', ]); + const adScriptPackage = packages.find((pkg) => pkg.name === '@agent-device/ad-script'); + assert.ok(adScriptPackage, 'ad-script package must exist'); + // Locks the "exports only `.`" boundary: a future `/codec` (or any other) + // subpath widens this key list and fails the assertion (#1478 P5 dossier). + assert.deepEqual([...adScriptPackage.exportTargets.keys()], ['@agent-device/ad-script']); + assert.deepEqual([...adScriptPackage.workspaceDependencies].sort(), [ + '@agent-device/contracts', + '@agent-device/kernel', + ]); const providerWebDriverPackage = packages.find( (pkg) => pkg.name === '@agent-device/provider-webdriver', ); @@ -270,6 +279,10 @@ test('the real tree parses, declares, and passes R11', () => { rootWorkspaceDependencyNames(repoRoot).has('@agent-device/maestro'), 'root must declare the maestro workspace dependency', ); + assert.ok( + rootWorkspaceDependencyNames(repoRoot).has('@agent-device/ad-script'), + 'root must declare the ad-script workspace dependency', + ); assert.ok( rootWorkspaceDependencyNames(repoRoot).has('@agent-device/provider-webdriver'), 'root must declare the provider-webdriver workspace dependency', @@ -304,6 +317,9 @@ test('Node resolution enforces the exports map at runtime', () => { '@agent-device/provider-limrun/src/runtime.ts', '@agent-device/xml/internal/parser', '@agent-device/xml/src/index.ts', + '@agent-device/ad-script/codec', + '@agent-device/ad-script/internal/script.ts', + '@agent-device/ad-script/src/index.ts', ]) { assert.throws( () => import.meta.resolve(deep), @@ -329,4 +345,6 @@ test('Node resolution enforces the exports map at runtime', () => { ); const xmlResolved = import.meta.resolve('@agent-device/xml'); assert.ok(xmlResolved.endsWith('packages/xml/src/index.ts'), xmlResolved); + const adScriptResolved = import.meta.resolve('@agent-device/ad-script'); + assert.ok(adScriptResolved.endsWith('packages/ad-script/src/index.ts'), adScriptResolved); }); From 854f2266588341e599ed596d95dc8f25eb37ba63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 1 Aug 2026 12:46:06 +0200 Subject: [PATCH 3/4] fix: remove polynomial-redos ambiguity from the target-v1 annotation line regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../__tests__/target-annotation-serde.test.ts | 28 +++++++++++++++++++ .../src/internal/target-annotation-serde.ts | 13 ++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts index d04ddf6b97..904030084f 100644 --- a/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts +++ b/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts @@ -312,3 +312,31 @@ test('parser accepts an explicit empty-string role (writer-legal for typeless no assert.equal(parsed.role, ''); assert.deepEqual(parsed.ancestry, [{ role: '' }]); }); + +// --------------------------------------------------------------------------- +// CodeQL js/polynomial-redos (#1536 review): the target-v1 comment-line regex +// used to backtrack polynomially on a run of separator whitespace that never +// resolves to a full match — `\s+` and a bare `.*` both accept whitespace, so +// there were exponentially many ways to split the run between them before +// concluding failure. +// --------------------------------------------------------------------------- + +test('parseTargetAnnotationCommentLine resolves an adversarial whitespace-run line in well under a second', () => { + // A trailing non-whitespace character is deliberate: `parseTargetAnnotationCommentLine` + // trims the line before matching, so a purely-trailing whitespace run (the + // literal CodeQL-cited shape) would already be stripped before it ever + // reaches the regex. Appending one non-whitespace byte keeps the long tab + // run internal, so this test genuinely exercises the flagged pattern + // through the public entry point rather than being neutralized by trim(). + const adversarial = `#agent-device:target-v0\t${'\t\t'.repeat(20_000)}x`; + const startedAt = Date.now(); + const result = parseTargetAnnotationCommentLine(adversarial); + const elapsedMs = Date.now() - startedAt; + assert.ok( + elapsedMs < 1000, + `expected the adversarial line to parse quickly, took ${elapsedMs}ms`, + ); + // v0 is a future-version comment to this (v1) reader — verifies the regex still + // matches the tag/version correctly, not just that it fails fast. + assert.deepEqual(result, { kind: 'future-version' }); +}); diff --git a/packages/ad-script/src/internal/target-annotation-serde.ts b/packages/ad-script/src/internal/target-annotation-serde.ts index 91aad00cce..9731deff41 100644 --- a/packages/ad-script/src/internal/target-annotation-serde.ts +++ b/packages/ad-script/src/internal/target-annotation-serde.ts @@ -17,7 +17,18 @@ import { AppError } from '@agent-device/kernel/errors'; const TARGET_ANNOTATION_TAG = 'agent-device:target-v1'; // Captures the rest of the line verbatim: a line claiming the tag with a // garbage payload is a malformed v1 annotation, never an ordinary comment. -const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(.*))?$/; +// +// The payload group is anchored on `\S` (CodeQL js/polynomial-redos, +// #1536 review): `\s+` and a bare `.*` both accept whitespace, so a run of +// separator characters that ultimately fails to match `$` (e.g. many tabs +// with no trailing content) has exponentially many `\s+`/`.*` splits to +// backtrack through. `\S` is `\s`'s complement, so it can never overlap with +// the mandatory `\s+` before it — the split point is unique, no backtracking +// possible. Behavior-preserving: the only caller (below) always matches +// against an already-`.trim()`-ed line, whose last character (when the tag +// matches at all) is never whitespace, so a payload section that `\S.*` +// would reject (all-whitespace) can never reach this regex in practice. +const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(\S.*))?$/; export const TARGET_ANNOTATION_MAX_FIELD_BYTES = 256; export const TARGET_ANNOTATION_MAX_PAYLOAD_BYTES = 4096; From 754274c9ed1341cedea241c7ecf8d6dc0564e9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 1 Aug 2026 13:55:27 +0200 Subject: [PATCH 4/4] 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 --- .../__tests__/target-annotation-serde.test.ts | 43 ++++++++++--------- .../src/internal/target-annotation-serde.ts | 3 +- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts b/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts index 904030084f..bf42b83646 100644 --- a/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts +++ b/packages/ad-script/src/internal/__tests__/target-annotation-serde.test.ts @@ -12,6 +12,7 @@ import { TARGET_ANNOTATION_MAX_FIELD_BYTES, TARGET_ANNOTATION_MAX_PAYLOAD_BYTES, type TargetAnnotationV1, + TARGET_ANNOTATION_LINE_RE, } from '../target-annotation-serde.ts'; function baseEvidence(overrides: Partial = {}): TargetAnnotationV1 { @@ -314,29 +315,29 @@ test('parser accepts an explicit empty-string role (writer-legal for typeless no }); // --------------------------------------------------------------------------- -// CodeQL js/polynomial-redos (#1536 review): the target-v1 comment-line regex -// used to backtrack polynomially on a run of separator whitespace that never -// resolves to a full match — `\s+` and a bare `.*` both accept whitespace, so -// there were exponentially many ways to split the run between them before -// concluding failure. +// CodeQL js/polynomial-redos (#1536 review): with the retired `\s+(.*)` form, +// the separator and payload both accepted whitespace, so a failing match +// re-split a long run quadratically. The public entry point can never feed +// the slow shape (trim strips edge whitespace and a per-line input carries no +// newline, so the old form matched greedily in one attempt there) — the +// regression surface is the pattern itself, pinned directly below; the +// entry-point case documents the behavior contract. // --------------------------------------------------------------------------- -test('parseTargetAnnotationCommentLine resolves an adversarial whitespace-run line in well under a second', () => { - // A trailing non-whitespace character is deliberate: `parseTargetAnnotationCommentLine` - // trims the line before matching, so a purely-trailing whitespace run (the - // literal CodeQL-cited shape) would already be stripped before it ever - // reaches the regex. Appending one non-whitespace byte keeps the long tab - // run internal, so this test genuinely exercises the flagged pattern - // through the public entry point rather than being neutralized by trim(). - const adversarial = `#agent-device:target-v0\t${'\t\t'.repeat(20_000)}x`; +test('the annotation-line pattern rejects an interior-whitespace non-match in linear time', () => { + // `x\n` tail: `.` cannot cross the newline and `$` needs true end-of-input, + // so the match FAILS — the retired form re-tried every split of the tab run + // (quadratic; multi-second at this size), while the `\S` anchor admits a + // single split point. + const adversarial = `#agent-device:target-v1${'\t'.repeat(100_000)}x\n`; const startedAt = Date.now(); - const result = parseTargetAnnotationCommentLine(adversarial); + const match = TARGET_ANNOTATION_LINE_RE.exec(adversarial); const elapsedMs = Date.now() - startedAt; - assert.ok( - elapsedMs < 1000, - `expected the adversarial line to parse quickly, took ${elapsedMs}ms`, - ); - // v0 is a future-version comment to this (v1) reader — verifies the regex still - // matches the tag/version correctly, not just that it fails fast. - assert.deepEqual(result, { kind: 'future-version' }); + assert.equal(match, null); + assert.ok(elapsedMs < 1000, `expected linear rejection, took ${elapsedMs}ms`); +}); + +test('parseTargetAnnotationCommentLine still recognizes versions across an interior whitespace run', () => { + const line = `#agent-device:target-v0\t${'\t\t'.repeat(20_000)}x`; + assert.deepEqual(parseTargetAnnotationCommentLine(line), { kind: 'future-version' }); }); diff --git a/packages/ad-script/src/internal/target-annotation-serde.ts b/packages/ad-script/src/internal/target-annotation-serde.ts index 9731deff41..cef9ad0b1d 100644 --- a/packages/ad-script/src/internal/target-annotation-serde.ts +++ b/packages/ad-script/src/internal/target-annotation-serde.ts @@ -28,7 +28,8 @@ const TARGET_ANNOTATION_TAG = 'agent-device:target-v1'; // against an already-`.trim()`-ed line, whose last character (when the tag // matches at all) is never whitespace, so a payload section that `\S.*` // would reject (all-whitespace) can never reach this regex in practice. -const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(\S.*))?$/; +/** Internal-test visibility only: the pattern itself is the ReDoS regression surface. */ +export const TARGET_ANNOTATION_LINE_RE = /^#\s*agent-device:target-v(\d+)(?:\s+(\S.*))?$/; export const TARGET_ANNOTATION_MAX_FIELD_BYTES = 256; export const TARGET_ANNOTATION_MAX_PAYLOAD_BYTES = 4096;