Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/strict-declaration-imports.md
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 9 additions & 1 deletion docs/preview-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>`, `yarn add agent-bundle@<url>`).
pnpm and yarn accept the same URLs (`pnpm add <url>`, `yarn add agent-bundle@<url>`);
pnpm 11 additionally needs the `blockExoticSubdeps` setting described below.

Previews carry the version string `0.0.0-preview-<sha>`, and the publish
(`--peerDeps`) rewrites every peer range that points at a sibling workspace
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-bundle/src/events/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,7 +130,6 @@ export interface EventRuntimeRequest {
readonly target: string;
}

export type EventRuntimeAvailability = z.infer<typeof runtimeAvailabilitySchema>;
export interface EventRuntimeStatus {
readonly artifactEpoch: string;
readonly availability: EventRuntimeAvailability;
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/routes/config-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
42 changes: 11 additions & 31 deletions packages/agent-bundle/src/routes/input-schema.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
// 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,
RouteInputSchema,
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[];
}
Expand Down Expand Up @@ -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)) {
Expand All @@ -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 };
Expand Down Expand Up @@ -151,7 +135,7 @@ type ScalarBaseResult =
| { readonly message: string; readonly ok: false };

/** Interprets one `z.<base>(...)` call as a bounded scalar projection base. */
export const scalarBaseOf = (
const scalarBaseOf = (
chain: ZodChain,
sourceFile: ts.SourceFile,
relativePath: string,
Expand Down Expand Up @@ -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)) {
Expand Down
68 changes: 68 additions & 0 deletions packages/agent-bundle/src/routes/syntax.ts
Original file line number Diff line number Diff line change
@@ -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<number> = new Set<number>([
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 extends SyntaxNode>(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);
5 changes: 3 additions & 2 deletions scripts/check-declaration-imports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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] <package-dir>...
*
Expand Down
13 changes: 10 additions & 3 deletions website/docs/en/guide/distribution/preview-packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, `/// <reference
types>` is checked like a bare import, and a relative import, `/// <reference path>`, or `exports`
target that names a file missing from the tarball fails too. publint is not a separate gate: every
Expand Down
9 changes: 7 additions & 2 deletions website/docs/zh/guide/distribution/preview-packages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,8 +64,8 @@ npx https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@<sha-or-
`scripts/check-declaration-imports.mjs` 检查同一份文件清单:消费者能从 `exports` 触达的每个 `.d.ts`
只能导入包内文件、包自身通过 `exports` 公开的子路径、能被 `imports` 映射解析的 `#` 导入、Node 内建模块,以及在 `dependencies`、
`peerDependencies` 或 `optionalDependencies` 中声明的包——绝对路径和 URL 在消费者的安装中永远无法解析,
可触达声明里出现 `zod` 这类 devDependency 会让门禁失败,而没有任何
入口触达的内部声明只报告为 warning(`--strict` 会让它们也失败;`lint:release` 默认不传该参数)。可触达性从
任何打包声明里出现 `zod` 这类 devDependency 都会让门禁失败——`lint:release` 以 `--strict`
运行该检查,因此没有任何入口触达的内部声明与可触达声明一样会失败(不带该参数时只报告为 warning)。可触达性从
`exports` 下的每个 `types` 目标以及旧式 `types` 字段出发;`/// <reference types>` 按裸导入同样检查;相对导入、
`/// <reference path>` 或 `exports` 目标指向压缩包中不存在的文件时也会失败。publint 不是单独的门禁:每个可发布包的
`rslib build` 都会通过 `rsbuild-plugin-publint` 运行它,并在出现 warning 时让构建失败。该门禁仅用于
Expand Down
Loading