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` 交付门禁。 ## 下一步