From 2ad0beffd6f47c9225deda7ee62986f1c53089e4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 2 May 2026 14:15:19 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(storyboard):=20field=5Fcontains=20vali?= =?UTF-8?q?dation=20=E2=80=94=20closes=20adcontextprotocol/adcp#3803=20ite?= =?UTF-8?q?m=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a wildcard-aware membership check to the storyboard validator. `path` may include `[*]` segments (resolved via the existing `resolvePathAll`), and the check passes when ANY resolved value matches `value` or any of `allowed_values`. Lets storyboards write: - check: field_contains path: creatives[0].errors[*].code value: PROVENANCE_VERIFIER_NOT_ACCEPTED instead of the brittle positional `errors[0].code` form, which breaks when a seller emits multiple errors or reorders the cascade. When the path has no wildcard segments the check reduces to scalar equality. Symmetric to field_value for missing-value/missing-path error paths and JSON pointer emission. 8 new test cases in test/lib/storyboard-validations. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/field-contains-validation.md | 19 +++ src/lib/testing/storyboard/types.ts | 10 ++ src/lib/testing/storyboard/validations.ts | 72 +++++++++++ test/lib/storyboard-validations.test.js | 141 ++++++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 .changeset/field-contains-validation.md diff --git a/.changeset/field-contains-validation.md b/.changeset/field-contains-validation.md new file mode 100644 index 000000000..1a9c39090 --- /dev/null +++ b/.changeset/field-contains-validation.md @@ -0,0 +1,19 @@ +--- +"@adcp/sdk": minor +--- + +feat(storyboard): `field_contains` validation check — closes adcontextprotocol/adcp#3803 item 2 + +Adds a wildcard-aware membership check to the storyboard validator. `path` may include `[*]` segments (resolved via the existing `resolvePathAll`), and the check passes when ANY resolved value matches `value` or any of `allowed_values`. + +Lets storyboards write: + +```yaml +- check: field_contains + path: creatives[0].errors[*].code + value: PROVENANCE_VERIFIER_NOT_ACCEPTED +``` + +instead of the brittle positional form `creatives[0].errors[0].code value: PROVENANCE_VERIFIER_NOT_ACCEPTED`, which breaks when a future seller emits multiple errors per creative or reorders the cascade. When the path has no wildcard segments the check reduces to scalar equality. + +Symmetric to `field_value` for missing-value/missing-path errors and JSON pointer emission. 8 new test cases in `test/lib/storyboard-validations.test.js`. diff --git a/src/lib/testing/storyboard/types.ts b/src/lib/testing/storyboard/types.ts index a31e6f455..73f0a3b67 100644 --- a/src/lib/testing/storyboard/types.ts +++ b/src/lib/testing/storyboard/types.ts @@ -482,6 +482,16 @@ export type StoryboardValidationCheck = | 'envelope_field_value_or_absent' | 'field_value' | 'field_value_or_absent' + // Wildcard-aware membership check. `path` may include `[*]` segments that + // expand to every array element via `resolvePathAll`. Passes when ANY + // resolved value matches `value` (or any of `allowed_values`). Lets + // storyboards assert presence of an expected entry inside an unordered + // array without hardcoding a positional index — e.g., + // `creatives[0].errors[*].code = PROVENANCE_DISCLOSURE_MISSING` passes + // regardless of cascade ordering or co-emitted errors. When the path has + // no wildcard segments this reduces to scalar equality / array membership + // depending on what the path resolves to. + | 'field_contains' | 'status_code' | 'error_code' // HTTP-probe checks (for raw_probe tasks) diff --git a/src/lib/testing/storyboard/validations.ts b/src/lib/testing/storyboard/validations.ts index a6efedd05..7760437a8 100644 --- a/src/lib/testing/storyboard/validations.ts +++ b/src/lib/testing/storyboard/validations.ts @@ -196,6 +196,8 @@ function runValidation(validation: StoryboardValidation, ctx: ValidationContext) return validateFieldValue(validation, resolveTarget(ctx)); case 'field_value_or_absent': return validateFieldValueOrAbsent(validation, resolveTarget(ctx)); + case 'field_contains': + return validateFieldContains(validation, resolveTarget(ctx)); case 'status_code': return requireTaskResult(ctx, validation, tr => validateStatusCode(validation, tr)); case 'error_code': @@ -851,6 +853,76 @@ function validateFieldValueOrAbsent(validation: StoryboardValidation, taskResult }; } +// ──────────────────────────────────────────────────────────── +// field_contains: wildcard-aware membership check +// +// Resolves `path` via `resolvePathAll` (which understands `[*]` segments) +// and passes when ANY resolved value matches `value` or any of +// `allowed_values`. Lets storyboards assert "this code appears somewhere +// in errors[]" without pinning a positional index that breaks if the +// seller's emit order shifts or co-emits additional errors. +// ──────────────────────────────────────────────────────────── + +function validateFieldContains(validation: StoryboardValidation, taskResult: TaskResult): ValidationResult { + const checkName = validation.check; + if (!validation.path) { + return { + check: checkName, + passed: false, + description: validation.description, + path: validation.path, + error: `No path specified for ${checkName} validation`, + json_pointer: null, + expected: 'path must be set in storyboard validation entry', + actual: null, + }; + } + + if (validation.value === undefined && !validation.allowed_values?.length) { + return { + check: checkName, + passed: false, + description: validation.description, + path: validation.path, + error: `${checkName} requires either \`value\` or \`allowed_values\``, + json_pointer: toJsonPointer(validation.path), + expected: '`value` or `allowed_values` must be set', + actual: null, + }; + } + + const resolved = resolvePathAll(taskResult.data, validation.path); + const pointer = toJsonPointer(validation.path); + + const candidates = validation.allowed_values?.length ? validation.allowed_values : [validation.value]; + const matched = resolved.some(actual => candidates.some(c => valuesMatch(actual, c))); + + if (matched) { + return { + check: checkName, + passed: true, + description: validation.description, + path: validation.path, + json_pointer: pointer, + }; + } + + const expected = validation.allowed_values?.length ? validation.allowed_values : validation.value; + const errMsg = validation.allowed_values?.length + ? `Expected one of ${JSON.stringify(validation.allowed_values)} to appear in path; got ${JSON.stringify(resolved)}` + : `Expected ${JSON.stringify(validation.value)} to appear in path; got ${JSON.stringify(resolved)}`; + return { + check: checkName, + passed: false, + description: validation.description, + path: validation.path, + error: errMsg, + json_pointer: pointer, + expected, + actual: resolved, + }; +} + // ──────────────────────────────────────────────────────────── // status_code: check TaskResult status // ──────────────────────────────────────────────────────────── diff --git a/test/lib/storyboard-validations.test.js b/test/lib/storyboard-validations.test.js index b5523cfcd..e77c790fe 100644 --- a/test/lib/storyboard-validations.test.js +++ b/test/lib/storyboard-validations.test.js @@ -303,3 +303,144 @@ describe('field_absent / envelope_field_absent (adcp#3429)', () => { assert.match(result.error, /No path specified for envelope_field_absent/); }); }); + +describe('field_contains (adcp#3803 item 2)', () => { + it('passes when value matches any element via [*] wildcard', () => { + const taskResult = { + success: true, + data: { + creatives: [ + { + errors: [ + { code: 'PROVENANCE_DISCLOSURE_MISSING', message: 'no disclosure' }, + { code: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', message: 'verifier off-list' }, + ], + }, + ], + }, + }; + const [result] = runOne( + [{ + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', + description: 'verifier-not-accepted appears regardless of cascade order', + }], + 'sync_creatives', + taskResult + ); + assert.strictEqual(result.passed, true, result.error); + assert.strictEqual(result.check, 'field_contains'); + }); + + it('passes when any allowed_values entry matches', () => { + const taskResult = { + success: true, + data: { + creatives: [{ errors: [{ code: 'PROVENANCE_DISCLOSURE_MISSING' }] }], + }, + }; + const [result] = runOne( + [{ + check: 'field_contains', + path: 'creatives[0].errors[*].code', + allowed_values: ['PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING', 'PROVENANCE_DISCLOSURE_MISSING'], + description: 'either disclosure code is acceptable', + }], + 'sync_creatives', + taskResult + ); + assert.strictEqual(result.passed, true, result.error); + }); + + it('fails when no resolved value matches', () => { + const taskResult = { + success: true, + data: { + creatives: [{ errors: [{ code: 'PROVENANCE_DISCLOSURE_MISSING' }] }], + }, + }; + const [result] = runOne( + [{ + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', + description: 'expected verifier-not-accepted', + }], + 'sync_creatives', + taskResult + ); + assert.strictEqual(result.passed, false); + assert.match(result.error, /PROVENANCE_VERIFIER_NOT_ACCEPTED/); + assert.deepStrictEqual(result.actual, ['PROVENANCE_DISCLOSURE_MISSING']); + }); + + it('fails when path resolves to empty (no array elements)', () => { + const taskResult = { success: true, data: { creatives: [{ errors: [] }] } }; + const [result] = runOne( + [{ + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'PROVENANCE_DISCLOSURE_MISSING', + description: 'expected disclosure error', + }], + 'sync_creatives', + taskResult + ); + assert.strictEqual(result.passed, false); + assert.deepStrictEqual(result.actual, []); + }); + + it('reduces to scalar equality when path has no wildcard', () => { + const taskResult = { success: true, data: { status: 'completed' } }; + const [hit] = runOne( + [{ check: 'field_contains', path: 'status', value: 'completed', description: 'status matches' }], + 'create_media_buy', + taskResult + ); + assert.strictEqual(hit.passed, true, hit.error); + + const [miss] = runOne( + [{ check: 'field_contains', path: 'status', value: 'submitted', description: 'status mismatch' }], + 'create_media_buy', + taskResult + ); + assert.strictEqual(miss.passed, false); + }); + + it('reports an error when path is missing', () => { + const [result] = runOne( + [{ check: 'field_contains', value: 'X', description: 'no path given' }], + 'create_media_buy', + { success: true, data: {} } + ); + assert.strictEqual(result.passed, false); + assert.match(result.error, /No path specified for field_contains/); + }); + + it('reports an error when neither value nor allowed_values is set', () => { + const [result] = runOne( + [{ check: 'field_contains', path: 'errors[*].code', description: 'no expectations' }], + 'create_media_buy', + { success: true, data: { errors: [] } } + ); + assert.strictEqual(result.passed, false); + assert.match(result.error, /requires either `value` or `allowed_values`/); + }); + + it('emits the canonical JSON pointer for the path', () => { + const [result] = runOne( + [{ + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'X', + description: 'pointer test', + }], + 'sync_creatives', + { success: true, data: { creatives: [{ errors: [{ code: 'X' }] }] } } + ); + assert.strictEqual(result.passed, true); + // toJsonPointer renders [*] as /* per RFC 6901 string-encoding rules + assert.match(result.json_pointer, /^\/creatives\/0\/errors\/.*\/code$/); + }); +}); From c8b9c3f70a109bf516415b0123fd9065aa9ce82e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 2 May 2026 14:41:36 -0400 Subject: [PATCH 2/2] test(storyboard): prettier-format + tighten field_contains JSON pointer assertion Apply prettier --write to satisfy CI formatting check and tighten the JSON-pointer assertion in the new field_contains tests to pin the literal `*` segment that toJsonPointer emits for `[*]` (per RFC 6901, `*` is not a forbidden character so it round-trips unescaped). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/lib/storyboard-validations.test.js | 75 ++++++++++++++----------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/test/lib/storyboard-validations.test.js b/test/lib/storyboard-validations.test.js index e77c790fe..789eb443a 100644 --- a/test/lib/storyboard-validations.test.js +++ b/test/lib/storyboard-validations.test.js @@ -320,12 +320,14 @@ describe('field_contains (adcp#3803 item 2)', () => { }, }; const [result] = runOne( - [{ - check: 'field_contains', - path: 'creatives[0].errors[*].code', - value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', - description: 'verifier-not-accepted appears regardless of cascade order', - }], + [ + { + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', + description: 'verifier-not-accepted appears regardless of cascade order', + }, + ], 'sync_creatives', taskResult ); @@ -341,12 +343,14 @@ describe('field_contains (adcp#3803 item 2)', () => { }, }; const [result] = runOne( - [{ - check: 'field_contains', - path: 'creatives[0].errors[*].code', - allowed_values: ['PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING', 'PROVENANCE_DISCLOSURE_MISSING'], - description: 'either disclosure code is acceptable', - }], + [ + { + check: 'field_contains', + path: 'creatives[0].errors[*].code', + allowed_values: ['PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING', 'PROVENANCE_DISCLOSURE_MISSING'], + description: 'either disclosure code is acceptable', + }, + ], 'sync_creatives', taskResult ); @@ -361,12 +365,14 @@ describe('field_contains (adcp#3803 item 2)', () => { }, }; const [result] = runOne( - [{ - check: 'field_contains', - path: 'creatives[0].errors[*].code', - value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', - description: 'expected verifier-not-accepted', - }], + [ + { + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'PROVENANCE_VERIFIER_NOT_ACCEPTED', + description: 'expected verifier-not-accepted', + }, + ], 'sync_creatives', taskResult ); @@ -378,12 +384,14 @@ describe('field_contains (adcp#3803 item 2)', () => { it('fails when path resolves to empty (no array elements)', () => { const taskResult = { success: true, data: { creatives: [{ errors: [] }] } }; const [result] = runOne( - [{ - check: 'field_contains', - path: 'creatives[0].errors[*].code', - value: 'PROVENANCE_DISCLOSURE_MISSING', - description: 'expected disclosure error', - }], + [ + { + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'PROVENANCE_DISCLOSURE_MISSING', + description: 'expected disclosure error', + }, + ], 'sync_creatives', taskResult ); @@ -430,17 +438,20 @@ describe('field_contains (adcp#3803 item 2)', () => { it('emits the canonical JSON pointer for the path', () => { const [result] = runOne( - [{ - check: 'field_contains', - path: 'creatives[0].errors[*].code', - value: 'X', - description: 'pointer test', - }], + [ + { + check: 'field_contains', + path: 'creatives[0].errors[*].code', + value: 'X', + description: 'pointer test', + }, + ], 'sync_creatives', { success: true, data: { creatives: [{ errors: [{ code: 'X' }] }] } } ); assert.strictEqual(result.passed, true); - // toJsonPointer renders [*] as /* per RFC 6901 string-encoding rules - assert.match(result.json_pointer, /^\/creatives\/0\/errors\/.*\/code$/); + // toJsonPointer renders [*] as /* (literal asterisk) — `*` isn't a + // forbidden character in RFC 6901, so it round-trips unescaped. + assert.strictEqual(result.json_pointer, '/creatives/0/errors/*/code'); }); });