diff --git a/.changeset/prebuilt-simplify-followup.md b/.changeset/prebuilt-simplify-followup.md new file mode 100644 index 000000000..b43cee35a --- /dev/null +++ b/.changeset/prebuilt-simplify-followup.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Simplify prebuilt-payload internals (post-#71 follow-up): dev-server preparations no longer pay the AB4750 payload-freshness mtime walk for commands that discard it, the payload-declaration parse and innermost-payload ownership rule are shared across discovery, normalization, and validation instead of being open-coded per module, and `PreparedProject.snapshotSource` is required so artifact re-snapshots always observe the payload roots the prepared identity hashed. diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 2d6be736b..a2a14c653 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -75,12 +75,15 @@ interface StagedTarget extends PlannedTarget { readonly root: string; } -const prebuiltReferenceExists = ( - model: NormalizedPlugin, - artifactPath: string, -): boolean => (model.payloads ?? []).some((payload) => - artifactPath.startsWith(`${payload.name}/`) && - payload.files.some((file) => `${payload.name}/${file.relativePath}` === artifactPath)); +const prebuiltArtifactPaths = (model: NormalizedPlugin): ReadonlySet => + new Set((model.payloads ?? []).flatMap((payload) => + payload.files.map((file) => `${payload.name}/${file.relativePath}`))); + +const missingPrebuiltDiagnostic = (subject: string, artifactPath: string): Diagnostic => ({ + code: 'AB4748', + message: `${subject} ${JSON.stringify(artifactPath)} is not present in its declared payload; run the project's own build before "agent-bundle build".`, + severity: 'error', +}); /** * AB4747-AB4749: an artifact build packages prebuilt payloads exactly as @@ -112,28 +115,21 @@ const prebuiltPayloadDiagnostics = ( }); } } + const artifactPaths = prebuiltArtifactPaths(model); const tokenPrefix = `${pathTokens.pluginRoot}/`; for (const server of model.mcpServers) { if (server.provenance.kind !== 'prebuilt') continue; const entry = server.args?.[0]; if (typeof entry !== 'string' || !entry.startsWith(tokenPrefix)) continue; const artifactPath = entry.slice(tokenPrefix.length); - if (!prebuiltReferenceExists(model, artifactPath)) { - diagnostics.push({ - code: 'AB4748', - message: `MCP server ${JSON.stringify(server.name)} prebuilt entry ${JSON.stringify(artifactPath)} is not present in its declared payload; run the project's own build before "agent-bundle build".`, - severity: 'error', - }); + if (!artifactPaths.has(artifactPath)) { + diagnostics.push(missingPrebuiltDiagnostic(`MCP server ${JSON.stringify(server.name)} prebuilt entry`, artifactPath)); } } for (const hook of model.hooks) { if (hook.prebuiltPath === undefined) continue; - if (!prebuiltReferenceExists(model, hook.prebuiltPath)) { - diagnostics.push({ - code: 'AB4748', - message: `Hook ${JSON.stringify(hook.name)} prebuilt handler ${JSON.stringify(hook.prebuiltPath)} is not present in its declared payload; run the project's own build before "agent-bundle build".`, - severity: 'error', - }); + if (!artifactPaths.has(hook.prebuiltPath)) { + diagnostics.push(missingPrebuiltDiagnostic(`Hook ${JSON.stringify(hook.name)} prebuilt handler`, hook.prebuiltPath)); } } return diagnostics; diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 6058f5c32..26d4f6395 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -111,6 +111,29 @@ const discoverAssets = async ( }))); }; +/** The declared source path of a payload declaration, or undefined when the declaration is not string-or-`{source}` shaped. */ +export const payloadDeclarationEntry = (declaration: unknown): string | undefined => { + const entry = typeof declaration === 'string' + ? declaration + : isRecord(declaration) ? declaration.source : undefined; + return typeof entry === 'string' && entry.trim().length > 0 ? entry : undefined; +}; + +/** + * The absolute, project-contained source directory of one well-shaped payload + * declaration. Malformed or escaping declarations return undefined — source + * validation reports those (AB4740-AB4742). + */ +export const payloadDeclarationSource = ( + projectRoot: string, + declaration: unknown, +): string | undefined => { + const entry = payloadDeclarationEntry(declaration); + if (entry === undefined) return undefined; + const source = resolve(projectRoot, entry); + return isInside(projectRoot, source) ? source : undefined; +}; + /** * The absolute source directories of well-shaped payload declarations. * Source snapshots use this to include payload files in the project @@ -124,10 +147,8 @@ export const configuredPayloadRoots = ( if (configured === undefined || !isRecord(configured)) return []; const roots: string[] = []; for (const declaration of Object.values(configured)) { - const entry = typeof declaration === 'string' ? declaration : declaration?.source; - if (typeof entry !== 'string' || entry.trim().length === 0) continue; - const source = resolve(projectRoot, entry); - if (isInside(projectRoot, source)) roots.push(source); + const source = payloadDeclarationSource(projectRoot, declaration); + if (source !== undefined) roots.push(source); } return [...new Set(roots)].sort((left, right) => left.localeCompare(right)); }; @@ -146,10 +167,8 @@ const discoverPayloads = async ( if (configured === undefined || !isRecord(configured)) return []; const payloads: DiscoveredPayload[] = []; for (const [name, declaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { - const entry = typeof declaration === 'string' ? declaration : declaration?.source; - if (typeof entry !== 'string' || entry.trim().length === 0) continue; - const source = resolve(projectRoot, entry); - if (!isInside(projectRoot, source)) continue; + const source = payloadDeclarationSource(projectRoot, declaration); + if (source === undefined) continue; let stats; try { stats = await stat(source); @@ -160,12 +179,13 @@ const discoverPayloads = async ( payloads.push({ files: [], name, source }); continue; } - const matches = (await fastGlob('**', { ...assetGlobOptions, cwd: source })).sort((left, right) => left.localeCompare(right)); + const matches = (await fastGlob('**', { ...assetGlobOptions, cwd: source, stats: true })) + .sort((left, right) => left.path.localeCompare(right.path)); payloads.push({ - files: await Promise.all(matches.map(async (file) => ({ - bytes: (await stat(file)).size, - relativePath: relative(source, file).replaceAll('\\', '/'), - source: file, + files: await Promise.all(matches.map(async (match) => ({ + bytes: (match.stats ?? await stat(match.path)).size, + relativePath: relative(source, match.path).replaceAll('\\', '/'), + source: match.path, }))), name, source, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 1c0db6ad9..9108f7b6c 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -42,7 +42,7 @@ import type { NormalizedSkill, SourceProvenance, } from '../core/types.ts'; -import type { DiscoveredProject } from './discover.ts'; +import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; const unique = (values: readonly string[]): string[] => [...new Set(values)]; @@ -230,20 +230,33 @@ const normalizePayloads = ( const discoveredByName = new Map((discovered.payloads ?? []).map((payload) => [payload.name, payload])); const payloads: NormalizedPayload[] = []; for (const [name, declaration] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { - const entry = typeof declaration === 'string' ? declaration : declaration.source; - if (typeof entry !== 'string' || entry.trim().length === 0) continue; + const source = payloadDeclarationSource(loaded.context.projectRoot, declaration); + if (source === undefined) continue; payloads.push({ files: (discoveredByName.get(name)?.files ?? []).map((file) => ({ ...file })), id: `payload:${name}`, name, provenance: { kind: 'prebuilt', sourcePath: loaded.configPath }, - source: resolve(loaded.context.projectRoot, entry), + source, targets: sortedUnique(typeof declaration === 'string' ? targetNames : (declaration.targets ?? targetNames)), }); } return payloads; }; +/** The innermost declared payload whose source directory contains the file. */ +export const owningPayload = ( + payloads: readonly Payload[], + source: string, +): Payload | undefined => { + let best: Payload | undefined; + for (const payload of payloads) { + if (!isInside(payload.source, source)) continue; + if (best === undefined || payload.source.length > best.source.length) best = payload; + } + return best; +}; + /** * The artifact-relative stable path of a prebuilt file: its declaring payload * destination plus the file's payload-relative path. Falls back to the @@ -255,14 +268,10 @@ export const prebuiltArtifactPath = ( root: string, source: string, ): string => { - let best: NormalizedPayload | undefined; - for (const payload of payloads) { - if (!isInside(payload.source, source)) continue; - if (best === undefined || payload.source.length > best.source.length) best = payload; - } - return best === undefined + const payload = owningPayload(payloads, source); + return payload === undefined ? relative(root, source).replaceAll('\\', '/') - : `${best.name}/${relative(best.source, source).replaceAll('\\', '/')}`; + : `${payload.name}/${relative(payload.source, source).replaceAll('\\', '/')}`; }; const isHookEntryList = ( @@ -306,9 +315,8 @@ const normalizeHook = ( const source = resolve(root, prebuilt ? handlerInput.prebuilt : handlerInput); const handler = relative(root, source).replaceAll('\\', '/'); const prebuiltPath = prebuilt ? prebuiltArtifactPath(payloads, root, source) : undefined; - const args = prebuilt && entry.args !== undefined - ? entry.args.filter((argument): argument is string => typeof argument === 'string') - : undefined; + // Non-string arguments are a validation error (AB4746); normalization trusts the declared type. + const args = prebuilt ? entry.args : undefined; const tools = sortedUnique(entry.tools ?? []).filter( (tool): tool is CanonicalHookTool => knownHookTools.has(tool as CanonicalHookTool), ); @@ -510,9 +518,10 @@ const normalizeMcpApps = ( for (const [serverName, rawServer] of Object.entries(configured).sort(([left], [right]) => left.localeCompare(right))) { const server = serverByName.get(serverName); + if (server === undefined || rawServer.apps === undefined) continue; // Apps require a local server entry: a compiled source entry, or a prebuilt one. - const prebuilt = isPrebuiltEntryInput(rawServer.entry); - if (server === undefined || (server.source === undefined && !prebuilt) || rawServer.apps === undefined) continue; + const prebuilt = server.provenance.kind === 'prebuilt'; + if (server.source === undefined && !prebuilt) continue; for (const [name, app] of Object.entries(rawServer.apps).sort(([left], [right]) => left.localeCompare(right))) { const declaration = app as AgentBundleMcpApp; apps.push({ diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 447245cf7..b21489d84 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -28,9 +28,10 @@ import { conventionalCliEntrySource, conventionalIndexEntrySource, conventionalMcpEntrySource, + owningPayload, reservedPayloadDestinations, } from './normalize.ts'; -import type { DiscoveredProject } from './discover.ts'; +import { type DiscoveredProject, payloadDeclarationEntry, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; import type { SkillDocument } from './skill.ts'; import { referencedResources } from './skill-references.ts'; @@ -90,9 +91,10 @@ const validateHooks = ( const handler = entry.handler; const prebuilt = isPrebuiltEntryInput(handler); if (prebuilt) { - const hookTargets = Array.isArray(entry.targets) && entry.targets.every(nonemptyString) - ? entry.targets - : selectedTargets.filter((target) => registry.supports(target, 'hooks')); + const hookTargets = declaredTargetsOr( + entry.targets, + selectedTargets.filter((target) => registry.supports(target, 'hooks')), + ); diagnostics.push(...validatePrebuiltReference( `Hook ${event}`, handler, @@ -243,6 +245,13 @@ const isProtocolJsonValue = (value: unknown, ancestors = new Set()): boo const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; +/** A declared targets restriction when it is a well-shaped string array, otherwise the fallback selection. */ +const declaredTargetsOr = ( + targets: unknown, + fallback: readonly string[], +): readonly string[] => + Array.isArray(targets) && targets.every(nonemptyString) ? targets : fallback; + const validateStringList = ( value: unknown, label: string, @@ -675,9 +684,7 @@ const validateMcpServer = ( if (entry !== undefined || conventionalEntry !== undefined) { if (isPrebuiltEntryInput(entry)) { - const serverTargets = Array.isArray(server.targets) && server.targets.every(nonemptyString) - ? server.targets - : selectedTargetNamesFor(loaded, registry); + const serverTargets = declaredTargetsOr(server.targets, selectedTargetNamesFor(loaded, registry)); diagnostics.push(...validatePrebuiltReference( `MCP server ${JSON.stringify(name)}`, entry, @@ -1023,17 +1030,13 @@ const declaredPayloads = ( const selectedTargets = selectedTargetNamesFor(loaded, registry); const payloads: DeclaredPayload[] = []; for (const [name, declaration] of Object.entries(configured)) { - const entry = typeof declaration === 'string' - ? declaration - : isRecord(declaration) ? declaration.source : undefined; - if (!nonemptyString(entry)) continue; - const source = resolve(loaded.context.projectRoot, entry); - if (!isInside(loaded.context.projectRoot, source)) continue; + const source = payloadDeclarationSource(loaded.context.projectRoot, declaration); + if (source === undefined) continue; const targets = typeof declaration === 'string' ? undefined : declaration.targets; payloads.push({ name, source, - targets: Array.isArray(targets) && targets.every(nonemptyString) ? targets : selectedTargets, + targets: declaredTargetsOr(targets, selectedTargets), }); } return payloads; @@ -1112,6 +1115,7 @@ const payloadTargetDiagnostics = ( const validatePayload = ( loaded: LoadedConfig, registry: NormalizationTargetRegistry, + freshness: boolean, ): Diagnostic[] => { const configured = loaded.config.payload; if (configured === undefined) return []; @@ -1128,10 +1132,8 @@ const validatePayload = ( loaded.configPath, )); } - const entry = typeof declaration === 'string' - ? declaration - : isRecord(declaration) ? declaration.source : undefined; - if (!nonemptyString(entry)) { + const entry = payloadDeclarationEntry(declaration); + if (entry === undefined) { diagnostics.push(sourceDiagnostic( 'AB4740', `Payload ${JSON.stringify(name)} must be a source directory path or an object with a source path.`, @@ -1142,8 +1144,8 @@ const validatePayload = ( if (typeof declaration !== 'string') { diagnostics.push(...payloadTargetDiagnostics(name, declaration.targets, loaded, registry)); } - const source = resolve(loaded.context.projectRoot, entry); - if (!isInside(loaded.context.projectRoot, source)) { + const source = payloadDeclarationSource(loaded.context.projectRoot, declaration); + if (source === undefined) { diagnostics.push(sourceDiagnostic( 'AB4742', `Payload ${JSON.stringify(name)} source must resolve inside the project root.`, @@ -1191,7 +1193,9 @@ const validatePayload = ( )); } } - const existing = sources.filter((payload) => existsSync(payload.source)); + // The freshness nudge walks every project and payload file's mtime, so + // flows that discard non-error source diagnostics skip it entirely. + const existing = freshness ? sources.filter((payload) => existsSync(payload.source)) : []; if (existing.length > 0) { const newestSource = newestFileMtime( loaded.context.projectRoot, @@ -1237,9 +1241,7 @@ const validatePrebuiltReference = ( if (!isInside(loaded.context.projectRoot, source)) { return [sourceDiagnostic('AB4744', `${label} prebuilt entry must resolve inside the project root.`, loaded.configPath)]; } - const payload = payloads - .filter((candidate) => isInside(candidate.source, source)) - .sort((left, right) => right.source.length - left.source.length)[0]; + const payload = owningPayload(payloads, source); if (payload === undefined) { diagnostics.push(sourceDiagnostic( 'AB4744', @@ -1321,10 +1323,20 @@ const validateTools = (loaded: LoadedConfig): Diagnostic[] => { return diagnostics; }; +export interface ValidateSourceOptions { + /** + * Compute the AB4750 payload-freshness nudge, a full-project mtime walk. + * Defaults to true; flows that discard non-error source diagnostics pass + * false to skip the walk. + */ + readonly payloadFreshness?: boolean; +} + export const validateSource = ( loaded: LoadedConfig, discovered: DiscoveredProject, registry: NormalizationTargetRegistry, + options?: ValidateSourceOptions, ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; const plugin = loaded.config.plugin as unknown; @@ -1379,7 +1391,7 @@ export const validateSource = ( diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); diagnostics.push(...validateMcp(loaded, registry, payloads)); - diagnostics.push(...validatePayload(loaded, registry)); + diagnostics.push(...validatePayload(loaded, registry, options?.payloadFreshness !== false)); diagnostics.push(...validateRuntime(loaded)); diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index 619ffe2e2..149835940 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -25,7 +25,7 @@ import { type NativePlaygroundCatalogPublicationOptions, type NativePlaygroundCatalogPublicationReceipt, } from '../playground/native-playground-service.ts'; -import { snapshotProjectSource, type PreparedProject } from '../project-service.ts'; +import type { PreparedProject } from '../project-service.ts'; import { freezeArtifactEpoch, type ArtifactEpoch, type DiagnosticSummary } from '../types.ts'; export interface SucceededArtifactEpochResult { @@ -228,11 +228,7 @@ export class ArtifactService { buildDiagnostics = freezeDiagnostics([...prepared.diagnostics, ...validationDiagnostics]); if (hasErrors(buildDiagnostics)) throw new DiagnosticError(buildDiagnostics); - const currentSource = await (prepared.snapshotSource ?? (() => snapshotProjectSource( - prepared.root, - prepared.configPath, - prepared.outputRoots, - )))(); + const currentSource = await prepared.snapshotSource(); if (!sameInputs(projectContext.sourceInputs, currentSource.inputs)) { throw new DiagnosticError([projectSourceChangedDiagnostic(prepared.configPath)]); } diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 759bce7f1..5b1482151 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -59,7 +59,12 @@ export interface PreparedProject { readonly projectContext?: ProjectContext; readonly registry: TargetRegistry; readonly root: string; - readonly snapshotSource?: () => Promise; + /** + * Re-snapshots the project with the same output and payload roots the + * prepared identity hashed; a divergent re-snapshot would make + * payload-bearing projects always appear drifted to epoch publication. + */ + readonly snapshotSource: () => Promise; readonly source: SourceStatus; /** The consumer bundler escape hatch, passed through for build lowering. */ readonly tools?: AgentBundleToolsConfig; @@ -523,12 +528,12 @@ const preparedProject = ( registry: TargetRegistry, root: string, source: SourceStatus, + snapshotSource: () => Promise, model?: NormalizedPlugin, devRuntime?: DevRuntimePreparedProject, devRuntimeDiagnostic?: Diagnostic, devAgentApiEnabled?: boolean, tools?: AgentBundleToolsConfig, - snapshotSource?: () => Promise, ): PreparedProject => Object.freeze({ configPath, ...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}), @@ -540,7 +545,7 @@ const preparedProject = ( ...(projectContext === undefined ? {} : { projectContext }), registry, root, - ...(snapshotSource === undefined ? {} : { snapshotSource }), + snapshotSource, source, ...(tools === undefined ? {} : { tools }), }); @@ -587,6 +592,9 @@ const invalidPreparedProject = (options: { options.registry, options.root, sourceStatus(options.diagnostics, snapshot.revision), + // A failed preparation never resolved its payload roots; the re-snapshot + // observes the same source tree the failure snapshot did. + () => snapshotProjectSource(options.root, options.configPath, options.outputRoots), ); }; @@ -712,7 +720,11 @@ export class ProjectService { const supplementalMetadataFailure = runtimeMetadata.changed; let sourceDiagnostics: readonly Diagnostic[]; try { - sourceDiagnostics = freezeDiagnostics(validateSource(preparedLoaded, discovered, registry)); + // The AB4750 freshness nudge only surfaces through `validate`; other + // commands skip its full-project mtime walk. + sourceDiagnostics = freezeDiagnostics(validateSource(preparedLoaded, discovered, registry, { + payloadFreshness: command === 'validate', + })); } catch { return failedPreparation( 'AB7001', @@ -722,10 +734,12 @@ export class ProjectService { snapshot, ); } + const snapshotSource = (): Promise => + snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots); if (hasErrors(sourceDiagnostics)) { const source = sourceStatus(sourceDiagnostics, snapshot.revision); log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root }); - return preparedProject(loaded.configPath, snapshot, sourceDiagnostics, outputRoots, undefined, registry, root, source); + return preparedProject(loaded.configPath, snapshot, sourceDiagnostics, outputRoots, undefined, registry, root, source, snapshotSource); } let model: NormalizedPlugin; @@ -834,15 +848,12 @@ export class ProjectService { registry, root, source, + snapshotSource, model, devRuntime, devRuntimeDiagnostic, devAgentApiEnabled, tools, - // Re-snapshots must observe the same payload roots the prepared - // identity hashed, or payload-bearing projects would always appear - // drifted to epoch publication. - () => snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots), ); } } diff --git a/packages/agent-bundle/tests/dev-package-build-service.test.ts b/packages/agent-bundle/tests/dev-package-build-service.test.ts index 9d4e07d21..4a4b18e28 100644 --- a/packages/agent-bundle/tests/dev-package-build-service.test.ts +++ b/packages/agent-bundle/tests/dev-package-build-service.test.ts @@ -41,6 +41,7 @@ const prepared = (options: { outputRoots: [], registry: undefined as never, root: options.root ?? '/project', + snapshotSource: async () => ({ inputs: [], revision: 'test-revision' }), source: { diagnostics: [], state: 'ready' }, ...(options.tools === undefined ? {} : { tools: options.tools }), }); diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index c3da56656..6edfacafd 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -1,5 +1,4 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; @@ -7,6 +6,7 @@ import { expect, it } from '@rstest/core'; import { build, validate } from '../src/api.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; const configSource = (options: { readonly payload?: string; readonly hooks?: string; readonly mcp?: string }): string => [ 'export default {', @@ -34,18 +34,13 @@ const standardHooksBlock = [ ].join('\n'); /** A payload fixture whose runtime tree is deliberately not framework-shaped. */ -const writePayloadFiles = async (root: string): Promise => { - await mkdir(join(root, 'built', 'runtime', 'mcp'), { recursive: true }); - await mkdir(join(root, 'built', 'runtime', 'chunks'), { recursive: true }); - await mkdir(join(root, 'built', 'app'), { recursive: true }); - await Promise.all([ - // A bare-specifier import would fail the generated-module graph - // validation (AB6005); prebuilt payloads are exempt by design. - writeFile(join(root, 'built', 'runtime', 'mcp', 'server.js'), 'import express from "express";\nexport default express;\n'), - writeFile(join(root, 'built', 'runtime', 'chunks', '417.js'), 'module.exports = require("./418.js");\n'), - writeFile(join(root, 'built', 'runtime', 'hook.js'), 'process.stdout.write("{}");\n'), - writeFile(join(root, 'built', 'app', 'index.html'), 'widget\n'), - ]); +const payloadFiles: Readonly> = { + 'built/app/index.html': 'widget\n', + 'built/runtime/chunks/417.js': 'module.exports = require("./418.js");\n', + 'built/runtime/hook.js': 'process.stdout.write("{}");\n', + // A bare-specifier import would fail the generated-module graph + // validation (AB6005); prebuilt payloads are exempt by design. + 'built/runtime/mcp/server.js': 'import express from "express";\nexport default express;\n', }; const createProject = async (options: { @@ -53,10 +48,16 @@ const createProject = async (options: { readonly hooks?: string; readonly mcp?: string; readonly withPayloadFiles?: boolean; + readonly files?: Readonly>; } = {}): Promise => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-prebuilt-')); - await writeFile(join(root, 'agent-bundle.config.ts'), configSource(options)); - if (options.withPayloadFiles !== false) await writePayloadFiles(root); + const { root } = await createProjectFixture({ + config: configSource(options), + files: { + ...(options.withPayloadFiles !== false ? payloadFiles : {}), + ...options.files, + }, + prefix: 'agent-bundle-prebuilt-', + }); return root; }; @@ -142,7 +143,7 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu const revalidated = await validate({ artifact: join(root, 'out'), root }); expect(revalidated.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); } finally { - await rm(root, { force: true, recursive: true }); + await removeProjectFixture(root); } }); @@ -171,7 +172,7 @@ it('validates an argument-less prebuilt hook without demanding a wrapper index e const revalidated = await validate({ artifact: join(root, 'out'), root }); expect(revalidated.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); } finally { - await rm(root, { force: true, recursive: true }); + await removeProjectFixture(root); } }); @@ -197,9 +198,8 @@ it('reports the prebuilt payload source diagnostics', async () => { " runtime: { source: './built/runtime', targets: ['claude'] },", ' },', ].join('\n'), + files: { 'src/hook.ts': 'export default () => undefined;\n' }, }); - await mkdir(join(root, 'src'), { recursive: true }); - await writeFile(join(root, 'src', 'hook.ts'), 'export default () => undefined;\n'); try { const result = await validate({ root }); const codes = result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity] as const); @@ -215,7 +215,7 @@ it('reports the prebuilt payload source diagnostics', async () => { // Hook arguments: rejected on compiled handlers and on unsafe values. expect(result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4746').length).toBe(2); } finally { - await rm(root, { force: true, recursive: true }); + await removeProjectFixture(root); } }); @@ -229,7 +229,7 @@ it('refuses to build while a payload is empty, a prebuilt entry is absent, or th diagnostics: [expect.objectContaining({ code: 'AB4747' })], }); } finally { - await rm(emptyPayloadRoot, { force: true, recursive: true }); + await removeProjectFixture(emptyPayloadRoot); } const missingEntryRoot = await createProject({ @@ -245,7 +245,7 @@ it('refuses to build while a payload is empty, a prebuilt entry is absent, or th diagnostics: [expect.objectContaining({ code: 'AB4748' })], }); } finally { - await rm(missingEntryRoot, { force: true, recursive: true }); + await removeProjectFixture(missingEntryRoot); } const overlapRoot = await createProject({ payload: standardPayloadBlock }); @@ -254,6 +254,6 @@ it('refuses to build while a payload is empty, a prebuilt entry is absent, or th diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'AB4749' })]), }); } finally { - await rm(overlapRoot, { force: true, recursive: true }); + await removeProjectFixture(overlapRoot); } });