From 893caeb94f8b4f1e7ad0c41fd7ee9012c816d2a2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 03:37:27 +0000 Subject: [PATCH 1/4] feat(build): hold the package build's dist bundles to AB6005 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm package build now walks every emitted dist/**/*.js|*.mjs with the same ESM import-graph walker the artifact build runs over host-pack modules (validateJavaScriptModules), before dist is published: a bare specifier that is not a Node built-in — including an import the tools hatch kept external — fails the build with AB6005 naming dist/ and the specifier. A dependency only a compiled bundle imports is therefore a build failure and never an AB7014 finding; AB7014's recovery names what still keeps a runtime dependency. bundleSyntaxCheckFor is shared by the artifact and package builds. --- .changeset/package-build-ab6005.md | 5 + AGENTS.md | 38 ++++--- docs/diagnostics.md | 9 +- docs/entry-conventions.md | 4 +- packages/agent-bundle/src/build/build.ts | 8 +- .../agent-bundle/src/build/module-imports.ts | 12 ++ .../src/build/pack-dependencies.ts | 10 +- .../agent-bundle/src/build/pack-inventory.ts | 16 ++- .../agent-bundle/src/build/package-build.ts | 30 ++++- .../src/build/validate-artifact-modules.ts | 44 +++++--- .../agent-bundle/tests/package-build.test.ts | 103 +++++++++++++++++- packages/agent-bundle/tests/prepack.test.ts | 94 +++++++++++++++- .../docs/en/guide/distribution/validation.mdx | 57 ++++++---- .../docs/zh/guide/distribution/validation.mdx | 45 ++++---- 14 files changed, 377 insertions(+), 98 deletions(-) create mode 100644 .changeset/package-build-ab6005.md diff --git a/.changeset/package-build-ab6005.md b/.changeset/package-build-ab6005.md new file mode 100644 index 000000000..0036becc6 --- /dev/null +++ b/.changeset/package-build-ab6005.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed declaration that references it, or an install script or packed file that runs it. (#TBD) diff --git a/AGENTS.md b/AGENTS.md index 68eb083ab..f282a57e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,28 +69,34 @@ every dependency of a generated executable — `autoExternal: false`, `bundle: true`, `splitChunks: false`, no `externals`. Rslib's `node` target leaves only Node built-ins (and `pnpapi`) external, and the only bare - specifiers `AB6005` accepts in a host-pack module are Node built-ins, so a - generated executable in a host pack loads nothing else from outside the - artifact. MCP App views - (`src/build/mcp-apps.ts`) inline every script and style into one HTML file. - The framework never adds `externals` to a plugin build; the `externals` - handling in `rslib.ts` (`reservedExternalsViolation`, + specifiers `AB6005` accepts in a host-pack module are Node built-ins. The + package build's `dist` bundles are walked by the same `AB6005` rule + (`src/build/package-build.ts` reuses `validateJavaScriptModules` from + `src/build/validate-artifact-modules.ts`), so a generated executable in a + host pack or in `dist` loads nothing but Node built-ins from outside its + tree. MCP App views (`src/build/mcp-apps.ts`) inline every script and style + into one HTML file. The framework never adds `externals` to a plugin build; + the `externals` handling in `rslib.ts` (`reservedExternalsViolation`, `guardReservedExternals`) only rejects reserved specifiers in the resolved externals, which come from the author's `tools` hatch and Rslib's built-in list, never from the profile. - No refactor, toolchain upgrade, or "leaner install" change may enable `autoExternal` or externalize a dependency on the author's behalf. A package - a consumer must install is the author's explicit decision — an import kept - external through the `tools` hatch, a packed declaration reference, an - install script — and the prepack gate judges it: `AB7014` demands - packed-file evidence of use, `AB7015` a specifier a consumer's npm can - install. + a consumer must install is the author's explicit decision, and an import + kept external through the `tools` hatch is not a way to make it anywhere: + `AB6005` fails such an import in a host pack and in `dist` alike. What + legitimately puts a package under `dependencies` is a packed declaration + reference, a prebuilt payload module that imports it, an install script, + or a `bin` command packed JavaScript runs — and the prepack gate judges + those: `AB7014` demands that evidence, `AB7015` a specifier a consumer's + npm can install. - Proof is bytes and processes, not config: every artifact build walks the - compiled host-pack modules (`AB6005` fails a bare package specifier there; - the package build's `dist` is judged by the prepack gate instead), and the - packed pool (`pnpm test:packed`) installs the packed tarball into a clean - consumer, builds, removes the project source, and spawns the generated - entry as a real process (`packed-deleted-source`). + compiled host-pack modules and every package build walks its emitted `dist` + bundles (`AB6005` fails a bare package specifier in either), the prepack + gate then judges what remains declared, and the packed pool + (`pnpm test:packed`) installs the packed tarball into a clean consumer, + builds, removes the project source, and spawns the generated entry as a + real process (`packed-deleted-source`). ## Documentation site diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5c39402ff..b4bf9c3e8 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -31,7 +31,7 @@ even when no error diagnostic was reported. | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: an emitted JavaScript module — a host-pack module or a package build `dist` bundle (`dist/bin/*.js`, the Flight workers, the `lib` entry), prebuilt payloads excepted — has an import that is neither a Node built-in nor a relative or `file:` specifier resolving to a listed regular file inside its tree, or a non-literal dynamic import; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | @@ -186,10 +186,13 @@ Validation happens at three moments, all fail-closed: | `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. | | `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist/bin` and the host packs, so such an entry only makes every consumer's `npm install` fetch build-time packages. Move them to `devDependencies`, or import the package from a packed module if a consumer really needs it at runtime. For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | +| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply import evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed declaration references, a `#subpath` import reaches, or an install script or packed file runs. For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | | `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | -The dependency evidence is read from the packed bytes themselves: every `.js`/`.mjs`/`.cjs` file +Compiled bundles reach this gate without bare imports: `prepack` builds before it packs, and `AB6005` +fails any `dist` bundle or host-pack module whose import is neither a Node built-in nor a listed file inside +its tree, so the lexed import evidence below describes prebuilt payload modules and other packed scripts +the framework did not compile. The dependency evidence is read from the packed bytes themselves: every `.js`/`.mjs`/`.cjs` file `npm pack --dry-run` lists is lexed for static and dynamic `import` specifiers and scanned for literal `require("…")` and `.resolve("…")` calls (`require.resolve`, `createRequire(…).resolve`, `import.meta.resolve`: a package located only to find an asset is still a runtime dependency; a diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 5a3050b63..c031a83f0 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1211,7 +1211,9 @@ hatch customizes *how code compiles*, never *what the artifact promises*. The framework's own profile keeps the same promise: `autoExternal` is off, `bundle: true`, `splitChunks: false`, and no `externals` are added, so Rslib's `node` target leaves only Node built-ins (and `pnpapi`) external, and `AB6005` -fails any bare specifier that is not a Node built-in in a host-pack module. +fails any bare specifier that is not a Node built-in in every compiled module +— host-pack modules and the package build's `dist` bundles alike — so the +hatch cannot externalize a dependency on the author's behalf. The hatch merges *beside* the framework profile, not over it: `plugins` arrays concatenate, and Rsbuild's plugin manager appends every plugin it is diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index be26fff1f..37ff1e5b9 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -30,6 +30,7 @@ import { } from './cli-bins.ts'; import { projectMeta } from './meta.ts'; import { compileMcpApps, planCompiledMcpApps, type CompiledMcpApp } from './mcp-apps.ts'; +import { bundleSyntaxCheckFor } from './module-imports.ts'; import { compileRslibSurfaces, settledRslibSurface } from './rslib.ts'; import { planTargetStages } from './target-stages.ts'; import { @@ -508,12 +509,7 @@ export const build = async (options: BuildOptions): Promise => { files: await listArtifactFiles(stageRoot), outputProvenance, }); - // The bundler's own output is trusted to the ESM lexer; once a consumer - // hatch can rewrite emitted assets (a banner, a processAssets pass), the - // final bytes are no longer the bundler's proof and are parsed in full. - const bundleSyntaxCheck = options.tools?.rspack === undefined && options.tools?.rsbuild === undefined - ? 'lexed' - : 'parsed'; + const bundleSyntaxCheck = bundleSyntaxCheckFor(options.tools); const preManifestDiagnostics = await validateArtifactFiles({ artifactRoot: stageRoot, bundleSyntaxCheck, diff --git a/packages/agent-bundle/src/build/module-imports.ts b/packages/agent-bundle/src/build/module-imports.ts index f3af038ed..d9f9e249c 100644 --- a/packages/agent-bundle/src/build/module-imports.ts +++ b/packages/agent-bundle/src/build/module-imports.ts @@ -1,6 +1,8 @@ import { parse as parseJavaScript } from 'acorn'; import { init, parse } from 'es-module-lexer'; +import type { AgentBundleToolsConfig } from '../core/types.ts'; + /** * One import of an ES module as the lexer reports it: `specifier` is the * literal module specifier (absent for a non-literal dynamic import), and @@ -26,6 +28,16 @@ export interface ModuleImport { */ export type ModuleSyntaxCheck = 'lexed' | 'parsed'; +/** + * How a build checks the syntax of the modules its own bundler emitted. The + * bundler's output is trusted to the ESM lexer; once a consumer `tools` hatch + * can rewrite emitted assets (a banner, a `processAssets` pass), the final + * bytes are no longer the bundler's proof and are parsed in full. The + * artifact build and the package build decide this the same way. + */ +export const bundleSyntaxCheckFor = (tools: AgentBundleToolsConfig | undefined): ModuleSyntaxCheck => + tools?.rspack === undefined && tools?.rsbuild === undefined ? 'lexed' : 'parsed'; + const importKind = (dynamic: number): ModuleImport['kind'] => dynamic === -2 ? 'meta' : dynamic === -1 ? 'static' : 'dynamic'; diff --git a/packages/agent-bundle/src/build/pack-dependencies.ts b/packages/agent-bundle/src/build/pack-dependencies.ts index 92884f5a1..1508ca0ac 100644 --- a/packages/agent-bundle/src/build/pack-dependencies.ts +++ b/packages/agent-bundle/src/build/pack-dependencies.ts @@ -14,7 +14,15 @@ import { readModuleImports, type ModuleImport } from './module-imports.ts'; * Evidence for the npm prepack dependency gate (`AB7014`/`AB7015`, emitted by * `pack-inventory.ts`): what `package.json` asks npm to install alongside the * package, and which packages the packed JavaScript and declaration files - * actually reference. + * actually reference. JavaScript the framework compiled — the `dist` bundles + * and the host-pack modules — never carries a bare package import, since + * `AB6005` fails the build first and `prepack` builds before it packs; the + * import, `require`, and resolve evidence read here is therefore that of + * prebuilt payload modules and other packed scripts the framework copied + * rather than compiled. The `bin`-command, declaration, `imports`-map, and + * install-script evidence is read from every packed file alike — a compiled + * bundle may still run a dependency's command (`spawnSync("tsc")`), which is + * not an import. */ /** diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index f5cb4bd9e..316ac33a0 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -146,11 +146,17 @@ const perField = ( .map(([field, own]) => emit(field, own.toSorted((left, right) => left.name.localeCompare(right.name)))); /** - * `AB7014`/`AB7015`: the build inlines every dependency into `dist/bin` and - * the host packs, so an installed-dependency entry no packed file references + * `AB7014`/`AB7015`: the build inlines every dependency into `dist` and the + * host packs, so an installed-dependency entry no packed file references * only makes every consumer's `npm install` fetch a build-time package — and * fail outright when the specifier is one a consumer's npm cannot resolve - * (git, remote tarball, path, or an unrewritten workspace protocol). + * (git, remote tarball, path, or an unrewritten workspace protocol). A + * compiled bundle cannot import a bare package at all: `AB6005` fails the + * build on any specifier that is not a Node built-in, and `prepack` runs + * that build before this inventory, so the packed-JavaScript import evidence + * `AB7014` accepts comes only from modules the framework copied rather than + * compiled — prebuilt payload modules and other scripts the `files` + * allowlist packs — never from a `dist` bundle or a host-pack module. */ const unresolvableMessage = (field: InstalledDependencyField, own: readonly DeclaredDependency[]): string => `package.json ${field} names packages a consumer's npm cannot resolve through a registry (an invalid name or a non-registry specifier): ${own.map((dependency) => @@ -168,6 +174,8 @@ const dependencyDiagnostics = async (options: { }): Promise => { const declared = declaredDependencies(options.packageDocument); if (declared.length === 0) return []; + // `prepack` runs the build before this inventory, and `AB6005` there refuses every bare import in a compiled + // bundle, so any import evidence found here belongs to a packed module the framework did not compile. const imported = await importedPackageNames({ declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), packageDocument: options.packageDocument, @@ -220,7 +228,7 @@ const dependencyDiagnostics = async (options: { : 'Every consumer installs them for nothing; the emitted outputs already inline what they use.'), field === 'peerDependencies' ? 'Keep a deliberate compatibility peer, mark it optional in peerDependenciesMeta so npm stops installing it, or move a build-only package to devDependencies.' - : 'Move build-only packages to devDependencies, or import the package from a packed module if a consumer needs it at runtime; a computed import() or require() in packed code withholds this check.', + : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005), so keep a runtime dependency only for what a prebuilt payload or other uncompiled packed module imports, a packed declaration file references, a #subpath import reaches through the imports map, or an install script or packed JavaScript runs; a computed import() or require() in packed code withholds this check.', field === 'peerDependencies' ? 'warning' : 'error', )), // npm skips an optional dependency it cannot fetch, so the install survives — but only once the specifier parsed diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 4a4711472..42c5d3732 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -22,17 +22,19 @@ import { terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; import { projectMeta } from './meta.ts'; +import { bundleSyntaxCheckFor } from './module-imports.ts'; import type { BundledOutputEvidence } from './provenance.ts'; import { buildWithRslib, isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts'; +import { validateJavaScriptModules } from './validate-artifact-modules.ts'; /** * The framework-owned npm package build: `bin` entries become self-executing * `dist/bin/.js` bundles (shebang + executable bit) and the `lib` entry * becomes `dist/.js` (+ a bundleless `.d.ts` declaration graph), all - * through the same Rslib synthesis, invariant assertions, and staged atomic - * publication as artifact executables. This is the build audiobook-curator - * previously needed a second bundler config, a tsconfig, and a hand-written - * bin shim to produce. + * through the same Rslib synthesis, invariant assertions, staged atomic + * publication, and self-containment rule (`AB6005`) as artifact executables. + * This is the build audiobook-curator previously needed a second bundler + * config, a tsconfig, and a hand-written bin shim to produce. */ const binShebang = '#!/usr/bin/env node'; @@ -337,7 +339,8 @@ export const buildPackageOutputs = async (options: { .map((entry) => chmod(resolveArtifactDestination(stageRoot, entry.outputRelativePath), executableMode))); const lib = packageBuild.lib; - const files = (await listArtifactFiles(stageRoot)).map((file): PackageOutputFile => { + const staged = await listArtifactFiles(stageRoot); + const files = staged.map((file): PackageOutputFile => { const bundled = evidenceByPath.get(file.path); const declared = lib?.dts === true && libSourceDir !== undefined && file.path.endsWith('.d.ts') ? declarationSource(libSourceDir, file.path) ?? lib.source @@ -366,6 +369,23 @@ export const buildPackageOutputs = async (options: { throw new Error(`Package build did not emit expected declarations ${JSON.stringify(`${lib.name}.d.ts`)}.`); } + // The npm form of the plugin is held to the same line as its host packs: + // every emitted `dist` module is walked as an ES module, and a bare + // specifier that is not a Node built-in — an import the `tools` hatch + // kept external — fails the build (`AB6005`) before `dist` is published, + // so a `dist/bin` executable loads nothing from a consumer's + // `node_modules`. Declarations are not modules and are not walked; they + // may still reference declared dependencies. + const selfContainment = await validateJavaScriptModules({ + artifactRoot: stageRoot, + bundledPaths: new Set(files.filter((file) => file.kind === 'bundle').map((file) => file.path)), + bundleSyntaxCheck: bundleSyntaxCheckFor(options.tools), + files: staged, + reportedRoot: toPosixRelative(projectRoot, outputRoot), + validJson: new Set(), + }); + if (selfContainment.length > 0) throw new DiagnosticError(selfContainment); + await publishArtifact({ outputRoot, stageRoot }); return Object.freeze({ files: Object.freeze(files), outputRoot }); } finally { diff --git a/packages/agent-bundle/src/build/validate-artifact-modules.ts b/packages/agent-bundle/src/build/validate-artifact-modules.ts index a96d5ca9d..26ca956c9 100644 --- a/packages/agent-bundle/src/build/validate-artifact-modules.ts +++ b/packages/agent-bundle/src/build/validate-artifact-modules.ts @@ -33,65 +33,69 @@ const graphDiagnostic = (importer: string, message: string): Diagnostic => diagn const resolveJavaScriptImport = async (options: { readonly artifactRoot: string; readonly files: ReadonlyMap; + /** The importing module, relative to `artifactRoot`. */ readonly importer: string; + /** How diagnostics name the importer (see `reportedRoot`). */ + readonly reportedImporter: string; readonly specifier: string; readonly validJson: ReadonlySet; }): Promise<{ readonly diagnostic?: Diagnostic; readonly module?: string }> => { + const importer = options.reportedImporter; if (isBuiltin(options.specifier)) return {}; if (!options.specifier.startsWith('.') && !options.specifier.startsWith('file:')) { - return { diagnostic: graphDiagnostic(options.importer, `uses unsupported specifier ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `uses unsupported specifier ${JSON.stringify(options.specifier)}.`) }; } let url: URL; try { url = new URL(options.specifier, pathToFileURL(resolve(options.artifactRoot, options.importer))); } catch { - return { diagnostic: graphDiagnostic(options.importer, `uses invalid specifier ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `uses invalid specifier ${JSON.stringify(options.specifier)}.`) }; } if (url.protocol !== 'file:' || url.search.length > 0 || url.hash.length > 0) { - return { diagnostic: graphDiagnostic(options.importer, `uses unsupported specifier ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `uses unsupported specifier ${JSON.stringify(options.specifier)}.`) }; } let path: string; try { path = fileURLToPath(url); } catch { - return { diagnostic: graphDiagnostic(options.importer, `uses invalid file URL ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `uses invalid file URL ${JSON.stringify(options.specifier)}.`) }; } if (artifactPathFor(options.artifactRoot, path) === undefined) { - return { diagnostic: graphDiagnostic(options.importer, `resolves outside the artifact root: ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `resolves outside the artifact root: ${JSON.stringify(options.specifier)}.`) }; } let metadata: Awaited>; try { metadata = await lstat(path); } catch { - return { diagnostic: graphDiagnostic(options.importer, `is missing ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `is missing ${JSON.stringify(options.specifier)}.`) }; } if (!metadata.isFile()) { - return { diagnostic: graphDiagnostic(options.importer, `does not resolve to a regular file: ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `does not resolve to a regular file: ${JSON.stringify(options.specifier)}.`) }; } let canonicalPath: string; try { canonicalPath = await realpath(path); } catch { - return { diagnostic: graphDiagnostic(options.importer, `is missing ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `is missing ${JSON.stringify(options.specifier)}.`) }; } const artifactPath = artifactPathFor(options.artifactRoot, canonicalPath); if (artifactPath === undefined) { - return { diagnostic: graphDiagnostic(options.importer, `resolves outside the artifact root: ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `resolves outside the artifact root: ${JSON.stringify(options.specifier)}.`) }; } if (!options.files.has(artifactPath)) { - return { diagnostic: graphDiagnostic(options.importer, `is not listed in the artifact manifest: ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `is not listed in the artifact manifest: ${JSON.stringify(options.specifier)}.`) }; } if (jsonModuleSuffix.test(artifactPath)) { return options.validJson.has(artifactPath) ? {} - : { diagnostic: graphDiagnostic(options.importer, `references invalid JSON ${JSON.stringify(options.specifier)}.`) }; + : { diagnostic: graphDiagnostic(importer, `references invalid JSON ${JSON.stringify(options.specifier)}.`) }; } if (!javaScriptModuleSuffix.test(artifactPath)) { - return { diagnostic: graphDiagnostic(options.importer, `uses unsupported target ${JSON.stringify(options.specifier)}.`) }; + return { diagnostic: graphDiagnostic(importer, `uses unsupported target ${JSON.stringify(options.specifier)}.`) }; } return { module: artifactPath }; }; @@ -109,12 +113,21 @@ export const validateJavaScriptModules = async (options: { readonly manifestFiles?: ReadonlySet; /** Prebuilt payload files: opaque consumer outputs excluded from graph validation. */ readonly prebuiltPaths?: ReadonlySet; + /** + * POSIX directory under which diagnostics name the validated modules, for + * a tree validated before it is published under another path: the package + * build walks its staged output and reports `dist/bin/.js`, the path + * a consumer sees, rather than the stage-relative `bin/.js`. Absent + * for the artifact, whose diagnostics name artifact-relative paths. + */ + readonly reportedRoot?: string; readonly validJson: ReadonlySet; }): Promise => { const artifactRoot = await realpath(options.artifactRoot); const files = new Map(options.files .filter((file) => options.manifestFiles === undefined || options.manifestFiles.has(file.path)) .map((file) => [file.path, file])); + const reported = (path: string): string => (options.reportedRoot === undefined ? path : `${options.reportedRoot}/${path}`); const diagnostics: Diagnostic[] = []; const visited = new Set(); const visiting = new Set(); @@ -131,7 +144,7 @@ export const validateJavaScriptModules = async (options: { try { bytes = await runWithPlatform(readFileBytes(resolve(artifactRoot, path))); } catch { - diagnostics.push(graphDiagnostic(path, 'cannot be read.')); + diagnostics.push(graphDiagnostic(reported(path), 'cannot be read.')); visiting.delete(path); visited.add(path); return; @@ -143,7 +156,7 @@ export const validateJavaScriptModules = async (options: { try { imports = await readModuleImports(bytes.toString('utf8'), { check, sha256: sha256Hex(bytes) }); } catch { - diagnostics.push(graphDiagnostic(path, 'has invalid syntax.')); + diagnostics.push(graphDiagnostic(reported(path), 'has invalid syntax.')); visiting.delete(path); visited.add(path); return; @@ -151,13 +164,14 @@ export const validateJavaScriptModules = async (options: { for (const imported of imports) { if (imported.kind === 'meta') continue; if (imported.specifier === undefined) { - diagnostics.push(graphDiagnostic(path, 'has a non-literal dynamic import.')); + diagnostics.push(graphDiagnostic(reported(path), 'has a non-literal dynamic import.')); continue; } const resolved = await resolveJavaScriptImport({ artifactRoot, files, importer: path, + reportedImporter: reported(path), specifier: imported.specifier, validJson: options.validJson, }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 2e7067c25..828f5ce6d 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -1,6 +1,6 @@ import { execFile as executeFile } from 'node:child_process'; import { EventEmitter } from 'node:events'; -import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -10,6 +10,7 @@ import { afterEach, describe, expect, it } from '@rstest/core'; import { build, runMcp } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; +import { DiagnosticError } from '../src/core/diagnostics.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { mcpServerStateDirectory } from '../src/services/mcp-run.ts'; @@ -228,6 +229,106 @@ describe('framework-owned package build', () => { expect(result.packageBuild?.files.some((file) => file.path === 'bin/package-build-fixture.js')).toBe(true); }, 120_000); + it('fails the package build with AB6005 when the tools hatch keeps a dependency external in a dist bundle', async () => { + // Every dist bundle is self-contained, exactly like a host pack: the + // `tools` hatch is no escape from that rule, and a literal dynamic import + // counts the same as a static one. Neither package is installed — the + // bundler never resolves an external — and `dts: false` keeps declaration + // generation (which would need their types) out of the picture. + const root = await fixtureRoot({ + ...conventionFixture(), + 'agent-bundle.config.ts': [ + 'export default {', + " lib: { entry: './src/index.ts', dts: false },", + " mcp: { servers: { echoer: {} } },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + " tools: { rsbuild: { output: { externals: ['left-pad', 'right-pad'] } } },", + '};', + '', + ].join('\n'), + 'src/cli.ts': [ + "import leftPad from 'left-pad';", + '', + 'export const main = async (argv: readonly string[]): Promise => {', + " const { default: rightPad } = await import('right-pad');", + " process.stdout.write(`${leftPad(argv.join(','), 8)}${rightPad('', 2)}\\n`);", + ' return 0;', + '};', + '', + ].join('\n'), + 'src/index.ts': [ + "import leftPad from 'left-pad';", + '', + 'export const padded = (value: string): string => leftPad(value, 8);', + '', + ].join('\n'), + }); + + const failure = await build({ output: 'artifact', packageOutputs: true, root }).then( + () => undefined, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(DiagnosticError); + const unsupported = (generatedPath: string, specifier: string) => ({ + code: 'AB6005', + generatedPath, + message: `Generated JavaScript import from ${JSON.stringify(generatedPath)} uses unsupported specifier ${JSON.stringify(specifier)}.`, + recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', + severity: 'error', + }); + const byMessage = (left: { message: string }, right: { message: string }): number => left.message.localeCompare(right.message); + expect([...(failure as DiagnosticError).diagnostics].sort(byMessage)).toEqual([ + unsupported('dist/bin/package-build-fixture.js', 'left-pad'), + unsupported('dist/bin/package-build-fixture.js', 'right-pad'), + unsupported('dist/index.js', 'left-pad'), + ].sort(byMessage)); + + // Nothing is published: no `dist`, and the staged tree is gone too. + await expect(stat(join(root, 'dist'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect((await readdir(root)).filter((entry) => entry.startsWith('.dist.stage-'))).toEqual([]); + }, 120_000); + + it('accepts Node built-ins under node: and bare specifiers in dist bundles', async () => { + const root = await fixtureRoot({ + ...conventionFixture(), + 'agent-bundle.config.ts': [ + 'export default {', + ' lib: false,', + " mcp: { servers: { echoer: {} } },", + " plugin: { name: 'package-build-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'src/cli.ts': [ + "import { readFileSync } from 'node:fs';", + "import { createRequire } from 'node:module';", + "import { join } from 'path';", + '', + 'export const main = async (): Promise => {', + ' const requireFromHere = createRequire(import.meta.url);', + " process.stdout.write(`${join('built', 'ins')}:${typeof readFileSync}:${typeof requireFromHere.resolve}\\n`);", + ' return 0;', + '};', + '', + ].join('\n'), + }); + const result = await build({ output: 'artifact', packageOutputs: true, root }); + expect(result.packageBuild?.files.map((file) => file.path)).toContain('bin/package-build-fixture.js'); + + const binPath = join(root, 'dist', 'bin', 'package-build-fixture.js'); + await expect(execFile(binPath, [])).resolves.toMatchObject({ stdout: 'built/ins:function:function\n' }); + // Rspack keeps Node built-ins external, so the emitted module still + // imports them by specifier — under both spellings — and the walker + // accepts those imports (and `import.meta.url`) as it does relative ones. + const binSource = await readFile(binPath, 'utf8'); + expect(binSource).toMatch(/from\s*["']node:fs["']/u); + expect(binSource).toMatch(/from\s*["']node:module["']/u); + expect(binSource).toMatch(/from\s*["'](?:node:)?path["']/u); + expect(binSource).toContain('import.meta.url'); + }, 120_000); + it('keeps colocated tests out of the declaration program and the package output', async () => { const root = await fixtureRoot({ ...conventionFixture(), diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index 6a961eb19..818d5b022 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -1,7 +1,7 @@ import { execFile as executeFile } from 'node:child_process'; import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join, relative } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { promisify } from 'node:util'; import { gzipSync } from 'node:zlib'; @@ -9,7 +9,7 @@ import { afterAll, beforeAll, expect, it } from '@rstest/core'; import { prepack } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; -import type { Diagnostic } from '../src/core/diagnostics.ts'; +import { type Diagnostic, DiagnosticError } from '../src/core/diagnostics.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { packInventoryDiagnostics, @@ -857,6 +857,96 @@ it('accepts a dependency that packed JavaScript imports, requires, or only resol }, )); +/** + * A sibling project under `cleanupRoot`, laid out like `projectRoot`: the + * workspace's node_modules, a manifest, a README, a config, and its files. + * Tests that run a whole `prepack` get their own project so the shared + * fixture's artifacts and manifest stay untouched. + */ +const createSiblingProject = async ( + name: string, + packageDocument: Readonly>, + configLines: readonly string[], + files: Readonly>, +): Promise => { + const root = join(cleanupRoot, name); + await mkdir(join(root, 'src'), { recursive: true }); + await symlink(workspaceNodeModules, join(root, 'node_modules'), 'dir'); + await Promise.all([ + writeFile(join(root, 'package.json'), `${JSON.stringify(packageDocument, null, 2)}\n`), + writeFile(join(root, 'README.md'), `# ${name}\n`), + writeFile(join(root, 'agent-bundle.config.ts'), `${configLines.join('\n')}\n`), + ...Object.entries(files).map(async ([path, content]) => { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), content); + }), + ]); + return root; +}; + +it('accepts a dependency that only a prebuilt payload module imports: prepack passes, AB6005 does not walk copied files', async () => { + const root = await createSiblingProject('prebuilt-project', { + bin: { 'prebuilt-fixture': './dist/bin/prebuilt-fixture.js' }, + dependencies: { express: '^5.0.0' }, + files: ['dist', 'host-packs', 'README.md'], + name: 'prebuilt-fixture', + type: 'module', + version: '1.2.3', + }, [ + 'export default {', + ' bin: false,', + " lib: './src/index.ts',", + " mcp: { servers: { timeline: { entry: { prebuilt: './built/runtime/mcp/server.js' }, transport: 'stdio' } } },", + " output: { distPath: 'host-packs' },", + " payload: { runtime: './built/runtime' },", + " plugin: { name: 'prebuilt-fixture' },", + " targets: ['cursor'],", + '};', + ], { + // A bare import in a module the framework copies rather than compiles: AB6005 never walks it, and the + // import is the usage evidence that keeps `express` out of AB7014. + 'built/runtime/mcp/server.js': 'import express from "express";\nexport default express;\n', + 'src/index.ts': 'export const value = 1;\n', + }); + const packed = await prepack({ root }); + const reported = [...packed.build.diagnostics, ...packed.diagnostics]; + expect(withCode(reported, 'AB6005')).toHaveLength(0); + expect(withCode(reported, 'AB7014')).toHaveLength(0); + expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/cursor/runtime/mcp/server.js'); +}, 180_000); + +it('fails prepack with AB6005, never AB7014, when only a compiled dist bundle imports a declared dependency', async () => { + const root = await createSiblingProject('externalized-project', { + dependencies: { 'left-pad': '^1.3.0' }, + files: ['dist', 'host-packs', 'README.md'], + name: 'externalized-fixture', + type: 'module', + version: '1.2.3', + }, [ + 'export default {', + ' bin: false,', + " lib: { entry: './src/index.ts', dts: false },", + " output: { distPath: 'host-packs' },", + " plugin: { name: 'externalized-fixture' },", + " targets: ['cursor'],", + // The hatch keeps the import external, so the compiled bundle carries a bare specifier. + " tools: { rsbuild: { output: { externals: ['left-pad'] } } },", + '};', + ], { + 'src/index.ts': "import leftPad from 'left-pad';\nexport const pad = (value: string): string => leftPad(value, 4);\n", + }); + const failure: unknown = await prepack({ root }).then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(DiagnosticError); + const reported = (failure as DiagnosticError).diagnostics; + expect(reported).toContainEqual(expect.objectContaining({ + code: 'AB6005', + generatedPath: 'dist/index.js', + message: expect.stringContaining('"left-pad"'), + })); + // The build fails before the inventory runs, so the two gates cannot disagree about the same dependency. + expect(withCode(reported, 'AB7014')).toHaveLength(0); +}, 180_000); + it('installs a real packed tarball and runs its Cursor installer from node_modules', async () => { const tarballs = join(cleanupRoot, 'tarballs'); const consumer = join(cleanupRoot, 'consumer'); diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index d910ebeeb..244f25320 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -26,16 +26,20 @@ hand-edited generated file fails rather than passing because the path still exis files are checked too — a manifest-declared `logo` that is missing from the artifact or escapes the deploy tree reports `AB6025`. -Every emitted JavaScript module is walked as an ES module (`AB6005`): each import must be a -literal specifier that either names a Node built-in (`node:fs`, `fs`) or resolves — as a relative -or `file:` specifier — to a regular file listed in the manifest inside the artifact. No non-literal -dynamic imports, no bare package names, nothing outside the tree. How thoroughly a module's syntax -is checked follows who produced its bytes. A module the framework compiled (manifest kind `bundle`) -is the bundler's own output, so only the ESM lexer runs over it, which rejects unterminated -strings, templates, comments, and regexps and unbalanced braces. A module the framework did not -compile — a copied consumer script, a generated installer — is parsed in full, and so is every -bundle of a build whose [`tools` hatch](../../reference/configuration.mdx#tools) could have rewritten the -emitted assets. Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and hash-locked only. +Every emitted JavaScript module is walked as an ES module (`AB6005`) — the host-pack modules of +every artifact build, and the package build's `dist` bundles (`dist/bin/*.js`, a rendered route's +Flight worker `.mjs`, the `lib` entry) before `dist` is published: each import must be a literal +specifier that either names a Node built-in (`node:fs`, `fs`) or resolves — as a relative or +`file:` specifier — to a regular file inside the emitted tree (for a host pack, one the manifest +lists). No non-literal dynamic imports, no bare package names, nothing outside the tree; a `dist` +finding names its file as `dist/`. How thoroughly a module's syntax is checked follows who +produced its bytes. A module the framework compiled (manifest kind `bundle`) is the bundler's own +output, so only the ESM lexer runs over it, which rejects unterminated strings, templates, +comments, and regexps and unbalanced braces. A module the framework did not compile — a copied +consumer script, a generated installer — is parsed in full, and so is every bundle of a build +whose [`tools` hatch](../../reference/configuration.mdx#tools) could have rewritten the emitted +assets, in `dist` as in a host pack. Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and +hash-locked only, and declaration files (`.d.ts`) are not walked. Every diagnostic is one structured record: a stable `AB` code, a severity, a message, and usually a `sourcePath` and a `recovery` hint. The diagnostic-gated commands — `build`, `prepack`, @@ -181,8 +185,12 @@ exact package and artifact inventory, manifest hashes, package bin targets, rele agreement, and the installed-dependency fields of `package.json`. The build inlines every dependency into `dist/bin` and the host packs, so a published plugin should install nothing: declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of the stack under -`devDependencies`. The exception is a package a packed module still imports — one kept external -through the `tools` escape hatch — which belongs under `dependencies`, and `AB7014` permits it there. `--output` is an artifact path relative to `--root` that overrides the configured +`devDependencies`. A compiled bundle — a host-pack module or a `dist` bundle alike — cannot import +a bare package at all: `AB6005` fails the build before `prepack` reaches the inventory. So +`dependencies` is only for what the packed files demonstrably need by other means — a prebuilt +payload module's import, a packed declaration's reference, a consumer install script, or a `bin` +command a packed file runs — since only JavaScript the framework did not compile can still import +one. `--output` is an artifact path relative to `--root` that overrides the configured `output.distPath`, defaulting to `artifact`. Use it as an npm `prepack` script; `--ignore-scripts` prevents recursion, and no npm lifecycle ever performs a host install. @@ -192,17 +200,18 @@ package build's JavaScript bundles — compile with Rslib's `autoExternal` disab and `splitChunks: false`, and the framework adds no `externals` of its own; Rslib's `node` target leaves only Node built-ins (`node:fs`, `path`, plus Yarn PnP's `pnpapi`) external. MCP App views inline every script and style into one HTML file. Artifact validation holds the compiled host-pack -bytes to the same line: a bare specifier that is not a Node built-in is `AB6005`, so a generated -executable in a host pack loads nothing but built-ins from outside the artifact. A `dependencies` -entry is therefore only for what the packed files demonstrably need from outside a compiled bundle -— a package a packed module still imports because the author kept it external through the -[`tools` hatch](../../reference/configuration.mdx#tools) (possible in the package build's `dist` -output, which `AB6005` does not walk; a host-pack module with that import fails the build), one a -prebuilt `.js`/`.mjs`/`.cjs` payload module imports (prebuilt files are opaque to `AB6005`, and -`AB7014` scans only those extensions, so an extensionless prebuilt module counts for nothing), one -a consumer-side install script runs, one whose `bin` a packed file executes, or one a packed -declaration file references — and `AB7014` reports a declared dependency with none of that -evidence, while `AB7015` reports one a consumer's npm cannot install. +bytes to the same line, and the package build holds its `dist` bundles to it before publishing +them: a bare specifier that is not a Node built-in is `AB6005`, so a generated executable — in a +host pack or in `dist` — loads nothing but built-ins from outside its tree. An import kept +external through the [`tools` hatch](../../reference/configuration.mdx#tools) is not a way around +that: it fails the build in either output and never reaches the prepack inventory. A +`dependencies` entry is therefore only for what the packed files demonstrably need from outside a +compiled bundle — a package a prebuilt `.js`/`.mjs`/`.cjs` payload module imports (prebuilt files +are opaque to `AB6005`, and `AB7014` scans only those extensions, so an extensionless prebuilt +module counts for nothing), one a consumer-side install script runs, one whose `bin` a packed file +executes, or one a packed declaration file references (`.d.ts` outputs are not walked by +`AB6005`) — and `AB7014` reports a declared dependency with none of that evidence, while `AB7015` +reports one a consumer's npm cannot install. | Code | Meaning | | --- | --- | @@ -210,7 +219,7 @@ evidence, while `AB7015` reports one a consumer's npm cannot install. | `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild, and do not modify generated host packs. | | `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them, no packed declaration references them, no `imports` mapping or consumer install script reaches them (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | +| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so that evidence comes from prebuilt payload modules and other scripts the framework did not compile), no packed declaration references them, no `imports` mapping or consumer install script reaches them (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | | `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns instead of failing, since npm continues without it; it stays an error when unparseable, or when a consumer install script needs the skipped package (runs its command — not merely mentions it — loads it from an inline `node -e` program — scanned for `require`, `createRequire`, and `import()` like a packed file — preloads it with `node -r`/`--require`/`--import`/`--loader`, or runs a packed file that imports it, `node .` running the root `main` included; each command after `&&`, `;`, or a newline is read on its own, shell quotes and backslash escapes resolved, and `node`'s options belong to `node` alone and end at the program — `node install.js --require x` preloads nothing, while a `NODE_OPTIONS=--require=x` assignment on the same `node` command does; every script word naming a packed JavaScript file counts as run, deliberately, so that runners the gate does not model — `tsx`, `zx`, `bun`, `deno run` — still have their file's dependencies traced, at the cost of a rare escalation for a word such as `echo install.js`, which the diagnostic makes visible by naming the file). A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry; npm never reads the duplicate peer's selector. `workspace:`/`catalog:` are reported too unless the `prepack` lifecycle runs under pnpm, Yarn, or Bun, which rewrite them at pack time — `npm publish` does not, so under npm `AB7015` fires; entries the tarball itself carries — `bundleDependencies` npm actually packed (never peers), and a `file:` path inside the package whose packed source is installable (a directory with a parseable `package.json`, or a well-formed tarball whose `package.json` parses) — are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | The dependency evidence is read from the packed bytes: every `.js`/`.mjs`/`.cjs` file npm would diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 751e77ce4..d340e6fb2 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -22,13 +22,16 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 把真实字节与这些摘要比对,因此被手工改过的生成文件会失败,而不会因为路径还在就通过。被引用的文件同样 会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。 -每个输出的 JavaScript 模块都会被当作 ES 模块遍历(`AB6005`):每个 import 必须是字面量说明符,要么指向 -Node 内建模块(`node:fs`、`fs`),要么以相对或 `file:` 说明符解析到产物内、清单中列出的常规文件。不允许 -非字面量的动态 import,不允许裸包名,不允许指向树外。模块语法检查的深度取决于它的字节由谁产出。框架编译的 -模块(清单 kind 为 `bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,它会拒绝未终止的字符串、模板、 -注释与正则以及不配对的花括号。框架没有编译的模块——被复制的消费者脚本、生成的安装器——则会被完整解析;若一次 -构建的 [`tools` 逃生口](../../reference/configuration.mdx#tools)有可能改写了输出资源,该构建的每个 bundle 也会被完整解析。 -预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定。 +每个输出的 JavaScript 模块都会被当作 ES 模块遍历(`AB6005`)——既包括每次产物构建的宿主包模块,也包括 +包构建在发布 `dist` 之前的各个 `dist` bundle(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs`、`lib` +入口):每个 import 必须是字面量说明符,要么指向 Node 内建模块(`node:fs`、`fs`),要么以相对或 `file:` +说明符解析到输出树内的常规文件(对宿主包而言,即清单中列出的文件)。不允许非字面量的动态 import,不允许 +裸包名,不允许指向树外;`dist` 中的发现以 `dist/` 点名其文件。模块语法检查的深度取决于它的字节由谁 +产出。框架编译的模块(清单 kind 为 `bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,它会拒绝未 +终止的字符串、模板、注释与正则以及不配对的花括号。框架没有编译的模块——被复制的消费者脚本、生成的安装器—— +则会被完整解析;若一次构建的 [`tools` 逃生口](../../reference/configuration.mdx#tools)有可能改写了输出资源, +该构建的每个 bundle 也会被完整解析,`dist` 与宿主包同理。预构建载荷(`kind: 'prebuilt'`)保持不透明,只做 +哈希锁定;声明文件(`.d.ts`)不会被遍历。 每条诊断都是一份结构化记录:稳定的 `AB` 代码、一个严重级别、一条消息,通常还有 `sourcePath` 与一条 `recovery` 提示。由诊断把关的命令——`build`、`prepack`、`validate`、`doctor`、`install` 与 `dev`——只有 @@ -160,23 +163,25 @@ npx agent-bundle prepack --root . --output artifact --json `prepack` 运行发布构建与 `npm pack --dry-run --json --ignore-scripts`,随后对精确的包与产物清单、清单 哈希、包 bin 目标、发布版本一致性以及 `package.json` 中的安装期依赖字段把关。构建会把每个依赖内联进 `dist/bin` 与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 -`zod` 以及其余技术栈都声明在 `devDependencies` 下。例外是打包后的模块仍然导入的包——通过 `tools` 逃生舱保持 -外部化的包——它应声明在 `dependencies` 下,`AB7014` 会允许它。`--output` 是相对 `--root` 的产物路径,会覆盖配置中的 -`output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` 脚本;`--ignore-scripts` 可防止递归, -而且任何 npm 生命周期都绝不会执行宿主安装。 +`zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle—— +根本不能导入裸包:`AB6005` 会在 `prepack` 触及清单之前就让构建失败。因此 `dependencies` 只留给打包后的 +文件以其他方式有据可证地需要的内容——预构建 payload 模块的导入、打包后声明文件的引用、消费者侧安装脚本, +或打包后的文件运行的 `bin` 命令——因为只有框架没有编译的 JavaScript 才仍然可能导入它。`--output` 是相对 +`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` +脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 这种内联是编译器的契约,而不是一次工具链升级就可能翻转的打包器默认值。生成的可执行文件——产物脚本、路由式 CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 `splitChunks: false` 的配置编译,框架自身也不添加任何 `externals`;Rslib 的 `node` target 只把 Node 内建模块(`node:fs`、`path`, 以及 Yarn PnP 的 `pnpapi`)保持外部化。MCP App 视图则把每个脚本与样式都内联进同一个 HTML 文件。产物校验对 -编译出的宿主包字节坚持同一条界线:任何不是 Node 内建模块的裸说明符即为 `AB6005`,因此宿主包中生成的可执行文件 -从产物之外加载的只有内建模块。因此,`dependencies` 条目只留给打包后的文件有据可证地需要从已编译 bundle 之外 -获取的内容——打包后的模块因作者通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)将其保持外部化 -而仍然导入的包(这在包构建的 `dist` 输出中是可能的,因为 `AB6005` 不会遍历它;带有这种导入的宿主包模块则会 -让构建失败),预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建文件对 `AB6005` 不透明,而 -`AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),消费者侧 -安装脚本运行的包,打包后的文件执行其 `bin` 的包,或打包后的声明文件引用的包—— -`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告消费者的 npm 无法安装的依赖。 +编译出的宿主包字节坚持同一条界线,包构建也在发布 `dist` bundle 之前对它们坚持同一条界线:任何不是 Node 内建 +模块的裸说明符即为 `AB6005`,因此生成的可执行文件——无论位于宿主包还是 `dist`——从自己的输出树之外加载的只有 +内建模块。通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化的导入并不能绕过这条界线: +它在两种输出中都会让构建失败,永远到不了 prepack 的清单检查。因此,`dependencies` 条目只留给打包后的文件 +有据可证地需要从已编译 bundle 之外获取的内容——预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建 +文件对 `AB6005` 不透明,而 `AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),消费者侧 +安装脚本运行的包,打包后的文件执行其 `bin` 的包,或打包后的声明文件引用的包(`.d.ts` 输出不会被 `AB6005` +遍历)——`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告消费者的 npm 无法安装的依赖。 | 代码 | 含义 | | --- | --- | @@ -184,7 +189,7 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 | `AB7011` | 磁盘上的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成的宿主包。 | | `AB7012` | 某个 `package.json` bin 指向了打包后的 `dist` 输出之外(包括 `src/`),或指名了一个被 npm 忽略的文件。请把它指向生成的 `dist/bin` 文件。 | | `AB7013` | `package.json`、规范化后的插件元数据、某份宿主清单或产物 provenance 报告了不同的发布版本。请让每处发布标识一致。 | -| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它,没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | +| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此这类证据只来自预构建 payload 模块以及其他框架没有编译的脚本),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | | `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。`optionalDependencies` 中的抓取类项只发出警告而不会失败,因为 npm 会继续安装;不可解析的项,或消费者安装脚本需要的项(运行其命令——而非仅仅提及它——在内联的 `node -e` 程序中加载它——该程序像已打包文件一样被扫描 `require`、`createRequire` 与 `import()`——用 `node -r`/`--require`/`--import`/`--loader` 预加载它,或运行某个导入它的已打包文件,包括通过根 `main` 运行的 `node .`;`&&`、`;` 或换行之后的每条命令单独判读,shell 引号与反斜杠转义均已解析,`node` 的选项只属于 `node` 且到程序为止——`node install.js --require x` 不会预加载任何东西,而同一条 `node` 命令上的 `NODE_OPTIONS=--require=x` 赋值会;脚本中每个点名已打包 JavaScript 文件的词都视为被运行,这是有意为之,使本闸门未建模的运行器——`tsx`、`zx`、`bun`、`deno run`——所运行文件的依赖仍被追踪,代价是像 `echo install.js` 这样的词偶有误升级,诊断会点名该文件使之可见),仍是错误。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断;npm 从不读取重复 peer 的选择器。`workspace:`/`catalog:` 同样会被报告,除非 `prepack` 生命周期运行在 pnpm、Yarn 或 Bun 之下——它们会在打包时重写这些协议;`npm publish` 不会重写,因此在 npm 下 `AB7015` 会触发;tarball 自身携带的项——`bundleDependencies` 中 npm 实际打包进去的项(peer 除外),以及已打包来源可供安装的包内 `file:` 路径(带有可解析 `package.json` 的目录,或格式正确且其 `package.json` 可解析的 tarball)——不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | 依赖证据直接读取自打包后的字节:npm 将发布的每个 `.js`/`.mjs`/`.cjs` 文件都会被词法分析出 `import` From 7ece913c8b7779093a30d761eab1758432212ccb Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 04:04:28 +0000 Subject: [PATCH 2/4] changeset: reference #588 --- .changeset/package-build-ab6005.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/package-build-ab6005.md b/.changeset/package-build-ab6005.md index 0036becc6..dbdb3328c 100644 --- a/.changeset/package-build-ab6005.md +++ b/.changeset/package-build-ab6005.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed declaration that references it, or an install script or packed file that runs it. (#TBD) +Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed declaration that references it, or an install script or packed file that runs it. (#588) From cdb70bd99d888653dff22b9538750555df0fe8dd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 04:27:26 +0000 Subject: [PATCH 3/4] docs: AB6005 walks import specifiers; require/createRequire/import.meta.resolve evidence stays per packed file (#591) --- .changeset/package-build-ab6005.md | 2 +- AGENTS.md | 7 +++++-- docs/diagnostics.md | 8 ++++--- .../src/build/pack-dependencies.ts | 17 ++++++++------- .../agent-bundle/src/build/pack-inventory.ts | 20 +++++++++++------- .../agent-bundle/src/build/package-build.ts | 10 ++++++--- .../docs/en/guide/distribution/validation.mdx | 21 +++++++++++-------- .../docs/zh/guide/distribution/validation.mdx | 15 +++++++------ 8 files changed, 61 insertions(+), 39 deletions(-) diff --git a/.changeset/package-build-ab6005.md b/.changeset/package-build-ab6005.md index dbdb3328c..8ae12824b 100644 --- a/.changeset/package-build-ab6005.md +++ b/.changeset/package-build-ab6005.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed declaration that references it, or an install script or packed file that runs it. (#588) +Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed file that requires or resolves it, a packed declaration that references it, or an install script or packed file that runs it. (#588) diff --git a/AGENTS.md b/AGENTS.md index e8d254bcf..b3daf463c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,8 +73,11 @@ package build's `dist` bundles are walked by the same `AB6005` rule (`src/build/package-build.ts` reuses `validateJavaScriptModules` from `src/build/validate-artifact-modules.ts`), so a generated executable in a - host pack or in `dist` loads nothing but Node built-ins from outside its - tree. MCP App views (`src/build/mcp-apps.ts`) inline every script and style + host pack or in `dist` imports nothing but Node built-ins from outside its + tree. The walk reads import specifiers, static and literal dynamic; a + `createRequire(…)(…)` or `import.meta.resolve(…)` call is not an import and + is outside `AB6005` in either output — the prepack gate reads those calls + as dependency evidence. MCP App views (`src/build/mcp-apps.ts`) inline every script and style into one HTML file. The framework never adds `externals` to a plugin build; the `externals` handling in `rslib.ts` (`reservedExternalsViolation`, `guardReservedExternals`) only rejects reserved specifiers in the resolved diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b4752796b..f737bdb41 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -186,13 +186,15 @@ Validation happens at three moments, all fail-closed: | `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. | | `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply import evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed declaration references, a `#subpath` import reaches, or an install script or packed file runs. For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | +| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply `import` evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript `import` evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts — while `require`, `createRequire`, and `import.meta.resolve` calls, which `AB6005` does not walk, count from any packed file. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed file requires or resolves, a packed declaration references, a `#subpath` import reaches, or an install script or packed file runs. For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | | `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | Compiled bundles reach this gate without bare imports: `prepack` builds before it packs, and `AB6005` fails any `dist` bundle or host-pack module whose import is neither a Node built-in nor a listed file inside -its tree, so the lexed import evidence below describes prebuilt payload modules and other packed scripts -the framework did not compile. The dependency evidence is read from the packed bytes themselves: every `.js`/`.mjs`/`.cjs` file +its tree, so the lexed `import` evidence below describes prebuilt payload modules and other packed scripts +the framework did not compile. `AB6005` walks import specifiers only; a `require`, `createRequire(…)(…)`, or +`import.meta.resolve(…)` call is not an import, so that evidence is read from every packed file, compiled +bundles included. The dependency evidence is read from the packed bytes themselves: every `.js`/`.mjs`/`.cjs` file `npm pack --dry-run` lists is lexed for static and dynamic `import` specifiers and scanned for literal `require("…")` and `.resolve("…")` calls (`require.resolve`, `createRequire(…).resolve`, `import.meta.resolve`: a package located only to find an asset is still a runtime dependency; a diff --git a/packages/agent-bundle/src/build/pack-dependencies.ts b/packages/agent-bundle/src/build/pack-dependencies.ts index 1508ca0ac..ebab0b70c 100644 --- a/packages/agent-bundle/src/build/pack-dependencies.ts +++ b/packages/agent-bundle/src/build/pack-dependencies.ts @@ -15,14 +15,17 @@ import { readModuleImports, type ModuleImport } from './module-imports.ts'; * `pack-inventory.ts`): what `package.json` asks npm to install alongside the * package, and which packages the packed JavaScript and declaration files * actually reference. JavaScript the framework compiled — the `dist` bundles - * and the host-pack modules — never carries a bare package import, since - * `AB6005` fails the build first and `prepack` builds before it packs; the - * import, `require`, and resolve evidence read here is therefore that of + * and the host-pack modules — never carries a bare package `import`, static + * or dynamic, since `AB6005` fails the build first and `prepack` builds + * before it packs; the import evidence read here is therefore that of * prebuilt payload modules and other packed scripts the framework copied - * rather than compiled. The `bin`-command, declaration, `imports`-map, and - * install-script evidence is read from every packed file alike — a compiled - * bundle may still run a dependency's command (`spawnSync("tsc")`), which is - * not an import. + * rather than compiled. The `require`, `createRequire`, and + * `import.meta.resolve` evidence is different: those are calls the bundler + * leaves in place and `AB6005` does not walk, so they are read from every + * packed file, compiled bundles included — as are the `bin`-command, + * declaration, `imports`-map, and install-script evidence (a compiled bundle + * may run a dependency's command, `spawnSync("tsc")`, which is not an + * import). */ /** diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 316ac33a0..40b8bf39d 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -151,12 +151,15 @@ const perField = ( * only makes every consumer's `npm install` fetch a build-time package — and * fail outright when the specifier is one a consumer's npm cannot resolve * (git, remote tarball, path, or an unrewritten workspace protocol). A - * compiled bundle cannot import a bare package at all: `AB6005` fails the - * build on any specifier that is not a Node built-in, and `prepack` runs - * that build before this inventory, so the packed-JavaScript import evidence - * `AB7014` accepts comes only from modules the framework copied rather than - * compiled — prebuilt payload modules and other scripts the `files` - * allowlist packs — never from a `dist` bundle or a host-pack module. + * compiled bundle cannot `import` a bare package at all: `AB6005` fails the + * build on any import specifier that is not a Node built-in, and `prepack` + * runs that build before this inventory, so the import evidence `AB7014` + * accepts comes only from modules the framework copied rather than compiled + * — prebuilt payload modules and other scripts the `files` allowlist packs — + * never from a `dist` bundle or a host-pack module. A `require`, + * `createRequire(…)(…)`, or `import.meta.resolve(…)` call is not an import + * and `AB6005` does not walk it, so that evidence is read from every packed + * file, compiled bundles included. */ const unresolvableMessage = (field: InstalledDependencyField, own: readonly DeclaredDependency[]): string => `package.json ${field} names packages a consumer's npm cannot resolve through a registry (an invalid name or a non-registry specifier): ${own.map((dependency) => @@ -175,7 +178,8 @@ const dependencyDiagnostics = async (options: { const declared = declaredDependencies(options.packageDocument); if (declared.length === 0) return []; // `prepack` runs the build before this inventory, and `AB6005` there refuses every bare import in a compiled - // bundle, so any import evidence found here belongs to a packed module the framework did not compile. + // bundle, so any `import` evidence found here belongs to a packed module the framework did not compile; the + // `require`/`createRequire`/`import.meta.resolve` evidence is not an import and may come from any packed file. const imported = await importedPackageNames({ declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), packageDocument: options.packageDocument, @@ -228,7 +232,7 @@ const dependencyDiagnostics = async (options: { : 'Every consumer installs them for nothing; the emitted outputs already inline what they use.'), field === 'peerDependencies' ? 'Keep a deliberate compatibility peer, mark it optional in peerDependenciesMeta so npm stops installing it, or move a build-only package to devDependencies.' - : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005), so keep a runtime dependency only for what a prebuilt payload or other uncompiled packed module imports, a packed declaration file references, a #subpath import reaches through the imports map, or an install script or packed JavaScript runs; a computed import() or require() in packed code withholds this check.', + : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005), so keep a runtime dependency only for what a prebuilt payload or other uncompiled packed module imports, a packed file requires or resolves (createRequire, import.meta.resolve), a packed declaration file references, a #subpath import reaches through the imports map, or an install script or packed JavaScript runs; a computed import() or require() in packed code withholds this check.', field === 'peerDependencies' ? 'warning' : 'error', )), // npm skips an optional dependency it cannot fetch, so the install survives — but only once the specifier parsed diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 42c5d3732..15f0f1f03 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -373,9 +373,13 @@ export const buildPackageOutputs = async (options: { // every emitted `dist` module is walked as an ES module, and a bare // specifier that is not a Node built-in — an import the `tools` hatch // kept external — fails the build (`AB6005`) before `dist` is published, - // so a `dist/bin` executable loads nothing from a consumer's - // `node_modules`. Declarations are not modules and are not walked; they - // may still reference declared dependencies. + // so a `dist/bin` executable imports nothing from a consumer's + // `node_modules`. The walk reads import specifiers (static and literal + // dynamic); a `createRequire(…)(…)` or `import.meta.resolve(…)` call is + // not an import and is outside it, in `dist` as in a host pack — the + // prepack gate reads those as dependency evidence. Declarations are not + // modules and are not walked; they may still reference declared + // dependencies. const selfContainment = await validateJavaScriptModules({ artifactRoot: stageRoot, bundledPaths: new Set(files.filter((file) => file.kind === 'bundle').map((file) => file.path)), diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index c94049268..69fa582a9 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -196,9 +196,10 @@ declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of `devDependencies`. A compiled bundle — a host-pack module or a `dist` bundle alike — cannot import a bare package at all: `AB6005` fails the build before `prepack` reaches the inventory. So `dependencies` is only for what the packed files demonstrably need by other means — a prebuilt -payload module's import, a packed declaration's reference, a consumer install script, or a `bin` -command a packed file runs — since only JavaScript the framework did not compile can still import -one. `--output` is an artifact path relative to `--root` that overrides the configured +payload module's import, a `require`, `createRequire`, or `import.meta.resolve` call in a packed +file (a call is not an import, so `AB6005` does not walk it), a packed declaration's reference, a +consumer install script, or a `bin` command a packed file runs — since only JavaScript the +framework did not compile can still import one. `--output` is an artifact path relative to `--root` that overrides the configured `output.distPath`, defaulting to `artifact`. Use it as an npm `prepack` script; `--ignore-scripts` prevents recursion, and no npm lifecycle ever performs a host install. @@ -210,16 +211,18 @@ leaves only Node built-ins (`node:fs`, `path`, plus Yarn PnP's `pnpapi`) externa inline every script and style into one HTML file. Artifact validation holds the compiled host-pack bytes to the same line, and the package build holds its `dist` bundles to it before publishing them: a bare specifier that is not a Node built-in is `AB6005`, so a generated executable — in a -host pack or in `dist` — loads nothing but built-ins from outside its tree. An import kept +host pack or in `dist` — imports nothing but built-ins from outside its tree. An import kept external through the [`tools` hatch](../../reference/configuration.mdx#tools) is not a way around that: it fails the build in either output and never reaches the prepack inventory. A `dependencies` entry is therefore only for what the packed files demonstrably need from outside a compiled bundle — a package a prebuilt `.js`/`.mjs`/`.cjs` payload module imports (prebuilt files are opaque to `AB6005`, and `AB7014` scans only those extensions, so an extensionless prebuilt -module counts for nothing), one a consumer-side install script runs, one whose `bin` a packed file -executes, or one a packed declaration file references (`.d.ts` outputs are not walked by -`AB6005`) — and `AB7014` reports a declared dependency with none of that evidence, while `AB7015` -reports one a consumer's npm cannot install. +module counts for nothing), one a packed file loads through a `require`, `createRequire`, or +`import.meta.resolve` call (a call is not an import, so `AB6005` does not walk it in either +output), one a consumer-side install script runs, one whose `bin` a packed file executes, or one a +packed declaration file references (`.d.ts` outputs are not walked by `AB6005`) — and `AB7014` +reports a declared dependency with none of that evidence, while `AB7015` reports one a consumer's +npm cannot install. | Code | Meaning | | --- | --- | @@ -227,7 +230,7 @@ reports one a consumer's npm cannot install. | `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild, and do not modify generated host packs. | | `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | -| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so that evidence comes from prebuilt payload modules and other scripts the framework did not compile), no packed declaration references them, no `imports` mapping or consumer install script reaches them (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | +| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so `import` evidence comes from prebuilt payload modules and other scripts the framework did not compile, while `require`, `createRequire`, and `import.meta.resolve` calls count from any packed file), no packed declaration references them, no `imports` mapping or consumer install script reaches them (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | | `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns instead of failing, since npm continues without it; it stays an error when unparseable, or when a consumer install script needs the skipped package (runs its command — not merely mentions it — loads it from an inline `node -e` program — scanned for `require`, `createRequire`, and `import()` like a packed file — preloads it with `node -r`/`--require`/`--import`/`--loader`, or runs a packed file that imports it, `node .` running the root `main` included; each command after `&&`, `;`, or a newline is read on its own, shell quotes and backslash escapes resolved, and `node`'s options belong to `node` alone and end at the program — `node install.js --require x` preloads nothing, while a `NODE_OPTIONS=--require=x` assignment on the same `node` command does; every script word naming a packed JavaScript file counts as run, deliberately, so that runners the gate does not model — `tsx`, `zx`, `bun`, `deno run` — still have their file's dependencies traced, at the cost of a rare escalation for a word such as `echo install.js`, which the diagnostic makes visible by naming the file). A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry; npm never reads the duplicate peer's selector. `workspace:`/`catalog:` are reported too unless the `prepack` lifecycle runs under pnpm, Yarn, or Bun, which rewrite them at pack time — `npm publish` does not, so under npm `AB7015` fires; entries the tarball itself carries — `bundleDependencies` npm actually packed (never peers), and a `file:` path inside the package whose packed source is installable (a directory with a parseable `package.json`, or a well-formed tarball whose `package.json` parses) — are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | The dependency evidence is read from the packed bytes: every `.js`/`.mjs`/`.cjs` file npm would diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 2c3a1b662..ffbbd257c 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -170,7 +170,8 @@ npx agent-bundle prepack --root . --output artifact --json `dist/bin` 与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 `zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle—— 根本不能导入裸包:`AB6005` 会在 `prepack` 触及清单之前就让构建失败。因此 `dependencies` 只留给打包后的 -文件以其他方式有据可证地需要的内容——预构建 payload 模块的导入、打包后声明文件的引用、消费者侧安装脚本, +文件以其他方式有据可证地需要的内容——预构建 payload 模块的导入、打包后文件中的 `require`、`createRequire` +或 `import.meta.resolve` 调用(调用不是导入,`AB6005` 不会遍历它)、打包后声明文件的引用、消费者侧安装脚本, 或打包后的文件运行的 `bin` 命令——因为只有框架没有编译的 JavaScript 才仍然可能导入它。`--output` 是相对 `--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` 脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 @@ -180,13 +181,15 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 `splitChunks: false` 的配置编译,框架自身也不添加任何 `externals`;Rslib 的 `node` target 只把 Node 内建模块(`node:fs`、`path`, 以及 Yarn PnP 的 `pnpapi`)保持外部化。MCP App 视图则把每个脚本与样式都内联进同一个 HTML 文件。产物校验对 编译出的宿主包字节坚持同一条界线,包构建也在发布 `dist` bundle 之前对它们坚持同一条界线:任何不是 Node 内建 -模块的裸说明符即为 `AB6005`,因此生成的可执行文件——无论位于宿主包还是 `dist`——从自己的输出树之外加载的只有 +模块的裸说明符即为 `AB6005`,因此生成的可执行文件——无论位于宿主包还是 `dist`——从自己的输出树之外导入的只有 内建模块。通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化的导入并不能绕过这条界线: 它在两种输出中都会让构建失败,永远到不了 prepack 的清单检查。因此,`dependencies` 条目只留给打包后的文件 有据可证地需要从已编译 bundle 之外获取的内容——预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建 -文件对 `AB6005` 不透明,而 `AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),消费者侧 -安装脚本运行的包,打包后的文件执行其 `bin` 的包,或打包后的声明文件引用的包(`.d.ts` 输出不会被 `AB6005` -遍历)——`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告消费者的 npm 无法安装的依赖。 +文件对 `AB6005` 不透明,而 `AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),打包后的 +文件通过 `require`、`createRequire` 或 `import.meta.resolve` 调用加载的包(调用不是导入,所以 `AB6005` 在两种 +输出中都不会遍历它),消费者侧安装脚本运行的包,打包后的文件执行其 `bin` 的包,或打包后的声明文件引用的包 +(`.d.ts` 输出不会被 `AB6005` 遍历)——`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告 +消费者的 npm 无法安装的依赖。 | 代码 | 含义 | | --- | --- | @@ -194,7 +197,7 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 | `AB7011` | 磁盘上的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成的宿主包。 | | `AB7012` | 某个 `package.json` bin 指向了打包后的 `dist` 输出之外(包括 `src/`),或指名了一个被 npm 忽略的文件。请把它指向生成的 `dist/bin` 文件。 | | `AB7013` | `package.json`、规范化后的插件元数据、某份宿主清单或产物 provenance 报告了不同的发布版本。请让每处发布标识一致。 | -| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此这类证据只来自预构建 payload 模块以及其他框架没有编译的脚本),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | +| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此 `import` 证据只来自预构建 payload 模块以及其他框架没有编译的脚本,而 `require`、`createRequire` 与 `import.meta.resolve` 调用则可来自任何打包后的文件),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | | `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。`optionalDependencies` 中的抓取类项只发出警告而不会失败,因为 npm 会继续安装;不可解析的项,或消费者安装脚本需要的项(运行其命令——而非仅仅提及它——在内联的 `node -e` 程序中加载它——该程序像已打包文件一样被扫描 `require`、`createRequire` 与 `import()`——用 `node -r`/`--require`/`--import`/`--loader` 预加载它,或运行某个导入它的已打包文件,包括通过根 `main` 运行的 `node .`;`&&`、`;` 或换行之后的每条命令单独判读,shell 引号与反斜杠转义均已解析,`node` 的选项只属于 `node` 且到程序为止——`node install.js --require x` 不会预加载任何东西,而同一条 `node` 命令上的 `NODE_OPTIONS=--require=x` 赋值会;脚本中每个点名已打包 JavaScript 文件的词都视为被运行,这是有意为之,使本闸门未建模的运行器——`tsx`、`zx`、`bun`、`deno run`——所运行文件的依赖仍被追踪,代价是像 `echo install.js` 这样的词偶有误升级,诊断会点名该文件使之可见),仍是错误。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断;npm 从不读取重复 peer 的选择器。`workspace:`/`catalog:` 同样会被报告,除非 `prepack` 生命周期运行在 pnpm、Yarn 或 Bun 之下——它们会在打包时重写这些协议;`npm publish` 不会重写,因此在 npm 下 `AB7015` 会触发;tarball 自身携带的项——`bundleDependencies` 中 npm 实际打包进去的项(peer 除外),以及已打包来源可供安装的包内 `file:` 路径(带有可解析 `package.json` 的目录,或格式正确且其 `package.json` 可解析的 tarball)——不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | 依赖证据直接读取自打包后的字节:npm 将发布的每个 `.js`/`.mjs`/`.cjs` 文件都会被词法分析出 `import` From f80da9eb3f656fdc9c4e652e077c08b0e9fa4e96 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 04:36:51 +0000 Subject: [PATCH 4/4] docs: AB6005 rejects bare import specifiers; the tools hatch cannot externalize an import --- .changeset/package-build-ab6005.md | 2 +- AGENTS.md | 2 +- docs/entry-conventions.md | 8 +++++--- website/docs/en/guide/distribution/validation.mdx | 2 +- website/docs/zh/guide/distribution/validation.mdx | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.changeset/package-build-ab6005.md b/.changeset/package-build-ab6005.md index 8ae12824b..9c4078e5c 100644 --- a/.changeset/package-build-ab6005.md +++ b/.changeset/package-build-ab6005.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed file that requires or resolves it, a packed declaration that references it, or an install script or packed file that runs it. (#588) +Hold the npm package build's `dist` JavaScript to the same self-containment rule as the host packs: `agent-bundle build` and `agent-bundle prepack` now walk every emitted `dist/**/*.js` and `*.mjs` bundle (`dist/bin/.js`, its rendered-route Flight worker, the generated install bin, and the `lib` entry) as an ES module and fail with `AB6005` on any bare import specifier that is not a Node built-in — including an import kept external through the `tools` escape hatch, which previously survived in `dist` and was judged only by the prepack gate. The diagnostic names the file as `dist/` and the specifier; a literal dynamic `import()` counts like a static import; `node:` and bare built-in imports, relative imports of emitted files, `import.meta`, and `createRequire` of packed files stay valid, and `.d.ts` output is not walked. A dependency that only a compiled bundle imports is therefore an `AB6005` build failure before the pack inventory runs, never an `AB7014` finding; `AB7014`'s recovery now names what still keeps a runtime dependency — a prebuilt payload module that imports it, a packed file that requires or resolves it, a packed declaration that references it, or an install script or packed file that runs it. (#588) diff --git a/AGENTS.md b/AGENTS.md index b3daf463c..9216e8568 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ every dependency of a generated executable — `output.autoExternal: false`, `bundle: true`, `splitChunks: false`, no `externals`. Rslib's `node` target leaves only Node built-ins (and `pnpapi`) external, and the only bare - specifiers `AB6005` accepts in a host-pack module are Node built-ins. The + import specifiers `AB6005` accepts in a host-pack module are Node built-ins. The package build's `dist` bundles are walked by the same `AB6005` rule (`src/build/package-build.ts` reuses `validateJavaScriptModules` from `src/build/validate-artifact-modules.ts`), so a generated executable in a diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 082a00cd7..a90318a88 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1211,9 +1211,11 @@ hatch customizes *how code compiles*, never *what the artifact promises*. The framework's own profile keeps the same promise: `output.autoExternal` is `false`, `bundle: true`, `splitChunks: false`, and no `externals` are added, so Rslib's `node` target leaves only Node built-ins (and `pnpapi`) external, and -`AB6005` fails any bare specifier that is not a Node built-in in every compiled -module — host-pack modules and the package build's `dist` bundles alike — so -the hatch cannot externalize a dependency on the author's behalf. Run-time +`AB6005` fails any bare import specifier that is not a Node built-in in every +compiled module — host-pack modules and the package build's `dist` bundles +alike — so the hatch cannot externalize an import on the author's behalf (a +`require`, `createRequire`, or `import.meta.resolve` call is not an import and +is outside that walk; the prepack gate reads those as dependency evidence). Run-time path references are kept the same way: a `new URL(…, import.meta.url)` or `new Worker(new URL(…))` in consumer or generated code names a file beside the artifact, so the invariant layer turns the bundler's URL and worker asset diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 69fa582a9..bd63e3d2d 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -210,7 +210,7 @@ and `splitChunks: false`, and the framework adds no `externals` of its own; Rsli leaves only Node built-ins (`node:fs`, `path`, plus Yarn PnP's `pnpapi`) external. MCP App views inline every script and style into one HTML file. Artifact validation holds the compiled host-pack bytes to the same line, and the package build holds its `dist` bundles to it before publishing -them: a bare specifier that is not a Node built-in is `AB6005`, so a generated executable — in a +them: a bare import specifier that is not a Node built-in is `AB6005`, so a generated executable — in a host pack or in `dist` — imports nothing but built-ins from outside its tree. An import kept external through the [`tools` hatch](../../reference/configuration.mdx#tools) is not a way around that: it fails the build in either output and never reaches the prepack inventory. A diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index ffbbd257c..4cdad9f31 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -181,7 +181,7 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 `splitChunks: false` 的配置编译,框架自身也不添加任何 `externals`;Rslib 的 `node` target 只把 Node 内建模块(`node:fs`、`path`, 以及 Yarn PnP 的 `pnpapi`)保持外部化。MCP App 视图则把每个脚本与样式都内联进同一个 HTML 文件。产物校验对 编译出的宿主包字节坚持同一条界线,包构建也在发布 `dist` bundle 之前对它们坚持同一条界线:任何不是 Node 内建 -模块的裸说明符即为 `AB6005`,因此生成的可执行文件——无论位于宿主包还是 `dist`——从自己的输出树之外导入的只有 +模块的裸导入说明符即为 `AB6005`,因此生成的可执行文件——无论位于宿主包还是 `dist`——从自己的输出树之外导入的只有 内建模块。通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化的导入并不能绕过这条界线: 它在两种输出中都会让构建失败,永远到不了 prepack 的清单检查。因此,`dependencies` 条目只留给打包后的文件 有据可证地需要从已编译 bundle 之外获取的内容——预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建