diff --git a/.changeset/auto-derive-compat-versions.md b/.changeset/auto-derive-compat-versions.md new file mode 100644 index 000000000..f2355daa9 --- /dev/null +++ b/.changeset/auto-derive-compat-versions.md @@ -0,0 +1,36 @@ +--- +'@adcp/sdk': patch +--- + +fix(version): auto-derive COMPATIBLE_ADCP_VERSIONS from ADCP_VERSION pin + +The 3.0.x patch enumeration in `COMPATIBLE_ADCP_VERSIONS` was a hardcoded +array literal inside the `scripts/sync-version.ts` template. Every AdCP +patch bump needed someone to remember to append the new version to it. +The 3.0.9, 3.0.10, and 3.0.11 chore PRs all forgot — the list capped +at `3.0.8` even though `ADCP_VERSION` moved to `3.0.11`. Symptom: +`isCompatibleWith('3.0.11') === false` against the SDK's own pin. + +Same root-cause class as the schema URL pinning drift surfaced by +`adcontextprotocol/adcp#4419` (BidMachine reports / "3.0.1 schemas" cited +against a 3.0.11 seller): a load-bearing version surface that depends on +human discipline at every patch bump. + +Fix: + +- `scripts/sync-version.ts` now derives the list dynamically from the + current `ADCP_VERSION`. Enumerates `3.0.0..3.0.` mechanically; + the bumper no longer has to remember anything. +- Fails closed when `ADCP_VERSION` falls outside the `3.0.x` range so a + future 3.1.x or 4.x bump forces the script to be extended (rather than + silently inheriting a stale enumeration). The compat surface for a + major/minor move is rarely mechanical — that's the right time to think. +- Adds `test/lib/compatible-versions-self-consistency.test.js` asserting + the regenerated list contains the current pin and fills the + `3.0.0..ADCP_VERSION` range without gaps. Future regressions (someone + reverting to hardcoded literals) fail loud at CI. + +Does not address the deeper schema URL pinning drift in +`src/lib/testing/storyboard/validations.ts` (SDK-build-time `ADCP_VERSION` +used in cited schema URLs regardless of the agent's advertised version); +tracked separately at adcp-client#NNNN. diff --git a/scripts/sync-version.ts b/scripts/sync-version.ts index 9bec52b9c..98792a337 100644 --- a/scripts/sync-version.ts +++ b/scripts/sync-version.ts @@ -73,10 +73,82 @@ function assertSafeVersion(value: string, source: string): void { } } +// Pre-3.0 / pre-stable version names. Kept as a separate constant so the +// 3.0.x patch enumeration below stays mechanical. Adding a future +// major/minor (3.1.x, 4.0.0-beta.1, etc.) requires updating this list AND +// the major/minor gate in `buildCompatibleVersions` — failing closed there +// is intentional, so a spec move forces a human to think about which +// historical versions stay in the compat surface. +const COMPATIBLE_PREFIX = ['v2.5', 'v2.6', 'v3', '3.0.0-beta.1', '3.0.0-beta.3'] as const; + +/** + * Build the `COMPATIBLE_ADCP_VERSIONS` list dynamically from the current + * `ADCP_VERSION`. The bumper PR doesn't have to remember to append a new + * version literal — the script enumerates `3.0.0..3.0.` automatically. + * + * Fails loudly when the version isn't in the `3.0.x` range so a 3.1.x or + * 4.x bump can't silently inherit a stale enumeration: the human bumper has + * to extend this script to define the new range. That's the right behavior — + * a major/minor move likely also moves the compat surface in a non-mechanical + * way (e.g. dropping older 3.0.x once 3.1 is stable). + * + * Background: adcontextprotocol/adcp-client schema URL pinning drift. The + * 3.0.9 / 3.0.10 / 3.0.11 chore PRs all forgot to manually extend the + * `COMPATIBLE_ADCP_VERSIONS` array literal that previously lived inline in + * the template, so the compat surface capped at `3.0.8` across multiple + * patch bumps. Auto-deriving eliminates that drift class. + */ +// Sanity bound on the patch enumeration. The AdCP spec patch cadence is +// roughly weekly; even at 10× speed we won't see 3.0.500 in the lifetime +// of the 3.0.x series. A defensive cap turns a hostile or fat-fingered +// `ADCP_VERSION = '3.0.999999999'` into a clean build failure instead of +// a silent ~10⁹-string OOM during enumeration. +const MAX_PATCH_ENUMERATION = 500; + +function buildCompatibleVersions(adcpVersion: string): string[] { + // Reject prerelease pins (e.g. `3.0.11-rc.1`) — enumerating + // `3.0.0..3.0.11` from a prerelease pin over-claims GA stability for + // the unreleased patch. The bumper for a prerelease should land the + // GA pin separately when it stabilizes. + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(adcpVersion); + if (!match) { + console.error( + `❌ ADCP_VERSION ${JSON.stringify(adcpVersion)} does not match the expected ` + + `major.minor.patch shape (no prerelease suffix). Update scripts/sync-version.ts ` + + `to define compat semantics if you intend to pin a prerelease.` + ); + process.exit(1); + } + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (major !== 3 || minor !== 0) { + console.error( + `❌ ADCP_VERSION ${JSON.stringify(adcpVersion)} is outside the 3.0.x range this ` + + `script enumerates. Extend buildCompatibleVersions in scripts/sync-version.ts ` + + `to cover the new major/minor range, then re-run npm run sync-version.` + ); + process.exit(1); + } + if (patch > MAX_PATCH_ENUMERATION) { + console.error( + `❌ ADCP_VERSION ${JSON.stringify(adcpVersion)} exceeds MAX_PATCH_ENUMERATION ` + + `(${MAX_PATCH_ENUMERATION}). If the spec has genuinely produced this many patches, ` + + `raise the constant in scripts/sync-version.ts deliberately.` + ); + process.exit(1); + } + const range3_0_x: string[] = []; + for (let p = 0; p <= patch; p++) range3_0_x.push(`3.0.${p}`); + return [...COMPATIBLE_PREFIX, ...range3_0_x]; +} + // Generate version.ts file with library and AdCP versions function generateVersionFile(libraryVersion: string, adcpVersion: string): void { assertSafeVersion(libraryVersion, 'package.json version'); assertSafeVersion(adcpVersion, 'ADCP_VERSION'); + const compatibleVersions = buildCompatibleVersions(adcpVersion); + const compatibleVersionsLiteral = compatibleVersions.map(v => ` '${v}',`).join('\n'); const versionFilePath = path.join(__dirname, '../src/lib/version.ts'); const versionContent = `// Generated version information // This file is auto-generated by sync-version.ts @@ -99,23 +171,14 @@ export const ADCP_VERSION = '${adcpVersion}'; export const ADCP_MAJOR_VERSION = 3; /** - * AdCP versions this library maintains backward compatibility with + * AdCP versions this library maintains backward compatibility with. + * + * Auto-derived from \`ADCP_VERSION\` by scripts/sync-version.ts — every + * \`3.0.0\` through the current pin is enumerated. Do not edit this list + * by hand; bumping the AdCP pin via \`npm run sync-version\` extends it. */ export const COMPATIBLE_ADCP_VERSIONS = [ - 'v2.5', - 'v2.6', - 'v3', - '3.0.0-beta.1', - '3.0.0-beta.3', - '3.0.0', - '3.0.1', - '3.0.2', - '3.0.3', - '3.0.4', - '3.0.5', - '3.0.6', - '3.0.7', - '3.0.8', +${compatibleVersionsLiteral} ] as const; /** diff --git a/src/lib/version.ts b/src/lib/version.ts index c6ff50c19..3a46c80c7 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -4,7 +4,7 @@ /** * AdCP SDK library version */ -export const LIBRARY_VERSION = '6.19.1'; +export const LIBRARY_VERSION = '7.0.0'; /** * AdCP specification version this library is built for @@ -19,7 +19,11 @@ export const ADCP_VERSION = '3.0.11'; export const ADCP_MAJOR_VERSION = 3; /** - * AdCP versions this library maintains backward compatibility with + * AdCP versions this library maintains backward compatibility with. + * + * Auto-derived from `ADCP_VERSION` by scripts/sync-version.ts — every + * `3.0.0` through the current pin is enumerated. Do not edit this list + * by hand; bumping the AdCP pin via `npm run sync-version` extends it. */ export const COMPATIBLE_ADCP_VERSIONS = [ 'v2.5', @@ -36,6 +40,9 @@ export const COMPATIBLE_ADCP_VERSIONS = [ '3.0.6', '3.0.7', '3.0.8', + '3.0.9', + '3.0.10', + '3.0.11', ] as const; /** @@ -52,10 +59,10 @@ export type AdcpVersion = (typeof COMPATIBLE_ADCP_VERSIONS)[number]; * Full version information */ export const VERSION_INFO = { - library: '6.19.1', + library: '7.0.0', adcp: '3.0.11', compatibleVersions: COMPATIBLE_ADCP_VERSIONS, - generatedAt: '2026-05-11T14:19:39.941Z', + generatedAt: '2026-05-12T01:00:50.163Z', } as const; /** diff --git a/test/lib/compatible-versions-self-consistency.test.js b/test/lib/compatible-versions-self-consistency.test.js new file mode 100644 index 000000000..5bbe753a3 --- /dev/null +++ b/test/lib/compatible-versions-self-consistency.test.js @@ -0,0 +1,64 @@ +/** + * Self-consistency check: the auto-derived COMPATIBLE_ADCP_VERSIONS list + * (scripts/sync-version.ts) MUST contain the current ADCP_VERSION pin. + * + * Background: the 3.0.9 / 3.0.10 / 3.0.11 chore PRs forgot to manually + * append the new patch to the hardcoded array literal that previously + * lived in the version.ts template — capping the compat surface at + * 3.0.8 even though ADCP_VERSION moved to 3.0.11. Tied to the + * SDK-build-time schema URL pinning drift surfaced by + * adcontextprotocol/adcp#4419. The script is now auto-derived; this + * test locks the invariant so a future regression (e.g. someone + * reverting the auto-derive to the old hardcoded shape) fails loud at + * CI rather than slipping out as schema URL drift on the next release. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { ADCP_VERSION, COMPATIBLE_ADCP_VERSIONS, isCompatibleWith } = require('../../dist/lib/version.js'); + +describe('COMPATIBLE_ADCP_VERSIONS self-consistency', () => { + test('includes the current ADCP_VERSION pin', () => { + assert.ok( + COMPATIBLE_ADCP_VERSIONS.includes(ADCP_VERSION), + `ADCP_VERSION=${ADCP_VERSION} is not present in COMPATIBLE_ADCP_VERSIONS=` + + `[${COMPATIBLE_ADCP_VERSIONS.join(', ')}]. ` + + `Run \`npm run sync-version\` to regenerate src/lib/version.ts so the ` + + `compat list extends through the current pin.` + ); + }); + + test('isCompatibleWith returns true for the current ADCP_VERSION', () => { + assert.equal(isCompatibleWith(ADCP_VERSION), true); + }); + + test('enumerates every 3.0.x patch from .0 up through the current pin', () => { + const match = /^3\.0\.(\d+)$/.exec(ADCP_VERSION); + if (!match) return; // pre-release or future major/minor — separate gate + const patch = Number(match[1]); + for (let p = 0; p <= patch; p++) { + const v = `3.0.${p}`; + assert.ok( + COMPATIBLE_ADCP_VERSIONS.includes(v), + `Expected ${v} in COMPATIBLE_ADCP_VERSIONS — auto-derivation should fill the ` + + `3.0.0..${ADCP_VERSION} range without gaps.` + ); + } + }); + + test('preserves the pre-3.0 legacy aliases (COMPATIBLE_PREFIX)', () => { + // Belt-and-suspenders against a regression that drops legacy entries + // while keeping 3.0.x intact (e.g. a future "clean up old aliases" PR + // that loses them silently). Catches that class without coupling to + // the exact prefix membership — adopters that pinned `v2.5` / + // `v2.6` / `v3` legacy aliases must keep matching. + for (const legacy of ['v2.5', 'v2.6', 'v3', '3.0.0-beta.1', '3.0.0-beta.3']) { + assert.ok( + COMPATIBLE_ADCP_VERSIONS.includes(legacy), + `Legacy alias ${legacy} dropped from COMPATIBLE_ADCP_VERSIONS — would break ` + + `adopters who pinned the alias as their adcpVersion option.` + ); + } + }); +});