diff --git a/.changeset/beta7-async-response-refs.md b/.changeset/beta7-async-response-refs.md new file mode 100644 index 0000000000..16e55f0516 --- /dev/null +++ b/.changeset/beta7-async-response-refs.md @@ -0,0 +1,9 @@ +--- +"adcontextprotocol": patch +--- + +Build schema bundles with `core/async-response-refs/` copies so SDK validators +can pre-register async response refs before compiling +`core/async-response-data.json` in the next release candidate. + +Refs #5161. diff --git a/package.json b/package.json index 4b6f4f4ed2..4692452054 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "test:sign-protocol-tarball": "node --test --test-force-exit --test-timeout=30000 tests/sign-protocol-tarball.test.cjs", "test:build-schemas-hoist-enums": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-hoist-enums.test.cjs", "test:build-schemas-hoist-marked": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-hoist-marked.test.cjs", + "test:build-schemas-async-response-refs": "node --test --test-force-exit --test-timeout=30000 tests/build-schemas-async-response-refs.test.cjs", "test:error-codes": "node scripts/lint-error-codes.cjs", "test:error-code-drift": "node scripts/lint-error-code-drift.cjs", "test:substitution-vector-names": "node scripts/lint-substitution-vector-names.cjs", @@ -85,7 +86,7 @@ "audit:oneof": "node scripts/audit-oneof.mjs", "test:schema-utf8": "node scripts/normalize-schema-utf8.mjs --check", "fix:schema-utf8": "node scripts/normalize-schema-utf8.mjs", - "test": "npm run test:docs-nav && npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", + "test": "npm run test:docs-nav && npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:extension-schemas && npm run test:error-handling && npm run test:json-schema && npm run test:canonical-reference-resolver && npm run test:composed && npm run test:rejection-arm-mutex && npm run test:migrations && npm run test:hmac-vectors && npm run test:hmac-signer-conformance && npm run test:transport-errors && npm run test:targeting-overlay-vectors && npm run test:storyboard-scoping && npm run test:storyboard-branch-sets && npm run test:storyboard-provides-state-for && npm run test:storyboard-contradictions && npm run test:storyboard-context-entity && npm run test:storyboard-auth-shape && npm run test:storyboard-test-kits && npm run test:storyboard-sample-request-schema && npm run test:storyboard-response-schema && npm run test:storyboard-context-output-paths && npm run test:storyboard-validations-paths && npm run test:storyboard-check-enum && npm run test:storyboard-advisory-expiry && npm run test:storyboard-raw-mode-required && npm run test:storyboard-upstream-traffic-paths && npm run test:storyboard-doc-parity && npm run test:pagination-invariant && npm run test:version-envelope && npm run test:test-dynamic-imports && npm run test:callapi-state-change && npm run test:sign-protocol-tarball && npm run test:build-schemas-hoist-enums && npm run test:build-schemas-hoist-marked && npm run test:build-schemas-async-response-refs && npm run test:error-codes && npm run test:substitution-vector-names && npm run test:platform-agnostic && npm run test:oneof-discriminators && npm run test:schema-utf8 && npm run test:unit && npm run test:server-unit && npm run test:openapi && npm run typecheck", "test:all": "npm run test:schemas && npm run test:examples && npm run test:extensions && npm run test:error-handling && npm run test:snippets && npm run typecheck", "precommit": "bash scripts/with-timeout.sh 60 npm run test:unit && npm run test:test-dynamic-imports && npm run test:callapi-state-change && npm run typecheck", "test:storyboards": "bash scripts/run-storyboards-matrix.sh", diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index 5decc9602b..9588fc197e 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -1018,6 +1018,45 @@ function copyAndTransformSchemas(sourceDir, targetDir, version) { } } +function copyAsyncResponseRefsToCore(targetDir) { + const asyncRefDir = path.join(targetDir, 'core', 'async-response-refs'); + + if (fs.existsSync(asyncRefDir)) { + fs.rmSync(asyncRefDir, { recursive: true, force: true }); + } + ensureDir(asyncRefDir); + + let count = 0; + + function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const rel = path.relative(targetDir, fullPath); + + if (entry.isDirectory()) { + if (rel === 'bundled' || rel.startsWith(`bundled${path.sep}`)) continue; + if (rel === path.join('core', 'async-response-refs') || rel.startsWith(`${path.join('core', 'async-response-refs')}${path.sep}`)) continue; + walk(fullPath); + continue; + } + + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.json')) continue; + if (!/-async-response-(submitted|working|input-required)\.json$/.test(entry.name)) continue; + if (rel.startsWith(`core${path.sep}`)) continue; + + const targetPath = path.join(asyncRefDir, rel); + ensureDir(path.dirname(targetPath)); + fs.copyFileSync(fullPath, targetPath); + count++; + } + } + + walk(targetDir); + return count; +} + function updateSourceRegistry(version) { const registryPath = path.join(SOURCE_DIR, 'index.json'); const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')); @@ -1879,6 +1918,8 @@ async function main() { console.log(`📋 Creating release: dist/schemas/${version}/`); ensureDir(versionDir); copyAndTransformSchemas(SOURCE_DIR, versionDir, version); + const asyncRefCount = copyAsyncResponseRefsToCore(versionDir); + console.log(` ✓ Copied ${asyncRefCount} async response schemas to core/async-response-refs/`); // Build extensions (auto-discovered, filtered by version) console.log(`🔌 Building extensions for ${version}`); @@ -1910,6 +1951,7 @@ async function main() { console.log(`📋 Updating latest/ to match release`); ensureDir(latestDir); copyAndTransformSchemas(SOURCE_DIR, latestDir, 'latest'); + copyAsyncResponseRefsToCore(latestDir); // Build extensions for latest (using full version for filtering) buildExtensions(SOURCE_DIR, latestDir, version); @@ -1963,6 +2005,8 @@ async function main() { console.log(`📋 Building schemas to dist/schemas/latest/`); ensureDir(latestDir); copyAndTransformSchemas(SOURCE_DIR, latestDir, 'latest'); + const asyncRefCount = copyAsyncResponseRefsToCore(latestDir); + console.log(` ✓ Copied ${asyncRefCount} async response schemas to core/async-response-refs/`); // Build extensions (auto-discovered, filtered by current version) console.log(`🔌 Building extensions for ${version}`); @@ -2016,7 +2060,7 @@ async function main() { console.log('📖 See docs/reference/versioning.mdx for guidance on which to use.'); } -module.exports = { hoistDuplicateInlineEnums, hoistMarkedSchemas, resolveRefs, versionInlineSchemaIds, dedupBundledSchemaIds, stripIdsFromSubtreesWithLocalRefs }; +module.exports = { hoistDuplicateInlineEnums, hoistMarkedSchemas, resolveRefs, versionInlineSchemaIds, dedupBundledSchemaIds, stripIdsFromSubtreesWithLocalRefs, copyAsyncResponseRefsToCore }; if (require.main === module) { main().catch(err => { diff --git a/scripts/stage-sdk-schema-bundle.sh b/scripts/stage-sdk-schema-bundle.sh index d9968e1763..ec0293dbf2 100755 --- a/scripts/stage-sdk-schema-bundle.sh +++ b/scripts/stage-sdk-schema-bundle.sh @@ -79,8 +79,8 @@ mkdir -p "$DST" (cd "$SRC" && tar -cf - .) | (cd "$DST" && tar -xf -) # SDK 8.1.0-beta.12's loader deliberately skips response-tool files during -# core pre-registration so response root relaxation can happen lazily. Older -# 3.0.x schema bundles have core/async-response-data.json $ref directly to +# core pre-registration so response root relaxation can happen lazily. Some +# published schema bundles have core/async-response-data.json $ref directly to # tool async response schemas, so those refs must still be registered before # core schemas compile. Duplicating them under core/ keeps the original bundle # intact while placing a copy in a directory the loader treats as fragments. diff --git a/tests/build-schemas-async-response-refs.test.cjs b/tests/build-schemas-async-response-refs.test.cjs new file mode 100644 index 0000000000..39246c7895 --- /dev/null +++ b/tests/build-schemas-async-response-refs.test.cjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const Ajv = require('ajv'); + +const { copyAsyncResponseRefsToCore } = require('../scripts/build-schemas.cjs'); + +function writeJson(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8'); +} + +function findJsonFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...findJsonFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.json')) { + out.push(fullPath); + } + } + return out; +} + +function isOriginalAsyncResponse(root, filePath) { + const rel = path.relative(root, filePath); + if (rel.startsWith(`core${path.sep}async-response-refs${path.sep}`)) return false; + return /-async-response-(submitted|working|input-required)\.json$/.test(path.basename(filePath)); +} + +test('core async-response-refs let async-response-data compile when original async response schemas are skipped', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'adcp-async-response-refs-')); + try { + writeJson(path.join(tmpRoot, 'core', 'async-response-data.json'), { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/latest/core/async-response-data.json', + title: 'Async Response Data', + description: 'Test async response union.', + anyOf: [ + { $ref: '/schemas/latest/media-buy/example-response.json' }, + { $ref: '/schemas/latest/media-buy/example-async-response-working.json' }, + ], + }); + + writeJson(path.join(tmpRoot, 'media-buy', 'example-response.json'), { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/latest/media-buy/example-response.json', + title: 'Example Response', + description: 'Completed response.', + type: 'object', + properties: { ok: { type: 'boolean' } }, + }); + + writeJson(path.join(tmpRoot, 'media-buy', 'example-async-response-working.json'), { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '/schemas/latest/media-buy/example-async-response-working.json', + title: 'Example Async Working', + description: 'Working response.', + type: 'object', + properties: { percentage: { type: 'number' } }, + }); + + const copied = copyAsyncResponseRefsToCore(tmpRoot); + assert.equal(copied, 1); + assert.ok(fs.existsSync(path.join(tmpRoot, 'core', 'async-response-refs', 'media-buy', 'example-async-response-working.json'))); + + const ajv = new Ajv({ allErrors: true, strict: false, discriminator: true, validateFormats: false }); + const target = path.join(tmpRoot, 'core', 'async-response-data.json'); + + for (const filePath of findJsonFiles(tmpRoot)) { + if (filePath === target) continue; + if (isOriginalAsyncResponse(tmpRoot, filePath)) continue; + const schema = JSON.parse(fs.readFileSync(filePath, 'utf8')); + ajv.addSchema(schema, schema.$id); + } + + assert.doesNotThrow(() => { + ajv.compile(JSON.parse(fs.readFileSync(target, 'utf8'))); + }); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +});