diff --git a/.changeset/strict-declaration-imports.md b/.changeset/strict-declaration-imports.md new file mode 100644 index 000000000..e82a59939 --- /dev/null +++ b/.changeset/strict-declaration-imports.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Ship type declarations that reference only packages a consumer can resolve: `dist/events/ipc.d.ts` no longer imports `zod` (`EventRuntimeAvailability` keeps the same `'available' | 'runtime-restarted' | 'runtime-unavailable'` union) and `dist/routes/input-schema.d.ts` no longer imports `typescript-5`, so a consumer type-checking with `skipLibCheck: false` never has to resolve an `agent-bundle` devDependency. The release gate enforces this from now on: `pnpm lint:release` runs `scripts/check-declaration-imports.mjs --strict`, so a devDependency or undeclared package imported from any packed `.d.ts` — internal declarations included, not only those reachable from `exports` — fails the gate instead of printing a warning. (#586) diff --git a/docs/preview-packages.md b/docs/preview-packages.md index 8d4646ede..cb2fdc494 100644 --- a/docs/preview-packages.md +++ b/docs/preview-packages.md @@ -60,7 +60,8 @@ npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@5685 npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@5685521 ``` -pnpm and yarn accept the same URLs (`pnpm add `, `yarn add agent-bundle@`). +pnpm and yarn accept the same URLs (`pnpm add `, `yarn add agent-bundle@`); +pnpm 11 additionally needs the `blockExoticSubdeps` setting described below. Previews carry the version string `0.0.0-preview-`, and the publish (`--peerDeps`) rewrites every peer range that points at a sibling workspace @@ -79,6 +80,13 @@ before the peer rewrite landed (PR #46, fixing #45) still carry the original package, so pair-installing those older shas with npm still requires `--legacy-peer-deps`. +That rewrite is what pnpm 11 rejects by default: `blockExoticSubdeps` (default +`true` since pnpm 11) forbids a transitive dependency resolved from a tarball +URL, so `pnpm add` of a preview `@agent-bundle/runtime` fails with +`ERR_PNPM_EXOTIC_SUBDEP` on its rewritten `rsc-markdown-stream` dependency. Set +`blockExoticSubdeps: false` in the consuming project's `pnpm-workspace.yaml`, +or install previews with npm. + ## How an npm release will flow Versioning is driven by Changesets (`.changeset/README.md`). Every PR that diff --git a/package.json b/package.json index be3c49625..740859ecb 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 && attw --pack --profile esm-only packages/rsc-runtime && 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-runtime packages/rsc-markdown-stream packages/create-agent-bundle", + "lint:release": "attw --pack --profile esm-only packages/agent-bundle && attw --pack --profile esm-only packages/rsc-runtime && attw --pack --profile esm-only packages/rsc-markdown-stream && attw --pack --profile esm-only packages/create-agent-bundle && node scripts/check-declaration-imports.mjs --strict packages/agent-bundle packages/rsc-runtime 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/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 352f66011..71ce80dc5 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -53,8 +53,8 @@ export default defineConfig({ // 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. + // (scripts/check-declaration-imports.mjs --strict via `pnpm lint:release`): + // no packed declaration, reachable or not, may import a devDependency. dts: true, format: 'esm', syntax: 'es2022', diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index 09f3fb1b2..572b81377 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -73,7 +73,15 @@ const eventStatusRequestSchema = z.object({ protocolVersion: z.literal(EVENT_RUNTIME_PROTOCOL_VERSION), }).strict(); -const runtimeAvailabilitySchema = z.enum(['available', 'runtime-restarted', 'runtime-unavailable']); +const eventRuntimeAvailabilities = ['available', 'runtime-restarted', 'runtime-unavailable'] as const; +/** + * Whether the shared event runtime answering a status probe is the one the + * hook client expects. Derived from the tuple, not from the zod schema, so + * the shipped declaration of this module does not import `zod` — a + * devDependency consumers never install (scripts/check-declaration-imports.mjs). + */ +export type EventRuntimeAvailability = (typeof eventRuntimeAvailabilities)[number]; +const runtimeAvailabilitySchema = z.enum(eventRuntimeAvailabilities); const eventRuntimeStatusPayloadSchema = z.object({ artifactEpoch: z.string().min(1), availability: runtimeAvailabilitySchema, @@ -122,7 +130,6 @@ export interface EventRuntimeRequest { readonly target: string; } -export type EventRuntimeAvailability = z.infer; export interface EventRuntimeStatus { readonly artifactEpoch: string; readonly availability: EventRuntimeAvailability; diff --git a/packages/agent-bundle/src/routes/config-extract.ts b/packages/agent-bundle/src/routes/config-extract.ts index decee4f3f..37eeb4a0c 100644 --- a/packages/agent-bundle/src/routes/config-extract.ts +++ b/packages/agent-bundle/src/routes/config-extract.ts @@ -8,8 +8,8 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { hasExportModifier, positionOf, unwrapExpression } from './input-schema.ts'; import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from './module-candidates.ts'; +import { hasExportModifier, positionOf, unwrapExpression } from './syntax.ts'; import { emptyRouteConfig } from './types.ts'; /** The package subpath route modules import compile-time authoring helpers from. */ diff --git a/packages/agent-bundle/src/routes/input-schema.ts b/packages/agent-bundle/src/routes/input-schema.ts index dec130ad8..abb7b0605 100644 --- a/packages/agent-bundle/src/routes/input-schema.ts +++ b/packages/agent-bundle/src/routes/input-schema.ts @@ -1,8 +1,9 @@ -// Aliased for the same reason as cli-argv.ts: this is a parser-only use of the -// TypeScript 5.x compiler API and route modules are never executed. +// Aliased for the same reason as config-extract.ts: this is a parser-only use +// of the TypeScript 5.x compiler API and route modules are never executed. import ts from 'typescript-5'; import { deepFreeze } from '../core/freeze.ts'; +import { hasExportModifier, positionOf, unwrapExpression } from './syntax.ts'; import type { RouteInputArrayItemSchema, RouteInputPropertySchema, @@ -10,13 +11,16 @@ import type { RouteInputSchemaLiteral, } from './types.ts'; -export interface ChainCall { +// The zod-chain grammar below is this module's own; nothing else imports it. +// Keeping it module-private also keeps `ts.*` out of the shipped declaration +// (see syntax.ts), where `typescript-5` would be unresolvable for consumers. +interface ChainCall { readonly args: readonly ts.Expression[]; readonly method: string; readonly node: ts.Node; } -export interface ZodChain { +interface ZodChain { readonly base: ChainCall; readonly calls: readonly ChainCall[]; } @@ -49,28 +53,8 @@ export type ParsedInputSchemaEntry = | Readonly<{ readonly issue: string }> | Readonly<{ readonly property: StaticInputSchemaProperty }>; -/** Casts, assertions, and parentheses carry no runtime value; unwrap them. */ -export const unwrapExpression = (expression: ts.Expression): ts.Expression => { - let current = expression; - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isSatisfiesExpression(current) || - ts.isNonNullExpression(current) || - ts.isTypeAssertionExpression(current) - ) { - current = current.expression; - } - return current; -}; - -export const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { - const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); - return `${line + 1}:${character + 1}`; -}; - /** Flattens `z.base(...).m1(...).m2(...)` into base + ordered calls. */ -export const flattenZodChain = (expression: ts.Expression): ZodChain | undefined => { +const flattenZodChain = (expression: ts.Expression): ZodChain | undefined => { const calls: ChainCall[] = []; let current = unwrapExpression(expression); while (ts.isCallExpression(current) && ts.isPropertyAccessExpression(current.expression)) { @@ -90,7 +74,7 @@ type StaticLiteral = | { readonly kind: 'dynamic'; readonly node: ts.Node }; /** Static literal grammar used by `.default(...)`; callers decide which values they can expose. */ -export const staticLiteral = (expression: ts.Expression): StaticLiteral => { +const staticLiteral = (expression: ts.Expression): StaticLiteral => { const node = unwrapExpression(expression); if (node.kind === ts.SyntaxKind.TrueKeyword) return { kind: 'value', value: true }; if (node.kind === ts.SyntaxKind.FalseKeyword) return { kind: 'value', value: false }; @@ -151,7 +135,7 @@ type ScalarBaseResult = | { readonly message: string; readonly ok: false }; /** Interprets one `z.(...)` call as a bounded scalar projection base. */ -export const scalarBaseOf = ( +const scalarBaseOf = ( chain: ZodChain, sourceFile: ts.SourceFile, relativePath: string, @@ -300,10 +284,6 @@ const bindsInputSchemaName = (name: ts.BindingName): boolean => { !ts.isOmittedExpression(element) && bindsInputSchemaName(element.name)); }; -export const hasExportModifier = (statement: ts.Statement): boolean => - ts.canHaveModifiers(statement) && - (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); - const findInputSchemaExport = (sourceFile: ts.SourceFile): InputSchemaExportSite | undefined => { for (const statement of sourceFile.statements) { if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { diff --git a/packages/agent-bundle/src/routes/syntax.ts b/packages/agent-bundle/src/routes/syntax.ts new file mode 100644 index 000000000..456b2a98d --- /dev/null +++ b/packages/agent-bundle/src/routes/syntax.ts @@ -0,0 +1,68 @@ +// Aliased for the same reason as config-extract.ts: this is a parser-only use +// of the TypeScript 5.x compiler API, bundled into the package (#381). +import ts from 'typescript-5'; + +/** + * The structural slice of a TypeScript AST node the helpers below need. They + * are declared here, not as `ts.*`, because `typescript-5` is a devDependency + * this package bundles and consumers never install: naming its types in an + * exported signature would put `import 'typescript-5'` into the shipped + * declaration of this module, which `pnpm lint:release` + * (scripts/check-declaration-imports.mjs) rejects. Every `ts.Node` satisfies + * them, so the extractors keep passing compiler nodes and narrowing the + * results with the compiler's own guards. + */ +export interface SyntaxNode { + readonly kind: number; + getStart(sourceFile?: SyntaxSourceFile): number; +} + +/** The slice of `ts.SourceFile` that maps a position to its line and column. */ +export interface SyntaxSourceFile { + getLineAndCharacterOfPosition(position: number): { readonly character: number; readonly line: number }; +} + +/** A top-level statement, which may carry modifiers such as `export`. */ +export interface SyntaxStatement extends SyntaxNode { + readonly modifiers?: readonly SyntaxNode[]; +} + +/** A node that wraps another expression without changing its runtime value. */ +interface TransparentWrapper extends SyntaxNode { + readonly expression: SyntaxNode; +} + +const transparentWrapperKinds: ReadonlySet = new Set([ + ts.SyntaxKind.AsExpression, + ts.SyntaxKind.NonNullExpression, + ts.SyntaxKind.ParenthesizedExpression, + ts.SyntaxKind.SatisfiesExpression, + ts.SyntaxKind.TypeAssertionExpression, +]); + +const isTransparentWrapper = (node: SyntaxNode): node is TransparentWrapper => transparentWrapperKinds.has(node.kind); + +/** + * Casts, assertions, and parentheses carry no runtime value; unwrap them. + * The result is typed as the argument: exact for `ts.Expression` (every + * wrapper is one, so the innermost node is one too) and safe for the + * brand-only families below it (`ts.UnaryExpression`, + * `ts.LeftHandSideExpression`), which add no members. Never pass a wrapper + * type itself, such as `ts.ParenthesizedExpression`, whose `expression` + * member the result lacks. + */ +export const unwrapExpression = (expression: Expression): Expression => { + let current: SyntaxNode = expression; + while (isTransparentWrapper(current)) current = current.expression; + return current as Expression; +}; + +/** The 1-based `line:column` of `node` in `sourceFile`, the form every extractor diagnostic quotes. */ +export const positionOf = (sourceFile: SyntaxSourceFile, node: SyntaxNode): string => { + const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + return `${line + 1}:${character + 1}`; +}; + +/** Whether a top-level statement carries the `export` modifier. */ +export const hasExportModifier = (statement: SyntaxStatement): boolean => + (statement.modifiers ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); diff --git a/scripts/check-declaration-imports.mjs b/scripts/check-declaration-imports.mjs index a5716292d..e169d55b0 100644 --- a/scripts/check-declaration-imports.mjs +++ b/scripts/check-declaration-imports.mjs @@ -14,8 +14,9 @@ * * - 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. + * - a violation in an internal declaration is reported as a warning + * without `--strict`; `pnpm lint:release` passes `--strict`, so it fails + * the release gate as an error too. * * Usage: node scripts/check-declaration-imports.mjs [--strict] ... * diff --git a/website/docs/en/guide/distribution/preview-packages.mdx b/website/docs/en/guide/distribution/preview-packages.mdx index 26b4c0232..12e8873c9 100644 --- a/website/docs/en/guide/distribution/preview-packages.mdx +++ b/website/docs/en/guide/distribution/preview-packages.mdx @@ -43,6 +43,12 @@ Previews published before the peer rewrite landed (PR #46) still carry the origi `agent-bundle@^0.1.0` peer range, so pair-installing those older SHAs with npm still requires `--legacy-peer-deps`. Anything newer installs with stock npm. +Stock pnpm 11 is the exception. The preview of `@agent-bundle/runtime` points its `rsc-markdown-stream` +dependency at the renderer's same-sha preview tarball, and pnpm 11's `blockExoticSubdeps` (default +`true`) rejects a transitive dependency resolved from a tarball URL with `ERR_PNPM_EXOTIC_SUBDEP`. +Set `blockExoticSubdeps: false` in the consuming project's `pnpm-workspace.yaml`, or install previews +with npm. + The scaffolder ships on the same channel and is meant to be run rather than installed: ```sh @@ -71,9 +77,10 @@ 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 +absolute paths and URLs never resolve in a consumer install, and a devDependency such as `zod` in +any packed declaration fails the gate — `lint:release` runs the check with `--strict`, so an +internal declaration no entry point reaches fails like a reachable one (without the flag it is only +reported as a warning). 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 diff --git a/website/docs/zh/guide/distribution/preview-packages.mdx b/website/docs/zh/guide/distribution/preview-packages.mdx index 8e5656682..ffc266eeb 100644 --- a/website/docs/zh/guide/distribution/preview-packages.mdx +++ b/website/docs/zh/guide/distribution/preview-packages.mdx @@ -34,6 +34,11 @@ PR 引用会解析到**该 PR 最近一次发布的预览**,因此 `@1` 表示 在 peer 改写落地(PR #46)之前发布的预览包仍携带原始的 `agent-bundle@^0.1.0` peer 范围,因此用 npm 配对安装那些较早的 SHA 仍然需要 `--legacy-peer-deps`。更新的预览包用原生 npm 即可安装。 +原生 pnpm 11 是例外。`@agent-bundle/runtime` 的预览包会把它的 `rsc-markdown-stream` 依赖指向渲染器同一 +SHA 的预览 tarball,而 pnpm 11 的 `blockExoticSubdeps`(默认 `true`)会拒绝从 tarball URL 解析的传递依赖, +报 `ERR_PNPM_EXOTIC_SUBDEP`。请在消费项目的 `pnpm-workspace.yaml` 中设置 `blockExoticSubdeps: false`, +或改用 npm 安装预览包。 + 脚手架发布在同一条通道上,其设计意图是直接运行而不是安装: ```sh @@ -59,8 +64,8 @@ npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@` 按裸导入同样检查;相对导入、 `/// ` 或 `exports` 目标指向压缩包中不存在的文件时也会失败。publint 不是单独的门禁:每个可发布包的 `rslib build` 都会通过 `rsbuild-plugin-publint` 运行它,并在出现 warning 时让构建失败。该门禁仅用于