From b2f4d1ce389a0e792684fd9e2da1e29a9bb74866 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:39:50 +0000 Subject: [PATCH 1/6] fix(rstest): alias agent-bundle/meta in the Rstest presets and raise AB4760 outside the compiler (#386) agentBundleRstest() and agentBundleBrowserRstest() write .agent-bundle/test/meta.mjs from the same generatedMetaModuleSource/projectMeta the build injects, fed from the compiler pass's plugin identity, and alias `agent-bundle/meta$` to it, so source importing the identity loads under unit, route-unit, renderRoute, and invokeCli tests without a build. The published dist/meta.js now throws the structured AB4760 diagnostic (code, recovery, diagnostic fields; recovery inline in the message) instead of a bare Error. --- .changeset/386-meta-rstest-alias.md | 5 + docs/diagnostics.md | 29 ++++++ docs/entry-conventions.md | 22 ++++- docs/framework-mode.md | 24 +++++ packages/agent-bundle/README.md | 9 ++ .../meta-consumer/agent-bundle.config.ts | 12 +++ .../fixtures/meta-consumer/package.json | 7 ++ .../fixtures/meta-consumer/rstest.config.ts | 14 +++ .../meta-consumer/rstest.no-preset.config.ts | 11 +++ .../meta-consumer/rstest.route-unit.config.ts | 6 ++ .../fixtures/meta-consumer/src/cli/version.ts | 21 ++++ .../meta-consumer/src/lib/identity.ts | 14 +++ .../src/mcp/consumer/tools/identity.ts | 34 +++++++ .../tests/route-unit/identity.test.ts | 31 ++++++ .../meta-consumer/tests/unit/identity.test.ts | 22 +++++ packages/agent-bundle/src/build/meta.ts | 15 ++- packages/agent-bundle/src/meta-diagnostic.ts | 62 ++++++++++++ packages/agent-bundle/src/meta.ts | 18 ++-- packages/agent-bundle/src/rstest/browser.ts | 7 ++ packages/agent-bundle/src/rstest/index.ts | 10 ++ .../agent-bundle/src/rstest/meta-module.ts | 36 +++++++ packages/agent-bundle/tests/mcp.test.ts | 13 ++- .../tests/rstest-meta-alias.test.ts | 98 +++++++++++++++++++ .../tests/rstest-meta-consumer.test.ts | 84 ++++++++++++++++ .../tests/test-browser-rstest.test.ts | 8 ++ rstest.config.ts | 2 + rstest.integration-tests.ts | 12 +++ rstest.unit.config.ts | 2 + 28 files changed, 611 insertions(+), 17 deletions(-) create mode 100644 .changeset/386-meta-rstest-alias.md create mode 100644 packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/package.json create mode 100644 packages/agent-bundle/fixtures/meta-consumer/rstest.config.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/rstest.no-preset.config.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/rstest.route-unit.config.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/src/cli/version.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/src/lib/identity.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/tests/route-unit/identity.test.ts create mode 100644 packages/agent-bundle/fixtures/meta-consumer/tests/unit/identity.test.ts create mode 100644 packages/agent-bundle/src/meta-diagnostic.ts create mode 100644 packages/agent-bundle/src/rstest/meta-module.ts create mode 100644 packages/agent-bundle/tests/rstest-meta-alias.test.ts create mode 100644 packages/agent-bundle/tests/rstest-meta-consumer.test.ts diff --git a/.changeset/386-meta-rstest-alias.md b/.changeset/386-meta-rstest-alias.md new file mode 100644 index 000000000..5cbd688c9 --- /dev/null +++ b/.changeset/386-meta-rstest-alias.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +`agentBundleRstest()` and `agentBundleBrowserRstest()` now alias `agent-bundle/meta` to a generated identity module (`.agent-bundle/test/meta.mjs`) written from the same compiler pass with the same `generatedMetaModuleSource` the build injects, so a source module importing `{ name, version, packageName, packageVersion }` loads under unit, route-unit, `renderRoute`, and `invokeCli` tests with the identity `package.json` and `agent-bundle.config.ts` declare instead of failing at import (#386). The published `agent-bundle/meta` module reached outside every compiled surface now throws the structured `AB4760` diagnostic — code, message, and the exact recovery (run under the preset, or alias the specifier in a custom runner) on the thrown error — rather than a bare `Error`. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 0623c48c0..cd534f7f5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -23,6 +23,7 @@ gate a build, a validation, or a dev rebuild. | `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. | | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | +| `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); see below. | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | @@ -289,6 +290,34 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | +## Build-time identity outside the compiler (`AB4760`) + +`agent-bundle/meta` (see `docs/entry-conventions.md`) is a reserved specifier +the compiler replaces in every compiled surface with the project's exact +`{ name, packageName, packageVersion, version }`. The published +`dist/meta.js` module behind that specifier therefore never carries an +identity of its own: every binding — `name`, `version`, `packageName`, +`packageVersion`, `meta`, and the default export — throws this diagnostic at +module evaluation, so a module that reaches it fails on import rather than +observing a fabricated identity. The thrown value is an `Error` named +`AgentBundleMetaUnavailableError` whose `code`, `recovery`, and structured +`diagnostic` fields carry the same data the message prints, so a bare `node` +process and a test runner both show the fix. The importing module is not +observable from a module evaluated through ESM linking, so the message names +the situation, not a file; the runner's own "failed to load" line names the +file. + +Unit tests are the common way to reach it (issue #386): a plain Rstest pool +imports a source module that imports `agent-bundle/meta`, no compiled surface +replaced the specifier, and every test that touches that module fails at +import. `agentBundleRstest()` and `agentBundleBrowserRstest()` prevent this by +aliasing the specifier to `.agent-bundle/test/meta.mjs`, generated from the +same compiler pass. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module exporting `{ name, packageName, packageVersion, version }` plus a frozen `meta` default computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version — the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | + ## Config beside a route-generated MCP server (`AB4340`) A `mcp.servers.` block for a server the route graph compiles in diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index eec831d0c..24abd7261 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -464,11 +464,23 @@ module is a reserved specifier, so the `tools` hatch cannot externalize it, and no emitted bundle can still carry an unresolved import of it. Types ship with the package export, so no generated declaration file is -involved. Outside Agent Bundle compilation the published module throws rather -than reporting a fabricated identity — a plugin slug exists only in the -config, and a runtime guess at it would silently disagree with the artifact. -A release build refuses a project with no release version at all (`AB4013`), -so a compiled artifact never carries the development fallback. +involved. Outside Agent Bundle compilation the published module throws the +`AB4760` diagnostic rather than reporting a fabricated identity — a plugin +slug exists only in the config, and a runtime guess at it would silently +disagree with the artifact. A release build refuses a project with no release +version at all (`AB4013`), so a compiled artifact never carries the +development fallback. + +Tests are not outside the compiler: `agentBundleRstest()` and +`agentBundleBrowserRstest()` (`agent-bundle/rstest`) alias the specifier to +`.agent-bundle/test/meta.mjs`, a module generated by the same +`generatedMetaModuleSource` the build injects, fed from the same compiler +pass's plugin identity. A source module importing `agent-bundle/meta` therefore +loads under any Rstest pool built from the preset — plain unit tests, the +route-unit level, `renderRoute`, and `invokeCli` alike — with the identity +`package.json` and `agent-bundle.config.ts` declare. A custom runner that +does not use the preset must add the same alias; the `AB4760` recovery text +spells it out (see [Diagnostics](diagnostics.md#build-time-identity-outside-the-compiler-ab4760)). ## Prebuilt payloads — package what you compiled yourself diff --git a/docs/framework-mode.md b/docs/framework-mode.md index ff42d5447..e8a340cd5 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -137,6 +137,30 @@ results and never renders JSX. Routed `src/cli/**` commands and Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` is plain. +## Release identity in source: `agent-bundle/meta` + +Plugin source reads its own identity from the framework instead of +maintaining a hand-written `src/lib/version.ts`: + +```ts +import { name, version } from 'agent-bundle/meta'; +``` + +The compiler replaces the specifier in every compiled surface with the exact +`{ name, packageName, packageVersion, version }` the artifact manifests, +`inspect`, and dev status report (see +[Entry conventions](entry-conventions.md#agent-bundlemeta--build-time-release-identity)). + +Unit tests need no build to load such a module: `agentBundleRstest()` and +`agentBundleBrowserRstest()` (`agent-bundle/rstest`) alias `agent-bundle/meta` +to a generated module carrying the same identity, written from the same +compiler pass to `.agent-bundle/test/meta.mjs`. Run every pool that reaches +that source — plain unit tests included — through the preset (pass `include` +to point it at the pool's files), and `renderRoute`, `invokeCli`, and direct +imports all observe the package identity. Outside the compiler and outside +those presets the published module raises `AB4760`, whose recovery names the +alias a custom runner must add; it never reports a fabricated identity. + ## Skills: convention, override, and rendered documents `src/skills//SKILL.md` ships with no declaration. Config wins, diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index b48325058..b46f56646 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -373,6 +373,15 @@ their own Rstest run, because rendering a route requires Node's `react-server` condition for the whole worker process. Keep them out of the project's ordinary `rstest` run. +The same configuration aliases `agent-bundle/meta` to a generated module +carrying the project identity the compiler pass reported (`name`, +`packageName`, `packageVersion`, `version`, exactly what a build stamps), so +source that imports it loads under the pool without a build. A pool that is +not built from `agentBundleRstest()` (or `agentBundleBrowserRstest()`) has no +such alias, and importing that source raises `AB4760`; build the pool that +reaches it from the preset too — `agentBundleRstest({ include: ['tests/unit/**/*.test.ts'] })` +— or add the alias the diagnostic names. + `agent-bundle/test` holds the helpers. `renderRoute` executes a route — by compiled route id, or by importing the module directly — through the real renderer and the real request store, and resolves to the final Agent Document: diff --git a/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts b/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts new file mode 100644 index 000000000..15d382976 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/agent-bundle.config.ts @@ -0,0 +1,12 @@ +// A project whose source imports `agent-bundle/meta` (issue #386). Plain +// object export, like every other repository fixture. `plugin.version` is +// omitted on purpose: the resolved version, `packageName`, and +// `packageVersion` all derive from the sibling package.json, which is the +// identity the Rstest presets must hand to source under test. +export default { + plugin: { + description: 'Source that reads its own release identity from agent-bundle/meta.', + name: 'meta-consumer', + }, + targets: ['claude'], +}; diff --git a/packages/agent-bundle/fixtures/meta-consumer/package.json b/packages/agent-bundle/fixtures/meta-consumer/package.json new file mode 100644 index 000000000..db040329a --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/package.json @@ -0,0 +1,7 @@ +{ + "name": "meta-consumer-fixture", + "version": "3.4.5", + "private": true, + "type": "module", + "description": "Fixture project whose source imports agent-bundle/meta; its release identity comes from this file (issue #386)." +} diff --git a/packages/agent-bundle/fixtures/meta-consumer/rstest.config.ts b/packages/agent-bundle/fixtures/meta-consumer/rstest.config.ts new file mode 100644 index 000000000..79088ff3c --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/rstest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@rstest/core'; + +import { agentBundleRstest } from '../../src/rstest/index.ts'; + +/** + * The consumer's ordinary unit pool, built from the shipped preset so a plain + * test that imports a source module reading `agent-bundle/meta` loads it with + * the project identity (issue #386). A repository fixture reaches the preset + * through source; a consumer imports it from `agent-bundle/rstest`. + */ +export default defineConfig(await agentBundleRstest({ + include: ['tests/unit/**/*.test.ts'], + root: import.meta.dirname, +})); diff --git a/packages/agent-bundle/fixtures/meta-consumer/rstest.no-preset.config.ts b/packages/agent-bundle/fixtures/meta-consumer/rstest.no-preset.config.ts new file mode 100644 index 000000000..68bf368b1 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/rstest.no-preset.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@rstest/core'; + +/** + * The pool issue #386 reported: the same unit tests without the preset, so + * `agent-bundle/meta` resolves to the published throwing module. The + * repository test drives this configuration only to prove that failure is + * the `AB4760` diagnostic — it is not a configuration a consumer should copy. + */ +export default defineConfig({ + include: ['tests/unit/**/*.test.ts'], +}); diff --git a/packages/agent-bundle/fixtures/meta-consumer/rstest.route-unit.config.ts b/packages/agent-bundle/fixtures/meta-consumer/rstest.route-unit.config.ts new file mode 100644 index 000000000..d8f1cbbb1 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/rstest.route-unit.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@rstest/core'; + +import { agentBundleRstest } from '../../src/rstest/index.ts'; + +/** The consumer's route-unit pool: the preset's defaults over this fixture root. */ +export default defineConfig(await agentBundleRstest({ root: import.meta.dirname })); diff --git a/packages/agent-bundle/fixtures/meta-consumer/src/cli/version.ts b/packages/agent-bundle/fixtures/meta-consumer/src/cli/version.ts new file mode 100644 index 000000000..c35f0a055 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/src/cli/version.ts @@ -0,0 +1,21 @@ +import type { CliRouteConfig } from 'agent-bundle'; +import { z } from 'zod'; + +import { identity } from '../lib/identity.ts'; + +export const config = { + description: 'Prints the identity agent-bundle/meta resolved to.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({}).strict(); + +export const resultSchema = z.object({ + name: z.string(), + packageName: z.string().optional(), + packageVersion: z.string().optional(), + version: z.string(), +}).strict(); + +export default async function version() { + return { ...identity }; +} diff --git a/packages/agent-bundle/fixtures/meta-consumer/src/lib/identity.ts b/packages/agent-bundle/fixtures/meta-consumer/src/lib/identity.ts new file mode 100644 index 000000000..79e1cf699 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/src/lib/identity.ts @@ -0,0 +1,14 @@ +import meta, { name, packageName, packageVersion, version } from 'agent-bundle/meta'; + +/** + * The pattern the framework-mode guidance recommends: no hand-written + * `src/lib/version.ts`, the module reads identity from the framework. Every + * binding is read at module evaluation, so importing this module outside a + * compiled surface or an aliasing test pool is exactly the failure #386 + * reports. + */ +export const identity = Object.freeze({ name, packageName, packageVersion, version }); + +export const frozenMeta = meta; + +export const banner = `${name} ${version}`; diff --git a/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts b/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts new file mode 100644 index 000000000..6ec1de0f3 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/src/mcp/consumer/tools/identity.ts @@ -0,0 +1,34 @@ +import { Agent } from '@agent-bundle/runtime'; +import { createElement } from 'react'; +import { z } from 'zod'; + +import { banner, identity } from '../../../lib/identity.ts'; + +export const config = { + annotations: { readOnlyHint: true }, + description: 'Reports the identity agent-bundle/meta resolved to.', + title: 'Identity', +}; + +export const inputSchema = z.object({}); + +export const resultSchema = z.object({ + banner: z.string(), + name: z.string(), + packageName: z.string().optional(), + packageVersion: z.string().optional(), + version: z.string(), +}); + +export default async function Identity() { + // The document value is JSON; the optional npm axes are omitted when absent + // rather than carried as `undefined`. + const value = { + banner, + name: identity.name, + ...(identity.packageName === undefined ? {} : { packageName: identity.packageName }), + ...(identity.packageVersion === undefined ? {} : { packageVersion: identity.packageVersion }), + version: identity.version, + }; + return createElement(Agent.Result, { value }, createElement(Agent.Text, null, banner)); +} diff --git a/packages/agent-bundle/fixtures/meta-consumer/tests/route-unit/identity.test.ts b/packages/agent-bundle/fixtures/meta-consumer/tests/route-unit/identity.test.ts new file mode 100644 index 000000000..1ce558cd9 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/tests/route-unit/identity.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from '@rstest/core'; + +import { cliJson, invokeCli } from '../../../../src/test/cli.ts'; +import { expectDocument } from '../../../../src/test/matchers.ts'; +import { renderRoute } from '../../../../src/test/render.ts'; +import { testManifest } from '../../../../src/test/registry.ts'; + +const expected = { + name: 'meta-consumer', + packageName: 'meta-consumer-fixture', + packageVersion: '3.4.5', + version: '3.4.5', +}; + +it('renderRoute reaches a route whose module imports agent-bundle/meta', async () => { + const manifest = testManifest(); + const rendered = await renderRoute('tool:consumer/identity'); + + expectDocument(rendered).toHaveStatus('success').toContainText('meta-consumer 3.4.5'); + expect(rendered.result).toEqual({ banner: 'meta-consumer 3.4.5', ...expected }); + // The identity the route observed is the one the compiler pass reported. + expect(manifest.plugin).toEqual(expected); +}); + +it('invokeCli reaches a command whose module imports agent-bundle/meta', async () => { + const run = await invokeCli(['version']); + + expect(run.exitCode).toBe(0); + expect(run.stderr).toBe(''); + expect(cliJson(run)).toEqual(expected); +}); diff --git a/packages/agent-bundle/fixtures/meta-consumer/tests/unit/identity.test.ts b/packages/agent-bundle/fixtures/meta-consumer/tests/unit/identity.test.ts new file mode 100644 index 000000000..bd5bdf8d7 --- /dev/null +++ b/packages/agent-bundle/fixtures/meta-consumer/tests/unit/identity.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from '@rstest/core'; + +import { banner, frozenMeta, identity } from '../../src/lib/identity.ts'; + +// The values the sibling package.json and agent-bundle.config.ts declare; the +// spawning repository test cross-checks them against the files themselves. +const expected = { + name: 'meta-consumer', + packageName: 'meta-consumer-fixture', + packageVersion: '3.4.5', + version: '3.4.5', +}; + +it('loads a source module importing agent-bundle/meta with the package identity', () => { + expect(identity).toEqual(expected); + expect(banner).toBe('meta-consumer 3.4.5'); +}); + +it('serves the frozen aggregate the published type declares', () => { + expect(frozenMeta).toEqual(expected); + expect(Object.isFrozen(frozenMeta)).toBe(true); +}); diff --git a/packages/agent-bundle/src/build/meta.ts b/packages/agent-bundle/src/build/meta.ts index f6c9b04d2..a29d47237 100644 --- a/packages/agent-bundle/src/build/meta.ts +++ b/packages/agent-bundle/src/build/meta.ts @@ -1,7 +1,6 @@ import { join } from 'node:path'; import type { AgentBundleMeta } from '../meta.ts'; -import type { NormalizedMetadata } from '../core/types.ts'; /** * The reserved namespace (under each build's output root) whose paths @@ -33,8 +32,20 @@ export const metaModuleSpecifier = 'agent-bundle/meta'; export const generatedMetaModulePath = (outputRoot: string): string => join(outputRoot, generatedModulesDirname, 'meta.mjs'); +/** + * The identity axes {@link projectMeta} reads. Normalized project metadata + * satisfies it directly, and so does the test manifest's plugin identity, so + * the build and the Rstest presets stamp one identity through one function. + */ +export interface ProjectMetaSource { + readonly name: string; + readonly packageName?: string | undefined; + readonly packageVersion?: string | undefined; + readonly version: string; +} + /** The exact identity a build stamps into every compiled surface. */ -export const projectMeta = (metadata: NormalizedMetadata): AgentBundleMeta => Object.freeze({ +export const projectMeta = (metadata: ProjectMetaSource): AgentBundleMeta => Object.freeze({ name: metadata.name, packageName: metadata.packageName, packageVersion: metadata.packageVersion, diff --git a/packages/agent-bundle/src/meta-diagnostic.ts b/packages/agent-bundle/src/meta-diagnostic.ts new file mode 100644 index 000000000..d577fd2e0 --- /dev/null +++ b/packages/agent-bundle/src/meta-diagnostic.ts @@ -0,0 +1,62 @@ +import type { Diagnostic } from './core/diagnostics.ts'; +import { CodedError } from './core/errors.ts'; + +/** + * The diagnostic the published `agent-bundle/meta` module raises when a + * module evaluates it outside every surface the compiler replaces it in + * (issue #386). It lives beside `meta.ts` rather than inside it so tests and + * the Rstest presets can name the code and the recovery without evaluating + * the throwing module. + */ +export const META_UNAVAILABLE_CODE = 'AB4760'; + +export type MetaUnavailableCode = typeof META_UNAVAILABLE_CODE; + +export const META_UNAVAILABLE_MESSAGE = + 'agent-bundle/meta is available only inside a surface Agent Bundle compiles; ' + + 'a plugin module reached it outside the compiler, so no project identity is available.'; + +/** + * The exact fix. `agentBundleRstest()` and `agentBundleBrowserRstest()` alias + * the specifier to a generated module carrying the compiled identity, so a + * unit or route-unit test never sees this diagnostic; a custom runner has to + * alias it the same way. + */ +export const META_UNAVAILABLE_RECOVERY = + 'Run the test under agentBundleRstest() or agentBundleBrowserRstest() from agent-bundle/rstest, ' + + 'which alias agent-bundle/meta to the project identity the compiler stamps, or compile the surface with `agent-bundle build`. ' + + 'In a custom test runner, alias `agent-bundle/meta` (resolve.alias, exact match) to a module exporting ' + + '{ name, packageName, packageVersion, version } plus a frozen `meta` default computed from the project\'s ' + + 'agent-bundle.config.ts plugin name and package.json version — the `.agent-bundle/test/meta.mjs` module ' + + 'agentBundleRstest() writes is that module.'; + +/** The structured diagnostic, in the same shape every other AB code reports. */ +export const metaUnavailableDiagnostic = (): Diagnostic => Object.freeze({ + code: META_UNAVAILABLE_CODE, + message: META_UNAVAILABLE_MESSAGE, + recovery: META_UNAVAILABLE_RECOVERY, + severity: 'error', +}); + +/** + * Thrown by every binding of the published `agent-bundle/meta` module. The + * message carries the code and the recovery inline so a runner that prints + * only `error.message` still shows the fix; `code`, `recovery`, and the + * structured `diagnostic` stay addressable for programmatic reporting. + */ +export class MetaUnavailableError extends CodedError { + readonly diagnostic: Diagnostic; + + readonly recovery: string; + + constructor() { + const diagnostic = metaUnavailableDiagnostic(); + super( + 'AgentBundleMetaUnavailableError', + META_UNAVAILABLE_CODE, + `[${META_UNAVAILABLE_CODE}] ${diagnostic.message}\n recovery: ${META_UNAVAILABLE_RECOVERY}`, + ); + this.diagnostic = diagnostic; + this.recovery = META_UNAVAILABLE_RECOVERY; + } +} diff --git a/packages/agent-bundle/src/meta.ts b/packages/agent-bundle/src/meta.ts index 8b8075e04..28e8b8bfd 100644 --- a/packages/agent-bundle/src/meta.ts +++ b/packages/agent-bundle/src/meta.ts @@ -1,10 +1,15 @@ +import { MetaUnavailableError } from './meta-diagnostic.ts'; + /** * The build-time project identity constant (issue #237). This package * subpath is replaced by the compiler in every compiled plugin surface — * Node script, CLI, MCP entry, hook, and package bundles, plus browser MCP * App bundles — with the exact identity the artifact manifests, `inspect`, * and dev status report. Plugin source imports it instead of maintaining a - * hand-written `src/lib/version.ts`. + * hand-written `src/lib/version.ts`. Under `agentBundleRstest()` and + * `agentBundleBrowserRstest()` the specifier is aliased to a generated module + * carrying the same identity, so unit and route-unit tests load such source + * without a build (issue #386). */ export interface AgentBundleMeta { /** The host-native plugin slug from `plugin.name`; never the npm package name. */ @@ -27,13 +32,14 @@ export interface AgentBundleMeta { * bundle emits `export default ;` above the const initializers, so a * top-level `throw` or a `const`-backed default export surfaces a TDZ * ReferenceError instead of this message (the same contract as - * `agent-bundle/mcp-apps`). + * `agent-bundle/mcp-apps`). The error is the `AB4760` diagnostic: the code, + * the message, and the exact recovery (run under `agentBundleRstest()`, or + * alias the specifier in a custom runner) ride on the thrown value. The + * importing module is not observable from a module evaluated through ESM + * linking, so the message names the situation rather than a file. */ function throwUnavailableEntrypoint(): never { - throw new Error( - 'agent-bundle/meta is available only inside a surface Agent Bundle compiles; ' - + 'a plugin module reached it outside the compiler.', - ); + throw new MetaUnavailableError(); } export const meta: AgentBundleMeta = throwUnavailableEntrypoint(); diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index 6ccd6183d..b8fc836ae 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -6,6 +6,7 @@ import { compileMcpApps } from '../build/mcp-apps.ts'; import type { NormalizedMcpApp } from '../core/types.ts'; import { compileTestManifest, proofLevelLabel, type TestableAppDescriptor } from '../test/manifest.ts'; import { writeBrowserTestSetup } from './browser-setup-module.ts'; +import { metaModuleAlias, writeTestMetaModule } from './meta-module.ts'; const browserAppInclude = 'tests/browser-app/**/*.test.{ts,tsx}'; @@ -34,6 +35,8 @@ export interface AgentBundleBrowserRstestConfig { viewport: { height: 900; width: 1440 }; }; include: string[]; + /** Routes the reserved `agent-bundle/meta` specifier to the generated identity module. */ + resolve: { alias: { [specifier: string]: string } }; setupFiles: string[]; source?: { tsconfigPath: string }; tools: { @@ -133,6 +136,9 @@ export const agentBundleBrowserRstest = async ( ); } const setup = await writeBrowserTestSetup(root, compiled); + // The compiled app bundles already carry the stamped identity; the alias + // covers test files and view helpers the browser pool bundles itself. + const metaModule = await writeTestMetaModule(root, manifest.plugin); const tsconfigPath = resolve(root, 'tsconfig.json'); return { browser: { @@ -143,6 +149,7 @@ export const agentBundleBrowserRstest = async ( viewport: { height: 900, width: 1440 }, }, include: [...(options.include ?? [browserAppInclude])], + resolve: { alias: metaModuleAlias(metaModule) }, setupFiles: [setup, ...(options.setupFiles ?? [])], ...(existsSync(tsconfigPath) ? { source: { tsconfigPath } } : {}), tools: { diff --git a/packages/agent-bundle/src/rstest/index.ts b/packages/agent-bundle/src/rstest/index.ts index abcdb90a4..2613295b8 100644 --- a/packages/agent-bundle/src/rstest/index.ts +++ b/packages/agent-bundle/src/rstest/index.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { compileTestManifest } from '../test/manifest.ts'; +import { metaModuleAlias, writeTestMetaModule } from './meta-module.ts'; import { writeRouteTestSetup } from './setup-module.ts'; export { agentBundleBrowserRstest } from './browser.ts'; @@ -60,6 +61,8 @@ export interface AgentBundleRstestOptions { export interface AgentBundleRstestConfig { include: string[]; pool: { execArgv: string[]; type: 'forks' }; + /** Routes the reserved `agent-bundle/meta` specifier to the generated identity module. */ + resolve: { alias: { [specifier: string]: string } }; setupFiles: string[]; source?: { tsconfigPath: string }; testEnvironment: 'node'; @@ -77,6 +80,11 @@ export interface AgentBundleRstestConfig { * pool, the test environment, the TypeScript transform, and the generated * route registry the helpers in `agent-bundle/test` read. No artifact is built. * + * The same pass supplies the project identity, so the configuration aliases + * `agent-bundle/meta` to a generated module carrying exactly the + * `{ name, packageName, packageVersion, version }` a build would stamp: any + * source module importing it loads under the pool instead of raising `AB4760`. + * * ```ts * import { defineConfig } from '@rstest/core'; * import { agentBundleRstest } from 'agent-bundle/rstest'; @@ -96,10 +104,12 @@ export const agentBundleRstest = async ( root, }); const setup = await writeRouteTestSetup(root, manifest); + const metaModule = await writeTestMetaModule(root, manifest.plugin); const tsconfigPath = resolve(root, 'tsconfig.json'); return { include: [...(options.include ?? [routeUnitInclude])], pool: { execArgv: [...reactServerConditions], type: 'forks' }, + resolve: { alias: metaModuleAlias(metaModule) }, setupFiles: [setup, ...(options.setupFiles ?? [])], ...(existsSync(tsconfigPath) ? { source: { tsconfigPath } } : {}), testEnvironment: 'node', diff --git a/packages/agent-bundle/src/rstest/meta-module.ts b/packages/agent-bundle/src/rstest/meta-module.ts new file mode 100644 index 000000000..6a756f168 --- /dev/null +++ b/packages/agent-bundle/src/rstest/meta-module.ts @@ -0,0 +1,36 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import { generatedMetaModuleSource, metaModuleSpecifier, projectMeta } from '../build/meta.ts'; +import type { TestManifestPluginIdentity } from '../test/manifest.ts'; + +/** + * The `resolve.alias` key both Rstest presets set for `agent-bundle/meta`. + * The trailing `$` is Rspack's exact-match marker — the same key the browser + * MCP App compiler uses — so `agent-bundle/meta/anything` never matches. + */ +export const metaModuleAliasKey = `${metaModuleSpecifier}$`; + +/** + * Writes the identity module a test pool serves for `agent-bundle/meta` + * (issue #386). The source is the very module the build injects + * (`generatedMetaModuleSource` over `projectMeta`), fed from the manifest's + * plugin identity — the identity the same compiler pass reports in + * `initialize` — so a source module importing `{ name, version }` observes + * under a test exactly what a compiled surface would. It lands beside the + * generated route registry under the project's `.agent-bundle/test` + * directory, which Rstest bundles like project source. + */ +export const writeTestMetaModule = async ( + projectRoot: string, + plugin: TestManifestPluginIdentity, +): Promise => { + const target = resolve(projectRoot, '.agent-bundle', 'test', 'meta.mjs'); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, generatedMetaModuleSource(projectMeta(plugin)), 'utf8'); + return target; +}; + +/** The `resolve.alias` record routing the reserved specifier to a written identity module. */ +export const metaModuleAlias = (metaModulePath: string): { [specifier: string]: string } => + ({ [metaModuleAliasKey]: metaModulePath }); diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index e9eb8fd4a..52444af19 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -1034,9 +1034,10 @@ it('rejects the built identity module with the intended error, not a TDZ Referen // `agent-bundle/meta` carries the same dist contract as `agent-bundle/mcp-apps`: // a compiled surface resolves it to the generated identity, and anything // else must say so rather than report a fabricated identity. - await expect(import('../src/meta.ts')).rejects.toThrow( - 'agent-bundle/meta is available only inside a surface Agent Bundle compiles', - ); + await expect(import('../src/meta.ts')).rejects.toMatchObject({ + code: 'AB4760', + message: expect.stringContaining('agent-bundle/meta is available only inside a surface Agent Bundle compiles'), + }); const distEntry = join(agentBundlePackageRoot, 'dist', 'meta.js'); const { code, stderr } = await new Promise<{ code: number | null; stderr: string }>((resolve) => { @@ -1055,7 +1056,11 @@ it('rejects the built identity module with the intended error, not a TDZ Referen }); }); expect(code).toBe(1); - expect(stderr).toContain('agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + expect(stderr).toContain('[AB4760] agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + // The recovery rides on the message, so a bare `node` process prints the + // exact fix without any diagnostic formatter (#386). + expect(stderr).toContain('recovery: Run the test under agentBundleRstest() or agentBundleBrowserRstest()'); + expect(stderr).toContain("code: 'AB4760'"); expect(stderr).not.toContain('ReferenceError'); }); diff --git a/packages/agent-bundle/tests/rstest-meta-alias.test.ts b/packages/agent-bundle/tests/rstest-meta-alias.test.ts new file mode 100644 index 000000000..1612f7f7c --- /dev/null +++ b/packages/agent-bundle/tests/rstest-meta-alias.test.ts @@ -0,0 +1,98 @@ +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { describe, expect, it } from '@rstest/core'; + +import { generatedMetaModuleSource, metaModuleSpecifier, projectMeta } from '../src/build/meta.ts'; +import { isDiagnostic } from '../src/core/diagnostics.ts'; +import { ProjectService } from '../src/dev/project-service.ts'; +import type { AgentBundleMeta } from '../src/meta.ts'; +import { + META_UNAVAILABLE_CODE, + META_UNAVAILABLE_RECOVERY, + MetaUnavailableError, + metaUnavailableDiagnostic, +} from '../src/meta-diagnostic.ts'; +import { agentBundleRstest } from '../src/rstest/index.ts'; +import { metaModuleAliasKey } from '../src/rstest/meta-module.ts'; + +const fixtureRoot = resolve(import.meta.dirname, '../fixtures/meta-consumer'); +const metaModulePath = resolve(fixtureRoot, '.agent-bundle', 'test', 'meta.mjs'); + +type GeneratedMetaModule = AgentBundleMeta & { readonly default: AgentBundleMeta; readonly meta: AgentBundleMeta }; + +describe('agentBundleRstest aliases agent-bundle/meta (#386)', () => { + it('routes the reserved specifier to a generated identity module under the project temp dir', async () => { + const config = await agentBundleRstest({ root: fixtureRoot }); + + expect(metaModuleAliasKey).toBe(`${metaModuleSpecifier}$`); + expect(config.resolve).toEqual({ alias: { [metaModuleAliasKey]: metaModulePath } }); + }); + + it('writes exactly the module the build injects, fed from the same package identity', async () => { + await agentBundleRstest({ root: fixtureRoot }); + const written = await readFile(metaModulePath, 'utf8'); + + // The build stamps `projectMeta(model.metadata)` from the same preparation. + const prepared = await new ProjectService({ root: fixtureRoot }).prepare('inspect'); + expect(prepared.model).toBeDefined(); + const stamped = projectMeta(prepared.model!.metadata); + expect(written).toBe(generatedMetaModuleSource(stamped)); + + const packageJson = JSON.parse(await readFile(resolve(fixtureRoot, 'package.json'), 'utf8')) as { + readonly name: string; + readonly version: string; + }; + expect(stamped).toEqual({ + name: 'meta-consumer', + packageName: packageJson.name, + packageVersion: packageJson.version, + version: packageJson.version, + }); + + const loaded = await import(pathToFileURL(metaModulePath).href) as GeneratedMetaModule; + expect({ + name: loaded.name, + packageName: loaded.packageName, + packageVersion: loaded.packageVersion, + version: loaded.version, + }).toEqual(stamped); + expect(loaded.meta).toEqual(stamped); + expect(loaded.default).toBe(loaded.meta); + expect(Object.isFrozen(loaded.meta)).toBe(true); + }); +}); + +describe('the published agent-bundle/meta module outside the compiler', () => { + it('rejects with the AB4760 diagnostic carrying the exact recovery', async () => { + let thrown: unknown; + try { + await import('../src/meta.ts'); + } catch (error: unknown) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(MetaUnavailableError); + const error = thrown as MetaUnavailableError; + expect(error.name).toBe('AgentBundleMetaUnavailableError'); + expect(error.code).toBe(META_UNAVAILABLE_CODE); + expect(error.code).toBe('AB4760'); + expect(error.recovery).toBe(META_UNAVAILABLE_RECOVERY); + expect(error.message).toContain('[AB4760] agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + expect(error.message).toContain('recovery: Run the test under agentBundleRstest() or agentBundleBrowserRstest()'); + expect(error.message).toContain('alias `agent-bundle/meta`'); + expect(error.diagnostic).toEqual(metaUnavailableDiagnostic()); + }); + + it('reports the diagnostic in the shared AB diagnostic shape', () => { + const diagnostic = metaUnavailableDiagnostic(); + + expect(isDiagnostic(diagnostic)).toBe(true); + expect(diagnostic).toMatchObject({ code: 'AB4760', severity: 'error' }); + expect(diagnostic.recovery).toContain('agentBundleRstest()'); + expect(diagnostic.recovery).toContain('agent-bundle build'); + expect(diagnostic.recovery).toContain('.agent-bundle/test/meta.mjs'); + expect(Object.isFrozen(diagnostic)).toBe(true); + }); +}); diff --git a/packages/agent-bundle/tests/rstest-meta-consumer.test.ts b/packages/agent-bundle/tests/rstest-meta-consumer.test.ts new file mode 100644 index 000000000..314bae8ae --- /dev/null +++ b/packages/agent-bundle/tests/rstest-meta-consumer.test.ts @@ -0,0 +1,84 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +const fixtureRoot = resolve(import.meta.dirname, '../fixtures/meta-consumer'); +const require = createRequire(import.meta.url); +const rstestBin = resolve(dirname(require.resolve('@rstest/core/package.json')), 'bin', 'rstest.js'); + +interface RstestRun { + readonly code: number | null; + readonly output: string; +} + +/** + * Runs one of the fixture's own Rstest pools the way a consumer would: + * `rstest --config ` from the project root, resolving the preset from + * this repository's source. The default reporter is pinned because Rstest + * switches to its agent report when it detects a non-interactive caller; + * output is merged so a failure prints the whole run in the assertion message. + */ +const runFixturePool = (configFile: string): Promise => + new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [rstestBin, '--config', configFile, '--reporter=default'], { + cwd: fixtureRoot, + env: { ...process.env, CI: '1', FORCE_COLOR: '0', NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { output += chunk; }); + child.stderr.on('data', (chunk: string) => { output += chunk; }); + child.on('error', reject); + child.on('close', (code) => { resolvePromise({ code, output }); }); + }); + +describe('a consumer project whose source imports agent-bundle/meta (#386)', () => { + it('declares in package.json the identity its tests assert', async () => { + const packageJson = JSON.parse(await readFile(resolve(fixtureRoot, 'package.json'), 'utf8')) as { + readonly name: string; + readonly version: string; + }; + const unitTest = await readFile(resolve(fixtureRoot, 'tests/unit/identity.test.ts'), 'utf8'); + const routeTest = await readFile(resolve(fixtureRoot, 'tests/route-unit/identity.test.ts'), 'utf8'); + + for (const source of [unitTest, routeTest]) { + expect(source).toContain(`packageName: '${packageJson.name}'`); + expect(source).toContain(`packageVersion: '${packageJson.version}'`); + expect(source).toContain(`version: '${packageJson.version}'`); + } + }); + + it('runs plain unit tests that import the module under agentBundleRstest()', { timeout: 180_000 }, async () => { + const run = await runFixturePool('rstest.config.ts'); + + expect(run.output).not.toContain('AB4760'); + expect(run.output).not.toContain('is available only inside a surface Agent Bundle compiles'); + expect(run.output).toMatch(/Tests\s+2 passed/u); + expect(run.code).toBe(0); + }); + + it('renders and dispatches routes reaching the module through renderRoute and invokeCli', { timeout: 180_000 }, async () => { + const run = await runFixturePool('rstest.route-unit.config.ts'); + + expect(run.output).not.toContain('AB4760'); + expect(run.output).toMatch(/Tests\s+2 passed/u); + expect(run.code).toBe(0); + }); + + it('fails the same unit tests with AB4760 when the pool is not built from the preset', { timeout: 180_000 }, async () => { + // The published module is what `agent-bundle/meta` resolves to without + // the alias; this pool runs after `pnpm build`, so that module is dist/meta.js. + const run = await runFixturePool('rstest.no-preset.config.ts'); + + expect(run.code).not.toBe(0); + expect(run.output).toContain('[AB4760] agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + expect(run.output).toContain('recovery: Run the test under agentBundleRstest() or agentBundleBrowserRstest()'); + expect(run.output).not.toContain('ReferenceError'); + expect(run.output).not.toMatch(/Tests\s+2 passed/u); + }); +}); diff --git a/packages/agent-bundle/tests/test-browser-rstest.test.ts b/packages/agent-bundle/tests/test-browser-rstest.test.ts index ed0e05b02..30fbe0b06 100644 --- a/packages/agent-bundle/tests/test-browser-rstest.test.ts +++ b/packages/agent-bundle/tests/test-browser-rstest.test.ts @@ -27,6 +27,7 @@ describe('agentBundleBrowserRstest', () => { viewport: { height: 900, width: 1440 }, }, include: ['tests/browser-app/**/*.test.{ts,tsx}'], + resolve: { alias: { 'agent-bundle/meta$': resolve(fixtureRoot, '.agent-bundle/test/meta.mjs') } }, setupFiles: [ resolve(fixtureRoot, '.agent-bundle/test/browser-app-setup.mjs'), './tests/setup.ts', @@ -50,6 +51,13 @@ describe('agentBundleBrowserRstest', () => { expect(setup).toContain('"html":"'); expect(setup).toContain('"proofLevel":"browser-app"'); expect(setup).toContain('"output":'); + + // The same compiler pass stamps the identity module `agent-bundle/meta` + // resolves to inside the browser pool (#386). + const metaModule = await readFile(config.resolve.alias['agent-bundle/meta$']!, 'utf8'); + expect(metaModule).toContain('export const name = "route-harness";'); + expect(metaModule).toContain('export const version = "1.0.0";'); + expect(metaModule).toContain('export const packageName = undefined;'); }); it('rejects a browser pool whose compiled manifest declares no apps', async () => { diff --git a/rstest.config.ts b/rstest.config.ts index 7356b7b09..90a4f494b 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; import { + fixtureProjectTestFiles, mcpConformanceTestFiles, projectionTestFiles, routeUnitTestFiles, @@ -14,6 +15,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [ + ...fixtureProjectTestFiles, ...mcpConformanceTestFiles, ...projectionTestFiles, ...routeUnitTestFiles, diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index e2169e90d..a85932a43 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -55,6 +55,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/provider-typegen.test.ts', 'packages/agent-bundle/tests/public-api.test.ts', 'packages/agent-bundle/tests/rsc-runtime-topology-script.test.ts', + 'packages/agent-bundle/tests/rstest-meta-consumer.test.ts', 'packages/agent-bundle/tests/script-playground-service.test.ts', 'packages/agent-bundle/tests/target-hook-contract.test.ts', 'packages/agent-bundle/tests/target-mcp-runtime.test.ts', @@ -183,3 +184,14 @@ export const projectionTestFiles: readonly string[] = [ export const templateTestFiles: readonly string[] = [ 'packages/create-agent-bundle/templates/**', ]; + +/** + * Fixture projects that carry their own Rstest pools: their test files run + * inside the fixture through its own configuration (the spawning repository + * test drives them, e.g. rstest-meta-consumer.test.ts over + * `fixtures/meta-consumer`), never through the workspace pools, whose include + * glob would otherwise collect them without the preset that makes them load. + */ +export const fixtureProjectTestFiles: readonly string[] = [ + 'packages/agent-bundle/fixtures/**/tests/**', +]; diff --git a/rstest.unit.config.ts b/rstest.unit.config.ts index c474463a9..763ec30b4 100644 --- a/rstest.unit.config.ts +++ b/rstest.unit.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from '@rstest/core'; import { + fixtureProjectTestFiles, integrationTestFiles, mcpConformanceTestFiles, nightlyEvidenceTestFiles, @@ -19,6 +20,7 @@ export default defineConfig({ 'packages/**/tests/**/*.test.ts', ], exclude: [ + ...fixtureProjectTestFiles, ...integrationTestFiles, ...mcpConformanceTestFiles, ...nightlyEvidenceTestFiles, From 77a1587e1d1a1830616f1fb8be78e2aaef97bfd2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 08:45:34 +0000 Subject: [PATCH 2/6] docs(changeset): rewrite #386 changeset as an imperative consumer-facing summary --- .changeset/386-meta-rstest-alias.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/386-meta-rstest-alias.md b/.changeset/386-meta-rstest-alias.md index 5cbd688c9..fa5dd83f3 100644 --- a/.changeset/386-meta-rstest-alias.md +++ b/.changeset/386-meta-rstest-alias.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -`agentBundleRstest()` and `agentBundleBrowserRstest()` now alias `agent-bundle/meta` to a generated identity module (`.agent-bundle/test/meta.mjs`) written from the same compiler pass with the same `generatedMetaModuleSource` the build injects, so a source module importing `{ name, version, packageName, packageVersion }` loads under unit, route-unit, `renderRoute`, and `invokeCli` tests with the identity `package.json` and `agent-bundle.config.ts` declare instead of failing at import (#386). The published `agent-bundle/meta` module reached outside every compiled surface now throws the structured `AB4760` diagnostic — code, message, and the exact recovery (run under the preset, or alias the specifier in a custom runner) on the thrown error — rather than a bare `Error`. +Alias `agent-bundle/meta` automatically in `agentBundleRstest()` and `agentBundleBrowserRstest()` (`agent-bundle/rstest`), so a source module that imports `{ name, version, packageName, packageVersion }` loads under unit, route-unit, `renderRoute`, and `invokeCli` tests with the identity `package.json` and `agent-bundle.config.ts` declare — the same values a build stamps — instead of failing at import. Throw the new `AB4760` diagnostic from the published `agent-bundle/meta` module when it is reached outside every compiled surface and outside those presets; its `code` and `recovery` name the fix (run the pool through the preset, or alias the specifier in a custom runner). (#416) From 6c0f2008120cd238ca5918a31456a51eacb83271 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:20:22 +0000 Subject: [PATCH 3/6] test(meta): read the dist AB4760 proof without Node 24's ANSI-colored error dump --- packages/agent-bundle/tests/mcp.test.ts | 12 ++++++++---- .../agent-bundle/tests/rstest-meta-consumer.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index 52444af19..ec00859ed 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -4,6 +4,7 @@ import { access, cp, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { stripVTControlCharacters } from 'node:util'; import { expect, it } from '@rstest/core'; import { Client } from '@modelcontextprotocol/client'; @@ -1056,12 +1057,15 @@ it('rejects the built identity module with the intended error, not a TDZ Referen }); }); expect(code).toBe(1); - expect(stderr).toContain('[AB4760] agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + // Node 24 colorizes the uncaught-error property dump even on a piped + // stderr, so the assertions read the text without its escape sequences. + const plain = stripVTControlCharacters(stderr); + expect(plain).toContain('[AB4760] agent-bundle/meta is available only inside a surface Agent Bundle compiles'); // The recovery rides on the message, so a bare `node` process prints the // exact fix without any diagnostic formatter (#386). - expect(stderr).toContain('recovery: Run the test under agentBundleRstest() or agentBundleBrowserRstest()'); - expect(stderr).toContain("code: 'AB4760'"); - expect(stderr).not.toContain('ReferenceError'); + expect(plain).toContain('recovery: Run the test under agentBundleRstest() or agentBundleBrowserRstest()'); + expect(plain).toContain("code: 'AB4760'"); + expect(plain).not.toContain('ReferenceError'); }); it('uses the selected streamable HTTP manifest with propagated cancellation and cleans data before rejecting tampering', async () => { diff --git a/packages/agent-bundle/tests/rstest-meta-consumer.test.ts b/packages/agent-bundle/tests/rstest-meta-consumer.test.ts index 314bae8ae..00218b9b5 100644 --- a/packages/agent-bundle/tests/rstest-meta-consumer.test.ts +++ b/packages/agent-bundle/tests/rstest-meta-consumer.test.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, resolve } from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; import { describe, expect, it } from '@rstest/core'; @@ -19,7 +20,8 @@ interface RstestRun { * `rstest --config ` from the project root, resolving the preset from * this repository's source. The default reporter is pinned because Rstest * switches to its agent report when it detects a non-interactive caller; - * output is merged so a failure prints the whole run in the assertion message. + * output is merged and stripped of escape sequences so a failure prints the + * whole run in the assertion message and Node's colorized error dumps match. */ const runFixturePool = (configFile: string): Promise => new Promise((resolvePromise, reject) => { @@ -34,7 +36,7 @@ const runFixturePool = (configFile: string): Promise => child.stdout.on('data', (chunk: string) => { output += chunk; }); child.stderr.on('data', (chunk: string) => { output += chunk; }); child.on('error', reject); - child.on('close', (code) => { resolvePromise({ code, output }); }); + child.on('close', (code) => { resolvePromise({ code, output: stripVTControlCharacters(output) }); }); }); describe('a consumer project whose source imports agent-bundle/meta (#386)', () => { From 43482024f2bd10e38fb43d33655ad9ba262fd788 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:29:16 +0000 Subject: [PATCH 4/6] fix(rstest): never serve the model-less manifest placeholder as agent-bundle/meta When preparation produced no plugin model the manifest carries the frozen FALLBACK_PLUGIN_IDENTITY placeholder; the presets now write a self-contained module whose bindings throw AB4760 naming the compiler diagnostics instead of stamping `unknown@0.0.0` as a real identity. --- docs/diagnostics.md | 6 +- packages/agent-bundle/src/rstest/browser.ts | 2 +- packages/agent-bundle/src/rstest/index.ts | 5 +- .../agent-bundle/src/rstest/meta-module.ts | 78 +++++++++++++++++-- packages/agent-bundle/src/test/manifest.ts | 24 +++++- .../tests/rstest-meta-alias.test.ts | 56 ++++++++++++- 6 files changed, 156 insertions(+), 15 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index cd534f7f5..1703e27e4 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -312,7 +312,11 @@ imports a source module that imports `agent-bundle/meta`, no compiled surface replaced the specifier, and every test that touches that module fails at import. `agentBundleRstest()` and `agentBundleBrowserRstest()` prevent this by aliasing the specifier to `.agent-bundle/test/meta.mjs`, generated from the -same compiler pass. +same compiler pass. When that pass produced no plugin model (the configuration +could not be loaded or normalized) there is no identity to stamp, so the +aliased module throws the same `AB4760` naming the compiler diagnostics and +the recovery "fix them, then rerun Rstest" — the manifest's placeholder +identity is never served as a real one. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/rstest/browser.ts b/packages/agent-bundle/src/rstest/browser.ts index b8fc836ae..65fc6bc5c 100644 --- a/packages/agent-bundle/src/rstest/browser.ts +++ b/packages/agent-bundle/src/rstest/browser.ts @@ -138,7 +138,7 @@ export const agentBundleBrowserRstest = async ( const setup = await writeBrowserTestSetup(root, compiled); // The compiled app bundles already carry the stamped identity; the alias // covers test files and view helpers the browser pool bundles itself. - const metaModule = await writeTestMetaModule(root, manifest.plugin); + const metaModule = await writeTestMetaModule(root, manifest); const tsconfigPath = resolve(root, 'tsconfig.json'); return { browser: { diff --git a/packages/agent-bundle/src/rstest/index.ts b/packages/agent-bundle/src/rstest/index.ts index 2613295b8..46611b3d6 100644 --- a/packages/agent-bundle/src/rstest/index.ts +++ b/packages/agent-bundle/src/rstest/index.ts @@ -84,6 +84,9 @@ export interface AgentBundleRstestConfig { * `agent-bundle/meta` to a generated module carrying exactly the * `{ name, packageName, packageVersion, version }` a build would stamp: any * source module importing it loads under the pool instead of raising `AB4760`. + * When preparation produced no plugin model there is no identity to stamp, and + * the aliased module throws `AB4760` naming the compiler diagnostics instead + * of serving the manifest placeholder. * * ```ts * import { defineConfig } from '@rstest/core'; @@ -104,7 +107,7 @@ export const agentBundleRstest = async ( root, }); const setup = await writeRouteTestSetup(root, manifest); - const metaModule = await writeTestMetaModule(root, manifest.plugin); + const metaModule = await writeTestMetaModule(root, manifest); const tsconfigPath = resolve(root, 'tsconfig.json'); return { include: [...(options.include ?? [routeUnitInclude])], diff --git a/packages/agent-bundle/src/rstest/meta-module.ts b/packages/agent-bundle/src/rstest/meta-module.ts index 6a756f168..d8602d68d 100644 --- a/packages/agent-bundle/src/rstest/meta-module.ts +++ b/packages/agent-bundle/src/rstest/meta-module.ts @@ -2,7 +2,11 @@ import { mkdir, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { generatedMetaModuleSource, metaModuleSpecifier, projectMeta } from '../build/meta.ts'; -import type { TestManifestPluginIdentity } from '../test/manifest.ts'; +import { + META_UNAVAILABLE_CODE, + META_UNAVAILABLE_MESSAGE, +} from '../meta-diagnostic.ts'; +import { type AgentBundleTestManifest, isFallbackPluginIdentity } from '../test/manifest.ts'; /** * The `resolve.alias` key both Rstest presets set for `agent-bundle/meta`. @@ -11,23 +15,81 @@ import type { TestManifestPluginIdentity } from '../test/manifest.ts'; */ export const metaModuleAliasKey = `${metaModuleSpecifier}$`; +const metaBindings = ['meta', 'name', 'packageName', 'packageVersion', 'version'] as const; + +/** + * The module a pool serves when the compiler pass produced no plugin model: + * the configuration could not be loaded or normalized, so there is no + * identity to stamp and stamping the manifest placeholder would hand + * application code a fabricated one. Every binding throws the same `AB4760` + * diagnostic the published module raises, with the recovery this situation + * actually has — the compiler diagnostics — instead of the generic one. The + * source is self-contained so it loads however the consumer resolved + * `agent-bundle`. + */ +export const unavailableMetaModuleSource = (manifest: AgentBundleTestManifest): string => { + const prepared = manifest.configPath ?? manifest.projectRoot; + const diagnostics = manifest.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); + const message = [ + `[${META_UNAVAILABLE_CODE}] ${META_UNAVAILABLE_MESSAGE}`, + ` This Rstest pool has no project identity to serve: preparing ${prepared} produced no plugin model.`, + ...(diagnostics.length === 0 + ? [] + : [' compiler diagnostics:', ...diagnostics.map((diagnostic) => ` [${diagnostic.code}] ${diagnostic.message}`)]), + ].join('\n'); + const recovery = 'Fix the compiler diagnostics reported above so agent-bundle.config.ts loads and normalizes, then rerun Rstest; ' + + 'the preset regenerates this module with the project identity.'; + return [ + '// @generated by agent-bundle/rstest. Do not edit: rerun Rstest to regenerate.', + '//', + '// The compiler pass produced no plugin model, so `agent-bundle/meta` has no', + '// identity in this pool. Every binding throws AB4760 through a hoisted', + '// function so the failure is this diagnostic, never a TDZ ReferenceError.', + 'function throwUnavailable() {', + ` const error = new Error(${JSON.stringify(`${message}\n recovery: ${recovery}`)});`, + " error.name = 'AgentBundleMetaUnavailableError';", + ` error.code = ${JSON.stringify(META_UNAVAILABLE_CODE)};`, + ` error.recovery = ${JSON.stringify(recovery)};`, + ' error.diagnostic = Object.freeze({', + ` code: ${JSON.stringify(META_UNAVAILABLE_CODE)},`, + ` message: ${JSON.stringify(message)},`, + ` recovery: ${JSON.stringify(recovery)},`, + " severity: 'error',", + ' });', + ' throw error;', + '}', + ...metaBindings.map((binding) => `export const ${binding} = throwUnavailable();`), + 'export default throwUnavailable();', + '', + ].join('\n'); +}; + /** - * Writes the identity module a test pool serves for `agent-bundle/meta` - * (issue #386). The source is the very module the build injects + * The source a pool serves for `agent-bundle/meta` (issue #386). With a + * model-backed identity it is the very module the build injects * (`generatedMetaModuleSource` over `projectMeta`), fed from the manifest's * plugin identity — the identity the same compiler pass reports in * `initialize` — so a source module importing `{ name, version }` observes - * under a test exactly what a compiled surface would. It lands beside the - * generated route registry under the project's `.agent-bundle/test` - * directory, which Rstest bundles like project source. + * under a test exactly what a compiled surface would. Without one it is the + * throwing module above: the placeholder identity is never served. + */ +export const testMetaModuleSource = (manifest: AgentBundleTestManifest): string => + isFallbackPluginIdentity(manifest.plugin) + ? unavailableMetaModuleSource(manifest) + : generatedMetaModuleSource(projectMeta(manifest.plugin)); + +/** + * Writes the identity module beside the generated route registry under the + * project's `.agent-bundle/test` directory, which Rstest bundles like + * project source, and returns its path for the alias. */ export const writeTestMetaModule = async ( projectRoot: string, - plugin: TestManifestPluginIdentity, + manifest: AgentBundleTestManifest, ): Promise => { const target = resolve(projectRoot, '.agent-bundle', 'test', 'meta.mjs'); await mkdir(dirname(target), { recursive: true }); - await writeFile(target, generatedMetaModuleSource(projectMeta(plugin)), 'utf8'); + await writeFile(target, testMetaModuleSource(manifest), 'utf8'); return target; }; diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index 17962505c..d9ffe6a01 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -122,6 +122,28 @@ export interface TestManifestPluginIdentity { readonly version: string; } +/** + * The identity a manifest carries when preparation produced no plugin model + * (the configuration could not be loaded or normalized). It is one frozen + * placeholder rather than a guess at the project, and {@link isFallbackPluginIdentity} + * recognizes it so no harness surface — the Rstest presets' `agent-bundle/meta` + * alias in particular — ever serves it as a real identity. + */ +export const FALLBACK_PLUGIN_IDENTITY: TestManifestPluginIdentity = Object.freeze({ + name: 'unknown', + version: '0.0.0', +}); + +/** True when a manifest's identity is the model-less placeholder, by reference or by value. */ +export const isFallbackPluginIdentity = (plugin: TestManifestPluginIdentity): boolean => + plugin === FALLBACK_PLUGIN_IDENTITY + || ( + plugin.name === FALLBACK_PLUGIN_IDENTITY.name + && plugin.version === FALLBACK_PLUGIN_IDENTITY.version + && plugin.packageName === undefined + && plugin.packageVersion === undefined + ); + /** The conventional state module the generated route-unit registry can load. */ export interface TestableStateDescriptor { readonly id: string; @@ -277,7 +299,7 @@ export const testManifestFromRouteGraph = (input: { diagnostics: [...(input.diagnostics ?? input.graph.diagnostics)], digest: input.graph.digest, ...(eventRuntimeServerId === undefined ? {} : { eventRuntimeServerId }), - plugin: input.plugin ?? { name: 'unknown', version: '0.0.0' }, + plugin: input.plugin ?? FALLBACK_PLUGIN_IDENTITY, projectRoot: input.projectRoot, proofLevel: ROUTE_UNIT_PROOF_LEVEL, routes, diff --git a/packages/agent-bundle/tests/rstest-meta-alias.test.ts b/packages/agent-bundle/tests/rstest-meta-alias.test.ts index 1612f7f7c..c587a9dc6 100644 --- a/packages/agent-bundle/tests/rstest-meta-alias.test.ts +++ b/packages/agent-bundle/tests/rstest-meta-alias.test.ts @@ -1,5 +1,6 @@ -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { describe, expect, it } from '@rstest/core'; @@ -15,7 +16,9 @@ import { metaUnavailableDiagnostic, } from '../src/meta-diagnostic.ts'; import { agentBundleRstest } from '../src/rstest/index.ts'; -import { metaModuleAliasKey } from '../src/rstest/meta-module.ts'; +import { metaModuleAliasKey, testMetaModuleSource } from '../src/rstest/meta-module.ts'; +import { FALLBACK_PLUGIN_IDENTITY, isFallbackPluginIdentity, testManifestFromRouteGraph } from '../src/test/manifest.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const fixtureRoot = resolve(import.meta.dirname, '../fixtures/meta-consumer'); const metaModulePath = resolve(fixtureRoot, '.agent-bundle', 'test', 'meta.mjs'); @@ -62,6 +65,53 @@ describe('agentBundleRstest aliases agent-bundle/meta (#386)', () => { expect(loaded.default).toBe(loaded.meta); expect(Object.isFrozen(loaded.meta)).toBe(true); }); + + it('never serves the model-less manifest placeholder as an identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-meta-no-model-')); + try { + // An empty root has no agent-bundle.config.ts, so preparation produces + // no plugin model and the manifest carries the placeholder identity. + const config = await agentBundleRstest({ root }); + const written = await readFile(config.resolve.alias[metaModuleAliasKey]!, 'utf8'); + + expect(written).not.toContain('unknown'); + expect(written).not.toContain('0.0.0'); + expect(written).toContain('@generated by agent-bundle/rstest'); + + let thrown: unknown; + try { + await import(pathToFileURL(config.resolve.alias[metaModuleAliasKey]!).href); + } catch (error: unknown) { + thrown = error; + } + const error = thrown as Error & { readonly code: string; readonly recovery: string }; + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('AgentBundleMetaUnavailableError'); + expect(error.code).toBe('AB4760'); + expect(error.message).toContain('[AB4760] agent-bundle/meta is available only inside a surface Agent Bundle compiles'); + expect(error.message).toContain('produced no plugin model'); + expect(error.message).toContain(root); + expect(error.recovery).toContain('Fix the compiler diagnostics'); + expect(error.message).not.toContain('ReferenceError'); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('recognizes the placeholder by reference and by value, and nothing else', () => { + const placeholder = testManifestFromRouteGraph({ graph: emptyCompiledRouteGraph, projectRoot: '/tmp/none' }); + + expect(placeholder.plugin).toBe(FALLBACK_PLUGIN_IDENTITY); + expect(isFallbackPluginIdentity(placeholder.plugin)).toBe(true); + expect(isFallbackPluginIdentity({ name: 'unknown', version: '0.0.0' })).toBe(true); + expect(isFallbackPluginIdentity({ name: 'unknown', packageName: 'unknown', version: '0.0.0' })).toBe(false); + expect(isFallbackPluginIdentity({ name: 'meta-consumer', version: '3.4.5' })).toBe(false); + + expect(testMetaModuleSource(placeholder)).toContain('throwUnavailable()'); + expect(testMetaModuleSource(placeholder)).not.toContain('export const name = "unknown"'); + expect(testMetaModuleSource({ ...placeholder, plugin: { name: 'meta-consumer', version: '3.4.5' } })) + .toBe(generatedMetaModuleSource(projectMeta({ name: 'meta-consumer', version: '3.4.5' }))); + }); }); describe('the published agent-bundle/meta module outside the compiler', () => { From b83f9c10c2ed5765ff4d1244a0a4588ba327d815 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:35:10 +0000 Subject: [PATCH 5/6] fix(test): recognize the model-less identity sentinel by reference only --- .../agent-bundle/src/rstest/meta-module.ts | 5 +++-- packages/agent-bundle/src/test/manifest.ts | 20 ++++++++++--------- .../tests/rstest-meta-alias.test.ts | 13 +++++++++--- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/agent-bundle/src/rstest/meta-module.ts b/packages/agent-bundle/src/rstest/meta-module.ts index d8602d68d..6cac64d0e 100644 --- a/packages/agent-bundle/src/rstest/meta-module.ts +++ b/packages/agent-bundle/src/rstest/meta-module.ts @@ -70,8 +70,9 @@ export const unavailableMetaModuleSource = (manifest: AgentBundleTestManifest): * (`generatedMetaModuleSource` over `projectMeta`), fed from the manifest's * plugin identity — the identity the same compiler pass reports in * `initialize` — so a source module importing `{ name, version }` observes - * under a test exactly what a compiled surface would. Without one it is the - * throwing module above: the placeholder identity is never served. + * under a test exactly what a compiled surface would. Without one — the + * manifest still carries the frozen sentinel object — it is the throwing + * module above: the placeholder identity is never served. */ export const testMetaModuleSource = (manifest: AgentBundleTestManifest): string => isFallbackPluginIdentity(manifest.plugin) diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index d9ffe6a01..0906748cf 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -125,7 +125,7 @@ export interface TestManifestPluginIdentity { /** * The identity a manifest carries when preparation produced no plugin model * (the configuration could not be loaded or normalized). It is one frozen - * placeholder rather than a guess at the project, and {@link isFallbackPluginIdentity} + * sentinel rather than a guess at the project, and {@link isFallbackPluginIdentity} * recognizes it so no harness surface — the Rstest presets' `agent-bundle/meta` * alias in particular — ever serves it as a real identity. */ @@ -134,15 +134,17 @@ export const FALLBACK_PLUGIN_IDENTITY: TestManifestPluginIdentity = Object.freez version: '0.0.0', }); -/** True when a manifest's identity is the model-less placeholder, by reference or by value. */ +/** + * True when a manifest's identity is the model-less sentinel. The check is + * by reference only: `deepFreeze` freezes in place, so the manifest the + * compiler pass hands the presets still carries the sentinel object itself, + * while a real project that happens to declare `plugin.name: 'unknown'` and + * `plugin.version: '0.0.0'` is a distinct model-backed object and keeps its + * identity. A manifest that crossed a JSON boundary has already been handed + * to a worker; only the preset, in the runner process, asks this question. + */ export const isFallbackPluginIdentity = (plugin: TestManifestPluginIdentity): boolean => - plugin === FALLBACK_PLUGIN_IDENTITY - || ( - plugin.name === FALLBACK_PLUGIN_IDENTITY.name - && plugin.version === FALLBACK_PLUGIN_IDENTITY.version - && plugin.packageName === undefined - && plugin.packageVersion === undefined - ); + plugin === FALLBACK_PLUGIN_IDENTITY; /** The conventional state module the generated route-unit registry can load. */ export interface TestableStateDescriptor { diff --git a/packages/agent-bundle/tests/rstest-meta-alias.test.ts b/packages/agent-bundle/tests/rstest-meta-alias.test.ts index c587a9dc6..0d81a8a4a 100644 --- a/packages/agent-bundle/tests/rstest-meta-alias.test.ts +++ b/packages/agent-bundle/tests/rstest-meta-alias.test.ts @@ -98,17 +98,24 @@ describe('agentBundleRstest aliases agent-bundle/meta (#386)', () => { } }); - it('recognizes the placeholder by reference and by value, and nothing else', () => { + it('recognizes the sentinel by reference only, so a real identity with the same values is served', () => { const placeholder = testManifestFromRouteGraph({ graph: emptyCompiledRouteGraph, projectRoot: '/tmp/none' }); + // A model-backed project may legitimately declare these exact values. + const lookalike = testManifestFromRouteGraph({ + graph: emptyCompiledRouteGraph, + plugin: { name: 'unknown', version: '0.0.0' }, + projectRoot: '/tmp/lookalike', + }); expect(placeholder.plugin).toBe(FALLBACK_PLUGIN_IDENTITY); expect(isFallbackPluginIdentity(placeholder.plugin)).toBe(true); - expect(isFallbackPluginIdentity({ name: 'unknown', version: '0.0.0' })).toBe(true); - expect(isFallbackPluginIdentity({ name: 'unknown', packageName: 'unknown', version: '0.0.0' })).toBe(false); + expect(isFallbackPluginIdentity(lookalike.plugin)).toBe(false); expect(isFallbackPluginIdentity({ name: 'meta-consumer', version: '3.4.5' })).toBe(false); expect(testMetaModuleSource(placeholder)).toContain('throwUnavailable()'); expect(testMetaModuleSource(placeholder)).not.toContain('export const name = "unknown"'); + expect(testMetaModuleSource(lookalike)) + .toBe(generatedMetaModuleSource(projectMeta({ name: 'unknown', version: '0.0.0' }))); expect(testMetaModuleSource({ ...placeholder, plugin: { name: 'meta-consumer', version: '3.4.5' } })) .toBe(generatedMetaModuleSource(projectMeta({ name: 'meta-consumer', version: '3.4.5' }))); }); From 746939ea94deca6d469e938acdb32da131a6ef92 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 09:42:09 +0000 Subject: [PATCH 6/6] docs(meta): name the meta named export in the AB4760 alias recipe --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/meta-diagnostic.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 1703e27e4..c4cca49b3 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -320,7 +320,7 @@ identity is never served as a real one. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | -| `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module exporting `{ name, packageName, packageVersion, version }` plus a frozen `meta` default computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version — the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | +| `AB4760` | error | A module evaluated the published `agent-bundle/meta` outside a surface Agent Bundle compiles — typically a unit test pool not built from the Rstest preset, or a hand-run script importing plugin source. | Run the test under `agentBundleRstest()` or `agentBundleBrowserRstest()` from `agent-bundle/rstest` (pass `include` to cover a plain unit pool), or compile the surface with `agent-bundle build`. In a custom test runner, alias `agent-bundle/meta` (`resolve.alias`, exact match) to a module with the named exports `{ name, packageName, packageVersion, version, meta }` — `meta` the frozen object of the other four, exported as both the named binding and the default export — computed from the project's `agent-bundle.config.ts` plugin name and `package.json` version; the `.agent-bundle/test/meta.mjs` module `agentBundleRstest()` writes is that module. | ## Config beside a route-generated MCP server (`AB4340`) diff --git a/packages/agent-bundle/src/meta-diagnostic.ts b/packages/agent-bundle/src/meta-diagnostic.ts index d577fd2e0..7b383de88 100644 --- a/packages/agent-bundle/src/meta-diagnostic.ts +++ b/packages/agent-bundle/src/meta-diagnostic.ts @@ -25,10 +25,10 @@ export const META_UNAVAILABLE_MESSAGE = export const META_UNAVAILABLE_RECOVERY = 'Run the test under agentBundleRstest() or agentBundleBrowserRstest() from agent-bundle/rstest, ' + 'which alias agent-bundle/meta to the project identity the compiler stamps, or compile the surface with `agent-bundle build`. ' - + 'In a custom test runner, alias `agent-bundle/meta` (resolve.alias, exact match) to a module exporting ' - + '{ name, packageName, packageVersion, version } plus a frozen `meta` default computed from the project\'s ' - + 'agent-bundle.config.ts plugin name and package.json version — the `.agent-bundle/test/meta.mjs` module ' - + 'agentBundleRstest() writes is that module.'; + + 'In a custom test runner, alias `agent-bundle/meta` (resolve.alias, exact match) to a module with the named exports ' + + '{ name, packageName, packageVersion, version, meta } — `meta` the frozen object of the other four, exported as both ' + + 'the named binding and the default export — computed from the project\'s agent-bundle.config.ts plugin name and ' + + 'package.json version; the `.agent-bundle/test/meta.mjs` module agentBundleRstest() writes is that module.'; /** The structured diagnostic, in the same shape every other AB code reports. */ export const metaUnavailableDiagnostic = (): Diagnostic => Object.freeze({