From 092bd5f49338c0d497169c35bd034a789eea50e7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:16:30 +0000 Subject: [PATCH 1/8] =?UTF-8?q?AB7014=20from=20evidence=20(option=20A);=20?= =?UTF-8?q?gate=20the=20generated-JS=20walk=20on=20the=20compile=20evidenc?= =?UTF-8?q?e=20record=20(#619=20steps=20b=E2=80=93d)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/619-evidence-ab7014.md | 5 + AGENTS.md | 62 +- docs/diagnostics.md | 92 +-- docs/effect-conventions.md | 11 +- docs/entry-conventions.md | 59 +- .../src/build/artifact-validation-types.ts | 9 - packages/agent-bundle/src/build/build.ts | 5 +- .../agent-bundle/src/build/module-imports.ts | 33 +- .../src/build/pack-dependencies.ts | 660 +++--------------- .../agent-bundle/src/build/pack-inventory.ts | 72 +- .../agent-bundle/src/build/package-build.ts | 28 +- packages/agent-bundle/src/build/rslib.ts | 41 +- .../src/build/validate-artifact-modules.ts | 23 +- .../src/build/validate-artifact.ts | 53 +- .../src/core/dependency-manifest.ts | 5 +- .../tests/artifact-validator.test.ts | 104 ++- packages/agent-bundle/tests/build.test.ts | 46 +- .../tests/generated-module-evidence.test.ts | 212 ++++++ packages/agent-bundle/tests/hooks.test.ts | 47 -- .../tests/pack-dependencies.test.ts | 29 +- .../tests/packed-consumer.test.ts | 4 +- .../tests/preflight-artifact-graph.test.ts | 11 +- packages/agent-bundle/tests/prepack.test.ts | 470 +++---------- website/docs/en/guide/authoring/mcp.mdx | 5 +- .../en/guide/authoring/package-entries.mdx | 6 +- .../docs/en/guide/concepts/architecture.mdx | 33 +- .../docs/en/guide/distribution/validation.mdx | 101 ++- .../docs/en/reference/targets-artifacts.mdx | 8 +- website/docs/zh/guide/authoring/mcp.mdx | 5 +- .../zh/guide/authoring/package-entries.mdx | 4 +- .../docs/zh/guide/concepts/architecture.mdx | 23 +- .../docs/zh/guide/distribution/validation.mdx | 65 +- .../docs/zh/reference/targets-artifacts.mdx | 7 +- 33 files changed, 946 insertions(+), 1392 deletions(-) create mode 100644 .changeset/619-evidence-ab7014.md create mode 100644 packages/agent-bundle/tests/generated-module-evidence.test.ts diff --git a/.changeset/619-evidence-ab7014.md b/.changeset/619-evidence-ab7014.md new file mode 100644 index 000000000..5b2bef7d4 --- /dev/null +++ b/.changeset/619-evidence-ab7014.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed for syntax and reported only for a non-literal `import()`; JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. `ValidateArtifactOptions.bundleSyntaxCheck` is removed, and a reserved specifier kept external by a function-form `tools` external now fails as compile-time `AB6005` instead of a residual-import error. (#634) diff --git a/AGENTS.md b/AGENTS.md index 3b4344d69..6c14dac86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,18 +70,19 @@ `packages/agent-bundle/src/build/rslib.ts` (`composeEntryLibConfig`) bundles 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 - 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 - 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`, + leaves only Node built-ins (and `pnpapi`) external. The compiler service + (`src/build/compiler.ts`, `external-policy.ts`, + `dependency-audit-plugin.ts`) records every `ExternalModule` of every + host-pack, `dist`, and MCP App view compilation and fails the build + (`AB6005`) on anything but a Node built-in, `pnpapi`, or an emitted sibling + — whatever spelling Rspack emitted. The package build's `dist` bundles are + judged by the same rule and then walked like host-pack modules + (`src/build/package-build.ts` reuses `validateJavaScriptModules`), so a + generated executable in a host pack or in `dist` imports 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. @@ -90,18 +91,31 @@ 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 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`). + legitimately puts a package under `dependencies` is one of four `AB7014` + evidence sources: `runtimeDependencies` on a prebuilt payload + (`definePrebuilt`), a packed declaration reference, a consumer-side install + script that names or runs it, or the framework's process-dependency record + (empty today). `AB7015` additionally requires a specifier a consumer's npm + can install. +- Proof is compiler evidence, the persisted record, and packed-process + tests: each compilation's own external and module records are judged before + emission, `build` writes them beside the emitted files as + `agent-bundle.compile-evidence.json` (`src/build/compile-evidence.ts`) and + `validate --artifact` re-checks the record against the file table + (`AB6039`), the prepack gate judges declared dependencies from evidence, + 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`). The + emitted-module walk (`src/build/validate-artifact-modules.ts`) remains + only for what the compiler cannot see: an expression `import()` in a + compiled module (Rslib's profile leaves `import()`, + `require()`, `require.resolve(…)`, `createRequire(…)(…)`, and + `import.meta.resolve(…)` verbatim — no module, no external, no warning), + JavaScript the framework did not compile (`install.mjs`, copied scripts), + and every module of a build with a `tools` hatch (`coverage.rewritable`). + A compiled module the record proves is lexed for syntax and not + import-resolved; the walk is not a second self-containment check and must + not grow one. ## Documentation site diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 44bcaeaaa..03a1de6fb 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -32,10 +32,10 @@ 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`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains behind that compile-time check and reports residual import, syntax, and relative-target findings; 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; `AB6039`: the compile evidence record does not match the manifest file table; `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`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains only for what the compiler cannot see — an expression `import()` in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten; 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). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7000`–`AB7004`: unsupported host, unreadable bundle identity, missing host, scope or mode refusal, host command failure — the same five codes are also the development project service's preparation failures; `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). | +| `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `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). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | | `AB7300`–`AB7332` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), and retained pre-#640 state (`AB7332`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | @@ -427,57 +427,35 @@ 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, and no prebuilt payload names them in `runtimeDependencies` (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, an install script or packed file runs, or a prebuilt payload names in `runtimeDependencies` (`definePrebuilt`). 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 the compiler -reports `AB6005` when a `dist` bundle or host-pack module keeps anything other than a Node built-in, -`pnpapi`, or an emitted sibling external, and when an MCP App view's compilation keeps anything external -at all. The emitted-module walk remains behind that compile-time check, so the lexed `import` evidence below describes prebuilt payload modules and other packed scripts the -framework did not compile. A `require`, `createRequire(…)(…)`, or `import.meta.resolve(…)` call the compiler -does not resolve is not a module dependency; its evidence is read from every packed file, compiled bundles -included. Because the compiler never opens a payload file, its -`runtimeDependencies` declaration also counts as dependency evidence. 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 -binding such as `const load = createRequire(import.meta.url)` is a loader and `load("…")` counts like -`require("…")`, whether the factory is imported under its own name, renamed with `as`, reached through a -namespace import, or chained off `require("node:module")`; `createRequire(…)("…")` inline counts too, qualified the same -ways — `Module.createRequire(…)("…")`, `require("node:module").createRequire(…)("…")` — the -factory's argument nesting calls such as `new URL("./entry.js", import.meta.url)`; comments between a loader -and its parentheses or around the argument, `require /* x */ ("y")`, are trivia), and -every `.d.ts`/`.d.mts`/`.d.cts` file is scanned for `from "…"`, -`import("…")`, `import x = require("…")`, `declare module "…"` (a module augmentation), and `/// ` (a consumer needs the -package that provides those types even without a runtime import; a type directive counts for the -named package and its `@types/*` twin, `@types/scope__name` for a scoped name); bare specifiers are reduced to -their package name (`@scope/name` or `name`), string escapes decoded first (`require("\x66oo")` loads `foo`), -and Node built-ins are ignored. A dependency packed JavaScript runs rather than loads — a string literal that -is one of the `bin` commands its manifest under `node_modules` declares, bare or followed by arguments -(`spawnSync("tsc", ["--version"])`, `execSync("tsc --noEmit")`) — counts as used too; a dependency not installed -at pack time has no known commands, so its bare name in a string proves nothing here. A mention inside a -comment or string can only keep a dependency, never report one, and `devDependencies` are never -inspected. A packed `#subpath` import counts for every package the `imports` entry Node would pick for it targets -(the exact key, or the wildcard key with the longest matching prefix, its `*` substituted — every conditional target -of that entry, since conditions are not settled here), and a dependency named — anywhere in the text, since a mention -can only keep a declaration — by a consumer-side `preinstall`/`install`/`postinstall` script (not `prepare`, which npm -runs on `pack`, local installs, and git dependencies but never for a published tarball), or by any -script those reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is -the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, -`npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, -so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved -(`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through -npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`) — by -package name or by one of its `bin` commands, read from `node_modules//package.json` (a string-form `bin` -is one command named after the installed manifest's unscoped `name` — `real` for an alias `"wrapper": -"npm:@scope/real@1"`; the manifest is read as npm reads it, so the last of duplicate keys wins), with the unscoped dependency name standing in when that manifest is unreadable or not JSON — counts as used. A computed `import(expression)` or -`require(expression)` — also `require.resolve`, `import.meta.resolve`, a direct `createRequire(…)(…)`, or a -`createRequire` binding — with a non-literal argument — in packed JavaScript could load any declared package, so its presence withholds -`AB7014` entirely; the recovery text says so. So does packed source the ESM lexer rejects, whose `import()` calls it cannot report, and so does a loader passed on as a value rather than called — `const load = require`, `fn(require)`, -`module.exports = require`, `x ? require : y` — since packages may then be loaded under a name the scan never sees (`require("x")`, -`require.resolve("x")`, `typeof require`, and a `require` inside a string or comment are not that). Only those resolvers count, literal argument or not: `path.resolve("foo")` -and `Promise.resolve("foo")` are not package resolution and never keep `foo`. +| `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the four evidence sources follow 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. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer 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`), or 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 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; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). 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). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. 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. | + +`AB7014` counts exactly four sources of evidence that a consumer needs a +declared package: + +1. `runtimeDependencies` on a prebuilt payload (`definePrebuilt`); +2. a packed `.d.ts`, `.d.mts`, or `.d.cts` reference as TypeScript's + `preProcessFile` reads it: `import`/`export … from`, + `import x = require("…")`, `import("…")` types, module augmentations in an + external-module file, or `/// ` (the named package + and its `@types` twin). An ambient `declare module "x"` in a file with no + imports declares that module and counts for nothing. A `#subpath` reaches + packages through the manifest's `imports` map; relative and built-in + specifiers name nothing; +3. the consumer-side `preinstall`, `install`, or `postinstall` script grammar, + including scripts reached through package-manager run and direct script + commands, naming the package or running one of its `bin` commands; and +4. the framework's process-dependency record, which is empty because no + framework runtime module bundled into a generated executable loads a + package at run time. + +A package the compiler bundled is not used. When one is otherwise unused, the +diagnostic names the `dist` bundles that inlined it. Expression requests +(`import(expr)`, `require(expr)`) remain outside the compiler's view; the +emitted-module walk still fails a compiled module on an expression `import()` +(`AB6005 has a non-literal dynamic import`), while `require(expr)`, +`require.resolve(…)`, `createRequire(…)(…)`, and `import.meta.resolve(…)` +are not imports and are outside both. ## Declaration generation (`AB4716`) @@ -1814,8 +1792,8 @@ placeholders itself. ## Built-artifact validation (`AB6000`–`AB6018`, `AB6023`–`AB6025`, `AB6039`) `agent-bundle build` validates the staged tree before it writes the manifest -(`validateArtifactFiles`: filesystem entries, generated JSON documents, and -generated JavaScript modules), validates the finished artifact against its +(`validateArtifactFiles`: filesystem entries and generated JSON documents), +validates the finished artifact against its manifest, and re-checks the validated snapshot after the staging tree is renamed into place. `agent-bundle validate --artifact ` runs the same validator over a built directory, `agent-bundle dev` runs it over every @@ -1853,7 +1831,7 @@ therefore does not prove the absence of such a load. | `AB6001` | error | `Artifact manifest is not a strict canonical manifest.` — `agent-bundle.manifest.json` does not parse as a strict canonical artifact manifest. `Artifact manifest changed during validation.` — its bytes or identity differ between the first read and the re-read after validation. | Regenerate the strict canonical manifest without concurrent writes, then rerun validation. | | `AB6002`–`AB6003` | error | Reserved: both codes are declared in the artifact diagnostic registry, but no validator emits either today. | `AB6002`: Rebuild the artifact from complete project source, then rerun validation. `AB6003`: Rebuild the artifact with canonical generated output, then rerun validation. | | `AB6004` | error | `Artifact files do not match the manifest.` — the regular files on disk differ from the manifest file table (a path, byte length, mode, or SHA-256; a missing or unmanifested file). `Artifact file changed during validation: "".` — a file differed between the initial and final inspection, or between a validated staging tree and its re-check after `build` renamed it into place. `Artifact file table changed during validation.` — the final inspection could not be taken. | Rebuild the artifact so its file table and contents match the manifest. | -| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. The emitted-module walk remains as defense in depth and reports `Generated JavaScript import from "" .` for a residual unsupported or invalid specifier, unreadable module, invalid syntax, or non-literal dynamic import. Its relative-target findings remain `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | +| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. Residual walk forms, `Generated JavaScript import from "" .`, reported only for what the compiler cannot see: `has a non-literal dynamic import` for an expression `import()` in any emitted module, compiled ones included, since Rslib's profile leaves that form verbatim; and, for a module the compile evidence record does not prove — JavaScript the framework did not compile (`install.mjs`, a copied script), every module of an artifact without a record, and every module of a build whose `tools` hatch may have rewritten the emitted bytes (`coverage.rewritable`) — `has invalid syntax` from a full parse, `uses unsupported specifier` or `uses invalid specifier` for a bare or malformed import, `cannot be read`, and the relative-target findings `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`. A compiled module the record covers with the same bytes is lexed for syntax and its literal imports are not resolved again — the compiler resolved them (bundled, built-in, or an emitted sibling). Prebuilt payloads are not walked; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | | `AB6006` | error | `Generated JSON cannot be parsed.` — a `.json` file in the artifact is not valid JSON (prebuilt payload files are exempt). Doctor's Claude document lane reports the same code inside an `AB7319` message for a Claude bundle document that is unreadable or not valid JSON. | Regenerate the affected JSON document as valid JSON, then rebuild the artifact. | | `AB6007` | error | `MCP manifest references missing generated server "".` — a root-level MCP manifest (pre-manifest pass) or a target's MCP manifest names a local server entry that the artifact does not contain. | Repair MCP manifest references to generated servers, then rebuild the artifact. | | `AB6008` | error | `Artifact Agent Skills provenance does not match the pinned schema contract.` — the manifest's `agentSkills` schema SHA-256, source revision, or specification differs from the framework's pinned Agent Skills revision. | Rebuild the artifact with the pinned Agent Skills contract. | @@ -1870,7 +1848,7 @@ therefore does not prove the absence of such a load. | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | | `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | -| `AB6039` | error | `Compile evidence record .` — the listed `agent-bundle.compile-evidence.json` failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` — it is listed but unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a manifest `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the manifest does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. | Rebuild the artifact so its compile evidence record describes the emitted files. | +| `AB6039` | error | `Compile evidence record .` — the listed `agent-bundle.compile-evidence.json` failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` — it is listed but unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a manifest `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the manifest does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. A record with any `AB6039` finding, or with `coverage.rewritable` set, proves nothing to the emitted-module walk, which then parses every module in full. | Rebuild the artifact so its compile evidence record describes the emitted files. | ## Workbench artifact inspection (`AB6200`–`AB6202`) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 2e1a2f2f4..e604c84ee 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -457,12 +457,11 @@ the first-party CLI's user-facing text — see compares its `dev`/`ino`/`nlink` with the discovering `lstat`; `opendir` for `Dirent` kinds. `build/validate-artifact.ts` `snapshotManifest` (`lstat` / read / `lstat` `dev`/`ino` identity), the `lstat` rows in - `validate-artifact-modules.ts`, `pack-inventory.ts`, `eval/artifact.ts`, - `eval/fixtures.ts`, `eval/graders.ts`, and the `Dirent`-typed listings in - `eval/codex-plugins.ts` and `host-contracts/*` `symlinkDiagnostics` - (a symlinked directory is not a directory to them). `declaration-diagnostics.ts`'s - synchronous `existsSync` probe beside `createRequire`'s synchronous - resolution. + `pack-inventory.ts`, `eval/artifact.ts`, `eval/fixtures.ts`, + `eval/graders.ts`, and the `Dirent`-typed listings in `eval/codex-plugins.ts` + and `host-contracts/*` `symlinkDiagnostics` (a symlinked directory is not a + directory to them). `declaration-diagnostics.ts`'s synchronous `existsSync` + probe beside `createRequire`'s synchronous resolution. - Synchronous SQLite setup (`rsc-runtime/src/state/sqlite.ts`). - Dev-server durable protocols and identity checks: `dev/epoch-store.ts`, `dev/dev-lock.ts`, `dev/runtime-generation-store.ts`'s publish path diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 2b74614d5..663084d44 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1261,8 +1261,8 @@ export default defineConfig({ native command as `node "/" ` — one config declaration replaces a hand-rolled `hooks/hooks.json` per host. Prebuilt hook `args` (for example `--host claude`) accept shell-safe strings only. -- **Prebuilt means opaque.** Payload files are exempt from generated-output - content validation (bundled-ESM import graphs, strict generated JSON) but +- **Prebuilt means opaque.** Payload files are exempt from compiler dependency + evidence, the emitted-module walk, and strict generated-JSON validation but remain hash-locked to the manifest. Declaration provenance is recorded as `kind: 'prebuilt'`. Hooks with prebuilt handlers are packaged like native hook documents: they do not compile wrappers and do not appear in the @@ -1314,25 +1314,33 @@ module specifiers are protected the same way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated module specifier (`agent-bundle/meta`, or a registry specifier such as `agent-bundle/mcp-apps`) fails the build with a hard diagnostic — at config inspection for statically visible `externals`, -and from the emitted bundle's residual imports for function-form `externals`. -The 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. The compiler service -lowers every host-pack surface and package-build entry. The framework-owned -`ArtifactDependencyAuditPlugin` taps `thisCompilation` and records every module -Rspack kept external, and the service reads that evidence before trusting an -asset. `AB6005` rejects anything Rspack kept external except a Node built-in, -`pnpapi`, or an emitted sibling of the same artifact, whatever spelling the -bundle uses. `agent-bundle build` writes that evidence as -`agent-bundle.compile-evidence.json` at the artifact root (listed in -`agent-bundle.manifest.json` as a `generated` file); `agent-bundle validate +and from the compilation's externals evidence for function-form `externals`. +The 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. +The compiler service lowers every host-pack surface and package-build entry. +The framework-owned `ArtifactDependencyAuditPlugin` taps `thisCompilation` +and records every module Rspack kept external, and the service reads that +evidence before trusting an asset. `AB6005` rejects anything Rspack kept +external except a Node built-in, `pnpapi`, or an emitted sibling of the same +artifact, whatever spelling the bundle uses. `agent-bundle build` writes that +evidence as `agent-bundle.compile-evidence.json` at the artifact root (listed +in `agent-bundle.manifest.json` as a `generated` file); `agent-bundle validate --artifact` re-checks a listed record against the file table without reading -JavaScript (`AB6039`). The emitted-module walk remains behind that check as -defense in depth. A `require`, -`createRequire(…)(…)`, or `import.meta.resolve(…)` call the compiler does not -resolve is not a module dependency; content the compiler did not compile is -opaque and must declare what it needs. Run-time path references are kept the -same way: a `new URL(…, import.meta.url)` or +JavaScript (`AB6039`). An expression request +(`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's +profile leaves it verbatim, and the compile evidence record lists those forms +as unobserved. The emitted-module walk +(`src/build/validate-artifact-modules.ts`) remains for exactly what the +compiler cannot see: it fails an expression `import()` in any emitted module +(`AB6005 has a non-literal dynamic import`), parses in full and resolves the +imports of JavaScript the framework did not compile (`install.mjs`, copied +scripts), and does the same for every module of a build whose `tools` hatch +may have rewritten the emitted bytes (`coverage.rewritable`). A compiled +module the record covers is lexed for syntax only; the compiler already +resolved its literal imports. Content the compiler did not compile is opaque +and must declare what it needs. 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 processing off after the hatch and the expression reaches the artifact @@ -1606,11 +1614,12 @@ resolve '../events'`). The route graph reports such an import first, as `agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, and `agent-bundle/test/browser`, matched exactly; `import type` and type-only -usage are not reported), while an external bare import (`AB6005 uses -unsupported specifier`) or a non-literal `import(spec)` (`AB6005 has a -non-literal dynamic import`) still fails artifact validation. From an -installed artifact the supported command is ` web` on -`bin/.mjs` (emitted when `web` is configured, even with no +usage are not reported), while an external bare import fails `AB6005` from +the compiler's externals evidence and a non-literal `import(spec)`, which the +compiler leaves verbatim, still fails artifact validation from the +emitted-module walk (`AB6005 has a non-literal dynamic import`). From an +installed artifact the supported command is ` web` +on `bin/.mjs` (emitted when `web` is configured, even with no `src/cli/**` commands). It reads the manifest `web` section beside `bin/`, launches the plugin's own packed MCP server, and prints the same ready line `MCP App / at (tool ; Ctrl-C stops the server)` — diff --git a/packages/agent-bundle/src/build/artifact-validation-types.ts b/packages/agent-bundle/src/build/artifact-validation-types.ts index 4c3ddcb94..98c1120be 100644 --- a/packages/agent-bundle/src/build/artifact-validation-types.ts +++ b/packages/agent-bundle/src/build/artifact-validation-types.ts @@ -6,20 +6,11 @@ import type { ArtifactHook, } from './emit.ts'; import type { ArtifactManifest } from './manifest.ts'; -import type { ModuleSyntaxCheck } from './module-imports.ts'; export interface ValidateArtifactOptions { /** Enables the one store-owned epoch staging marker after its exact schema validates. */ readonly allowEpochStagingMarker?: true; readonly artifactRoot: string; - /** - * How the syntax of a module the framework compiled (manifest kind - * `bundle`) is checked: `lexed` (the default) trusts the bundler's own - * output to the ESM lexer; `parsed` runs the full parse a build selects - * when a consumer bundler hatch may have rewritten the emitted assets. - * Every other module is always parsed in full. - */ - readonly bundleSyntaxCheck?: ModuleSyntaxCheck; /** * Artifact-relative paths of prebuilt payload files for pre-manifest * validation. Prebuilt files are integrity-checked but never subjected to diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index b09482cc9..ed936f617 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -32,7 +32,6 @@ import { type McpAppCompileMode, type PlannedMcpApp, } from './mcp-apps.ts'; -import { bundleSyntaxCheckFor } from './module-imports.ts'; import { compileRslibSurfaces, settledRslibSurface } from './compiler.ts'; import { compileEvidenceFileName, @@ -590,10 +589,8 @@ export const build = async (options: BuildOptions): Promise => { files: await listArtifactFiles(stageRoot), outputProvenance, }); - const bundleSyntaxCheck = bundleSyntaxCheckFor(options.tools); const preManifestDiagnostics = await validateArtifactFiles({ artifactRoot: stageRoot, - bundleSyntaxCheck, manifestFiles: files, prebuiltPaths: new Set(outputProvenance .filter((output) => output.kind === 'prebuilt') @@ -614,7 +611,7 @@ export const build = async (options: BuildOptions): Promise => { selected: composite.selected, }), }); - const diagnostics = await validateArtifact({ artifactRoot: stageRoot, bundleSyntaxCheck, registry: options.registry }); + const diagnostics = await validateArtifact({ artifactRoot: stageRoot, registry: options.registry }); if (diagnostics.some((entry) => entry.severity === 'error')) { throw new DiagnosticError(diagnostics); } diff --git a/packages/agent-bundle/src/build/module-imports.ts b/packages/agent-bundle/src/build/module-imports.ts index d9f9e249c..9d06ae02d 100644 --- a/packages/agent-bundle/src/build/module-imports.ts +++ b/packages/agent-bundle/src/build/module-imports.ts @@ -1,8 +1,6 @@ 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 @@ -19,34 +17,25 @@ export interface ModuleImport { * * - `lexed`: the ESM lexer is the only pass. It rejects unterminated strings, * templates, comments, and regexps and unbalanced braces — enough for a - * module the framework's own bundler emitted, whose syntax is the - * bundler's to guarantee. Re-parsing megabytes of bundler output to prove - * it is JavaScript was the dominant cost of every build. - * - `parsed`: a full `acorn` parse runs first, so a module the framework did - * not compile — a copied consumer script, a generated installer — keeps - * the complete syntax check. + * module the compiler emitted and its evidence record covers, whose syntax + * is the bundler's to guarantee. Re-parsing megabytes of bundler output to + * prove it is JavaScript was the dominant cost of every build. + * - `parsed`: a full `acorn` parse runs first, so a module the compiler's + * evidence does not cover — a copied consumer script, a generated + * installer, a bundle a `tools` hatch may have rewritten — keeps the + * complete syntax check. */ 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'; /** * Imports already read from bytes with a known SHA-256, keyed by check level - * and digest. Within one process the same emitted bundle is scanned by the - * post-compile self-containment check and then by artifact validation, twice - * (before and after the manifest is written); the bytes never change between - * those passes, so the imports of a multi-megabyte bundle are lexed once. + * and digest. Within one process the same emitted module is scanned by + * artifact validation twice (before and after the manifest is written); the + * bytes never change between those passes, so the imports of a + * multi-megabyte bundle are lexed once. * The records are a few dozen specifiers per module; the map stays bounded. */ const importsByDigest = new Map(); diff --git a/packages/agent-bundle/src/build/pack-dependencies.ts b/packages/agent-bundle/src/build/pack-dependencies.ts index 3a9d07205..4e6c7c1a2 100644 --- a/packages/agent-bundle/src/build/pack-dependencies.ts +++ b/packages/agent-bundle/src/build/pack-dependencies.ts @@ -4,28 +4,19 @@ import { posix, relative, resolve } from 'node:path'; import { gunzipSync } from 'node:zlib'; import npa from 'npm-package-arg'; +import ts from 'typescript-5'; -import { sha256Hex } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { isRecord } from '../core/strict-json.ts'; -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. JavaScript the framework compiled — the `dist` bundles - * 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 `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). + * `pack-inventory.ts`): how a consumer's npm reads each `package.json` + * dependency entry, and which of them a consumer must have installed — the + * packages the packed declaration files reference, as TypeScript reads them, + * and the packages the consumer-side install scripts name or run. Packed + * JavaScript is never read: a compiled bundle inlines its imports (`AB6005`), + * and a prebuilt payload declares what it loads (`runtimeDependencies`). */ /** Relative, package-imports (`#`), absolute, and URL-scheme specifiers (`node:`, `data:`, `file:`, `C:\`) name no package. */ @@ -201,305 +192,11 @@ export const packagedSourceInstallable = async ( return tarHoldsPackage(archive); }; -/** A single- or double-quoted string literal; the group after the opening quote is its body. */ -const quotedLiteral = String.raw`(["'])((?:(?!\1)[^\\\n]|\\.)+)\1`; +/** The `bin` commands of each declared dependency, by package name; the unscoped name stands in when no manifest is readable. */ +type ExecutableCommands = ReadonlyMap; -const escapeSequence = /\\(?:x(?[0-9A-Fa-f]{2})|u\{(?[0-9A-Fa-f]+)\}|u(?[0-9A-Fa-f]{4})|(?.))/gsu; -const controlEscapes: Readonly> = { 0: '\0', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' }; - -/** - * The string a JavaScript literal's body denotes: `\x66oo` is `foo`, - * `foo\u002fsubpath` is `foo/subpath`, `\/` is `/`. Node resolves the value, - * not the source text, so a package name compared textually has to be - * decoded first. - */ -const decodeLiteral = (body: string): string => body.replace(escapeSequence, (...args) => { - const groups = args.at(-1) as Record; - if (groups.hex !== undefined) return String.fromCharCode(Number.parseInt(groups.hex, 16)); - if (groups.unit !== undefined) return String.fromCharCode(Number.parseInt(groups.unit, 16)); - if (groups.point !== undefined) { - const point = Number.parseInt(groups.point, 16); - // Beyond Unicode the literal is a syntax error; the file never loads anything. - return point > 0x10_ff_ff ? '' : String.fromCodePoint(point); - } - const other = groups.other ?? ''; - return controlEscapes[other] ?? other; -}); - -/** - * A `require("…")` call, or a resolution-only use — `require.resolve("…")`, - * `createRequire(…).resolve("…")`, `import.meta.resolve("…")` — with a - * literal argument. The ESM lexer reports `import` forms only; CommonJS - * payloads a consumer prebuilt reach the package through `require`, and a - * package located only to find an asset or executable is still a runtime - * dependency. Only these resolvers count: `path.resolve("foo")` or - * `Promise.resolve("foo")` never make an unused `foo` look reachable. A match - * inside a comment or string can only mark a dependency as imported, never as - * unused, so the pattern otherwise errs toward keeping a declaration. - */ -/** - * A parenthesised argument list with calls nested up to two deep — - * `(new URL("./entry.js", import.meta.url))`, `(join(dirname(x), "y"))` — the - * shapes a `createRequire` argument takes. - */ -const callArguments = (() => { - const flat = String.raw`[(][^()]*[)]`; - const nested = String.raw`[(](?:[^()]|${flat})*[)]`; - return String.raw`[(](?:[^()]|${nested})*[)]`; -})(); - -// Whitespace and comments, the trivia JavaScript allows around a call's parentheses: `require /* x */ ("y")`. -const trivia = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*\n)*`; - -/** - * What may qualify a factory: nothing (`createRequire(…)` after a named - * import), a dotted namespace (`Module.createRequire(…)` after `import * as - * Module from "node:module"`, `module.createRequire(…)`), or a CommonJS load - * (`require("node:module").createRequire(…)`, `require('module')…`; any - * argument, since a same-named factory from elsewhere can only keep a - * declaration). No capture group: the literal patterns after it count theirs - * by number. - */ -const factoryQualifier = String.raw`(?:(?:[A-Za-z_$][\w$]*\s*\.\s*)*|\brequire\s*${callArguments}\s*\.\s*)`; - -/** A factory call producing a loader, qualified or not: `createRequire(import.meta.url)`, `Module.createRequire(…)`, `require("node:module").createRequire(…)`. */ -const factoryCall = (factories: readonly string[]): string => - String.raw`${factoryQualifier}\b(?:${factories.join('|')})${trivia}${callArguments}`; - -/** The resolvers a file loads packages through, each followed by its argument list. */ -const loadCall = (loaders: readonly string[], factories: readonly string[]): string => - String.raw`(?:\b(?:${loaders.join('|')})(?:\.resolve)?|\bimport\.meta\.resolve|${factoryCall(factories)}(?:\.resolve)?)${trivia}[(]${trivia}`; - -const literalLoad = (loaders: readonly string[], factories: readonly string[]): RegExp => new RegExp( - String.raw`${loadCall(loaders, factories)}${quotedLiteral}${trivia}[)]`, - 'gu', -); - -/** - * A CommonJS load or resolution whose argument is not a string literal — - * `require(x)`, `require.resolve(x)`, `import.meta.resolve(x)`, or a direct - * `createRequire(…)(x)` — selecting a package at runtime, which no literal can - * prove. An argument that merely starts with a literal, `require("driver/" + - * variant)`, is computed too. Bundler runtimes (`__webpack_require__(…)`) have - * no word boundary before `require` and never match; `path.resolve(x)` and - * `Promise.resolve(x)` are not resolution and never match. - */ -const computedLoad = (loaders: readonly string[], factories: readonly string[]): RegExp => new RegExp( - // A comment is trivia the call prefix already consumed, not the start of a computed argument. - String.raw`${loadCall(loaders, factories)}(?:(?!/[*/])[^"'\s)]|"[^"\n]*"${trivia}(?!/[*/])[^)\s]|'[^'\n]*'${trivia}(?!/[*/])[^)\s])`, - 'u', -); - -const escapeIdentifier = (name: string): string => name.replace(/\$/gu, String.raw`\$`); - -/** - * `createRequire` renamed on import or destructuring: `import { createRequire - * as makeRequire } from "node:module"` or `const { createRequire: makeRequire } - * = require("node:module")`. Each alias is a factory like `createRequire` itself. - */ -const createRequireAlias = /\bcreateRequire\s*(?:as|:)\s*([A-Za-z_$][\w$]*)/gu; - -const factoryNames = (source: string): readonly string[] => [ - 'createRequire', - ...Array.from(source.matchAll(createRequireAlias), (match) => escapeIdentifier(match[1] ?? '')), -]; - -/** - * `const load = (…)`, the factory qualified as `factoryQualifier` - * allows or not: the binding is a loader, called like `require` from then on. - */ -const loaderBinding = (factories: readonly string[]): RegExp => new RegExp( - String.raw`\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*${factoryQualifier}\b(?:${factories.join('|')})\s*[(]`, - 'gu', -); - -/** - * The identifiers a file loads packages through: `require` itself plus every - * name bound to a `createRequire(…)` result — under the factory's own name or - * an alias — so `const load = createRequire(import.meta.url); load("driver")` - * counts like `require("driver")`. - */ -const loaderNames = (source: string): readonly string[] => [ - 'require', - ...Array.from(source.matchAll(loaderBinding(factoryNames(source))), (match) => escapeIdentifier(match[1] ?? '')), -]; - -/** - * JavaScript comments and string literals, each replaced by a space: the - * text that is not code. Bundled docblocks are prose ("may fail, require - * Effect services"), and a scan for a bare identifier has to skip them. - * Regular-expression literals are not recognised; one containing a quote - * can misalign the strings after it on the same line, which at worst hides - * or invents a bare reference there. - */ -const codeOnly = (source: string): string => - source.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\[\s\S])*`/gu, ' '); - -/** - * A loader passed on as a value rather than called — `const load = require`, - * `fn(require)`, `[require]`, `{ require }`, `module.exports = require`, - * `return require`, `x ? require : y` — after which packages may be loaded - * under a name this scan never sees, so the file's evidence is incomplete - * like a computed load's. A call (`require("x")`), a property access - * (`require.resolve`), and `typeof require` pass nothing on and never match. - * Run on `codeOnly` text, so a mention in a comment or string is not one. - */ -const loaderReference = (loaders: readonly string[]): RegExp => new RegExp( - String.raw`(?:=>|\breturn|[=(,[{:?|&])\s*\b(?:${loaders.join('|')})\b\s*(?=[;,)\]}:]|$)`, - 'mu', -); - -/** - * Every module specifier a declaration file resolves: `from "…"`, - * `import("…")`, `import x = require("…")`, `declare module "…"` (an - * augmentation of that package's types, in an external-module declaration), - * and `/// `. - * A consumer needs the package that provides these types even though the - * bundled JavaScript has no runtime import. Declarations are not ES modules - * the lexer accepts, so this is a text scan with the same keep-only bias. - */ -const declarationSpecifier = new RegExp( - String.raw`\b(?:from|import|require|declare\s+module)\s*\(?\s*${quotedLiteral}|` directive resolves through: - * `name` itself when it ships declarations, or its DefinitelyTyped package - * (`@types/name`; `@types/scope__name` for a scoped name). Both are reported, - * since the declaration cannot say which one the consumer needs. - */ -const typeDirectivePackages = (name: string): readonly string[] => [ - name, - name.startsWith('@') ? `@types/${name.slice(1).replace('/', '__')}` : `@types/${name}`, -]; - -/** The literal module specifiers a declaration file resolves, in order of appearance. */ -export const declarationSpecifiers = (source: string): readonly string[] => - Array.from(source.matchAll(declarationSpecifier)).flatMap((match) => - (match[4] === undefined ? [decodeLiteral(match[2] ?? '')] : typeDirectivePackages(match[4]))); - -/** The `bin` commands of each declared dependency, by package name. */ -type ExecutableCommands = ReadonlyMap; - -interface DependencyExecutables { - /** The commands the dependency's own manifest declares; empty when it declares none. */ - readonly commands: readonly string[]; - /** - * Whether `commands` was read from the manifest rather than guessed. With no - * manifest under `node_modules` (Plug'n'Play, a platform-specific optional - * dependency not installed here) the unscoped package name stands in — npm's - * default bin name — which is evidence enough in a shell script but too - * loose for JavaScript, where the bare name is also how the package is - * mentioned in a comment or docblock. - */ - readonly known: boolean; -} - -/** What one packed file proves about the packages it resolves. */ -interface FileEvidence { - readonly specifiers: readonly string[]; - /** Dependencies the file runs as executables rather than loading as modules. */ - readonly executed: readonly string[]; - /** `false` when a computed `import(expression)` or `require(expression)`, or a `require` passed on as a value, means the file may load a package no literal names. */ - readonly complete: boolean; -} - -const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); - -/** - * A string literal that is a shell command running `command`: the bare name - * (`spawnSync("foo", ["--version"])`) or the name followed by its arguments - * (`execSync("foo --version")`). A dependency a CLI package only ever shells - * out to is still one the consumer needs installed; the match is keep-only, - * so a name inside a comment or an unrelated string merely keeps a declaration. - */ -const commandLiteral = (command: string): RegExp => - new RegExp(String.raw`(["'\x60])${escapeRegExp(command)}(?:\s[^"'\x60\n]*)?\1`, 'u'); - -/** - * The module specifiers packed JavaScript resolves: the lexer's static and - * dynamic literal imports — never a mention inside a comment or string, - * which bundled library docblocks are full of — plus literal `require` calls; - * and the dependencies it runs by one of their `bin` commands. - */ -/** - * The module specifiers JavaScript source loads — the lexer's static and - * dynamic literal imports, plus literal `require`/`createRequire` calls — - * and whether that is all of them: a computed `import(x)` or `require(x)`, - * or a loader passed on as a value, means it is not — and so does source the - * lexer rejects, whose `import()` calls it could not report (syntax itself is - * another gate's concern). Packed files and inline `node -e` programs are - * read alike. - */ -const moduleLoads = async (source: string, sha256?: string): Promise> => { - let imports: readonly ModuleImport[]; - let lexed = true; - try { - imports = await readModuleImports(source, { check: 'lexed', ...(sha256 === undefined ? {} : { sha256 }) }); - } catch { - imports = []; - lexed = false; - } - const loaders = loaderNames(source); - const factories = factoryNames(source); - return { - complete: lexed - && imports.every((record) => record.kind !== 'dynamic' || record.specifier !== undefined) - && !computedLoad(loaders, factories).test(source) - && !loaderReference(loaders).test(codeOnly(source)), - specifiers: [ - ...imports.flatMap((record) => (record.specifier === undefined ? [] : [record.specifier])), - ...Array.from(source.matchAll(literalLoad(loaders, factories)), (match) => decodeLiteral(match[2] ?? '')), - ], - }; -}; - -const javaScriptEvidence = async (bytes: Buffer, executables: ExecutableCommands): Promise => { - const source = bytes.toString('utf8'); - return { - ...await moduleLoads(source, sha256Hex(bytes)), - executed: [...executables] - .filter(([, { commands, known }]) => known && commands.some((command) => commandLiteral(command).test(source))) - .map(([name]) => name), - }; -}; - -const javaScriptSuffix = /\.[cm]?js$/u; const declarationSuffix = /\.d\.[cm]?ts$/u; -const fileEvidence = async (path: string, executables: ExecutableCommands): Promise => { - let bytes: Buffer; - try { - bytes = await readFile(path); - } catch (error) { - // npm listed the file; only its absence is a benign inconsistency. - if (isErrno(error, 'ENOENT')) return { complete: true, executed: [], specifiers: [] }; - throw error; - } - return declarationSuffix.test(path) - ? { complete: true, executed: [], specifiers: declarationSpecifiers(bytes.toString('utf8')) } - : javaScriptEvidence(bytes, executables); -}; - -export interface ImportedPackages { - /** Every package name the packed files name literally, run as an executable, or need during a consumer's install. */ - readonly names: ReadonlySet; - /** - * The subset a consumer's install lifecycle needs: run as a command by a - * script, or loaded by a packed file the script runs. An optional dependency - * npm skipped after a failed fetch is then missing from `PATH` or - * `node_modules`, so the install fails after all. - */ - readonly installScripts: ReadonlySet; - /** - * Whether `names` is the whole story. A computed `import(expression)` or - * `require(expression)` in packed JavaScript may load any declared package, - * so no declaration can then be called unused. - */ - readonly complete: boolean; -} - /** Every string target in a `package.json` `imports` map, through conditional and nested targets. */ const importMapTargets = (value: unknown): readonly string[] => { if (typeof value === 'string') return [value]; @@ -589,7 +286,7 @@ const unscopedName = (name: string): string => name.replace(/^@[^/]+\//u, ''); * manifest's unscoped `name` — `real` for an alias `"wrapper": * "npm:@scope/real@1"`, not `wrapper`. When the manifest is unreadable — * absent, not JSON, or not an object — the dependency's unscoped name stands - * in, marked as a guess; a broken install never fails the gate. + * in (npm's default bin name); a broken install never fails the gate. */ const executableCommands = async (names: readonly string[], projectRoot: string): Promise => { const readManifest = async (name: string): Promise> | undefined> => { @@ -602,22 +299,24 @@ const executableCommands = async (names: readonly string[], projectRoot: string) return undefined; } }; - const binCommands = async (name: string): Promise => { + const binCommands = async (name: string): Promise => { const parsed = await readManifest(name); - if (parsed === undefined) return { commands: [unscopedName(name)], known: false }; + if (parsed === undefined) return [unscopedName(name)]; const bin = parsed.bin; - if (isRecord(bin)) return { commands: Object.keys(bin), known: true }; - if (typeof bin !== 'string') return { commands: [], known: true }; - return { commands: [unscopedName(typeof parsed.name === 'string' ? parsed.name : name)], known: true }; + if (isRecord(bin)) return Object.keys(bin); + if (typeof bin !== 'string') return []; + return [unscopedName(typeof parsed.name === 'string' ? parsed.name : name)]; }; return new Map(await Promise.all(names.map(async (name) => [name, await binCommands(name)] as const))); }; +const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); + /** * Dependencies a consumer's install lifecycle mentions: npm puts every * dependency's executables on `PATH` for `preinstall`/`install`/`postinstall`, * so a script that names a dependency, or one of its `bin` commands, anywhere - * may need it installed even though no packed JavaScript imports it. Keep-only + * may need it installed even though nothing else references it. Keep-only * evidence for `AB7014`: a mention in an argument or `echo` string cannot * report a dependency, but neither does it prove the script runs it. */ @@ -625,7 +324,7 @@ const installScriptMentions = (text: string, executables: ExecutableCommands): r if (text === '') return []; const runs = (command: string): boolean => new RegExp(String.raw`(? text.includes(name) || commands.some(runs)) + .filter(([name, commands]) => text.includes(name) || commands.some(runs)) .map(([name]) => name); }; @@ -805,31 +504,20 @@ const valuedNodeOptions = new Set([ ]); /** Node's preloads, loaded before the program: `-r`/`--require` (CommonJS), `--import`, and `--loader`/`--experimental-loader` (ES modules). */ const preloadOptions = /^(?:-r|--require|--import|--loader|--experimental-loader)$/u; -/** Node's inline programs: `-e`/`--eval`, `-p`/`--print`, and the one short combination Node accepts, `-pe`. */ -const inlineOptions = /^(?:-e|--eval|-p|-pe|--print)$/u; - -interface NodeCommand { - readonly options: readonly ParsedOption[]; - /** The script Node runs, when the command names one: the first positional, unless an inline program makes it an argument. */ - readonly program: string | undefined; -} /** - * A `node` command read as Node does — `node [options] [script | -e code | -] - * [arguments]`: options up to the first positional or a `--`, that positional - * the program (an argument instead when `-e`/`-p` supply the program), and - * nothing after it Node's (`node install.js --require x` passes `--require - * x` to `install.js`). A `NODE_OPTIONS` assignment on the same command - * (`NODE_OPTIONS=--require=x node install.js`, `cross-env NODE_OPTIONS="-r x" - * node .`) supplies options Node applies before the command line's; one - * exported by an earlier command is not read. + * The options of a `node` command, read as Node does — `node [options] + * [script | -e code | -] [arguments]`: everything up to the first positional + * or a `--`, nothing after it (`node install.js --require x` passes + * `--require x` to `install.js`). A `NODE_OPTIONS` assignment on the same + * command (`NODE_OPTIONS=--require=x node install.js`, `cross-env + * NODE_OPTIONS="-r x" node .`) supplies options Node applies before the + * command line's; one exported by an earlier command is not read. */ -const nodeCommand = ({ environment, operands }: SimpleCommand): NodeCommand => { - const inherited = leadingOptions(shellWords(environment.get('NODE_OPTIONS') ?? ''), valuedNodeOptions).options; - const { options, positional } = leadingOptions(operands, valuedNodeOptions); - const inline = options.some((option) => inlineOptions.test(option.name)); - return { options: [...inherited, ...options], program: inline ? undefined : operands[positional] }; -}; +const nodeCommand = ({ environment, operands }: SimpleCommand): readonly ParsedOption[] => [ + ...leadingOptions(shellWords(environment.get('NODE_OPTIONS') ?? ''), valuedNodeOptions).options, + ...leadingOptions(operands, valuedNodeOptions).options, +]; /** * The values a script gives one of `node`'s options, across every `node` @@ -839,235 +527,111 @@ const nodeCommand = ({ environment, operands }: SimpleCommand): NodeCommand => { * is never Node's. */ const nodeOptionValues = (commands: readonly SimpleCommand[], options: RegExp): readonly string[] => - commands.filter(({ command }) => command === 'node').flatMap((command) => nodeCommand(command).options + commands.filter(({ command }) => command === 'node').flatMap((command) => nodeCommand(command) .flatMap(({ name, value }) => (options.test(name) && value !== undefined ? [value] : []))); +/** The package names among `specifiers`: bare ones by `packageNameOf`, `#` ones through the manifest's `imports` map. */ +const packageNames = (packageDocument: Readonly>, specifiers: readonly string[]): readonly string[] => + specifiers + .flatMap((specifier) => (specifier.startsWith('#') ? packageImportTargets(packageDocument, specifier) : [specifier])) + .flatMap((specifier) => { + const name = packageNameOf(specifier); + return name === undefined ? [] : [name]; + }); + /** - * Dependencies a consumer's install lifecycle demonstrably needs: one of - * their `bin` commands in command position (`setup-tool --init`, `npx - * setup-tool`, `./node_modules/.bin/setup-tool`), a file of theirs run - * directly (`node node_modules/setup-tool/install.js`), or a load in an - * inline `node -e` program (`node -e "require('setup-tool')"`, `node - * --input-type=module -e "await import('setup-tool')"`, read as a packed - * file is; a computed load there may reach any declared package). An optional dependency npm - * skipped after a failed fetch then fails the script, so this is what turns - * a survivable `AB7015` fatal; `echo setup-tool` never does. The packages - * the files and preloads a `node` command runs then load are - * `installScriptModuleDependencies`'s. + * Dependencies a consumer's install lifecycle fails without: one of their + * `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, + * `./node_modules/.bin/setup-tool`), a file of theirs run directly (`node + * node_modules/setup-tool/install.js`), or a bare preload (`node -r + * setup-tool/register .`, `NODE_OPTIONS=--import=setup-tool node .`); a + * relative preload names no package. An optional dependency npm skipped + * after a failed fetch then fails the script, so this is what turns a + * survivable `AB7015` fatal; `echo setup-tool` never does. */ -const installScriptCommandDependencies = async ( +const installScriptNeeds = ( text: string, commands: readonly SimpleCommand[], executables: ExecutableCommands, - declared: readonly string[], -): Promise => { - if (text === '') return []; + packageDocument: Readonly>, +): readonly string[] => { const run = new Set(commands.map(({ command }) => command)); const words = shellWords(text); - const names = new Set(); - for (const [name, { commands: bins }] of executables) { - if (bins.some((command) => run.has(command))) names.add(name); - if (words.some((word) => word.replace(/^\.\//u, '').startsWith(`node_modules/${name}/`))) names.add(name); - } - for (const program of nodeOptionValues(commands, inlineOptions)) { - const { complete, specifiers } = await moduleLoads(program); - if (!complete) for (const name of declared) names.add(name); - for (const specifier of specifiers) { - const name = packageNameOf(specifier); - if (name !== undefined) names.add(name); - } - } - return [...names]; + return [ + ...[...executables] + .filter(([name, bins]) => bins.some((command) => run.has(command)) + || words.some((word) => word.replace(/^\.\//u, '').startsWith(`node_modules/${name}/`))) + .map(([name]) => name), + ...packageNames(packageDocument, nodeOptionValues(commands, preloadOptions)), + ]; }; -/** The packed files a module resolves through: the module files, and each packed directory manifest's `main`. */ -interface PackedModules { - readonly files: ReadonlySet; - /** Directory → the path its packed `package.json` `main` names, as Node reads it for `require("./dir")`. */ - readonly mains: ReadonlyMap; +export interface InstallScriptDependencies { + /** Every package a consumer-side install script names or runs: keep-only evidence for `AB7014`. */ + readonly names: ReadonlySet; + /** The subset the install fails without (`installScriptNeeds`), which escalates an optional dependency's `AB7015`. */ + readonly needed: ReadonlySet; } -/** - * Directory → the `main` of its packed manifest (`lib/package.json` with - * `"main": "setup.cjs"` → `lib` → `lib/setup.cjs`), which Node consults before - * the `index.js` fallback when a directory is required or run: the package's - * own manifest for `.` (what `node .` runs), read from the document since - * the inventory's `package.json` is the package itself, and every other - * packed manifest outside `node_modules`, whose manifests belong to bundled - * dependencies. - */ -const packedMains = async ( - paths: readonly string[], - projectRoot: string, - packageDocument: Readonly>, -): Promise> => { - const manifests = paths.filter((path) => /^(?!node_modules\/).+\/package\.json$/u.test(path)); - const entries = await Promise.all(manifests.map(async (path): Promise => { - try { - const parsed: unknown = JSON.parse(await readFile(resolve(projectRoot, path), 'utf8')); - if (!isRecord(parsed) || typeof parsed.main !== 'string') return undefined; - const directory = posix.dirname(path); - return [directory, posix.join(directory, parsed.main)]; - } catch { - return undefined; - } - })); - return new Map([ - ...(typeof packageDocument.main === 'string' ? [['.', posix.join('.', packageDocument.main)] as const] : []), - ...entries.filter((entry): entry is readonly [string, string] => entry !== undefined), - ]); -}; - -/** - * The packed module a path names, in the order Node's CommonJS loader tries - * for `require("./lib")` or `node scripts/install`: the exact file, the `.js` - * extension, then the directory — its manifest's `main` as a file, with - * `.js`, or as a directory index, and finally `index.js`. Node never tries - * `.cjs` or `.mjs` there. A trailing slash names the same directory (`node - * scripts/`, `node ./`), and `.` is the package root. - */ -const packedModule = (path: string, modules: PackedModules): string | undefined => { - const base = posix.normalize(path).replace(/(?<=.)\/$/u, ''); - const main = modules.mains.get(base); - const candidates = [ - base, - `${base}.js`, - ...(main === undefined ? [] : [main, `${main}.js`, posix.join(main, 'index.js')]), - posix.join(base, 'index.js'), - ]; - return candidates.find((candidate) => javaScriptSuffix.test(candidate) && modules.files.has(candidate)); +/** What the consumer-side install scripts (`installScriptText`) say about the installed dependencies `declared`. */ +export const installScriptDependencies = async (options: { + readonly declared: readonly string[]; + readonly packageDocument: Readonly>; + readonly projectRoot: string; +}): Promise => { + const executables = await executableCommands(options.declared, resolve(options.projectRoot)); + const text = installScriptText(isRecord(options.packageDocument.scripts) ? options.packageDocument.scripts : {}); + const needed = new Set(installScriptNeeds(text, simpleCommands(text), executables, options.packageDocument)); + return { names: new Set([...installScriptMentions(text, executables), ...needed]), needed }; }; -/** `.` or `./`: the package directory, which `node .` runs through the root manifest's `main`. */ -const packageDirectory = /^\.\/*$/u; - /** - * The packed JavaScript files an install script runs: `node install.cjs`, - * `node ./scripts/setup.mjs`, `node scripts/install`, `node "scripts/my - * install.cjs"`. Every word is tried; a word that is not a packed module is - * not one, and the empty word `""` names no path. The package directory - * itself counts only as a `node` program (`node .`, `node -r dotenv/config - * .`): elsewhere `.` is the working directory an option names (`npm --prefix - * . run setup`), not a program. + * The package a `/// ` directive resolves through: + * `name` itself when it ships declarations, or its DefinitelyTyped package + * (`@types/name`; `@types/scope__name` for a scoped name). Both are reported, + * since the declaration cannot say which one the consumer needs. */ -const installScriptFiles = (text: string, commands: readonly SimpleCommand[], modules: PackedModules): readonly string[] => [ - ...shellWords(text).filter((word) => word !== '' && !packageDirectory.test(word)), - ...commands.filter(({ command }) => command === 'node').flatMap((command) => { - const { program } = nodeCommand(command); - return program !== undefined && packageDirectory.test(program) ? [program] : []; - }), -].flatMap((word) => { - const module = packedModule(word, modules); - return module === undefined ? [] : [module]; -}); - -const relativeSpecifier = /^\.\.?\//u; +const typeDirectivePackages = (name: string): readonly string[] => [ + name, + name.startsWith('@') ? `@types/${name.slice(1).replace('/', '__')}` : `@types/${name}`, +]; /** - * The packages a consumer's install lifecycle loads through the packed - * JavaScript it runs — `"postinstall": "node install.cjs"` with `install.cjs` - * requiring a driver — and through the modules `node` preloads before the - * program (`node -r dotenv/config install.cjs`, `node --import ./setup.mjs - * .`), following relative imports and the manifest's `imports` map through - * the tarball. A computed load in any of those files could reach any declared - * package, so every declared name then counts as needed at install time. - */ -const installScriptModuleDependencies = (options: { - /** The packed files run directly. */ - readonly roots: readonly string[]; - /** The specifiers preloaded from the package root, where npm runs the lifecycle. */ - readonly preloads: readonly string[]; - readonly evidenceByPath: ReadonlyMap; - readonly modules: PackedModules; - readonly packageDocument: Readonly>; - readonly declared: readonly string[]; -}): readonly string[] => { - const { declared, evidenceByPath, modules, packageDocument } = options; - const names = new Set(); - const seen = new Set(); - const queue = [...options.roots]; - // What one specifier resolved from `directory` reaches: a relative one a packed module to follow, a bare one a - // package, a `#` one whatever the `imports` map gives it — a package, or the package's own file - // (`"#setup": "./setup.js"`), relative to the package root. - const follow = (specifier: string, directory: string): void => { - if (relativeSpecifier.test(specifier)) { - const target = packedModule(posix.join(directory, specifier), modules); - if (target !== undefined) queue.push(target); - return; - } - for (const target of specifier.startsWith('#') ? packageImportTargets(packageDocument, specifier) : [specifier]) { - if (relativeSpecifier.test(target)) { - const module = packedModule(target, modules); - if (module !== undefined) queue.push(module); - continue; - } - const name = packageNameOf(target); - if (name !== undefined) names.add(name); - } - }; - for (const specifier of options.preloads) follow(specifier, '.'); - for (let path = queue.shift(); path !== undefined; path = queue.shift()) { - const evidence = evidenceByPath.get(path); - if (seen.has(path) || evidence === undefined) continue; - seen.add(path); - if (!evidence.complete) for (const name of declared) names.add(name); - for (const name of evidence.executed) names.add(name); - for (const specifier of evidence.specifiers) follow(specifier, posix.dirname(path)); - } - return [...names]; + * Package names a declaration file makes a consumer need, as TypeScript reads + * it (`preProcessFile`, string escapes already decoded): every `importedFiles` + * entry (`import`/`export … from`, `import x = require("…")`, `import("…")` + * types, module augmentations in an external-module file) and every + * `typeReferenceDirectives` entry with its `@types` twin. The + * `ambientExternalModules` — `declare module "x"` in a file with no imports — + * declare that module themselves and make nothing needed. Relative and + * built-in specifiers name no package; a `#subpath` specifier reaches + * whatever the manifest's `imports` map gives it (`packageImportTargets`). + */ +export const declarationPackageReferences = (text: string, packageDocument: Readonly>): readonly string[] => { + const { importedFiles, typeReferenceDirectives } = ts.preProcessFile(text, true, false); + return packageNames(packageDocument, [ + ...importedFiles.map((file) => file.fileName), + ...typeReferenceDirectives.flatMap((directive) => typeDirectivePackages(directive.fileName)), + ]); }; -/** - * Every package name the packed JavaScript imports, requires, resolves, or - * runs as an executable, the packed declarations reference, a packed `#` - * import may reach through the manifest's `imports` map, or a consumer's - * install script runs or loads — read from the bytes npm would publish. - */ -export const importedPackageNames = async (options: { - /** Names to test for executable and install-script use; every other source is scanned whole. */ - readonly declared: readonly string[]; +/** Package names the packed declaration files reference; `paths` is the pack inventory (POSIX, no `./`). */ +export const packedDeclarationReferences = async (options: { readonly packageDocument: Readonly>; readonly paths: readonly string[]; readonly projectRoot: string; -}): Promise => { +}): Promise> => { const projectRoot = resolve(options.projectRoot); - const executables = await executableCommands(options.declared, projectRoot); - const evidenceByPath = new Map(await Promise.all(options.paths - .filter((path) => javaScriptSuffix.test(path) || declarationSuffix.test(path)) - .map(async (path) => [path, await fileEvidence(resolve(projectRoot, path), executables)] as const))); - const modules: PackedModules = { - files: new Set(evidenceByPath.keys()), - mains: await packedMains(options.paths, projectRoot, options.packageDocument), - }; - const evidence = [...evidenceByPath.values()]; - const scriptText = installScriptText(isRecord(options.packageDocument.scripts) ? options.packageDocument.scripts : {}); - const commands = simpleCommands(scriptText); - const neededByScripts = [ - ...await installScriptCommandDependencies(scriptText, commands, executables, options.declared), - ...installScriptModuleDependencies({ - declared: options.declared, - evidenceByPath, - modules, - packageDocument: options.packageDocument, - preloads: nodeOptionValues(commands, preloadOptions), - roots: installScriptFiles(scriptText, commands, modules), - }), - ]; - const specifiers = evidence.flatMap((file) => file.specifiers); - const reachable = specifiers - .filter((specifier) => specifier.startsWith('#')) - .flatMap((specifier) => packageImportTargets(options.packageDocument, specifier)); - return { - complete: evidence.every((file) => file.complete), - installScripts: new Set(neededByScripts), - names: new Set([ - ...[...specifiers, ...reachable].flatMap((specifier) => { - const name = packageNameOf(specifier); - return name === undefined ? [] : [name]; - }), - ...evidence.flatMap((file) => file.executed), - ...installScriptMentions(scriptText, executables), - ...neededByScripts, - ]), - }; + const references = await Promise.all(options.paths.filter((path) => declarationSuffix.test(path)).map(async (path) => { + let text: string; + try { + text = await readFile(resolve(projectRoot, path), 'utf8'); + } catch (error) { + // npm listed the file; only its absence is a benign inconsistency. + if (isErrno(error, 'ENOENT')) return []; + throw error; + } + return declarationPackageReferences(text, options.packageDocument); + })); + return new Set(references.flat()); }; diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index b535222a9..55d369ba6 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -13,10 +13,11 @@ import { parseArtifactManifest } from './manifest.ts'; import { declaredDependencies, type DeclaredDependency, type InstalledDependencyField } from '../core/package-dependencies.ts'; import { classifyDependency, - importedPackageNames, + installScriptDependencies, isWorkspaceProtocol, packagedSourceInstallable, packagedSourcePath, + packedDeclarationReferences, type DependencyKind, } from './pack-dependencies.ts'; import type { PackageBuildResult } from './package-build.ts'; @@ -137,22 +138,14 @@ 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` 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). A - * 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. A prebuilt payload's `runtimeDependencies` - * declaration is evidence of the same standing: the compiler never opens a - * payload file, so the author states what it loads. + * `AB7014`/`AB7015`: an installed-dependency entry counts as used only when a + * consumer must have it installed — a prebuilt payload declares it in + * `runtimeDependencies`, a packed declaration file references it, or a + * consumer-side install script names or runs it. The build inlines every + * other import (`AB6005`), so a bundled package is still unused: every + * consumer's `npm install` fetches it for nothing — and fails outright when + * the specifier is one a consumer's npm cannot resolve (git, remote tarball, + * path, or an unrewritten workspace protocol). */ 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) => @@ -163,6 +156,8 @@ const unresolvableRecovery = 'Depend on a published registry version, or bundle + 'which only pnpm, Yarn, or Bun rewrite while packing.'; const dependencyDiagnostics = async (options: { + /** Package → the packed bundles the compiler inlined it into, each sorted. */ + readonly bundledInto: ReadonlyMap; readonly declaredRuntimeDependencies: ReadonlySet; readonly packageDocument: Readonly>; readonly packedPaths: readonly string[]; @@ -171,15 +166,17 @@ 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; 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), + const declarationReferences = await packedDeclarationReferences({ packageDocument: options.packageDocument, paths: options.packedPaths, projectRoot: options.projectRoot, }); + const installScripts = await installScriptDependencies({ + declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), + packageDocument: options.packageDocument, + projectRoot: options.projectRoot, + }); + const used = new Set([...options.declaredRuntimeDependencies, ...declarationReferences, ...installScripts.names]); // The tarball itself may carry the dependency: `bundleDependencies` exempts an entry only when npm actually // packed it (a name absent from node_modules at pack time is silently dropped, and the consumer neither // fetches nor finds it), and a `file:` path inside the package is installed from the consumer's own copy @@ -206,29 +203,31 @@ const dependencyDiagnostics = async (options: { const unresolvable = declared.filter((dependency) => !embedded(dependency) && (dependency.installed ? kinds.get(dependency) !== 'registry' : kinds.get(dependency) === 'unparseable')); // An optional dependency npm parses but cannot fetch: the install continues without it — unless a consumer - // install script then runs it, or loads it from a packed file it runs, and fails on the missing package. + // install script then runs it and fails on the missing package. const survivable = (dependency: DeclaredDependency): boolean => dependency.field === 'optionalDependencies' && kinds.get(dependency) === 'fetched' - && !imported.installScripts.has(dependency.name); - // A computed import() may load any declared package; nothing can then be called unused. - const unused = imported.complete - ? declared.filter((dependency) => dependency.installed - && !imported.names.has(dependency.name) - && !options.declaredRuntimeDependencies.has(dependency.name)) - : []; + && !installScripts.needed.has(dependency.name); + const unused = declared.filter((dependency) => dependency.installed && !used.has(dependency.name)); + const inlinedSentence = (own: readonly DeclaredDependency[]): string | undefined => { + const inlined = own.flatMap((dependency) => { + const bundles = options.bundledInto.get(dependency.name); + return bundles === undefined ? [] : [`${JSON.stringify(dependency.name)} into ${bundles.join(' and ')}`]; + }); + return inlined.length === 0 ? undefined : `The build inlined ${inlined.join(', and ')}; every consumer installs them for nothing.`; + }; return [ // A peer nothing imports may be a deliberate compatibility contract with the host that loads the package; // npm 7+ still installs it for every consumer, so it is worth a look, not a refusal. ...perField(unused, (field, own) => diagnostic( 'AB7014', - `package.json ${field} names packages no packed JavaScript or declaration file references, runs, or install script needs, and no prebuilt payload declares: ${quoteAll(own.map((dependency) => dependency.name))}. ` - + (field === 'peerDependencies' + `package.json ${field} names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: ${quoteAll(own.map((dependency) => dependency.name))}. ` + + (inlinedSentence(own) ?? (field === 'peerDependencies' ? 'If they only constrain the host version, that is a compatibility contract; npm 7+ still installs them for every consumer.' - : 'Every consumer installs them for nothing; the emitted outputs already inline what they use.'), + : 'Nothing packed reaches them at runtime; every consumer installs them for nothing.')), 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 file requires or resolves (createRequire, import.meta.resolve), a packed declaration file references, a #subpath import reaches through the imports map, an install script or packed JavaScript runs, or a prebuilt payload names in runtimeDependencies (definePrebuilt); a computed import() or require() in packed code withholds this check.', + : 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).', field === 'peerDependencies' ? 'warning' : 'error', )), // npm skips an optional dependency it cannot fetch, so the install survives — but only once the specifier parsed @@ -338,7 +337,12 @@ export const packInventoryDiagnostics = async (options: { )); } + const bundledInto = new Map([...Map.groupBy( + options.packageBuild.evidence.assets.flatMap((asset) => asset.packages.map((name) => ({ name, path: asset.path }))), + (entry) => entry.name, + )].map(([name, entries]) => [name, entries.map((entry) => entry.path).sort((left, right) => left.localeCompare(right))])); diagnostics.push(...await dependencyDiagnostics({ + bundledInto, declaredRuntimeDependencies: new Set((options.model.payloads ?? []).flatMap((payload) => payload.runtimeDependencies)), packageDocument, packedPaths: [...packed], diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 1e685eff0..d7ea56b57 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -30,7 +30,6 @@ import { terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; import { projectMeta } from './meta.ts'; -import { bundleSyntaxCheckFor } from './module-imports.ts'; import { isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; import { validateJavaScriptModules } from './validate-artifact-modules.ts'; @@ -379,23 +378,20 @@ 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 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 rewritable = options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined; + // The npm form of the plugin is held to the same line as its host packs. + // The compiler resolved every literal import of the bundles it emitted + // (`AB6005` failed the build on anything but a Node built-in), so the + // walk lexes them for the one form the compiler leaves verbatim — an + // expression `import()` — unless a `tools` hatch may have rewritten the + // emitted bytes, in which case every module is parsed in full and its + // imports resolved. 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), + provenPaths: new Set(rewritable ? [] : files.filter((file) => file.kind === 'bundle').map((file) => file.path)), + reportedRoot: publishedPrefix, validJson: new Set(), }); if (selfContainment.length > 0) throw new DiagnosticError(selfContainment); @@ -403,7 +399,7 @@ export const buildPackageOutputs = async (options: { const evidence = await createCompileEvidenceRecord({ pathPrefix: publishedPrefix, results: [compileResult], - rewritable: options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined, + rewritable, root: stageRoot, rspackVersion: rspack.rspackVersion, }); diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index b19916096..b88100960 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -7,7 +7,6 @@ import { readFile, realpath } from 'node:fs/promises'; import { dirname, join, resolve, sep } from 'node:path'; import { dependencyManifestPath } from '../core/dependency-manifest.ts'; -import { sha256Hex } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { isInsideOrEqual, posixRelativeWhenInside } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; @@ -32,7 +31,6 @@ import { metaModuleSpecifier, virtualModulesPluginConstructor, } from './meta.ts'; -import { readModuleImports, type ModuleImport } from './module-imports.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; export interface RslibVirtualModule { @@ -213,7 +211,7 @@ const reservedExternalError = (specifier: string): Error => new Error( * object entry whose value is `false` explicitly opts out of * externalization, so it is not a violation. Function externals cannot be * inspected here; {@link guardReservedExternals} intercepts those at build - * time and the post-build residual-import scan fails closed behind both. + * time. */ const reservedExternalsViolation = (externals: unknown, reserved: readonly string[]): string | undefined => { if (externals === undefined || externals === null) return undefined; @@ -302,42 +300,6 @@ const reservedAliasViolation = ( return reserved.some((specifier) => specifier === base || (!exact && specifier.startsWith(`${base}/`))); }); -/** - * Fail-closed self-containment check on the emitted bundles themselves: - * no reserved specifier may survive bundling as a live import. This is the - * belt behind the static externals check and the function-external guard. - * The bundle is lexed as an ES module (the emitted format by contract), so - * string literals or comments that merely mention a reserved specifier are - * not violations. The lex is keyed by the bundle's digest, so artifact - * validation, which scans these same bytes next, reads the imports once. - */ -const assertNoResidualReservedImports = async ( - entries: readonly RslibEntry[], - outputRoot: string, -): Promise => { - await Promise.all(entries.map(async (entry) => { - const reserved = reservedSpecifiers(entry); - const bytes = await readFile(resolve(outputRoot, entry.outputRelativePath)); - let imports: readonly ModuleImport[]; - try { - imports = await readModuleImports(bytes.toString('utf8'), { check: 'lexed', sha256: sha256Hex(bytes) }); - } catch { - throw new Error(`Generated executable ${JSON.stringify(entry.outputRelativePath)} did not parse as an ES module.`); - } - const residual = imports - .map((record) => record.specifier) - .find((specifier) => specifier !== undefined && reserved.includes(specifier)); - if (residual !== undefined) { - throw new Error( - `Generated executable ${JSON.stringify(entry.outputRelativePath)} is not self-contained: ` - + `the reserved module specifier ${JSON.stringify(residual)} survived bundling. ` - + 'The tools escape hatch must not externalize ' - + `${reserved.map((specifier) => JSON.stringify(specifier)).join(', ')}.`, - ); - } - })); -}; - const projectDependencyFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const; /** What a dependency's bundle can pull in: its devDependencies never ship. */ const runtimeDependencyFields = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const; @@ -894,7 +856,6 @@ export const buildRslibSurfaces = async ( projectRoot: options.cwd, stats: result.stats, }); - await assertNoResidualReservedImports(entries, options.outputRoot); const emittedAssets = new Set(entries.map((entry) => entry.outputRelativePath)); const evidenceByPath = new Map(evidence.map((asset) => [asset.path, asset])); const resultByEntry = new Map(entries.map((entry) => { diff --git a/packages/agent-bundle/src/build/validate-artifact-modules.ts b/packages/agent-bundle/src/build/validate-artifact-modules.ts index 523ea2963..90f1f6d48 100644 --- a/packages/agent-bundle/src/build/validate-artifact-modules.ts +++ b/packages/agent-bundle/src/build/validate-artifact-modules.ts @@ -100,19 +100,24 @@ const resolveJavaScriptImport = async (options: { return { module: artifactPath }; }; +/** + * Walks every JavaScript module of the artifact for what the compiler could + * not see. A module in `provenPaths` is one the compiler emitted and whose + * evidence covers these exact bytes: the compiler resolved every literal + * import (bundled, built-in, or an emitted sibling), so the module is lexed + * for syntax and only an expression load — the one form Rslib's profile + * leaves verbatim — is reported. Every other module (a generated installer, + * a copied script, a bundle a `tools` hatch may have rewritten) is parsed in + * full and its imports are resolved against the file table. + */ export const validateJavaScriptModules = async (options: { readonly artifactRoot: string; - /** - * Modules the framework compiled (manifest kind `bundle`), checked at - * `bundleSyntaxCheck`; every other module is parsed in full (`parsed`). - */ - readonly bundledPaths?: ReadonlySet; - /** How a bundled module's syntax is checked; `lexed` unless a caller knows the bundler output may have been rewritten. */ - readonly bundleSyntaxCheck?: ModuleSyntaxCheck; readonly files: readonly ArtifactFile[]; readonly manifestFiles?: ReadonlySet; /** Prebuilt payload files: opaque consumer outputs excluded from graph validation. */ readonly prebuiltPaths?: ReadonlySet; + /** Compiled modules whose evidence record covers their bytes; lexed, not import-resolved. */ + readonly provenPaths: ReadonlySet; /** * POSIX directory under which diagnostics name the validated modules, for * a tree validated before it is published under another path: the package @@ -139,7 +144,8 @@ export const validateJavaScriptModules = async (options: { return; } visiting.add(path); - const check = options.bundledPaths?.has(path) === true ? options.bundleSyntaxCheck ?? 'lexed' : 'parsed'; + const proven = options.provenPaths.has(path); + const check: ModuleSyntaxCheck = proven ? 'lexed' : 'parsed'; let bytes: Buffer; try { bytes = await runWithPlatform(readFileBytes(resolve(artifactRoot, path))); @@ -167,6 +173,7 @@ export const validateJavaScriptModules = async (options: { diagnostics.push(graphDiagnostic(reported(path), 'has a non-literal dynamic import.')); continue; } + if (proven) continue; const resolved = await resolveJavaScriptImport({ artifactRoot, files, diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 9dde7c782..c37855e19 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -35,7 +35,6 @@ import { type ManifestFile, } from './emit.ts'; import { parseArtifactManifest, type ArtifactManifest } from './manifest.ts'; -import type { ModuleSyntaxCheck } from './module-imports.ts'; import type { ValidateArtifactOptions, ValidatedArtifactMcpServerEvidence, @@ -585,32 +584,46 @@ const validateArtifactStructure = (options: { return Object.freeze(diagnostics); }; +/** + * Reads the compile evidence record the manifest lists and re-checks it + * against the file table (`AB6039`). A clean record from a build without a + * `tools` hatch proves every manifest `bundle` file: the compiler resolved + * their literal imports, so the module walk only lexes them. A missing, + * failing, or rewritable record proves nothing and every module is walked + * in full. + */ const validateCompileEvidence = async (options: { readonly artifactRoot: string; - readonly manifest: ArtifactManifest; -}): Promise => { - if (!options.manifest.files.some((file) => file.path === compileEvidenceFileName)) return Object.freeze([]); + readonly manifestFiles: readonly ManifestFile[]; +}): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly provenPaths: ReadonlySet }> => { + const unproven = (diagnostics: readonly Diagnostic[]) => + Object.freeze({ diagnostics: Object.freeze(diagnostics), provenPaths: new Set() }); + if (!options.manifestFiles.some((file) => file.path === compileEvidenceFileName)) return unproven([]); const bytes = await runWithPlatform(readFileString(resolve(options.artifactRoot, compileEvidenceFileName))) .catch(() => undefined); if (bytes === undefined) { - return Object.freeze([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); + return unproven([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); } let record: ReturnType; try { record = parseCompileEvidenceRecord(bytes); } catch (error) { if (!(error instanceof TypeError)) throw error; - return Object.freeze([diagnostic('AB6039', error.message, compileEvidenceFileName)]); + return unproven([diagnostic('AB6039', error.message, compileEvidenceFileName)]); } - return compileEvidenceDiagnostics( + const diagnostics = compileEvidenceDiagnostics( record, - new Map(options.manifest.files.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), + new Map(options.manifestFiles.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), ); + if (diagnostics.length > 0 || record.coverage.rewritable) return unproven(diagnostics); + return Object.freeze({ + diagnostics, + provenPaths: new Set(options.manifestFiles.filter((file) => file.kind === 'bundle').map((file) => file.path)), + }); }; const validateGeneratedFiles = async (options: { readonly artifactRoot: string; - readonly bundleSyntaxCheck?: ModuleSyntaxCheck; readonly files: readonly ArtifactFile[]; readonly manifestFiles?: readonly ManifestFile[]; readonly prebuiltPaths?: ReadonlySet; @@ -638,17 +651,16 @@ const validateGeneratedFiles = async (options: { } } + const evidence = options.manifestFiles === undefined + ? { diagnostics: [], provenPaths: new Set() } + : await validateCompileEvidence({ artifactRoot: options.artifactRoot, manifestFiles: options.manifestFiles }); + diagnostics.push(...evidence.diagnostics); diagnostics.push(...await validateJavaScriptModules({ artifactRoot: options.artifactRoot, - ...(options.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: options.bundleSyntaxCheck }), files: options.files, - ...(options.manifestFiles === undefined - ? {} - : { - bundledPaths: new Set(options.manifestFiles.filter((file) => file.kind === 'bundle').map((file) => file.path)), - manifestFiles: new Set(options.manifestFiles.map((file) => file.path)), - }), + ...(options.manifestFiles === undefined ? {} : { manifestFiles: new Set(options.manifestFiles.map((file) => file.path)) }), prebuiltPaths, + provenPaths: evidence.provenPaths, validJson, })); @@ -658,8 +670,8 @@ const validateGeneratedFiles = async (options: { /** * The pre-manifest content pass `build` runs over a staged tree before it * writes the manifest. The planned manifest file table, when given, tells - * the JavaScript validator which modules the compiler emitted; without it - * every module is parsed in full. + * the JavaScript validator which modules the compile evidence record proves; + * without it every module is parsed in full. */ export const validateArtifactFiles = async ( context: ValidateArtifactOptions & { readonly manifestFiles?: readonly ManifestFile[] }, @@ -669,7 +681,6 @@ export const validateArtifactFiles = async ( ...filesystemDiagnostics(inspection.filesystem), ...await validateGeneratedFiles({ artifactRoot: context.artifactRoot, - ...(context.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: context.bundleSyntaxCheck }), files: inspection.files, ...(context.manifestFiles === undefined ? {} : { manifestFiles: context.manifestFiles }), ...(context.prebuiltPaths === undefined ? {} : { prebuiltPaths: context.prebuiltPaths }), @@ -768,7 +779,6 @@ export const validateArtifactWithSnapshot = async ( // Read-only validators over the same immutable inspection run concurrently; // collecting in this fixed order keeps the diagnostics sequence deterministic. const [ - compileEvidenceRecordDiagnostics, targetContractDiagnostics, portableTargetDiagnostics, mcpCoherenceDiagnostics, @@ -776,7 +786,6 @@ export const validateArtifactWithSnapshot = async ( emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ - validateCompileEvidence({ artifactRoot, manifest }), validateTargetContracts({ artifactRoot, files: inspection.files, @@ -811,13 +820,11 @@ export const validateArtifactWithSnapshot = async ( }), validateGeneratedFiles({ artifactRoot, - ...(context.bundleSyntaxCheck === undefined ? {} : { bundleSyntaxCheck: context.bundleSyntaxCheck }), files: inspection.files, manifestFiles: manifest.files, }), ]); diagnostics.push( - ...compileEvidenceRecordDiagnostics, ...targetContractDiagnostics, ...portableTargetDiagnostics, ...mcpCoherenceDiagnostics, diff --git a/packages/agent-bundle/src/core/dependency-manifest.ts b/packages/agent-bundle/src/core/dependency-manifest.ts index eede681b7..496337ad8 100644 --- a/packages/agent-bundle/src/core/dependency-manifest.ts +++ b/packages/agent-bundle/src/core/dependency-manifest.ts @@ -11,9 +11,8 @@ import { exists } from './paths.ts'; * too. A package whose `exports` map hides `package.json` makes that lookup * throw, so the same ancestor walk is then performed by hand. * - * Plain Node, no framework imports: the build's dependency-root discovery and - * `agent-bundle/web-host`, which is bundled into generated executables, locate - * packages the same way. + * Plain Node, no framework imports: the build's dependency-root discovery in + * `rslib.ts` is its only caller. */ export const dependencyManifestPath = async (packageRoot: string, name: string): Promise => { try { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index f7f2a9b47..60eb6a549 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -17,6 +17,12 @@ import { type TargetArtifactDocumentValidator, type TargetArtifactWrite, } from '../src/adapters/types.ts'; +import { + compileEvidenceFileName, + externalPolicy, + serializeCompileEvidenceRecord, + unobservedLoadForms, +} from '../src/build/compile-evidence.ts'; import { composeProjections } from '../src/build/compose.ts'; import { compileEvidenceFileName, @@ -1981,47 +1987,83 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as } }); +/** A compile evidence record covering every `bundle` fixture file with its exact bytes. */ +const compileEvidenceFor = (files: readonly ArtifactFixtureFile[], rewritable = false): ArtifactFixtureFile => ({ + contents: serializeCompileEvidenceRecord({ + assets: files + .filter((file) => file.kind === 'bundle') + .map((file) => ({ externals: [], packages: [], path: file.path, sha256: hash(file.contents) })) + .sort((left, right) => left.path.localeCompare(right.path)), + coverage: { rewritable, unobserved: unobservedLoadForms }, + policy: externalPolicy, + producer: { name: 'agent-bundle', rspack: '2.2.2', version: '0.1.0' }, + }), + kind: 'generated', + path: compileEvidenceFileName, +}); + /** - * The syntax check a module gets follows who produced it. A module the - * framework compiled (manifest kind `bundle`) is the bundler's own output: - * only the ESM lexer runs over it, so re-parsing megabytes of bundler output - * no longer dominates every build, and a bare `export const broken = ;` — - * which no bundler emits — passes while unterminated input still fails. A - * module the framework did not compile (a copied consumer script, a - * generated installer) is parsed in full and keeps the complete check. + * The check a module gets follows what the compiler proved. A manifest + * `bundle` file the compile evidence record covers — same bytes, from a build + * without a `tools` hatch — had every literal import resolved by the compiler: + * only the ESM lexer runs over it, so a bare `export const broken = ;` (which + * no bundler emits) passes while unterminated input still fails, and its + * literal specifiers are not resolved again. Every other module — a copied + * consumer script, a generated installer, a bundle without a record or from a + * build whose hatch may have rewritten it — is parsed in full and its imports + * resolved against the file table. */ -it('parses copied and generated modules in full and trusts compiler bundles to the ESM lexer', async () => { +it('lexes compiled modules the evidence record proves and walks every other module in full', async () => { const brokenStatement = 'export const broken = ;\n'; - const root = await writeArtifact([ + const modules: readonly ArtifactFixtureFile[] = [ { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'document.json' }, { contents: brokenStatement, kind: 'copy', path: 'scripts/copied.mjs' }, { contents: brokenStatement, kind: 'generated', path: 'scripts/generated.mjs' }, { contents: brokenStatement, kind: 'bundle', path: 'scripts/bundled.mjs' }, { contents: 'export const unterminated = `;\n', kind: 'bundle', path: 'scripts/unterminated.mjs' }, { contents: "export { missing } from './missing.mjs';\n", kind: 'bundle', path: 'scripts/dangling.mjs' }, - ], true, [customManifestTarget]); + { contents: "import 'unbundled-package';\n", kind: 'bundle', path: 'scripts/bare.mjs' }, + { contents: "import 'unbundled-package';\n", kind: 'generated', path: 'scripts/uncompiled.mjs' }, + ]; + const reported = async (files: readonly ArtifactFixtureFile[]): Promise => { + const root = await writeArtifact(files, true, [customManifestTarget]); + try { + return (await validateArtifact({ artifactRoot: root, registry: customRegistry() })) + .filter((entry) => entry.code === 'AB6005' || entry.code === 'AB6039') + .map((entry) => [entry.code, entry.generatedPath, entry.message]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }; + const walkedInFull = [ + ['AB6005', 'scripts/bare.mjs', 'Generated JavaScript import from "scripts/bare.mjs" uses unsupported specifier "unbundled-package".'], + ['AB6005', 'scripts/bundled.mjs', 'Generated JavaScript import from "scripts/bundled.mjs" has invalid syntax.'], + ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + ['AB6005', 'scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], + ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], + ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], + ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], + ]; - try { - const diagnostics = await validateArtifact({ artifactRoot: root, registry: customRegistry() }); - expect(diagnostics.filter((entry) => entry.code === 'AB6005').map((entry) => [entry.generatedPath, entry.message])).toEqual([ - ['scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], - ['scripts/dangling.mjs', 'Generated JavaScript import from "scripts/dangling.mjs" is missing "./missing.mjs".'], - ['scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], - ['scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], - ]); - // A build whose consumer hatch may have rewritten the emitted assets asks - // for the full parse of bundles too; nothing else changes. - const parsed = await validateArtifact({ artifactRoot: root, bundleSyntaxCheck: 'parsed', registry: customRegistry() }); - expect(parsed.filter((entry) => entry.code === 'AB6005').map((entry) => entry.generatedPath)).toEqual([ - 'scripts/bundled.mjs', - 'scripts/copied.mjs', - 'scripts/dangling.mjs', - 'scripts/generated.mjs', - 'scripts/unterminated.mjs', - ]); - } finally { - await rm(root, { force: true, recursive: true }); - } + // Without a record nothing is proven: every module is parsed and resolved. + expect(await reported(modules)).toEqual(walkedInFull); + // A record covering the bundles proves them: the lexer still rejects + // unterminated input, but a bare specifier or a dangling sibling in a + // covered bundle is the compiler's resolved import, not the walk's. + expect(await reported([...modules, compileEvidenceFor(modules)])).toEqual([ + ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], + ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], + ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], + ]); + // A hatch may have rewritten the emitted bytes after the compiler judged + // them: the record says so and proves nothing. + expect(await reported([...modules, compileEvidenceFor(modules, true)])).toEqual(walkedInFull); + // A record that does not parse is reported once and proves nothing. + expect(await reported([...modules, { contents: '{', kind: 'generated', path: compileEvidenceFileName }])).toEqual([ + ['AB6039', compileEvidenceFileName, 'Compile evidence record is not valid JSON.'], + ...walkedInFull, + ]); }); it('does not import copied non-JavaScript resources', async () => { diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 4bf16c04d..41a42fe6b 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1439,11 +1439,49 @@ it('excludes linked dependencies that live inside the project directory', async expect(evidence.assets).toEqual([{ path: 'scripts/linked.mjs', sourceInputs: linkedWorkspaceSourceInputs(root) }]); }, 20_000); +it('fails the build on an expression import the compiler left verbatim in a compiled script', async () => { + // Rslib's profile bundles a literal `import()` but leaves `import()` + // in the emitted bundle untouched, unrecorded, and unwarned; the evidence + // record lists that form as unobserved, and the walk over the emitted module + // is what still reports it. + const project = await createProject(); + try { + const expressionImportSource = [ + "export const load = (name: string) => import(name);", + "console.log(Object.keys(await load(process.argv[2] ?? 'node:os')).length);", + '', + ].join('\n'); + await writeFile(project.scriptPath, expressionImportSource); + const model = modelFor(project); + await expect(build({ + model: { + ...model, + skills: model.skills.map((skill) => ({ + ...skill, + resources: skill.resources.map((resource) => + resource.source === project.scriptPath + ? { ...resource, bytes: Buffer.byteLength(expressionImportSource) } + : resource, + ), + })), + }, + outputRoot: project.outputRoot, + projectRoot: project.root, + registry: new TargetRegistry().register((await import('../src/adapters/portable.ts')).portableAdapter, { default: true }), + })).rejects.toThrow( + 'Agent Bundle compilation failed with 1 error:\n[AB6005] Generated JavaScript import from "scripts/greeting.mjs" has a non-literal dynamic import.', + ); + } finally { + await cleanupProject(project); + } +}, 20_000); + it('parses emitted bundles in full when a tools hatch could have rewritten them', async () => { - // A compiler bundle is trusted to the ESM lexer only while its bytes are the - // bundler's own. A hatch runs after Rspack parsed the source and can rewrite - // the emitted asset — here a raw banner that leaves the lexer satisfied but - // Node unable to start the module — so a hatch build keeps the full parse. + // A compiler bundle is trusted to the ESM lexer only while the evidence + // record covers its bytes from a build without a hatch. A hatch runs after + // Rspack parsed the source and can rewrite the emitted asset — here a raw + // banner that leaves the lexer satisfied but Node unable to start the module + // — so the record says `coverage.rewritable` and the walk keeps the full parse. const project = await createProject(); try { await expect(build({ diff --git a/packages/agent-bundle/tests/generated-module-evidence.test.ts b/packages/agent-bundle/tests/generated-module-evidence.test.ts new file mode 100644 index 000000000..73077f385 --- /dev/null +++ b/packages/agent-bundle/tests/generated-module-evidence.test.ts @@ -0,0 +1,212 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; +import { buildWithRslib } from '../src/build/compiler.ts'; +import type { CompileResult } from '../src/build/compile-result.ts'; +import { + cliEntryRuntimePath, + cliEntryRuntimeSpecifier, + generatedCliBinEntrySource, + generatedExecutableEntrySource, + generatedInstallBinEntrySource, + generatedStdioMcpEntrySource, + installEntryRuntimePath, + installEntryRuntimeSpecifier, + launchEnvRuntimePath, + mcpEntryRuntimePath, + mcpEntryRuntimeSpecifier, + stdioPreludeVirtualModule, + terminalCapabilityRuntimePath, + terminalCapabilityRuntimeSpecifier, +} from '../src/build/entry-shell.ts'; +import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from '../src/build/launch-env-shell.ts'; +import type { RslibEntry } from '../src/build/rslib.ts'; +import { runtimeIgnoredRoot } from '../src/build/runtime-path.ts'; +import type { NormalizedHook, SourceProvenance } from '../src/core/types.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; +import type { CompiledAgentRoute, CompiledCliCommand } from '../src/routes/types.ts'; +import { agentBundleNodeModules } from './helpers/workspace-paths.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const testMeta: AgentBundleMeta = Object.freeze({ + name: 'fixture', + packageName: '@fixture/plugin', + packageVersion: '1.0.0', + version: '1.0.0', +}); + +const plugin = { name: 'fixture', version: '1.0.0' }; + +const provenance = (root: string): SourceProvenance => ({ + kind: 'config', + sourcePath: join(root, 'agent-bundle.config.ts'), +}); + +const fixtureRoot = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-generated-module-evidence-'))); + roots.push(root); + for (const directory of ['src/cli', 'src/mcp', 'src/hooks']) { + await mkdir(join(root, ...directory.split('/')), { recursive: true }); + } + await writeFile( + join(root, 'package.json'), + '{"name":"generated-module-evidence-fixture","type":"module","private":true,"dependencies":{"@agent-bundle/runtime":"*"}}\n', + ); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + await writeFile(join(root, 'src', 'cli.ts'), 'export const main = async (): Promise => 0;\n'); + await writeFile(join(root, 'src', 'cli', 'report.ts'), 'export const run = async (): Promise => 0;\n'); + await writeFile(join(root, 'src', 'mcp', 'curator.ts'), 'export default (): Record => ({});\n'); + await writeFile(join(root, 'src', 'hooks', 'probe.ts'), 'export default async (): Promise => undefined;\n'); + await symlink(agentBundleNodeModules, join(root, 'node_modules'), 'dir'); + return root; +}; + +const cliRoute = (root: string): CompiledAgentRoute => ({ + config: {}, + id: 'cli:report', + kind: 'cli', + provenance: { kind: 'conventional', relativePath: 'src/cli/report.ts' }, + source: join(root, 'src', 'cli', 'report.ts'), +}); + +const plainCommand: CompiledCliCommand = { + aliases: [], + exitCode: 'zero', + options: [], + path: ['report'], + rendered: false, + routeId: 'cli:report', +}; + +const configHook = (root: string): NormalizedHook => ({ + event: 'sessionStart', + id: 'hook:sessionStart:probe', + name: 'probe', + provenance: provenance(root), + source: join(root, 'src', 'hooks', 'probe.ts'), + targets: ['claude'], + tools: [], +}); + +const claudeWrapper = (root: string): TargetHookWrapper => ({ + event: 'sessionStart', + hook: configHook(root), + nativeEvent: 'SessionStart', + relativePath: 'hooks/probe.mjs', + target: 'claude', +}); + +const entryOf = ( + options: Pick, +): RslibEntry => ({ + ...options, + sourceInputs: [options.source], +}); + +const generators: ReadonlyArray<{ + readonly label: string; + readonly plan: (root: string) => RslibEntry; +}> = [ + { + label: 'generatedExecutableEntrySource (main, cli)', + plan: (root) => { + const source = join(root, 'src', 'cli.ts'); + return entryOf({ + aliases: { [terminalCapabilityRuntimeSpecifier]: terminalCapabilityRuntimePath() }, + name: 'bin-main', + outputRelativePath: 'bin/main.js', + source, + virtualSource: generatedExecutableEntrySource({ + entrySource: source, + exportName: 'main', + hostSurface: 'cli', + }), + }); + }, + }, + { + label: 'generatedCliBinEntrySource (plain)', + plan: (root) => entryOf({ + aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, + name: 'bin-cli', + outputRelativePath: 'bin/cli.js', + source: join(root, 'src', 'cli', 'report.ts'), + virtualSource: generatedCliBinEntrySource({ + commands: [plainCommand], + plugin, + routes: [cliRoute(root)], + }), + }), + }, + { + label: 'generatedInstallBinEntrySource', + plan: (root) => entryOf({ + aliases: { [installEntryRuntimeSpecifier]: installEntryRuntimePath() }, + name: 'bin-install', + outputRelativePath: 'bin/installer.js', + source: join(root, 'src', 'cli.ts'), + virtualSource: generatedInstallBinEntrySource({ + artifactRelativeUrl: '../../artifact/', + hosts: ['claude', 'codex', 'cursor'], + name: 'installer', + }), + }), + }, + { + label: 'generatedStdioMcpEntrySource', + plan: (root) => { + const source = join(root, 'src', 'mcp', 'curator.ts'); + return entryOf({ + aliases: { + [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), + [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath(), + }, + name: 'mcp-curator', + outputRelativePath: 'mcp/curator.mjs', + source, + virtualModules: [stdioPreludeVirtualModule()], + virtualSource: generatedStdioMcpEntrySource({ entrySource: source, serverName: 'curator' }), + }); + }, + }, + { + label: 'nativeHookWrapperSource (Claude)', + plan: (root) => entryOf({ + aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, + name: 'hooks-probe', + outputRelativePath: 'hooks/probe.mjs', + source: join(root, 'src', 'hooks', 'probe.ts'), + virtualModules: [operatorEnvLayerVirtualModule()], + virtualSource: nativeHookWrapperSource(claudeWrapper(root), 'Claude'), + }), + }, +]; + +const compileGenerator = async (root: string, entry: RslibEntry): Promise => { + const ignored = [...new Set(Object.values(entry.aliases ?? {}).map(runtimeIgnoredRoot))]; + return buildWithRslib({ + cwd: root, + entries: [entry], + ...(ignored.length === 0 ? {} : { ignoredSourcePaths: ignored }), + logLevel: 'error', + meta: testMeta, + outputRoot: join(root, 'dist'), + }); +}; + +describe('framework-generated modules compile with only Node builtin externals', () => { + it.each(generators)('$label', async ({ plan }) => { + const root = await fixtureRoot(); + const result = await compileGenerator(root, plan(root)); + expect(result.externals.every((external) => external.kind === 'builtin')).toBe(true); + }, 120_000); +}); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 933c63fac..271eb8292 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -485,53 +485,6 @@ it('fails closed when the resolved environment lost its virtual modules or wrapp } }); -it('fails closed when an emitted bundle retains a residual reserved import', async () => { - const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-residual-output-')); - const rslib = { - build: async () => ({ - close: async () => undefined, - stats: { - toJson: () => ({ - assets: [{ name: 'hooks/residual-probe.mjs' }], - modules: [], - }), - }, - }), - inspectConfig: async () => ({ - origin: { - bundlerConfigs: [{ - name: 'agent-bundle-hooks-residual-probe', - output: { asyncChunks: false, path: outputRoot }, - target: 'node', - ...resolvedVirtualModules('/tmp'), - }], - environmentConfigs: { 'agent-bundle-hooks-residual-probe': { output: { cleanDistPath: false } } }, - }, - }), - }; - - try { - await mkdir(join(outputRoot, 'hooks'), { recursive: true }); - await writeFile( - join(outputRoot, 'hooks', 'residual-probe.mjs'), - 'import { runGeneratedStdioMcpEntry } from "agent-bundle/mcp-entry";\nawait runGeneratedStdioMcpEntry({});\n', - ); - await expect(buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'residual-probe', - outputRelativePath: 'hooks/residual-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - }], - meta: probeMeta, - outputRoot, - }, { createRslib: async () => rslib as never })).rejects.toThrow(/not self-contained/u); - } finally { - await rm(outputRoot, { force: true, recursive: true }); - } -}); - it('closes the Rslib build result when provenance stats are unavailable', async () => { const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-error-output-')); const close = rs.fn(async () => undefined); diff --git a/packages/agent-bundle/tests/pack-dependencies.test.ts b/packages/agent-bundle/tests/pack-dependencies.test.ts index f801c1871..b2cbf0efe 100644 --- a/packages/agent-bundle/tests/pack-dependencies.test.ts +++ b/packages/agent-bundle/tests/pack-dependencies.test.ts @@ -2,8 +2,8 @@ import { expect, it } from '@rstest/core'; import { classifyDependency, + declarationPackageReferences, isWorkspaceProtocol, - declarationSpecifiers, packageNameOf, rewritesWorkspaceProtocols, shellWords, @@ -155,23 +155,40 @@ it.each([ expect(packageNameOf(specifier)).toBe(name); }); +const importsMap = { imports: { '#driver': { default: 'driver-package', node: 'driver-package/node' }, '#own': './own.js' } }; + it.each([ ['import { a } from "effect";', ['effect']], ["import type { ZodType } from 'zod';", ['zod']], - ['export * from "@scope/name/deep";', ['@scope/name/deep']], + ['export * from "@scope/name/deep";', ['@scope/name']], + ['export type { B } from "types-only";', ['types-only']], ['import "side-effect";', ['side-effect']], ['type T = import("types-only").T;', ['types-only']], ['import x = require("legacy");', ['legacy']], ["declare const y: typeof import ( 'spaced' );", ['spaced']], // A type directive resolves through the package itself or its DefinitelyTyped package. ['/// ', ['node', '@types/node']], - ['declare module "driver-package" { interface Options { verbose?: boolean } }', ['driver-package']], - ["declare module 'augmented' {}", ['augmented']], ["/// ", ['@scope/name', '@types/scope__name']], + // In an external-module file, `declare module` augments a package the consumer must have. + ['import "x";\ndeclare module "augmented" { interface Options { verbose?: boolean } }', ['x', 'augmented']], + ['export declare const x: number;\ndeclare module "augmented" {}', ['augmented']], + // With no import or export, `declare module` is an ambient declaration: the file itself provides the module. + ['declare module "ambient-only" { interface Options { verbose?: boolean } }', []], + ['declare module "outer" { import y from "inner"; }', ['inner']], + // A `#` specifier reaches whatever the manifest's imports map gives it; a relative target is the package's own file. + ['import { driver } from "#driver";', ['driver-package', 'driver-package']], + ['import { own } from "#own";', []], + ['import { unmapped } from "#unmapped";', []], + // Relative, built-in, path, and lib references name no package; a `require()` call is JavaScript, not a declaration import. + ['import "./relative.js"; import "../up.js"; import "node:fs"; import "fs"; import "/abs.js";', []], + ['/// \n/// ', []], + ['declare const x: typeof require("js-only");', []], + // String escapes are decoded before the name is read. + ['import x from "\\x68ex-pkg";', ['hex-pkg']], ['import a from "one"; export { b } from "two"; import c = require("three");', ['one', 'two', 'three']], ['declare const n: string;', []], -])('declarationSpecifiers(%j) is %j', (source, specifiers) => { - expect(declarationSpecifiers(source)).toEqual(specifiers); +])('declarationPackageReferences(%j) is %j', (source, names) => { + expect(declarationPackageReferences(source, importsMap)).toEqual(names); }); it.each([ diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 5f706c32c..36939ebe0 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -79,8 +79,8 @@ interface EmittedModuleReport { * an emitted module may import; every other specifier must be a relative * path (`./` or `../`) to a file inside the same output tree. The author's * own `tools` hatch is the only way a non-builtin may remain external, and - * this fixture declares none. `AB6005` (src/build/validate-artifact-modules.ts) - * enforces the same rule inside every build; this walk proves it on the + * this fixture declares none. `AB6005` (src/build/external-policy.ts, compiler.ts) + * enforces the same rule at compile time; this walk proves it on the * outputs a real consumer builds from the installed tarball. */ const emittedModuleReport = async (root: string): Promise => { diff --git a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts index 4e342bc6d..d086a745e 100644 --- a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -5,10 +5,10 @@ import { dirname, join, relative } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; +import { init, parse } from 'es-module-lexer'; import { build, type BuildProjectResult } from '../src/api.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; -import { readModuleImports } from '../src/build/module-imports.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; import { compileRouteGraph } from '../src/routes/graph.ts'; import { isRelativeSpecifier, moduleCandidates, readModuleFromDisk } from '../src/routes/module-candidates.ts'; @@ -117,9 +117,15 @@ interface EmittedModule { const siblingUrlReference = /new URL\(\s*(?:\/\*[^*]*\*\/\s*)?["'](\.\.?\/[^"']+\.mjs)["']\s*,\s*import\.meta\.url\s*\)/gu; +const importKind = (d: number): 'dynamic' | 'meta' | 'static' => { + if (d === -2) return 'meta'; + if (d === -1) return 'static'; + return 'dynamic'; +}; + const readEmittedModule = async (artifactRoot: string, path: string): Promise => { const bytes = await readFile(join(artifactRoot, path), 'utf8'); - const imports = await readModuleImports(bytes, { check: 'lexed' }); + const imports = parse(bytes)[0].map((record) => ({ kind: importKind(record.d), specifier: record.n })); const statics: string[] = []; const deferred: string[] = []; const bare: string[] = []; @@ -212,6 +218,7 @@ describe('preflight artifact graph (#595)', () => { const cache = new Map(); beforeAll(async () => { + await init; root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-preflight-graph-'))); // The audiobook example's installed tree supplies @agent-bundle/runtime, react, and zod. await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index c9cc3fdb7..0983a234f 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 { dirname, join, relative } from 'node:path'; +import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { gzipSync } from 'node:zlib'; @@ -17,6 +17,7 @@ import { packOutputFromJson, type PackOutput, } from '../src/build/pack-inventory.ts'; +import type { PackageBuildResult } from '../src/build/package-build.ts'; const execFile = promisify(executeFile); const workspaceNodeModules = join(process.cwd(), 'node_modules'); @@ -222,7 +223,7 @@ it('reports package, model, host, and provenance version disagreement as AB7013' }, )); -it('reports installed dependencies no packed JavaScript imports as AB7014, per field', () => withPackageDocument( +it('reports installed dependencies a consumer never needs as AB7014, per field', () => withPackageDocument( (document) => { document.dependencies = { zod: '4.5.4', effect: '4.0.0' }; document.peerDependencies = { react: '19.2.8', 'optional-host': '^1.0.0' }; @@ -232,19 +233,70 @@ it('reports installed dependencies no packed JavaScript imports as AB7014, per f async () => { const reported = withCode(await diagnostics(), 'AB7014'); // One diagnostic per field; devDependencies never reach a consumer and optional peers are never installed, so - // nothing has to use optional-host. + // nothing has to use optional-host. The generated install bin inlines `effect`, so the compiler's evidence names + // that bundle; `zod` reached no bundle and is only listed. + expect(result.build.packageBuild!.evidence.assets.find((asset) => asset.path === 'dist/bin/installer-fixture.js')?.packages) + .toContain('effect'); expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ - expect.stringMatching(/^package\.json dependencies .*"effect", "zod"/u), - expect.stringMatching(/^package\.json peerDependencies .*"react"/u), + 'package.json dependencies names packages a consumer never needs installed: no packed declaration file references them, ' + + 'no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: ' + + '"effect", "zod". The build inlined "effect" into dist/bin/installer-fixture.js; every consumer installs them for nothing.', + expect.stringMatching(/^package\.json peerDependencies .*"react"\. If they only constrain the host version/u), ]); // A required peer nothing imports may be a deliberate host-compatibility contract: a warning, not a refusal. expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'warning']); - expect(reported[0]?.recovery).toContain('devDependencies'); + expect(reported[0]?.recovery).toBe('Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). ' + + 'Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, ' + + 'or a prebuilt payload declares in runtimeDependencies (definePrebuilt).'); expect(reported[1]?.message).toContain('compatibility contract'); expect(reported[1]?.recovery).toContain('peerDependenciesMeta'); }, )); +/** The shared fixture's package build, its evidence record's `packages` set per recorded asset path (`dist/…`). */ +const packageBuildBundling = (bundled: Readonly>): PackageBuildResult => { + const packageBuild = result.build.packageBuild!; + return { + ...packageBuild, + evidence: { + ...packageBuild.evidence, + assets: packageBuild.evidence.assets.map((asset) => ({ ...asset, packages: bundled[asset.path] ?? [] })), + }, + }; +}; + +it('reports a dependency only compiled dist bundles inlined as AB7014, naming the bundles', () => withPackageDocument( + (document) => { + document.dependencies = { 'left-pad': '^1.3.0', 'never-loaded': '^1.0.0', 'tiny-pkg': '^1.0.0' }; + document.optionalDependencies = { 'optional-extra': '^1.0.0' }; + document.peerDependencies = { react: '19.2.8' }; + }, + async () => { + const paths = result.build.packageBuild!.files.map((file) => file.path); + expect(paths).toEqual(expect.arrayContaining(['bin/installer-fixture.js', 'index.js'])); + const reported = withCode(await packInventoryDiagnostics({ + artifactRoot: result.build.build.outputRoot, + model: result.build.model, + packageBuild: packageBuildBundling({ + 'dist/bin/installer-fixture.js': ['left-pad', 'tiny-pkg'], + 'dist/index.js': ['left-pad', 'react'], + }), + packOutput: result.pack, + packerRewritesWorkspaceProtocols: false, + projectRoot, + }), 'AB7014'); + // Bundled is not used: the names are still reported, and the tail says which bundles inlined them, names in + // field order and each name's bundles sorted. A peer the build inlined gets the same sentence; a field the + // compiler never touched gets the plain tail. + expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ + expect.stringMatching(/^package\.json dependencies .*: "left-pad", "never-loaded", "tiny-pkg"\. The build inlined "left-pad" into dist\/bin\/installer-fixture\.js and dist\/index\.js, and "tiny-pkg" into dist\/bin\/installer-fixture\.js; every consumer installs them for nothing\.$/u), + expect.stringMatching(/^package\.json optionalDependencies .*: "optional-extra"\. Nothing packed reaches them at runtime; every consumer installs them for nothing\.$/u), + expect.stringMatching(/^package\.json peerDependencies .*: "react"\. The build inlined "react" into dist\/index\.js; every consumer installs them for nothing\.$/u), + ]); + expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'error', 'warning']); + }, +)); + it('accepts a dependency declared by a prebuilt payload runtimeDependencies list', () => withPackageDocument( (document) => { document.dependencies = { sharp: '^0.33.0', 'never-loaded': '^1.0.0' }; @@ -297,97 +349,6 @@ it('reports an optional peer only for a protocol npm cannot parse, which fails t }, )); -it('accepts a package loaded through a createRequire() binding, literal or computed', () => withPackageDocument( - (document) => { document.dependencies = { 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'aliased.mjs'); - await writeFile(consumer, [ - // The factory renamed on import is still a factory. - 'import { createRequire as makeRequire } from "node:module";', - 'const load = makeRequire(import.meta.url);', - 'export const driver = load("driver-package");', - '', - ].join('\n')); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/aliased.mjs' }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - expect(reported?.message).not.toContain('"driver-package"'); - // Namespace-qualified factory, computed argument. - await writeFile(consumer, 'import * as Module from "node:module";\nconst load = Module.createRequire(import.meta.url);\nexport const any = (name) => load(name);\n'); - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - // Factory chained off a CommonJS load, literal argument. - await writeFile(consumer, 'const load = require("node:module").createRequire(__filename);\nmodule.exports = load("driver-package");\n'); - const [chained] = withCode(await diagnostics(pack), 'AB7014'); - expect(chained?.message).toContain('"never-loaded"'); - expect(chained?.message).not.toContain('"driver-package"'); - // Loader called inline with a literal. - await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const driver = createRequire(import.meta.url)("driver-package");\n'); - const [inline] = withCode(await diagnostics(pack), 'AB7014'); - expect(inline?.message).toContain('"never-loaded"'); - expect(inline?.message).not.toContain('"driver-package"'); - // Factory argument with nested calls, literal target. - await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const driver = createRequire(new URL("./entry.js", import.meta.url))("driver-package");\n'); - const [nested] = withCode(await diagnostics(pack), 'AB7014'); - expect(nested?.message).toContain('"never-loaded"'); - expect(nested?.message).not.toContain('"driver-package"'); - // The same factory argument, computed target: nothing can be called unused. - await writeFile(consumer, 'import { createRequire } from "node:module";\nexport const any = (name) => createRequire(new URL("./entry.js", import.meta.url))(name);\n'); - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - // Comments between the loader and its parentheses, and around the literal, are trivia — and not a computed argument. - await writeFile(consumer, 'module.exports = require /* driver */ ( // which\n /* a */ "driver-package" /* b */ );\n'); - const [commented] = withCode(await diagnostics(pack), 'AB7014'); - expect(commented?.message).toContain('"never-loaded"'); - expect(commented?.message).not.toContain('"driver-package"'); - // Comment trivia before a computed argument still leaves the load computed. - await writeFile(consumer, 'module.exports = (name) => require /* any */ (/* of */ name);\n'); - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['a namespace import', 'import * as Module from "node:module";\nexport const driver = Module.createRequire(import.meta.url)("driver-package");'], - ['the default import', 'import module from "node:module";\nexport const driver = module.createRequire(import.meta.url)("driver-package");'], - ['require("node:module")', 'module.exports = require("node:module").createRequire(__filename)("driver-package");'], - ["require('module')", "module.exports = require('module').createRequire(__filename)('driver-package');"], - ['require("node:module") and .resolve', 'module.exports = require("node:module").createRequire(__filename).resolve("driver-package");'], - ['a two-level namespace, bound first', 'import * as ns from "node:module";\nconst load = ns.default.createRequire(import.meta.url);\nexport const driver = load("driver-package");'], -])('accepts a package loaded by a createRequire() call qualified through %s, direct or bound', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - expect(reported?.message).not.toContain('"driver-package"'); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['a namespace import', 'import * as Module from "node:module";\nexport const load = (name) => Module.createRequire(import.meta.url)(name);'], - ['require("node:module")', 'module.exports = (name) => require("node:module").createRequire(__filename)(name);'], -])('withholds AB7014 for a computed direct createRequire()() call qualified through %s', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifiers as AB7015', () => withPackageDocument( (document) => { document.dependencies = { @@ -419,65 +380,44 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier document.bundleDependencies = ['embedded', 'not-embedded']; document.optionalDependencies = { scp: 'git@github.com:owner/repo.git', - // npm skips these ten after the failed fetch too — and then postinstall fails: on the missing commands, and on - // the missing modules packed files it runs require. `newline-tool` is the first command of postinstall's second - // line, `setup-tool` the second of the first. The files are reached as `node scripts/install` (Node resolves - // `scripts/install.js`), as `npm test` running a script whose quoted path contains a space, and as - // `node scripts/hooks.cjs&&…` with no whitespace around the shell operator; the last loads its dependencies - // through a wildcard `imports` entry mapped to the package's own file, and through a directory whose packed - // manifest names its `main`. An inline `node -e` program that requires a package needs it too — its quotes - // escaped for the shell — as do the modules `node` preloads — a bare `-r` package and a packed `--import=` - // file, behind the valued `--conditions` option — before running `.`, the root `main`, and the one a - // `NODE_OPTIONS=--require=…` assignment on the `node` command preloads. + // npm skips these five after the failed fetch too — and then postinstall fails on the missing package: + // `newline-tool` is the first command of postinstall's second line, `setup-tool` the second of the first; + // `node node_modules/optional-driver/install.js` runs a file of the third directly, reached through `npm test` + // with no whitespace around the shell operator; `node` preloads the last two, a bare `-r` package behind the + // valued `--conditions` option and the one a `NODE_OPTIONS=--require=…` assignment on the `node` command names. 'setup-tool': 'git+https://github.com/owner/setup-tool.git', 'newline-tool': 'github:owner/newline-tool', 'optional-driver': 'github:owner/optional-driver', - 'optional-tester': 'github:owner/optional-tester', - 'optional-hook': 'github:owner/optional-hook', - 'optional-main': 'github:owner/optional-main', - 'optional-inline': 'github:owner/optional-inline', 'optional-preload': 'github:owner/optional-preload', - 'optional-imported': 'github:owner/optional-imported', - 'optional-root': 'github:owner/optional-root', 'optional-env-preload': 'github:owner/optional-env-preload', // Merely mentioned by the script — an `echo` argument; the operand of `rm -r`, whose `-r` is not Node's - // preload option; and the value of a `--require` after the program, which Node hands to the program as an - // argument — so npm's skipping them breaks nothing: a warning. + // preload option; the value of a `--require` after the program, which Node hands to the program as an + // argument; and the name of a packed file `node` runs, which the gate no longer opens — so npm's skipping + // them breaks nothing: a warning. 'optional-mentioned': 'github:owner/optional-mentioned', 'optional-removed': 'github:owner/optional-removed', 'optional-argument': 'github:owner/optional-argument', + 'optional-script': 'github:owner/optional-script', // npm parses these only to fail, so optional or not, the consumer's install dies. 'typo-optional': 'foo:bar', 'tag-optional': 'not a valid spec', 'url-optional': 'http:%zz', 'bad name': '^1.0.0', }; - document.imports = { '#hooks/*': './scripts/*-setup.cjs' }; - document.main = './scripts/root-setup.cjs'; document.scripts = { ...(document.scripts as Record | undefined), postinstall: 'echo start\nnewline-tool --init && setup-tool --init' - + ' && NODE_OPTIONS=--require=optional-env-preload node scripts/install --require optional-argument && npm test', - // A `NODE_OPTIONS` that preloads nothing, set through `cross-env`, changes nothing about the command it precedes. - test: 'cross-env NODE_OPTIONS="--max-old-space-size=4096" node "scripts/my install.cjs";node scripts/hooks.cjs&&echo optional-mentioned' - + ' && node -e "require(\\"optional-inline\\")"' + + ' && NODE_OPTIONS=--require=optional-env-preload node scripts/optional-script.cjs --require optional-argument && npm test', + // A `NODE_OPTIONS` that preloads nothing, set through `cross-env`, changes nothing about the command it precedes; + // a relative `--import=` preload names no package. + test: 'cross-env NODE_OPTIONS="--max-old-space-size=4096" node "scripts/my install.cjs";node node_modules/optional-driver/install.js&&echo optional-mentioned' + ' && rm -r optional-removed && node --conditions react-server -r optional-preload/register --import="./scripts/preload.mjs" .', }; }, async () => { - await mkdir(join(projectRoot, 'scripts', 'lib'), { recursive: true }); await mkdir(join(projectRoot, 'vendor', 'vendored'), { recursive: true }); await mkdir(join(projectRoot, 'vendor', 'bad-manifest'), { recursive: true }); await Promise.all([ - writeFile(join(projectRoot, 'scripts', 'install.js'), 'import "./driver-setup.cjs";\n'), - writeFile(join(projectRoot, 'scripts', 'driver-setup.cjs'), 'module.exports = require("optional-driver");\n'), - writeFile(join(projectRoot, 'scripts', 'my install.cjs'), 'require("optional-tester");\n'), - writeFile(join(projectRoot, 'scripts', 'hooks.cjs'), 'require("#hooks/hook");\nrequire("./lib");\n'), - writeFile(join(projectRoot, 'scripts', 'hook-setup.cjs'), 'require("optional-hook");\n'), - writeFile(join(projectRoot, 'scripts', 'lib', 'package.json'), '{ "main": "setup.cjs" }\n'), - writeFile(join(projectRoot, 'scripts', 'lib', 'setup.cjs'), 'require("optional-main");\n'), - writeFile(join(projectRoot, 'scripts', 'preload.mjs'), 'import "optional-imported";\n'), - writeFile(join(projectRoot, 'scripts', 'root-setup.cjs'), 'require("optional-root");\n'), writeFile(join(projectRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), writeFile(join(projectRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), writeFile(join(projectRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), @@ -491,52 +431,29 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier { path: 'vendor/tarred.tgz' }, { path: 'vendor/bad-tarred-manifest.tgz' }, { path: 'vendor/not-archive.tgz' }, - { path: 'scripts/install.js' }, - { path: 'scripts/driver-setup.cjs' }, - { path: 'scripts/my install.cjs' }, - { path: 'scripts/hooks.cjs' }, - { path: 'scripts/hook-setup.cjs' }, - { path: 'scripts/lib/package.json' }, - { path: 'scripts/lib/setup.cjs' }, - { path: 'scripts/preload.mjs' }, - { path: 'scripts/root-setup.cjs' }, ] }; const reported = withCode(await diagnostics(pack), 'AB7015'); expect(reported.map((diagnostic) => diagnostic.message)).toEqual([ expect.stringMatching(/^package\.json dependencies .*consumers cannot install the package\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*"bad name" -> "\^1\.0\.0", "newline-tool" -> "github:owner\/newline-tool", "optional-driver" -> "github:owner\/optional-driver", "optional-env-preload" -> "github:owner\/optional-env-preload", "optional-hook" -> "github:owner\/optional-hook", "optional-imported" -> "github:owner\/optional-imported", "optional-inline" -> "github:owner\/optional-inline", "optional-main" -> "github:owner\/optional-main", "optional-preload" -> "github:owner\/optional-preload", "optional-root" -> "github:owner\/optional-root", "optional-tester" -> "github:owner\/optional-tester", "setup-tool" -> "git\+https:\/\/github\.com\/owner\/setup-tool\.git", "tag-optional" -> "not a valid spec", "typo-optional" -> "foo:bar", "url-optional" -> "http:%zz"; consumers cannot install the package\.$/u), - expect.stringMatching(/^package\.json optionalDependencies .*"optional-argument" -> "github:owner\/optional-argument", "optional-mentioned" -> "github:owner\/optional-mentioned", "optional-removed" -> "github:owner\/optional-removed", "scp" -> "git@github\.com:owner\/repo\.git".*continues without them/u), + expect.stringMatching(/^package\.json optionalDependencies .*"bad name" -> "\^1\.0\.0", "newline-tool" -> "github:owner\/newline-tool", "optional-driver" -> "github:owner\/optional-driver", "optional-env-preload" -> "github:owner\/optional-env-preload", "optional-preload" -> "github:owner\/optional-preload", "setup-tool" -> "git\+https:\/\/github\.com\/owner\/setup-tool\.git", "tag-optional" -> "not a valid spec", "typo-optional" -> "foo:bar", "url-optional" -> "http:%zz"; consumers cannot install the package\.$/u), + expect.stringMatching(/^package\.json optionalDependencies .*"optional-argument" -> "github:owner\/optional-argument", "optional-mentioned" -> "github:owner\/optional-mentioned", "optional-removed" -> "github:owner\/optional-removed", "optional-script" -> "github:owner\/optional-script", "scp" -> "git@github\.com:owner\/repo\.git".*continues without them/u), ]); // npm survives an optional dependency it parsed but cannot fetch, so that entry warns rather than blocks the // release; a specifier it cannot parse fails the manifest read and stays fatal, as does a skipped package an - // install script then runs or loads. + // install script then runs. expect(reported.map((diagnostic) => diagnostic.severity)).toEqual(['error', 'error', 'warning']); - for (const name of ['scp', 'optional-argument', 'optional-mentioned', 'optional-removed']) { + for (const name of ['scp', 'optional-argument', 'optional-mentioned', 'optional-removed', 'optional-script']) { expect(reported[1]?.message).not.toContain(JSON.stringify(name)); } - for (const name of [ - 'setup-tool', 'newline-tool', 'optional-driver', 'optional-tester', 'optional-hook', 'optional-main', 'optional-inline', - 'optional-preload', 'optional-imported', 'optional-root', 'optional-env-preload', - ]) { + for (const name of ['setup-tool', 'newline-tool', 'optional-driver', 'optional-preload', 'optional-env-preload']) { expect(reported[2]?.message).not.toContain(JSON.stringify(name)); } - // To npm, `.` is the working directory (`--prefix .`), not a program: only `node .` runs the root `main`. - await withPackageDocument( - (document) => { - document.scripts = { ...(document.scripts as Record), postinstall: 'npm --prefix . run setup', setup: 'echo setup' }; - }, - async () => { - const survivable = withCode(await diagnostics(pack), 'AB7015').find((diagnostic) => diagnostic.severity === 'warning'); - expect(survivable?.message).toContain('"optional-root"'); - }, - ); for (const name of ['@agent-bundle/runtime', 'bashjsast', 'local', 'sibling', 'not-embedded', 'not-vendored', 'not-archive', 'bad-manifest', 'bad-tarred-manifest']) { expect(reported[0]?.message).toContain(`${JSON.stringify(name)} -> `); } for (const name of ['alias', 'tilde', 'versioned', 'embedded', 'vendored', 'tarred']) { expect(reported[0]?.message).not.toContain(JSON.stringify(name)); } - await rm(join(projectRoot, 'scripts'), { force: true, recursive: true }); await rm(join(projectRoot, 'vendor'), { force: true, recursive: true }); expect(reported[0]?.recovery).toContain('registry'); @@ -545,122 +462,10 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier }, )); -it('withholds AB7014 when packed JavaScript has a computed import() that could load any declared package', () => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'computed.mjs'); - await writeFile(consumer, 'export const load = (name) => import(name);\n'); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/computed.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - // Without that file the same declaration is reported. - expect(withCode(await diagnostics(), 'AB7014')).toHaveLength(1); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it('withholds AB7014 when packed JavaScript the lexer rejects may hide an import()', () => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'unlexable.mjs'); - // An unbalanced call: the lexer throws before reporting any import, so nothing proves the package unused. - await writeFile(consumer, 'export const load = () => import("chosen-at-runtime"\n'); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/unlexable.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['require()', 'module.exports = (name) => require(name);'], - ['require.resolve()', 'module.exports = (name) => require.resolve(name);'], - ['import.meta.resolve()', 'export const where = (name) => import.meta.resolve(name);'], - ['a direct createRequire()()', 'import { createRequire } from "node:module";\nexport const load = (name) => createRequire(import.meta.url)(name);'], - ['require() of a literal-prefixed expression', 'module.exports = (variant) => require("chosen-at-runtime/" + variant);'], - ['require.resolve() of a template literal', 'module.exports = (variant) => require.resolve(`chosen-at-runtime/${variant}`);'], -])('withholds AB7014 for a computed CommonJS %s just as for a computed import()', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('module.exports') ? 'computed.cjs' : 'computed.mjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['const load = require;', 'const load = require;\nmodule.exports = load("chosen-at-runtime");'], - ['fn(require)', 'module.exports = (fn) => fn(require);'], - ['module.exports = require', 'module.exports = require'], - ['[require]', 'module.exports = [require];'], - ['a ? require : b', 'module.exports = typeof require === "function" ? require : null;'], - ['a createRequire() binding passed on', 'import { createRequire } from "node:module";\nconst load = createRequire(import.meta.url);\nexport const use = (fn) => fn(load);'], -])('withholds AB7014 when a loader is passed on as a value (%s), since it may load anything under another name', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'chosen-at-runtime': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'alias.mjs' : 'alias.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it.each([ - ['require("…")', 'module.exports = require("node:path");'], - ['require.resolve("…")', 'module.exports = require.resolve("node:path");'], - ['typeof require', 'module.exports = typeof require;'], - ['the string "require"', 'module.exports = "require";'], - ['prose in comments', '/**\n * Use when a getter may fail, require\n * services, or run asynchronously.\n */\n// factory(module, require)\nmodule.exports = 1;'], - ['a bundler runtime named like require', 'const load = __webpack_require__;\nmodule.exports = load;'], -])('still reports AB7014 when require is only called, resolved through, type-tested, or named in a string or comment (%s)', (_form, source) => withPackageDocument( - (document) => { document.dependencies = { 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'not-alias.cjs'); - await writeFile(consumer, `${source}\n`); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/not-alias.cjs' }] }; - const [reported] = withCode(await diagnostics(pack), 'AB7014'); - expect(reported?.message).toContain('"never-loaded"'); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it('still reports AB7014 when the only resolve() calls are path or Promise resolution, literal or not', () => withPackageDocument( - (document) => { document.dependencies = { 'never-loaded': '^1.0.0' }; }, - async () => { - const consumer = join(projectRoot, 'dist', 'resolvers.mjs'); - await writeFile(consumer, [ - 'import path, { resolve } from "node:path";', - 'export const f = (a, b) => [resolve(a, b), Promise.resolve(a), path.resolve("never-loaded"), Promise.resolve("never-loaded")];', - '', - ].join('\n')); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/resolvers.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')[0]?.message).toContain('"never-loaded"'); - } finally { - await rm(consumer, { force: true }); - } - }, -)); - -it('accepts a dependency reached through a package imports map or run by a consumer install script', () => withPackageDocument( +it('accepts a dependency a consumer install script names or runs, through delegated scripts and their hooks', () => withPackageDocument( (document) => { document.dependencies = { + // Reached only through the manifest's imports map, which no packed declaration file resolves: unused. 'driver-package': '^1.0.0', 'named-in-script': '^1.0.0', typescript: '^5.0.0', @@ -700,21 +505,13 @@ it('accepts a dependency reached through a package imports map or run by a consu }; }, async () => { - const consumer = join(projectRoot, 'dist', 'mapped.mjs'); - await writeFile(consumer, 'export { default } from "#driver";\n'); // The fixture's node_modules is the workspace's; the manifest is removed again below. const wrapper = join(workspaceNodeModules, 'prepack-test-wrapper'); await mkdir(wrapper, { recursive: true }); await writeFile(join(wrapper, 'package.json'), JSON.stringify({ name: '@scope/real', version: '1.0.0', bin: 'cli.js' })); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/mapped.mjs' }] }; - // `#driver` reaches driver-package; the script names named-in-script directly, typescript through its `tsc` bin, - // and the alias through `real`, the bin npm derives from the installed manifest's name. `prepare` proves nothing. - const [withImport] = withCode(await diagnostics(pack), 'AB7014'); - expect(withImport?.message).toContain('"prepare-only"'); - expect(withImport?.message).toContain('"dormant-only"'); - expect(withImport?.message).not.toContain('"driver-package"'); - // Without the `#` import the map alone proves nothing. + // The script names named-in-script directly, typescript through its `tsc` bin, and the alias through `real`, + // the bin npm derives from the installed manifest's name. `prepare` proves nothing, nor does the map alone. const [reported] = withCode(await diagnostics(), 'AB7014'); expect(reported?.message).toContain('"driver-package"'); expect(reported?.message).toContain('"prepare-only"'); @@ -737,31 +534,10 @@ it('accepts a dependency reached through a package imports map or run by a consu ); } finally { await rm(wrapper, { force: true, recursive: true }); - await rm(consumer, { force: true }); } }, )); -it.each([ - ['a literal import()', 'node -e "import(\'optional-driver\')"', 'error'], - ['an awaited import() in an ES module program', 'node --input-type=module -e "await import(\'optional-driver\')"', 'error'], - ['a computed import(), which may load any declared package', 'node -e "import(process.argv[1])"', 'error'], - ['a literal require()', 'node -e "require(\'optional-driver\')"', 'error'], - ['source the lexer rejects, which may hide an import()', 'node -e "import(\'optional-driver\'"', 'error'], - ['import.meta, which loads nothing', 'node --input-type=module -p "typeof import.meta"', 'warning'], - ['the package name in a string', 'node -p "\'optional-driver\'"', 'warning'], -])('an inline node program with %s (%s) leaves a skipped optional dependency at severity %s', (_form, postinstall, severity) => withPackageDocument( - (document) => { - document.optionalDependencies = { 'optional-driver': 'github:owner/optional-driver' }; - document.scripts = { ...(document.scripts as Record | undefined), postinstall }; - }, - async () => { - const [reported] = withCode(await diagnostics(), 'AB7015'); - expect(reported?.message).toContain('"optional-driver"'); - expect(reported?.severity).toBe(severity); - }, -)); - it('reads a dependency whose installed manifest is not JSON as an unknown executable instead of failing the gate', () => withPackageDocument( (document) => { document.dependencies = { 'broken-dep': '^1.0.0', 'never-loaded': '^1.0.0' }; @@ -812,14 +588,14 @@ it('reads an installed manifest as npm does, so the last of duplicate name keys it('prepack succeeds and surfaces the warning when the only finding is an unresolvable optional dependency', () => withPackageDocument( (document) => { document.optionalDependencies = { 'optional-native': 'github:owner/optional-native' }; - // The build rewrites dist, so the packed module that loads the optional package lives in its own packed - // directory; an install script naming it instead would make the failed fetch fatal. + // The build rewrites dist, so the packed declaration that references the optional package lives in its own + // packed directory; an install script running it instead would make the failed fetch fatal. document.files = [...(document.files as readonly string[]), 'extras']; }, async () => { const extras = join(projectRoot, 'extras'); await mkdir(extras, { recursive: true }); - await writeFile(join(extras, 'optional.mjs'), 'export const native = await import("optional-native").catch(() => undefined);\n'); + await writeFile(join(extras, 'optional.d.ts'), 'export type { Native } from "optional-native";\n'); try { const packed = await prepack({ root: projectRoot }); expect(packed.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity])).toEqual([['AB7015', 'warning']]); @@ -830,61 +606,33 @@ it('prepack succeeds and surfaces the warning when the only finding is an unreso )); it('accepts a dependency that only packed declaration files reference, including @types for a type directive', () => withPackageDocument( - (document) => { document.dependencies = { zod: '^4.5.4', '@types/node': '^22.0.0' }; }, + (document) => { + document.dependencies = { zod: '^4.5.4', '@types/node': '^22.0.0', 'driver-package': '^1.0.0', 'never-loaded': '^1.0.0' }; + document.imports = { '#driver': { node: 'driver-package/node', default: 'driver-package' } }; + }, async () => { const declaration = join(projectRoot, 'dist', 'consumer.d.ts'); + const modern = join(projectRoot, 'dist', 'driver.d.mts'); await writeFile(declaration, [ '/// ', "import type { ZodType } from 'zod';", 'export declare const schema: ZodType;', 'export declare const buffer: Buffer;', + // A comment or string is not a reference, and a `declare module` in a module file augments a package. + '// import { Function } from "effect" -- never counts.', + 'export declare const text: "import x from \\"effect\\"";', '', ].join('\n')); + // A `#` specifier reaches the package the imports map names. + await writeFile(modern, 'export type { Driver } from "#driver";\n'); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.d.ts' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); + const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.d.ts' }, { path: 'dist/driver.d.mts' }] }; + const [reported] = withCode(await diagnostics(pack), 'AB7014'); + expect(reported?.message).toContain('"never-loaded"'); + for (const name of ['zod', '@types/node', 'driver-package']) expect(reported?.message).not.toContain(JSON.stringify(name)); } finally { await rm(declaration, { force: true }); - } - }, -)); - -it('accepts a dependency that packed JavaScript imports, requires, or only resolves', () => withPackageDocument( - (document) => { - document.dependencies = { - 'left-pad': '^1.3.0', - '@scope/required': '^2.0.0', - 'asset-pkg': '^1.0.0', - 'tool-pkg': '^1.0.0', - // Named only through escaped literals, which Node decodes before resolving. - 'hex-pkg': '^1.0.0', - 'unicode-pkg': '^1.0.0', - // Run as an executable, never loaded: by the `tsc` bin its installed manifest declares. - typescript: '^5.0.0', - }; - }, - async () => { - const consumer = join(projectRoot, 'dist', 'consumer.mjs'); - await writeFile(consumer, [ - 'import { execSync, spawnSync } from "node:child_process";', - 'const ran = [spawnSync("tsc", ["--version"]), execSync("tsc --noEmit")];', - 'import leftPad from "left-pad/lib/index.js";', - 'const { createRequire } = await import("node:module");', - 'const require = createRequire(import.meta.url);', - 'const required = require("@scope/required/subpath");', - 'const asset = require.resolve("asset-pkg/package.json");', - 'const tool = import.meta.resolve("tool-pkg/bin/tool");', - String.raw`const hex = require("\x68ex-pkg");`, - String.raw`const unicode = require('unicode-pkg\u002fsubpath');`, - '// import { Function } from "effect" -- a comment never counts.', - 'export { leftPad, required, asset, tool, hex, unicode, ran };', - '', - ].join('\n')); - try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.mjs' }] }; - expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); - } finally { - await rm(consumer, { force: true }); + await rm(modern, { force: true }); } }, )); @@ -916,28 +664,38 @@ const createSiblingProject = async ( return root; }; -it('accepts a dependency that only a prebuilt payload module imports: prepack passes, AB6005 does not walk prebuilt payloads', async () => { +it('accepts a dependency a prebuilt payload declares in runtimeDependencies: prepack passes, the payload stays opaque', async () => { const root = await createSiblingProject('prebuilt-project', { bin: { 'prebuilt-fixture': './dist/bin/prebuilt-fixture.js' }, - dependencies: { express: '^5.0.0' }, + dependencies: { 'body-parser': '^2.0.0', cors: '^2.8.5', express: '^5.0.0' }, files: ['dist', 'host-packs', 'README.md'], name: 'prebuilt-fixture', type: 'module', version: '1.2.3', }, [ + "import { definePrebuilt } from 'agent-bundle';", + '', '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' },", + " payload: { runtime: definePrebuilt({ runtimeDependencies: ['body-parser', 'cors', 'express'], source: './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', + // A bare import, a `require()`, and a `require.resolve()` in a module the framework copies rather than compiles: + // nothing opens the file — AB6005 never walks it, and AB7014 reads no packed JavaScript — so the declaration + // above is what keeps `express`, `body-parser`, and `cors` out of AB7014. + 'built/runtime/mcp/server.js': [ + 'import express from "express";', + 'const body = require("body-parser");', + 'const where = require.resolve("cors");', + 'export default express;', + 'export { body, where };', + '', + ].join('\n'), 'src/index.ts': 'export const value = 1;\n', }); const packed = await prepack({ root }); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 704c86133..12ff61d6e 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -1027,8 +1027,9 @@ Invalid `web` is `AB4341` (error); the message shapes are in the When `web` is configured, the composite artifact's `agent-bundle.manifest.json` gains a `web` section and the build emits `bin/.mjs` even without `src/cli/**` commands. The host page -lives inside that bin — there is no `web/` directory in the artifact. `AB6005` walks the bin -like every other executable. +lives inside that bin — there is no `web/` directory in the artifact. Its compilation contributes +the same external-module evidence as every other executable, so `AB6005` rejects any external +other than a Node built-in, `pnpapi`, or an emitted sibling. From the built artifact or the installed plugin root: diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 147287c81..efbc0938c 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -501,7 +501,11 @@ silently overriding the contract. Reserved module specifiers are protected the s that externalizes `agent-bundle/mcp-entry`, `agent-bundle/meta`, or `agent-bundle/mcp-apps` fails the build. Externalization in general is bounded too: `AB4725` rejects statically visible package `externals`, while relative, function-form, and mutator externals are judged from the -compilation's own evidence (`AB6005`). Run-time path references are kept the same way: a +compilation's own evidence (`AB6005`). A hatch also runs after the compiler judged the module graph +and can rewrite the emitted bytes, so the build's compile evidence record marks +`coverage.rewritable` and artifact validation parses every emitted module in full and resolves its +imports, instead of trusting the record; without a hatch a compiled module is only lexed. Run-time +path references are kept the same way: a `new URL(…, import.meta.url)` or `new Worker(new URL(…))` in your code or a generated entry names a file beside the artifact, so the invariant layer turns the bundler's URL and worker asset processing off after the hatch and the expression reaches the artifact verbatim. The hatch diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 02dfb303b..9124fcf40 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -60,7 +60,8 @@ composite plugin root + agent-bundle.compile-evidence.json │ ▼ -build/validate-artifact*.ts / AB6005 / AB6039 +build/compiler.ts + build/external-policy.ts / AB6005 + + build/validate-artifact*.ts / AB6005 (residual), AB6039 / prepack AB7014, AB7015 ← Artifact layer ``` @@ -288,26 +289,27 @@ embed their HTML), then the node surfaces. | Check | Module | Code | | --- | --- | --- | | Canonical manifest parse + file digest match | `build/validate-artifact.ts` | `AB60xx` family; parse errors throw from `parseArtifactManifest` | +| Host-pack, package-build `dist`, and MCP App view compilations keep only permitted externals | `build/compiler.ts`, `build/external-policy.ts` | `AB6005` | | Compile evidence record vs manifest `bundle` files | `build/compile-evidence.ts` `compileEvidenceDiagnostics` | `AB6039` | -| Host-pack and package-build `dist` modules import only relative/`file:` specifiers or Node built-ins | `build/validate-artifact-modules.ts` | `AB6005` | +| Emitted modules the compiler could not see: an expression `import()` anywhere, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten | `build/validate-artifact-modules.ts` | `AB6005` (residual forms) | | Skills / hooks / MCP documents match the selected hosts | `build/validate-artifact-skills.ts`, `build/validate-artifact-hooks.ts`, `build/validate-artifact-mcp.ts` | per-surface `AB60xx` | | Artifact ownership of the output root | `build/validate-artifact.ts` | `AB6014` | | Packed tarball contains every manifest file and no unused install deps | `build/pack-inventory.ts` | `AB7010`, `AB7014`, `AB7015` | -`AB6005` walks every emitted JavaScript module: host-pack `.mjs` rows in -`manifest.files`, and the package build's `dist` bundles (`dist/bin/*.js`, -Flight workers, the `lib` entry) before `dist` is published. A bare package -specifier fails; only Node built-ins (`node:module.isBuiltin`) may stay -external. -Prebuilt payloads (`kind: 'prebuilt'`) are hash-locked only and are not -walked. That is the same self-containment rule the generated-plugin -compiler profile enforces (`build/rslib.ts` `composeEntryLibConfig`: -`autoExternal: false`, `bundle: true`, no author-facing `externals`). +`AB6005` reads compile evidence for every host-pack, package-build `dist`, and +MCP App view compilation. Each `ExternalModule` that Rspack kept must be a +Node built-in, `pnpapi`, or an emitted sibling; any other external fails. +That is the self-containment rule enforced by `build/compiler.ts` and +`build/external-policy.ts`, alongside the generated-plugin compiler profile +(`build/rslib.ts` `composeEntryLibConfig`: `autoExternal: false`, +`bundle: true`, no author-facing `externals`). `AB7014` / `AB7015` are the prepack gate: a `package.json` install-time -dependency must appear in packed JavaScript, declarations, `imports`, or a -consumer install script (`AB7014`), and a consumer's npm must be able to -fetch it (`AB7015`). +dependency must be justified by prebuilt `runtimeDependencies`, a packed +declaration reference, a consumer install script, or the framework's +currently empty process-dependency record (`AB7014`), and a consumer's npm +must be able to fetch it (`AB7015`). A package inlined into a bundle is not +used; compile evidence records the `dist` bundles named in the diagnostic. ### Composite root @@ -435,8 +437,9 @@ Production readers call `parseArtifactManifest` or take an already-parsed | Reader | Fields used | Purpose | | --- | --- | --- | | `build/validate-artifact.ts` | all of them | Re-parse the on-disk bytes, match `files[]` digests and modes, check `targets[]` against the registry, pin `agentSkills` and `runtime.node` | +| `build/compiler.ts`, `build/external-policy.ts` | `CompileResult.externals` for host packs, package-build `dist`, and MCP App views | `AB6005` compile-evidence self-containment | | `build/compile-evidence.ts` | `files[]` (`bundle` rows) | `AB6039`: re-check the persisted compile evidence record against the file table without parsing JavaScript | -| `build/validate-artifact-modules.ts` | `files[]` (`.mjs` rows) plus package-build `dist` | `AB6005` import graph | +| `build/validate-artifact-modules.ts` | `files[]` (`.js`/`.mjs` rows) plus package-build `dist`; `bundle` rows the compile evidence record proves are lexed only | `AB6005` residual walk: expression `import()`, uncompiled or hatch-rewritten modules | | `build/validate-artifact-skills.ts` | `targets[]` (`manifestTargets`) plus the tree | Skill documents vs selected hosts | | `build/validate-artifact-hooks.ts` | `targets[]`, hook files | Hook documents vs selected hosts | | `build/validate-artifact-mcp.ts` | `targets[]`, MCP files | MCP documents vs selected hosts | diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index cc087d570..aff3c4dac 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -29,7 +29,8 @@ manifest file table reports `AB6039`. Self-containment is proven from the compilation's externals (`AB6005` at compile time). The compiler service lowers every host-pack surface and package-build entry (`dist/bin/*.js`, a -rendered route's Flight worker `.mjs`, and the `lib` entry). The framework-owned +rendered route's Flight worker `.mjs`, and the `lib` entry), plus every MCP App view. The +framework-owned `ArtifactDependencyAuditPlugin` taps `thisCompilation` to record every module Rspack kept external, and the service reads that evidence before trusting an asset. Only Node built-ins (`node:fs`, `fs`), `pnpapi`, and an emitted sibling of the same artifact may remain external; any @@ -37,18 +38,22 @@ other request fails whatever spelling Rspack emitted (`import`, `require`, or it shim). An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. -The emitted-module walk remains behind that compiler evidence as defense in depth. It walks each -emitted JavaScript module as an ES module and rejects residual unsupported imports, non-literal -dynamic imports, and targets missing from or outside the emitted tree; a `dist` finding names its -file as `dist/`. How thoroughly syntax is checked follows who produced the bytes. A module -the framework compiled (manifest kind `bundle`) is the bundler's own output, so only the ESM lexer -runs over it, rejecting unterminated strings, templates, comments, and regexps and unbalanced -braces. A module the framework did not compile — a copied consumer script or generated installer -— is parsed in full, as 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, and declaration files -(`.d.ts`) are not walked. The route graph guards the same -self-containment before the bundler runs: a route module, layout, or provider — or a module one of +The emitted-module walk remains behind that compiler evidence for exactly what the compiler cannot +see. It reads every emitted `.js`/`.mjs` module as an ES module and, in any of them, compiled ones +included, fails an expression `import()` (`AB6005 has a non-literal dynamic import`). A module the +compile evidence record proves — a manifest `bundle` file the record covers with the same bytes, +from a build without a [`tools` hatch](../../reference/configuration.mdx#tools) — is lexed for +syntax only: the compiler already resolved its literal imports as bundled, built-in, or an emitted +sibling, so the walk does not resolve them again. Every other module is parsed in full and its +imports resolved against the manifest — a generated `install.mjs`, a copied consumer script, every +module of an artifact without a record, and every module of a build whose hatch could have rewritten +the emitted assets (the record's `coverage.rewritable`). Those findings are the residual `AB6005` +forms: unsupported or invalid specifiers, targets missing from or outside the emitted tree, invalid +syntax; a `dist` finding names its file as `dist/`. Prebuilt payloads (`kind: 'prebuilt'`) +stay opaque and hash-locked only, and declaration files (`.d.ts`) are not walked. + +The route graph guards the same self-containment before the bundler runs: a route module, layout, +or provider — or a module one of them reaches through relative imports — that value-imports a compiler-carrying framework entry (`agent-bundle`, `agent-bundle/api`, `agent-bundle/config`, `agent-bundle/eval`, `agent-bundle/rstest`, `agent-bundle/test`, `agent-bundle/test/browser`) is reported as `AB4837` @@ -243,11 +248,8 @@ dependency into `dist/bin` and the host packs, so a published plugin should inst declare the framework, `@agent-bundle/runtime`, `react`, `zod`, and the rest of the stack under `devDependencies`. A compiled bundle — a host-pack module or a `dist` bundle alike — cannot keep a bare package external: the compiler reports `AB6005` 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 `require`, `createRequire`, or `import.meta.resolve` call in a packed -file that the compiler did not resolve as a module dependency, 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 +bundling a package does not prove that consumers need it installed. `--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. @@ -264,19 +266,27 @@ external — a `react` mapped to a global through the hatch, say — fails the v `externals` entries that name packages. RegExp, function-form, relative, and mutator externals are judged from the compilation's own evidence: `AB6005` fails a generated executable in a host pack or `dist` when Rspack kept anything except a built-in, `pnpapi`, or an emitted sibling external. -The emitted-module walk remains behind this compile-time check. An external kept through the -[`tools` hatch](../../reference/configuration.mdx#tools) is therefore not a way around the -contract 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 packed file loads through a `require`, `createRequire`, or -`import.meta.resolve` call that the compiler does not resolve as a module dependency, 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`), or one a -prebuilt payload names in `runtimeDependencies` — and `AB7014` -reports a declared dependency with none of that evidence, while `AB7015` reports one a consumer's -npm cannot install. +An external kept through the [`tools` hatch](../../reference/configuration.mdx#tools) is therefore +not a way around the contract and never reaches the prepack inventory. + +`AB7014` accepts exactly four kinds of consumer-runtime evidence: + +1. `runtimeDependencies` on a prebuilt payload declared with `definePrebuilt`; +2. a packed declaration file reference, as TypeScript's `preProcessFile` reads it: imports, + exports, import-equals `require`, `import("…")` types, module augmentations in an + external-module file, and `/// ` (including its `@types` twin); an ambient + `declare module` in a file with no imports counts for nothing, while a `#subpath` follows the + package `imports` map; +3. a consumer-side `preinstall`, `install`, or `postinstall` script, including delegated scripts, + that names the dependency or runs one of its `bin` commands; and +4. the framework's process-dependency record, which is currently empty. + +A package merely inlined into a compiled bundle is not used. When that is the only evidence, +`AB7014` names the `dist` bundles that inlined it, for example: +`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` Move +build-only packages to `devDependencies`; compiled bundles inline their imports (`AB6005`). Keep a +runtime dependency only for what a packed declaration file references, a consumer install script +names or runs, or a prebuilt payload declares in `runtimeDependencies` (`definePrebuilt`). | Code | Meaning | | --- | --- | @@ -284,33 +294,8 @@ 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 `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, and no prebuilt payload names them in `runtimeDependencies` (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`. | - -A prebuilt payload's `runtimeDependencies` declaration counts as use, since the compiler never opens a -payload file. The remaining dependency evidence is read from the packed bytes: every `.js`/`.mjs`/`.cjs` file npm would -publish is lexed for `import` specifiers and scanned for literal `require("…")`, `require.resolve("…")`, and -`import.meta.resolve("…")` calls (a `createRequire(…)` binding counts as `require`, even with the factory -renamed on import, as do direct `Module.createRequire(…)("…")` and `require("node:module").createRequire(…)("…")` calls; `path.resolve("…")` does not count), and every -packed `.d.ts` is scanned for the modules its types reference (a `/// ` directive -counts for the package and its `@types/*` twin); specifiers are reduced to package names, string escapes -decoded first, with Node built-ins ignored. A dependency packed code runs as an executable — a string literal -that is one of the `bin` commands its installed manifest declares, such as `spawnSync("tsc", ["--version"])` — -counts as used, as does one a -`#subpath` import may reach through the `imports` map, and a -dependency a consumer-side `preinstall`/`install`/`postinstall` script — not `prepare`, which npm never runs -for a published tarball — or any script it delegates to with `npm run` (the first positional after `run`; options -before or after `run`, valued or not, are skipped, and later words such as `npm run setup -- dormant` are that -script's arguments) or a direct `npm test`/`start`/`stop`/`restart` names (`npm restart` without a `restart` script running `stop` then `start`) (or whose `bin` command it runs; a -string-form `bin` is named after the installed manifest, read as npm reads it — the last of duplicate keys -wins — and the unscoped name stands in when the dependency is not installed locally or its manifest is not -JSON). A -computed `import(expression)` or `require(expression)` (likewise `require.resolve`, `import.meta.resolve`, -a direct `createRequire(…)(…)`, or a `createRequire` binding) in packed code could load any declared -package, so it withholds `AB7014` altogether; so does packed source the ESM lexer rejects, whose `import()` -calls it cannot report, and so does `require` passed on as a value (`const load = require`, -`fn(require)`) rather than called, since packages may then be loaded under a name the scan never sees. A mention inside a comment can only keep a dependency, never -report one; `devDependencies` are never inspected, and an `npm:` alias counts as a registry specifier when its target does. +| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed. Use requires one of the four evidence sources above; compiled bundles that inline a package do not count. The diagnostic names any `dist` bundles that inlined an otherwise unused package. It is an error for `dependencies` and `optionalDependencies`, and a warning for `peerDependencies`; optional peers are skipped, and an `optionalDependencies` entry supersedes the same name under `dependencies`. | +| `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 because npm can continue without it, but escalates to an error when an install script needs the package: its `bin` command appears in command position, a `node_modules//` file is run, or a bare package is preloaded with `-r`/`--require`/`--import`/`--loader` (including through `NODE_OPTIONS`). A peer also present in `dependencies` or `optionalDependencies` is judged by that concrete entry. `workspace:`/`catalog:` are allowed only when the pack lifecycle rewrites them; packed `bundleDependencies` and installable in-package `file:` sources are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | A release build also refuses a project with **no** release version at all (`AB4013`), so a published artifact never carries the `0.0.0-dev.` development fallback. A diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 5d33cde7e..2f405c19e 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -178,10 +178,10 @@ Four version axes are tracked separately and are expected to agree: The `host-install` proof level records all four and fails closed when any is missing or differs. `prepack` gates the first two plus normalized plugin metadata and host manifests (`AB7013`), and -refuses a `package.json` whose installed-dependency fields name 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 (`AB7014`) — or that a consumer's npm cannot -fetch from a registry (`AB7015`): a published plugin installs only what its packed files need. The +refuses a `package.json` whose installed-dependency fields name packages no packed declaration, +consumer install script, prebuilt `runtimeDependencies`, or framework process-dependency record +proves a consumer needs (`AB7014`) — or that a consumer's npm cannot fetch from a registry +(`AB7015`). A package merely inlined into a compiled bundle does not count as used. The [validation guide](../guide/distribution/validation.mdx) lists every source of evidence. Target rows also record an `adapterRevision` (monotonic, repository-owned) and an diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 6c6a4ffb1..561b2a45f 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -891,8 +891,9 @@ web: { (error);各消息形状见[诊断参考](../../reference/diagnostics.md)。 配置了 `web` 时,组合产物的 `agent-bundle.manifest.json` 会增加 `web` 节,即使没有 `src/cli/**` -命令也会输出 `bin/.mjs`。宿主页面装在该 bin 里——产物中没有 `web/` 目录。`AB6005` 像遍历 -其他每个可执行文件一样遍历它。 +命令也会输出 `bin/.mjs`。宿主页面装在该 bin 里——产物中没有 `web/` 目录。它的编译与其他 +每个可执行文件一样提供 external-module 证据,因此 `AB6005` 会拒绝 Node 内建模块、`pnpapi` 或已输出 +同级文件之外的任何 external。 从已构建产物或已安装插件根目录运行: diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 7b0b42f8e..2ea58ddbc 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -448,7 +448,9 @@ export default defineConfig({ 悄悄覆盖契约。保留的模块 specifier 也以同样方式受到保护:把 `agent-bundle/mcp-entry`、 `agent-bundle/meta` 或 `agent-bundle/mcp-apps` 外部化的逃生舱会让构建失败。外部化整体上也受到同样的 边界约束:`AB4725` 会拒绝静态可见、点名某个包的 `externals`,相对路径、函数形式以及 mutator 添加的 -externals 则由编译过程自身的证据判断(`AB6005`)。运行时路径引用也以同样方式得到保留:你的代码或 +externals 则由编译过程自身的证据判断(`AB6005`)。逃生舱还运行在编译器判定模块图之后,可以改写输出 +字节,因此该构建的编译证据记录会标记 `coverage.rewritable`,产物校验会完整解析每个已输出模块并解析 +其导入,而不是信任记录;没有逃生舱时,编译产物只做词法检查。运行时路径引用也以同样方式得到保留:你的代码或 生成入口中的 `new URL(…, import.meta.url)` 或 `new Worker(new URL(…))` 指向的是产物旁的一个文件,因此 不变量层会在逃生舱之后关闭打包器对 URL 与 worker 的静态资源处理,让该表达式原样进入产物。逃生舱定制的是 *代码如何编译*,绝不是*产物承诺了什么*。 diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 52b0e31b3..8959a1b0c 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -56,7 +56,8 @@ composite plugin root + agent-bundle.compile-evidence.json │ ▼ -build/validate-artifact*.ts / AB6005 / AB6039 +build/compiler.ts + build/external-policy.ts / AB6005 + + build/validate-artifact*.ts / AB6005 (residual), AB6039 / prepack AB7014, AB7015 ← Artifact layer ``` @@ -254,22 +255,23 @@ node 表面。 | 检查 | 模块 | 代码 | | --- | --- | --- | | 规范清单解析 + 文件摘要匹配 | `build/validate-artifact.ts` | `AB60xx` 系列;解析错误由 `parseArtifactManifest` 抛出 | +| 宿主包、包构建 `dist` 与 MCP App 视图的编译只保留获准的 external | `build/compiler.ts`、`build/external-policy.ts` | `AB6005` | | 编译证据记录对照清单 `bundle` 文件 | `build/compile-evidence.ts` 的 `compileEvidenceDiagnostics` | `AB6039` | -| 宿主包与包构建 `dist` 模块只导入相对/`file:` 说明符或 Node 内建模块 | `build/validate-artifact-modules.ts` | `AB6005` | +| 编译器看不见的已输出模块:任何位置的表达式 `import()`,以及框架未编译或 `tools` 逃生舱可能改写的 JavaScript 的导入与语法 | `build/validate-artifact-modules.ts` | `AB6005`(残余形态) | | Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | | 产物对输出根目录的所有权 | `build/validate-artifact.ts` | `AB6014` | | 打包的 tarball 包含清单中的每个文件,且没有未使用的安装依赖 | `build/pack-inventory.ts` | `AB7010`、`AB7014`、`AB7015` | -`AB6005` 遍历每个发出的 JavaScript 模块:`manifest.files` 中的宿主包 `.mjs` 行,以及 -包构建的 `dist` 捆绑(`dist/bin/*.js`、Flight worker、`lib` 入口)——在 `dist` 发布之前。 -裸包说明符会失败;只有 Node 内建模块(`node:module.isBuiltin`)可以保持外部。预构建 payload -(`kind: 'prebuilt'`)只做哈希锁定,不参与遍历。这与生成式插件编译器配置所强制的 -自包含规则相同(`build/rslib.ts` 的 `composeEntryLibConfig`:`autoExternal: false`、 +`AB6005` 读取每次宿主包、包构建 `dist` 与 MCP App 视图编译的证据。Rspack 保持的每个 +`ExternalModule` 都必须是 Node 内建模块、`pnpapi` 或已输出的同级文件;其他 external 都会失败。 +这就是 `build/compiler.ts` 与 `build/external-policy.ts` 强制的自包含规则;生成式插件编译器配置 +同样实施该规则(`build/rslib.ts` 的 `composeEntryLibConfig`:`autoExternal: false`、 `bundle: true`、没有面向作者的 `externals`)。 `AB7014` / `AB7015` 是 prepack 门禁:`package.json` 中的安装期依赖必须出现在打包后的 -JavaScript、声明文件、`imports` 或消费者侧安装脚本中(`AB7014`),并且消费者的 npm 必须 -能够抓取它(`AB7015`)。 +预构建 `runtimeDependencies`、打包后声明文件引用、消费者安装脚本,或框架目前为空的进程依赖记录中 +(`AB7014`),并且消费者的 npm 必须能够抓取它(`AB7015`)。内联进 bundle 的包不算已使用; +编译证据会记录诊断中点名的 `dist` bundle。 ### 复合根目录 @@ -382,8 +384,9 @@ CLI bin 的文件,只以 `files[]` 中一行的形式出现。 | 读取方 | 使用的字段 | 用途 | | --- | --- | --- | | `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `targets[]`,固定 `agentSkills` 与 `runtime.node` | +| `build/compiler.ts`、`build/external-policy.ts` | 宿主包、包构建 `dist` 与 MCP App 视图的 `CompileResult.externals` | `AB6005` 编译证据自包含检查 | | `build/compile-evidence.ts` | `files[]`(`bundle` 行) | `AB6039`:把持久化的编译证据记录对照文件表复核,且不解析 JavaScript | -| `build/validate-artifact-modules.ts` | `files[]`(`.mjs` 行)以及包构建的 `dist` | `AB6005` 导入图 | +| `build/validate-artifact-modules.ts` | `files[]`(`.js`/`.mjs` 行)加包构建 `dist`;编译证据记录证明的 `bundle` 行只做词法检查 | `AB6005` 残余遍历:表达式 `import()`、未编译或被逃生舱改写的模块 | | `build/validate-artifact-skills.ts` | `targets[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | | `build/validate-artifact-hooks.ts` | `targets[]`、钩子文件 | 钩子文档对照所选宿主 | | `build/validate-artifact-mcp.ts` | `targets[]`、MCP 文件 | MCP 文档对照所选宿主 | diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index e5b547d8c..7baf14af7 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -23,21 +23,26 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。已列入清单的编译证据记录若 与清单文件表不一致,会报告 `AB6039`。 -自包含性首先由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面 -以及每个包构建入口(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs` 与 `lib` 入口)。框架自有的 +自包含性由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面、 +每个包构建入口(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs` 与 `lib` 入口),以及每个 MCP App +视图。框架自有的 `ArtifactDependencyAuditPlugin` 会接入 `thisCompilation`,记录 Rspack 保持外部化的每个模块,服务则在 信任任何产物之前读取这份证据。只有 Node 内建模块(`node:fs`、`fs`)、`pnpapi` 以及同一产物已输出的同级 文件可以保持外部化;其他请求无论被 Rspack 输出成 `import`、`require` 还是其 `createRequire` shim,都会 失败。表达式请求(`import(expr)`、`require(expr)`)不在编译器视野之内:Rslib 的配置不解析它,而是原样保留, 因此它既不会被打包,也不算外部模块。 -输出模块遍历仍作为纵深防线留在编译证据之后。它把每个输出的 JavaScript 模块当作 ES 模块遍历,拒绝残留的 -不受支持导入、非字面量动态 import,以及输出树中缺失或逃逸到树外的目标;`dist` 中的发现以 -`dist/` 点名其文件。模块语法检查的深度取决于它的字节由谁产出。框架编译的模块(清单 kind 为 -`bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,拒绝未终止的字符串、模板、注释与正则以及 -不配对的花括号。框架没有编译的模块——被复制的消费者脚本或生成的安装器——会被完整解析;若一次构建的 -[`tools` 逃生口](../../reference/configuration.mdx#tools)可能改写了输出资源,该构建的每个 bundle 也会 -被完整解析。预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定;声明文件(`.d.ts`)不会被遍历。 +已输出模块的遍历仍留在这份编译器证据之后,只负责编译器看不见的部分。它把每个已输出的 `.js`/`.mjs` +模块按 ES 模块读取,并在其中任何一个——包括编译产物——里出现表达式 `import()` 时失败 +(`AB6005 has a non-literal dynamic import`)。被编译证据记录证明的模块——记录以相同字节覆盖的清单 +`bundle` 文件,且构建未使用 [`tools` 逃生舱](../../reference/configuration.mdx#tools)——只做词法 +语法检查:编译器已把它的字面量导入解析为已打包、内建或已输出的同级文件,遍历不会再次解析它们。 +其他所有模块都会被完整解析,导入也会对照清单解析——生成的 `install.mjs`、被复制的消费者脚本、 +没有记录的产物中的每个模块,以及逃生舱可能改写了输出资源的构建(记录中的 `coverage.rewritable`) +里的每个模块。这些发现就是 `AB6005` 的残余形态:不受支持或无效的说明符、缺失于或逃逸出输出树的目标、 +无效语法;`dist` 的发现以 `dist/` 命名文件。预构建负载(`kind: 'prebuilt'`)保持不透明、仅做哈希 +锁定,声明文件(`.d.ts`)不会被遍历。 + 路由图会在打包器运行之前守住同一份自包含性: 路由模块、布局或 provider——或它们之一通过相对导入触达的模块——若值导入了携带编译器的框架入口 (`agent-bundle`、`agent-bundle/api`、`agent-bundle/config`、`agent-bundle/eval`、`agent-bundle/rstest`、 @@ -210,10 +215,7 @@ npx agent-bundle prepack --root . --output artifact --json `dist/bin` 与各宿主包,因此已发布的插件不应安装任何东西:请把框架、`@agent-bundle/runtime`、`react`、 `zod` 以及其余技术栈都声明在 `devDependencies` 下。已编译的 bundle——无论是宿主包模块还是 `dist` bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及清单之前报告 `AB6005`。因此 -`dependencies` 只留给打包后的文件以其他方式有据可证地需要的内容——预构建 payload 模块的导入、打包后 -文件中未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用、打包后声明文件 -的引用、消费者侧安装脚本,或打包后的文件运行的 `bin` 命令——因为只有框架没有编译的 JavaScript 才仍然 -可能导入它。`--output` 是相对 +仅仅把包内联进 bundle 并不能证明消费者需要安装它。`--output` 是相对 `--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` 脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 @@ -227,15 +229,26 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 的 `tools.rsbuild.output.autoExternal`,以及静态可见、 点名某个包的字符串或对象 `externals` 条目。RegExp、函数形式、相对路径与 mutator 添加的 externals 则由编译过程 自身的证据判断:若 Rspack 在宿主包或 `dist` 的生成可执行文件中保持了内建模块、`pnpapi` 或已输出同级文件 -之外的任何 external,`AB6005` 就会失败。输出模块遍历仍留在这项编译期检查之后。通过 +之外的任何 external,`AB6005` 就会失败。通过 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化并不能绕过契约,也永远到不了 prepack -的清单检查。因此,`dependencies` 条目只留给打包后的文件 -有据可证地需要从已编译 bundle 之外获取的内容——预构建的 `.js`/`.mjs`/`.cjs` payload 模块导入的包(预构建 -文件对 `AB6005` 不透明,而 `AB7014` 只扫描这些扩展名,因此无扩展名的预构建模块不算任何证据),打包后的 -文件通过未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用加载的包, -消费者侧安装脚本运行的包,打包后的文件执行其 `bin` 的包,打包后的声明文件引用的包 -(`.d.ts` 输出不会被 `AB6005` 遍历),或预构建 payload 在 `runtimeDependencies` 中声明的包——`AB7014` 会报告不具备上述任何一种证据的已声明依赖,而 `AB7015` 会报告 -消费者的 npm 无法安装的依赖。 +的清单检查。 + +`AB7014` 只接受四类消费者运行时证据: + +1. 通过 `definePrebuilt` 声明的预构建 payload 上的 `runtimeDependencies`; +2. TypeScript 的 `preProcessFile` 从打包后声明文件读出的引用:import、export、import-equals + `require`、`import("…")` 类型、外部模块文件中的模块扩充,以及 + `/// `(连同其 `@types` 对应包);没有 import 的文件中的环境式 + `declare module` 不算证据,而 `#子路径` 会经由包的 `imports` 映射解析; +3. 消费者侧 `preinstall`、`install` 或 `postinstall` 脚本(包括其委托的脚本)点名该依赖,或运行其 + `bin` 命令;以及 +4. 框架的进程依赖记录;该记录目前为空。 + +仅仅内联进已编译 bundle 的包不算已使用。当这是唯一证据时,`AB7014` 会点名内联它的 `dist` bundle, +例如:`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` +请把仅构建期使用的包移到 `devDependencies`;编译后的 bundle 会内联其 import(`AB6005`)。仅当打包后的 +声明文件引用、消费者安装脚本点名或运行,或预构建 payload 在 `runtimeDependencies` +(`definePrebuilt`)中声明某个包时,才把它保留为运行时依赖。 | 代码 | 含义 | | --- | --- | @@ -243,14 +256,8 @@ 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` 判为失败,因此 `import` 证据只来自预构建 payload 模块以及其他框架没有编译的脚本,而 `require`、`createRequire` 与 `import.meta.resolve` 调用则可来自任何打包后的文件),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它,也没有任何预构建 payload 在 `runtimeDependencies` 中声明它(每个字段一条诊断;可选 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` 下。 | - -预构建 payload 的 `runtimeDependencies` 声明视为已使用,因为编译器从不打开 payload 文件。其余依赖证据直接读取自打包后的字节:npm 将发布的每个 `.js`/`.mjs`/`.cjs` 文件都会被词法分析出 `import` -说明符,并扫描字面量 `require("…")`、`require.resolve("…")` 与 `import.meta.resolve("…")` 调用(`createRequire(…)` 的绑定视同 `require`,即使导入时重命名了该工厂,直接调用的 `Module.createRequire(…)("…")` 与 `require("node:module").createRequire(…)("…")` 亦然;`path.resolve("…")` 不计入);每个打包后的 `.d.ts` 会被扫描其类型所引用的模块(`/// ` 指令同时计入该包及其 -`@types/*` 对应包)。说明符先解码字符串转义再归约为包名,忽略 Node 内建模块。打包代码作为可执行文件运行的依赖——字符串字面量正是其已安装清单所声明的某个 `bin` 命令,如 `spawnSync("tsc", ["--version"])`——视为已使用;`#子路径` 导入计入 `imports` 映射所指向的每个包;消费者侧 `preinstall`/`install`/`postinstall` 脚本(不含 `prepare`——npm 从不为已发布的 tarball 运行它)或其通过 `npm run`(取 `run` 之后的第一个位置参数;`run` 前后的选项,带值与否均被跳过,其后的词如 `npm run setup -- dormant` 中的 `dormant` 是该脚本的参数)或直接的 `npm test`/`start`/`stop`/`restart`(没有 `restart` 脚本时,`npm restart` 依次运行 `stop` 与 `start`)委托的任何脚本点名(或运行其 `bin` 命令;字符串形式的 `bin` 以已安装清单的名字命名——清单按 npm 的方式读取,重复键以最后一个为准——依赖未在本地安装或其清单不是 JSON 时以去掉作用域的包名代替)的依赖 -同样视为已使用。打包代码中的计算型 `import(表达式)` 或 `require(表达式)`(同样包括 `require.resolve`、`import.meta.resolve`、直接的 `createRequire(…)(…)` 或 `createRequire` 绑定)可能加载任何已声明的包,因此会整体撤回 `AB7014`;被 ESM 词法分析器拒绝的打包源码亦然,因为其中的 `import()` 调用无法被报告;把 `require` 当作值传递而非调用(`const load = require`、`fn(require)`)亦然,因为此后包可能以扫描看不到的名字被加载。注释中的提及只会保留某个依赖,绝不会报告它;`devDependencies` 永不检查, -`npm:` 别名在其目标是注册表说明符时才视为注册表说明符。 +| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段点名了消费者无需安装的包。必须有上列四类证据之一才算使用;已编译 bundle 内联该包不算。诊断会点名内联了这个未使用包的所有 `dist` bundle。对 `dependencies` 与 `optionalDependencies` 是 error,对 `peerDependencies` 是 warning;可选 peer 会跳过,`optionalDependencies` 中的同名项优先于 `dependencies`。 | +| `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 可以在没有它时继续;但消费者安装脚本需要该包时会升级为错误:其 `bin` 命令位于命令位置、运行了 `node_modules//` 文件,或通过 `-r`/`--require`/`--import`/`--loader`(包括 `NODE_OPTIONS`)预加载了裸包。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断。`workspace:`/`catalog:` 仅在打包生命周期会重写它们时允许;已打包的 `bundleDependencies` 与包内可安装的 `file:` 来源不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | 发布构建同样会拒绝**完全没有**发布版本的项目(`AB4013`),因此已发布的产物绝不会携带 `0.0.0-dev.` 这个开发期回退值。声明的 `plugin.version` 与 `package.json` 不一致时会 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index fc60b198c..4ed3f0331 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -159,9 +159,10 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 - **运行中的进程** —— 活跃的 MCP `initialize` 所报告的版本。 `host-install` 证明级别会记录全部四者,并在任何一项缺失或不一致时以失败告终。`prepack` 则为前两者,外加 -规范化后的插件元数据与宿主清单把关(`AB7013`),并拒绝这样的 `package.json`:安装期依赖字段中列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它, -没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它(`AB7014`)——或消费者的 npm 无法从注册表抓取它(`AB7015`): -已发布的插件只安装其打包文件所需的内容。[验证指南](../guide/distribution/validation.mdx)列出了每一种证据来源。 +规范化后的插件元数据与宿主清单把关(`AB7013`),并拒绝这样的 `package.json`:安装期依赖字段点名了 +打包后声明文件、消费者安装脚本、预构建 `runtimeDependencies` 或框架进程依赖记录都不能证明消费者需要的包 +(`AB7014`),或消费者的 npm 无法从注册表抓取的包(`AB7015`)。仅仅内联进已编译 bundle 的包不算已使用。 +[验证指南](../guide/distribution/validation.mdx)列出了每一种证据来源。 target 行还记录 `adapterRevision`(单调递增,仓库自有)与 `observedVersion`(记录该能力证据时所观察到的 宿主版本)。两者都不做哈希:Git 已经为仓库自有内容做了版本管理,在仓库内部再哈希一遍会让每次编辑表格 From 31629f6d19ae831795d2f8aea6458a9d3c959134 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:21:32 +0000 Subject: [PATCH 2/8] Merge the duplicate compile-evidence import --- packages/agent-bundle/tests/artifact-validator.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 60eb6a549..e82af698f 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -22,14 +22,10 @@ import { externalPolicy, serializeCompileEvidenceRecord, unobservedLoadForms, -} from '../src/build/compile-evidence.ts'; -import { composeProjections } from '../src/build/compose.ts'; -import { - compileEvidenceFileName, - serializeCompileEvidenceRecord, type CompileEvidenceAsset, type CompileEvidenceExternal, } from '../src/build/compile-evidence.ts'; +import { composeProjections } from '../src/build/compose.ts'; import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; import { digest, sha256Hex } from '../src/core/digest.ts'; From 4dbb7a80c4e3ea5ae32f27ff4036da42c8120a5c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:45:38 +0000 Subject: [PATCH 3/8] Address reviewer pass 2: real-build provenPaths test, three AB7014 sources, changeset scope --- .changeset/619-evidence-ab7014.md | 2 +- AGENTS.md | 11 ++-- docs/diagnostics.md | 11 ++-- packages/agent-bundle/src/build/mcp-apps.ts | 2 +- packages/agent-bundle/src/build/rslib.ts | 37 +++++++------ packages/agent-bundle/tests/build.test.ts | 52 +++++++++++++++++++ .../agent-bundle/tests/package-build.test.ts | 14 ++++- .../docs/en/guide/concepts/architecture.mdx | 3 +- .../docs/en/guide/distribution/validation.mdx | 8 +-- .../docs/en/reference/targets-artifacts.mdx | 3 +- .../docs/zh/guide/concepts/architecture.mdx | 2 +- .../docs/zh/guide/distribution/validation.mdx | 9 ++-- .../docs/zh/reference/targets-artifacts.mdx | 2 +- 13 files changed, 113 insertions(+), 43 deletions(-) diff --git a/.changeset/619-evidence-ab7014.md b/.changeset/619-evidence-ab7014.md index 5b2bef7d4..92b5570b1 100644 --- a/.changeset/619-evidence-ab7014.md +++ b/.changeset/619-evidence-ab7014.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed for syntax and reported only for a non-literal `import()`; JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. `ValidateArtifactOptions.bundleSyntaxCheck` is removed, and a reserved specifier kept external by a function-form `tools` external now fails as compile-time `AB6005` instead of a residual-import error. (#634) +Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed for syntax and reported only for a non-literal `import()`; JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. A reserved specifier kept external by a function-form `tools` external now fails as compile-time `AB6005` instead of a residual-import error. (#634) diff --git a/AGENTS.md b/AGENTS.md index 6c14dac86..4102c1849 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,12 +91,13 @@ 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 one of four `AB7014` + legitimately puts a package under `dependencies` is one of three `AB7014` evidence sources: `runtimeDependencies` on a prebuilt payload - (`definePrebuilt`), a packed declaration reference, a consumer-side install - script that names or runs it, or the framework's process-dependency record - (empty today). `AB7015` additionally requires a specifier a consumer's npm - can install. + (`definePrebuilt`), a packed declaration reference, or a consumer-side + install script that names or runs it. The framework's own runtime modules + load no package at run time (`generated-module-evidence.test.ts`), so there + is no framework process-dependency record to read. `AB7015` additionally + requires a specifier a consumer's npm can install. - Proof is compiler evidence, the persisted record, and packed-process tests: each compilation's own external and module records are judged before emission, `build` writes them beside the emitted files as diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 03a1de6fb..1b6f7f228 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -430,7 +430,7 @@ Validation happens at three moments, all fail-closed: | `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the four evidence sources follow 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. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer 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`), or 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 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; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). 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). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. 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. | -`AB7014` counts exactly four sources of evidence that a consumer needs a +`AB7014` counts exactly three sources of evidence that a consumer needs a declared package: 1. `runtimeDependencies` on a prebuilt payload (`definePrebuilt`); @@ -444,10 +444,11 @@ declared package: specifiers name nothing; 3. the consumer-side `preinstall`, `install`, or `postinstall` script grammar, including scripts reached through package-manager run and direct script - commands, naming the package or running one of its `bin` commands; and -4. the framework's process-dependency record, which is empty because no - framework runtime module bundled into a generated executable loads a - package at run time. + commands, naming the package or running one of its `bin` commands. + +The framework's own runtime modules bundled into a generated executable load +no package at run time (`generated-module-evidence.test.ts` holds that), so +there is no framework process-dependency record to read as a fourth source. A package the compiler bundled is not used. When one is otherwise unused, the diagnostic names the `dist` bundles that inlined it. Expression requests diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 4ed3439f5..fea917179 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -574,7 +574,7 @@ export const compileMcpApps = async ( const compileResults = Object.freeze(compiledApps.map((app, index) => compileResultOf(viewEvidence[index]!, { asset: { path: `mcp-apps/${app.name}.html`, sourceInputs: app.sourceInputs }, cwd: options.cwd, - dependencyRoots: [], + dependencyRoots: new Map(), emittedAssets, }))); /** diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index b88100960..3d581d974 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -357,9 +357,15 @@ const canonicalProjectRoot = async (cwd: string): Promise => { * inside the project (`/packages/dep`, `file:./vendor/dep`) is still * a dependency and is excluded like any other. */ -const declaredDependencyRoots = async (cwd: string): Promise => { +/** + * Real root → package name of every declared dependency, transitively through + * workspace-linked ones. Rspack records a symlinked dependency's modules at + * their real path, where no `node_modules` segment names the package, so the + * name is kept from the declaration that reached it. + */ +const declaredDependencyRoots = async (cwd: string): Promise> => { const projectRoot = await canonicalProjectRoot(cwd); - const roots = new Set(); + const roots = new Map(); const visited = new Set(); const visit = async (packageRoot: string, fields: readonly string[]): Promise => { if (visited.has(packageRoot)) return; @@ -371,12 +377,12 @@ const declaredDependencyRoots = async (cwd: string): Promise if (manifestPath === undefined) return; const root = await realpath(dirname(manifestPath)); if (root === projectRoot) return; - roots.add(root); + if (!roots.has(root)) roots.set(root, name); if (!isBeneathNodeModules(root)) await visit(root, runtimeDependencyFields); })); }; await visit(projectRoot, projectDependencyFields); - return Object.freeze([...roots].sort((left, right) => left.localeCompare(right))); + return new Map([...roots].sort(([left], [right]) => left.localeCompare(right))); }; interface InspectedBundlerConfig { @@ -726,10 +732,14 @@ const assertDistinctLibIds = (entries: readonly RslibEntry[]): void => { } }; -const packageNameOfResource = (resource: string): string | undefined => { +/** The package a module belongs to: named by its `node_modules` segment, or by the declared dependency root that contains it. */ +const packageNameOfResource = (resource: string, dependencyRoots: ReadonlyMap): string | undefined => { const segments = resource.replaceAll('\\', '/').split('/'); const nodeModules = segments.lastIndexOf('node_modules'); - if (nodeModules === -1) return undefined; + if (nodeModules === -1) { + const root = [...dependencyRoots.keys()].find((candidate) => isInsideOrEqual(candidate, resource)); + return root === undefined ? undefined : dependencyRoots.get(root); + } const name = segments[nodeModules + 1]; if (name === undefined) return undefined; return name.startsWith('@') && segments[nodeModules + 2] !== undefined @@ -743,7 +753,7 @@ export const compileResultOf = ( options: { readonly asset: AssetIR; readonly cwd: string; - readonly dependencyRoots: readonly string[]; + readonly dependencyRoots: ReadonlyMap; readonly emittedAssets: ReadonlySet; }, ): CompileResult => { @@ -760,7 +770,7 @@ export const compileResultOf = ( userRequest: external.userRequest, }))), modules: Object.freeze(record.modules.map((module): ModuleIR => { - const packageName = module.resource === undefined ? undefined : packageNameOfResource(module.resource); + const packageName = module.resource === undefined ? undefined : packageNameOfResource(module.resource, options.dependencyRoots); return { asset, identifier: module.identifier, @@ -775,15 +785,10 @@ export const compileResultOf = ( const moduleKindOf = ( resource: string | undefined, cwd: string, - dependencyRoots: readonly string[], + dependencyRoots: ReadonlyMap, ): ModuleIR['kind'] => { if (resource !== undefined && isInsideOrEqual(generatedModulesRoot(cwd), resource)) return 'generated'; - if ( - resource !== undefined - && (packageNameOfResource(resource) !== undefined || dependencyRoots.some((root) => isInsideOrEqual(root, resource))) - ) { - return 'dependency'; - } + if (resource !== undefined && packageNameOfResource(resource, dependencyRoots) !== undefined) return 'dependency'; return 'authored'; }; @@ -851,7 +856,7 @@ export const buildRslibSurfaces = async ( // Generated wrapper/registry modules are virtual, but they still // surface in stats as modules under this reserved namespace. resolve(generatedModulesRoot(options.cwd)), - ...dependencyRoots, + ...dependencyRoots.keys(), ], projectRoot: options.cwd, stats: result.stats, diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 41a42fe6b..04e2f3ddf 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -20,6 +20,7 @@ import type { AgentBundleMeta } from '../src/meta.ts'; import { publishArtifact } from '../src/build/emit.ts'; import type { TargetHookContract } from '../src/adapters/hook-contract.ts'; import { parseArtifactManifest, serializeArtifactManifest } from '../src/build/manifest.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; import { createProjectContext } from '../src/core/project-context.ts'; @@ -1505,6 +1506,57 @@ it('parses emitted bundles in full when a tools hatch could have rewritten them' } }, 20_000); +it('lexes a compiled bundle the real build\'s evidence record proves and parses it once the record is rewritable', async () => { + // The same bytes, judged twice: covered by a clean record from a build + // without a hatch, the walk trusts the compiler and only lexes; with + // `coverage.rewritable` set, it parses in full and the syntax error surfaces. + const project = await createProject(); + try { + await build({ + model: modelFor(project), + outputRoot: project.outputRoot, + projectRoot: project.root, + registry: new TargetRegistry().register((await import('../src/adapters/portable.ts')).portableAdapter, { default: true }), + }); + const manifestPath = join(project.outputRoot, 'agent-bundle.manifest.json'); + const recordPath = join(project.outputRoot, compileEvidenceFileName); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + readonly files: Array<{ bytes: number; path: string; sha256: string }>; + }; + const record = JSON.parse(await readFile(recordPath, 'utf8')) as { + readonly assets: Array<{ path: string; sha256: string }>; + readonly coverage: { rewritable: boolean }; + }; + const entry = (path: string) => manifest.files.find((file) => file.path === path) + ?? (() => { throw new Error(`Expected manifest entry for ${path}.`); })(); + const rewrite = async (path: string, contents: string): Promise => { + await writeFile(join(project.outputRoot, path), contents); + Object.assign(entry(path), { bytes: Buffer.byteLength(contents), sha256: sha256Hex(contents) }); + }; + const writeRecord = async (): Promise => { + await rewrite(compileEvidenceFileName, `${JSON.stringify(record)}\n`); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + }; + + const broken = 'export const broken = ;\n'; + await rewrite('scripts/greeting.mjs', broken); + record.assets.find((asset) => asset.path === 'scripts/greeting.mjs')!.sha256 = sha256Hex(broken); + await writeRecord(); + expect((await validateArtifact({ artifactRoot: project.outputRoot })).filter((diagnostic) => diagnostic.code === 'AB6005')) + .toEqual([]); + + record.coverage.rewritable = true; + await writeRecord(); + expect((await validateArtifact({ artifactRoot: project.outputRoot })).filter((diagnostic) => diagnostic.code === 'AB6005')) + .toEqual([expect.objectContaining({ + generatedPath: 'scripts/greeting.mjs', + message: 'Generated JavaScript import from "scripts/greeting.mjs" has invalid syntax.', + })]); + } finally { + await cleanupProject(project); + } +}, 20_000); + it('keeps sibling staged outputs alive under a tools hatch that asks to clean the output root', async () => { const { entry, root } = await reservedSpecifierProject(); try { diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index c6d6f7bc9..df8b047af 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -37,6 +37,10 @@ const fixtureRoot = async (files: Readonly>): Promise> => ({ '};', '', ].join('\n'), - 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', + 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"dependencies":{"linked-dep":"1.0.0"}}\n', 'node_modules/evidence-package/index.js': 'globalThis.__evidencePackageLoaded = true;\n', 'node_modules/evidence-package/package.json': '{"name":"evidence-package","type":"module","version":"1.0.0"}\n', + // Linked into node_modules below, as a package manager links a workspace dependency. + 'packages/linked-dep/index.js': 'globalThis.__linkedDepLoaded = true;\n', + 'packages/linked-dep/package.json': '{"name":"linked-dep","type":"module","version":"1.0.0"}\n', 'tsconfig.json': JSON.stringify({ compilerOptions: { module: 'esnext', @@ -63,6 +70,7 @@ const conventionFixture = (): Readonly> => ({ }), 'src/cli.ts': [ "import 'evidence-package';", + "import 'linked-dep';", '', 'export const main = async (argv: readonly string[]): Promise => {', " process.stdout.write(`ran:${argv.join(',')}\\n`);", @@ -146,9 +154,11 @@ describe('framework-owned package build', () => { ); expect(packageBuild!.evidence.assets.flatMap((asset) => asset.externals) .every((external) => external.kind === 'builtin')).toBe(true); + // A package under `node_modules` is named by its path; a workspace-linked one, which Rspack records at its + // real path, by the declaration that reached it. expect(packageBuild!.evidence.assets .find((asset) => asset.path === 'dist/bin/package-build-fixture.js')?.packages) - .toContain('evidence-package'); + .toEqual(['evidence-package', 'linked-dep']); for (const file of packageBuild!.files) { expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); } diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 9124fcf40..ba642c503 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -306,8 +306,7 @@ That is the self-containment rule enforced by `build/compiler.ts` and `AB7014` / `AB7015` are the prepack gate: a `package.json` install-time dependency must be justified by prebuilt `runtimeDependencies`, a packed -declaration reference, a consumer install script, or the framework's -currently empty process-dependency record (`AB7014`), and a consumer's npm +declaration reference, or a consumer install script (`AB7014`), and a consumer's npm must be able to fetch it (`AB7015`). A package inlined into a bundle is not used; compile evidence records the `dist` bundles named in the diagnostic. diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index aff3c4dac..49e79273f 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -278,8 +278,10 @@ not a way around the contract and never reaches the prepack inventory. `declare module` in a file with no imports counts for nothing, while a `#subpath` follows the package `imports` map; 3. a consumer-side `preinstall`, `install`, or `postinstall` script, including delegated scripts, - that names the dependency or runs one of its `bin` commands; and -4. the framework's process-dependency record, which is currently empty. + that names the dependency or runs one of its `bin` commands. + +The framework's own runtime modules load no package at run time, so there is no framework +process-dependency record to read as a fourth source. A package merely inlined into a compiled bundle is not used. When that is the only evidence, `AB7014` names the `dist` bundles that inlined it, for example: @@ -294,7 +296,7 @@ names or runs, or a prebuilt payload declares in `runtimeDependencies` (`defineP | `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 a consumer never needs installed. Use requires one of the four evidence sources above; compiled bundles that inline a package do not count. The diagnostic names any `dist` bundles that inlined an otherwise unused package. It is an error for `dependencies` and `optionalDependencies`, and a warning for `peerDependencies`; optional peers are skipped, and an `optionalDependencies` entry supersedes the same name under `dependencies`. | +| `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages a consumer never needs installed. Use requires one of the three evidence sources above; compiled bundles that inline a package do not count. The diagnostic names any `dist` bundles that inlined an otherwise unused package. It is an error for `dependencies` and `optionalDependencies`, and a warning for `peerDependencies`; optional peers are skipped, and an `optionalDependencies` entry supersedes the same name under `dependencies`. | | `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 because npm can continue without it, but escalates to an error when an install script needs the package: its `bin` command appears in command position, a `node_modules//` file is run, or a bare package is preloaded with `-r`/`--require`/`--import`/`--loader` (including through `NODE_OPTIONS`). A peer also present in `dependencies` or `optionalDependencies` is judged by that concrete entry. `workspace:`/`catalog:` are allowed only when the pack lifecycle rewrites them; packed `bundleDependencies` and installable in-package `file:` sources are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | A release build also refuses a project with **no** release version at all (`AB4013`), so a diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 2f405c19e..22b2f02e9 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -179,8 +179,7 @@ Four version axes are tracked separately and are expected to agree: The `host-install` proof level records all four and fails closed when any is missing or differs. `prepack` gates the first two plus normalized plugin metadata and host manifests (`AB7013`), and refuses a `package.json` whose installed-dependency fields name packages no packed declaration, -consumer install script, prebuilt `runtimeDependencies`, or framework process-dependency record -proves a consumer needs (`AB7014`) — or that a consumer's npm cannot fetch from a registry +consumer install script, or prebuilt `runtimeDependencies` proves a consumer needs (`AB7014`) — or that a consumer's npm cannot fetch from a registry (`AB7015`). A package merely inlined into a compiled bundle does not count as used. The [validation guide](../guide/distribution/validation.mdx) lists every source of evidence. diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 8959a1b0c..75dc65f45 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -269,7 +269,7 @@ node 表面。 `bundle: true`、没有面向作者的 `externals`)。 `AB7014` / `AB7015` 是 prepack 门禁:`package.json` 中的安装期依赖必须出现在打包后的 -预构建 `runtimeDependencies`、打包后声明文件引用、消费者安装脚本,或框架目前为空的进程依赖记录中 +预构建 `runtimeDependencies`、打包后声明文件引用或消费者安装脚本中 (`AB7014`),并且消费者的 npm 必须能够抓取它(`AB7015`)。内联进 bundle 的包不算已使用; 编译证据会记录诊断中点名的 `dist` bundle。 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 7baf14af7..36540b50e 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -233,7 +233,7 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 [`tools` 逃生舱](../../reference/configuration.mdx#tools)保持外部化并不能绕过契约,也永远到不了 prepack 的清单检查。 -`AB7014` 只接受四类消费者运行时证据: +`AB7014` 只接受三类消费者运行时证据: 1. 通过 `definePrebuilt` 声明的预构建 payload 上的 `runtimeDependencies`; 2. TypeScript 的 `preProcessFile` 从打包后声明文件读出的引用:import、export、import-equals @@ -241,8 +241,9 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 `/// `(连同其 `@types` 对应包);没有 import 的文件中的环境式 `declare module` 不算证据,而 `#子路径` 会经由包的 `imports` 映射解析; 3. 消费者侧 `preinstall`、`install` 或 `postinstall` 脚本(包括其委托的脚本)点名该依赖,或运行其 - `bin` 命令;以及 -4. 框架的进程依赖记录;该记录目前为空。 + `bin` 命令。 + +框架自身的运行时模块在运行时不加载任何包,因此不存在可作为第四类证据读取的框架进程依赖记录。 仅仅内联进已编译 bundle 的包不算已使用。当这是唯一证据时,`AB7014` 会点名内联它的 `dist` bundle, 例如:`The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing.` @@ -256,7 +257,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` 字段点名了消费者无需安装的包。必须有上列四类证据之一才算使用;已编译 bundle 内联该包不算。诊断会点名内联了这个未使用包的所有 `dist` bundle。对 `dependencies` 与 `optionalDependencies` 是 error,对 `peerDependencies` 是 warning;可选 peer 会跳过,`optionalDependencies` 中的同名项优先于 `dependencies`。 | +| `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段点名了消费者无需安装的包。必须有上列三类证据之一才算使用;已编译 bundle 内联该包不算。诊断会点名内联了这个未使用包的所有 `dist` bundle。对 `dependencies` 与 `optionalDependencies` 是 error,对 `peerDependencies` 是 warning;可选 peer 会跳过,`optionalDependencies` 中的同名项优先于 `dependencies`。 | | `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 可以在没有它时继续;但消费者安装脚本需要该包时会升级为错误:其 `bin` 命令位于命令位置、运行了 `node_modules//` 文件,或通过 `-r`/`--require`/`--import`/`--loader`(包括 `NODE_OPTIONS`)预加载了裸包。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断。`workspace:`/`catalog:` 仅在打包生命周期会重写它们时允许;已打包的 `bundleDependencies` 与包内可安装的 `file:` 来源不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | 发布构建同样会拒绝**完全没有**发布版本的项目(`AB4013`),因此已发布的产物绝不会携带 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 4ed3f0331..11a1af10d 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -160,7 +160,7 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 `host-install` 证明级别会记录全部四者,并在任何一项缺失或不一致时以失败告终。`prepack` 则为前两者,外加 规范化后的插件元数据与宿主清单把关(`AB7013`),并拒绝这样的 `package.json`:安装期依赖字段点名了 -打包后声明文件、消费者安装脚本、预构建 `runtimeDependencies` 或框架进程依赖记录都不能证明消费者需要的包 +打包后声明文件、消费者安装脚本或预构建 `runtimeDependencies` 都不能证明消费者需要的包 (`AB7014`),或消费者的 npm 无法从注册表抓取的包(`AB7015`)。仅仅内联进已编译 bundle 的包不算已使用。 [验证指南](../guide/distribution/validation.mdx)列出了每一种证据来源。 From dc0ba12c14df53b4cd9b6bec74eea46788ca4842 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:55:33 +0000 Subject: [PATCH 4/8] Attribute nested linked dependencies to the deepest declared root; three AB7014 sources everywhere --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/build/rslib.ts | 7 +++++-- .../agent-bundle/tests/package-build.test.ts | 17 +++++++++++++---- .../docs/en/guide/distribution/validation.mdx | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 1b6f7f228..63475d4fd 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -427,7 +427,7 @@ 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 a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the four evidence sources follow 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. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer 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 a consumer never needs installed: `package.json names packages a consumer never needs installed: no packed declaration file references them, no consumer-side install script names or runs them, and no prebuilt payload declares them in runtimeDependencies: "a", "b".` (one diagnostic per field; the three evidence sources follow 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. A package the compiler bundled is not used; when the build inlined any of the field's names, the diagnostic adds `The build inlined "a" into dist/bin/x.js and dist/index.js; every consumer installs them for nothing.`, otherwise `Nothing packed reaches them at runtime; every consumer installs them for nothing.` Recovery is `Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a runtime dependency only for what a packed declaration file references, a consumer install script names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).` For `peerDependencies` the diagnostic is a warning: a required peer 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`), or 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 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; a relative preload names no package): npm continues past the failed fetch, then the script fails on the missing command or module. The scripts read are `preinstall`, `install`, and `postinstall` and every script they reach through `npm run ` (also `pnpm`/`yarn`/`bun run` and npm's `run-script`/`rum`/`urn` aliases; the script is the first positional after `run`, options before or after `run` skipped with their values — `npm --prefix . run setup`, `npm run -w pkg setup`, `pnpm --filter pkg run setup`, `npm run -- setup` — and every later word an argument of that script, so `npm run setup -- dormant` and `npm run setup dormant` run `setup` alone; shell quotes and backslash escapes resolved (`npm run "setup"`) and `&&`/`||`/`;`/`|`/`&`/newline split off; the script is visited with its `pre`/`post` hooks) or through npm's direct script commands (`npm test`, `t`, `tst`, `start`, `stop`, `restart`, each running the script of that name with its hooks; `npm test foo` runs `test` alone; `npm restart` without a `restart` script runs `stop` then `start`, each with its hooks, inside `prerestart`/`postrestart`). 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). The inline program of a `node -e` command and the JavaScript files a script runs are not read: what they load is not evidence here. 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. | `AB7014` counts exactly three sources of evidence that a consumer needs a diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 3d581d974..c6f5b3581 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -732,12 +732,15 @@ const assertDistinctLibIds = (entries: readonly RslibEntry[]): void => { } }; -/** The package a module belongs to: named by its `node_modules` segment, or by the declared dependency root that contains it. */ +/** The package a module belongs to: named by its `node_modules` segment, or by the deepest declared dependency root that contains it. */ const packageNameOfResource = (resource: string, dependencyRoots: ReadonlyMap): string | undefined => { const segments = resource.replaceAll('\\', '/').split('/'); const nodeModules = segments.lastIndexOf('node_modules'); if (nodeModules === -1) { - const root = [...dependencyRoots.keys()].find((candidate) => isInsideOrEqual(candidate, resource)); + let root: string | undefined; + for (const candidate of dependencyRoots.keys()) { + if (isInsideOrEqual(candidate, resource) && (root === undefined || candidate.length > root.length)) root = candidate; + } return root === undefined ? undefined : dependencyRoots.get(root); } const name = segments[nodeModules + 1]; diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index df8b047af..8f0619867 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -40,6 +40,11 @@ const fixtureRoot = async (files: Readonly>): Promise> => ({ 'node_modules/evidence-package/index.js': 'globalThis.__evidencePackageLoaded = true;\n', 'node_modules/evidence-package/package.json': '{"name":"evidence-package","type":"module","version":"1.0.0"}\n', // Linked into node_modules below, as a package manager links a workspace dependency. - 'packages/linked-dep/index.js': 'globalThis.__linkedDepLoaded = true;\n', - 'packages/linked-dep/package.json': '{"name":"linked-dep","type":"module","version":"1.0.0"}\n', + 'packages/linked-dep/index.js': "import 'linked-nested';\nglobalThis.__linkedDepLoaded = true;\n", + 'packages/linked-dep/package.json': + '{"name":"linked-dep","type":"module","version":"1.0.0","dependencies":{"linked-nested":"1.0.0"}}\n', + // A linked dependency's own linked dependency, whose real root sits inside the parent's. + 'packages/linked-dep/vendor/linked-nested/index.js': 'globalThis.__linkedNestedLoaded = true;\n', + 'packages/linked-dep/vendor/linked-nested/package.json': '{"name":"linked-nested","type":"module","version":"1.0.0"}\n', 'tsconfig.json': JSON.stringify({ compilerOptions: { module: 'esnext', @@ -155,10 +164,10 @@ describe('framework-owned package build', () => { expect(packageBuild!.evidence.assets.flatMap((asset) => asset.externals) .every((external) => external.kind === 'builtin')).toBe(true); // A package under `node_modules` is named by its path; a workspace-linked one, which Rspack records at its - // real path, by the declaration that reached it. + // real path, by the declaration that reached it — the deepest such root when one sits inside another. expect(packageBuild!.evidence.assets .find((asset) => asset.path === 'dist/bin/package-build-fixture.js')?.packages) - .toEqual(['evidence-package', 'linked-dep']); + .toEqual(['evidence-package', 'linked-dep', 'linked-nested']); for (const file of packageBuild!.files) { expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); } diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 49e79273f..d564804e3 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -269,7 +269,7 @@ or `dist` when Rspack kept anything except a built-in, `pnpapi`, or an emitted s An external kept through the [`tools` hatch](../../reference/configuration.mdx#tools) is therefore not a way around the contract and never reaches the prepack inventory. -`AB7014` accepts exactly four kinds of consumer-runtime evidence: +`AB7014` accepts exactly three kinds of consumer-runtime evidence: 1. `runtimeDependencies` on a prebuilt payload declared with `definePrebuilt`; 2. a packed declaration file reference, as TypeScript's `preProcessFile` reads it: imports, From 2b3f21e6c63797bd5937a45f04e70145278141fa Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:18:17 +0000 Subject: [PATCH 5/8] Hold a proven bundle's literal imports to the record: an ignored (rspackIgnore/webpackIgnore) import fails AB6005 --- .changeset/619-evidence-ab7014.md | 2 +- AGENTS.md | 11 +- docs/diagnostics.md | 10 +- docs/entry-conventions.md | 12 ++- .../src/build/compile-evidence.ts | 16 +++ .../agent-bundle/src/build/package-build.ts | 32 +++--- .../src/build/validate-artifact-modules.ts | 39 ++++--- .../src/build/validate-artifact.ts | 18 ++-- packages/agent-bundle/tests/build.test.ts | 102 ++++++++++++++++++ .../agent-bundle/tests/package-build.test.ts | 85 +++++++++++++++ .../en/guide/authoring/package-entries.mdx | 3 +- .../docs/en/guide/concepts/architecture.mdx | 4 +- .../docs/en/guide/distribution/validation.mdx | 10 +- .../zh/guide/authoring/package-entries.mdx | 3 +- .../docs/zh/guide/concepts/architecture.mdx | 4 +- .../docs/zh/guide/distribution/validation.mdx | 7 +- 16 files changed, 298 insertions(+), 60 deletions(-) diff --git a/.changeset/619-evidence-ab7014.md b/.changeset/619-evidence-ab7014.md index 92b5570b1..0abdb89be 100644 --- a/.changeset/619-evidence-ab7014.md +++ b/.changeset/619-evidence-ab7014.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed for syntax and reported only for a non-literal `import()`; JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. A reserved specifier kept external by a function-form `tools` external now fails as compile-time `AB6005` instead of a residual-import error. (#634) +Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed, not parsed, and reported for a non-literal `import()` and for any literal import that is neither a Node built-in nor one of the file's recorded externals — an import the build was told to ignore (`rspackIgnore`/`webpackIgnore`); JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. A reserved specifier kept external by a function-form `tools` external now fails as compile-time `AB6005` instead of a residual-import error. (#634) diff --git a/AGENTS.md b/AGENTS.md index 4102c1849..8fcb494bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,9 +114,14 @@ `import.meta.resolve(…)` verbatim — no module, no external, no warning), JavaScript the framework did not compile (`install.mjs`, copied scripts), and every module of a build with a `tools` hatch (`coverage.rewritable`). - A compiled module the record proves is lexed for syntax and not - import-resolved; the walk is not a second self-containment check and must - not grow one. + A compiled module the record proves is lexed, not parsed, and each literal + import it still carries is held to the record: a Node built-in or a + recorded external passes, anything else is an import the build was told + to ignore (`rspackIgnore`/`webpackIgnore` — Rspack leaves it verbatim with + no module, external, or warning) and fails `AB6005`. A matching digest + proves the bytes are the compiler's, never that every import in them was + resolved. The walk is not a second self-containment check and must not + grow one: it resolves nothing the record already judged. ## Documentation site diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 63475d4fd..d49539bb1 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -454,9 +454,11 @@ A package the compiler bundled is not used. When one is otherwise unused, the diagnostic names the `dist` bundles that inlined it. Expression requests (`import(expr)`, `require(expr)`) remain outside the compiler's view; the emitted-module walk still fails a compiled module on an expression `import()` -(`AB6005 has a non-literal dynamic import`), while `require(expr)`, -`require.resolve(…)`, `createRequire(…)(…)`, and `import.meta.resolve(…)` -are not imports and are outside both. +(`AB6005 has a non-literal dynamic import`) and on a literal import the +build was told to ignore (`rspackIgnore`/`webpackIgnore`; `AB6005 loads +"", which the compiler neither bundled nor recorded as an external`), +while `require(expr)`, `require.resolve(…)`, `createRequire(…)(…)`, and +`import.meta.resolve(…)` are not imports and are outside both. ## Declaration generation (`AB4716`) @@ -1832,7 +1834,7 @@ therefore does not prove the absence of such a load. | `AB6001` | error | `Artifact manifest is not a strict canonical manifest.` — `agent-bundle.manifest.json` does not parse as a strict canonical artifact manifest. `Artifact manifest changed during validation.` — its bytes or identity differ between the first read and the re-read after validation. | Regenerate the strict canonical manifest without concurrent writes, then rerun validation. | | `AB6002`–`AB6003` | error | Reserved: both codes are declared in the artifact diagnostic registry, but no validator emits either today. | `AB6002`: Rebuild the artifact from complete project source, then rerun validation. `AB6003`: Rebuild the artifact with canonical generated output, then rerun validation. | | `AB6004` | error | `Artifact files do not match the manifest.` — the regular files on disk differ from the manifest file table (a path, byte length, mode, or SHA-256; a missing or unmanifested file). `Artifact file changed during validation: "".` — a file differed between the initial and final inspection, or between a validated staging tree and its re-check after `build` renamed it into place. `Artifact file table changed during validation.` — the final inspection could not be taken. | Rebuild the artifact so its file table and contents match the manifest. | -| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external. MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. Residual walk forms, `Generated JavaScript import from "" .`, reported only for what the compiler cannot see: `has a non-literal dynamic import` for an expression `import()` in any emitted module, compiled ones included, since Rslib's profile leaves that form verbatim; and, for a module the compile evidence record does not prove — JavaScript the framework did not compile (`install.mjs`, a copied script), every module of an artifact without a record, and every module of a build whose `tools` hatch may have rewritten the emitted bytes (`coverage.rewritable`) — `has invalid syntax` from a full parse, `uses unsupported specifier` or `uses invalid specifier` for a bare or malformed import, `cannot be read`, and the relative-target findings `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`. A compiled module the record covers with the same bytes is lexed for syntax and its literal imports are not resolved again — the compiler resolved them (bundled, built-in, or an emitted sibling). Prebuilt payloads are not walked; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | +| `AB6005` | error | Primary compile-time form: `Compiled module "" keeps "" external () from ; a generated executable bundles everything but Node built-ins.` `` is the run-time load target; when an object-map external redirected the authored specifier, `, imported as "",` follows the type, a relative target that names no emitted asset of the artifact (or escapes it) ends `; it names no module emitted by this artifact.` instead, and a request kept under an external type that does not load a module (`var`, `global`, `this`, `window`, `assign`, `umd`, `amd`, `system`, `jsonp`, `promise`, `script`, …) — even a Node built-in — ends `; external type reads a variable instead of loading a module.` — the compiler service lowered a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) and Rspack kept something other than a Node built-in, `pnpapi`, or an emitted sibling of that artifact external, whatever spelling Rspack emitted (`import`, `require`, or its `createRequire` shim); `generatedPath` names the asset. An expression request (`import(expr)`, `require(expr)`) is outside the compiler's view: Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external; so is a literal import marked `rspackIgnore`/`webpackIgnore`, which Rspack leaves verbatim with no module, external, or warning. The emitted-module walk reports both in a compiled module the record proves: `Generated JavaScript import from "" has a non-literal dynamic import.` and `Generated JavaScript import from "" loads "", which the compiler neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.` (a literal request that is neither a Node built-in nor one of the record's externals for that file). MCP App view form (the `, imported as` clause applies to both forms): `Compiled MCP App view "mcp-apps/.html" keeps "" external () from ; a view inlines every module it loads.` — a browser document has no allowable external, so the view's Rsbuild compilation (which carries the same audit plugin) fails on any `ExternalModule`, whatever the hatch mapped it to. Residual walk forms, `Generated JavaScript import from "" .`, reported only for what the compiler cannot see: `has a non-literal dynamic import` for an expression `import()` in any emitted module, compiled ones included, since Rslib's profile leaves that form verbatim; and, for a module the compile evidence record does not prove — JavaScript the framework did not compile (`install.mjs`, a copied script), every module of an artifact without a record, and every module of a build whose `tools` hatch may have rewritten the emitted bytes (`coverage.rewritable`) — `has invalid syntax` from a full parse, `uses unsupported specifier` or `uses invalid specifier` for a bare or malformed import, `cannot be read`, and the relative-target findings `is missing`, `resolves outside the artifact root`, `is not listed in the artifact manifest`, `does not resolve to a regular file`, `references invalid JSON`, and `uses unsupported target`. A compiled module the record covers with the same bytes is lexed for syntax and its literal imports are not resolved again — the compiler resolved them (bundled, built-in, or an emitted sibling). Prebuilt payloads are not walked; a `dist` finding names `dist/`. | Bundle every JavaScript dependency into the artifact, then rebuild it. | | `AB6006` | error | `Generated JSON cannot be parsed.` — a `.json` file in the artifact is not valid JSON (prebuilt payload files are exempt). Doctor's Claude document lane reports the same code inside an `AB7319` message for a Claude bundle document that is unreadable or not valid JSON. | Regenerate the affected JSON document as valid JSON, then rebuild the artifact. | | `AB6007` | error | `MCP manifest references missing generated server "".` — a root-level MCP manifest (pre-manifest pass) or a target's MCP manifest names a local server entry that the artifact does not contain. | Repair MCP manifest references to generated servers, then rebuild the artifact. | | `AB6008` | error | `Artifact Agent Skills provenance does not match the pinned schema contract.` — the manifest's `agentSkills` schema SHA-256, source revision, or specification differs from the framework's pinned Agent Skills revision. | Rebuild the artifact with the pinned Agent Skills contract. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 663084d44..970a1547e 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1337,9 +1337,15 @@ compiler cannot see: it fails an expression `import()` in any emitted module imports of JavaScript the framework did not compile (`install.mjs`, copied scripts), and does the same for every module of a build whose `tools` hatch may have rewritten the emitted bytes (`coverage.rewritable`). A compiled -module the record covers is lexed for syntax only; the compiler already -resolved its literal imports. Content the compiler did not compile is opaque -and must declare what it needs. Run-time +module the record covers is lexed, not parsed, and each literal import it +still carries is held to the record: a Node built-in or one of the file's +recorded externals passes; any other request is one the build was told to +ignore (`rspackIgnore`/`webpackIgnore` — Rspack leaves it verbatim with no +module, external, or warning) and fails `AB6005 loads "", which the +compiler neither bundled nor recorded as an external`. A matching digest +proves the bytes are the compiler's, not that every import in them was +resolved. Content the compiler did not compile is opaque and must declare +what it needs. 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/packages/agent-bundle/src/build/compile-evidence.ts b/packages/agent-bundle/src/build/compile-evidence.ts index 7ed51f5ae..5a7ee16e6 100644 --- a/packages/agent-bundle/src/build/compile-evidence.ts +++ b/packages/agent-bundle/src/build/compile-evidence.ts @@ -93,6 +93,22 @@ const sortedUnique = (values: readonly string[]): readonly string[] => export const bundledPackagesOf = (result: CompileResult, asset: string): readonly string[] => sortedUnique(result.modules.flatMap((module) => (module.asset === asset && module.package !== undefined ? [module.package] : []))); +/** + * The literal requests the compiler accounted for in each recorded asset — its + * externals — keyed by the asset's path with `pathPrefix` removed. Every other + * literal import the compiler bundled, so a literal request still present in + * a proven file that is neither a Node built-in nor one of these was left + * verbatim: an import the build was told to ignore (`webpackIgnore`, + * `rspackIgnore`), which Rspack neither resolves, records, nor warns about. + */ +export const accountedRequestsOf = ( + record: CompileEvidenceRecord, + pathPrefix?: string, +): ReadonlyMap> => new Map(record.assets.map((asset) => [ + pathPrefix === undefined ? asset.path : posix.relative(pathPrefix, asset.path), + new Set(asset.externals.map((external) => external.request)), +])); + const recordedExternal = (external: ExternalIR, recorded: (path: string) => string): CompileEvidenceExternal => { switch (external.kind) { case 'artifact-relative': diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index d7ea56b57..924b3007c 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -9,6 +9,7 @@ import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; import { cliBinSourceInputs } from './cli-bins.ts'; import { + accountedRequestsOf, createCompileEvidenceRecord, type CompileEvidenceRecord, } from './compile-evidence.ts'; @@ -379,30 +380,29 @@ export const buildPackageOutputs = async (options: { } const rewritable = options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined; + const evidence = await createCompileEvidenceRecord({ + pathPrefix: publishedPrefix, + results: [compileResult], + rewritable, + root: stageRoot, + rspackVersion: rspack.rspackVersion, + }); // The npm form of the plugin is held to the same line as its host packs. - // The compiler resolved every literal import of the bundles it emitted - // (`AB6005` failed the build on anything but a Node built-in), so the - // walk lexes them for the one form the compiler leaves verbatim — an - // expression `import()` — unless a `tools` hatch may have rewritten the - // emitted bytes, in which case every module is parsed in full and its - // imports resolved. Declarations are not modules and are not walked; - // they may still reference declared dependencies. + // The bundles the compiler emitted are lexed and their imports held to + // the record — a Node built-in or a recorded external passes, an + // expression `import()` or an import the build ignored is reported — + // unless a `tools` hatch may have rewritten the emitted bytes, in which + // case every module is parsed in full and its imports resolved. + // Declarations are not modules and are not walked; they may still + // reference declared dependencies. const selfContainment = await validateJavaScriptModules({ artifactRoot: stageRoot, files: staged, - provenPaths: new Set(rewritable ? [] : files.filter((file) => file.kind === 'bundle').map((file) => file.path)), + provenModules: rewritable ? new Map() : accountedRequestsOf(evidence, publishedPrefix), reportedRoot: publishedPrefix, validJson: new Set(), }); if (selfContainment.length > 0) throw new DiagnosticError(selfContainment); - - const evidence = await createCompileEvidenceRecord({ - pathPrefix: publishedPrefix, - results: [compileResult], - rewritable, - root: stageRoot, - rspackVersion: rspack.rspackVersion, - }); await publishArtifact({ outputRoot, stageRoot }); return Object.freeze({ evidence, 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 90f1f6d48..799386c6b 100644 --- a/packages/agent-bundle/src/build/validate-artifact-modules.ts +++ b/packages/agent-bundle/src/build/validate-artifact-modules.ts @@ -102,13 +102,19 @@ const resolveJavaScriptImport = async (options: { /** * Walks every JavaScript module of the artifact for what the compiler could - * not see. A module in `provenPaths` is one the compiler emitted and whose - * evidence covers these exact bytes: the compiler resolved every literal - * import (bundled, built-in, or an emitted sibling), so the module is lexed - * for syntax and only an expression load — the one form Rslib's profile - * leaves verbatim — is reported. Every other module (a generated installer, - * a copied script, a bundle a `tools` hatch may have rewritten) is parsed in - * full and its imports are resolved against the file table. + * not see. A module in `provenModules` is one the compiler emitted and whose + * evidence record covers these exact bytes, mapped to the literal requests + * the compiler accounted for (its recorded externals; `AB6039` has already + * judged each). Such a module is lexed, not parsed, and each import it still + * carries is held to the record: a Node built-in or an accounted request + * passes, an expression load is reported (the form Rslib's profile leaves + * verbatim), and any other literal request is reported too — the compiler + * neither bundled nor recorded it, which is what an import marked + * `webpackIgnore`/`rspackIgnore` looks like. A matching digest proves the + * bytes are the compiler's, never that every import in them was resolved. + * Every other module (a generated installer, a copied script, a bundle a + * `tools` hatch may have rewritten) is parsed in full and its imports are + * resolved against the file table. */ export const validateJavaScriptModules = async (options: { readonly artifactRoot: string; @@ -116,8 +122,8 @@ export const validateJavaScriptModules = async (options: { readonly manifestFiles?: ReadonlySet; /** Prebuilt payload files: opaque consumer outputs excluded from graph validation. */ readonly prebuiltPaths?: ReadonlySet; - /** Compiled modules whose evidence record covers their bytes; lexed, not import-resolved. */ - readonly provenPaths: ReadonlySet; + /** Compiled modules whose evidence record covers their bytes, each with the literal requests the compiler accounted for. */ + readonly provenModules: ReadonlyMap>; /** * POSIX directory under which diagnostics name the validated modules, for * a tree validated before it is published under another path: the package @@ -144,8 +150,8 @@ export const validateJavaScriptModules = async (options: { return; } visiting.add(path); - const proven = options.provenPaths.has(path); - const check: ModuleSyntaxCheck = proven ? 'lexed' : 'parsed'; + const accounted = options.provenModules.get(path); + const check: ModuleSyntaxCheck = accounted === undefined ? 'parsed' : 'lexed'; let bytes: Buffer; try { bytes = await runWithPlatform(readFileBytes(resolve(artifactRoot, path))); @@ -173,7 +179,16 @@ export const validateJavaScriptModules = async (options: { diagnostics.push(graphDiagnostic(reported(path), 'has a non-literal dynamic import.')); continue; } - if (proven) continue; + if (accounted !== undefined) { + if (!isAllowedExternalRequest(imported.specifier) && !accounted.has(imported.specifier)) { + diagnostics.push(graphDiagnostic( + reported(path), + `loads ${JSON.stringify(imported.specifier)}, which the compiler neither bundled nor recorded as an external; ` + + 'an import the build ignored is a run-time load outside the artifact.', + )); + } + continue; + } const resolved = await resolveJavaScriptImport({ artifactRoot, files, diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index c37855e19..6c1dce5e8 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -21,6 +21,7 @@ import { matchesManifestFile, } from './artifact-layout.ts'; import { + accountedRequestsOf, compileEvidenceDiagnostics, compileEvidenceFileName, parseCompileEvidenceRecord, @@ -587,17 +588,17 @@ const validateArtifactStructure = (options: { /** * Reads the compile evidence record the manifest lists and re-checks it * against the file table (`AB6039`). A clean record from a build without a - * `tools` hatch proves every manifest `bundle` file: the compiler resolved - * their literal imports, so the module walk only lexes them. A missing, + * `tools` hatch proves every manifest `bundle` file: the module walk lexes + * them and holds their imports to the recorded externals. A missing, * failing, or rewritable record proves nothing and every module is walked * in full. */ const validateCompileEvidence = async (options: { readonly artifactRoot: string; readonly manifestFiles: readonly ManifestFile[]; -}): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly provenPaths: ReadonlySet }> => { +}): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly provenModules: ReadonlyMap> }> => { const unproven = (diagnostics: readonly Diagnostic[]) => - Object.freeze({ diagnostics: Object.freeze(diagnostics), provenPaths: new Set() }); + Object.freeze({ diagnostics: Object.freeze(diagnostics), provenModules: new Map>() }); if (!options.manifestFiles.some((file) => file.path === compileEvidenceFileName)) return unproven([]); const bytes = await runWithPlatform(readFileString(resolve(options.artifactRoot, compileEvidenceFileName))) .catch(() => undefined); @@ -616,10 +617,7 @@ const validateCompileEvidence = async (options: { new Map(options.manifestFiles.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), ); if (diagnostics.length > 0 || record.coverage.rewritable) return unproven(diagnostics); - return Object.freeze({ - diagnostics, - provenPaths: new Set(options.manifestFiles.filter((file) => file.kind === 'bundle').map((file) => file.path)), - }); + return Object.freeze({ diagnostics, provenModules: accountedRequestsOf(record) }); }; const validateGeneratedFiles = async (options: { @@ -652,7 +650,7 @@ const validateGeneratedFiles = async (options: { } const evidence = options.manifestFiles === undefined - ? { diagnostics: [], provenPaths: new Set() } + ? { diagnostics: [], provenModules: new Map>() } : await validateCompileEvidence({ artifactRoot: options.artifactRoot, manifestFiles: options.manifestFiles }); diagnostics.push(...evidence.diagnostics); diagnostics.push(...await validateJavaScriptModules({ @@ -660,7 +658,7 @@ const validateGeneratedFiles = async (options: { files: options.files, ...(options.manifestFiles === undefined ? {} : { manifestFiles: new Set(options.manifestFiles.map((file) => file.path)) }), prebuiltPaths, - provenPaths: evidence.provenPaths, + provenModules: evidence.provenModules, validJson, })); diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 04e2f3ddf..cb57f4ca7 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1477,6 +1477,108 @@ it('fails the build on an expression import the compiler left verbatim in a comp } }, 20_000); +const ignoredImportSource = [ + // Rspack honours `rspackIgnore`/`webpackIgnore`: the call survives verbatim with no module, no + // external, and no warning, so the compile evidence record cannot mention it. + "export const pad = () => import('left-pad' /* rspackIgnore: true */);", + "export const missing = () => import(/* webpackIgnore: true */ './missing.mjs');", + "export const outside = () => import(/* rspackIgnore: true */ '../../outside.mjs');", + "export const sibling = () => import(/* rspackIgnore: true */ './sibling.mjs');", + "console.log(typeof pad, typeof missing, typeof outside, typeof sibling);", + '', +].join('\n'); + +const ignoredImportDiagnostics = (importer: string): unknown[] => [ + 'left-pad', + './missing.mjs', + '../../outside.mjs', + './sibling.mjs', +].map((request) => expect.objectContaining({ + code: 'AB6005', + generatedPath: importer, + message: `Generated JavaScript import from ${JSON.stringify(importer)} loads ${JSON.stringify(request)}, which the compiler ` + + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', +})); + +it('fails the build on a literal import the compiler was told to ignore, package or relative', async () => { + // The record proves the emitted bytes are the compiler's, not that every + // import in them was resolved: an ignored `import()` is neither bundled nor + // an external, so a proven bundle's lexed imports are held to the record. + const project = await createProject(); + try { + await writeFile(project.scriptPath, ignoredImportSource); + const model = modelFor(project); + await expect(build({ + model: { + ...model, + skills: model.skills.map((skill) => ({ + ...skill, + resources: skill.resources.map((resource) => + resource.source === project.scriptPath + ? { ...resource, bytes: Buffer.byteLength(ignoredImportSource) } + : resource, + ), + })), + }, + outputRoot: project.outputRoot, + projectRoot: project.root, + registry: new TargetRegistry().register((await import('../src/adapters/portable.ts')).portableAdapter, { default: true }), + })).rejects.toMatchObject({ + diagnostics: expect.arrayContaining(ignoredImportDiagnostics('scripts/greeting.mjs')), + }); + } finally { + await cleanupProject(project); + } +}, 20_000); + +it('validates a relocated artifact from its record alone and still reports an ignored import in a proven bundle', async () => { + // A copied artifact carries no compilation: the record travels with it and + // is what `validate --artifact` reads. Re-signed bytes are proven (lexed, not + // parsed), and the residual literal-import check still applies to them. + const project = await createProject(); + const relocated = await mkdtemp(join(tmpdir(), 'agent-bundle-relocated-')); + try { + await build({ + model: modelFor(project), + outputRoot: project.outputRoot, + projectRoot: project.root, + registry: new TargetRegistry().register((await import('../src/adapters/portable.ts')).portableAdapter, { default: true }), + }); + const artifactRoot = join(relocated, 'artifact'); + await rename(project.outputRoot, artifactRoot); + await rm(project.root, { force: true, recursive: true }); + expect(await validateArtifact({ artifactRoot })).toEqual([]); + + const manifestPath = join(artifactRoot, 'agent-bundle.manifest.json'); + const recordPath = join(artifactRoot, compileEvidenceFileName); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + readonly files: Array<{ bytes: number; path: string; sha256: string }>; + }; + const record = JSON.parse(await readFile(recordPath, 'utf8')) as { + readonly assets: Array<{ path: string; sha256: string }>; + }; + const entry = (path: string) => manifest.files.find((file) => file.path === path) + ?? (() => { throw new Error(`Expected manifest entry for ${path}.`); })(); + const rewrite = async (path: string, contents: string): Promise => { + await writeFile(join(artifactRoot, path), contents); + Object.assign(entry(path), { bytes: Buffer.byteLength(contents), sha256: sha256Hex(contents) }); + }; + // The lexer accepts this; acorn would not. A proven bundle is lexed, so + // the only findings are the ignored imports themselves. + const rewritten = `${ignoredImportSource}export const broken = ;\n`; + await rewrite('scripts/greeting.mjs', rewritten); + record.assets.find((asset) => asset.path === 'scripts/greeting.mjs')!.sha256 = sha256Hex(rewritten); + await rewrite(compileEvidenceFileName, `${JSON.stringify(record)}\n`); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const diagnostics = await validateArtifact({ artifactRoot }); + expect(diagnostics.filter((diagnostic) => diagnostic.code === 'AB6039')).toEqual([]); + expect(diagnostics.filter((diagnostic) => diagnostic.code === 'AB6005')).toEqual(ignoredImportDiagnostics('scripts/greeting.mjs')); + } finally { + await rm(relocated, { force: true, recursive: true }); + await cleanupProject(project); + } +}, 20_000); + it('parses emitted bundles in full when a tools hatch could have rewritten them', async () => { // A compiler bundle is trusted to the ESM lexer only while the evidence // record covers its bytes from a build without a hatch. A hatch runs after diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 8f0619867..ad49b6695 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -497,6 +497,91 @@ describe('framework-owned package build', () => { expect(binSource).toContain('./shipped.cjs'); }, 120_000); + it('fails the package build with AB6005 on a literal import the compiler was told to ignore in a dist bundle', async () => { + // `rspackIgnore`/`webpackIgnore` leave the call verbatim with no module, external, or warning; + // the walk over the record-proven bundle holds each lexed import to the recorded externals. + 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'],", + '};', + '', + ].join('\n'), + 'src/cli.ts': [ + "export const pad = () => import('left-pad' /* rspackIgnore: true */);", + "export const missing = () => import(/* webpackIgnore: true */ './missing.js');", + "export const outside = () => import(/* rspackIgnore: true */ '../../outside.js');", + '', + 'export const main = async (): Promise => {', + ' process.stdout.write(`${typeof pad}${typeof missing}${typeof outside}\\n`);', + ' return 0;', + '};', + '', + ].join('\n'), + 'src/index.ts': [ + "export const sibling = () => import(/* rspackIgnore: true */ './bin/package-build-fixture.js');", + '', + ].join('\n'), + }); + + const failure = await packageBuildFailure(root); + expect(failure).toBeInstanceOf(DiagnosticError); + const ignored = (asset: string, request: string): Diagnostic => ({ + code: 'AB6005', + generatedPath: asset, + message: `Generated JavaScript import from ${JSON.stringify(asset)} loads ${JSON.stringify(request)}, which the compiler ` + + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', + recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', + severity: 'error', + }); + expect([...withCode((failure as DiagnosticError).diagnostics, 'AB6005')].sort(byMessage)).toEqual([ + ignored('dist/bin/package-build-fixture.js', '../../outside.js'), + ignored('dist/bin/package-build-fixture.js', './missing.js'), + ignored('dist/bin/package-build-fixture.js', 'left-pad'), + ignored('dist/index.js', './bin/package-build-fixture.js'), + ].sort(byMessage)); + await unpublishedPackageOutput(root); + }, 120_000); + + it('fails the package build with AB6005 on an expression import the compiler left verbatim in a dist bundle', 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': [ + 'export const load = (name: string) => import(name);', + '', + 'export const main = async (argv: readonly string[]): Promise => {', + " process.stdout.write(`${Object.keys(await load(argv[0] ?? 'node:os')).length}\\n`);", + ' return 0;', + '};', + '', + ].join('\n'), + }); + + const failure = await packageBuildFailure(root); + expect(failure).toBeInstanceOf(DiagnosticError); + expect(withCode((failure as DiagnosticError).diagnostics, 'AB6005')).toEqual([{ + code: 'AB6005', + generatedPath: 'dist/bin/package-build-fixture.js', + message: 'Generated JavaScript import from "dist/bin/package-build-fixture.js" has a non-literal dynamic import.', + recovery: 'Bundle every JavaScript dependency into the artifact, then rebuild it.', + severity: 'error', + }]); + await unpublishedPackageOutput(root); + }, 120_000); + it('accepts a sibling authored module that the package build bundles into the executable', async () => { const root = await fixtureRoot({ ...conventionFixture(), diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index efbc0938c..4dbc98c88 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -504,7 +504,8 @@ package `externals`, while relative, function-form, and mutator externals are ju compilation's own evidence (`AB6005`). A hatch also runs after the compiler judged the module graph and can rewrite the emitted bytes, so the build's compile evidence record marks `coverage.rewritable` and artifact validation parses every emitted module in full and resolves its -imports, instead of trusting the record; without a hatch a compiled module is only lexed. Run-time +imports, instead of trusting the record; without a hatch a compiled module is lexed and its literal +imports held to the record's externals (an `rspackIgnore` import fails `AB6005`). Run-time path references are kept the same way: a `new URL(…, import.meta.url)` or `new Worker(new URL(…))` in your code or a generated entry 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/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index ba642c503..b7c2fa976 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -291,7 +291,7 @@ embed their HTML), then the node surfaces. | Canonical manifest parse + file digest match | `build/validate-artifact.ts` | `AB60xx` family; parse errors throw from `parseArtifactManifest` | | Host-pack, package-build `dist`, and MCP App view compilations keep only permitted externals | `build/compiler.ts`, `build/external-policy.ts` | `AB6005` | | Compile evidence record vs manifest `bundle` files | `build/compile-evidence.ts` `compileEvidenceDiagnostics` | `AB6039` | -| Emitted modules the compiler could not see: an expression `import()` anywhere, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten | `build/validate-artifact-modules.ts` | `AB6005` (residual forms) | +| Emitted modules the compiler could not see: an expression `import()` anywhere, a literal import the build was told to ignore (`rspackIgnore`/`webpackIgnore`) in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten | `build/validate-artifact-modules.ts` | `AB6005` (residual forms) | | Skills / hooks / MCP documents match the selected hosts | `build/validate-artifact-skills.ts`, `build/validate-artifact-hooks.ts`, `build/validate-artifact-mcp.ts` | per-surface `AB60xx` | | Artifact ownership of the output root | `build/validate-artifact.ts` | `AB6014` | | Packed tarball contains every manifest file and no unused install deps | `build/pack-inventory.ts` | `AB7010`, `AB7014`, `AB7015` | @@ -438,7 +438,7 @@ Production readers call `parseArtifactManifest` or take an already-parsed | `build/validate-artifact.ts` | all of them | Re-parse the on-disk bytes, match `files[]` digests and modes, check `targets[]` against the registry, pin `agentSkills` and `runtime.node` | | `build/compiler.ts`, `build/external-policy.ts` | `CompileResult.externals` for host packs, package-build `dist`, and MCP App views | `AB6005` compile-evidence self-containment | | `build/compile-evidence.ts` | `files[]` (`bundle` rows) | `AB6039`: re-check the persisted compile evidence record against the file table without parsing JavaScript | -| `build/validate-artifact-modules.ts` | `files[]` (`.js`/`.mjs` rows) plus package-build `dist`; `bundle` rows the compile evidence record proves are lexed only | `AB6005` residual walk: expression `import()`, uncompiled or hatch-rewritten modules | +| `build/validate-artifact-modules.ts` | `files[]` (`.js`/`.mjs` rows) plus package-build `dist`; `bundle` rows the compile evidence record proves are lexed and their literal imports held to the recorded externals | `AB6005` residual walk: expression `import()`, ignored (`rspackIgnore`) literal imports, uncompiled or hatch-rewritten modules | | `build/validate-artifact-skills.ts` | `targets[]` (`manifestTargets`) plus the tree | Skill documents vs selected hosts | | `build/validate-artifact-hooks.ts` | `targets[]`, hook files | Hook documents vs selected hosts | | `build/validate-artifact-mcp.ts` | `targets[]`, MCP files | MCP documents vs selected hosts | diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index d564804e3..973b9df69 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -42,9 +42,13 @@ The emitted-module walk remains behind that compiler evidence for exactly what t see. It reads every emitted `.js`/`.mjs` module as an ES module and, in any of them, compiled ones included, fails an expression `import()` (`AB6005 has a non-literal dynamic import`). A module the compile evidence record proves — a manifest `bundle` file the record covers with the same bytes, -from a build without a [`tools` hatch](../../reference/configuration.mdx#tools) — is lexed for -syntax only: the compiler already resolved its literal imports as bundled, built-in, or an emitted -sibling, so the walk does not resolve them again. Every other module is parsed in full and its +from a build without a [`tools` hatch](../../reference/configuration.mdx#tools) — is lexed, not +parsed, and each literal import it still carries is held to the record: a Node built-in or one of +the file's recorded externals passes; any other request is one the build was told to ignore +(`rspackIgnore`/`webpackIgnore` — Rspack leaves it verbatim with no module, external, or warning) +and fails `AB6005 loads "", which the compiler neither bundled nor recorded as an +external`. A matching digest proves the bytes are the compiler's, not that every import in them was +resolved. Every other module is parsed in full and its imports resolved against the manifest — a generated `install.mjs`, a copied consumer script, every module of an artifact without a record, and every module of a build whose hatch could have rewritten the emitted assets (the record's `coverage.rewritable`). Those findings are the residual `AB6005` diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 2ea58ddbc..51e992340 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -450,7 +450,8 @@ export default defineConfig({ 边界约束:`AB4725` 会拒绝静态可见、点名某个包的 `externals`,相对路径、函数形式以及 mutator 添加的 externals 则由编译过程自身的证据判断(`AB6005`)。逃生舱还运行在编译器判定模块图之后,可以改写输出 字节,因此该构建的编译证据记录会标记 `coverage.rewritable`,产物校验会完整解析每个已输出模块并解析 -其导入,而不是信任记录;没有逃生舱时,编译产物只做词法检查。运行时路径引用也以同样方式得到保留:你的代码或 +其导入,而不是信任记录;没有逃生舱时,编译产物只做词法分析,其字面量导入对照记录在案的外部依赖核对 +(`rspackIgnore` 导入会以 `AB6005` 失败)。运行时路径引用也以同样方式得到保留:你的代码或 生成入口中的 `new URL(…, import.meta.url)` 或 `new Worker(new URL(…))` 指向的是产物旁的一个文件,因此 不变量层会在逃生舱之后关闭打包器对 URL 与 worker 的静态资源处理,让该表达式原样进入产物。逃生舱定制的是 *代码如何编译*,绝不是*产物承诺了什么*。 diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 75dc65f45..bacdee33d 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -257,7 +257,7 @@ node 表面。 | 规范清单解析 + 文件摘要匹配 | `build/validate-artifact.ts` | `AB60xx` 系列;解析错误由 `parseArtifactManifest` 抛出 | | 宿主包、包构建 `dist` 与 MCP App 视图的编译只保留获准的 external | `build/compiler.ts`、`build/external-policy.ts` | `AB6005` | | 编译证据记录对照清单 `bundle` 文件 | `build/compile-evidence.ts` 的 `compileEvidenceDiagnostics` | `AB6039` | -| 编译器看不见的已输出模块:任何位置的表达式 `import()`,以及框架未编译或 `tools` 逃生舱可能改写的 JavaScript 的导入与语法 | `build/validate-artifact-modules.ts` | `AB6005`(残余形态) | +| 编译器看不见的已输出模块:任何位置的表达式 `import()`、编译产物中构建被要求忽略的字面量导入(`rspackIgnore`/`webpackIgnore`),以及框架未编译或 `tools` 逃生舱可能改写的 JavaScript 的导入与语法 | `build/validate-artifact-modules.ts` | `AB6005`(残余形态) | | Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | | 产物对输出根目录的所有权 | `build/validate-artifact.ts` | `AB6014` | | 打包的 tarball 包含清单中的每个文件,且没有未使用的安装依赖 | `build/pack-inventory.ts` | `AB7010`、`AB7014`、`AB7015` | @@ -386,7 +386,7 @@ CLI bin 的文件,只以 `files[]` 中一行的形式出现。 | `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `targets[]`,固定 `agentSkills` 与 `runtime.node` | | `build/compiler.ts`、`build/external-policy.ts` | 宿主包、包构建 `dist` 与 MCP App 视图的 `CompileResult.externals` | `AB6005` 编译证据自包含检查 | | `build/compile-evidence.ts` | `files[]`(`bundle` 行) | `AB6039`:把持久化的编译证据记录对照文件表复核,且不解析 JavaScript | -| `build/validate-artifact-modules.ts` | `files[]`(`.js`/`.mjs` 行)加包构建 `dist`;编译证据记录证明的 `bundle` 行只做词法检查 | `AB6005` 残余遍历:表达式 `import()`、未编译或被逃生舱改写的模块 | +| `build/validate-artifact-modules.ts` | `files[]`(`.js`/`.mjs` 行)加包构建 `dist`;编译证据记录证明的 `bundle` 行只做词法分析,其字面量导入对照记录在案的外部依赖核对 | `AB6005` 残余遍历:表达式 `import()`、被忽略(`rspackIgnore`)的字面量导入、未编译或被逃生舱改写的模块 | | `build/validate-artifact-skills.ts` | `targets[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | | `build/validate-artifact-hooks.ts` | `targets[]`、钩子文件 | 钩子文档对照所选宿主 | | `build/validate-artifact-mcp.ts` | `targets[]`、MCP 文件 | MCP 文档对照所选宿主 | diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 36540b50e..9d3d5ea45 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -36,8 +36,11 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 模块按 ES 模块读取,并在其中任何一个——包括编译产物——里出现表达式 `import()` 时失败 (`AB6005 has a non-literal dynamic import`)。被编译证据记录证明的模块——记录以相同字节覆盖的清单 `bundle` 文件,且构建未使用 [`tools` 逃生舱](../../reference/configuration.mdx#tools)——只做词法 -语法检查:编译器已把它的字面量导入解析为已打包、内建或已输出的同级文件,遍历不会再次解析它们。 -其他所有模块都会被完整解析,导入也会对照清单解析——生成的 `install.mjs`、被复制的消费者脚本、 +分析而不完整解析,但它仍带有的每个字面量导入都要对照记录核对:Node 内建模块或该文件记录在案的 +外部依赖通过;其他任何请求都是构建被要求忽略的导入(`rspackIgnore`/`webpackIgnore`——Rspack +原样保留它,没有模块、外部依赖或警告),并以 `AB6005 loads "", which the compiler neither +bundled nor recorded as an external` 失败。摘要匹配只证明字节是编译器输出的,不证明其中每个导入都 +已被解析。其他所有模块都会被完整解析,导入也会对照清单解析——生成的 `install.mjs`、被复制的消费者脚本、 没有记录的产物中的每个模块,以及逃生舱可能改写了输出资源的构建(记录中的 `coverage.rewritable`) 里的每个模块。这些发现就是 `AB6005` 的残余形态:不受支持或无效的说明符、缺失于或逃逸出输出树的目标、 无效语法;`dist` 的发现以 `dist/` 命名文件。预构建负载(`kind: 'prebuilt'`)保持不透明、仅做哈希 From 201472146264c18ed2c339292eb1f0f43d1fa5b4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:28:18 +0000 Subject: [PATCH 6/8] Synthetic gate test: a proven bundle's unaccounted imports are reported, recorded ones pass --- .../tests/artifact-validator.test.ts | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index e82af698f..4f1c61cb7 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1983,12 +1983,16 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as } }); -/** A compile evidence record covering every `bundle` fixture file with its exact bytes. */ -const compileEvidenceFor = (files: readonly ArtifactFixtureFile[], rewritable = false): ArtifactFixtureFile => ({ +/** A compile evidence record covering every `bundle` fixture file with its exact bytes and the externals given per path. */ +const compileEvidenceFor = ( + files: readonly ArtifactFixtureFile[], + rewritable = false, + externals: Readonly> = {}, +): ArtifactFixtureFile => ({ contents: serializeCompileEvidenceRecord({ assets: files .filter((file) => file.kind === 'bundle') - .map((file) => ({ externals: [], packages: [], path: file.path, sha256: hash(file.contents) })) + .map((file) => ({ externals: externals[file.path] ?? [], packages: [], path: file.path, sha256: hash(file.contents) })) .sort((left, right) => left.path.localeCompare(right.path)), coverage: { rewritable, unobserved: unobservedLoadForms }, policy: externalPolicy, @@ -2001,13 +2005,14 @@ const compileEvidenceFor = (files: readonly ArtifactFixtureFile[], rewritable = /** * The check a module gets follows what the compiler proved. A manifest * `bundle` file the compile evidence record covers — same bytes, from a build - * without a `tools` hatch — had every literal import resolved by the compiler: - * only the ESM lexer runs over it, so a bare `export const broken = ;` (which - * no bundler emits) passes while unterminated input still fails, and its - * literal specifiers are not resolved again. Every other module — a copied - * consumer script, a generated installer, a bundle without a record or from a - * build whose hatch may have rewritten it — is parsed in full and its imports - * resolved against the file table. + * without a `tools` hatch — is the compiler's output: only the ESM lexer runs + * over it, so a bare `export const broken = ;` (which no bundler emits) passes + * while unterminated input still fails, and each literal import it carries is + * held to the record rather than resolved — a Node built-in or a recorded + * external passes, anything else is an import the build ignored. Every other + * module — a copied consumer script, a generated installer, a bundle without + * a record or from a build whose hatch may have rewritten it — is parsed in + * full and its imports resolved against the file table. */ it('lexes compiled modules the evidence record proves and walks every other module in full', async () => { const brokenStatement = 'export const broken = ;\n'; @@ -2020,7 +2025,19 @@ it('lexes compiled modules the evidence record proves and walks every other modu { contents: "export { missing } from './missing.mjs';\n", kind: 'bundle', path: 'scripts/dangling.mjs' }, { contents: "import 'unbundled-package';\n", kind: 'bundle', path: 'scripts/bare.mjs' }, { contents: "import 'unbundled-package';\n", kind: 'generated', path: 'scripts/uncompiled.mjs' }, + { contents: "import 'node:fs';\nimport './sibling.mjs';\n", kind: 'bundle', path: 'scripts/accounted.mjs' }, + { contents: 'export const sibling = true;\n', kind: 'bundle', path: 'scripts/sibling.mjs' }, ]; + const accountedExternals = { + 'scripts/accounted.mjs': [{ + externalType: 'module', + issuers: ['src/accounted.ts'], + kind: 'artifact-relative' as const, + request: './sibling.mjs', + target: 'scripts/sibling.mjs', + userRequest: './sibling.mjs', + }], + }; const reported = async (files: readonly ArtifactFixtureFile[]): Promise => { const root = await writeArtifact(files, true, [customManifestTarget]); try { @@ -2043,18 +2060,30 @@ it('lexes compiled modules the evidence record proves and walks every other modu // Without a record nothing is proven: every module is parsed and resolved. expect(await reported(modules)).toEqual(walkedInFull); - // A record covering the bundles proves them: the lexer still rejects - // unterminated input, but a bare specifier or a dangling sibling in a - // covered bundle is the compiler's resolved import, not the walk's. - expect(await reported([...modules, compileEvidenceFor(modules)])).toEqual([ + // A record covering the bundles proves their bytes, not their imports: the + // lexer still rejects unterminated input, a built-in and a recorded sibling + // pass, and a bare specifier or a dangling sibling the record does not + // account for — what an `rspackIgnore` import leaves behind — is reported + // without being resolved. + const unaccounted = (importer: string, request: string): readonly string[] => [ + 'AB6005', + importer, + `Generated JavaScript import from ${JSON.stringify(importer)} loads ${JSON.stringify(request)}, which the compiler ` + + 'neither bundled nor recorded as an external; an import the build ignored is a run-time load outside the artifact.', + ]; + expect(await reported([...modules, compileEvidenceFor(modules, false, accountedExternals)])).toEqual([ + unaccounted('scripts/bare.mjs', 'unbundled-package'), ['AB6005', 'scripts/copied.mjs', 'Generated JavaScript import from "scripts/copied.mjs" has invalid syntax.'], + unaccounted('scripts/dangling.mjs', './missing.mjs'), ['AB6005', 'scripts/generated.mjs', 'Generated JavaScript import from "scripts/generated.mjs" has invalid syntax.'], ['AB6005', 'scripts/uncompiled.mjs', 'Generated JavaScript import from "scripts/uncompiled.mjs" uses unsupported specifier "unbundled-package".'], ['AB6005', 'scripts/unterminated.mjs', 'Generated JavaScript import from "scripts/unterminated.mjs" has invalid syntax.'], ]); + // Without the sibling external in the record, the same import is unaccounted for. + expect(await reported([...modules, compileEvidenceFor(modules)])).toContainEqual(unaccounted('scripts/accounted.mjs', './sibling.mjs')); // A hatch may have rewritten the emitted bytes after the compiler judged // them: the record says so and proves nothing. - expect(await reported([...modules, compileEvidenceFor(modules, true)])).toEqual(walkedInFull); + expect(await reported([...modules, compileEvidenceFor(modules, true, accountedExternals)])).toEqual(walkedInFull); // A record that does not parse is reported once and proves nothing. expect(await reported([...modules, { contents: '{', kind: 'generated', path: compileEvidenceFileName }])).toEqual([ ['AB6039', compileEvidenceFileName, 'Compile evidence record is not valid JSON.'], From 8cd6d025010f3a2c5057065059e6cfe5f1f96128 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:45:31 +0000 Subject: [PATCH 7/8] Docs: pre-manifest pass still walks JavaScript and reads the record; reserved-external wording --- .changeset/619-evidence-ab7014.md | 2 +- docs/diagnostics.md | 5 +++-- docs/entry-conventions.md | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.changeset/619-evidence-ab7014.md b/.changeset/619-evidence-ab7014.md index 0abdb89be..99e8af156 100644 --- a/.changeset/619-evidence-ab7014.md +++ b/.changeset/619-evidence-ab7014.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed, not parsed, and reported for a non-literal `import()` and for any literal import that is neither a Node built-in nor one of the file's recorded externals — an import the build was told to ignore (`rspackIgnore`/`webpackIgnore`); JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. A reserved specifier kept external by a function-form `tools` external now fails as compile-time `AB6005` instead of a residual-import error. (#634) +Judge `AB7014` from consumer-runtime evidence only: a packed declaration file reference (read with TypeScript's `preProcessFile`), a consumer-side install script that names or runs the package, or a prebuilt payload's `runtimeDependencies` (`definePrebuilt`). A package the build inlined is not used; the diagnostic names the `dist` bundles that inlined it, read from the package build's compile evidence record. `prepack` no longer reads packed JavaScript — `require`/`createRequire`/`import.meta.resolve` literals, `bin`-command strings, `#subpath` imports, inline `node -e` programs, and the files install scripts run are no longer evidence, and a computed load no longer withholds the check. `AB7015` escalates a fetched optional dependency only for a command-position `bin` command, a `node_modules//` file, or a bare preload. `AB6005` is judged from compile evidence first, and the emitted-module walk in `build`, `validate --artifact`, and the package build is gated on it: a compiled module the `agent-bundle.compile-evidence.json` record covers from a build without a `tools` hatch is lexed, not parsed, and reported for a non-literal `import()` and for any literal import that is neither a Node built-in nor one of the file's recorded externals — an import the build was told to ignore (`rspackIgnore`/`webpackIgnore`); JavaScript the framework did not compile (`install.mjs`, copied scripts), every module of an artifact without a record, and every module of a hatch build keep the full parse and import resolution. The scan of emitted bytes for residual reserved imports is removed: a reserved specifier a `tools` hatch externalizes is rejected at config inspection or by the build-time guard around function-form externals, and the compile-time externals audit (`AB6005`) is the backstop for anything that still reaches the module graph. (#634) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index d49539bb1..5524a4e7c 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1795,8 +1795,9 @@ placeholders itself. ## Built-artifact validation (`AB6000`–`AB6018`, `AB6023`–`AB6025`, `AB6039`) `agent-bundle build` validates the staged tree before it writes the manifest -(`validateArtifactFiles`: filesystem entries and generated JSON documents), -validates the finished artifact against its +(`validateArtifactFiles`: filesystem entries, generated JSON documents, the +compile evidence record against the planned file table (`AB6039`), and +generated JavaScript modules), validates the finished artifact against its manifest, and re-checks the validated snapshot after the staging tree is renamed into place. `agent-bundle validate --artifact ` runs the same validator over a built directory, `agent-bundle dev` runs it over every diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 970a1547e..2304938e3 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1314,7 +1314,9 @@ module specifiers are protected the same way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated module specifier (`agent-bundle/meta`, or a registry specifier such as `agent-bundle/mcp-apps`) fails the build with a hard diagnostic — at config inspection for statically visible `externals`, -and from the compilation's externals evidence for function-form `externals`. +from the build-time guard that wraps function-form `externals`, and from the +compilation's externals evidence (`AB6005`) for anything that still reaches +the module graph; the emitted bytes are no longer scanned for reserved text. The 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. From 3111475aea6b3e95aa9461adb11d9b8ba42f5f93 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:47:58 +0000 Subject: [PATCH 8/8] Test comments: self-containment is judged from externals, not emitted text --- packages/agent-bundle/tests/build.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index cb57f4ca7..247898830 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1141,9 +1141,9 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; await writeFile(join(sourceRoot, 'entry.ts'), [ "import { marker } from 'agent-bundle/mcp-entry';", "import registry from 'agent-bundle/mcp-apps';", - // A reserved specifier mentioned as data, not imported: the residual-import - // scan parses the emitted bundle instead of grepping it, so this survives - // into the output without failing the self-containment check. + // A reserved specifier mentioned as data, not imported: self-containment + // is judged from the module graph's externals, not from the emitted text, + // so this survives into the output without failing the check. "const mentioned = 'agent-bundle/mcp-entry';", 'export const main = () => { console.log(marker, registry, mentioned); };', '', @@ -1184,8 +1184,8 @@ it('inlines reserved specifiers through exact-match aliases and virtual generate expect(bundle).toContain('generated-registry'); expect(bundle).toContain('generated-wrapper-marker'); expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); - // The scan tolerates a reserved specifier that is only mentioned as a - // string literal; only a live import fails the build. + // A reserved specifier that is only mentioned as a string literal is not + // an external; only a live import kept external fails the build. expect(bundle).toContain('agent-bundle/mcp-entry'); // The wrapper entry and registry module were served from memory at // guaranteed-nonexistent paths: the reserved namespace never reaches the