diff --git a/.changeset/identity-package-axes.md b/.changeset/identity-package-axes.md new file mode 100644 index 000000000..71f09dfc5 --- /dev/null +++ b/.changeset/identity-package-axes.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Derive validated `packageName`/`packageVersion` from the project's `package.json` into the project identity (issue #94 stages 1-2). Both axes now flow through the normalized model metadata, `ProjectContext`, artifact manifests, inspect output, and dev status DTOs; projects without a package version keep a clearly labeled development fallback in displays. New warning diagnostics: AB4008 (`plugin.version` differs from the package version), AB4009 (invalid npm package name), AB4010 (invalid package semver), AB4011 (unparsable package.json). diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 8f626b3ea..177bab518 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -13,7 +13,16 @@ export default defineConfig({ plugin: { description: 'Complete plan-first audiobook inventory, matching, conversion, repair, and integrity audit.', + // `name` is the host-native plugin slug — deliberately not the npm + // package name (`@agent-bundle-example/audiobook-curator`); scoped npm + // names never become slugs. name: 'audiobook-curator', + // Release identity is derived from package.json: `packageName` and + // `packageVersion` flow into the project context, artifact manifests, + // inspect output, and dev status. This declared version must match the + // package.json version — a mismatch reports the AB4008 warning. The + // package.json version is the single version source; this field only + // restates it until plugin.version becomes optional (issue #94 stage 3). version: '1.0.0', }, runtime: { node: '22.19.0' }, diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index d9e050258..7a81e31ec 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -4,6 +4,7 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; +import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; @@ -43,6 +44,10 @@ export interface ArtifactManifestProject { readonly configDigest: string; readonly configPath: string; readonly modelDigest: string; + /** The validated npm package name axis; absent for unpackaged development projects. */ + readonly packageName?: string; + /** The validated semantic release-version axis; absent for unpackaged development projects. */ + readonly packageVersion?: string; readonly revision: string; readonly sourceInputs: readonly ArtifactManifestSourceInput[]; } @@ -307,7 +312,24 @@ 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 packageName = project.packageName === undefined + ? undefined + : requireString(project.packageName, 'project.packageName'); + if (packageName !== undefined && !isValidPackageName(packageName)) { + fail('project.packageName must be a valid npm package name.'); + } + const packageVersion = project.packageVersion === undefined + ? undefined + : requireString(project.packageVersion, 'project.packageVersion'); + if (packageVersion !== undefined && !isValidPackageVersion(packageVersion)) { + fail('project.packageVersion must be a valid semantic version.'); + } const sourceInputs = parseSourceInputs(project.sourceInputs, 'project.sourceInputs'); const configPath = requirePath(project.configPath, 'project.configPath'); const configDigest = requireHash(project.configDigest, 'project.configDigest'); @@ -354,6 +376,8 @@ const validateManifest = (value: unknown): ArtifactManifest => { configDigest, configPath, modelDigest: requireHash(project.modelDigest, 'project.modelDigest'), + ...(packageName === undefined ? {} : { packageName }), + ...(packageVersion === undefined ? {} : { packageVersion }), revision, sourceInputs, }, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index bb5d65c2d..91e60fc6a 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -19,6 +19,7 @@ import type { ProjectOptions, } from './api.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; +import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; @@ -215,6 +216,12 @@ const writeHumanInspect = (output: Output, result: Awaited plan.target).join(', ')}\n`); + // Release identity is derived from package.json (issue #94); a project + // without a package version gets a clearly labeled development fallback. + if (result.projectContext.packageName !== undefined) { + output.write(`Package: ${result.projectContext.packageName}\n`); + } + output.write(`Version: ${projectVersionLabel(result.projectContext)}\n`); }; const emptyEvalSummary = Object.freeze({ cases: 0, fail: 0, inconclusive: 0, pass: 0, trials: 0 }); diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 9108f7b6c..146acad71 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -11,6 +11,7 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; +import { snapshotPackageIdentity } from '../core/project-context.ts'; import { isRecord } from '../core/strict-json.ts'; import { isPrebuiltEntryInput, parseNativeHookToolSelector, pathTokens } from '../core/types.ts'; import type { @@ -773,6 +774,10 @@ export const normalizeProject = async ( }; }); const description = loaded.config.plugin.description; + // The npm package axes are derived, never authored in config: package.json + // is authoritative for release identity (issue #94), while plugin.version + // remains the host-facing declared version during the migration. + const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot); const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry); const payloads = normalizePayloads(loaded, discovered, targetNames); const mcpServers = normalizeMcpServers(loaded, targetNames, payloads); @@ -787,6 +792,8 @@ export const normalizeProject = async ( ...(typeof description === 'string' ? { description } : {}), id: `plugin:${loaded.config.plugin.name}`, name: loaded.config.plugin.name, + ...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }), + ...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }), provenance: configProvenance, version: loaded.config.plugin.version, }, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index b21489d84..8d47ba021 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,10 +1,14 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs'; -import { basename, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path'; +import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; import { scanEntryExportsSource } from '../build/entry-exports.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; +import { + snapshotPackageIdentity, + type PackageIdentityIssueKind, +} from '../core/project-context.ts'; import { defaultGeneratedRuntime, parseRuntimeVersion, @@ -1332,6 +1336,61 @@ export interface ValidateSourceOptions { readonly payloadFreshness?: boolean; } +/** + * AB4008-AB4011: the package-identity axes derived from `package.json` + * (issue #94). The package version is authoritative release identity, so a + * conflicting `plugin.version` and any invalid derived value surface as + * warnings — never errors: a missing package.json (or missing name/version + * fields) stays a normal, silent development state with a labeled fallback. + */ +const packageIdentityIssueCode = (kind: PackageIdentityIssueKind): string => { + switch (kind) { + case 'invalid-name': + return 'AB4009'; + case 'invalid-version': + return 'AB4010'; + case 'unparsable': + return 'AB4011'; + default: { + const exhaustive: never = kind; + throw new TypeError(`Unknown package identity issue kind ${String(exhaustive)}.`); + } + } +}; + +const validatePackageIdentity = (loaded: LoadedConfig): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const identity = snapshotPackageIdentity(loaded.context.projectRoot); + const packageJsonPath = join(loaded.context.projectRoot, 'package.json'); + for (const issue of identity.issues) { + diagnostics.push(warningDiagnostic( + packageIdentityIssueCode(issue.kind), + issue.message, + packageJsonPath, + 'Correct the package.json field so the derived package identity is valid, then validate again.', + )); + } + const plugin = loaded.config.plugin as unknown; + const pluginVersion = + typeof plugin === 'object' && plugin !== null && !Array.isArray(plugin) + ? (plugin as Record).version + : undefined; + if ( + identity.packageVersion !== undefined && + typeof pluginVersion === 'string' && + pluginVersion.trim().length > 0 && + pluginVersion !== identity.packageVersion + ) { + diagnostics.push(warningDiagnostic( + 'AB4008', + `Config plugin.version ${JSON.stringify(pluginVersion)} differs from package.json version ${JSON.stringify(identity.packageVersion)}; the package version is authoritative for release identity.`, + loaded.configPath, + 'Align plugin.version with the package.json version, or update package.json.', + )); + } + return diagnostics; +}; + export const validateSource = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1387,6 +1446,7 @@ export const validateSource = ( const payloads = declaredPayloads(loaded, registry); diagnostics.push(...validateAssets(loaded)); + diagnostics.push(...validatePackageIdentity(loaded)); diagnostics.push(...validateBin(loaded)); diagnostics.push(...validateHooks(loaded, registry, payloads)); diagnostics.push(...validateLib(loaded)); diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index ae5c35e20..e891a5a14 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -1,5 +1,5 @@ -import { realpathSync } from 'node:fs'; -import { isAbsolute, relative, resolve } from 'node:path'; +import { readFileSync, realpathSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; import { digest } from './digest.ts'; import { deepFreeze } from './freeze.ts'; @@ -25,10 +25,106 @@ export interface ProjectContext { readonly configDigest: string; readonly configPath: string; readonly modelDigest: string; + /** The validated npm package name axis; absent for unpackaged development projects. */ + readonly packageName?: string; + /** The validated semantic release-version axis; absent for unpackaged development projects. */ + readonly packageVersion?: string; readonly revision: string; readonly sourceInputs: readonly ProjectSourceInput[]; } +export type PackageIdentityIssueKind = 'invalid-name' | 'invalid-version' | 'unparsable'; + +/** One problem found while deriving package identity from `package.json`. */ +export interface PackageIdentityIssue { + readonly kind: PackageIdentityIssueKind; + readonly message: string; +} + +/** The release-identity axes derived from a project's `package.json`. */ +export interface PackageIdentitySnapshot { + readonly issues: readonly PackageIdentityIssue[]; + readonly packageName?: string; + readonly packageVersion?: string; +} + +/** npm's naming rules for new packages: lowercase, URL-safe, optional scope. */ +const packageNamePattern = /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u; + +/** The strict semver 2.0.0 grammar, without any leading `v`. */ +const packageVersionPattern = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/u; + +/** True for a name npm would accept for a new package. */ +export const isValidPackageName = (value: string): boolean => + value.length > 0 && value.length <= 214 && packageNamePattern.test(value); + +/** True for a strict semver 2.0.0 version. */ +export const isValidPackageVersion = (value: string): boolean => packageVersionPattern.test(value); + +/** + * Derives the release-identity axes from `/package.json`. A missing + * package.json (or missing name/version fields) is a normal development + * state: no identity and no issues. An invalid name or version becomes an + * issue for the caller to surface as a diagnostic, never a crash, and the + * invalid value is withheld from the derived identity. + */ +export const snapshotPackageIdentity = (root: string): PackageIdentitySnapshot => { + let bytes: string; + try { + bytes = readFileSync(join(resolve(root), 'package.json'), 'utf8'); + } catch { + return deepFreeze({ issues: [] }); + } + let parsed: unknown; + try { + parsed = JSON.parse(bytes); + } catch { + return deepFreeze({ issues: [{ kind: 'unparsable', message: 'package.json is not valid JSON.' }] }); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return deepFreeze({ issues: [{ kind: 'unparsable', message: 'package.json must contain a JSON object.' }] }); + } + const record = parsed as Readonly>; + const issues: PackageIdentityIssue[] = []; + let packageName: string | undefined; + if (record.name !== undefined) { + if (typeof record.name === 'string' && isValidPackageName(record.name)) packageName = record.name; + else { + issues.push({ + kind: 'invalid-name', + message: `package.json name ${JSON.stringify(record.name)} is not a valid npm package name.`, + }); + } + } + let packageVersion: string | undefined; + if (record.version !== undefined) { + if (typeof record.version === 'string' && isValidPackageVersion(record.version)) packageVersion = record.version; + else { + issues.push({ + kind: 'invalid-version', + message: `package.json version ${JSON.stringify(record.version)} is not a valid semantic version.`, + }); + } + } + return deepFreeze({ + issues, + ...(packageName === undefined ? {} : { packageName }), + ...(packageVersion === undefined ? {} : { packageVersion }), + }); +}; + +/** + * The human display label for the release-version axis. Without a package + * version there is no release identity, so the label is a clearly marked + * development fallback over the source revision — never a semantic version. + */ +export const projectVersionLabel = ( + context: Pick, +): string => + context.packageVersion ?? + `dev.${context.revision.slice(0, 12)} (development fallback — no package.json version)`; + export interface CreateProjectContextOptions { readonly configPath: string; readonly model: NormalizedPlugin; @@ -293,10 +389,13 @@ 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 { packageName, packageVersion } = options.model.metadata; return deepFreeze({ configDigest: configInput.sha256, configPath, modelDigest: digest(canonicalizeNormalizedModel(canonicalRoot, options.model)), + ...(packageName === undefined ? {} : { packageName }), + ...(packageVersion === undefined ? {} : { 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..6e4270a06 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -245,6 +245,10 @@ export interface NormalizedMetadata { readonly description?: string; readonly id: string; readonly name: string; + /** The validated npm package name derived from the project's package.json. */ + readonly packageName?: string; + /** The validated semantic version derived from the project's package.json. */ + readonly packageVersion?: string; readonly provenance: SourceProvenance; readonly version: string; } 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/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index 149835940..0b24f6238 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -241,6 +241,8 @@ export class ArtifactService { id: epochId, manifestPath: join(prepared.root, '.agent-bundle', 'epochs', epochId, 'agent-bundle.manifest.json'), modelDigest: projectContext.modelDigest, + ...(projectContext.packageName === undefined ? {} : { packageName: projectContext.packageName }), + ...(projectContext.packageVersion === undefined ? {} : { packageVersion: projectContext.packageVersion }), projectRevision: projectContext.revision, targetDigests: await targetDigests(artifactRoot, model), }); diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index c492220e9..980047ffe 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -141,6 +141,7 @@ const artifactEpochKeys = [ 'projectRevision', 'targetDigests', ] as const; +const artifactEpochOptionalKeys = ['packageName', 'packageVersion'] as const; const epochDiagnosticsKeys = ['errors', 'infos', 'warnings'] as const; const epochReferenceCounts = new Map(); const epochLeaseQueues = new Map>(); @@ -150,6 +151,17 @@ const hasExactOwnKeys = (value: object, keys: readonly string[]): boolean => { return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); }; +/** Every required key present, every present key either required or optional. */ +const hasRequiredOwnKeys = ( + value: object, + required: readonly string[], + optional: readonly string[], +): boolean => { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every((key) => allowed.has(key)); +}; + const leaseQueueFor = (agentBundlePath: string): ReturnType => { const existing = epochLeaseQueues.get(agentBundlePath); if (existing !== undefined) return existing; @@ -194,7 +206,7 @@ const normalizeEpoch = (value: unknown): ArtifactEpoch | undefined => { typeof value !== 'object' || value === null || Array.isArray(value) || - !hasExactOwnKeys(value, artifactEpochKeys) + !hasRequiredOwnKeys(value, artifactEpochKeys, artifactEpochOptionalKeys) ) return undefined; const epoch = value as Partial; const diagnostics = epoch.diagnostics; @@ -205,6 +217,8 @@ const normalizeEpoch = (value: unknown): ArtifactEpoch | undefined => { typeof epoch.id !== 'string' || typeof epoch.manifestPath !== 'string' || typeof epoch.modelDigest !== 'string' || + (epoch.packageName !== undefined && typeof epoch.packageName !== 'string') || + (epoch.packageVersion !== undefined && typeof epoch.packageVersion !== 'string') || typeof epoch.projectRevision !== 'string' || typeof diagnostics !== 'object' || diagnostics === null || @@ -241,6 +255,8 @@ const normalizeEpoch = (value: unknown): ArtifactEpoch | undefined => { id: epoch.id, manifestPath: epoch.manifestPath, modelDigest: epoch.modelDigest, + ...(epoch.packageName === undefined ? {} : { packageName: epoch.packageName }), + ...(epoch.packageVersion === undefined ? {} : { packageVersion: epoch.packageVersion }), projectRevision: epoch.projectRevision, targetDigests: Object.fromEntries( targetEntries.sort(([left], [right]) => left.localeCompare(right)), diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index a8f9d0fa5..4f4328683 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -20,6 +20,10 @@ export interface ArtifactEpoch { readonly id: string; readonly manifestPath: string; readonly modelDigest: string; + /** The npm package name axis of the published project, when packaged. */ + readonly packageName?: string; + /** The semantic release-version axis of the published project, when packaged. */ + readonly packageVersion?: string; readonly projectRevision: string; readonly targetDigests: Readonly>; } diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index 0e0b9a2c7..baa7bafd7 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; import { build, inspect, invokeMcp, listHooks, listMcp, runEvals, simulateHook, validate } from '../src/api.ts'; +import { projectVersionLabel } from '../src/core/project-context.ts'; const execFile = promisify(executeFile); const examplesRoot = join(process.cwd(), 'examples'); @@ -17,7 +18,8 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { await rm(output, { force: true, recursive: true }); try { - await expect(inspect({ root })).resolves.toMatchObject({ + const inspection = await inspect({ root }); + expect(inspection).toMatchObject({ model: { metadata: { name: 'skills-starter' }, scripts: [], @@ -25,6 +27,12 @@ it('builds the Skills Starter through public Agent Bundle APIs', async () => { }, state: 'ready', }); + if (inspection.state !== 'ready') throw new Error('unreachable'); + // Identity stages 1-2 (#94): no package.json version, so the release + // axis is absent and displays fall back to the labeled dev form. + expect(inspection.projectContext.packageName).toBe('@agent-bundle-example/skills-starter'); + expect(inspection.projectContext.packageVersion).toBeUndefined(); + expect(projectVersionLabel(inspection.projectContext)).toContain('development fallback'); await build({ output, root }); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); await expect(readFile(join(output, 'portable', 'skills', 'release-review', 'SKILL.md'), 'utf8')) @@ -254,3 +262,22 @@ it('simulates the Hooks example and executes release checks', async () => { ]); } }); + +it('derives the Audiobook Curator release identity from package.json as the one version source', async () => { + const root = join(examplesRoot, 'audiobook-curator'); + const inspection = await inspect({ root }); + expect(inspection.state).toBe('ready'); + if (inspection.state !== 'ready') throw new Error('unreachable'); + // package.json declares 1.0.0 once; both derived axes and the config's + // still-required plugin.version agree, so no AB4008 mismatch surfaces. + expect(inspection.projectContext.packageName).toBe('@agent-bundle-example/audiobook-curator'); + expect(inspection.projectContext.packageVersion).toBe('1.0.0'); + expect(inspection.model.metadata).toMatchObject({ + name: 'audiobook-curator', + packageName: '@agent-bundle-example/audiobook-curator', + packageVersion: '1.0.0', + version: '1.0.0', + }); + expect(projectVersionLabel(inspection.projectContext)).toBe('1.0.0'); + expect(inspection.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4008')).toEqual([]); +}); diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index 0dd4f6e28..92d82c891 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -279,3 +279,32 @@ 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('round-trips the optional package identity axes distinctly', () => { + const manifest = validManifest(); + (manifest.project as { packageName?: string }).packageName = '@agent-bundle-example/audiobook-curator'; + (manifest.project as { packageVersion?: string }).packageVersion = '1.0.0'; + const assembled = assembleArtifactManifest(manifest); + expect(assembled.manifest.project.packageName).toBe('@agent-bundle-example/audiobook-curator'); + expect(assembled.manifest.project.packageVersion).toBe('1.0.0'); + expect(parseArtifactManifest(assembled.bytes).project).toMatchObject({ + packageName: '@agent-bundle-example/audiobook-curator', + packageVersion: '1.0.0', + }); +}); + +it('accepts a project without package identity and rejects invalid identity values', () => { + const withoutIdentity = assembleArtifactManifest(validManifest()); + expect(withoutIdentity.manifest.project.packageName).toBeUndefined(); + expect(withoutIdentity.manifest.project.packageVersion).toBeUndefined(); + + const invalidName = validManifest(); + (invalidName.project as { packageName?: string }).packageName = 'Not A Valid Name'; + expect(() => serializeArtifactManifest(invalidName)) + .toThrow('project.packageName must be a valid npm package name.'); + + const invalidVersion = validManifest(); + (invalidVersion.project as { packageVersion?: string }).packageVersion = 'v1.0.0'; + expect(() => serializeArtifactManifest(invalidVersion)) + .toThrow('project.packageVersion must be a valid semantic version.'); +}); diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index a0ee279a7..9324a1b8a 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -136,7 +136,10 @@ it('normalizes registered extensions and validates registered script and hook ta targetRegistry: NormalizationTargetRegistry, ) => Diagnostic[]; - expect(sourceValidator(loaded, { skills: [] }, extensionRegistry)).toEqual([]); + // Pin flip (#94 stages 1-2): this fixture is rooted at the workspace, whose + // package.json version (0.0.0) differs from the fixture's plugin.version, + // so the AB4008 mismatch warning is the only expected diagnostic. + expect(sourceValidator(loaded, { skills: [] }, extensionRegistry).map(({ code }) => code)).toEqual(['AB4008']); const model = await normalizeProject(loaded, { skills: [] }, extensionRegistry); const extensions = (model as unknown as { @@ -255,7 +258,10 @@ it('reports unknown hook and script targets through the target registry', () => registry: NormalizationTargetRegistry, ) => Diagnostic[]; + // Pin flip (#94 stages 1-2): the workspace root package.json version + // (0.0.0) differs from the fixture's plugin.version, adding AB4008. expect(sourceValidator(loaded, { skills: [] }, targetRegistry).map(({ code }) => code)).toEqual([ + 'AB4008', 'AB4203', 'AB4406', ]); @@ -747,8 +753,10 @@ it('validates the assets configuration shape, containment, and literal existence expect(diagnosticsFor('assets')).toEqual(['AB4600']); expect(diagnosticsFor([''])).toEqual(['AB4600']); expect(diagnosticsFor([42])).toEqual(['AB4600']); - expect(diagnosticsFor(['../outside'], process.cwd())).toEqual(['AB4601']); - expect(diagnosticsFor(['definitely-missing-asset-entry'], process.cwd())).toEqual(['AB4602']); - expect(diagnosticsFor(['definitely-missing/*.svg'], process.cwd())).toEqual([]); - expect(diagnosticsFor(['package.json'], process.cwd())).toEqual([]); + // Pin flip (#94 stages 1-2): fixtures rooted at the workspace also report + // the AB4008 plugin.version/package version mismatch warning. + expect(diagnosticsFor(['../outside'], process.cwd())).toEqual(['AB4601', 'AB4008']); + expect(diagnosticsFor(['definitely-missing-asset-entry'], process.cwd())).toEqual(['AB4602', 'AB4008']); + expect(diagnosticsFor(['definitely-missing/*.svg'], process.cwd())).toEqual(['AB4008']); + expect(diagnosticsFor(['package.json'], process.cwd())).toEqual(['AB4008']); }); diff --git a/packages/agent-bundle/tests/package-identity.test.ts b/packages/agent-bundle/tests/package-identity.test.ts new file mode 100644 index 000000000..a799cf88a --- /dev/null +++ b/packages/agent-bundle/tests/package-identity.test.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { normalizeProject, validateSource, type NormalizationTargetRegistry } from '../src/config/index.ts'; +import type { LoadedConfig } from '../src/config/load.ts'; +import { + createProjectContext, + isValidPackageName, + isValidPackageVersion, + projectVersionLabel, + snapshotPackageIdentity, +} from '../src/core/project-context.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; + +const registry: NormalizationTargetRegistry = { + configExtensions: () => [], + defaultTargetNames: () => ['portable'], + has: (name) => name === 'portable', + supports: () => false, +}; + +const config = (version = '1.0.0'): AgentBundleConfig => ({ + plugin: { name: 'identity-fixture', version }, +}); + +const loadedProject = (root: string, pluginVersion = '1.0.0'): LoadedConfig => ({ + config: config(pluginVersion), + configPath: join(root, 'agent-bundle.config.ts'), + context: { + command: 'build', + mode: 'production', + projectRoot: root, + selectedTargets: [], + }, +}); + +const withProject = async ( + packageJson: string | undefined, + run: (root: string) => Promise, +): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-package-identity-')); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + if (packageJson !== undefined) await writeFile(join(root, 'package.json'), packageJson); + await run(root); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +it('accepts npm package names and strict semver versions only', () => { + expect(isValidPackageName('@agent-bundle-example/audiobook-curator')).toBe(true); + expect(isValidPackageName('audiobook-curator')).toBe(true); + expect(isValidPackageName('UpperCase')).toBe(false); + expect(isValidPackageName('.hidden')).toBe(false); + expect(isValidPackageName('a'.repeat(215))).toBe(false); + expect(isValidPackageName('')).toBe(false); + + expect(isValidPackageVersion('1.0.0')).toBe(true); + expect(isValidPackageVersion('1.2.3-rc.1+build.5')).toBe(true); + expect(isValidPackageVersion('v1.0.0')).toBe(false); + expect(isValidPackageVersion('1.0')).toBe(false); + expect(isValidPackageVersion('01.0.0')).toBe(false); +}); + +it('derives both package identity axes from a packaged project', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => { + expect(snapshotPackageIdentity(root)).toEqual({ + issues: [], + packageName: '@scope/pkg', + packageVersion: '2.3.4', + }); + }); +}); + +it('treats a missing package.json as a normal development state', async () => { + await withProject(undefined, async (root) => { + expect(snapshotPackageIdentity(root)).toEqual({ issues: [] }); + }); +}); + +it('withholds invalid identity values as issues instead of crashing', async () => { + await withProject(JSON.stringify({ name: 'Not Valid!', version: 'one.two' }), async (root) => { + const identity = snapshotPackageIdentity(root); + expect(identity.packageName).toBeUndefined(); + expect(identity.packageVersion).toBeUndefined(); + expect(identity.issues).toMatchObject([ + { kind: 'invalid-name' }, + { kind: 'invalid-version' }, + ]); + }); + await withProject('not json', async (root) => { + expect(snapshotPackageIdentity(root).issues).toMatchObject([{ kind: 'unparsable' }]); + }); +}); + +it('labels the development fallback distinctly from a release version', () => { + expect(projectVersionLabel({ packageVersion: '1.0.0', revision: 'a'.repeat(64) })).toBe('1.0.0'); + const fallback = projectVersionLabel({ revision: 'abc123def4567890'.padEnd(64, '0') }); + expect(fallback).toBe('dev.abc123def456 (development fallback — no package.json version)'); +}); + +it('carries the derived axes through the normalized model into the project context', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => { + const loaded = loadedProject(root, '2.3.4'); + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata).toMatchObject({ packageName: '@scope/pkg', packageVersion: '2.3.4', version: '2.3.4' }); + + const configSha = createHash('sha256').update('export default {};\n').digest('hex'); + const context = createProjectContext({ + configPath: loaded.configPath, + model, + root, + sourceInputs: [{ path: 'agent-bundle.config.ts', sha256: configSha }], + }); + expect(context.packageName).toBe('@scope/pkg'); + expect(context.packageVersion).toBe('2.3.4'); + expect(projectVersionLabel(context)).toBe('2.3.4'); + }); +}); + +it('omits both axes from the model and context for unpackaged projects', async () => { + await withProject(undefined, async (root) => { + const loaded = loadedProject(root); + const model = await normalizeProject(loaded, { skills: [] }, registry); + expect(model.metadata.packageName).toBeUndefined(); + expect(model.metadata.packageVersion).toBeUndefined(); + + const configSha = createHash('sha256').update('export default {};\n').digest('hex'); + const context = createProjectContext({ + configPath: loaded.configPath, + model, + root, + sourceInputs: [{ path: 'agent-bundle.config.ts', sha256: configSha }], + }); + expect(context.packageName).toBeUndefined(); + expect(context.packageVersion).toBeUndefined(); + expect(projectVersionLabel(context)).toContain('development fallback'); + }); +}); + +it('warns with AB4008 when plugin.version differs from the package version', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.0.0' }), async (root) => { + const diagnostics = validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry); + expect(diagnostics).toMatchObject([{ + code: 'AB4008', + severity: 'warning', + sourcePath: join(root, 'agent-bundle.config.ts'), + }]); + expect(diagnostics[0]!.message).toContain('"1.0.0"'); + expect(diagnostics[0]!.message).toContain('"2.0.0"'); + }); +}); + +it('stays silent when plugin.version matches the package version or no package version exists', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '1.0.0' }), async (root) => { + expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry)).toEqual([]); + }); + await withProject(JSON.stringify({ name: '@scope/pkg' }), async (root) => { + expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry)).toEqual([]); + }); + await withProject(undefined, async (root) => { + expect(validateSource(loadedProject(root, '1.0.0'), { skills: [] }, registry)).toEqual([]); + }); +}); + +it('warns with AB4009/AB4010/AB4011 for invalid package identity values', async () => { + await withProject(JSON.stringify({ name: 'Not Valid!', version: 'one.two' }), async (root) => { + expect(validateSource(loadedProject(root), { skills: [] }, registry)).toMatchObject([ + { code: 'AB4009', severity: 'warning', sourcePath: join(root, 'package.json') }, + { code: 'AB4010', severity: 'warning', sourcePath: join(root, 'package.json') }, + ]); + }); + await withProject('not json', async (root) => { + expect(validateSource(loadedProject(root), { skills: [] }, registry)).toMatchObject([ + { code: 'AB4011', severity: 'warning', sourcePath: join(root, 'package.json') }, + ]); + }); +});