From f504a1e1c70e365bc4760741c2a58b9989433cf1 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:55:51 +0000 Subject: [PATCH 1/4] feat(identity): derive packageName/packageVersion into ProjectContext (#94) --- packages/agent-bundle/src/build/manifest.ts | 21 +- packages/agent-bundle/src/config/normalize.ts | 6 + .../agent-bundle/src/core/project-context.ts | 115 ++++++++- packages/agent-bundle/src/core/types.ts | 12 + .../agent-bundle/src/dev/agent-api-wire.ts | 6 + packages/agent-bundle/src/dev/agent-api.ts | 6 +- .../artifacts/artifact-inspection-service.ts | 2 + packages/agent-bundle/src/dev/coordinator.ts | 2 + .../agent-bundle/src/dev/project-service.ts | 22 +- packages/agent-bundle/src/dev/types.ts | 2 + packages/agent-bundle/tests/config.test.ts | 25 ++ packages/agent-bundle/tests/core.test.ts | 235 +++++++++++++++++- packages/agent-bundle/tests/manifest.test.ts | 23 ++ 13 files changed, 469 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index d9e050258..8df3a8f7b 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -1,4 +1,5 @@ import { digest, stableJson } from '../core/digest.ts'; +import { isPackageName, isSemanticPackageVersion } from '../core/project-context.ts'; import { formatRuntimeVersion, parseRuntimeVersion, @@ -43,6 +44,8 @@ export interface ArtifactManifestProject { readonly configDigest: string; readonly configPath: string; readonly modelDigest: string; + readonly packageName?: string; + readonly packageVersion?: string; readonly revision: string; readonly sourceInputs: readonly ArtifactManifestSourceInput[]; } @@ -307,7 +310,7 @@ const validateManifest = (value: unknown): ArtifactManifest => { if (producer.name !== 'agent-bundle') fail('producer.name must be "agent-bundle".'); const project = requireRecord(manifest.project, 'project'); - requireExactKeys(project, 'project', ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs']); + requireExactKeys(project, 'project', ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs'], ['packageName', 'packageVersion']); const sourceInputs = parseSourceInputs(project.sourceInputs, 'project.sourceInputs'); const configPath = requirePath(project.configPath, 'project.configPath'); const configDigest = requireHash(project.configDigest, 'project.configDigest'); @@ -317,6 +320,21 @@ const validateManifest = (value: unknown): ArtifactManifest => { } const revision = requireHash(project.revision, 'project.revision'); if (revision !== digest({ inputs: sourceInputs })) fail('project.revision does not match project.sourceInputs.'); + const packageName = project.packageName === undefined + ? undefined + : requireString(project.packageName, 'project.packageName'); + const packageVersion = project.packageVersion === undefined + ? undefined + : requireString(project.packageVersion, 'project.packageVersion'); + if ((packageName === undefined) !== (packageVersion === undefined)) { + fail('project must include both packageName and packageVersion, or neither.'); + } + if (packageName !== undefined && !isPackageName(packageName)) { + fail('project.packageName must be a nonempty package name.'); + } + if (packageVersion !== undefined && !isSemanticPackageVersion(packageVersion)) { + fail('project.packageVersion must be a semantic version.'); + } const files = parseFiles(manifest.files); const projectInputPaths = new Set(sourceInputs.map((input) => input.path)); @@ -354,6 +372,7 @@ const validateManifest = (value: unknown): ArtifactManifest => { configDigest, configPath, modelDigest: requireHash(project.modelDigest, 'project.modelDigest'), + ...(packageName === undefined ? {} : { packageName, packageVersion }), revision, sourceInputs, }, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 9108f7b6c..901290e40 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -5,6 +5,7 @@ import { basename, extname, relative, resolve } from 'node:path'; import { digest } from '../core/digest.ts'; import { isInside } from '../core/paths.ts'; +import { readProjectPackageJson } from '../core/project-context.ts'; import { defaultGeneratedRuntime, formatRuntimeVersion, @@ -779,6 +780,7 @@ export const normalizeProject = async ( const scripts = normalizeScripts(loaded, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath); + const packageIdentity = readProjectPackageJson(loaded.context.projectRoot)?.identity; const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), @@ -795,6 +797,10 @@ export const normalizeProject = async ( hooks: normalizeHooks(loaded, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), ...(packageBuild === undefined ? {} : { packageBuild }), + ...(packageIdentity === undefined ? {} : { + packageName: packageIdentity.packageName, + packageVersion: packageIdentity.packageVersion, + }), ...(payloads.length === 0 ? {} : { payloads }), runtime: normalizeRuntime(loaded), scripts, diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index ae5c35e20..93254ff00 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -1,10 +1,13 @@ -import { realpathSync } from 'node:fs'; +import { readFileSync, realpathSync } from 'node:fs'; import { isAbsolute, relative, resolve } from 'node:path'; -import { digest } from './digest.ts'; +import type { Diagnostic } from './diagnostics.ts'; +import { digest, sha256Hex } from './digest.ts'; +import { isErrno } from './errors.ts'; import { deepFreeze } from './freeze.ts'; import { isInsideOrEqual } from './paths.ts'; -import { snapshotStrictJsonValue } from './strict-json.ts'; +import { parseSemanticVersion } from './semver.ts'; +import { isRecord, snapshotStrictJsonValue } from './strict-json.ts'; import type { NormalizedPlugin, SourceProvenance } from './types.ts'; /** One deterministic, byte-addressed authored input in a project identity. */ @@ -25,6 +28,8 @@ export interface ProjectContext { readonly configDigest: string; readonly configPath: string; readonly modelDigest: string; + readonly packageName?: string; + readonly packageVersion?: string; readonly revision: string; readonly sourceInputs: readonly ProjectSourceInput[]; } @@ -32,6 +37,7 @@ export interface ProjectContext { export interface CreateProjectContextOptions { readonly configPath: string; readonly model: NormalizedPlugin; + readonly requirePackageIdentity?: boolean; readonly root: string; readonly sourceInputs: readonly ProjectSourceSnapshotInput[]; } @@ -283,20 +289,121 @@ const canonicalSourceInputs = ( return deepFreeze(canonical); }; +export interface ProjectPackageJsonSnapshot { + readonly identity?: { + readonly packageName: string; + readonly packageVersion: string; + }; + readonly sha256: string; +} + +const packageVersionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; + +export const isPackageName = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0 && value.trim() === value; + +export const isSemanticPackageVersion = (value: unknown): value is string => + typeof value === 'string' && + packageVersionPattern.test(value) && + parseSemanticVersion(value) !== undefined; + +const invalidPackageIdentity = (root: string): never => { + throw new TypeError( + `Project package.json in ${JSON.stringify(root)} must declare a nonempty name and valid semantic version.`, + ); +}; + +export const readProjectPackageJson = (root: string): ProjectPackageJsonSnapshot | undefined => { + const packageJsonPath = resolve(root, 'package.json'); + let bytes: string; + try { + bytes = readFileSync(packageJsonPath, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) return undefined; + throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} could not be read.`); + } + const sha256 = sha256Hex(bytes); + let parsed: unknown; + try { + parsed = JSON.parse(bytes); + } catch { + throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be valid JSON.`); + } + if (!isRecord(parsed)) { + throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be a JSON object.`); + } + if (isPackageName(parsed.name) && isSemanticPackageVersion(parsed.version)) { + return { + identity: { packageName: parsed.name, packageVersion: parsed.version }, + sha256, + }; + } + return { sha256 }; +}; + +export const packageVersionMismatchDiagnostic = ( + pluginVersion: string, + packageVersion: string, + sourcePath: string, +): Diagnostic | undefined => { + if (pluginVersion === packageVersion) return undefined; + return { + code: 'AB4008', + message: + `plugin.version ${JSON.stringify(pluginVersion)} differs from package.json version ${JSON.stringify(packageVersion)}; package.json is authoritative.`, + recovery: + 'Keep package.json version as the package identity and update plugin.version to match. plugin.version does not override package.json.', + severity: 'warning', + sourcePath, + }; +}; + +const withPackageJsonSourceInput = ( + root: string, + inputs: readonly ProjectSourceSnapshotInput[], + packageSnapshot: ProjectPackageJsonSnapshot | undefined, +): readonly ProjectSourceSnapshotInput[] => { + if (packageSnapshot === undefined) return inputs; + const packageJsonPath = resolve(root, 'package.json'); + const canonicalPath = resolvedProjectPath(root, packageJsonPath, 'Package manifest path'); + const alreadyDeclared = inputs.some((input) => { + try { + return resolvedProjectPath(root, input.path, 'Project source input path') === canonicalPath; + } catch { + return false; + } + }); + if (alreadyDeclared) return inputs; + return [...inputs, { path: packageJsonPath, sha256: packageSnapshot.sha256 }]; +}; + /** Creates the single canonical identity carried from preparation to publication. */ export const createProjectContext = (options: CreateProjectContextOptions): ProjectContext => { const canonicalRoot = realpathSync(resolve(options.root)); const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path'); - const sourceInputs = canonicalSourceInputs(options.root, options.sourceInputs); + const packageSnapshot = readProjectPackageJson(canonicalRoot); + if (options.requirePackageIdentity === true && packageSnapshot?.identity === undefined) { + invalidPackageIdentity(canonicalRoot); + } + const sourceInputs = canonicalSourceInputs( + options.root, + withPackageJsonSourceInput(canonicalRoot, options.sourceInputs, packageSnapshot), + ); const configInput = sourceInputs.find((input) => input.path === configPath); if (configInput === undefined) { throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`); } assertModelPathsResolveInsideProject(canonicalRoot, options.model); + const identity = packageSnapshot?.identity; return deepFreeze({ configDigest: configInput.sha256, configPath, modelDigest: digest(canonicalizeNormalizedModel(canonicalRoot, options.model)), + ...(identity === undefined ? {} : { + packageName: identity.packageName, + packageVersion: identity.packageVersion, + }), revision: digest({ inputs: sourceInputs }), sourceInputs, }); diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 36dcc18e1..7ff32a2a7 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -453,6 +453,18 @@ export interface NormalizedPlugin { * models predating the package build stay valid. */ readonly packageBuild?: NormalizedPackageBuild; + /** + * Derived package.json name for packaged projects. Absent for unpackaged + * scratch projects. plugin.name stays the host-facing plugin identity and + * is never overwritten. + */ + readonly packageName?: string; + /** + * Derived package.json semantic version for packaged projects. Absent for + * unpackaged scratch projects; release builds fail closed instead of + * inventing a fallback version. + */ + readonly packageVersion?: string; /** * Declared prebuilt payload directories packaged verbatim. Present only * when the config declares a `payload` block; optional so hand-constructed diff --git a/packages/agent-bundle/src/dev/agent-api-wire.ts b/packages/agent-bundle/src/dev/agent-api-wire.ts index 213318ff6..2b443a488 100644 --- a/packages/agent-bundle/src/dev/agent-api-wire.ts +++ b/packages/agent-bundle/src/dev/agent-api-wire.ts @@ -70,6 +70,8 @@ export type AgentApiBuildStatus = export interface AgentApiSourceStatus { readonly diagnostics: readonly AgentApiDiagnostic[]; + readonly packageName?: string; + readonly packageVersion?: string; readonly revision?: string; readonly state: 'invalid' | 'ready' | 'unknown'; } @@ -243,8 +245,12 @@ const sourceWireDto = (value: unknown): AgentApiSourceStatus => { const source = snapshotRecord(value); const state = source?.state; const revision = safeDigest(source?.revision); + const packageName = typeof source?.packageName === 'string' && source.packageName.length > 0 ? source.packageName : undefined; + const packageVersion = typeof source?.packageVersion === 'string' && source.packageVersion.length > 0 ? source.packageVersion : undefined; return Object.freeze({ diagnostics: diagnosticWireDtos(source?.diagnostics), + ...(packageName === undefined ? {} : { packageName }), + ...(packageVersion === undefined ? {} : { packageVersion }), ...(revision === undefined ? {} : { revision }), state: state === 'invalid' || state === 'ready' || state === 'unknown' ? state : 'unknown', }); diff --git a/packages/agent-bundle/src/dev/agent-api.ts b/packages/agent-bundle/src/dev/agent-api.ts index 5db1fbe01..ff9eda724 100644 --- a/packages/agent-bundle/src/dev/agent-api.ts +++ b/packages/agent-bundle/src/dev/agent-api.ts @@ -325,7 +325,7 @@ interface AgentApiDiagnostic { interface AgentApiProjectStatus { readonly artifact: unknown; readonly build: unknown; - readonly source: Readonly<{ readonly diagnostics: readonly AgentApiDiagnostic[]; readonly revision?: string; readonly state: string }>; + readonly source: Readonly<{ readonly diagnostics: readonly AgentApiDiagnostic[]; readonly packageName?: string; readonly packageVersion?: string; readonly revision?: string; readonly state: string }>; } /** Durable, path-free acknowledgement returned when an eval background job is admitted. */ @@ -493,8 +493,12 @@ const sourceWireDto = (value: unknown): AgentApiProjectStatus['source'] => { const source = snapshotRecord(value); const state = source?.state; const revision = safeDigest(source?.revision); + const packageName = typeof source?.packageName === 'string' && source.packageName.length > 0 ? source.packageName : undefined; + const packageVersion = typeof source?.packageVersion === 'string' && source.packageVersion.length > 0 ? source.packageVersion : undefined; return Object.freeze({ diagnostics: diagnosticWireDtos(source?.diagnostics), + ...(packageName === undefined ? {} : { packageName }), + ...(packageVersion === undefined ? {} : { packageVersion }), ...(revision === undefined ? {} : { revision }), state: state === 'invalid' || state === 'ready' || state === 'unknown' ? state : 'unknown', }); diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index 60a8ba1cf..676ca53df 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -253,6 +253,8 @@ export class ArtifactInspectionService { configDigest: project.configDigest, configPath: project.configPath, modelDigest: project.modelDigest, + ...(project.packageName === undefined ? {} : { packageName: project.packageName }), + ...(project.packageVersion === undefined ? {} : { packageVersion: project.packageVersion }), revision: project.revision, sourceInputs: Object.freeze(inputs as ArtifactInspectionSourceInput[]), }); diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index c28e12e62..9e2702d41 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -151,6 +151,8 @@ const withDiagnostics = ( build: { state: 'idle' }, source: { diagnostics: freezeDiagnostics([...source.diagnostics, ...diagnostics]), + ...(source.packageName === undefined ? {} : { packageName: source.packageName }), + ...(source.packageVersion === undefined ? {} : { packageVersion: source.packageVersion }), ...(source.revision === undefined ? {} : { revision: source.revision }), state: hasErrors([...source.diagnostics, ...diagnostics]) ? 'invalid' : source.state, }, diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 5b1482151..5a9a191fc 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -15,6 +15,7 @@ import { deduplicateDiagnostics, type Diagnostic, withDiagnosticRecovery } from import { digest } from '../core/digest.ts'; import { createProjectContext, + packageVersionMismatchDiagnostic, type ProjectContext, type ProjectSourceSnapshotInput, } from '../core/project-context.ts'; @@ -311,8 +312,13 @@ const snapshotForLoadFailure = async ( const sourceStatus = ( diagnostics: readonly Diagnostic[], revision: string, + identity?: Readonly<{ readonly packageName: string; readonly packageVersion: string }>, ): SourceStatus => Object.freeze({ diagnostics, + ...(identity === undefined ? {} : { + packageName: identity.packageName, + packageVersion: identity.packageVersion, + }), revision, state: hasErrors(diagnostics) ? 'invalid' : 'ready', }); @@ -805,6 +811,14 @@ export class ProjectService { } catch { diagnostics.push(projectDiagnostic('AB7001', 'Unable to create project context.', { sourcePath: loaded.configPath })); } + if (projectContext?.packageVersion !== undefined) { + const mismatch = packageVersionMismatchDiagnostic( + model.metadata.version, + projectContext.packageVersion, + loaded.configPath, + ); + if (mismatch !== undefined) diagnostics.push(mismatch); + } let frozenDiagnostics: readonly Diagnostic[]; try { frozenDiagnostics = freezeDiagnostics(diagnostics); @@ -817,7 +831,13 @@ export class ProjectService { snapshot, ); } - const source = sourceStatus(frozenDiagnostics, snapshot.revision); + const source = sourceStatus( + frozenDiagnostics, + snapshot.revision, + projectContext?.packageName === undefined || projectContext.packageVersion === undefined + ? undefined + : { packageName: projectContext.packageName, packageVersion: projectContext.packageVersion }, + ); log(this.#options.logger, 'project.prepared', { diagnostics: frozenDiagnostics.length, root, diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index a8f9d0fa5..f47843e53 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -147,6 +147,8 @@ export type SourceState = 'unknown' | 'ready' | 'invalid'; export interface SourceStatus { readonly diagnostics: readonly Diagnostic[]; + readonly packageName?: string; + readonly packageVersion?: string; readonly revision?: string; readonly state: SourceState; } diff --git a/packages/agent-bundle/tests/config.test.ts b/packages/agent-bundle/tests/config.test.ts index 31eee1be8..c8aa6fd43 100644 --- a/packages/agent-bundle/tests/config.test.ts +++ b/packages/agent-bundle/tests/config.test.ts @@ -6,6 +6,7 @@ import { join } from 'node:path'; import { discoverProject, loadConfig, + normalizeProject, parseSkill, } from '../src/config/index.ts'; import { @@ -508,3 +509,27 @@ it('keeps mandatory resource ignores when .gitignore re-includes their paths', a await removeProjectFixture(fixture.root); } }); + +it('adds derived package identity to the normalized model without changing plugin.name', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-normalize-identity-')); + try { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'review-tools', version: '1.0.0' }, targets: ['portable'] };\n", + ); + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'canonical-pkg', version: '4.5.6' })); + const loaded = await loadConfig({ command: 'build', mode: 'production', root }); + const model = await normalizeProject(loaded, { skills: [] }, { + configExtensions: () => [], + defaultTargetNames: () => ['portable'], + has: (name) => name === 'portable', + supports: () => false, + }); + expect(model.metadata.name).toBe('review-tools'); + expect(model.metadata.version).toBe('1.0.0'); + expect(model.packageName).toBe('canonical-pkg'); + expect(model.packageVersion).toBe('4.5.6'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/core.test.ts b/packages/agent-bundle/tests/core.test.ts index 808db9376..aa46536dc 100644 --- a/packages/agent-bundle/tests/core.test.ts +++ b/packages/agent-bundle/tests/core.test.ts @@ -1,12 +1,25 @@ import { expect, it } from '@rstest/core'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { inspect } from '../src/api.ts'; import { DiagnosticBag, DiagnosticError, type Diagnostic, } from '../src/core/diagnostics.ts'; -import { digest, stableJson } from '../src/core/digest.ts'; +import { digest, sha256Hex, stableJson } from '../src/core/digest.ts'; import { assertInside } from '../src/core/paths.ts'; +import { + createProjectContext, + isPackageName, + isSemanticPackageVersion, + packageVersionMismatchDiagnostic, + readProjectPackageJson, +} from '../src/core/project-context.ts'; +import type { NormalizedPlugin } from '../src/core/types.ts'; +import { ProjectService } from '../src/dev/project-service.ts'; import type { McpTransport } from '../src/index.ts'; // Type-level contract: only modern MCP transports are public. @@ -98,3 +111,223 @@ it('throws stable error summaries containing only error diagnostics', () => { throw new Error('Expected DiagnosticBag.throwIfErrors() to throw.'); }); + +const identityModel = (configPath: string, version = '1.0.0'): NormalizedPlugin => ({ + extensions: {}, + hooks: [], + mcpServers: [], + metadata: { + id: 'plugin:review', + name: 'review', + provenance: { kind: 'config', sourcePath: configPath }, + version, + }, + runtime: { node: '22.12.0' }, + scripts: [], + skills: [], + targets: [{ + id: 'target:portable', + name: 'portable', + provenance: { kind: 'config', sourcePath: configPath }, + }], +}); + +const writeIdentityProject = async (options: { + readonly packageJson?: string; + readonly pluginVersion?: string; +} = {}): Promise<{ readonly configPath: string; readonly root: string }> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-identity-')); + const configPath = join(root, 'agent-bundle.config.ts'); + await writeFile( + configPath, + `export default { plugin: { name: 'review', version: '${options.pluginVersion ?? '1.0.0'}' }, targets: ['portable'] };\n`, + ); + if (options.packageJson !== undefined) { + await writeFile(join(root, 'package.json'), options.packageJson); + } + return { configPath, root }; +}; + +it('loads valid package.json name and version into ProjectContext', async () => { + const { configPath, root } = await writeIdentityProject({ + packageJson: JSON.stringify({ name: '@acme/review', version: '2.3.4' }), + }); + try { + const configBytes = await readFile(configPath); + const packageBytes = await readFile(join(root, 'package.json')); + const context = createProjectContext({ + configPath, + model: identityModel(configPath), + root, + sourceInputs: [{ path: configPath, sha256: sha256Hex(configBytes) }], + }); + expect(context.packageName).toBe('@acme/review'); + expect(context.packageVersion).toBe('2.3.4'); + expect(context.sourceInputs.map((input) => input.path)).toEqual(['agent-bundle.config.ts', 'package.json']); + expect(context.sourceInputs.find((input) => input.path === 'package.json')?.sha256).toBe(sha256Hex(packageBytes)); + expect(Object.isFrozen(context)).toBe(true); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('changes the source revision when package.json bytes change', async () => { + const { configPath, root } = await writeIdentityProject({ + packageJson: JSON.stringify({ name: 'review-pkg', version: '1.0.0' }), + }); + try { + const configBytes = await readFile(configPath); + const first = createProjectContext({ + configPath, + model: identityModel(configPath), + root, + sourceInputs: [{ path: configPath, sha256: sha256Hex(configBytes) }], + }); + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'review-pkg', version: '1.1.0' })); + const second = createProjectContext({ + configPath, + model: identityModel(configPath), + root, + sourceInputs: [{ path: configPath, sha256: sha256Hex(configBytes) }], + }); + expect(second.packageVersion).toBe('1.1.0'); + expect(second.revision).not.toBe(first.revision); + expect(second.packageVersion).not.toBe(first.packageVersion); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('fails closed when release identity is missing or invalid', async () => { + const missing = await writeIdentityProject(); + try { + const configBytes = await readFile(missing.configPath); + expect(() => createProjectContext({ + configPath: missing.configPath, + model: identityModel(missing.configPath), + requirePackageIdentity: true, + root: missing.root, + sourceInputs: [{ path: missing.configPath, sha256: sha256Hex(configBytes) }], + })).toThrow(/nonempty name and valid semantic version/i); + } finally { + await rm(missing.root, { force: true, recursive: true }); + } + + const invalid = await writeIdentityProject({ + packageJson: JSON.stringify({ name: 'review-pkg', version: 'not-a-version' }), + }); + try { + const configBytes = await readFile(invalid.configPath); + expect(() => createProjectContext({ + configPath: invalid.configPath, + model: identityModel(invalid.configPath), + requirePackageIdentity: true, + root: invalid.root, + sourceInputs: [{ path: invalid.configPath, sha256: sha256Hex(configBytes) }], + })).toThrow(/nonempty name and valid semantic version/i); + } finally { + await rm(invalid.root, { force: true, recursive: true }); + } +}); + +it('omits package identity for unpackaged scratch projects', async () => { + const { configPath, root } = await writeIdentityProject(); + try { + const configBytes = await readFile(configPath); + const context = createProjectContext({ + configPath, + model: identityModel(configPath), + root, + sourceInputs: [{ path: configPath, sha256: sha256Hex(configBytes) }], + }); + expect(context.packageName).toBeUndefined(); + expect(context.packageVersion).toBeUndefined(); + expect(Object.keys(context)).toEqual([ + 'configDigest', + 'configPath', + 'modelDigest', + 'revision', + 'sourceInputs', + ]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('treats a hash as an invalid package version and keeps plugin.name separate', () => { + expect(isPackageName('review')).toBe(true); + expect(isPackageName('')).toBe(false); + expect(isSemanticPackageVersion('1.2.3')).toBe(true); + expect(isSemanticPackageVersion('1.2.3-alpha.1+build.5')).toBe(true); + expect(isSemanticPackageVersion('a'.repeat(64))).toBe(false); + expect(readProjectPackageJson('/does-not-exist-agent-bundle-identity')).toBeUndefined(); + expect(packageVersionMismatchDiagnostic('1.0.0', '1.0.0', 'agent-bundle.config.ts')).toBeUndefined(); + expect(packageVersionMismatchDiagnostic('1.0.0', '2.0.0', 'agent-bundle.config.ts')).toMatchObject({ + code: 'AB4008', + severity: 'warning', + }); +}); + +it('exposes package identity on inspect results and development source status', async () => { + const { root } = await writeIdentityProject({ + packageJson: JSON.stringify({ name: 'inspect-pkg', version: '3.1.0' }), + pluginVersion: '3.1.0', + }); + try { + const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect'); + expect(prepared.projectContext?.packageName).toBe('inspect-pkg'); + expect(prepared.projectContext?.packageVersion).toBe('3.1.0'); + expect(prepared.source.packageName).toBe('inspect-pkg'); + expect(prepared.source.packageVersion).toBe('3.1.0'); + expect(prepared.source.revision).toBe(prepared.projectContext?.revision); + expect(prepared.model?.metadata.name).toBe('review'); + expect(prepared.model?.packageName).toBe('inspect-pkg'); + expect(prepared.model?.packageVersion).toBe('3.1.0'); + + const inspection = await inspect({ root, targets: ['portable'] }); + expect(inspection.state).toBe('ready'); + if (inspection.state !== 'ready') throw new Error('Expected a ready inspection.'); + expect(inspection.projectContext.packageName).toBe('inspect-pkg'); + expect(inspection.projectContext.packageVersion).toBe('3.1.0'); + expect(inspection.model.metadata.name).toBe('review'); + expect(inspection.model.packageName).toBe('inspect-pkg'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('warns when plugin.version differs from package.json and does not let plugin.version win', async () => { + const { root } = await writeIdentityProject({ + packageJson: JSON.stringify({ name: 'mismatch-pkg', version: '9.9.9' }), + pluginVersion: '1.0.0', + }); + try { + const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect'); + expect(prepared.projectContext?.packageVersion).toBe('9.9.9'); + expect(prepared.model?.metadata.version).toBe('1.0.0'); + expect(prepared.model?.packageVersion).toBe('9.9.9'); + expect(prepared.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB4008', + severity: 'warning', + }), + ])); + expect(prepared.source.state).toBe('ready'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('keeps plugin.name when package.json uses a different package name', async () => { + const { root } = await writeIdentityProject({ + packageJson: JSON.stringify({ name: 'canonical-package', version: '1.0.0' }), + }); + try { + const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect'); + expect(prepared.model?.metadata.name).toBe('review'); + expect(prepared.model?.packageName).toBe('canonical-package'); + expect(prepared.diagnostics.some((diagnostic) => diagnostic.message.includes('plugin.name'))).toBe(false); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index 0dd4f6e28..a828de9a4 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -279,3 +279,26 @@ it('rejects whitespace, key-order drift, and trailing input outside the canonica expect(() => parseArtifactManifest(reordered)).toThrow(/canonical/i); expect(() => parseArtifactManifest(`${bytes} `)).toThrow(/canonical|JSON/i); }); + +it('records semantic package identity on the project without substituting a hash', () => { + const manifest = clone(); + manifest.project.packageName = '@acme/review'; + manifest.project.packageVersion = '2.3.4'; + const parsed = parseArtifactManifest(canonicalBytes(manifest)); + expect(parsed.project.packageName).toBe('@acme/review'); + expect(parsed.project.packageVersion).toBe('2.3.4'); + expect(parsed.project.packageVersion).not.toMatch(/^[a-f0-9]{64}$/u); + expect(parsed.project.revision).toMatch(/^[a-f0-9]{64}$/u); +}); + +it('rejects a digest masquerading as packageVersion and incomplete identity pairs', () => { + const hashed = clone(); + hashed.project.packageName = 'review'; + hashed.project.packageVersion = hash('e'); + expectInvalid(hashed, /semantic version/i); + + const incomplete = clone(); + incomplete.project.packageName = 'review'; + delete incomplete.project.packageVersion; + expectInvalid(incomplete, /both packageName and packageVersion/i); +}); From f70a5f46838e8b7b9cb590c57464512a91c06aaf Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:04:31 +0000 Subject: [PATCH 2/4] feat(identity): expose labeled 0.0.0-dev fallback and pin Wave 1 proofs Package.json remains authoritative for packaged projects. Unversioned and unpackaged projects now carry a labeled development fallback through the normalized model, ProjectContext, manifests, inspect, and source status. plugin.version mismatches warn only against a real package.json version; plugin.name is unchanged. --- .changeset/project-identity-package.md | 12 ++ .../audiobook-curator/agent-bundle.config.ts | 2 + packages/agent-bundle/src/config/normalize.ts | 10 +- .../agent-bundle/src/core/project-context.ts | 61 +++++- packages/agent-bundle/src/core/types.ts | 10 +- .../agent-bundle/src/dev/project-service.ts | 37 ++-- packages/agent-bundle/tests/api.test.ts | 4 + packages/agent-bundle/tests/config.test.ts | 23 +++ packages/agent-bundle/tests/core.test.ts | 31 ++- .../agent-bundle/tests/dev-services.test.ts | 9 + .../tests/examples-contract.test.ts | 12 ++ .../tests/project-identity.test.ts | 179 ++++++++++++++++++ 12 files changed, 354 insertions(+), 36 deletions(-) create mode 100644 .changeset/project-identity-package.md create mode 100644 packages/agent-bundle/tests/project-identity.test.ts diff --git a/.changeset/project-identity-package.md b/.changeset/project-identity-package.md new file mode 100644 index 000000000..3fdcd2e98 --- /dev/null +++ b/.changeset/project-identity-package.md @@ -0,0 +1,12 @@ +--- +"agent-bundle": minor +--- + +Derive project package identity from `package.json` (#94 Wave 1 stages 1–2). + +Normalized models, artifact manifests, inspect results, and development +source status now expose validated `packageName`/`packageVersion`. +`plugin.version` still authors the native plugin version but no longer +silently wins: a mismatch warns (`AB4008`) and `package.json` remains +authoritative. `plugin.name` is unchanged (G9). Unpackaged or unversioned +projects receive the labeled `0.0.0-dev` development fallback. diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 8f626b3ea..64e6402e3 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -14,6 +14,8 @@ export default defineConfig({ description: 'Complete plan-first audiobook inventory, matching, conversion, repair, and integrity audit.', name: 'audiobook-curator', + // Package identity is derived from package.json. plugin.name stays the + // host slug; plugin.version must match until a later stage makes it optional. version: '1.0.0', }, runtime: { node: '22.19.0' }, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 901290e40..f6907d1ea 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -5,7 +5,7 @@ import { basename, extname, relative, resolve } from 'node:path'; import { digest } from '../core/digest.ts'; import { isInside } from '../core/paths.ts'; -import { readProjectPackageJson } from '../core/project-context.ts'; +import { derivePackageIdentity } from '../core/project-context.ts'; import { defaultGeneratedRuntime, formatRuntimeVersion, @@ -780,7 +780,7 @@ export const normalizeProject = async ( const scripts = normalizeScripts(loaded, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); const packageBuild = normalizePackageBuild(loaded.config, loaded.context.projectRoot, loaded.configPath); - const packageIdentity = readProjectPackageJson(loaded.context.projectRoot)?.identity; + const packageIdentity = derivePackageIdentity(loaded.context.projectRoot); const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), @@ -797,10 +797,8 @@ export const normalizeProject = async ( hooks: normalizeHooks(loaded, targetNames, registry, payloads), ...(nativeHooks.length === 0 ? {} : { nativeHooks }), ...(packageBuild === undefined ? {} : { packageBuild }), - ...(packageIdentity === undefined ? {} : { - packageName: packageIdentity.packageName, - packageVersion: packageIdentity.packageVersion, - }), + packageName: packageIdentity.packageName, + packageVersion: packageIdentity.packageVersion, ...(payloads.length === 0 ? {} : { payloads }), runtime: normalizeRuntime(loaded), scripts, diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 93254ff00..8e2b5090b 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -294,9 +294,22 @@ export interface ProjectPackageJsonSnapshot { readonly packageName: string; readonly packageVersion: string; }; + readonly packageName?: string; readonly sha256: string; } +/** Labeled development fallback used when package.json has no valid name. */ +export const fallbackDevPackageName = 'agent-bundle-dev'; + +/** Labeled development fallback used when package.json has no valid version. */ +export const fallbackDevPackageVersion = '0.0.0-dev'; + +export interface DerivedPackageIdentity { + readonly packageName: string; + readonly packageVersion: string; + readonly source: 'dev-fallback' | 'package.json'; +} + const packageVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; @@ -333,15 +346,45 @@ export const readProjectPackageJson = (root: string): ProjectPackageJsonSnapshot if (!isRecord(parsed)) { throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be a JSON object.`); } - if (isPackageName(parsed.name) && isSemanticPackageVersion(parsed.version)) { + const packageName = isPackageName(parsed.name) ? parsed.name : undefined; + const packageVersion = isSemanticPackageVersion(parsed.version) ? parsed.version : undefined; + return { + sha256, + ...(packageName === undefined ? {} : { packageName }), + ...(packageName === undefined || packageVersion === undefined + ? {} + : { identity: { packageName, packageVersion } }), + }; +}; + +const derivedIdentityFromSnapshot = ( + snapshot: ProjectPackageJsonSnapshot | undefined, +): DerivedPackageIdentity => { + if (snapshot?.identity !== undefined) { + return { + packageName: snapshot.identity.packageName, + packageVersion: snapshot.identity.packageVersion, + source: 'package.json', + }; + } + if (snapshot?.packageName !== undefined) { return { - identity: { packageName: parsed.name, packageVersion: parsed.version }, - sha256, + packageName: snapshot.packageName, + packageVersion: fallbackDevPackageVersion, + source: 'dev-fallback', }; } - return { sha256 }; + return { + packageName: fallbackDevPackageName, + packageVersion: fallbackDevPackageVersion, + source: 'dev-fallback', + }; }; +/** Resolves package.json identity, or the labeled development fallback. */ +export const derivePackageIdentity = (root: string): DerivedPackageIdentity => + derivedIdentityFromSnapshot(readProjectPackageJson(root)); + export const packageVersionMismatchDiagnostic = ( pluginVersion: string, packageVersion: string, @@ -383,7 +426,8 @@ export const createProjectContext = (options: CreateProjectContextOptions): Proj const canonicalRoot = realpathSync(resolve(options.root)); const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path'); const packageSnapshot = readProjectPackageJson(canonicalRoot); - if (options.requirePackageIdentity === true && packageSnapshot?.identity === undefined) { + const derived = derivedIdentityFromSnapshot(packageSnapshot); + if (options.requirePackageIdentity === true && derived.source !== 'package.json') { invalidPackageIdentity(canonicalRoot); } const sourceInputs = canonicalSourceInputs( @@ -395,15 +439,12 @@ export const createProjectContext = (options: CreateProjectContextOptions): Proj throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`); } assertModelPathsResolveInsideProject(canonicalRoot, options.model); - const identity = packageSnapshot?.identity; return deepFreeze({ configDigest: configInput.sha256, configPath, modelDigest: digest(canonicalizeNormalizedModel(canonicalRoot, options.model)), - ...(identity === undefined ? {} : { - packageName: identity.packageName, - packageVersion: identity.packageVersion, - }), + packageName: derived.packageName, + packageVersion: derived.packageVersion, revision: digest({ inputs: sourceInputs }), sourceInputs, }); diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 7ff32a2a7..2f942374e 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -454,15 +454,13 @@ export interface NormalizedPlugin { */ readonly packageBuild?: NormalizedPackageBuild; /** - * Derived package.json name for packaged projects. Absent for unpackaged - * scratch projects. plugin.name stays the host-facing plugin identity and - * is never overwritten. + * Derived package.json name, or omitted for unpackaged scratch projects. + * plugin.name stays the host-facing plugin identity and is never overwritten. */ readonly packageName?: string; /** - * Derived package.json semantic version for packaged projects. Absent for - * unpackaged scratch projects; release builds fail closed instead of - * inventing a fallback version. + * Derived package.json version, or the labeled development fallback + * `0.0.0-dev` when package.json names a package without a valid version. */ readonly packageVersion?: string; /** diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 5a9a191fc..ee943c8d0 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -15,7 +15,11 @@ import { deduplicateDiagnostics, type Diagnostic, withDiagnosticRecovery } from import { digest } from '../core/digest.ts'; import { createProjectContext, + derivePackageIdentity, + fallbackDevPackageName, + fallbackDevPackageVersion, packageVersionMismatchDiagnostic, + type DerivedPackageIdentity, type ProjectContext, type ProjectSourceSnapshotInput, } from '../core/project-context.ts'; @@ -297,6 +301,22 @@ const emptySnapshot = (): ProjectSourceSnapshot => Object.freeze({ revision: digest({ inputs: [] }), }); +const statusIdentity = ( + identity: DerivedPackageIdentity, +): Readonly<{ readonly packageName: string; readonly packageVersion: string }> => identity; + +const packageIdentityFor = (root: string): DerivedPackageIdentity => { + try { + return derivePackageIdentity(root); + } catch { + return { + packageName: fallbackDevPackageName, + packageVersion: fallbackDevPackageVersion, + source: 'dev-fallback', + }; + } +}; + const snapshotForLoadFailure = async ( root: string, configPath: string, @@ -597,7 +617,7 @@ const invalidPreparedProject = (options: { undefined, options.registry, options.root, - sourceStatus(options.diagnostics, snapshot.revision), + sourceStatus(options.diagnostics, snapshot.revision, statusIdentity(packageIdentityFor(options.root))), // 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), @@ -743,7 +763,7 @@ export class ProjectService { const snapshotSource = (): Promise => snapshotProjectSource(root, loaded.configPath, outputRoots, payloadRoots); if (hasErrors(sourceDiagnostics)) { - const source = sourceStatus(sourceDiagnostics, snapshot.revision); + const source = sourceStatus(sourceDiagnostics, snapshot.revision, statusIdentity(packageIdentityFor(root))); log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root }); return preparedProject(loaded.configPath, snapshot, sourceDiagnostics, outputRoots, undefined, registry, root, source, snapshotSource); } @@ -811,10 +831,11 @@ export class ProjectService { } catch { diagnostics.push(projectDiagnostic('AB7001', 'Unable to create project context.', { sourcePath: loaded.configPath })); } - if (projectContext?.packageVersion !== undefined) { + const packageIdentity = packageIdentityFor(root); + if (packageIdentity.source === 'package.json') { const mismatch = packageVersionMismatchDiagnostic( model.metadata.version, - projectContext.packageVersion, + packageIdentity.packageVersion, loaded.configPath, ); if (mismatch !== undefined) diagnostics.push(mismatch); @@ -831,13 +852,7 @@ export class ProjectService { snapshot, ); } - const source = sourceStatus( - frozenDiagnostics, - snapshot.revision, - projectContext?.packageName === undefined || projectContext.packageVersion === undefined - ? undefined - : { packageName: projectContext.packageName, packageVersion: projectContext.packageVersion }, - ); + const source = sourceStatus(frozenDiagnostics, snapshot.revision, statusIdentity(packageIdentity)); log(this.#options.logger, 'project.prepared', { diagnostics: frozenDiagnostics.length, root, diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 8798fa543..638d9a490 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -723,9 +723,13 @@ it('returns an output-independent project context without absolute project paths 'configDigest', 'configPath', 'modelDigest', + 'packageName', + 'packageVersion', 'revision', 'sourceInputs', ]); + expect(left.projectContext.packageName).toBe('agent-bundle-dev'); + expect(left.projectContext.packageVersion).toBe('0.0.0-dev'); expect(JSON.stringify(left.projectContext)).not.toContain(leftRoot); expect(JSON.stringify(right.projectContext)).not.toContain(rightRoot); expect(JSON.stringify(left.projectContext)).not.toContain('custom-artifact'); diff --git a/packages/agent-bundle/tests/config.test.ts b/packages/agent-bundle/tests/config.test.ts index c8aa6fd43..b2f8473c3 100644 --- a/packages/agent-bundle/tests/config.test.ts +++ b/packages/agent-bundle/tests/config.test.ts @@ -533,3 +533,26 @@ it('adds derived package identity to the normalized model without changing plugi await rm(root, { force: true, recursive: true }); } }); + +it('labels unversioned package.json identity as 0.0.0-dev without changing plugin.name', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-normalize-fallback-')); + try { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'review-tools', version: '1.0.0' }, targets: ['portable'] };\n", + ); + await writeFile(join(root, 'package.json'), JSON.stringify({ name: 'canonical-pkg' })); + const loaded = await loadConfig({ command: 'build', mode: 'production', root }); + const model = await normalizeProject(loaded, { skills: [] }, { + configExtensions: () => [], + defaultTargetNames: () => ['portable'], + has: (name) => name === 'portable', + supports: () => false, + }); + expect(model.metadata.name).toBe('review-tools'); + expect(model.packageName).toBe('canonical-pkg'); + expect(model.packageVersion).toBe('0.0.0-dev'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/core.test.ts b/packages/agent-bundle/tests/core.test.ts index aa46536dc..57d754264 100644 --- a/packages/agent-bundle/tests/core.test.ts +++ b/packages/agent-bundle/tests/core.test.ts @@ -13,6 +13,8 @@ import { digest, sha256Hex, stableJson } from '../src/core/digest.ts'; import { assertInside } from '../src/core/paths.ts'; import { createProjectContext, + fallbackDevPackageName, + fallbackDevPackageVersion, isPackageName, isSemanticPackageVersion, packageVersionMismatchDiagnostic, @@ -230,7 +232,7 @@ it('fails closed when release identity is missing or invalid', async () => { } }); -it('omits package identity for unpackaged scratch projects', async () => { +it('uses the labeled development fallback for unpackaged scratch projects', async () => { const { configPath, root } = await writeIdentityProject(); try { const configBytes = await readFile(configPath); @@ -240,12 +242,14 @@ it('omits package identity for unpackaged scratch projects', async () => { root, sourceInputs: [{ path: configPath, sha256: sha256Hex(configBytes) }], }); - expect(context.packageName).toBeUndefined(); - expect(context.packageVersion).toBeUndefined(); + expect(context.packageName).toBe(fallbackDevPackageName); + expect(context.packageVersion).toBe(fallbackDevPackageVersion); expect(Object.keys(context)).toEqual([ 'configDigest', 'configPath', 'modelDigest', + 'packageName', + 'packageVersion', 'revision', 'sourceInputs', ]); @@ -254,6 +258,27 @@ it('omits package identity for unpackaged scratch projects', async () => { } }); + +it('keeps package.json name and labels a missing version as 0.0.0-dev', async () => { + const { configPath, root } = await writeIdentityProject({ + packageJson: JSON.stringify({ name: 'named-scratch' }), + }); + try { + const configBytes = await readFile(configPath); + const context = createProjectContext({ + configPath, + model: identityModel(configPath), + root, + sourceInputs: [{ path: configPath, sha256: sha256Hex(configBytes) }], + }); + expect(context.packageName).toBe('named-scratch'); + expect(context.packageVersion).toBe(fallbackDevPackageVersion); + expect(context.sourceInputs.map((input) => input.path)).toEqual(['agent-bundle.config.ts', 'package.json']); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('treats a hash as an invalid package version and keeps plugin.name separate', () => { expect(isPackageName('review')).toBe(true); expect(isPackageName('')).toBe(false); diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index 5b0073f5d..4a994d93c 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -464,9 +464,18 @@ it('creates an exact deeply frozen root-independent project context', async () = 'configDigest', 'configPath', 'modelDigest', + 'packageName', + 'packageVersion', 'revision', 'sourceInputs', ]); + expect(left.projectContext?.packageName).toBe('agent-bundle-dev'); + expect(left.projectContext?.packageVersion).toBe('0.0.0-dev'); + expect(left.model?.packageName).toBe('agent-bundle-dev'); + expect(left.model?.packageVersion).toBe('0.0.0-dev'); + expect(left.model?.metadata.name).toBe('dev-service-fixture'); + expect(left.source.packageName).toBe('agent-bundle-dev'); + expect(left.source.packageVersion).toBe('0.0.0-dev'); expect(left.projectContext?.configPath).toBe('agent-bundle.config.ts'); expect(left.projectContext?.revision).toBe(digest({ inputs: left.projectContext?.sourceInputs })); expect(left.projectContext?.sourceInputs.map((input) => input.path)).toEqual([ diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index 0e0b9a2c7..68453cc55 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -20,9 +20,15 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { await expect(inspect({ root })).resolves.toMatchObject({ model: { metadata: { name: 'skills-starter' }, + packageName: '@agent-bundle-example/skills-starter', + packageVersion: '0.0.0-dev', scripts: [], targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], }, + projectContext: { + packageName: '@agent-bundle-example/skills-starter', + packageVersion: '0.0.0-dev', + }, state: 'ready', }); await build({ output, root }); @@ -118,6 +124,8 @@ it('publishes the MCP App example service readiness across targets and returns d }); expect(inspected).toMatchObject({ model: { + packageName: '@agent-bundle-example/mcp-app', + packageVersion: '0.0.0-dev', hooks: [{ event: 'sessionStart', targets: ['claude', 'codex'] }], mcpApps: [{ name: 'status', targets: ['portable'] }], mcpServers: [{ name: 'status', targets: ['claude', 'codex', 'portable'] }], @@ -125,6 +133,10 @@ it('publishes the MCP App example service readiness across targets and returns d skills: [{ name: 'service-readiness', targets: ['portable', 'codex', 'claude'] }], targets: [{ name: 'portable' }, { name: 'codex' }, { name: 'claude' }], }, + projectContext: { + packageName: '@agent-bundle-example/mcp-app', + packageVersion: '0.0.0-dev', + }, state: 'ready', }); for (const target of ['portable', 'codex', 'claude'] as const) { diff --git a/packages/agent-bundle/tests/project-identity.test.ts b/packages/agent-bundle/tests/project-identity.test.ts new file mode 100644 index 000000000..f690ffb6e --- /dev/null +++ b/packages/agent-bundle/tests/project-identity.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { + derivePackageIdentity, + fallbackDevPackageName, + fallbackDevPackageVersion, + isPackageName, + isSemanticPackageVersion, + packageVersionMismatchDiagnostic, + readProjectPackageJson, +} from '../src/core/project-context.ts'; +import { ProjectService } from '../src/dev/project-service.ts'; + +const skillMarkdown = [ + '---', + 'name: review', + 'description: Reviews changes', + '---', + 'Review the changed files.', + '', +].join('\n'); + +const writeProject = async (options: { + readonly packageJson?: string; + readonly pluginVersion?: string; +}): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-project-identity-')); + await mkdir(join(root, 'skills', 'review'), { recursive: true }); + await Promise.all([ + writeFile( + join(root, 'agent-bundle.config.ts'), + [ + 'export default {', + ` plugin: { name: 'identity-fixture', version: '${options.pluginVersion ?? '1.0.0'}' },`, + " targets: ['portable'],", + '};', + '', + ].join('\n'), + ), + writeFile(join(root, 'skills', 'review', 'SKILL.md'), skillMarkdown), + ...(options.packageJson === undefined + ? [] + : [writeFile(join(root, 'package.json'), options.packageJson)]), + ]); + return root; +}; + +it('accepts scoped package names and semantic versions', () => { + expect(isPackageName('@agent-bundle-example/audiobook-curator')).toBe(true); + expect(isPackageName('dev-project')).toBe(true); + expect(isPackageName('')).toBe(false); + expect(isPackageName(' padded ')).toBe(false); + expect(isSemanticPackageVersion('1.0.0')).toBe(true); + expect(isSemanticPackageVersion('0.0.0-dev')).toBe(true); + expect(isSemanticPackageVersion('1.2.3-beta.1+build.5')).toBe(true); + expect(isSemanticPackageVersion('01.0.0')).toBe(false); + expect(isSemanticPackageVersion('1.0')).toBe(false); +}); + +it('derives package identity from package.json and falls back when version is absent', async () => { + const packaged = await writeProject({ + packageJson: JSON.stringify({ name: '@scope/packaged', version: '2.4.0', type: 'module' }), + }); + const unpackaged = await writeProject({ + packageJson: JSON.stringify({ name: '@scope/unpackaged', type: 'module' }), + }); + const missing = await writeProject({}); + try { + expect(derivePackageIdentity(packaged)).toEqual({ + packageName: '@scope/packaged', + packageVersion: '2.4.0', + source: 'package.json', + }); + expect(readProjectPackageJson(packaged)).toMatchObject({ + identity: { packageName: '@scope/packaged', packageVersion: '2.4.0' }, + packageName: '@scope/packaged', + }); + expect(derivePackageIdentity(unpackaged)).toEqual({ + packageName: '@scope/unpackaged', + packageVersion: fallbackDevPackageVersion, + source: 'dev-fallback', + }); + expect(derivePackageIdentity(missing)).toEqual({ + packageName: fallbackDevPackageName, + packageVersion: fallbackDevPackageVersion, + source: 'dev-fallback', + }); + } finally { + await Promise.all([ + rm(packaged, { force: true, recursive: true }), + rm(unpackaged, { force: true, recursive: true }), + rm(missing, { force: true, recursive: true }), + ]); + } +}); + +it('warns only when plugin.version differs from a real package.json version', () => { + expect(packageVersionMismatchDiagnostic('1.0.0', '1.0.0', 'agent-bundle.config.ts')).toBeUndefined(); + expect(packageVersionMismatchDiagnostic('1.0.0', '2.4.0', 'agent-bundle.config.ts')).toMatchObject({ + code: 'AB4008', + severity: 'warning', + sourcePath: 'agent-bundle.config.ts', + }); +}); + +it('exposes package identity on inspect, source status, and keeps plugin.name untouched', async () => { + const packaged = await writeProject({ + packageJson: JSON.stringify({ name: '@scope/packaged', version: '2.4.0', type: 'module' }), + pluginVersion: '2.4.0', + }); + const mismatched = await writeProject({ + packageJson: JSON.stringify({ name: '@scope/mismatched', version: '9.9.9', type: 'module' }), + pluginVersion: '1.0.0', + }); + try { + const ready = await new ProjectService({ root: packaged }).prepare('inspect'); + expect(ready.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); + expect(ready.model?.metadata.name).toBe('identity-fixture'); + expect(ready.model?.metadata.version).toBe('2.4.0'); + expect(ready.model?.packageName).toBe('@scope/packaged'); + expect(ready.model?.packageVersion).toBe('2.4.0'); + expect(ready.projectContext).toMatchObject({ + packageName: '@scope/packaged', + packageVersion: '2.4.0', + }); + expect(ready.source).toMatchObject({ + packageName: '@scope/packaged', + packageVersion: '2.4.0', + state: 'ready', + }); + expect(ready.projectContext?.sourceInputs.map((input) => input.path)).toEqual( + expect.arrayContaining(['package.json']), + ); + + const conflict = await new ProjectService({ root: mismatched }).prepare('inspect'); + expect(conflict.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB4008', severity: 'warning' }), + ])); + expect(conflict.model?.metadata.name).toBe('identity-fixture'); + expect(conflict.model?.packageVersion).toBe('9.9.9'); + expect(conflict.projectContext?.packageVersion).toBe('9.9.9'); + } finally { + await Promise.all([ + rm(packaged, { force: true, recursive: true }), + rm(mismatched, { force: true, recursive: true }), + ]); + } +}); + +it('uses package.json as the only version source for audiobook-curator and labels other examples', async () => { + const examplesRoot = join(process.cwd(), 'examples'); + const audiobook = await new ProjectService({ root: join(examplesRoot, 'audiobook-curator') }).prepare('inspect'); + expect(audiobook.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); + expect(audiobook.model?.metadata.name).toBe('audiobook-curator'); + expect(audiobook.model?.packageName).toBe('@agent-bundle-example/audiobook-curator'); + expect(audiobook.model?.packageVersion).toBe('1.0.0'); + expect(audiobook.projectContext?.packageName).toBe('@agent-bundle-example/audiobook-curator'); + expect(audiobook.projectContext?.packageVersion).toBe('1.0.0'); + expect(audiobook.source.packageVersion).toBe('1.0.0'); + + for (const example of [ + ['skills-starter', '@agent-bundle-example/skills-starter'], + ['hooks-and-scripts', '@agent-bundle-example/hooks-and-scripts'], + ['mcp-app', '@agent-bundle-example/mcp-app'], + ['rsc-agent-runtime', '@agent-bundle/rsc-agent-runtime-demo'], + ] as const) { + const prepared = await new ProjectService({ root: join(examplesRoot, example[0]) }).prepare('inspect'); + expect(prepared.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); + expect(prepared.model?.metadata.name).not.toBe(example[1]); + expect(prepared.model?.packageName).toBe(example[1]); + expect(prepared.model?.packageVersion).toBe(fallbackDevPackageVersion); + expect(prepared.projectContext?.packageVersion).toBe(fallbackDevPackageVersion); + expect(prepared.source.packageVersion).toBe(fallbackDevPackageVersion); + } +}); From 642e14353bf2d5357e44b77f7f2a9ec33fd7bec6 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:07:16 +0000 Subject: [PATCH 3/4] test(identity): pin fallback on exact source DTOs and example package.json --- .../agent-bundle/tests/dev-services.test.ts | 2 ++ .../tests/project-identity.test.ts | 28 ++++++++----------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index 4a994d93c..3f45be09c 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -179,6 +179,8 @@ it('surfaces a non-finite registered config extension as the closed AB4500 proje severity: 'error', sourcePath: join(project.root, 'agent-bundle.config.ts'), }], + packageName: 'agent-bundle-dev', + packageVersion: '0.0.0-dev', revision: expect.any(String), state: 'invalid', }); diff --git a/packages/agent-bundle/tests/project-identity.test.ts b/packages/agent-bundle/tests/project-identity.test.ts index f690ffb6e..7c603e484 100644 --- a/packages/agent-bundle/tests/project-identity.test.ts +++ b/packages/agent-bundle/tests/project-identity.test.ts @@ -151,29 +151,23 @@ it('exposes package identity on inspect, source status, and keeps plugin.name un } }); -it('uses package.json as the only version source for audiobook-curator and labels other examples', async () => { +it('uses package.json as the only version source for audiobook-curator and labels other examples', () => { const examplesRoot = join(process.cwd(), 'examples'); - const audiobook = await new ProjectService({ root: join(examplesRoot, 'audiobook-curator') }).prepare('inspect'); - expect(audiobook.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); - expect(audiobook.model?.metadata.name).toBe('audiobook-curator'); - expect(audiobook.model?.packageName).toBe('@agent-bundle-example/audiobook-curator'); - expect(audiobook.model?.packageVersion).toBe('1.0.0'); - expect(audiobook.projectContext?.packageName).toBe('@agent-bundle-example/audiobook-curator'); - expect(audiobook.projectContext?.packageVersion).toBe('1.0.0'); - expect(audiobook.source.packageVersion).toBe('1.0.0'); - + expect(derivePackageIdentity(join(examplesRoot, 'audiobook-curator'))).toEqual({ + packageName: '@agent-bundle-example/audiobook-curator', + packageVersion: '1.0.0', + source: 'package.json', + }); for (const example of [ ['skills-starter', '@agent-bundle-example/skills-starter'], ['hooks-and-scripts', '@agent-bundle-example/hooks-and-scripts'], ['mcp-app', '@agent-bundle-example/mcp-app'], ['rsc-agent-runtime', '@agent-bundle/rsc-agent-runtime-demo'], ] as const) { - const prepared = await new ProjectService({ root: join(examplesRoot, example[0]) }).prepare('inspect'); - expect(prepared.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); - expect(prepared.model?.metadata.name).not.toBe(example[1]); - expect(prepared.model?.packageName).toBe(example[1]); - expect(prepared.model?.packageVersion).toBe(fallbackDevPackageVersion); - expect(prepared.projectContext?.packageVersion).toBe(fallbackDevPackageVersion); - expect(prepared.source.packageVersion).toBe(fallbackDevPackageVersion); + expect(derivePackageIdentity(join(examplesRoot, example[0]))).toEqual({ + packageName: example[1], + packageVersion: fallbackDevPackageVersion, + source: 'dev-fallback', + }); } }); From 826b2abe31170eaafb5fd9227252941670bc0d15 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:14:16 +0000 Subject: [PATCH 4/4] test(identity): keep Wave 1 example proof off the RSC demo tree The labeled 0.0.0-dev fallback is covered by skills-starter, hooks-and-scripts, and mcp-app. --- packages/agent-bundle/tests/project-identity.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/agent-bundle/tests/project-identity.test.ts b/packages/agent-bundle/tests/project-identity.test.ts index 7c603e484..420db6eb7 100644 --- a/packages/agent-bundle/tests/project-identity.test.ts +++ b/packages/agent-bundle/tests/project-identity.test.ts @@ -162,7 +162,6 @@ it('uses package.json as the only version source for audiobook-curator and label ['skills-starter', '@agent-bundle-example/skills-starter'], ['hooks-and-scripts', '@agent-bundle-example/hooks-and-scripts'], ['mcp-app', '@agent-bundle-example/mcp-app'], - ['rsc-agent-runtime', '@agent-bundle/rsc-agent-runtime-demo'], ] as const) { expect(derivePackageIdentity(join(examplesRoot, example[0]))).toEqual({ packageName: example[1],