From d7aa72ad741eee06c27821f3409f227ba030c133 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 23:49:15 +0000 Subject: [PATCH 1/2] fix(packaging): stop shipping duplicate compliance and declaration bytes Trim @adcp/sdk from 26,862,556 to 24,993,401 packed bytes (8,529 to 8,176 files) without changing runtime behavior: - Exclude compliance/cache/*/domains/ from the package. Every bundle's domains/ is a byte-identical copy of protocols/, the only directory the storyboard loader reads (-286,556 bytes, -353 files). - Emit one-line ESM facades for the type-only generated declarations (types/core.generated, types/tools.generated, registry/types.generated), as types/schemas.generated already did, instead of copying 5.7 MB of declarations (-1,174,602 bytes). - Store the self-contained MCP tools/list request projections as compact JSON. They are only ever JSON.parse'd (-407,997 bytes). Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/package-size-headroom.md | 9 +++ package.json | 1 + scripts/copy-schemas-to-dist.ts | 14 +++++ scripts/generate-dmts-declarations.ts | 76 ++++++++++++++++++------- test/generate-dmts-declarations.test.js | 57 +++++++++++++++++++ 5 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 .changeset/package-size-headroom.md diff --git a/.changeset/package-size-headroom.md b/.changeset/package-size-headroom.md new file mode 100644 index 000000000..fdd683155 --- /dev/null +++ b/.changeset/package-size-headroom.md @@ -0,0 +1,9 @@ +--- +'@adcp/sdk': patch +--- + +Trim the published package from 26.86 MB to 24.99 MB packed (8,529 to 8,176 files). Runtime behavior is unchanged. + +- The compliance bundles no longer ship their `domains/` directories. Each was a byte-identical copy of `protocols/`, which is what the storyboard loader reads. If you read storyboards from `node_modules/@adcp/sdk/compliance/cache//domains//`, read `protocols//` instead. `index.json` keeps listing both arrays because it is the upstream bundle file. +- The ESM declarations for the generated protocol and registry types (`types/core.generated.d.mts`, `types/tools.generated.d.mts`, `registry/types.generated.d.mts`) now re-export the CommonJS declarations, as `types/schemas.generated.d.mts` already did, instead of shipping a second copy. ESM and CommonJS consumers get the same types. +- The MCP `tools/list` request projections under `dist/lib/schemas-data//mcp/` are stored as compact JSON. `tools/list` serves the parsed schemas, so the wire output is the same. diff --git a/package.json b/package.json index ac97bd902..05941a5b1 100644 --- a/package.json +++ b/package.json @@ -599,6 +599,7 @@ "compliance/cache/3.1.31/**/*", "compliance/cache/3.2.7/**/*", "compliance/cache/3.3.0-beta.3/**/*", + "!compliance/cache/*/domains/**/*", "ADCP_VERSION", "docs/README.md", "docs/TYPE-SUMMARY.md", diff --git a/scripts/copy-schemas-to-dist.ts b/scripts/copy-schemas-to-dist.ts index ea0e4bfe5..9411abe62 100644 --- a/scripts/copy-schemas-to-dist.ts +++ b/scripts/copy-schemas-to-dist.ts @@ -264,6 +264,19 @@ function stripMcpOutputSchemaReferences(schemaRoot: string): void { visit(mcpRoot); } +/** + * Re-serialize the retained MCP projections without indentation. Each request + * projection is self-contained (every `$ref` inlined), so indentation alone is + * about a quarter of their 26 MB and roughly 400 KB of the packed tarball. + * The loader only ever `JSON.parse`s these files, and `tools/list` serves the + * parsed object, so the bytes on the wire are unchanged. + */ +function compactMcpProjectionJson(schemaRoot: string): void { + for (const file of walkJsonFiles(path.join(schemaRoot, 'mcp'))) { + writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, 'utf8')))); + } +} + function walkJsonFiles(root: string): string[] { if (!existsSync(root)) return []; const files: string[] = []; @@ -404,6 +417,7 @@ function main(): void { stripMcpOutputSchemaReferences(destRoot); relaxAdagentsAuthorizedAgentsMinItems(destRoot); patchPrereleaseGetProductsTaskType(destRoot); + compactMcpProjectionJson(destRoot); writeCompactBundles(srcRoot, destRoot); const note = key === source.version ? '' : ` (key collapsed from ${source.version})`; console.log(`[copy-schemas-to-dist] copied ${srcRoot} → ${destRoot}${note}`); diff --git a/scripts/generate-dmts-declarations.ts b/scripts/generate-dmts-declarations.ts index 74f20d0b8..82ef94857 100644 --- a/scripts/generate-dmts-declarations.ts +++ b/scripts/generate-dmts-declarations.ts @@ -22,10 +22,11 @@ * `tsup.config.ts`: it normally copies each `.d.ts` to `.d.mts` and appends the * module extension to every relative `from`/`import(...)` specifier — `.mjs` * for a file, `/index.mjs` for a directory — resolving each against the built - * dist tree (the bytes that actually ship). The generated Zod declaration is - * the exception: its exact inferred type graph is tens of MiB, so its ESM file - * is a facade over the canonical CJS declaration instead of a byte-for-byte - * duplicate. + * dist tree (the bytes that actually ship). The multi-MiB generated + * declarations in {@link ESM_FACADE_DECLARATIONS} are the exception: their ESM + * file is a one-line facade over the canonical CJS declaration instead of a + * byte-for-byte duplicate, which would otherwise double their share of the + * published tarball. */ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; @@ -60,6 +61,52 @@ function wildcardOnlySlices(): Set { return new Set(Object.values(index.tools).map(t => path.join(DIST_LIB, 'types', `${path.basename(t.subpath)}.d.ts`))); } +// Generated declarations whose `.d.mts` is `export * from './.js';` +// rather than a copy. The Zod declaration is tens of MiB because TypeScript +// expands the inferred schema shapes; the protocol and registry type modules +// are 0.5-2.7 MiB of interfaces and type aliases each. Copying all four cost +// about 1.2 MB of the packed tarball. The type-only entries must stay free of +// value declarations and package imports, so ESM and CJS consumers resolve the +// exact same structural types through the facade. +const ESM_FACADE_DECLARATIONS: ReadonlyArray<{ file: string; typeOnly: boolean }> = [ + { file: 'types/schemas.generated.d.ts', typeOnly: false }, + { file: 'types/core.generated.d.ts', typeOnly: true }, + { file: 'types/tools.generated.d.ts', typeOnly: true }, + { file: 'registry/types.generated.d.ts', typeOnly: true }, +]; + +function hasDefaultExport(source: string): boolean { + return ( + /^\s*export\s+default\b/m.test(source) || + /^\s*export\s*=\s*/m.test(source) || + /\bexport\s*\{[^}]*\bdefault\b[^}]*\}/m.test(source) + ); +} + +// tsc emits top-level statements at column 0, so anchoring there skips +// interface members such as a schema's `enum?: string[]` and JSDoc text. +function hasValueOrPackageDependency(source: string): boolean { + return ( + /^(?:export\s+)?(?:declare\s+)?(?:abstract\s+)?(?:const|let|var|function|class|enum|namespace|module)\b/m.test( + source + ) || /^(?:import|export)\b[^;]*\bfrom\s*['"][^.'"][^'"]*['"]/m.test(source) + ); +} + +function writeEsmFacade(file: string, source: string, typeOnly: boolean): void { + const name = path.basename(file, '.d.ts'); + if (hasDefaultExport(source)) { + throw new Error(`${name}.d.ts has an export that the ESM facade would omit`); + } + if (typeOnly && hasValueOrPackageDependency(source)) { + throw new Error(`${name}.d.ts is no longer type-only; give it a full ESM declaration or review the facade`); + } + if (!existsSync(file.replace(/\.d\.ts$/, '.js'))) { + throw new Error(`${name}.js is missing; build runtime files before declarations`); + } + writeFileSync(file.replace(/\.d\.ts$/, '.d.mts'), `export * from './${name}.js';\n`); +} + // Resolve a relative specifier against the dist tree and return the ESM form. // A specifier that already carries a JS/JSON extension is normalised (`.js` → // `.mjs`); an extensionless one becomes `.mjs` for a file or @@ -95,26 +142,13 @@ function main(): void { const skip = wildcardOnlySlices(); const declarations = collectDeclarationFiles(DIST_LIB).filter(f => !skip.has(f)); - const generatedSchemasDeclaration = path.join(DIST_LIB, 'types', 'schemas.generated.d.ts'); + const facades = new Map(ESM_FACADE_DECLARATIONS.map(entry => [path.join(DIST_LIB, entry.file), entry.typeOnly])); let written = 0; for (const file of declarations) { const source = readFileSync(file, 'utf8'); - if (file === generatedSchemasDeclaration) { - // This declaration is tens of MiB because TypeScript expands the full - // inferred Zod shapes. Keep the exact CJS declaration as the source of - // truth and expose it through a tiny ESM declaration facade instead of - // copying the same type graph byte-for-byte. - const hasDefaultExport = - /^\s*export\s+default\b/m.test(source) || - /^\s*export\s*=\s*/m.test(source) || - /\bexport\s*\{[^}]*\bdefault\b[^}]*\}/m.test(source); - if (hasDefaultExport) { - throw new Error('schemas.generated.d.ts has an export that the ESM facade would omit'); - } - if (!existsSync(file.replace(/\.d\.ts$/, '.js'))) { - throw new Error('schemas.generated.js is missing; build runtime files before declarations'); - } - writeFileSync(file.replace(/\.d\.ts$/, '.d.mts'), `export * from './schemas.generated.js';\n`); + const typeOnly = facades.get(file); + if (typeOnly !== undefined) { + writeEsmFacade(file, source, typeOnly); written++; continue; } diff --git a/test/generate-dmts-declarations.test.js b/test/generate-dmts-declarations.test.js index 146e66c63..257bf8cbd 100644 --- a/test/generate-dmts-declarations.test.js +++ b/test/generate-dmts-declarations.test.js @@ -67,3 +67,60 @@ test('rejects schema exports that export-star would omit', () => { } } }); + +function addTypeOnlyDeclaration(root, relativePath, declaration) { + const file = path.join(root, 'dist', 'lib', relativePath); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, declaration); + writeFileSync(file.replace(/\.d\.ts$/, '.js'), '"use strict";\n'); +} + +test('uses ESM facades for the type-only generated protocol and registry declarations', () => { + const root = createFixture('export declare const ExampleSchema: { readonly exact: true };\n'); + try { + addTypeOnlyDeclaration( + root, + 'types/core.generated.d.ts', + 'export interface Product {\n enum?: string[];\n /** declare const in prose */\n module?: string;\n}\n' + ); + addTypeOnlyDeclaration( + root, + 'types/tools.generated.d.ts', + "import type { Product } from './core.generated';\nexport type GetProducts = { products: Product[] };\n" + ); + addTypeOnlyDeclaration(root, 'registry/types.generated.d.ts', 'export interface paths {\n}\n'); + const result = runGenerator(root); + assert.equal(result.status, 0, result.stderr); + for (const [relativePath, name] of [ + ['types/core.generated.d.mts', 'core.generated'], + ['types/tools.generated.d.mts', 'tools.generated'], + ['registry/types.generated.d.mts', 'types.generated'], + ]) { + assert.equal( + readFileSync(path.join(root, 'dist', 'lib', relativePath), 'utf8'), + `export * from './${name}.js';\n`, + relativePath + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('rejects a type-only facade declaration that gains values or package imports', () => { + for (const declaration of [ + 'export declare const ProductSchema: unknown;\n', + 'export declare enum Status {\n Active = "active"\n}\n', + "import type { z } from 'zod';\nexport type Product = z.infer;\n", + ]) { + const root = createFixture('export declare const ExampleSchema: { readonly exact: true };\n'); + try { + addTypeOnlyDeclaration(root, 'types/core.generated.d.ts', declaration); + const result = runGenerator(root); + assert.notEqual(result.status, 0, declaration); + assert.match(result.stderr, /core\.generated\.d\.ts is no longer type-only/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } +}); From 2a5db0c799eea3e0b5dc7ebe52a6c6da247b0961 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 23:49:23 +0000 Subject: [PATCH 2/2] ci(packaging): size budgets with headroom and a per-PR growth check The packed-byte and file-count budgets were pinned to the measurement, so two PRs that each added a file or a few KB passed alone and broke main together (#3510 + #3541, #3564 + #3559/#3560). Replace the pins with: - Ceilings with headroom in scripts/package-size-budget.json: @adcp/sdk 26,000,000 packed bytes / 135,000,000 unpacked / 8,450 files against a measured 24,993,401 / 129,047,083 / 8,176 (about 4%), and @adcp/reporting-storage 490,000 / 2,250,000 / 150. A warning fires when less than two PRs' allowance of headroom is left. - A per-PR growth check. main caches each commit's measurement; a pull request compares itself with the record for its exact merge base and may add 100 KB / 25 files to the SDK (15 KB / 8 files to the storage package). More needs the same PR to raise the ceiling, so the growth is reviewed in the diff. A missing record skips only this comparison. - The focused-client eager graph count becomes a ceiling (230, measured 216) instead of an exact pin; the named heavy-module exclusions stay. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 32 + package.json | 2 +- .../scripts/check-package.mjs | 65 +- scripts/check-package-size.mjs | 698 ++++++++---------- scripts/package-size-budget.json | 39 + test/check-package-size.test.js | 75 ++ test/lib/client-core-public-export.test.js | 15 +- 7 files changed, 497 insertions(+), 429 deletions(-) create mode 100644 scripts/package-size-budget.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6310a2f3a..576a37257 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,6 +261,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v5 + with: + # A pull request checks out its merge commit; depth 2 keeps the first + # parent (the merge base) so its package-size measurement can be found. + fetch-depth: 2 - name: Setup Node.js uses: actions/setup-node@v5 @@ -309,8 +313,36 @@ jobs: - name: Guard browser schema portability and tree-shaking budgets run: npm run test:schemas-browser + # main records each commit's package measurement. A pull request compares + # itself with the record for its exact merge base, so the per-PR growth + # allowance sees only this PR's change. A missing record (the base's main + # run has not finished, or the cache was evicted) skips that comparison; + # the ceilings in scripts/package-size-budget.json still apply. + - name: Resolve package-size merge base + id: size-base + if: github.event_name == 'pull_request' + run: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" + + - name: Restore merge-base package measurement + if: github.event_name == 'pull_request' + uses: actions/cache/restore@v4 + with: + path: .context/package-size.json + key: package-size-${{ runner.os }}-${{ steps.size-base.outputs.sha }} + - name: Enforce publish-size budgets run: npm run check:package-size + env: + PACKAGE_SIZE_BASE_SHA: ${{ steps.size-base.outputs.sha }} + PACKAGE_SIZE_BASELINE: ${{ github.event_name == 'pull_request' && '.context/package-size.json' || '' }} + PACKAGE_SIZE_REPORT: ${{ github.event_name == 'pull_request' && '.context/package-size-head.json' || '.context/package-size.json' }} + + - name: Record package measurement for pull requests + if: github.event_name == 'push' + uses: actions/cache/save@v4 + with: + path: .context/package-size.json + key: package-size-${{ runner.os }}-${{ github.sha }} - name: Typecheck reporting storage package run: npm run typecheck:reporting-storage diff --git a/package.json b/package.json index 05941a5b1..de1f4ec19 100644 --- a/package.json +++ b/package.json @@ -707,7 +707,7 @@ "check:package": "npm run check:runtime-compat && npm run check:packed-artifact && npm run check:publish-protocol-artifacts && npm run typecheck:examples && publint --strict && node --max-old-space-size=8192 ./node_modules/@arethetypeswrong/cli/dist/index.js --pack . --exclude-entrypoints compliance-fixtures/reporting-consumer-status-v1.json compliance-fixtures/principal.yaml compliance-fixtures/reporting-core.yaml compliance-fixtures/canonical-storyboards-provenance.json && npm run check --workspace=@adcp/reporting-storage && node --max-old-space-size=8192 ./node_modules/@arethetypeswrong/cli/dist/index.js --pack packages/reporting-storage", "check:packed-artifact": "node scripts/check-packed-artifact.mjs && node scripts/check-reporting-consumer-package.mjs && node scripts/check-reporting-storage-package.mjs", "check:runtime-compat": "node scripts/check-runtime-compat.mjs", - "check:package-size": "node scripts/check-package-size.mjs && node packages/reporting-storage/scripts/check-package.mjs --size", + "check:package-size": "node scripts/check-package-size.mjs", "check:publish-protocol-artifacts": "node scripts/check-publish-protocol-artifacts.mjs", "verify:package": "node scripts/verify-package.mjs", "validate:reporting-gcp": "node packages/reporting-storage/scripts/validate-reporting-gcp.mjs", diff --git a/packages/reporting-storage/scripts/check-package.mjs b/packages/reporting-storage/scripts/check-package.mjs index aa3756986..35500887c 100644 --- a/packages/reporting-storage/scripts/check-package.mjs +++ b/packages/reporting-storage/scripts/check-package.mjs @@ -3,7 +3,7 @@ * Offline checks for the `@adcp/reporting-storage` tarball. Requires a prior `npm run build`. * * node scripts/check-package.mjs export map, packed contents and budgets - * node scripts/check-package.mjs --size the budgets only (the root `check:package-size` runs this) + * node scripts/check-package.mjs --size the size ceilings only (the root `check:package-size` checks them too) * * The package is built like the SDK (one transpiled module per source module, CommonJS and ESM with both * declaration flavours), so the checks mirror the SDK's: every export target is packed, nothing from `src/`, @@ -19,16 +19,17 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ' // The one example that ships: the integrated production-service walkthrough the guides link to. const PUBLISHED_EXAMPLE = 'examples/reliable-reporting-service/README.md'; -// Measured at 0.0.0: about 408,000 packed bytes, 1,883,000 unpacked, 115 files (28 modules x 4 flavours, the -// manifest, the README and the example). A new module adds four files. Raise a budget deliberately, in the -// change that grows the package. -const MAX_PACKED_BYTES = 450_000; -const MAX_UNPACKED_BYTES = 2_200_000; -// +13 at 0.3: postgresStorage, the versioned migrations and the CLI module (three modules x 4) and -// bin/adcp-reporting-storage.js. -const MAX_FILE_COUNT = 140; +// Size ceilings and the per-PR growth allowance live with the SDK's in the repository's +// scripts/package-size-budget.json, so the root `check:package-size` compares both packages with their merge +// base in one place. A new module adds four files (two runtime formats, two declaration flavours). +const BUDGET_FILE = path.resolve(packageRoot, '..', '..', 'scripts', 'package-size-budget.json'); +export const PACKAGE_NAME = '@adcp/reporting-storage'; -function pack() { +function ceiling() { + return JSON.parse(readFileSync(BUDGET_FILE, 'utf8')).packages[PACKAGE_NAME].ceiling; +} + +export function packReportingStorage() { const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts', '--loglevel=error'], { cwd: packageRoot, encoding: 'utf8', @@ -46,15 +47,15 @@ function pack() { throw new Error('npm pack did not return package metadata'); } -function checkBudgets(info) { +export function checkReportingStorageBudgets(info) { + const limit = ceiling(); const problems = []; - if (info.size > MAX_PACKED_BYTES) - problems.push(`packed tarball is ${info.size} bytes; budget is ${MAX_PACKED_BYTES}`); - if (info.unpackedSize > MAX_UNPACKED_BYTES) { - problems.push(`unpacked package is ${info.unpackedSize} bytes; budget is ${MAX_UNPACKED_BYTES}`); + if (info.size > limit.packedBytes) + problems.push(`packed tarball is ${info.size} bytes; ceiling is ${limit.packedBytes}`); + if (info.unpackedSize > limit.unpackedBytes) { + problems.push(`unpacked package is ${info.unpackedSize} bytes; ceiling is ${limit.unpackedBytes}`); } - if (info.entryCount > MAX_FILE_COUNT) - problems.push(`package has ${info.entryCount} files; budget is ${MAX_FILE_COUNT}`); + if (info.entryCount > limit.files) problems.push(`package has ${info.entryCount} files; ceiling is ${limit.files}`); return problems; } @@ -95,17 +96,23 @@ function checkContents(info) { return problems; } -try { - const info = pack(); - const problems = [...checkBudgets(info), ...(process.argv.includes('--size') ? [] : checkContents(info))]; - if (problems.length > 0) { - for (const problem of problems) console.error(` - ${problem}`); - throw new Error(`${problems.length} package problem(s)`); +const scriptPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined; +if (scriptPath === fileURLToPath(import.meta.url)) { + try { + const info = packReportingStorage(); + const problems = [ + ...checkReportingStorageBudgets(info), + ...(process.argv.includes('--size') ? [] : checkContents(info)), + ]; + if (problems.length > 0) { + for (const problem of problems) console.error(` - ${problem}`); + throw new Error(`${problems.length} package problem(s)`); + } + console.log( + `✅ ${PACKAGE_NAME}: ${info.size} bytes packed, ${info.unpackedSize} unpacked, ${info.entryCount} files` + ); + } catch (error) { + console.error(`❌ ${PACKAGE_NAME} package check failed: ${error.message ?? error}`); + process.exitCode = 1; } - console.log( - `✅ @adcp/reporting-storage: ${info.size} bytes packed, ${info.unpackedSize} unpacked, ${info.entryCount} files` - ); -} catch (error) { - console.error(`❌ @adcp/reporting-storage package check failed: ${error.message ?? error}`); - process.exitCode = 1; } diff --git a/scripts/check-package-size.mjs b/scripts/check-package-size.mjs index 05bb1a9f4..be11734e4 100644 --- a/scripts/check-package-size.mjs +++ b/scripts/check-package-size.mjs @@ -1,364 +1,70 @@ #!/usr/bin/env node /** - * Fast, offline publish-size audit. + * Fast, offline publish-size audit for `@adcp/sdk` and `@adcp/reporting-storage`. + * Requires a prior `npm run build:lib`. * - * Requires a prior `npm run build:lib`. Unlike the full clean-room package - * smoke, this runs on every library build so any packaged source or generated - * artifact can trip the budgets. + * Budget policy. The numbers live in `scripts/package-size-budget.json`: + * + * 1. Ceilings with headroom. Each package has a packed-byte, unpacked-byte and + * file-count ceiling set at a measured size plus deliberate headroom (about + * 4% for the SDK). The old ceilings were pinned to the measurement, so two + * PRs that each added a file or a few KB passed alone and broke `main` + * together (#3510 + #3541, #3564 + #3559). A warning fires once less than + * two PRs' worth of headroom is left, so the ceiling gets raised, or the + * package trimmed, on purpose before parallel PRs can collide on it. + * 2. Per-PR growth. In CI a pull request is compared with the measurement that + * `main` recorded for its exact merge base (`--baseline`). Growing a package + * by more than its `perPullRequest` allowance fails unless the same PR raises + * that ceiling, which puts the decision in the reviewed diff. Without a + * baseline (local runs, or a base commit whose `main` run has not finished) + * only the ceilings apply. + * 3. Structural checks that never move: no source maps, no schema-sync + * snapshots, no expanded bundled schemas, no legacy compliance `domains/` + * alias, the required schema and compliance roots, and one-line ESM facades + * for the large generated declarations. + * + * Usage: + * node scripts/check-package-size.mjs [--baseline ] [--report ] + * `PACKAGE_SIZE_BASELINE`, `PACKAGE_SIZE_REPORT` and `PACKAGE_SIZE_BASE_SHA` + * (the commit the baseline must describe) are the CI equivalents. */ import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertPublishProtocolArtifacts } from './check-publish-protocol-artifacts.mjs'; -// Compact local-ref schema bundles and omission of source maps bring the -// package below pnpm's default fetch-timeout boundary on moderate links. Keep -// enough compressor variance for supported Node/npm versions without allowing -// the old 48 MB artifact shape to return. The packed limit is decimal because -// npm reports published package size in MB and issue #2579 set a 20 MB target. -// Canonical agent resolution adds three dual-format module/declaration sets -// and one migration guide (+13 files). The measured tarball is 20,004,504 -// bytes; allow 25 KB above the existing 20 MB target for these artifacts and -// compressor variance. Restoring age-provenance declarations adds about 5 KB -// compressed (20,022,298 bytes on main versus 20,027,281 with the fix), without -// adding published files. Allow that growth while retaining the same margin; -// schema-sync skill snapshots are excluded and all other budgets stay fixed. -// Bounded validation diagnostics add two internal module/declaration sets and -// enlarge the shared dispatcher bundles. The measured tarball is 20,032,303 -// bytes; allow 10 KB for this change and compressor variance. -// AgentOAuthClientCredentialsSchema publishes auth/oauth/client-credentials-schema -// as CJS + ESM + both declaration flavours (four artifacts, 3,354 unpacked bytes). -// The measured tarball is 20,040,986 bytes; allow 5 KB for these artifacts and -// compressor variance. File-count, unpacked, and schema budgets stay fixed. -// The optional GCS write fence adds three dual-format module/declaration sets -// and one guide. CI measured 20,060,226 compressed bytes; preserve 10 KB of -// compressor margin for that additive surface. Other size budgets stay fixed. -// The complete GCS adapter/private-reader surface adds six internal module sets -// and a guide; the first measured combined artifact is 20,079,662 bytes. -// Allow 35 KB above that candidate for final documentation and compressor variance. -// Operation resolution over A2A (GHSA-frxv-c96c-4vqw) adds the shared -// signing/operation-resolution module, the A2A gate in the adapter, and the -// grader's A2A vector module (about 42 KB compressed on top of the GCS surface). The measured combined -// tarball is 20,121,361 bytes; allow 14 KB for compressor variance. -// AdCP 3.2.3 adds 33 canonical A2A operation-resolution vectors. The measured -// tarball is about 20,146,000 bytes; preserve 14 KB of compressor margin for this -// signed compliance corpus. Unpacked, schema, and declaration budgets stay fixed. -// Reliable Reporting row storage (paged exact reads, the row-storage migration -// and the verified PostgreSQL chunk engine) adds three internal dual-format -// module sets. The measured paged-read artifact is 20,160,752 bytes; allow 40 KB -// for the following row-storage modules and compressor variance. -// Object-storage row bindings add the provider port, key rendering, filesystem -// provider and conformance runner; the measured artifact is 20,205,763 bytes. -// Allow 55 KB for row retention, host reads and the cloud object providers. -// Shared discovery and per-call MCP context add two dual-format module sets -// and an adoption guide. With the reporting host/cloud modules on main, the -// measured combined artifact is 20,287,031 bytes; allow 23 KB for compressor -// variance. Unpacked, schema and declaration budgets remain unchanged. -// The BigQuery warehouse sink completes Stage 1 reporting row storage; the -// measured combined artifact is 20,313,297 bytes. Allow 22 KB for compressor -// variance and the operations runbook. -// Core-only ledger maintenance, the not-found error mapping and the 15.7.0 -// changelog measure 20,335,129 bytes packed; allow 25 KB for compressor variance. -// Snapshot compaction and its key-template guard measure 20,360,760 bytes packed; -// allow 25 KB for compressor variance. -// Canonical creative selector projection, private seller-origin cache evidence, -// and its delivery guide measure 20,364,177 bytes in the combined CI artifact -// on both Node floors. Preserve the existing 25 KB compressor margin; unpacked, -// file-count, bundled-schema, and declaration budgets stay fixed. -// AdCP 3.2.4 replaces the primary signed compliance corpus and adds 22 -// request-signing vectors. Its unreadable-revision validators and health -// projection measured 20,403,623 bytes in CI before the managed-delivery router. -// The router adds one dual-format module set. The combined signed 3.2.4 -// artifact measures 20,419,639 bytes locally; preserve the existing 25 KB -// compressor margin, rounded up to the next 10 KB. Other byte budgets stay fixed. -// Reporting row-storage field alignment (AdCP names, the source-timezone period date, -// provider stat and the migration guide) measured 20,450,821 bytes in CI on both Node -// floors. Allow 30 KB above that for compressor variance; other byte budgets stay fixed. -// Experimental identity 3.3 adds ten runtime/declaration module sets and a -// CLI command. The measured tarball is 20,579,706 bytes; allow 40 KB for -// documentation and compressor variance. Other byte budgets stay fixed. -// Account sync contracts and keyed persistence add four dual-format runtime/ -// declaration module sets and a packaged storyboard. CI measured 20,623,608 -// bytes before packaging the storyboard; preserve about 25 KB of compressor -// margin for this additive surface. All other byte budgets stay fixed. -// The identity 3.3 follow-ups (#3249, #3250, #3252, #3258: layered Web Bot -// Auth verification, interop support, trust and grant semantics) measure -// 20,660,761 bytes for the full stack; allow about 40 KB for compressor -// variance. Other byte budgets stay fixed. -// The orphaned row-object collector and provider `list` (four providers, the -// conformance check, maintenance wiring and the operations runbook) measure -// 20,702,804 bytes packed; allow about 37 KB for compressor variance. Other byte -// budgets stay fixed. -// The Reliable Reporting storage split moved the PostgreSQL store, row storage, managed delivery, notification -// activity and the cloud providers (24 modules, 96 files) into @adcp/reporting-storage, which has its own budget in -// packages/reporting-storage/scripts/check-package.mjs. The SDK keeps three new modules (receipts, offering -// validation, scheduler support) and gains a migration guide. The measured tarball is 20,318,824 bytes; allow -// about 40 KB for compressor variance. -// Replacing both compliance corpora with signed AdCP 3.2.5 / 3.1.29 on the -// current main base measures 20,365,246 bytes packed and 111,595,107 unpacked -// across 6,556 files. Preserve about 40 KB of compressor margin. The previous -// corpora are replaced; no duplicate compliance roots or archives are packaged. -// The property-list reference probe adds one internal dual-format runtime/ -// declaration module set (+4 files) and expands runner evidence handling. -// The measured tarball is 20,422,982 bytes; allow about 27 KB for compressor -// variance. Unpacked, schema, and declaration byte budgets stay fixed. -// With the creative build probe, the combined beta.8 package measures -// 20,439,714 bytes across 6,578 files. This fits the existing byte cap; -// retain both probes' additive file allowances and every other guard. -// The 4.0 compatibility validation and refreshed registry declarations measure -// 20,452,069 bytes packed in CI. Allow about 28 KB of compressor variance for -// this additive surface; all unpacked, file-count, schema and declaration -// budgets stay fixed. -// The collection fixture foundation adds four published files — the -// `PreparedFixturesDeniedError` surface and the grown `testing/storyboard` -// modules, whose reviewed rationale for the authority binding and the -// typed-denial contract ships as JSDoc in the `.d.ts`. Measured on the -// merged tree at 20,483,097 bytes across 6,394 files. Allow about 36 KB of -// compressor variance; unpacked, file-count, schema and declaration -// budgets stay fixed. -// Cross-release translation (adcp-client#3370) adds the version-negotiation, -// schema-projection and translation modules, the 3.1 adapters and the -// account-reduction helpers. Measured in CI at 20,533,600 bytes on the rebased -// tree; allow about 40 KB of compressor variance and the follow-up adapter -// module set. Unpacked, file-count, schema and declaration budgets stay fixed. -// MCP principal partitions add four runtime/declaration artifacts and explicit -// native-task routing/signing context declarations. CI measures 20,581,610 -// packed bytes; retain about 43 KB of compressor variance for this additive -// surface. All unpacked, file-count, schema and declaration limits stay fixed. -// SDK 17 retains stable 3.2.7 alongside the signed 3.3 beta bundle. -// Measured: 25,702,691 packed bytes, 146,739,833 unpacked bytes and 8,293 -// files, including a new 774,320-byte bundled schema archive. These caps -// preserve small release-note/compression margins and the archive-only policy. -// AdCP 3.3.0-beta.1 replaces the beta.0 schema and compliance bundles (no -// duplicate preview root is packaged) and adds the request_account, -// media-buy issue and reporting-subscription schema families, their generated -// types and typed client methods. Measured locally after the signed sync: -// 26,314,876 packed bytes, 144,462,916 unpacked bytes and 8,411 files. Allow -// about 45 KB of compressor variance; the unpacked, file-count, bundled-schema -// and declaration budgets are unchanged and still pass. -// AdCP 3.3 reporting subscriptions (#3504) add the subscription calendar, -// selection, coverage, capability and engine modules plus the principal -// subscription runtime. Measured 26,423,860 packed bytes after merging main; -// allow about 40 KB above that for compressor variance. -// The buyer runtime, engine store and expectations (#3504) measured 26,470,249 -// packed bytes after merging main's 3.3.0-beta.2 adoption; allow about 40 KB. -// Writing subscription obligations into the reporting ledger (#3504) extends the -// ledger producer, coverage, handlers and status ingest, and then the service, -// engine and managed delivery, and adds no module file. Allow about 55 KB above -// the runtime's budget for them. Other budgets stay fixed. -// AdCP 3.3 reporting subscription coverage (#3504) adds the calendar, -// selection, coverage and capability modules and the parity table. Measured -// 26,370,041 packed bytes; allow about 40 KB above that for compressor variance. -// AdCP 3.3.0-beta.2 (#3541) and the creative-delivery legacy-ref -// reconciliation (#3510) landed together. CI measured 26,363,856 packed bytes -// after #3510 and 26,369,089 after #3541 on main, about 9 KB over the beta.1 -// cap. Allow about 50 KB of compressor variance; the unpacked, file-count, -// bundled-schema and declaration budgets are unchanged and still pass. -// The reporting-subscription DX slices (#3504: the advanced subpath, wind-down grant, typed errors with -// their TSDoc, and the canonical-vector fixes) plus main through #3510 measured 26,520,756 packed bytes; -// allow about 40 KB above that for compressor variance. Other budgets stay fixed. -// The managed seller path, grant journal and buyer write facade (#3504, T5-T7) measured 26,566,153 -// packed bytes; allow about 44 KB above that for compressor variance. Other budgets stay fixed. -// The storage facade, the accounts.ensure outcome and the reporting-subscription guides, README section and -// skill (#3504, T8-T10) measured 26,613,272 packed bytes; allow about 27 KB above that. Other budgets stay fixed. -// AdCP 3.3.0-beta.3 (#3504, T1) replaces the beta.2 schemas and compliance corpus with larger -// ones (the composed protocol envelope on every response, live creative updates, the -// reporting_subscription_probe controller scenario) and regenerates the types. Measured -// 26,825,751 packed bytes; allow about 44 KB above that. Other budgets stay fixed. -// The beta.3 engine slice (#3560) and the creative package-fit decisions (#3564) landed together on -// main: 26,862,555 packed bytes. Allow about 45 KB above that, which also covers the release PR's -// CHANGELOG growth. -const MAX_PACKED_TARBALL_BYTES = 26_910_000; -const MAX_UNPACKED_PACKAGE_BYTES = 144 * 1024 * 1024; -// AdCP 3.2.0-beta.10 adds the account-change schema family. The seller reporting -// ledger adds ten public module artifacts. AdCP 3.2.0-rc.2 replaced the preview -// schemas with its generated task surface and current compliance artifacts; -// superseded rc.1 fixtures are excluded from the published package. Its -// sync_reporting_status tool slice adds four publishable runtime/declaration artifacts. -// Its adapter-safe identity helpers add four module artifacts, and the portable -// cross-SDK consumer-status vector adds one published JSON artifact. -// Account-feed adoption adds five runtime/declaration module sets (20 files) -// and one migration guide. Byte budgets remain unchanged. -// The standalone proposal verifier adds twelve helper module artifacts and -// one buyer guide; byte budgets remain unchanged. -// AdCP 3.2.0-rc.3 publishes sixteen new source schemas — the scoped and -// media-buy frequency-cap families plus `core/media-buy-available-action-id.json` -// and the shared frequency-cap duration/control/mutable-field enums — and two -// new compliance storyboards. With their bundled and compact-projection copies -// that is +26 published files. The request-only Targeting Input helpers add one -// module (CJS + ESM + both declaration flavours), for +4 more. -// The 14.x adoption pass adds an existing-platform guide, a release worksheet, -// and a compile-gated thin integration example; byte budgets remain unchanged. -// The targeting-input migration follow-up adds one compile-gated provider -// adapter example; byte budgets remain unchanged. -// Canonical principal/reporting-core distribution adds two YAML files, one -// provenance manifest, and one dual-format module/declaration set (+7 files). -// The rc.3 buyer-side consumer-status loop adds one module, -// `reporting/content-mismatch` (CJS + ESM + both declaration flavours), for +4. -// Its reconciliation changes are edits to existing modules, and the unpacked -// total is unchanged against the 120 MiB ceiling. -// Unified action assessment adds nine module sets (36 artifacts) and one guide. -// Superseded preview compliance and historical compatibility test schemas are -// not published. Retiring the v3.1 beta type bundle removes eight artifacts; -// its current legacy-view wholesale type replacement adds four, for 6,107 -// published files. Byte budgets stay fixed. -// Reliable Reporting Core adds three module sets (CJS + ESM + both declaration -// flavours) and one packaged setup guide, bringing the clean package to 6,120 -// published files. Byte budgets stay fixed. -// Transactional reporting notification activity adds one module set (CJS + ESM -// + both declaration flavours), for 6,124 published files. Managed Delivery and -// Reconciled Billing add two public dual-format runtime/declaration module sets -// — `reporting/ledger/managed` and `reporting/ledger/managed-postgres`, each CJS -// + ESM + both declaration flavours — for +8 files and 6,132 total. -// The durable buyer-writes adoption example adds two TypeScript files and one -// packaged guide, bringing the clean package to 6,135 published files. The -// remaining changes edit existing modules; byte budgets stay fixed. -// Supply-path verification adds 32 runtime/declaration artifacts and its guide -// and packaged example (34 files total); byte and schema budgets stay unchanged. -// Native A2A compliance routing adds one internal dual-format module with both -// declaration flavours (+4 files); byte and schema budgets stay unchanged. -// AdCP 3.2.0-rc.4 adds frequency-cap storyboards, error-recovery/reporting-summary/ -// supply-path vectors, verification-token claims, and their packaged skill copies. -// After compact-schema packaging and existing filters, the publish set grows by 10 files. -// Official-client A2A request-signing dispatch adds one internal dual-format -// module with both declaration flavours (+4 files). -// Targeting-overlay conformance adds one public dual-format runtime/declaration -// module set (+4 files); byte and schema budgets stay unchanged. -// Core-only buyer reconciliation adds one public dual-format -// runtime/declaration module set (+4 files); byte budgets stay fixed. -// Principal lifecycle helpers add two dual-format runtime/declaration module -// sets and one packaged guide (+9 files); byte budgets stay fixed. -// Durable server-side principal state adds four dual-format -// runtime/declaration module sets (+16 files); byte budgets stay fixed. -// Wholesale-feed webhook registration adds one public dual-format -// runtime/declaration module set (+4 files); byte budgets stay fixed. -// The compile-gated wholesale-feed mirror quickstart adds one packaged file. -// Acceptance-policy catalog resolution adds one public dual-format -// runtime/declaration module set and one packaged buyer guide (+5 files). -// The focused client entrypoint and cross-format response-schema cache add two -// dual-format runtime/declaration module sets (+8 files); byte budgets stay fixed. -// Acceptance-policy assessment adds one public dual-format runtime/declaration -// module set (+4 files); byte and schema budgets stay fixed. -// Acceptance-policy storyboard verification adds one internal dual-format -// runtime/declaration module set (+4 files); byte and schema budgets stay fixed. -// AdCP 3.2.0-rc.6 replaces the rc.4 schema/compliance bundle and adds eleven -// publishable schema, storyboard, and packaged-skill artifacts in aggregate. -// Calendar-day source boundaries add the internal reporting/ledger/schedule module: -// schedule.js, schedule.mjs, schedule.d.ts, and schedule.d.mts (+4 files). -// Reliable Reporting adds three dual-format runtime/declaration modules (+12) -// and one packaged operations guide (+1); byte budgets remain unchanged. -// The focused buyer entrypoint adds four dual-format runtime/declaration files, -// and its published existing-app worker and guide add two more (+6). -// Public account resolution adds one dual-format runtime/declaration module (+4). -// AdCP 3.2.1 GA replaces the rc.7 bundle: 27 request-signing profile vectors, -// six media-buy storyboards, the hosted-grader test kit, and five new schemas -// (execution requirements, outcome-target cost-per, reporting delivery offering id) (+39). -// Signed-request rejection diagnostics add one internal dual-format module -// with both declaration flavours (+4 files); byte budgets stay fixed. -// Buyer provisioning and discovery authorization add five runtime/declaration -// module sets (+20 files) and one first-call guide (+1); byte budgets stay fixed. -// Opt-in strict account references add one internal dual-format -// runtime/declaration module for once-per-process deprecation warnings -// (+4 files); byte budgets stay fixed. -// The 14.0-to-14.1 upgrade checklist adds one packaged Markdown guide (+1); -// every byte budget stays fixed. -// Storage-independent wholesale mirrors add one module set and one buyer guide (+5 files). -// Shared JSONPath translation and bounded validation diagnostics add two -// internal module/declaration sets (+8 files). -// Operation resolution over A2A and the grader's A2A vector module add two -// internal dual-format runtime/declaration module sets (+8 files). -const MAX_PACKED_FILE_COUNT = - 4 + // Shared MCP schema validator (CJS, ESM, and both declaration formats). - 6_135 + - 34 + - 4 + - 10 + - 4 + - 4 + - 4 + - 9 + - 16 + - 4 + - 1 + - 5 + - 8 + - 4 + - 4 + - 11 + - 4 + - 13 + - 6 + - 4 + - 39 + - 4 + - 21 + - 4 + - 13 + - 1 + - 5 + - 8 + - 8 + - 13 + // GCS write-fence runtime/declaration modules and adoption guide. - 29 + // GCS adapter, reader, validation, fence split, errors, I/O modules and guide. - 33 + // AdCP 3.2.3 canonical A2A vectors; the measured package contains 6,457 files. - 8 + // Reporting row encoding and revision-metadata helper modules (two dual-format sets). - 4 + // Reporting row-storage migration module (one dual-format set). - 4 + // Verified reporting row-storage engine (one dual-format set). - 8 + // Reporting row-storage errors and object-provider modules (two dual-format sets). - 4 + // Reporting host change-feed module (one dual-format set). - 8 + // GCS row-object provider and shared cloud-provider I/O modules (two dual-format sets). - 9 + // Shared discovery and per-call MCP context modules (two dual-format sets) and adoption guide. - 4 + // `reporting/s3` row-object provider subpath (one dual-format set). - 4 + // `reporting/azure` row-object provider subpath (one dual-format set). - 4 + // `reporting/bigquery` warehouse sink subpath (one dual-format set). - 8 + // Pending OAuth flow serialization and store conformance (two dual-format sets). - 8 + // Browser schemas entry and shared get-products validator (two dual-format runtime/declaration sets). - 4 + // Local creative-schema reference inspection (one dual-format runtime/declaration set). - 4 + // Managed-delivery adapter router (`reporting/ledger/managed-router`, one dual-format set). - 1 + // Reporting row-storage field-alignment migration guide. - 22 + // AdCP 3.2.4 signed request-signing compliance vectors; no new runtime module sets. - 1 + // Agent identity origin/resolver migration guide. - 41 + // Experimental identity 3.3: ten dual-format module sets and one CLI file. - 17 + // Account sync/keyed-store modules (four dual-format sets) and example storyboard. - 1 + // Reliable Reporting stability policy guide. - 4 + // Identity 3.3 generated-type bridge (`identity-3.3/generated-compat`, one dual-format set). - 4 + // Shared consent-macro recognition (one dual-format runtime/declaration set). - 4 + // Reporting page-size helper (`reporting/pagination`, one dual-format runtime/declaration set). - 12 + // Reporting receipts, offering validation and scheduler support modules (three dual-format sets). - 1 - // Reliable Reporting storage migration guide. - 96 - // 24 storage modules (96 files) now ship in @adcp/reporting-storage. - 1 + // The integrated reporting-service example README now ships with @adcp/reporting-storage. - 2 + // The maintained bundle replacement adds the hosted-grader kit and compact macro-bearing URL schema. - 13 + // Property-list identifier, server, and certification modules (three dual-format sets) and adoption guide. - 4 + // Property-list reference probe (one dual-format runtime/declaration module set). - 5 + // Creative build probe: one dual-format runtime/declaration module (+4) and one guide (+1). - 8 + // Discovery currency and required-metric invariants (two dual-format runtime/declaration sets). - 1840 + // SDK 17: 3.3 schemas/compliance, per-tool type entrypoints and migration/release notes. - 20 + // Reporting subscription calendar, selection, coverage, capabilities and index (five dual-format sets). - 8 + // Reporting subscription engine and principal subscription runtime (two dual-format sets). - 4 + // Reporting subscription release gate (one dual-format set). - 12 + // Reporting subscription engine store, buyer expectations and client helper (three dual-format sets). - 2 + // Reporting subscriptions example storyboard and reference seller. - 8 + // `reporting/advanced` subpath and the service's subscription-hook module (two dual-format sets). - 4 + // Managed reporting-subscription block for the Reliable Reporting service (one dual-format set). - 4 + // Buyer reporting-subscription write facade, client.reporting (one dual-format set). - 8 + // `storage` subpath: memoryStorage() and its in-memory ledger (two dual-format sets). - 4 + // Account outcomes for client.accounts.ensure: AccountOutcome, nextStep, formatPendingAction (one dual-format set). - 3 + // Reporting-subscription seller and buyer guides, and the reporting-subscriptions skill. - // AdCP 3.3.0-beta.3: eleven new source schemas (task-id, task-message and the task-status envelopes; live - // creative updates) and the new compliance storyboards and controller-scenario files that ship with them. - 21 + // AdCP 3.3.0-beta.3 adoption (#3559). - 1; // Creative package-fit guide (#3564), merged in parallel with #3559's exact count. -const MAX_BUNDLED_SCHEMA_BYTES = 2000 * 1024; +export const SDK_PACKAGE = '@adcp/sdk'; +const BUDGET_FILE = 'scripts/package-size-budget.json'; +const MEASUREMENT_SCHEMA = 1; +/** Metrics a ceiling covers, and the subset a single PR is held to. */ +const CEILING_METRICS = ['packedBytes', 'unpackedBytes', 'files']; +const GROWTH_METRICS = ['packedBytes', 'files']; + +// Compact local-ref archives replaced the expanded bundled schemas (tens of +// MiB). The ceiling leaves room for a protocol-version swap while still +// failing loudly if expanded bundles return. +const MAX_BUNDLED_SCHEMA_BYTES = 2_200_000; const MAX_CJS_SCHEMA_DECLARATION_BYTES = 45 * 1024 * 1024; -const MAX_ESM_SCHEMA_FACADE_BYTES = 1024; -const EXPECTED_ESM_SCHEMA_FACADE = "export * from './schemas.generated.js';\n"; +// Generated declarations whose `.d.mts` is a one-line facade over the CJS +// declaration (see scripts/generate-dmts-declarations.ts). Copying them instead +// would add about 1.2 MB to the packed tarball. +const ESM_FACADE_DECLARATIONS = [ + 'dist/lib/types/schemas.generated', + 'dist/lib/types/core.generated', + 'dist/lib/types/tools.generated', + 'dist/lib/registry/types.generated', +]; +const MAX_ESM_FACADE_BYTES = 1024; function mib(bytes) { return (bytes / 1024 / 1024).toFixed(1); } +function formatCount(value) { + return value.toLocaleString('en-US'); +} + function assertAtMost(label, actual, limit, unit = 'bytes') { if (!Number.isFinite(actual)) { throw new Error(`${label} is missing or is not a finite number`); @@ -369,53 +75,148 @@ function assertAtMost(label, actual, limit, unit = 'bytes') { } export function parseNpmPackOutput(output) { - // npm 10 can print prepare-hook output (including ANSI color sequences) - // before --json output even with --ignore-scripts. Try each array opener in - // order and accept only a complete top-level JSON array. for (let start = output.indexOf('['); start !== -1; start = output.indexOf('[', start + 1)) { try { const parsed = JSON.parse(output.slice(start).trim()); if (Array.isArray(parsed)) return parsed; } catch { - // Keep scanning past banners such as ESC[32m or non-JSON hook output. + // Skip prepare-hook banners and keep looking for npm's JSON array. } } throw new Error('npm pack did not return a valid JSON array'); } -export function checkPackageSize(repoRoot) { - const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts', '--loglevel=error'], { - cwd: repoRoot, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'inherit'], - // npm includes one metadata entry per packed file in its JSON response. - // Keep this above the package's own file-count budget so the audit can - // report a useful size failure instead of terminating with ENOBUFS. - maxBuffer: 64 * 1024 * 1024, - }); - const [packageInfo] = parseNpmPackOutput(output); - if (!packageInfo || !Array.isArray(packageInfo.files)) { - throw new Error('npm pack did not return package file metadata'); +export function readBudget(repoRoot) { + const budget = JSON.parse(readFileSync(path.join(repoRoot, BUDGET_FILE), 'utf8')); + for (const [name, entry] of Object.entries(budget.packages ?? {})) { + for (const metric of CEILING_METRICS) { + if (!Number.isFinite(entry.ceiling?.[metric])) + throw new Error(`${BUDGET_FILE}: ${name} has no ceiling.${metric}`); + } + for (const metric of GROWTH_METRICS) { + if (!Number.isFinite(entry.perPullRequest?.[metric])) { + throw new Error(`${BUDGET_FILE}: ${name} has no perPullRequest.${metric}`); + } + } } + if (!budget.packages?.[SDK_PACKAGE]) throw new Error(`${BUDGET_FILE} has no ${SDK_PACKAGE} entry`); + return budget; +} + +/** The size metrics of one `npm pack --json` entry. */ +export function measurePack(packageInfo) { for (const field of ['size', 'unpackedSize', 'entryCount']) { - if (!Number.isFinite(packageInfo[field])) { + if (!Number.isFinite(packageInfo?.[field])) { throw new Error(`npm pack returned an invalid ${field}`); } } + return { packedBytes: packageInfo.size, unpackedBytes: packageInfo.unpackedSize, files: packageInfo.entryCount }; +} - if (packageInfo.size > MAX_PACKED_TARBALL_BYTES) { - throw new Error( - `packed tarball is ${packageInfo.size} bytes (${mib(packageInfo.size)} MiB); ` + - `budget is ${MAX_PACKED_TARBALL_BYTES} bytes (${mib(MAX_PACKED_TARBALL_BYTES)} MiB)` - ); +const METRIC_LABELS = { packedBytes: 'packed bytes', unpackedBytes: 'unpacked bytes', files: 'files' }; + +/** Ceiling failures, plus warnings when fewer than two PRs' worth of headroom is left. */ +export function checkCeilings(name, measured, entry) { + const failures = []; + const warnings = []; + for (const metric of CEILING_METRICS) { + const limit = entry.ceiling[metric]; + if (measured[metric] > limit) { + failures.push( + `${name} ${METRIC_LABELS[metric]}: ${formatCount(measured[metric])} is over the ceiling of ` + + `${formatCount(limit)}. Trim the package, or raise the ceiling in ${BUDGET_FILE} and explain why in the PR.` + ); + continue; + } + const allowance = entry.perPullRequest[metric]; + if (allowance !== undefined && limit - measured[metric] < 2 * allowance) { + warnings.push( + `${name} ${METRIC_LABELS[metric]}: ${formatCount(limit - measured[metric])} of headroom left under the ` + + `${formatCount(limit)} ceiling, less than two PRs' allowance. Trim or raise the ceiling soon so ` + + 'parallel PRs do not collide.' + ); + } } - if (packageInfo.unpackedSize > MAX_UNPACKED_PACKAGE_BYTES) { - throw new Error( - `unpacked package is ${mib(packageInfo.unpackedSize)} MiB; budget is ${mib(MAX_UNPACKED_PACKAGE_BYTES)} MiB` + return { failures, warnings }; +} + +/** + * Compare a measurement with the one recorded for the merge base. Growth past + * the per-PR allowance fails unless this change raised the matching ceiling. + */ +export function compareWithBaseline(current, baseline, budget) { + const failures = []; + const notes = []; + const rows = []; + for (const [name, entry] of Object.entries(budget.packages)) { + const now = current.packages[name]; + const before = baseline.packages?.[name]; + if (!now) continue; + if (!before) { + notes.push(`${name}: the merge base has no measurement, so per-PR growth is not checked.`); + continue; + } + for (const metric of CEILING_METRICS) { + if (!Number.isFinite(before[metric])) continue; + const delta = now[metric] - before[metric]; + const allowance = GROWTH_METRICS.includes(metric) ? entry.perPullRequest[metric] : undefined; + rows.push({ name, metric, before: before[metric], now: now[metric], delta, allowance }); + if (allowance === undefined || delta <= allowance) continue; + const previousCeiling = before.ceiling?.[metric]; + if (Number.isFinite(previousCeiling) && entry.ceiling[metric] > previousCeiling) { + notes.push( + `${name} grows by ${formatCount(delta)} ${METRIC_LABELS[metric]} (allowance ${formatCount(allowance)}); ` + + `accepted because this change raises the ceiling from ${formatCount(previousCeiling)} to ` + + `${formatCount(entry.ceiling[metric])}.` + ); + continue; + } + failures.push( + `${name} grows by ${formatCount(delta)} ${METRIC_LABELS[metric]} against the merge base; one PR may add ` + + `${formatCount(allowance)}. Trim what does not need to ship, or, if the growth is intended, raise ` + + `packages["${name}"].ceiling.${metric} in ${BUDGET_FILE} in this PR so the growth is reviewed.` + ); + } + } + return { failures, notes, rows }; +} + +function signed(value) { + return `${value > 0 ? '+' : ''}${formatCount(value)}`; +} + +export function formatGrowthSummary(rows, baseCommit) { + const lines = [ + `### Package size against the merge base${baseCommit ? ` (${baseCommit.slice(0, 12)})` : ''}`, + '', + '| Package | Metric | Merge base | This change | Delta | Per-PR allowance |', + '| --- | --- | ---: | ---: | ---: | ---: |', + ]; + for (const row of rows) { + lines.push( + `| ${row.name} | ${METRIC_LABELS[row.metric]} | ${formatCount(row.before)} | ${formatCount(row.now)} | ` + + `${signed(row.delta)} | ${row.allowance === undefined ? 'n/a' : formatCount(row.allowance)} |` ); } - assertAtMost('packed file count', packageInfo.entryCount, MAX_PACKED_FILE_COUNT, 'files'); + return `${lines.join('\n')}\n`; +} +function packDryRun(cwd) { + const output = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts', '--loglevel=error'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + maxBuffer: 64 * 1024 * 1024, + }); + const [packageInfo] = parseNpmPackOutput(output); + if (!packageInfo || !Array.isArray(packageInfo.files)) { + throw new Error('npm pack did not return package file metadata'); + } + return packageInfo; +} + +/** Structural invariants of the SDK tarball. These do not have headroom. */ +function checkSdkContents(repoRoot, packageInfo) { const sourceMaps = packageInfo.files.filter(file => file.path.endsWith('.map')); if (sourceMaps.length > 0) { throw new Error(`packed package contains ${sourceMaps.length} source map files`); @@ -432,6 +233,24 @@ export function checkPackageSize(repoRoot) { if (rawBundledSchemas.length > 0) { throw new Error(`packed package contains ${rawBundledSchemas.length} expanded bundled schema files`); } + // Upstream compliance bundles still carry `domains/`, the legacy name of + // `protocols/`, as a byte-identical copy. The storyboard loader reads only + // `protocols/`, so package.json excludes the copy. + const complianceAliases = packageInfo.files.filter(file => /^compliance\/cache\/[^/]+\/domains\//.test(file.path)); + if (complianceAliases.length > 0) { + throw new Error( + `packed package contains ${complianceAliases.length} files under the legacy compliance domains/ alias ` + + '(it duplicates protocols/)' + ); + } + const complianceRoots = new Set( + packageInfo.files.map(file => /^compliance\/cache\/([^/]+)\//.exec(file.path)?.[1]).filter(Boolean) + ); + for (const root of complianceRoots) { + if (!packageInfo.files.some(file => file.path.startsWith(`compliance/cache/${root}/protocols/`))) { + throw new Error(`packed compliance bundle ${root} has no protocols/ storyboards`); + } + } const bundledSchemaBytes = packageInfo.files .filter(file => file.path.endsWith('/bundled.schemas.br')) .reduce((sum, file) => sum + file.size, 0); @@ -441,10 +260,10 @@ export function checkPackageSize(repoRoot) { ? currentProtocolVersion : currentProtocolVersion.split('.').slice(0, 2).join('.'); const expectedBundleKeys = new Set(['3.0', '3.1', currentBundleKey]); - const packedPaths = new Set(packageInfo.files.map(file => file.path)); + const packed = new Map(packageInfo.files.map(file => [file.path, file])); for (const bundleKey of expectedBundleKeys) { const expectedArchivePath = `dist/lib/schemas-data/${bundleKey}/bundled.schemas.br`; - if (!packedPaths.has(expectedArchivePath)) { + if (!packed.has(expectedArchivePath)) { throw new Error(`packed package is missing a required bundled schema archive: ${expectedArchivePath}`); } } @@ -458,10 +277,8 @@ export function checkPackageSize(repoRoot) { const compatibleVersions = [...compatibleBlock.matchAll(/'([^']+)'/g)].map(match => match[1]); assertPublishProtocolArtifacts({ packageInfo, manifest, currentProtocolVersion, compatibleVersions }); - const cjsSchema = packageInfo.files.find(file => file.path === 'dist/lib/types/schemas.generated.d.ts'); - const esmSchema = packageInfo.files.find(file => file.path === 'dist/lib/types/schemas.generated.d.mts'); + const cjsSchema = packed.get('dist/lib/types/schemas.generated.d.ts'); if (!cjsSchema) throw new Error('packed package is missing schemas.generated.d.ts'); - if (!esmSchema) throw new Error('packed package is missing schemas.generated.d.mts'); if (!Number.isFinite(cjsSchema.size)) { throw new Error('npm pack returned an invalid size for schemas.generated.d.ts'); } @@ -471,28 +288,129 @@ export function checkPackageSize(repoRoot) { `budget is ${mib(MAX_CJS_SCHEMA_DECLARATION_BYTES)} MiB` ); } - assertAtMost('schemas.generated.d.mts', esmSchema.size, MAX_ESM_SCHEMA_FACADE_BYTES); - - const facadePath = path.join(repoRoot, 'dist', 'lib', 'types', 'schemas.generated.d.mts'); - if (readFileSync(facadePath, 'utf8') !== EXPECTED_ESM_SCHEMA_FACADE) { - throw new Error('schemas.generated.d.mts is not the expected exact ESM facade'); + for (const stem of ESM_FACADE_DECLARATIONS) { + const name = path.basename(stem); + const esm = packed.get(`${stem}.d.mts`); + if (!packed.has(`${stem}.d.ts`)) throw new Error(`packed package is missing ${name}.d.ts`); + if (!esm) throw new Error(`packed package is missing ${name}.d.mts`); + assertAtMost(`${name}.d.mts`, esm.size, MAX_ESM_FACADE_BYTES); + if (readFileSync(path.join(repoRoot, `${stem}.d.mts`), 'utf8') !== `export * from './${name}.js';\n`) { + throw new Error(`${name}.d.mts is not the expected one-line ESM facade`); + } } + return { bundledSchemaBytes, cjsSchemaBytes: cjsSchema.size }; +} +/** + * Pack the SDK, enforce its ceilings and structural checks, and return the + * `npm pack` metadata. `verify-package.mjs` calls this before its clean-room + * install. + */ +export function checkPackageSize(repoRoot) { + const budget = readBudget(repoRoot); + const packageInfo = packDryRun(repoRoot); + const measured = measurePack(packageInfo); + const { failures, warnings } = checkCeilings(SDK_PACKAGE, measured, budget.packages[SDK_PACKAGE]); + if (failures.length > 0) throw new Error(failures.join('\n')); + for (const warning of warnings) annotate('warning', warning); + const { bundledSchemaBytes, cjsSchemaBytes } = checkSdkContents(repoRoot, packageInfo); console.log( - `✅ Package size: ${mib(packageInfo.size)} MiB packed, ${mib(packageInfo.unpackedSize)} MiB unpacked, ` + - `${packageInfo.entryCount} files; bundled schemas ${mib(bundledSchemaBytes)} MiB; ` + - `schema declarations ${mib(cjsSchema.size)} MiB CJS + ${esmSchema.size} B ESM` + `✅ Package size: ${formatCount(measured.packedBytes)} bytes packed (${mib(measured.packedBytes)} MiB), ` + + `${mib(measured.unpackedBytes)} MiB unpacked, ${formatCount(measured.files)} files; ` + + `bundled schemas ${mib(bundledSchemaBytes)} MiB; schema declarations ${mib(cjsSchemaBytes)} MiB CJS` ); return packageInfo; } +function annotate(level, message) { + if (process.env.GITHUB_ACTIONS === 'true') console.log(`::${level}::${message}`); + else console.log(`${level === 'warning' ? '⚠️ ' : 'ℹ️ '} ${message}`); +} + +function argValue(flag, envName) { + const index = process.argv.indexOf(flag); + if (index !== -1) { + const value = process.argv[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`${flag} needs a file path`); + return value; + } + return process.env[envName] || undefined; +} + +function currentCommit(repoRoot) { + if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA; + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +function loadBaseline(file, expectedCommit) { + if (!file || !existsSync(file)) { + return { skip: file ? `no merge-base measurement at ${file}` : undefined }; + } + const baseline = JSON.parse(readFileSync(file, 'utf8')); + if (baseline.schema !== MEASUREMENT_SCHEMA) { + return { skip: `merge-base measurement uses schema ${baseline.schema}, expected ${MEASUREMENT_SCHEMA}` }; + } + if (expectedCommit && baseline.commit !== expectedCommit) { + return { skip: `merge-base measurement describes ${baseline.commit}, not ${expectedCommit}` }; + } + return { baseline }; +} + +async function main(repoRoot) { + const budget = readBudget(repoRoot); + const failures = []; + const measurement = { schema: MEASUREMENT_SCHEMA, commit: currentCommit(repoRoot), packages: {} }; + + const sdkInfo = checkPackageSize(repoRoot); + measurement.packages[SDK_PACKAGE] = { ...measurePack(sdkInfo), ceiling: budget.packages[SDK_PACKAGE].ceiling }; + + const storage = await import('../packages/reporting-storage/scripts/check-package.mjs'); + const storageEntry = budget.packages[storage.PACKAGE_NAME]; + if (storageEntry) { + const storageMeasured = measurePack(storage.packReportingStorage()); + const result = checkCeilings(storage.PACKAGE_NAME, storageMeasured, storageEntry); + failures.push(...result.failures); + for (const warning of result.warnings) annotate('warning', warning); + measurement.packages[storage.PACKAGE_NAME] = { ...storageMeasured, ceiling: storageEntry.ceiling }; + if (result.failures.length === 0) { + console.log( + `✅ ${storage.PACKAGE_NAME}: ${formatCount(storageMeasured.packedBytes)} bytes packed, ` + + `${formatCount(storageMeasured.unpackedBytes)} unpacked, ${storageMeasured.files} files` + ); + } + } + + const reportFile = argValue('--report', 'PACKAGE_SIZE_REPORT'); + if (reportFile) { + mkdirSync(path.dirname(path.resolve(reportFile)), { recursive: true }); + writeFileSync(reportFile, `${JSON.stringify(measurement, null, 2)}\n`); + } + + const baseCommit = process.env.PACKAGE_SIZE_BASE_SHA || undefined; + const { baseline, skip } = loadBaseline(argValue('--baseline', 'PACKAGE_SIZE_BASELINE'), baseCommit); + if (skip) { + annotate('notice', `Per-PR package growth not checked: ${skip}. The ceilings still apply.`); + } else if (baseline) { + const growth = compareWithBaseline(measurement, baseline, budget); + failures.push(...growth.failures); + for (const note of growth.notes) annotate('notice', note); + const summary = formatGrowthSummary(growth.rows, baseline.commit); + console.log(summary); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + } + + if (failures.length > 0) throw new Error(failures.join('\n')); +} + const scriptPath = process.argv[1] ? path.resolve(process.argv[1]) : undefined; if (scriptPath === fileURLToPath(import.meta.url)) { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - try { - checkPackageSize(repoRoot); - } catch (error) { + main(repoRoot).catch(error => { console.error(`❌ Package size check failed: ${error.message ?? error}`); process.exitCode = 1; - } + }); } diff --git a/scripts/package-size-budget.json b/scripts/package-size-budget.json new file mode 100644 index 000000000..32da9f673 --- /dev/null +++ b/scripts/package-size-budget.json @@ -0,0 +1,39 @@ +{ + "$comment": "Publish-size budgets, enforced by scripts/check-package-size.mjs (policy in its header). A ceiling is the measured size plus deliberate headroom, so parallel PRs do not collide on main; perPullRequest caps what one PR may add against its merge base. A PR that needs more raises the ceiling here, which reviewers then see in the diff.", + "packages": { + "@adcp/sdk": { + "ceiling": { + "packedBytes": 26000000, + "unpackedBytes": 135000000, + "files": 8450 + }, + "perPullRequest": { + "packedBytes": 100000, + "files": 25 + }, + "measuredWhenSet": { + "packedBytes": 24993401, + "unpackedBytes": 129047083, + "files": 8176, + "at": "17.0.0-beta.11 after the package-size trim" + } + }, + "@adcp/reporting-storage": { + "ceiling": { + "packedBytes": 490000, + "unpackedBytes": 2250000, + "files": 150 + }, + "perPullRequest": { + "packedBytes": 15000, + "files": 8 + }, + "measuredWhenSet": { + "packedBytes": 447064, + "unpackedBytes": 2039835, + "files": 129, + "at": "@adcp/reporting-storage 0.3.0-beta.2" + } + } + } +} diff --git a/test/check-package-size.test.js b/test/check-package-size.test.js index 2dc6b6a60..eecdd06a3 100644 --- a/test/check-package-size.test.js +++ b/test/check-package-size.test.js @@ -20,3 +20,78 @@ test('fails closed when npm pack returns no JSON array', async () => { const { parseNpmPackOutput } = await modulePromise; assert.throws(() => parseNpmPackOutput('\x1b[31mpack failed\x1b[0m\n'), /valid JSON array/); }); + +const budget = { + packages: { + '@adcp/sdk': { + ceiling: { packedBytes: 1_000_000, unpackedBytes: 5_000_000, files: 500 }, + perPullRequest: { packedBytes: 10_000, files: 10 }, + }, + }, +}; + +function measurement(packedBytes, files, ceiling = budget.packages['@adcp/sdk'].ceiling) { + return { + schema: 1, + commit: 'base', + packages: { '@adcp/sdk': { packedBytes, unpackedBytes: packedBytes * 5, files, ceiling } }, + }; +} + +test('ceilings leave headroom and warn once fewer than two PRs fit', async () => { + const { checkCeilings } = await modulePromise; + const entry = budget.packages['@adcp/sdk']; + assert.deepEqual(checkCeilings('@adcp/sdk', { packedBytes: 900_000, unpackedBytes: 1, files: 400 }, entry), { + failures: [], + warnings: [], + }); + const tight = checkCeilings('@adcp/sdk', { packedBytes: 985_000, unpackedBytes: 1, files: 485 }, entry); + assert.equal(tight.failures.length, 0); + assert.equal(tight.warnings.length, 2); + const over = checkCeilings('@adcp/sdk', { packedBytes: 1_000_001, unpackedBytes: 1, files: 501 }, entry); + assert.equal(over.failures.length, 2); + assert.match(over.failures[0], /over the ceiling of 1,000,000/); +}); + +test('per-PR growth within the allowance passes', async () => { + const { compareWithBaseline } = await modulePromise; + const result = compareWithBaseline(measurement(910_000, 410), measurement(900_000, 400), budget); + assert.deepEqual(result.failures, []); + assert.equal(result.rows.find(row => row.metric === 'packedBytes').delta, 10_000); +}); + +test('per-PR growth past the allowance fails unless the PR raises the ceiling', async () => { + const { compareWithBaseline } = await modulePromise; + const grown = compareWithBaseline(measurement(950_000, 420), measurement(900_000, 400), budget); + assert.equal(grown.failures.length, 2); + assert.match(grown.failures[0], /grows by 50,000 packed bytes against the merge base; one PR may add 10,000/); + assert.match(grown.failures[1], /ceiling\.files/); + + const lowerCeiling = { packedBytes: 990_000, unpackedBytes: 5_000_000, files: 480 }; + const acknowledged = compareWithBaseline(measurement(950_000, 420), measurement(900_000, 400, lowerCeiling), budget); + assert.deepEqual(acknowledged.failures, []); + assert.equal(acknowledged.notes.length, 2); + assert.match(acknowledged.notes[0], /raises the ceiling from 990,000 to 1,000,000/); +}); + +test('a package missing from the merge-base measurement is not growth-checked', async () => { + const { compareWithBaseline, formatGrowthSummary } = await modulePromise; + const result = compareWithBaseline(measurement(950_000, 420), { schema: 1, packages: {} }, budget); + assert.deepEqual(result.failures, []); + assert.match(result.notes[0], /merge base has no measurement/); + assert.match(formatGrowthSummary([], 'abc'), /merge base \(abc\)/); +}); + +test('the committed budget keeps headroom above its recorded measurement', async () => { + const { readBudget } = await modulePromise; + const committed = readBudget(path.resolve(__dirname, '..')); + for (const [name, entry] of Object.entries(committed.packages)) { + for (const metric of ['packedBytes', 'files']) { + const headroom = entry.ceiling[metric] - entry.measuredWhenSet[metric]; + assert.ok( + headroom >= 2 * entry.perPullRequest[metric], + `${name} ${metric}: a ceiling needs at least two PRs of headroom when it is set` + ); + } + } +}); diff --git a/test/lib/client-core-public-export.test.js b/test/lib/client-core-public-export.test.js index 1888612bc..2cf590ec9 100644 --- a/test/lib/client-core-public-export.test.js +++ b/test/lib/client-core-public-export.test.js @@ -153,15 +153,12 @@ test('@adcp/sdk/client/core keeps heavy generated-schema surfaces out of its eag } } - // #3461 adds only the public delivery-parser module through the existing - // webhooks barrel; all of its transitive dependencies were already present. - // #3530 adds one internal shared MCP validator module. No generated schema - // or registry module is added to the eager graph. - // #3504 adds the reporting-subscription write facade (client.reporting and the raw sync_principal - // guard); offering selection inside it loads on demand. - // #3504 (T9) adds the account-outcomes module behind client.accounts.ensure (nextStep, formatPendingAction). - // Schema compaction stays lazy. Keep heavy schema/registry exclusions intact. - assert.ok(visited.size <= 216, `focused client eager graph grew to ${visited.size} internal modules`); + // The exclusions below guard the heavy surfaces by name. The count is a + // ceiling with headroom, not a pin: it was 216 when set, so features that + // each add a module or two to the focused client do not collide when they + // merge in parallel, while an accidental barrel import still fails it. When + // a change crosses it, check what joined the graph before raising it. + assert.ok(visited.size <= 230, `focused client eager graph grew to ${visited.size} internal modules`); for (const excluded of [ 'types/schemas.generated.mjs', 'utils/response-schemas.mjs',