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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/619-define-prebuilt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"agent-bundle": minor
---

Add `definePrebuilt` to `agent-bundle` and `agent-bundle/config`, with a
`runtimeDependencies` field for the bare package names a prebuilt payload
loads. Report a malformed list as `AB4740`, and as `AB4751` a name npm does
not read as a bare package name or one `package.json` does not install for a
consumer (`dependencies`, `optionalDependencies`, or a peer not marked
optional); count declared runtime dependencies as used for `AB7014` and expose
them as `NormalizedPayload.runtimeDependencies`. (#630)
13 changes: 8 additions & 5 deletions docs/diagnostics.md

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1139,11 +1139,17 @@ declares already-built directory trees the build packages **as-is**, and the
inside them:

```ts
import { defineConfig, definePrebuilt } from 'agent-bundle';

export default defineConfig({
payload: {
// key = artifact-root destination directory, value = the built tree
app: './dist/app',
runtime: { source: './dist/runtime', targets: ['claude', 'codex'] },
runtime: definePrebuilt({
source: './dist/runtime',
targets: ['claude', 'codex'],
runtimeDependencies: ['sharp'],
}),
},
mcp: {
servers: {
Expand Down Expand Up @@ -1191,6 +1197,14 @@ export default defineConfig({
simulatable hook index. MCP Apps declared on a prebuilt server stay a
development surface (the Workbench compiles them live); the build assumes
the payload already serves the resource.
- **Declare what the payload loads.** Because payload trees are opaque,
`runtimeDependencies` on a `definePrebuilt` entry lists the bare package
names its files load. A name npm would not read as a bare package name,
or one `package.json` does not install for a consumer (`dependencies`,
`optionalDependencies`, or a peer not marked optional), is `AB4751`; a
malformed list is `AB4740`. The declaration check is skipped when
`package.json` is missing (silent), unparsable, or outside the root
(`AB4011`), and declared names count as used for `AB7014`.
- **Ordering.** Run your own build before `agent-bundle build`: a missing or
empty payload is a validation warning (`AB4743`/`AB4745`) so `dev` works
from a clean checkout, but `agent-bundle build` refuses it
Expand Down
89 changes: 0 additions & 89 deletions packages/agent-bundle/src/build/pack-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,95 +28,6 @@ import { readModuleImports, type ModuleImport } from './module-imports.ts';
* import).
*/

/**
* The `package.json` fields npm installs alongside the published package.
* `peerDependencies` counts because npm 7+ installs peers automatically;
* `devDependencies` never reach a consumer and are not inspected.
*/
export const installedDependencyFields = Object.freeze([
'dependencies',
'optionalDependencies',
'peerDependencies',
] as const);

export type InstalledDependencyField = (typeof installedDependencyFields)[number];

export interface DeclaredDependency {
readonly field: InstalledDependencyField;
readonly name: string;
readonly specifier: string;
/** Embedded in the tarball by npm (`bundleDependencies`), so a consumer never fetches its specifier. */
readonly bundled: boolean;
/**
* Fetched for a consumer. `false` for a peer `peerDependenciesMeta` marks
* optional: npm parses its specifier — an unsupported protocol still fails
* the install — but never installs it, so no packed file has to use it.
*/
readonly installed: boolean;
}

/** Peers `peerDependenciesMeta` marks optional: npm parses but never installs them. */
const optionalPeers = (packageDocument: Readonly<Record<string, unknown>>): ReadonlySet<string> => {
const meta = packageDocument.peerDependenciesMeta;
return new Set(isRecord(meta)
? Object.entries(meta).filter(([, entry]) => isRecord(entry) && entry.optional === true).map(([name]) => name)
: []);
};

/**
* Which dependencies npm embeds under `node_modules` in the published tarball:
* `bundleDependencies` (or the `bundledDependencies` spelling) as a name list,
* or `true` for every entry of `dependencies`. Peers are never bundled, whatever
* the list says: npm packs no `node_modules` entry for a peer-only name, so a
* consumer still resolves the peer's own specifier.
*/
const bundledDependencies = (
packageDocument: Readonly<Record<string, unknown>>,
): ((field: InstalledDependencyField, name: string) => boolean) => {
const value = packageDocument.bundleDependencies ?? packageDocument.bundledDependencies;
const names = new Set(Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []);
return (field, name) => field !== 'peerDependencies' && (value === true ? field === 'dependencies' : names.has(name));
};

/**
* The entries a consumer's npm reads, one per name and field. A name in both
* `dependencies` and `optionalDependencies` is the optional entry (npm lets
* the optional declaration override), and a peer that `dependencies` or
* `optionalDependencies` also names is that concrete entry — npm resolves
* the concrete declaration and never reads the duplicate peer's selector; an
* optional peer is kept, not `installed`.
*/
export const declaredDependencies = (packageDocument: Readonly<Record<string, unknown>>): readonly DeclaredDependency[] => {
const skippedPeers = optionalPeers(packageDocument);
const bundled = bundledDependencies(packageDocument);
const entries = (field: InstalledDependencyField): readonly (readonly [string, string])[] => {
const value = packageDocument[field];
return isRecord(value) ? Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') : [];
};
const optional = new Set(entries('optionalDependencies').map(([name]) => name));
const concrete = new Set([...entries('dependencies').map(([name]) => name), ...optional]);
const shadowed = (field: InstalledDependencyField, name: string): boolean => {
switch (field) {
case 'dependencies': return optional.has(name);
case 'peerDependencies': return concrete.has(name);
case 'optionalDependencies': return false;
default: {
const exhaustive: never = field;
return exhaustive;
}
}
};
return installedDependencyFields.flatMap((field) => entries(field)
.filter(([name]) => !shadowed(field, name))
.map(([name, specifier]) => ({
field,
name,
specifier,
bundled: bundled(field, name),
installed: !(field === 'peerDependencies' && skippedPeers.has(name)),
})));
};

/** Relative, package-imports (`#`), absolute, and URL-scheme specifiers (`node:`, `data:`, `file:`, `C:\`) name no package. */
const nonPackageSpecifier = /^(?:[.#/]|[a-z][a-z0-9+.-]*:)/iu;
/** `@scope/name` or `name`; a subpath after it is dropped. */
Expand Down
18 changes: 11 additions & 7 deletions packages/agent-bundle/src/build/pack-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,14 @@ import { isRecord } from '../core/strict-json.ts';
import { readFileBytes, readFileString, runWithPlatform } from '../effect/platform.ts';
import { artifactManifestName } from './emit.ts';
import { parseArtifactManifest } from './manifest.ts';
import { declaredDependencies, type DeclaredDependency, type InstalledDependencyField } from '../core/package-dependencies.ts';
import {
classifyDependency,
declaredDependencies,
importedPackageNames,
isWorkspaceProtocol,
packagedSourceInstallable,
packagedSourcePath,
type DeclaredDependency,
type DependencyKind,
type InstalledDependencyField,
} from './pack-dependencies.ts';
import type { PackageBuildResult } from './package-build.ts';

Expand Down Expand Up @@ -152,7 +150,9 @@ const perField = (
* 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.
* 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.
*/
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) =>
Expand All @@ -163,6 +163,7 @@ const unresolvableRecovery = 'Depend on a published registry version, or bundle
+ 'which only pnpm, Yarn, or Bun rewrite while packing.';

const dependencyDiagnostics = async (options: {
readonly declaredRuntimeDependencies: ReadonlySet<string>;
readonly packageDocument: Readonly<Record<string, unknown>>;
readonly packedPaths: readonly string[];
readonly packerRewritesWorkspaceProtocols: boolean;
Expand Down Expand Up @@ -212,20 +213,22 @@ const dependencyDiagnostics = async (options: {
&& !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))
? declared.filter((dependency) => dependency.installed
&& !imported.names.has(dependency.name)
&& !options.declaredRuntimeDependencies.has(dependency.name))
: [];
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: ${quoteAll(own.map((dependency) => dependency.name))}. `
`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'
? '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.'),
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, or an install script or packed JavaScript runs; a computed import() or require() in packed code withholds this check.',
: 'Move build-only packages to devDependencies; compiled bundles inline their imports (AB6005), so keep a runtime dependency only for what a prebuilt payload or other uncompiled packed module imports, a packed file requires or resolves (createRequire, import.meta.resolve), a packed declaration file references, a #subpath import reaches through the imports map, 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.',
field === 'peerDependencies' ? 'warning' : 'error',
)),
// npm skips an optional dependency it cannot fetch, so the install survives — but only once the specifier parsed
Expand Down Expand Up @@ -336,6 +339,7 @@ export const packInventoryDiagnostics = async (options: {
}

diagnostics.push(...await dependencyDiagnostics({
declaredRuntimeDependencies: new Set((options.model.payloads ?? []).flatMap((payload) => payload.runtimeDependencies)),
packageDocument,
packedPaths: [...packed],
packerRewritesWorkspaceProtocols: options.packerRewritesWorkspaceProtocols,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { PortableConfigExtension } from '../adapters/portable.ts';
import type { AgentBundleConfig as CoreAgentBundleConfig } from '../core/types.ts';

export { discoverProject } from './discover.ts';
export { defineConfig } from '../core/types.ts';
export { defineConfig, definePrebuilt } from '../core/types.ts';
export type {
AgentProviderContext,
AgentProviderFactory,
Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,13 +372,15 @@ const normalizePayloads = (
for (const [name, declaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) {
const source = payloadDeclarationSource(loaded.context.projectRoot, declaration);
if (source === undefined) continue;
const entry = typeof declaration === 'string' ? undefined : declaration;
payloads.push({
files: (discoveredByName.get(name)?.files ?? []).map((file) => ({ ...file })),
id: `payload:${name}`,
name,
provenance: { kind: 'prebuilt', sourcePath: loaded.configPath },
runtimeDependencies: sortedUnique(entry?.runtimeDependencies ?? []),
source,
targets: sortedUnique(typeof declaration === 'string' ? targetNames : (declaration.targets ?? targetNames)),
targets: sortedUnique(entry?.targets ?? targetNames),
});
}
return payloads;
Expand Down
73 changes: 67 additions & 6 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import { isPlainRecord, isRecord } from '../core/strict-json.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { stableJson } from '../core/digest.ts';
import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts';
import { declaredDependencies, isBarePackageName } from '../core/package-dependencies.ts';
import {
developmentFallbackVersion,
readPackageDocument,
snapshotPackageIdentity,
type PackageIdentityIssueKind,
} from '../core/project-context.ts';
Expand Down Expand Up @@ -1658,11 +1660,66 @@ const payloadTargetDiagnostics = (
};

/**
* AB4740-AB4743 and the AB4750 freshness nudge: shape, destination-name,
* source-path, and existence checks for the prebuilt `payload` block.
* Missing or empty payloads warn here (development flows never require the
* consumer's own build to have run); `agent-bundle build` refuses them with
* AB4747/AB4748.
* The names a consumer's npm installs — `dependencies`, `optionalDependencies`,
* and peers not marked optional — the same set `AB7014` judges; undefined when
* the package document is absent or is one AB4011 already reports
* (unparsable, outside the root).
*/
const installedDependencyNames = (projectRoot: string): ReadonlySet<string> | undefined => {
const read = readPackageDocument(projectRoot);
if (read.kind !== 'document') return undefined;
return new Set(declaredDependencies(read.document)
.filter((dependency) => dependency.installed)
.map((dependency) => dependency.name));
};

/**
* AB4740/AB4751: one payload declaration's optional `runtimeDependencies`.
* The compiler never opens a payload file, so this list is the payload's
* only dependency evidence (`AB7014`); every name must be a bare package
* name (npm's own grammar) a consumer's npm installs.
*/
const payloadRuntimeDependencyDiagnostics = (
name: string,
runtimeDependencies: unknown,
loaded: LoadedConfig,
installed: ReadonlySet<string> | undefined,
): Diagnostic[] => {
if (runtimeDependencies === undefined) return [];
if (!Array.isArray(runtimeDependencies) || !runtimeDependencies.every(nonemptyString)) {
return [sourceDiagnostic(
'AB4740',
`Payload ${JSON.stringify(name)} runtimeDependencies must be an array of package names.`,
loaded.configPath,
)];
}
return runtimeDependencies.flatMap((dependency: string) => {
if (!isBarePackageName(dependency)) {
return [sourceDiagnostic(
'AB4751',
`Payload ${JSON.stringify(name)} runtimeDependencies entry ${JSON.stringify(dependency)} is not a bare package name.`,
loaded.configPath,
'Name the package (e.g. "sharp" or "@scope/name"), not a subpath or a specifier.',
)];
}
if (installed !== undefined && !installed.has(dependency)) {
return [sourceDiagnostic(
'AB4751',
`Payload ${JSON.stringify(name)} runtimeDependencies names ${JSON.stringify(dependency)}, which package.json does not declare as a dependency a consumer installs (dependencies, optionalDependencies, or a peer not marked optional).`,
loaded.configPath,
'Declare the package under dependencies, optionalDependencies, or peerDependencies (not marked optional) so a consumer installs it, or remove it from runtimeDependencies.',
)];
}
return [];
});
};

/**
* AB4740-AB4743, AB4751, and the AB4750 freshness nudge: shape,
* destination-name, source-path, runtime-dependency, and existence checks
* for the prebuilt `payload` block. Missing or empty payloads warn here
* (development flows never require the consumer's own build to have run);
* `agent-bundle build` refuses them with AB4747/AB4748.
*/
const validatePayload = (
loaded: LoadedConfig,
Expand All @@ -1676,6 +1733,7 @@ const validatePayload = (
}
const diagnostics: Diagnostic[] = [];
const sources: { name: string; source: string }[] = [];
const installed = installedDependencyNames(loaded.context.projectRoot);
for (const [name, declaration] of Object.entries(configured)) {
if (!isSafeOutputName(name) || reservedPayloadDestinations.has(name)) {
const diagnostic = sourceDiagnostic(
Expand All @@ -1700,7 +1758,10 @@ const validatePayload = (
continue;
}
if (typeof declaration !== 'string') {
diagnostics.push(...payloadTargetDiagnostics(name, declaration.targets, loaded, registry));
diagnostics.push(
...payloadTargetDiagnostics(name, declaration.targets, loaded, registry),
...payloadRuntimeDependencyDiagnostics(name, declaration.runtimeDependencies, loaded, installed),
);
}
const source = payloadDeclarationSource(loaded.context.projectRoot, declaration);
if (source === undefined) {
Expand Down
Loading
Loading