From 15d0dc1ed975fe11cc1954bb9c7d507f1bf9b7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 03:52:29 +0000 Subject: [PATCH 1/2] build: sourcesContent:false across the tsup fleet; sourcemap-no-sources-content gate Set sourcesContent:false once (scripts/tsup-drop-sources-content.mjs, wired into every tsup.config.ts via tsup's esbuildOptions hook) so published .js.map files no longer embed the complete original source text. mappings are untouched -- stack-trace positions still resolve correctly. Add pnpm check:sourcemap-no-sources-content, a closure-wide gate over dist/**/*.map with a positive-control fixture, wired into ci.yml's Build Core job beside check:dual-build-cjs-loads. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- .changeset/sourcemap-drop-sources-content.md | 30 ++ .github/workflows/ci.yml | 9 + package.json | 1 + packages/apps/account/tsup.config.ts | 3 + packages/apps/setup/tsup.config.ts | 3 + packages/apps/studio/tsup.config.ts | 3 + packages/core/tsup.config.ts | 3 + packages/lint/tsup.config.ts | 3 + packages/metadata-core/tsup.config.ts | 3 + packages/metadata-fs/tsup.config.ts | 3 + packages/metadata-protocol/tsup.config.ts | 3 + packages/metadata/tsup.config.ts | 3 + packages/objectql/tsup.config.ts | 3 + packages/platform-objects/tsup.config.ts | 3 + packages/plugins/plugin-auth/tsup.config.ts | 3 + .../plugins/plugin-webhooks/tsup.config.ts | 3 + packages/runtime/tsup.config.ts | 3 + .../service-cluster-redis/tsup.config.ts | 3 + .../services/service-cluster/tsup.config.ts | 3 + .../service-datasource/tsup.config.ts | 3 + packages/spec/tsup.config.ts | 4 + packages/types/tsup.config.ts | 3 + .../check-sourcemap-no-sources-content.mjs | 506 ++++++++++++++++++ scripts/tsup-drop-sources-content.mjs | 45 ++ tsup.config.ts | 3 + 25 files changed, 652 insertions(+) create mode 100644 .changeset/sourcemap-drop-sources-content.md create mode 100644 scripts/check-sourcemap-no-sources-content.mjs create mode 100644 scripts/tsup-drop-sources-content.mjs diff --git a/.changeset/sourcemap-drop-sources-content.md b/.changeset/sourcemap-drop-sources-content.md new file mode 100644 index 0000000000..fa01c785e2 --- /dev/null +++ b/.changeset/sourcemap-drop-sources-content.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": patch +"@objectstack/cli": patch +"@objectstack/core": patch +"@objectstack/types": patch +"@objectstack/metadata": patch +"@objectstack/metadata-core": patch +"@objectstack/metadata-fs": patch +"@objectstack/metadata-protocol": patch +"@objectstack/objectql": patch +"@objectstack/runtime": patch +"@objectstack/lint": patch +"@objectstack/platform-objects": patch +"@objectstack/plugin-auth": patch +"@objectstack/plugin-webhooks": patch +"@objectstack/service-cluster": patch +"@objectstack/service-cluster-redis": patch +"@objectstack/service-datasource": patch +"@objectstack/account": patch +"@objectstack/setup": patch +"@objectstack/studio": patch +--- + +Published `.js.map` files no longer embed the complete original source text (`sourcesContent`) — comments included. `sourcemap: true` was esbuild shorthand, and esbuild's own default for `sourcesContent` is `true`; nobody had decided to publish every package's full source (including `@internal`/test-only comments) to npm inside its source maps, it fell out of a default nobody had looked at. Measured before this change: 55 of 57 publishable packages shipped embedded source text, and maps were roughly half of `@objectstack/spec`'s published bytes. + +`sourcesContent: false` is now set at one shared place (`scripts/tsup-drop-sources-content.mjs`, wired into every `tsup.config.ts` via tsup's `esbuildOptions` hook — most packages build through the repo-root config directly and pick this up with no config change of their own). `mappings` are untouched, so stack-trace positions still resolve correctly to the original file/line/column; only the embedded source text is gone. + +`@objectstack/cli` (built with `tsc`, not `tsup`) never embedded source text to begin with — its maps' `sources` entries point at `src/**` paths that are not part of the published tarball either way. That is not a defect unique to `cli`: every `tsup`-built package's `sources` entries are `../src/**`-relative paths that are equally outside `files: ["dist", …]`, and were merely masked by the embedded content that just stopped shipping. Shipping `src/**` in `files[]` to make `sources` resolve was rejected — it would put most of the removed bytes straight back. So `cli`'s maps are left exactly as `tsc` emits them: this is now the fleet-consistent shape (accurate `mappings`, non-resolving-but-honest `sources` labels, no embedded text), not an outlier. + +A new gate, `pnpm check:sourcemap-no-sources-content`, sweeps every built, non-private package's `dist/**/*.map` and fails if any of them carries a non-empty `sourcesContent` array — so a future `tsup.config.ts` that skips the shared hook, or a toolchain upgrade that changes esbuild's default back, is caught rather than silently re-publishing source text. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddbb499901..e9c9a4b65e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1695,6 +1695,15 @@ jobs: - name: Every published require entry point actually loads run: pnpm check:dual-build-cjs-loads + # #16469 (build half of #15905's E3 question 1): `sourcesContent` is off + # by construction at the shared tsup/esbuild layer, so a `.map` file that + # carries it means a config skipped the shared hook, or a toolchain + # upgrade changed esbuild's default back to embedding. Lives here for the + # same reason the step above does: it reads a real `dist/`, and refuses + # (exit 3) rather than passing silently when there is none. + - name: No published source map embeds source text + run: pnpm check:sourcemap-no-sources-content + - name: Analyze bundle size run: pnpm --filter @objectstack/spec analyze diff --git a/package.json b/package.json index 4154ce7292..5535da548e 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "check:published-readme-links": "node scripts/check-published-readme-links.mjs --self-test && node scripts/check-published-readme-links.mjs", "check:dual-build-cjs-loads": "node scripts/check-dual-build-cjs-loads.mjs --self-test && node scripts/check-dual-build-cjs-loads.mjs", "check:dts-closure": "node scripts/check-dts-closure.mjs --self-test && node scripts/check-dts-closure.mjs", + "check:sourcemap-no-sources-content": "node scripts/check-sourcemap-no-sources-content.mjs --self-test && node scripts/check-sourcemap-no-sources-content.mjs", "check:type-check-coverage": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs", "check:type-check-debt": "node scripts/check-type-check-coverage.mjs --self-test && node scripts/check-type-check-coverage.mjs --re-measure", "check:driver-conformance": "node scripts/check-driver-conformance.mjs --self-test && node scripts/check-driver-conformance.mjs", diff --git a/packages/apps/account/tsup.config.ts b/packages/apps/account/tsup.config.ts index dde3395982..59408ce91b 100644 --- a/packages/apps/account/tsup.config.ts +++ b/packages/apps/account/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: { index: 'src/index.ts', @@ -12,4 +14,5 @@ export default defineConfig({ sourcemap: true, splitting: false, treeshake: true, + esbuildOptions: dropSourcesContent, }); diff --git a/packages/apps/setup/tsup.config.ts b/packages/apps/setup/tsup.config.ts index dde3395982..59408ce91b 100644 --- a/packages/apps/setup/tsup.config.ts +++ b/packages/apps/setup/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: { index: 'src/index.ts', @@ -12,4 +14,5 @@ export default defineConfig({ sourcemap: true, splitting: false, treeshake: true, + esbuildOptions: dropSourcesContent, }); diff --git a/packages/apps/studio/tsup.config.ts b/packages/apps/studio/tsup.config.ts index dde3395982..59408ce91b 100644 --- a/packages/apps/studio/tsup.config.ts +++ b/packages/apps/studio/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: { index: 'src/index.ts', @@ -12,4 +14,5 @@ export default defineConfig({ sourcemap: true, splitting: false, treeshake: true, + esbuildOptions: dropSourcesContent, }); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 365b76b31f..63e561824a 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + /** * `@objectstack/core` ships two entry points: * - `index.ts` — full microkernel (Node-only; pulls in plugin sandbox, @@ -16,4 +18,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); diff --git a/packages/lint/tsup.config.ts b/packages/lint/tsup.config.ts index 54940a82a5..76fc989551 100644 --- a/packages/lint/tsup.config.ts +++ b/packages/lint/tsup.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + /** * Package-local config (#4463): `@objectstack/lint` ships TWO entries, so it * cannot use the repo-root `tsup.config.ts` (single `src/index.ts`). @@ -20,4 +22,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); diff --git a/packages/metadata-core/tsup.config.ts b/packages/metadata-core/tsup.config.ts index f09047352c..2bb2e612c6 100644 --- a/packages/metadata-core/tsup.config.ts +++ b/packages/metadata-core/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig, type Options } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + // Everything both halves below share. Spelled once so the two cannot drift in // anything except the two properties they exist to differ in: `entry`/`format`. // @@ -49,6 +51,7 @@ const shared: Options = { // emitted before the split. shims: true, external: ['vitest'], + esbuildOptions: dropSourcesContent, }; // [#13013] The split is by FORMAT, never by ENTRY — that distinction is the diff --git a/packages/metadata-fs/tsup.config.ts b/packages/metadata-fs/tsup.config.ts index 88826a7e6b..b296ee4fc4 100644 --- a/packages/metadata-fs/tsup.config.ts +++ b/packages/metadata-fs/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts'], splitting: false, @@ -11,4 +13,5 @@ export default defineConfig({ format: ['esm', 'cjs'], target: 'es2020', external: ['chokidar', '@objectstack/metadata-core'], + esbuildOptions: dropSourcesContent, }); diff --git a/packages/metadata-protocol/tsup.config.ts b/packages/metadata-protocol/tsup.config.ts index b83882928b..d14f44ac17 100644 --- a/packages/metadata-protocol/tsup.config.ts +++ b/packages/metadata-protocol/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts'], splitting: true, @@ -31,4 +33,5 @@ export default defineConfig({ // `__dirname`/`__filename`, so the ESM build's shim path is a no-op. shims: true, external: ['vitest'], + esbuildOptions: dropSourcesContent, }); diff --git a/packages/metadata/tsup.config.ts b/packages/metadata/tsup.config.ts index 0d57ae6343..4e7657cefb 100644 --- a/packages/metadata/tsup.config.ts +++ b/packages/metadata/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: [ 'src/index.ts', @@ -23,4 +25,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); diff --git a/packages/objectql/tsup.config.ts b/packages/objectql/tsup.config.ts index e664fcc78f..0aed7068b3 100644 --- a/packages/objectql/tsup.config.ts +++ b/packages/objectql/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ // `core` is the lean engine entry (ADR-0076) — engine/registry/hooks/validation // only, no kernel plugin or @objectstack/metadata-protocol. `index` is the @@ -13,4 +15,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); diff --git a/packages/platform-objects/tsup.config.ts b/packages/platform-objects/tsup.config.ts index f147ec30fd..54ebfd9f51 100644 --- a/packages/platform-objects/tsup.config.ts +++ b/packages/platform-objects/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: { index: 'src/index.ts', @@ -22,4 +24,5 @@ export default defineConfig({ sourcemap: true, splitting: false, treeshake: true, + esbuildOptions: dropSourcesContent, }); diff --git a/packages/plugins/plugin-auth/tsup.config.ts b/packages/plugins/plugin-auth/tsup.config.ts index ee7f3ee9e0..ccc92cd7e9 100644 --- a/packages/plugins/plugin-auth/tsup.config.ts +++ b/packages/plugins/plugin-auth/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + /** * Two entries, deliberately (#6040). * @@ -42,4 +44,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); diff --git a/packages/plugins/plugin-webhooks/tsup.config.ts b/packages/plugins/plugin-webhooks/tsup.config.ts index 37429d0932..43e3ce9c9f 100644 --- a/packages/plugins/plugin-webhooks/tsup.config.ts +++ b/packages/plugins/plugin-webhooks/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts', 'src/schema.ts'], splitting: true, @@ -11,4 +13,5 @@ export default defineConfig({ format: ['esm', 'cjs'], target: 'es2020', external: ['vitest'], + esbuildOptions: dropSourcesContent, }); diff --git a/packages/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts index c0c84ea7b1..345b0af880 100644 --- a/packages/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts'], splitting: false, @@ -34,4 +36,5 @@ export default defineConfig({ '@objectstack/metadata', '@objectstack/objectql', ], + esbuildOptions: dropSourcesContent, }); diff --git a/packages/services/service-cluster-redis/tsup.config.ts b/packages/services/service-cluster-redis/tsup.config.ts index c1773045fb..158cf7412b 100644 --- a/packages/services/service-cluster-redis/tsup.config.ts +++ b/packages/services/service-cluster-redis/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts'], splitting: true, @@ -11,4 +13,5 @@ export default defineConfig({ format: ['esm', 'cjs'], target: 'es2020', external: ['vitest', 'ioredis'], + esbuildOptions: dropSourcesContent, }); diff --git a/packages/services/service-cluster/tsup.config.ts b/packages/services/service-cluster/tsup.config.ts index a895e6be22..d58a0607a1 100644 --- a/packages/services/service-cluster/tsup.config.ts +++ b/packages/services/service-cluster/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig, type Options } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + // Everything both halves below share. Spelled once so the two cannot drift in // anything except the two properties they exist to differ in: `entry`/`format`. // @@ -22,6 +24,7 @@ const shared: Options = { dts: !process.env.OS_SKIP_DTS, target: 'es2020', external: ['vitest'], + esbuildOptions: dropSourcesContent, }; // [#13013] The split is by FORMAT, never by ENTRY. diff --git a/packages/services/service-datasource/tsup.config.ts b/packages/services/service-datasource/tsup.config.ts index d54f0e0b9a..37d72b654e 100644 --- a/packages/services/service-datasource/tsup.config.ts +++ b/packages/services/service-datasource/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../../scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts', 'src/contracts/index.ts'], splitting: true, @@ -16,4 +18,5 @@ export default defineConfig({ // never tries to bundle/resolve those optional natives. (They are devDeps for // tests; previously they were optional peerDeps, which tsup auto-externalized.) external: ['vitest', /^@objectstack\/driver-/], + esbuildOptions: dropSourcesContent, }); diff --git a/packages/spec/tsup.config.ts b/packages/spec/tsup.config.ts index 031bda72de..62b24aceb3 100644 --- a/packages/spec/tsup.config.ts +++ b/packages/spec/tsup.config.ts @@ -5,6 +5,8 @@ import { dirname, join } from 'node:path'; import { defineConfig, type Options } from 'tsup'; import type { Plugin } from 'esbuild'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + /** * [#10031] Annotate deferred schema construction as pure IN THE EMITTED * BUNDLES, so a CONSUMER's bundler may drop the schema consts its entry never @@ -192,6 +194,7 @@ const mainConfig: Options = { target: 'es2020', treeshake: true, esbuildPlugins: [pureSchemaConstruction], + esbuildOptions: dropSourcesContent, }; /** @@ -213,6 +216,7 @@ const browserConfig: Options = { target: 'es2020', treeshake: true, esbuildPlugins: [pureSchemaConstruction, swapServerOnlyGrammarArm], + esbuildOptions: dropSourcesContent, }; // The DTS pass re-runs only the main config (declarations once, per entry); diff --git a/packages/types/tsup.config.ts b/packages/types/tsup.config.ts index e2c6cde48b..62d83d5d60 100644 --- a/packages/types/tsup.config.ts +++ b/packages/types/tsup.config.ts @@ -2,6 +2,8 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + /** * Two entries, deliberately. `src/index.ts` is the edge/browser-safe root that * `@objectstack/hono` (Cloudflare Workers, Deno, Bun) and the Worker-bootable @@ -25,4 +27,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); diff --git a/scripts/check-sourcemap-no-sources-content.mjs b/scripts/check-sourcemap-no-sources-content.mjs new file mode 100644 index 0000000000..879722cf29 --- /dev/null +++ b/scripts/check-sourcemap-no-sources-content.mjs @@ -0,0 +1,506 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-sourcemap-no-sources-content -- run ONCE over the whole built +// workspace, AFTER the closure build: no published `.map` file may carry a +// non-empty `sourcesContent` array. +// +// node scripts/check-sourcemap-no-sources-content.mjs # sweep +// node scripts/check-sourcemap-no-sources-content.mjs --self-test # prove it can go red +// +// ## Exit codes +// +// 0 every built, non-private package's `.map` files carry no source text. +// 1 a FINDING: at least one map embeds `sourcesContent`. Named, per package, +// per file. +// 3 PREREQUISITE NOT MET: not one workspace package has a `dist/`, so there +// was nothing to sweep. NOT a pass and NOT a finding -- same convention as +// `check-dts-closure.mjs`, `check-dual-build-cjs-loads.mjs`. +// +// --------------------------------------------------------------------------- +// THE DEFECT IT EXISTS TO CLOSE (#16469, the build half of #15905's E3 Q1) +// +// `sourcemap: true` is esbuild shorthand; esbuild's OWN default for the +// `sourcesContent` option is `true`. Nobody in this tree decided to publish +// the complete original source of every package -- comments included -- to +// npm inside `.js.map`, it fell out of a default nobody looked at. Measured on +// `origin/main` before this gate landed: 55 of 57 publishable packages shipped +// `.js.map` with `sourcesContent`, and source maps were roughly half of +// `@objectstack/spec`'s published bytes. +// +// The repair (this PR) sets `sourcesContent: false` at the tsup/esbuild layer +// -- `mappings` (stack-trace positions) survive, source text does not. This +// gate is the PIN: without it, one new `tsup.config.ts` that omits the shared +// `esbuildOptions` hook -- or a tsup upgrade that changes esbuild's default -- +// silently re-embeds the same text and nothing says so until someone reads a +// tarball by hand again. +// +// ## Why a closure-wide sweep and not a per-package build-script line +// +// `check-dts-emitted.mjs` is wired into each package's OWN build script +// because it answers a question only that package's own tsup run can answer. +// This gate asks a question about the SHAPE OF THE OUTPUT BYTES, which is the +// same question for every package regardless of which config produced them, +// so one sweep over `dist/**/*.map` after the closure build covers the whole +// fleet without a change to every package's `build` script. It runs from +// `Build Core` in `ci.yml`, alongside `check:dual-build-cjs-loads` and +// `check:dts-closure` -- the same job, for the same reason: it reads a real +// `dist/`, and refuses (exit 3) rather than passing silently when there is +// none. +// --------------------------------------------------------------------------- + +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import process from 'node:process'; + +import { EXIT_FINDINGS, EXIT_PREREQUISITE_NOT_MET } from './import-prerequisite.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; +import { workspacePackages } from './workspace-enumerator.mjs'; + +const SELF = 'scripts/check-sourcemap-no-sources-content.mjs'; + +/** + * `scripts/pm/dispatch-gates.mjs` derives which gates a card owes by matching + * path literals in a gate's own source against the card's changed files (the + * `ROOT_DIR_WATCH_HINTS` idiom, see `check-dual-build-cjs-loads.mjs` for the + * full argument). Reused verbatim: it is the same population this gate's own + * verdict is a function of — a `tsup.config.ts` that drops the shared + * `esbuildOptions` hook is exactly how a package regresses. + */ +const ROOT_DIR_WATCH_HINTS = ['packages/**/tsup.config.ts']; + +/** + * The two repo-root files outside `packages/**` that this gate's verdict is + * also a function of: the shared root config every un-customised package + * builds through, and the one shared function that decides the setting for + * every `tsup.config.ts`, this one included. Spelled with a trailing `/**` + * per the established idiom (`git-merge-regen.mjs`'s `ROOT_FILE_WATCH_HINTS`) + * so a bare top-level filename is not refused as "too generic". + */ +const ROOT_FILE_WATCH_HINTS = ['tsup.config.ts/**', 'scripts/tsup-drop-sources-content.mjs/**']; + +/** A package's `dist/` as this gate decides "was it built in this job". */ +export function hasDist(root, dir) { + const abs = join(root, dir, 'dist'); + try { + return statSync(abs).isDirectory() && readdirSync(abs).length > 0; + } catch { + return false; + } +} + +/** Every `.map` file under `//dist`, recursively, repo-relative. */ +export function mapFilesUnder(root, dir) { + const distAbs = join(root, dir, 'dist'); + const out = []; + const walk = (abs) => { + let entries; + try { + entries = readdirSync(abs, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const childAbs = join(abs, e.name); + if (e.isDirectory()) { + walk(childAbs); + } else if (e.isFile() && e.name.endsWith('.map')) { + out.push(relative(root, childAbs)); + } + } + }; + walk(distAbs); + return out.sort(); +} + +/** + * Whether a parsed source map carries source text this gate refuses. + * `sourcesContent` absent, `null`, or an empty array is clean -- esbuild with + * `sourcesContent: false` and tsc without `inlineSources` both omit the key + * entirely, so "absent" is the expected healthy shape, not a special case. + */ +export function embedsSourceText(map) { + return Array.isArray(map?.sourcesContent) && map.sourcesContent.length > 0; +} + +/** + * The sweep itself, over an already-enumerated member list. + * + * @param {string} root repo (or fixture workspace) root + * @param {Array<{dir: string, manifest: Record}>} members + * @returns {{built: number, files: number, findings: Array<{name: string, dir: string, maps: string[]}>}} + */ +export function sweep(root, members) { + const findings = []; + let built = 0; + let files = 0; + + for (const { dir, manifest } of members) { + if (manifest?.private === true) continue; + if (!hasDist(root, dir)) continue; + built += 1; + + const maps = mapFilesUnder(root, dir); + const embedded = []; + for (const rel of maps) { + files += 1; + let parsed; + try { + parsed = JSON.parse(readFileSync(join(root, rel), 'utf8')); + } catch { + // A `.map` file this gate cannot parse is not this gate's finding — + // some other check owns malformed JSON. + continue; + } + if (embedsSourceText(parsed)) embedded.push(rel); + } + if (embedded.length > 0) { + findings.push({ name: typeof manifest?.name === 'string' ? manifest.name : dir, dir, maps: embedded }); + } + } + + findings.sort((a, b) => a.dir.localeCompare(b.dir)); + return { built, files, findings }; +} + +/** The refusal text, same shape as `check-dts-closure.mjs`'s. */ +export function prerequisiteNotMetText(message) { + return ( + `\ncheck-sourcemap-no-sources-content: PREREQUISITE NOT MET\n\n` + + `${message}\n\n` + + ` ⛔ This is NOT a pass and NOT a finding: nothing was swept, so this run says\n` + + ` NOTHING about whether any published map embeds source text.\n` + + ` (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from a finding's ${EXIT_FINDINGS} — capture it BEFORE any pipe:\n` + + ` \`node ${SELF} > /tmp/check-sourcemap-no-sources-content.log 2>&1; echo "EXIT=$?"\`.\n` + + ` Piped, \`$?\` is the LAST command's status, and \`head\`/\`tail\` essentially never fail — that\n` + + ` is the false green. \`\${PIPESTATUS[0]}\`/\`pipefail\` do recover this gate's own code.)` + ); +} + +/** The finding report, as a value for the same reason the refusal is one. */ +export function findingsText(result) { + const lines = [ + `\nx check-sourcemap-no-sources-content: ${result.findings.length} built package(s) publish source`, + ` maps that embed the ORIGINAL SOURCE TEXT (\`sourcesContent\`), comments included.\n`, + ]; + for (const f of result.findings) { + lines.push(` ${f.name} (${f.dir})`); + for (const m of f.maps) lines.push(` ${m}`); + } + lines.push( + '', + ' This is the pin for #16469 (build half of #15905 question 1): `sourcesContent` is OFF', + ' by construction at the shared tsup/esbuild layer, so a map carrying it means either a', + ' new build config skipped the shared `esbuildOptions` hook, or a toolchain upgrade', + ' changed esbuild\'s default back to embedding.', + '', + ' `mappings` (stack-trace positions) are NOT this gate\'s business and are untouched —', + ' only the embedded source TEXT is refused.', + '', + ' Rebuild the named package(s) after fixing its `tsup.config.ts` (or the tsc project\'s', + ' `inlineSources`, if it is tsc-built) and re-run this gate:', + ' pnpm --filter build && node ' + SELF, + '', + ); + return lines.join('\n'); +} + +export function run(root) { + const members = workspacePackages(root); + const result = sweep(root, members); + + if (result.built === 0) { + console.error( + prerequisiteNotMetText( + ` Not one of the ${members.length} workspace package(s) under ${relative(process.cwd(), root) || '.'} has a\n` + + ' `dist/`, so there was nothing to sweep. This gate reads the tree the closure build\n' + + ' leaves behind; run it AFTER that build.\n\n' + + ' Run `pnpm build` (or the workflow step that builds the closure) and re-run this gate.', + ), + ); + return EXIT_PREREQUISITE_NOT_MET; + } + + if (result.findings.length > 0) { + console.error(findingsText(result)); + return EXIT_FINDINGS; + } + + console.log( + `check-sourcemap-no-sources-content: ${result.built} built package(s) swept - ${result.files} map(s), ` + + 'none embed source text.', + ); + return 0; +} + +// --- self-test ------------------------------------------------------------ +// Driven against REAL fixture workspaces in a temp dir, not against an +// injected filesystem — the positive control the card asks for (a planted +// map that makes the gate go red) is exactly this: a fixture map that +// genuinely embeds `sourcesContent`, read back off real disk. + +const SELF_TEST_BATTERIES = Object.freeze({ + 'the sweep, over real fixture workspaces': 8, + 'the dispatch-gates watch hints': 3, +}); + +const SELF_TEST_BATTERY_FLOOR = 1; +const UNATTRIBUTED_BATTERY = '(no battery open)'; + +let selfTestReachedVerdict = false; + +function selfTest() { + const batterySeen = new Map(); + let openBattery = null; + const battery = (name) => { + openBattery = name; + }; + const registerCase = () => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); + }; + const failures = []; + const eq = (label, actual, expected) => { + registerCase(); + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) failures.push(`${label}\n expected ${e}\n actual ${a}`); + }; + const ok = (label, condition) => eq(label, condition === true, true); + /** Run `fn` with this gate's own printing suppressed, and return its value. */ + const quiet = (fn) => { + const { log, error } = console; + console.log = () => {}; + console.error = () => {}; + try { + return fn(); + } finally { + console.log = log; + console.error = error; + } + }; + + const scratch = mkdtempSync(join(tmpdir(), 'sourcemap-no-sources-content-')); + + /** + * Build a fixture workspace: `{ '': { manifest, dist: { '': '' } | null } }`. + * `dist: null` means the package was never built. + */ + const fixture = (name, members) => { + const root = join(scratch, name); + mkdirSync(root, { recursive: true }); + writeFileSync(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n'); + for (const [dir, spec] of Object.entries(members)) { + const abs = join(root, dir); + mkdirSync(abs, { recursive: true }); + writeFileSync(join(abs, 'package.json'), JSON.stringify(spec.manifest, null, 2)); + if (spec.dist) { + for (const [file, contents] of Object.entries(spec.dist)) { + const target = join(abs, 'dist', file); + mkdirSync(join(target, '..'), { recursive: true }); + writeFileSync(target, contents); + } + } + } + return root; + }; + + const healthyMap = JSON.stringify({ + version: 3, + sources: ['../src/index.ts'], + names: [], + mappings: 'AAAA', + file: 'index.js', + }); + const embeddedMap = JSON.stringify({ + version: 3, + sources: ['../src/index.ts'], + names: [], + mappings: 'AAAA', + file: 'index.js', + // The positive control: real, planted source text on real disk. + sourcesContent: ['export const secret = 1; // @internal test-only comment\n'], + }); + const emptyArrayMap = JSON.stringify({ + version: 3, + sources: ['../src/index.ts'], + names: [], + mappings: 'AAAA', + file: 'index.js', + sourcesContent: [], + }); + + try { + battery('the sweep, over real fixture workspaces'); + + // (a) a healthy map with no `sourcesContent` key -> green. + const greenRoot = fixture('green', { + 'packages/a': { + manifest: { name: '@fixture/a' }, + dist: { 'index.js': 'export {}\n', 'index.js.map': healthyMap }, + }, + }); + const green = sweep(greenRoot, workspacePackages(greenRoot)); + eq('(a) a map with no `sourcesContent` key is clean', green.findings, []); + eq('(a) it is still counted as swept', [green.built, green.files], [1, 1]); + + // (b) THE POSITIVE CONTROL — a map that genuinely embeds source text goes red. + const redRoot = fixture('red', { + 'packages/b': { + manifest: { name: '@fixture/b' }, + dist: { 'index.js': 'export {}\n', 'index.js.map': embeddedMap }, + }, + }); + const red = sweep(redRoot, workspacePackages(redRoot)); + eq( + '(b) REJECTS a planted map carrying `sourcesContent`', + red.findings.map((f) => `${f.name}|${f.maps.join(',')}`), + ['@fixture/b|packages/b/dist/index.js.map'], + ); + const redText = findingsText(red); + ok('(b) the report NAMES the package', redText.includes('@fixture/b')); + ok('(b) the report NAMES the offending map file', redText.includes('packages/b/dist/index.js.map')); + eq('(b) run() answers a finding with exit 1', quiet(() => run(redRoot)), EXIT_FINDINGS); + + // (c) an EMPTY `sourcesContent` array is clean — "non-empty" is the bar. + const emptyRoot = fixture('empty-array', { + 'packages/c': { + manifest: { name: '@fixture/c' }, + dist: { 'index.js': 'export {}\n', 'index.js.map': emptyArrayMap }, + }, + }); + eq( + '(c) an empty `sourcesContent` array is not a finding', + sweep(emptyRoot, workspacePackages(emptyRoot)).findings, + [], + ); + + // (d) a PRIVATE package is not swept at all — this gate is about what npm + // ships, and a private workspace member ships nothing. + const privateRoot = fixture('private', { + 'packages/d': { + manifest: { name: '@fixture/d', private: true }, + dist: { 'index.js': 'export {}\n', 'index.js.map': embeddedMap }, + }, + }); + const privateResult = sweep(privateRoot, workspacePackages(privateRoot)); + eq('(d) a private package is skipped, embedded map or not', [privateResult.built, privateResult.findings.length], [0, 0]); + + // (e) a nested map (e.g. `dist/browser/index.js.map`) is reached too — the + // walk is recursive, not `dist/*.map` only. + const nestedRoot = fixture('nested', { + 'packages/e': { + manifest: { name: '@fixture/e' }, + dist: { 'browser/index.js': 'export {}\n', 'browser/index.js.map': embeddedMap }, + }, + }); + eq( + '(e) a map nested under dist/ (not directly in it) is still swept', + sweep(nestedRoot, workspacePackages(nestedRoot)).findings.map((f) => f.maps), + [['packages/e/dist/browser/index.js.map']], + ); + + // (f) an UNBUILT package (no `dist/`) is skipped, not a finding. + const mixedRoot = fixture('mixed', { + 'packages/built': { + manifest: { name: '@fixture/built' }, + dist: { 'index.js': 'export {}\n', 'index.js.map': healthyMap }, + }, + 'packages/unbuilt': { manifest: { name: '@fixture/unbuilt' }, dist: null }, + }); + const mixed = sweep(mixedRoot, workspacePackages(mixedRoot)); + eq('(f) an unbuilt package is skipped, not reported', [mixed.findings.length, mixed.built], [0, 1]); + + // (g) no `dist/` anywhere -> PREREQUISITE NOT MET, never a pass. + const bareRoot = fixture('bare', { + 'packages/a': { manifest: { name: '@fixture/a' }, dist: null }, + }); + eq('(g) run() answers a closure with no dist/ with exit 3, not 0', quiet(() => run(bareRoot)), EXIT_PREREQUISITE_NOT_MET); + ok( + '(g) the refusal disclaims the measurement rather than reading as a pass', + prerequisiteNotMetText('x').includes('NOT a pass and NOT a finding'), + ); + + // (h) the healthy fixture, end to end through `run()`, exits 0. + eq('(h) run() answers an all-clean closure with exit 0', quiet(() => run(greenRoot)), 0); + + battery('the dispatch-gates watch hints'); + // Spelled as LITERALS the extractor can read, never built from a variable + // — see `check-dual-build-cjs-loads.mjs`'s own pin of the same property. + ok( + 'the package-scoped hint is spelled as a literal this file also contains', + readFileSync(new URL(import.meta.url), 'utf8').includes("'packages/**/tsup.config.ts'"), + ); + eq('the two root-file hints', ROOT_FILE_WATCH_HINTS, ['tsup.config.ts/**', 'scripts/tsup-drop-sources-content.mjs/**']); + ok('neither watch-hint list is empty', ROOT_DIR_WATCH_HINTS.length > 0 && ROOT_FILE_WATCH_HINTS.length > 0); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── + const declaredBatteries = Object.keys(SELF_TEST_BATTERIES); + let floorBreached = false; + if (declaredBatteries.length < SELF_TEST_BATTERY_FLOOR) { + floorBreached = true; + failures.push( + `SELF_TEST_BATTERIES declares ${declaredBatteries.length} batteries, below the pinned ` + + `${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`, + ); + } + for (const [name, count] of batterySeen) { + if (declaredBatteries.includes(name)) continue; + floorBreached = true; + failures.push( + `self-test battery "${name}" registered ${count} case(s) but is not declared in ` + + 'SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.', + ); + } + for (const name of declaredBatteries) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floorBreached = true; + failures.push( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. ` + + 'The verdict below would have claimed those cases hold.' + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ` + + `${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (floorBreached) { + failures.push( + 'A battery at or below its floor means cases STOPPED RUNNING — the battery is the bug, not the ' + + 'number. Find what stopped registering (an early return, a deleted block, a guard that now ' + + 'skips) and restore it.', + ); + } + + if (failures.length > 0) { + console.error(`\nx check-sourcemap-no-sources-content self-test: ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` - ${f}\n`); + return 1; + } + console.log('check-sourcemap-no-sources-content self-test: all assertions passed.'); + selfTestReachedVerdict = true; + return 0; +} + +// Behind the entrypoint guard, for the reason the sibling gates state: an +// unguarded `process.exit` here would end any importer mid-import, with status +// 0 on the healthy path. +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + const code = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-sourcemap-no-sources-content self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(code); + } + process.exit(run(process.cwd())); +} diff --git a/scripts/tsup-drop-sources-content.mjs b/scripts/tsup-drop-sources-content.mjs new file mode 100644 index 0000000000..6c9cedccad --- /dev/null +++ b/scripts/tsup-drop-sources-content.mjs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * tsup-drop-sources-content -- the ONE place `sourcesContent: false` is + * decided for every tsup/esbuild build in this tree (#16469, the build half + * of #15905's E3 standard question 1: "is `.js.map` `sourcesContent` + * published?" -- answered "no" by construction). + * + * `sourcemap: true` is tsup's own option and stays untouched everywhere -- + * `mappings` (stack-trace positions) are unaffected. `sourcesContent` has no + * tsup-level knob; esbuild owns it, and esbuild's own default is `true` -- + * nobody decided to publish the complete original source of every package + * (comments included) to npm, it fell out of a default nobody looked at. + * tsup's `esbuildOptions(options, context)` hook is the one place a tsup + * config reaches esbuild's real `BuildOptions` before the build runs, so + * that is where this is set. + * + * `packages/**\/tsup.config.ts` import this and wire it in: + * + * import { dropSourcesContent } from '../../scripts/tsup-drop-sources-content.mjs'; + * export default defineConfig({ + * ... + * esbuildOptions: dropSourcesContent, + * }); + * + * A config that already uses `esbuildOptions` for something else calls this + * INSIDE its own hook body instead of assigning the export directly (see + * `packages/spec/tsup.config.ts` for the shape once it exists there). + * + * There is no monorepo-wide tsup config every package extends -- most + * packages share the repo-root `tsup.config.ts` directly (`tsup --config + * ../../tsup.config.ts` in their `build` script; see `pnpm --filter run + * build`'s definition to confirm a given package's shape), and that root + * config is one of this file's callers, so those packages need no change of + * their own. The ~20 packages with a bespoke entry shape carry their own + * `tsup.config.ts` and each needs this same one-line wire-up. + * `check:sourcemap-no-sources-content` (scripts/check-sourcemap-no-sources-content.mjs) + * is the pin that catches a config that skips it, or a future tsup/esbuild + * upgrade that changes the default back. + * + * @param {import('esbuild').BuildOptions} options + */ +export function dropSourcesContent(options) { + options.sourcesContent = false; +} diff --git a/tsup.config.ts b/tsup.config.ts index ffa32ebd48..ff3746789f 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'tsup'; +import { dropSourcesContent } from './scripts/tsup-drop-sources-content.mjs'; + export default defineConfig({ entry: ['src/index.ts'], splitting: false, @@ -8,4 +10,5 @@ export default defineConfig({ dts: !process.env.OS_SKIP_DTS, format: ['esm', 'cjs'], target: 'es2020', + esbuildOptions: dropSourcesContent, }); From 8a8e49a6bb2da7c33ff377fc1e781a694bf59ff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 04:13:47 +0000 Subject: [PATCH 2/2] fix(scripts): pair tsup-drop-sources-content.mjs with a .d.mts for the root tsc program The root tsup.config.ts imports the shared helper, and the root TypeScript program (scripts/** plus top-level configs) type-checks it -- an untyped .mjs import there is TS7016, which pushed the @objectstack/spec-monorepo DEBT ledger from 26 to 27. Follows the invoked-as.d.mts / workspace- enumerator.d.mts convention: a hand-written declaration, discovered by check-declaration-mirrors.mjs. Re-measured: 26, no drift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8 --- scripts/tsup-drop-sources-content.d.mts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 scripts/tsup-drop-sources-content.d.mts diff --git a/scripts/tsup-drop-sources-content.d.mts b/scripts/tsup-drop-sources-content.d.mts new file mode 100644 index 0000000000..30a4866bb3 --- /dev/null +++ b/scripts/tsup-drop-sources-content.d.mts @@ -0,0 +1,23 @@ +// Types for `tsup-drop-sources-content.mjs`, published for the root +// `tsup.config.ts` — a TypeScript-authored config running inside the ROOT tsc +// program, where an untyped `.mjs` import is TS7016. See `invoked-as.d.mts` +// for why this pairing exists; this file follows the same convention. +// +// `esbuild`'s own `BuildOptions` is not imported here on purpose: it is not +// resolvable from every context this declaration is read in (measured — it is +// not a direct or hoisted dependency at the repo root). tsup's own +// `Options['esbuildOptions']` field type is +// `(options: BuildOptions, context: { format: Format }) => void` — a +// structural `Record` parameter is NOT assignable there +// (measured: TS2322, `BuildOptions` does not satisfy an index signature), so +// the parameter is declared `any` on purpose. That costs the one property +// this module actually writes (`sourcesContent`) any narrowing here — the +// caller gets no assurance beyond "this function exists and takes one +// argument" — but it is what makes `esbuildOptions: dropSourcesContent` +// type-check against tsup's real, imported `BuildOptions` at every call site. + +/** + * A tsup `esbuildOptions` hook: mutates the real esbuild options in place, + * setting `sourcesContent = false`. + */ +export function dropSourcesContent(options: any): void;