diff --git a/.changeset/README.md b/.changeset/README.md
index 63925c4b9..41d8c5a8d 100644
--- a/.changeset/README.md
+++ b/.changeset/README.md
@@ -87,7 +87,12 @@ because they change no publishable package.
- `agent-bundle` and `@agent-bundle/runtime` version **independently**
(`fixed` and `linked` are empty). `agent-bundle` declares
- `@agent-bundle/runtime` as an *optional* peer with range `*`, and
+ `@agent-bundle/runtime` as an *optional* peer with range `>=0.0.0 <1`: the
+ lower bound is the first runtime version that ships every subpath the
+ generated code imports (`mount`, `notices`, `notices/inbox-route`, `state`,
+ `state/sqlite`, `flight/server`, `lineage`), so a changeset that adds a
+ runtime subpath the compiler emits must also raise that bound; the upper
+ bound keeps a future `1.x` runtime from satisfying a `0.x` compiler.
`@agent-bundle/runtime` does not depend on `agent-bundle`, so neither
package needs to move when the other does. Preview tarballs pin the peer to
the same commit (`docs/preview-packages.md`), which is a preview concern,
diff --git a/.changeset/pack-validation.md b/.changeset/pack-validation.md
new file mode 100644
index 000000000..d0aad2ef0
--- /dev/null
+++ b/.changeset/pack-validation.md
@@ -0,0 +1,7 @@
+---
+"agent-bundle": patch
+"rsc-markdown-stream": patch
+"create-agent-bundle": minor
+---
+
+Expose `./package.json` in the `exports` of `agent-bundle`, `rsc-markdown-stream`, and `create-agent-bundle`. `create-agent-bundle` gains its first `exports` map, so `create-agent-bundle/dist/**` deep imports no longer resolve — the CLI is reachable only through its `bin`, which is the breaking change behind its minor bump. Bound `agent-bundle`'s optional `@agent-bundle/runtime` peer to `>=0.0.0 <1` instead of `*`; drop `@modelcontextprotocol/server` from `agent-bundle`'s devDependencies (it stays a dependency) and the dead `!dist/workbench/**/*.map` entry from its `files`; gate releases on `attw --profile esm-only` for all three packed tarballs plus `scripts/check-declaration-imports.mjs`, which fails `pnpm lint:release` when a shipped `.d.ts` a consumer can reach imports a devDependency, an undeclared package, an unexported subpath of the package itself, or a `#` import its `imports` map does not resolve. (#568)
diff --git a/package.json b/package.json
index 36d6b6c22..a809770ed 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"release": "pnpm check:release && changeset publish",
"preview:publish": "pkg-pr-new publish --previewVersion --peerDeps --no-compact --no-template './packages/agent-bundle' './packages/rsc-runtime' './packages/rsc-markdown-stream' './packages/create-agent-bundle'",
"pack:dry-run": "pnpm build && npm pack ./packages/agent-bundle --dry-run --json",
- "lint:release": "attw --pack --profile esm-only packages/agent-bundle",
+ "lint:release": "attw --pack --profile esm-only packages/agent-bundle && attw --pack --profile esm-only packages/rsc-markdown-stream && attw --pack --profile esm-only packages/create-agent-bundle && node scripts/check-declaration-imports.mjs packages/agent-bundle packages/rsc-markdown-stream packages/create-agent-bundle",
"check:release": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed:release",
"check:release:ci": "pnpm pack:dry-run && pnpm lint:release && pnpm test:packed",
"example:hooks": "pnpm build && pnpm --filter @agent-bundle-example/hooks-and-scripts dev",
diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json
index 49ca5535e..d87a35104 100644
--- a/packages/agent-bundle/package.json
+++ b/packages/agent-bundle/package.json
@@ -31,7 +31,6 @@
"files": [
"bin",
"dist",
- "!dist/workbench/**/*.map",
"LICENSE",
"NOTICE",
"README.md"
@@ -91,7 +90,8 @@
"./test/browser": {
"types": "./dist/test/browser.d.ts",
"import": "./dist/test/browser.js"
- }
+ },
+ "./package.json": "./package.json"
},
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
@@ -119,7 +119,6 @@
"yaml": "2.9.0"
},
"devDependencies": {
- "@modelcontextprotocol/server": "2.0.0",
"@types/npm-package-arg": "6.1.4",
"@types/react": "19.2.18",
"@types/ws": "8.18.1",
@@ -129,7 +128,7 @@
"zod": "4.5.4"
},
"peerDependencies": {
- "@agent-bundle/runtime": "*",
+ "@agent-bundle/runtime": ">=0.0.0 <1",
"@rstest/core": "^0.11.10",
"react": "19.2.8"
},
diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts
index 94b02cdbb..b10fff281 100644
--- a/packages/agent-bundle/rslib.config.ts
+++ b/packages/agent-bundle/rslib.config.ts
@@ -47,6 +47,14 @@ export default defineConfig({
lib: [
{
bundle: true,
+ // One `.d.ts` per source module. Bundling them per entry
+ // (`dts: { bundle: true }`, API Extractor) was measured and rejected:
+ // it fails inside a devDependency's `.d.cts` (zod), takes ~6x longer,
+ // emits ~30% more bytes by inlining shared types into every entry, and
+ // renames colliding public names (`AgentBundleConfig_2`). What keeps
+ // the shipped declarations honest instead is the release gate
+ // (scripts/check-declaration-imports.mjs via `pnpm lint:release`): no
+ // declaration a consumer can reach may import a devDependency.
dts: true,
format: 'esm',
syntax: 'es2022',
diff --git a/packages/agent-bundle/tests/check-declaration-imports.test.ts b/packages/agent-bundle/tests/check-declaration-imports.test.ts
new file mode 100644
index 000000000..c75bc1d00
--- /dev/null
+++ b/packages/agent-bundle/tests/check-declaration-imports.test.ts
@@ -0,0 +1,466 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
+
+import { describe, expect, it } from '@rstest/core';
+
+import {
+ checkPackedDeclarations,
+ declarationImportViolations,
+ declarationSpecifiers,
+ formatDeclarationImportReport,
+ packageNameOf,
+ runCheckDeclarationImports,
+ type DeclarationManifest,
+} from '../../../scripts/check-declaration-imports.mjs';
+
+const manifest: DeclarationManifest = {
+ name: 'fixture-package',
+ dependencies: { effect: '4.0.0', '@modelcontextprotocol/client': '2.0.0' },
+ devDependencies: { zod: '4.5.4', 'typescript-5': 'npm:typescript@5.9.3', '@types/node': '26.4.0' },
+ exports: {
+ '.': { types: './dist/index.d.ts', import: './dist/index.js' },
+ './routes': { types: './dist/routes/public.d.ts', import: './dist/routes.js' },
+ './package.json': './package.json',
+ },
+ peerDependencies: { react: '19.2.8' },
+};
+
+describe('declarationSpecifiers', () => {
+ it('collects every form of module reference a declaration can carry', () => {
+ const text = [
+ '/// ',
+ '/// ',
+ "import { z } from 'zod';",
+ 'import type { Effect } from "effect";',
+ "import * as ns from './ns.ts';",
+ "import ts = require('typescript-5');",
+ "import './side-effect.js';",
+ "export * from './re-export.js';",
+ "export type { Foo } from '../foo.js';",
+ 'export declare const lazy: () => Promise;',
+ "export declare const inline: import('react').ReactNode;",
+ ].join('\n');
+
+ expect(declarationSpecifiers(text).map(({ kind, specifier }) => `${kind} ${specifier}`)).toEqual([
+ 'import zod',
+ 'import effect',
+ 'import ./ns.ts',
+ 'import typescript-5',
+ 'import ./side-effect.js',
+ 'import ./re-export.js',
+ 'import ../foo.js',
+ 'import @modelcontextprotocol/client',
+ 'import react',
+ 'types-reference node',
+ 'path-reference ./globals.d.ts',
+ ]);
+ });
+
+ it('ignores prose in comments, string literal types, templates, and declare module', () => {
+ const text = [
+ '/**',
+ " * Mirrors the runtime: `import { x } from 'driver'` is what the consumer writes,",
+ " * and `require('not reported')` never runs here.",
+ ' */',
+ "// import { hidden } from 'line-comment';",
+ "export type Kind = 'from' | 'import';",
+ "export interface Shape { readonly from: 'literal'; readonly import: 'also literal'; import(spec: 'x'): void }",
+ 'export type Tpl = `from ${string}`;',
+ "declare module 'ambient-module' { export const value: number; }",
+ "export { real } from './real.js'; // trailing: import('comment')",
+ ].join('\n');
+
+ expect(declarationSpecifiers(text)).toEqual([{ kind: 'import', line: 10, specifier: './real.js' }]);
+ });
+
+ it('derives the package name from scoped and deep specifiers', () => {
+ expect(packageNameOf('effect')).toBe('effect');
+ expect(packageNameOf('effect/Schema')).toBe('effect');
+ expect(packageNameOf('@agent-bundle/runtime/state/sqlite')).toBe('@agent-bundle/runtime');
+ });
+});
+
+describe('declarationImportViolations', () => {
+ const packedPaths = [
+ 'package.json',
+ 'dist/index.js',
+ 'dist/index.d.ts',
+ 'dist/routes.js',
+ 'dist/routes/public.d.ts',
+ 'dist/routes/input-schema.d.ts',
+ 'dist/events/ipc.d.ts',
+ 'dist/core/types.d.ts',
+ 'dist/core/capabilities.json',
+ 'dist/globals.d.ts',
+ ];
+
+ it('accepts declarations that only reach packed files, built-ins, and declared packages', () => {
+ const report = declarationImportViolations({
+ manifest,
+ packedPaths,
+ declarations: [
+ {
+ path: 'dist/index.d.ts',
+ text: [
+ '/// ',
+ "import type { Effect } from 'effect';",
+ "import type { Client } from '@modelcontextprotocol/client/index.js';",
+ "import { readFile } from 'node:fs/promises';",
+ "import { EventEmitter } from 'events';",
+ "import type { Own } from 'fixture-package/routes';",
+ "import type { Shared } from './core/types.ts';",
+ "import capabilities from './core/capabilities.json';",
+ "export * from './routes/public.js';",
+ ].join('\n'),
+ },
+ { path: 'dist/routes/public.d.ts', text: "import type { ReactNode } from 'react';\nexport type Route = ReactNode;" },
+ { path: 'dist/core/types.d.ts', text: 'export type Shared = string;' },
+ { path: 'dist/globals.d.ts', text: 'declare const __VERSION__: string;' },
+ ],
+ });
+
+ expect(report.errors).toEqual([]);
+ expect(report.warnings).toEqual([]);
+ expect(report.declarationCount).toBe(4);
+ expect([...report.reachable].sort()).toEqual([
+ 'dist/core/types.d.ts',
+ 'dist/globals.d.ts',
+ 'dist/index.d.ts',
+ 'dist/routes/public.d.ts',
+ ]);
+ expect(report.roots).toEqual([
+ { entry: '.', path: 'dist/index.d.ts' },
+ { entry: './routes', path: 'dist/routes/public.d.ts' },
+ ]);
+ });
+
+ it('fails a devDependency import the moment an export reaches it, and only warns while it is internal', () => {
+ const report = declarationImportViolations({
+ manifest,
+ packedPaths,
+ declarations: [
+ { path: 'dist/index.d.ts', text: "export type { Input } from './routes/input-schema.ts';" },
+ { path: 'dist/routes/public.d.ts', text: 'export type Route = string;' },
+ // Reached through dist/index.d.ts, so a consumer's TypeScript loads it.
+ { path: 'dist/routes/input-schema.d.ts', text: "import ts from 'typescript-5';\nexport type Input = ts.Node;" },
+ // No export reaches it: latent until the next re-export.
+ { path: 'dist/events/ipc.d.ts', text: "import { z } from 'zod';\nexport declare const schema: z.ZodType;" },
+ ],
+ });
+
+ expect(report.errors).toEqual([{
+ line: 1,
+ message: 'imports "typescript-5" — "typescript-5" is a devDependency, so consumers do not install it',
+ path: 'dist/routes/input-schema.d.ts',
+ reachableFrom: '.',
+ reason: 'dev-dependency',
+ specifier: 'typescript-5',
+ }]);
+ expect(report.warnings).toEqual([{
+ line: 1,
+ message: 'imports "zod" — "zod" is a devDependency, so consumers do not install it',
+ path: 'dist/events/ipc.d.ts',
+ reachableFrom: undefined,
+ reason: 'dev-dependency',
+ specifier: 'zod',
+ }]);
+ });
+
+ it('follows `#` imports mapped to packed files when computing reachability', () => {
+ const report = declarationImportViolations({
+ manifest: { ...manifest, imports: { '#events/*': './dist/events/*.js' } },
+ packedPaths,
+ declarations: [
+ { path: 'dist/index.d.ts', text: "export type { Schema } from '#events/ipc';" },
+ { path: 'dist/routes/public.d.ts', text: 'export type Route = string;' },
+ { path: 'dist/events/ipc.d.ts', text: "import { z } from 'zod';\nexport type Schema = z.ZodType;" },
+ ],
+ });
+
+ expect(report.warnings).toEqual([]);
+ expect(report.errors.map(({ path, reachableFrom, specifier }) => ({ path, reachableFrom, specifier }))).toEqual([
+ { path: 'dist/events/ipc.d.ts', reachableFrom: '.', specifier: 'zod' },
+ ]);
+ });
+
+ it('reports undeclared packages, unpacked relative targets, type references, and missing export targets', () => {
+ const report = declarationImportViolations({
+ manifest: { ...manifest, exports: { ...(manifest.exports as object), './gone': { types: './dist/gone.d.ts' } } },
+ packedPaths,
+ declarations: [
+ {
+ path: 'dist/index.d.ts',
+ text: [
+ '/// ',
+ // An absolute reference resolves from the filesystem root, not the
+ // package, even when a same-named file happens to be packed.
+ '/// ',
+ "import type { Ajv } from 'ajv';",
+ "import type { Missing } from './missing.js';",
+ "import type { Internal } from '#internal/thing';",
+ "import type { Remote } from 'https://example.invalid/types.d.ts';",
+ "import type { Own } from 'fixture-package/internal';",
+ ].join('\n'),
+ },
+ { path: 'dist/routes/public.d.ts', text: 'export type Route = string;' },
+ ],
+ });
+
+ expect(report.errors.map(({ reason, specifier }) => `${reason} ${specifier}`)).toEqual([
+ 'export-target-missing dist/gone.d.ts',
+ 'dev-dependency node',
+ 'unresolvable /globals.d.ts',
+ 'undeclared ajv',
+ 'missing-target ./missing.js',
+ 'subpath-import #internal/thing',
+ 'unresolvable https://example.invalid/types.d.ts',
+ 'unexported fixture-package/internal',
+ ]);
+ expect(report.errors[1]?.message).toBe(
+ 'references types "node" — "@types/node" is a devDependency, so consumers do not install it',
+ );
+ expect(report.errors[2]?.message).toBe(
+ 'references "/globals.d.ts" — an absolute path or URL cannot resolve in a consumer install',
+ );
+ expect(report.errors[4]?.message).toBe(
+ 'no packed declaration for "./missing.js" (tried dist/missing.d.ts)',
+ );
+ expect(report.errors[7]?.message).toBe(
+ 'imports "fixture-package/internal" — the package\'s own "exports" has no entry for "./internal"',
+ );
+ });
+
+ it('resolves self-imports through the package\'s own exports map', () => {
+ // Severity is covered above; these fixtures export `.js` targets only, so
+ // the declaration is unreachable and every violation is a warning.
+ const check = (exports: unknown, specifiers: readonly string[]): readonly string[] => {
+ const report = declarationImportViolations({
+ manifest: { name: 'self', exports },
+ packedPaths: ['dist/index.d.ts'],
+ declarations: [{ path: 'dist/index.d.ts', text: specifiers.map((specifier) => `import '${specifier}';`).join('\n') }],
+ });
+ return [...report.errors, ...report.warnings].map(({ specifier }) => specifier);
+ };
+
+ // Subpath map: exact keys and `*` patterns resolve, anything else does not.
+ const subpaths = { '.': './dist/index.js', './routes': './dist/routes.js', './features/*': './dist/features/*.js' };
+ expect(check(subpaths, ['self', 'self/routes', 'self/features/a', 'self/features/nested/b'])).toEqual([]);
+ expect(check(subpaths, ['self/internal', 'self/features'])).toEqual(['self/internal', 'self/features']);
+ // Node precedence: an exact key beats a pattern and the longest matching
+ // prefix beats a broader one, so `null` entries block what `./*` exposes.
+ const blocked = { './*': './dist/*.js', './internal/*': null, './internal/public': './dist/public.js', './secret.js': null };
+ expect(check(blocked, ['self/anything', 'self/internal/public'])).toEqual([]);
+ expect(check(blocked, ['self/internal/x', 'self/internal/deep/y', 'self/secret.js']))
+ .toEqual(['self/internal/x', 'self/internal/deep/y', 'self/secret.js']);
+ // A string or conditions-only `exports` serves the root alone.
+ expect(check('./dist/index.js', ['self', 'self/routes'])).toEqual(['self/routes']);
+ expect(check({ types: './dist/index.d.ts', import: './dist/index.js' }, ['self', 'self/routes'])).toEqual(['self/routes']);
+ // Conditions: a target no active condition selects is not served; the
+ // first selected condition wins even when it is `null`, whether nested or
+ // not; an array skips `null` entries and keeps looking.
+ expect(check({ '.': { browser: './dist/browser.js' } }, ['self'])).toEqual(['self']);
+ expect(check({ '.': { import: null, default: './dist/index.js' } }, ['self'])).toEqual(['self']);
+ expect(check({ '.': { types: { node: null }, default: './dist/index.js' } }, ['self'])).toEqual(['self']);
+ expect(check({ '.': [null, './dist/index.js'] }, ['self'])).toEqual([]);
+ expect(check({ browser: './dist/browser.js' }, ['self'])).toEqual(['self']);
+ // Invalid targets: `exports` never serves a bare specifier or a path that
+ // leaves the package; an array skips them, a selected condition does not.
+ expect(check({ '.': ['../outside.js', './dist/../escape.js', 'zod', './dist/index.js'] }, ['self'])).toEqual([]);
+ expect(check({ '.': ['../outside.js', 'zod'] }, ['self'])).toEqual(['self']);
+ expect(check({ '.': { import: '../outside.js', default: './dist/index.js' } }, ['self'])).toEqual(['self']);
+ // Node rejects `.`, `..`, and `node_modules` segments case-insensitively
+ // and after percent-decoding; an empty array blocks rather than falls through.
+ for (const target of ['./dist/./index.js', './dist/NODE_MODULES/x.js', './dist/%2e%2e/x.js', './dist/%6Eode_modules/x.js']) {
+ expect(check({ '.': target }, ['self'])).toEqual(['self']);
+ }
+ expect(check({ '.': [] }, ['self'])).toEqual(['self']);
+ expect(check({ '.': { import: [], default: './dist/index.js' } }, ['self'])).toEqual(['self']);
+ // No `exports` at all: every file resolves by path.
+ expect(check(undefined, ['self', 'self/dist/anything.js'])).toEqual([]);
+ });
+
+ it('resolves `#` imports through the imports map and classifies what they map to', () => {
+ const report = declarationImportViolations({
+ manifest: {
+ ...manifest,
+ imports: {
+ '#internal/*': './dist/internal/*.js',
+ '#pkg': { types: './dist/pkg.d.ts', import: './dist/pkg.js' },
+ '#browser-only': { browser: './dist/browser.js' },
+ '#dev': 'zod',
+ '#blocked': null,
+ '#blocked-condition': { import: null, default: './dist/pkg.js' },
+ '#array-fallback': [null, '../outside.js', '/abs.js', './dist/pkg.js'],
+ '#escapes': './dist/../../outside.js',
+ },
+ },
+ packedPaths: [...packedPaths, 'dist/internal/thing.d.ts', 'dist/pkg.d.ts'],
+ declarations: [
+ {
+ path: 'dist/index.d.ts',
+ text: [
+ "import type { Thing } from '#internal/thing';",
+ "import type { Pkg } from '#pkg';",
+ "import type { Fallback } from '#array-fallback';",
+ "import type { Missing } from '#internal/missing';",
+ "import type { Browser } from '#browser-only';",
+ "import type { Dev } from '#dev';",
+ "import type { Blocked } from '#blocked';",
+ "import type { BlockedCondition } from '#blocked-condition';",
+ "import type { Escapes } from '#escapes';",
+ "import type { Unmapped } from '#nope';",
+ // Node rejects these before consulting the map, pattern or not.
+ "import type { Bare } from '#';",
+ "import type { Slash } from '#/internal/thing';",
+ "import type { Trailing } from '#internal/thing/';",
+ ].join('\n'),
+ },
+ ],
+ });
+
+ expect(report.errors.map(({ reason, specifier }) => `${reason} ${specifier}`)).toEqual([
+ 'missing-target #internal/missing',
+ 'subpath-import #browser-only',
+ 'dev-dependency #dev',
+ 'subpath-import #blocked',
+ 'subpath-import #blocked-condition',
+ 'subpath-import #escapes',
+ 'subpath-import #nope',
+ 'subpath-import #',
+ 'subpath-import #/internal/thing',
+ 'subpath-import #internal/thing/',
+ ]);
+ expect(report.errors[0]?.message).toBe(
+ 'imports "#internal/missing" → "./dist/internal/missing.js": no packed declaration for "./dist/internal/missing.js" '
+ + '(tried dist/internal/missing.d.ts)',
+ );
+ expect(report.errors[2]?.message).toBe(
+ 'imports "#dev" → "zod": imports "zod" — "zod" is a devDependency, so consumers do not install it',
+ );
+ });
+
+ it('resolves the source extensions tsgo keeps and extensionless directory imports', () => {
+ const report = declarationImportViolations({
+ manifest: { name: 'fixture-package', exports: { '.': { types: './dist/index.d.ts' } } },
+ packedPaths: ['dist/index.d.ts', 'dist/a.d.ts', 'dist/b.d.mts', 'dist/c.d.cts', 'dist/dir/index.d.ts'],
+ declarations: [
+ {
+ path: 'dist/index.d.ts',
+ text: [
+ "export * from './a.ts';",
+ "export * from './a.tsx';",
+ "export * from './b.mjs';",
+ "export * from './b.mts';",
+ "export * from './c.cjs';",
+ "export * from './dir';",
+ "export * from './a';",
+ ].join('\n'),
+ },
+ { path: 'dist/a.d.ts', text: 'export {};' },
+ { path: 'dist/b.d.mts', text: 'export {};' },
+ { path: 'dist/c.d.cts', text: 'export {};' },
+ { path: 'dist/dir/index.d.ts', text: 'export {};' },
+ ],
+ });
+
+ expect(report.errors).toEqual([]);
+ expect(report.reachable.size).toBe(5);
+ });
+});
+
+describe('the packed-declaration gate', () => {
+ const writeFixturePack = async (files: Readonly>): Promise => {
+ const root = await mkdtemp(join(tmpdir(), 'agent-bundle-declaration-gate-'));
+ for (const [path, text] of Object.entries(files)) {
+ await mkdir(dirname(join(root, path)), { recursive: true });
+ await writeFile(join(root, path), text);
+ }
+ return root;
+ };
+
+ const badManifest = {
+ name: 'bad-fixture',
+ version: '0.0.0',
+ type: 'module',
+ exports: { '.': { types: './dist/index.d.ts', import: './dist/index.js' }, './package.json': './package.json' },
+ dependencies: { effect: '4.0.0' },
+ devDependencies: { zod: '4.5.4' },
+ };
+ const badFiles = {
+ 'package.json': `${JSON.stringify(badManifest, null, 2)}\n`,
+ 'dist/index.js': 'export const schema = 1;\n',
+ 'dist/index.d.ts': "import type { z } from 'zod';\nexport declare const schema: z.ZodType;\n",
+ };
+ const packed = ['package.json', 'dist/index.js', 'dist/index.d.ts'];
+
+ it('fails a fixture pack whose exported declaration imports a devDependency', async () => {
+ const root = await writeFixturePack(badFiles);
+ try {
+ const report = await checkPackedDeclarations({ manifest: badManifest, packageDirectory: root, packedPaths: packed });
+ expect(report.errors.map(({ path, reason, specifier }) => [path, reason, specifier])).toEqual([
+ ['dist/index.d.ts', 'dev-dependency', 'zod'],
+ ]);
+
+ const lines: string[] = [];
+ const exitCode = await runCheckDeclarationImports({
+ argv: [root],
+ inventory: async () => packed,
+ log: (line) => lines.push(line),
+ });
+ expect(exitCode).toBe(1);
+ expect(lines).toEqual([
+ 'bad-fixture: 1 packed declarations, 1 reachable from 1 export entries; 1 errors, 0 warnings',
+ ' error dist/index.d.ts:1 imports "zod" — "zod" is a devDependency, so consumers do not install it '
+ + '(reachable from exports["."])',
+ ]);
+ } finally {
+ await rm(root, { force: true, recursive: true });
+ }
+ });
+
+ it('passes the same pack once the import is declared, and lets --strict fail internal declarations', async () => {
+ const goodManifest = { ...badManifest, name: 'good-fixture', dependencies: { effect: '4.0.0', zod: '4.5.4' }, devDependencies: {} };
+ const root = await writeFixturePack({
+ ...badFiles,
+ 'package.json': `${JSON.stringify(goodManifest, null, 2)}\n`,
+ 'dist/internal.d.ts': "import type ts from 'typescript-5';\nexport type Node = ts.Node;\n",
+ });
+ const paths = [...packed, 'dist/internal.d.ts'];
+ try {
+ const lines: string[] = [];
+ const log = (line: string): void => {
+ lines.push(line);
+ };
+ expect(await runCheckDeclarationImports({ argv: [root], inventory: async () => paths, log })).toBe(0);
+ expect(lines).toEqual([
+ 'good-fixture: 2 packed declarations, 1 reachable from 1 export entries; 0 errors, 1 warnings',
+ ' warning dist/internal.d.ts:1 imports "typescript-5" — "typescript-5" is not declared in dependencies, '
+ + 'peerDependencies, or optionalDependencies (internal declaration; no export reaches it)',
+ ]);
+
+ lines.length = 0;
+ expect(await runCheckDeclarationImports({ argv: ['--strict', root], inventory: async () => paths, log })).toBe(1);
+ expect(lines[0]).toBe('good-fixture: 2 packed declarations, 1 reachable from 1 export entries; 1 errors, 0 warnings');
+ expect(lines[1]).toContain(' error dist/internal.d.ts:1 imports "typescript-5"');
+ } finally {
+ await rm(root, { force: true, recursive: true });
+ }
+ });
+
+ it('formats a clean report on one line', () => {
+ const report = declarationImportViolations({
+ manifest: { name: 'clean', exports: { '.': { types: './dist/index.d.ts' } } },
+ packedPaths: ['dist/index.d.ts'],
+ declarations: [{ path: 'dist/index.d.ts', text: 'export {};' }],
+ });
+ expect(formatDeclarationImportReport('clean', report)).toEqual([
+ 'clean: 1 packed declarations, 1 reachable from 1 export entries; 0 errors, 0 warnings',
+ ]);
+ });
+
+ it('rejects an unknown flag and an empty package list', async () => {
+ await expect(runCheckDeclarationImports({ argv: ['--bogus', 'x'] })).rejects.toThrow('Unknown argument: --bogus');
+ await expect(runCheckDeclarationImports({ argv: [] })).rejects.toThrow(/Usage: node scripts\/check-declaration-imports\.mjs/u);
+ });
+});
diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts
index 532c41f58..2f65ab57a 100644
--- a/packages/agent-bundle/tests/public-api.test.ts
+++ b/packages/agent-bundle/tests/public-api.test.ts
@@ -48,7 +48,8 @@ interface PackageManifest {
engines?: {
node?: string;
};
- exports: Record;
+ /** Code entries carry both conditions; `./package.json` is a plain file target. */
+ exports: Record;
version: string;
}
@@ -202,6 +203,10 @@ it('publishes directly executable built entrypoints with declarations', async ()
expect(manifest.engines?.node).toBe('>=22.19.0');
for (const entrypoint of Object.values(manifest.exports)) {
+ if (typeof entrypoint === 'string') {
+ await expect(access(join(packageRoot, entrypoint))).resolves.toBeUndefined();
+ continue;
+ }
await expect(access(join(packageRoot, entrypoint.import))).resolves.toBeUndefined();
await expect(access(join(packageRoot, entrypoint.types))).resolves.toBeUndefined();
}
@@ -270,6 +275,7 @@ it('keeps every public declaration graph free of effect', async () => {
const effectImport = /from\s+["']effect(?:\/|["'])/u;
const offenders: string[] = [];
for (const [name, entrypoint] of Object.entries(manifest.exports)) {
+ if (typeof entrypoint === 'string') continue;
const reachable = await reachableDeclarations(join(packageRoot, entrypoint.types));
for (const [file, source] of reachable) {
if (effectImport.test(source)) offenders.push(`${name} -> ${relative(packageRoot, file)}`);
diff --git a/packages/create-agent-bundle/package.json b/packages/create-agent-bundle/package.json
index a67bcdd77..d297d777a 100644
--- a/packages/create-agent-bundle/package.json
+++ b/packages/create-agent-bundle/package.json
@@ -37,6 +37,9 @@
"bin": {
"create-agent-bundle": "./bin/create-agent-bundle.js"
},
+ "exports": {
+ "./package.json": "./package.json"
+ },
"scripts": {
"build": "node ../../scripts/sync-license-files.mjs && rslib build",
"typecheck": "tsc -p tsconfig.json --noEmit"
diff --git a/packages/rsc-markdown-stream/package.json b/packages/rsc-markdown-stream/package.json
index a956f3334..e55523f5b 100644
--- a/packages/rsc-markdown-stream/package.json
+++ b/packages/rsc-markdown-stream/package.json
@@ -40,7 +40,8 @@
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
- }
+ },
+ "./package.json": "./package.json"
},
"scripts": {
"build": "node ../../scripts/sync-license-files.mjs && rslib build",
diff --git a/packages/rsc-markdown-stream/rslib.config.ts b/packages/rsc-markdown-stream/rslib.config.ts
index daab60814..438560e97 100644
--- a/packages/rsc-markdown-stream/rslib.config.ts
+++ b/packages/rsc-markdown-stream/rslib.config.ts
@@ -7,7 +7,9 @@ export default defineConfig({
bundle: true,
// The public types are the hand-written `src/index.d.ts`, copied into
// dist below; the renderer itself is plain ESM, so there is nothing
- // for a declaration emit to derive.
+ // for a declaration emit to derive. tests/types.ts compiles real calls
+ // against those declarations and tests/declarations.test.ts holds their
+ // value exports equal to the renderer's, so the copy cannot drift.
dts: false,
format: 'esm',
syntax: 'es2022',
diff --git a/packages/rsc-markdown-stream/tests/declarations.test.ts b/packages/rsc-markdown-stream/tests/declarations.test.ts
new file mode 100644
index 000000000..8eb258ee2
--- /dev/null
+++ b/packages/rsc-markdown-stream/tests/declarations.test.ts
@@ -0,0 +1,24 @@
+import { readFile } from 'node:fs/promises';
+
+import { expect, it } from '@rstest/core';
+
+import * as renderer from '../src/index.js';
+
+/**
+ * The published types are the hand-written `src/index.d.ts` (copied into
+ * `dist/` by rslib.config.ts, `dts: false`), so no compiler derives them from
+ * the renderer. `tests/types.ts` proves the declared signatures compile
+ * against real calls; this proves the declared value exports are exactly the
+ * ones `src/index.js` — the entry `dist/index.js` bundles — implements, so a
+ * renamed or added function cannot ship with a stale declaration.
+ */
+const declaredValueExports = (declaration: string): readonly string[] => [
+ ...declaration.matchAll(/^export (?:declare )?(?:async )?(?:function|const|let|var|class) (?[A-Za-z_$][\w$]*)/gmu),
+].map((match) => match.groups!['name']!);
+
+it('declares exactly the value exports the renderer implements', async () => {
+ const declaration = await readFile(new URL('../src/index.d.ts', import.meta.url), 'utf8');
+
+ expect([...declaredValueExports(declaration)].sort()).toEqual(Object.keys(renderer).sort());
+ expect(Object.keys(renderer).sort()).toEqual(['renderToMarkdown', 'renderToMarkdownStream']);
+});
diff --git a/scripts/check-declaration-imports.d.mts b/scripts/check-declaration-imports.d.mts
new file mode 100644
index 000000000..8e2ff2dc2
--- /dev/null
+++ b/scripts/check-declaration-imports.d.mts
@@ -0,0 +1,96 @@
+export type DeclarationSpecifierKind = 'import' | 'path-reference' | 'types-reference';
+
+export interface DeclarationSpecifier {
+ readonly kind: DeclarationSpecifierKind;
+ readonly line: number;
+ readonly specifier: string;
+}
+
+export type DeclarationViolationReason =
+ | 'dev-dependency'
+ | 'export-target-missing'
+ | 'missing-target'
+ | 'subpath-import'
+ | 'undeclared'
+ | 'unexported'
+ | 'unresolvable';
+
+export interface DeclarationViolation {
+ readonly line?: number;
+ readonly message: string;
+ readonly path: string;
+ /** The `exports` entry (or `types`/`typings`) a consumer reaches the declaration through; absent for internal declarations. */
+ readonly reachableFrom?: string;
+ readonly reason: DeclarationViolationReason;
+ readonly specifier: string;
+}
+
+export interface DeclarationManifest {
+ readonly name: string;
+ readonly dependencies?: Readonly>;
+ readonly devDependencies?: Readonly>;
+ readonly exports?: unknown;
+ readonly imports?: unknown;
+ readonly optionalDependencies?: Readonly>;
+ readonly peerDependencies?: Readonly>;
+ readonly types?: string;
+ readonly typings?: string;
+}
+
+export interface PackedDeclaration {
+ readonly path: string;
+ readonly text: string;
+}
+
+export interface DeclarationRoot {
+ readonly entry: string;
+ readonly path: string;
+}
+
+export interface DeclarationImportReport {
+ readonly declarationCount: number;
+ readonly errors: readonly DeclarationViolation[];
+ readonly reachable: ReadonlySet;
+ readonly roots: readonly DeclarationRoot[];
+ readonly warnings: readonly DeclarationViolation[];
+}
+
+export interface DeclarationImportInput {
+ readonly declarations: readonly PackedDeclaration[];
+ readonly manifest: DeclarationManifest;
+ readonly packedPaths: readonly string[];
+}
+
+export interface CheckPackedDeclarationsInput {
+ readonly manifest: DeclarationManifest;
+ readonly packageDirectory: string;
+ readonly packedPaths: readonly string[];
+ readonly readFile?: (path: string, encoding: 'utf8') => Promise;
+}
+
+export interface RunCheckDeclarationImportsOptions {
+ readonly argv?: readonly string[];
+ readonly cwd?: string;
+ readonly inventory?: (packageDirectory: string, manifest: DeclarationManifest) => Promise;
+ readonly log?: (line: string) => void;
+}
+
+export declare const isDeclarationPath: (path: string) => boolean;
+
+export declare const packageNameOf: (specifier: string) => string;
+
+export declare const declarationSpecifiers: (text: string) => readonly DeclarationSpecifier[];
+
+export declare const declarationImportViolations: (input: DeclarationImportInput) => DeclarationImportReport;
+
+export declare const checkPackedDeclarations: (input: CheckPackedDeclarationsInput) => Promise;
+
+export declare const packedPaths: (packageDirectory: string, manifest: DeclarationManifest) => Promise;
+
+export declare const formatDeclarationImportReport: (
+ name: string,
+ report: DeclarationImportReport,
+ options?: { readonly strict?: boolean },
+) => readonly string[];
+
+export declare const runCheckDeclarationImports: (options?: RunCheckDeclarationImportsOptions) => Promise;
diff --git a/scripts/check-declaration-imports.mjs b/scripts/check-declaration-imports.mjs
new file mode 100644
index 000000000..a5716292d
--- /dev/null
+++ b/scripts/check-declaration-imports.mjs
@@ -0,0 +1,592 @@
+/**
+ * Release gate for the type declarations a package ships: every `.d.ts` in
+ * the tarball may only reference modules a consumer can resolve — the
+ * package's own packed files, Node built-ins, and packages named in its
+ * `dependencies`, `peerDependencies`, or `optionalDependencies`.
+ *
+ * Rslib's per-file `dts: true` emits one declaration per source module, so
+ * the pack carries internals nothing under `exports` reaches. A declaration
+ * that imports a devDependency (`zod`, `typescript-5`) is latent while it is
+ * internal and becomes a consumer type error (`skipLibCheck: false`) the
+ * moment a re-export makes it reachable; attw only follows entry points and
+ * publint reads the manifest, so neither reports it. This script reads the
+ * whole inventory instead:
+ *
+ * - a violation in a declaration reachable from `exports` (or `types`) is
+ * an error and fails the gate;
+ * - a violation in an internal declaration is a warning, so the debt stays
+ * visible on every release run; `--strict` makes it an error too.
+ *
+ * Usage: node scripts/check-declaration-imports.mjs [--strict] ...
+ *
+ * The inventory is `npm pack --dry-run --json` — the files a publish would
+ * ship — and each listed declaration is read from the package directory,
+ * which is what npm copies into the tarball verbatim.
+ */
+import { execFile as executeFile } from 'node:child_process';
+import { readFile as readFileFromDisk } from 'node:fs/promises';
+import { isBuiltin } from 'node:module';
+import { join, posix, resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { promisify } from 'node:util';
+
+import { packOutputFromJson } from './npm-pack-json.mjs';
+
+const execFile = promisify(executeFile);
+const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+
+const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
+
+export const isDeclarationPath = (path) => /\.d\.[mc]?ts$/u.test(path);
+
+/** `@scope/name/sub/path` → `@scope/name`; `name/sub` → `name`. */
+export const packageNameOf = (specifier) => {
+ const segments = specifier.split('/');
+ return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
+};
+
+const isIdentifierStart = (character) => /[A-Za-z_$]/u.test(character);
+const isIdentifierPart = (character) => /[\w$]/u.test(character);
+
+const referenceDirective =
+ /^\/\/\/\s*types|path|lib|no-default-lib)\s*=\s*(?["'])(?[^"']*)\k/u;
+
+/**
+ * Splits a declaration file into the tokens the specifier scan needs — words,
+ * string literals, and single-character punctuation — dropping comments (a
+ * JSDoc line that reads `from 'driver'` is prose, not an import) and keeping
+ * triple-slash `` directives aside. Template literals are skipped
+ * whole: a module specifier is never a template.
+ */
+const tokenizeDeclaration = (text) => {
+ const tokens = [];
+ const directives = [];
+ let line = 1;
+ let index = 0;
+ while (index < text.length) {
+ const character = text[index];
+ if (character === '\n') {
+ line += 1;
+ index += 1;
+ continue;
+ }
+ if (/\s/u.test(character)) {
+ index += 1;
+ continue;
+ }
+ if (character === '/' && text[index + 1] === '/') {
+ const lineEnd = text.indexOf('\n', index);
+ const end = lineEnd === -1 ? text.length : lineEnd;
+ const directive = referenceDirective.exec(text.slice(index, end));
+ if (directive?.groups !== undefined) {
+ directives.push({ attribute: directive.groups.attribute, line, value: directive.groups.value });
+ }
+ index = end;
+ continue;
+ }
+ if (character === '/' && text[index + 1] === '*') {
+ const close = text.indexOf('*/', index + 2);
+ const end = close === -1 ? text.length : close + 2;
+ line += (text.slice(index, end).match(/\n/gu) ?? []).length;
+ index = end;
+ continue;
+ }
+ if (character === '"' || character === "'") {
+ let end = index + 1;
+ let value = '';
+ while (end < text.length && text[end] !== character && text[end] !== '\n') {
+ if (text[end] === '\\') {
+ value += text[end + 1] ?? '';
+ end += 2;
+ continue;
+ }
+ value += text[end];
+ end += 1;
+ }
+ tokens.push({ kind: 'string', line, value });
+ index = end + 1;
+ continue;
+ }
+ if (character === '`') {
+ let end = index + 1;
+ while (end < text.length && text[end] !== '`') {
+ if (text[end] === '\\') end += 1;
+ else if (text[end] === '\n') line += 1;
+ end += 1;
+ }
+ tokens.push({ kind: 'template', line, value: '' });
+ index = end + 1;
+ continue;
+ }
+ if (isIdentifierStart(character)) {
+ let end = index + 1;
+ while (end < text.length && isIdentifierPart(text[end])) end += 1;
+ tokens.push({ kind: 'word', line, value: text.slice(index, end) });
+ index = end;
+ continue;
+ }
+ tokens.push({ kind: 'punctuation', line, value: character });
+ index += 1;
+ }
+ return { directives, tokens };
+};
+
+/**
+ * The module specifiers a declaration file resolves: `import`/`export … from`,
+ * `import x = require()`, inline `import("x")` types, side-effect imports, and
+ * `/// ` directives. `declare module "x"` declares a
+ * module rather than resolving one and is not reported.
+ */
+export const declarationSpecifiers = (text) => {
+ const { directives, tokens } = tokenizeDeclaration(text);
+ const specifiers = [];
+ tokens.forEach((token, index) => {
+ if (token.kind !== 'string') return;
+ const previous = tokens[index - 1];
+ const before = tokens[index - 2];
+ const afterFrom = previous?.kind === 'word' && previous.value === 'from';
+ const afterImportKeyword = previous?.kind === 'word' && previous.value === 'import';
+ const insideCall = previous?.kind === 'punctuation'
+ && previous.value === '('
+ && before?.kind === 'word'
+ && (before.value === 'import' || before.value === 'require');
+ if (afterFrom || afterImportKeyword || insideCall) {
+ specifiers.push({ kind: 'import', line: token.line, specifier: token.value });
+ }
+ });
+ for (const directive of directives) {
+ if (directive.attribute === 'types') {
+ specifiers.push({ kind: 'types-reference', line: directive.line, specifier: directive.value });
+ } else if (directive.attribute === 'path') {
+ specifiers.push({ kind: 'path-reference', line: directive.line, specifier: directive.value });
+ }
+ }
+ return specifiers;
+};
+
+const normalizePackedPath = (target) => posix.normalize(target).replace(/^\.\//u, '');
+
+/**
+ * Declaration files a consumer's TypeScript enters the package through: every
+ * `.d.ts` target under `exports` (any condition, any depth) plus the legacy
+ * top-level `types`/`typings`.
+ */
+const declarationRoots = (manifest) => {
+ const roots = [];
+ const visit = (entry, target) => {
+ if (typeof target === 'string') {
+ if (isDeclarationPath(target)) roots.push({ entry, path: normalizePackedPath(target) });
+ } else if (Array.isArray(target)) {
+ for (const item of target) visit(entry, item);
+ } else if (isRecord(target)) {
+ for (const [key, value] of Object.entries(target)) visit(key.startsWith('.') ? key : entry, value);
+ }
+ };
+ visit('.', manifest.exports);
+ for (const field of ['types', 'typings']) {
+ if (typeof manifest[field] === 'string') visit(field, manifest[field]);
+ }
+ return roots;
+};
+
+/**
+ * Packed paths TypeScript would try for a relative specifier written in a
+ * declaration: `./x.js`, `./x.ts` (tsgo keeps the source extension), and
+ * `./x.tsx` map to `x.d.ts`, `.mjs`/`.mts` to `.d.mts`, `.cjs`/`.cts` to
+ * `.d.cts`; an extensionless specifier tries the file then the directory
+ * index; a `.json` or explicit declaration path is taken as written.
+ */
+const relativeTargets = (fromPath, specifier) => {
+ const base = posix.normalize(posix.join(posix.dirname(fromPath), specifier));
+ if (isDeclarationPath(base) || base.endsWith('.json')) return [base];
+ const extension = /\.([cm]?)[jt]sx?$/u.exec(base);
+ if (extension !== null) {
+ return [`${base.slice(0, -extension[0].length)}.d.${extension[1]}ts`];
+ }
+ return ['', '/index'].flatMap((suffix) => ['ts', 'mts', 'cts'].map((flavor) => `${base}${suffix}.d.${flavor}`));
+};
+
+const declaredPackages = (manifest) => ({
+ dev: new Set(Object.keys(manifest.devDependencies ?? {})),
+ runtime: new Set([
+ ...Object.keys(manifest.dependencies ?? {}),
+ ...Object.keys(manifest.peerDependencies ?? {}),
+ ...Object.keys(manifest.optionalDependencies ?? {}),
+ ]),
+});
+
+/** `node` → `@types/node`; `@scope/name` → `@types/scope__name` (DefinitelyTyped's mangling). */
+const typesPackageOf = (name) => (name.startsWith('@')
+ ? `@types/${name.slice(1).replace('/', '__')}`
+ : `@types/${name}`);
+
+const hasScheme = (specifier) => /^[a-z][a-z0-9+.-]*:/iu.test(specifier);
+
+/** An absolute path or a URL (anything but `node:`): never resolvable from a consumer's `node_modules`. */
+const isAbsoluteOrUrl = (specifier) => specifier.startsWith('/') || (hasScheme(specifier) && !specifier.startsWith('node:'));
+
+const isRelative = (specifier) => specifier.startsWith('./') || specifier.startsWith('../');
+
+/** Replaces the `*` in every string leaf of an `exports`/`imports` target with the matched text. */
+const substituteStar = (target, match) => {
+ if (typeof target === 'string') return target.replaceAll('*', match);
+ if (Array.isArray(target)) return target.map((entry) => substituteStar(entry, match));
+ if (isRecord(target)) {
+ return Object.fromEntries(Object.entries(target).map(([key, value]) => [key, substituteStar(value, match)]));
+ }
+ return target;
+};
+
+/**
+ * Node's subpath resolution over an `exports` or `imports` map: an exact key
+ * wins; otherwise the matching single-`*` pattern with the longest prefix
+ * (then the longest suffix) wins, and its `*` is substituted into the target.
+ * Returns the matched target — `null` when the entry blocks the subpath — or
+ * `undefined` when no key matches.
+ */
+const resolveSubpathMap = (map, subpath) => {
+ if (Object.hasOwn(map, subpath) && !subpath.includes('*')) return { target: map[subpath] };
+ let best;
+ for (const key of Object.keys(map)) {
+ const star = key.indexOf('*');
+ if (star === -1 || key.includes('*', star + 1)) continue;
+ const prefix = key.slice(0, star);
+ const suffix = key.slice(star + 1);
+ if (subpath.length < prefix.length + suffix.length || !subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
+ const longer = best === undefined
+ || prefix.length > best.prefix.length
+ || (prefix.length === best.prefix.length && suffix.length > best.suffix.length);
+ if (longer) best = { key, prefix, suffix };
+ }
+ if (best === undefined) return undefined;
+ const match = subpath.slice(best.prefix.length, subpath.length - best.suffix.length);
+ return { target: substituteStar(map[best.key], match) };
+};
+
+/** Conditions a Node ESM consumer's type checker matches, in the object order Node honours. */
+const activeConditions = new Set(['types', 'import', 'node', 'default']);
+
+/**
+ * Node's package-target validity: a `./` target may not climb out of the
+ * package or into `node_modules`; anything else is valid only as a bare
+ * specifier in `imports` (`internal`), never in `exports`.
+ */
+const isValidTarget = (target, internal) => {
+ if (target.startsWith('./')) {
+ return !target.split(/[/\\]/u).slice(1).some((segment) => invalidSegments.has(decodedSegment(segment)));
+ }
+ return internal && !target.startsWith('../') && !target.startsWith('/') && !hasScheme(target);
+};
+
+/** Segments Node rejects in a package target, compared after percent-decoding and case-folding. */
+const invalidSegments = new Set(['.', '..', 'node_modules']);
+
+const decodedSegment = (segment) => {
+ try {
+ return decodeURIComponent(segment).toLowerCase();
+ } catch {
+ return segment.toLowerCase();
+ }
+};
+
+/**
+ * The string a target resolves to under the active conditions; `null` when
+ * the selected branch blocks it or is invalid, `undefined` when nothing is
+ * selected. As in Node, a conditions object stops at the first active
+ * condition even when its value is `null` or invalid, while an array skips
+ * `null` and invalid entries and keeps looking.
+ */
+const targetString = (target, internal) => {
+ if (target === null) return null;
+ if (typeof target === 'string') return isValidTarget(target, internal) ? target : null;
+ if (Array.isArray(target)) {
+ if (target.length === 0) return null;
+ let blocked = false;
+ for (const entry of target) {
+ const resolved = targetString(entry, internal);
+ if (typeof resolved === 'string') return resolved;
+ if (resolved === null) blocked = true;
+ }
+ return blocked ? null : undefined;
+ }
+ if (!isRecord(target)) return undefined;
+ for (const [condition, value] of Object.entries(target)) {
+ if (!activeConditions.has(condition)) continue;
+ const resolved = targetString(value, internal);
+ if (resolved !== undefined) return resolved;
+ }
+ return undefined;
+};
+
+/** Whether a resolved `exports` target names a file under the active conditions. */
+const resolvesToFile = (target) => typeof targetString(target, false) === 'string';
+
+/**
+ * Whether the package's own `exports` serves `subpath` (`.` or `./x`): a
+ * string, array, or conditions-object `exports` serves only `.`; a subpath
+ * map is resolved like Node does, so a `null` entry blocks what a broader
+ * pattern would otherwise expose and a target no active condition selects is
+ * not served; a manifest without `exports` serves any file by path.
+ */
+const exportsSubpath = (manifest, subpath) => {
+ const { exports } = manifest;
+ if (exports === undefined || exports === null) return true;
+ if (typeof exports === 'string' || Array.isArray(exports)) return subpath === '.' && resolvesToFile(exports);
+ if (!isRecord(exports)) return false;
+ if (!Object.keys(exports).some((key) => key.startsWith('.'))) return subpath === '.' && resolvesToFile(exports);
+ const resolved = resolveSubpathMap(exports, subpath);
+ return resolved !== undefined && resolvesToFile(resolved.target);
+};
+
+/**
+ * Resolves a `#subpath` import through the manifest's `imports` map to the
+ * string it maps to — `undefined` when the specifier is one Node rejects
+ * outright (`#`, `#/…`, a trailing slash), when the map lacks or blocks it,
+ * or when it maps to something the active conditions do not select.
+ */
+const importsTarget = (manifest, specifier) => {
+ if (specifier === '#' || specifier.startsWith('#/') || specifier.endsWith('/')) return undefined;
+ if (!isRecord(manifest.imports)) return undefined;
+ const resolved = resolveSubpathMap(manifest.imports, specifier);
+ const mapped = resolved === undefined ? undefined : targetString(resolved.target, true);
+ return typeof mapped === 'string' ? mapped : undefined;
+};
+
+/**
+ * Where inside the tarball a specifier may land: a relative import or
+ * `/// ` resolves from the declaration's directory, a `#`
+ * import mapped to a relative file resolves from the package root.
+ * `undefined` for anything that leaves the package.
+ */
+const packedCandidates = ({ manifest, path, specifier }) => {
+ const { kind, specifier: target } = specifier;
+ if (isAbsoluteOrUrl(target)) return undefined;
+ if (kind === 'path-reference') return [posix.normalize(posix.join(posix.dirname(path), target))];
+ if (isRelative(target)) return relativeTargets(path, target);
+ if (kind === 'import' && target.startsWith('#')) {
+ const mapped = importsTarget(manifest, target);
+ return mapped !== undefined && isRelative(mapped) ? relativeTargets('package.json', mapped) : undefined;
+ }
+ return undefined;
+};
+
+/**
+ * Classifies one specifier against the manifest and the tarball. Returns
+ * `undefined` when a consumer resolves it, otherwise the violation reason and
+ * message.
+ */
+const classifySpecifier = ({ declared, manifest, packed, path, specifier }) => {
+ const { kind, specifier: target } = specifier;
+ if (isAbsoluteOrUrl(target)) {
+ return {
+ message: `${kind === 'import' ? 'imports' : 'references'} "${target}" — an absolute path or URL cannot resolve in a consumer install`,
+ reason: 'unresolvable',
+ };
+ }
+ if (kind === 'path-reference' || isRelative(target)) {
+ const candidates = packedCandidates({ manifest, path, specifier });
+ if (candidates.some((candidate) => packed.has(candidate))) return undefined;
+ return {
+ message: `no packed declaration for "${target}" (tried ${candidates.join(', ')})`,
+ reason: 'missing-target',
+ };
+ }
+ if (kind === 'types-reference') {
+ const candidates = [target, typesPackageOf(target)];
+ if (candidates.some((candidate) => declared.runtime.has(candidate))) return undefined;
+ const dev = candidates.find((candidate) => declared.dev.has(candidate));
+ return dev === undefined
+ ? {
+ message: `references types "${target}" — neither "${target}" nor "${typesPackageOf(target)}" is declared in `
+ + 'dependencies, peerDependencies, or optionalDependencies',
+ reason: 'undeclared',
+ }
+ : { message: `references types "${target}" — "${dev}" is a devDependency, so consumers do not install it`, reason: 'dev-dependency' };
+ }
+ if (target.startsWith('#')) {
+ // `imports` targets are package-root-relative files or bare specifiers
+ // (Node rejects `#` targets), so the mapped string is classified as if
+ // `package.json` itself had imported it.
+ const mapped = importsTarget(manifest, target);
+ if (mapped === undefined || mapped.startsWith('#')) {
+ return { message: `imports "${target}" — the manifest's "imports" map does not resolve it`, reason: 'subpath-import' };
+ }
+ const violation = classifySpecifier({
+ declared,
+ manifest,
+ packed,
+ path: 'package.json',
+ specifier: { kind: 'import', specifier: mapped },
+ });
+ return violation === undefined
+ ? undefined
+ : { ...violation, message: `imports "${target}" → "${mapped}": ${violation.message}` };
+ }
+ if (isBuiltin(target)) return undefined;
+ const name = packageNameOf(target);
+ if (name === manifest.name) {
+ const subpath = `.${target.slice(name.length)}`;
+ return exportsSubpath(manifest, subpath)
+ ? undefined
+ : { message: `imports "${target}" — the package's own "exports" has no entry for "${subpath}"`, reason: 'unexported' };
+ }
+ if (declared.runtime.has(name)) return undefined;
+ return declared.dev.has(name)
+ ? { message: `imports "${target}" — "${name}" is a devDependency, so consumers do not install it`, reason: 'dev-dependency' }
+ : {
+ message: `imports "${target}" — "${name}" is not declared in dependencies, peerDependencies, or optionalDependencies`,
+ reason: 'undeclared',
+ };
+};
+
+/**
+ * Checks every packed declaration against the manifest. `errors` are
+ * violations a consumer can reach from `exports`/`types` (plus export targets
+ * missing from the tarball); `warnings` are violations in internal
+ * declarations no entry point reaches.
+ */
+export const declarationImportViolations = ({ declarations, manifest, packedPaths }) => {
+ const packed = new Set(packedPaths.map(normalizePackedPath));
+ const declared = declaredPackages(manifest);
+ const specifiersByPath = new Map(declarations.map(({ path, text }) => [normalizePackedPath(path), declarationSpecifiers(text)]));
+ const roots = declarationRoots(manifest);
+ const errors = [];
+ const warnings = [];
+
+ const reachable = new Map();
+ const pending = [];
+ for (const root of roots) {
+ if (!packed.has(root.path)) {
+ errors.push({
+ message: `exports["${root.entry}"] points at ${root.path}, which is not in the tarball`,
+ path: root.path,
+ reachableFrom: root.entry,
+ reason: 'export-target-missing',
+ specifier: root.path,
+ });
+ continue;
+ }
+ if (!reachable.has(root.path)) {
+ reachable.set(root.path, root.entry);
+ pending.push(root.path);
+ }
+ }
+ while (pending.length > 0) {
+ const path = pending.pop();
+ const entry = reachable.get(path);
+ for (const specifier of specifiersByPath.get(path) ?? []) {
+ const candidates = packedCandidates({ manifest, path, specifier });
+ const target = candidates?.find((candidate) => specifiersByPath.has(candidate));
+ if (target !== undefined && !reachable.has(target)) {
+ reachable.set(target, entry);
+ pending.push(target);
+ }
+ }
+ }
+
+ for (const [path, specifiers] of specifiersByPath) {
+ for (const specifier of specifiers) {
+ const verdict = classifySpecifier({ declared, manifest, packed, path, specifier });
+ if (verdict === undefined) continue;
+ const reachableFrom = reachable.get(path);
+ const violation = {
+ line: specifier.line,
+ message: verdict.message,
+ path,
+ reachableFrom,
+ reason: verdict.reason,
+ specifier: specifier.specifier,
+ };
+ (reachableFrom === undefined ? warnings : errors).push(violation);
+ }
+ }
+ const byLocation = (left, right) => left.path.localeCompare(right.path) || (left.line ?? 0) - (right.line ?? 0);
+ return {
+ declarationCount: specifiersByPath.size,
+ errors: errors.sort(byLocation),
+ reachable: new Set(reachable.keys()),
+ roots,
+ warnings: warnings.sort(byLocation),
+ };
+};
+
+/** Reads every declaration the inventory lists from the package directory and checks it. */
+export const checkPackedDeclarations = async ({ manifest, packageDirectory, packedPaths, readFile = readFileFromDisk }) => {
+ const declarations = await Promise.all(packedPaths
+ .filter((path) => isDeclarationPath(path))
+ .map(async (path) => ({ path, text: await readFile(join(packageDirectory, path), 'utf8') })));
+ return declarationImportViolations({ declarations, manifest, packedPaths });
+};
+
+/** The paths `npm pack` would ship for the package, selected by name from the workspace-aware output. */
+export const packedPaths = async (packageDirectory, manifest) => {
+ const { stdout } = await execFile(npm, ['pack', '--dry-run', '--json'], {
+ cwd: packageDirectory,
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+ timeout: 600_000,
+ });
+ const files = packOutputFromJson(stdout, manifest.name).files;
+ if (!Array.isArray(files)) {
+ throw new TypeError(`npm pack --dry-run --json for ${manifest.name} listed no files.`);
+ }
+ return files.map((file) => file.path);
+};
+
+export const formatDeclarationImportReport = (name, report, { strict = false } = {}) => {
+ const failing = strict ? [...report.errors, ...report.warnings] : report.errors;
+ const advisory = strict ? [] : report.warnings;
+ const lines = [
+ `${name}: ${String(report.declarationCount)} packed declarations, ${String(report.reachable.size)} reachable from `
+ + `${String(report.roots.length)} export entries; ${String(failing.length)} errors, ${String(advisory.length)} warnings`,
+ ];
+ const location = (violation) => (violation.line === undefined ? violation.path : `${violation.path}:${String(violation.line)}`);
+ for (const violation of failing) {
+ const via = violation.reachableFrom === undefined ? 'internal declaration' : `reachable from exports["${violation.reachableFrom}"]`;
+ lines.push(` error ${location(violation)} ${violation.message} (${via})`);
+ }
+ for (const violation of advisory) {
+ lines.push(` warning ${location(violation)} ${violation.message} (internal declaration; no export reaches it)`);
+ }
+ return lines;
+};
+
+const parseArguments = (argv) => {
+ const options = { packageDirectories: [], strict: false };
+ for (const argument of argv) {
+ if (argument === '--strict') options.strict = true;
+ else if (argument.startsWith('-')) throw new Error(`Unknown argument: ${argument}`);
+ else options.packageDirectories.push(argument);
+ }
+ if (options.packageDirectories.length === 0) {
+ throw new Error('Usage: node scripts/check-declaration-imports.mjs [--strict] ...');
+ }
+ return options;
+};
+
+/** Runs the gate for each package directory; resolves to the process exit code. */
+export const runCheckDeclarationImports = async ({
+ argv = process.argv.slice(2),
+ cwd = process.cwd(),
+ inventory = packedPaths,
+ log = (line) => process.stdout.write(`${line}\n`),
+} = {}) => {
+ const options = parseArguments(argv);
+ let failed = false;
+ for (const directory of options.packageDirectories) {
+ const packageDirectory = resolve(cwd, directory);
+ const manifest = JSON.parse(await readFileFromDisk(join(packageDirectory, 'package.json'), 'utf8'));
+ const paths = await inventory(packageDirectory, manifest);
+ const report = await checkPackedDeclarations({ manifest, packageDirectory, packedPaths: paths });
+ for (const line of formatDeclarationImportReport(manifest.name, report, options)) log(line);
+ if (report.errors.length > 0 || (options.strict && report.warnings.length > 0)) failed = true;
+ }
+ return failed ? 1 : 0;
+};
+
+const invokedDirectly = process.argv[1] !== undefined
+ && import.meta.url === pathToFileURL(process.argv[1]).href;
+
+if (invokedDirectly) {
+ process.exitCode = await runCheckDeclarationImports();
+}
diff --git a/website/docs/en/guide/distribution/preview-packages.mdx b/website/docs/en/guide/distribution/preview-packages.mdx
index 2bf731cd4..d62443edf 100644
--- a/website/docs/en/guide/distribution/preview-packages.mdx
+++ b/website/docs/en/guide/distribution/preview-packages.mdx
@@ -63,11 +63,21 @@ Before that path is enabled, the release owner has to resolve two things: the fi
and license, and the repository-wide `"access": "restricted"` policy for `agent-bundle`, which is
not currently overridden with `publishConfig.access`.
-`pnpm release` runs the release gate — `pnpm pack:dry-run`, `pnpm lint:release` (`attw` on the
-packed `agent-bundle` tarball with the `esm-only` profile), and `pnpm test:packed:release` — before
-publishing. publint is not a separate gate: every publishable package's `rslib build` runs it
-through `rsbuild-plugin-publint` and fails the build on a warning. That gate is release-only and
-does not replace the ordinary `pnpm check` delivery gate.
+`pnpm release` runs the release gate — `pnpm pack:dry-run`, `pnpm lint:release`, and
+`pnpm test:packed:release` — before publishing. `lint:release` runs `attw` with the `esm-only`
+profile on the packed `agent-bundle`, `rsc-markdown-stream`, and `create-agent-bundle` tarballs,
+then `scripts/check-declaration-imports.mjs` over the same inventories: every `.d.ts` a consumer can
+reach from `exports` may only import packed files, the package's own exported subpaths, `#` imports
+its `imports` map resolves, Node built-ins, and packages declared in `dependencies`,
+`peerDependencies`, or `optionalDependencies` —
+absolute paths and URLs never resolve in a consumer install, and a devDependency such as `zod` in a
+reachable declaration fails the gate, while one in an internal declaration no entry point reaches is
+reported as a warning (`--strict` fails those too; `lint:release` does not pass it). Reachability
+starts from every `types` target under `exports` plus the legacy `types` field, `/// ` is checked like a bare import, and a relative import, `/// `, or `exports`
+target that names a file missing from the tarball fails too. publint is not a separate gate: every
+publishable package's `rslib build` runs it through `rsbuild-plugin-publint` and fails the build on
+a warning. That gate is release-only and does not replace the ordinary `pnpm check` delivery gate.
## Next
diff --git a/website/docs/zh/guide/distribution/preview-packages.mdx b/website/docs/zh/guide/distribution/preview-packages.mdx
index 26406b57c..f2b3784ac 100644
--- a/website/docs/zh/guide/distribution/preview-packages.mdx
+++ b/website/docs/zh/guide/distribution/preview-packages.mdx
@@ -53,10 +53,18 @@ npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@` 按裸导入同样检查;相对导入、
+`/// ` 或 `exports` 目标指向压缩包中不存在的文件时也会失败。publint 不是单独的门禁:每个可发布包的
+`rslib build` 都会通过 `rsbuild-plugin-publint` 运行它,并在出现 warning 时让构建失败。该门禁仅用于
+发布,并不替代日常的 `pnpm check` 交付门禁。
## 下一步