From f6a1de16b1a939b74eaca8ab5b833e9c2b2fd881 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 01:10:04 +0000 Subject: [PATCH 1/3] feat(identity): derive package name/version into project identity (stages 1-2) Part of #94. Derives validated packageName/packageVersion from the project's package.json into NormalizedMetadata and ProjectContext, and exposes both axes distinctly in artifact manifests, inspect output, and dev status DTOs (ArtifactEpoch). Projects without a package version keep a clearly labeled development fallback in displays; nothing new is required. New warning diagnostics: AB4008 (plugin.version differs from the package version), AB4009 (invalid npm package name), AB4010 (invalid package semver), AB4011 (unparsable package.json). Per G9, plugin.name stays the host-native slug and is never derived. --- .changeset/identity-package-axes.md | 5 + .../audiobook-curator/agent-bundle.config.ts | 9 + packages/agent-bundle/src/build/manifest.ts | 26 ++- packages/agent-bundle/src/cli.ts | 7 + packages/agent-bundle/src/config/normalize.ts | 7 + packages/agent-bundle/src/config/validate.ts | 62 +++++- .../agent-bundle/src/core/project-context.ts | 103 +++++++++- packages/agent-bundle/src/core/types.ts | 4 + .../artifacts/artifact-inspection-service.ts | 2 + .../src/dev/artifacts/artifact-service.ts | 2 + packages/agent-bundle/src/dev/epoch-store.ts | 18 +- packages/agent-bundle/src/dev/types.ts | 4 + .../tests/examples-contract.test.ts | 29 ++- packages/agent-bundle/tests/manifest.test.ts | 29 +++ .../agent-bundle/tests/normalization.test.ts | 18 +- .../tests/package-identity.test.ts | 183 ++++++++++++++++++ 16 files changed, 497 insertions(+), 11 deletions(-) create mode 100644 .changeset/identity-package-axes.md create mode 100644 packages/agent-bundle/tests/package-identity.test.ts 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') }, + ]); + }); +}); From 62dded1611447f7097dce699792a189404b93b4a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 01:20:30 +0000 Subject: [PATCH 2/3] feat(identity): adopt #115 conventions and fix Codex findings Adopted from PR #115 (parallel session): the labeled 0.0.0-dev development-fallback naming, package identity on the dev source status DTO (SourceStatus + agent API wire DTOs + coordinator passthrough), a minor changeset (new public manifest/context fields are a feature), and ProjectService-level source-status test coverage. Codex fixes: reject npm-reserved package names (node_modules, favicon.ico) and ignore a package.json symlinked outside the project root (AB4011) so identity cannot drift without a revision change. Also pins rejection of invalid semver prerelease identifiers, which #115's looser pattern accepted. --- .changeset/identity-package-axes.md | 4 +- packages/agent-bundle/src/config/validate.ts | 2 + .../agent-bundle/src/core/project-context.ts | 30 ++++++++-- .../agent-bundle/src/dev/agent-api-wire.ts | 10 ++++ packages/agent-bundle/src/dev/agent-api.ts | 10 +++- packages/agent-bundle/src/dev/coordinator.ts | 2 + .../agent-bundle/src/dev/project-service.ts | 20 ++++++- packages/agent-bundle/src/dev/types.ts | 4 ++ .../tests/package-identity.test.ts | 55 ++++++++++++++++++- 9 files changed, 125 insertions(+), 12 deletions(-) diff --git a/.changeset/identity-package-axes.md b/.changeset/identity-package-axes.md index 71f09dfc5..da5f184d6 100644 --- a/.changeset/identity-package-axes.md +++ b/.changeset/identity-package-axes.md @@ -1,5 +1,5 @@ --- -'agent-bundle': patch +'agent-bundle': minor --- -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). +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 (source status and artifact epochs); `plugin.version` still authors the native plugin version but the package version is authoritative for release identity and a mismatch never silently wins. Projects without a package version keep a clearly labeled `0.0.0-dev` 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 (unusable package.json). diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 8d47ba021..0b2da5b34 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1349,6 +1349,8 @@ const packageIdentityIssueCode = (kind: PackageIdentityIssueKind): string => { return 'AB4009'; case 'invalid-version': return 'AB4010'; + case 'outside-root': + return 'AB4011'; case 'unparsable': return 'AB4011'; default: { diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index e891a5a14..2cd2e1d04 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -33,7 +33,7 @@ export interface ProjectContext { readonly sourceInputs: readonly ProjectSourceInput[]; } -export type PackageIdentityIssueKind = 'invalid-name' | 'invalid-version' | 'unparsable'; +export type PackageIdentityIssueKind = 'invalid-name' | 'invalid-version' | 'outside-root' | 'unparsable'; /** One problem found while deriving package identity from `package.json`. */ export interface PackageIdentityIssue { @@ -51,13 +51,19 @@ export interface PackageIdentitySnapshot { /** 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; +/** Names npm's validator rejects outright even though the grammar matches. */ +const reservedPackageNames = new Set(['node_modules', 'favicon.ico']); + /** 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); + value.length > 0 && + value.length <= 214 && + !reservedPackageNames.has(value.toLowerCase()) && + packageNamePattern.test(value); /** True for a strict semver 2.0.0 version. */ export const isValidPackageVersion = (value: string): boolean => packageVersionPattern.test(value); @@ -70,9 +76,25 @@ export const isValidPackageVersion = (value: string): boolean => packageVersionP * invalid value is withheld from the derived identity. */ export const snapshotPackageIdentity = (root: string): PackageIdentitySnapshot => { + let packageJsonPath: string; + let canonicalRoot: string; + try { + canonicalRoot = realpathSync(resolve(root)); + packageJsonPath = realpathSync(join(resolve(root), 'package.json')); + } catch { + return deepFreeze({ issues: [] }); + } + // A package.json symlinked outside the project cannot join the identity: + // its bytes are invisible to the source snapshot, so deriving release + // identity from it would let identity drift without a revision change. + if (!isInsideOrEqual(canonicalRoot, packageJsonPath)) { + return deepFreeze({ + issues: [{ kind: 'outside-root', message: 'package.json resolves outside the project root; package identity is ignored.' }], + }); + } let bytes: string; try { - bytes = readFileSync(join(resolve(root), 'package.json'), 'utf8'); + bytes = readFileSync(packageJsonPath, 'utf8'); } catch { return deepFreeze({ issues: [] }); } @@ -123,7 +145,7 @@ export const projectVersionLabel = ( context: Pick, ): string => context.packageVersion ?? - `dev.${context.revision.slice(0, 12)} (development fallback — no package.json version)`; + `0.0.0-dev.${context.revision.slice(0, 12)} (development fallback — no package.json version)`; export interface CreateProjectContextOptions { readonly configPath: string; diff --git a/packages/agent-bundle/src/dev/agent-api-wire.ts b/packages/agent-bundle/src/dev/agent-api-wire.ts index 213318ff6..d543543d4 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,16 @@ 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..6f92ca6f5 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,16 @@ 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/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..a2c8cd172 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, + snapshotPackageIdentity, type ProjectContext, type ProjectSourceSnapshotInput, } from '../core/project-context.ts'; @@ -308,11 +309,24 @@ const snapshotForLoadFailure = async ( } }; +/** The derived identity axes a source status carries, when present. */ +const sourceIdentity = ( + root: string, +): Readonly<{ readonly packageName?: string; readonly packageVersion?: string }> => { + const identity = snapshotPackageIdentity(root); + return Object.freeze({ + ...(identity.packageName === undefined ? {} : { packageName: identity.packageName }), + ...(identity.packageVersion === undefined ? {} : { packageVersion: identity.packageVersion }), + }); +}; + const sourceStatus = ( diagnostics: readonly Diagnostic[], revision: string, + root: string, ): SourceStatus => Object.freeze({ diagnostics, + ...sourceIdentity(root), revision, state: hasErrors(diagnostics) ? 'invalid' : 'ready', }); @@ -591,7 +605,7 @@ const invalidPreparedProject = (options: { undefined, options.registry, options.root, - sourceStatus(options.diagnostics, snapshot.revision), + sourceStatus(options.diagnostics, snapshot.revision, 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), @@ -737,7 +751,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, root); log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root }); return preparedProject(loaded.configPath, snapshot, sourceDiagnostics, outputRoots, undefined, registry, root, source, snapshotSource); } @@ -817,7 +831,7 @@ export class ProjectService { snapshot, ); } - const source = sourceStatus(frozenDiagnostics, snapshot.revision); + const source = sourceStatus(frozenDiagnostics, snapshot.revision, root); 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 4f4328683..70484402b 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -151,6 +151,10 @@ export type SourceState = 'unknown' | 'ready' | 'invalid'; export interface SourceStatus { readonly diagnostics: readonly Diagnostic[]; + /** The npm package name axis derived from package.json, when valid. */ + readonly packageName?: string; + /** The semantic release-version axis derived from package.json, when valid. */ + readonly packageVersion?: string; readonly revision?: string; readonly state: SourceState; } diff --git a/packages/agent-bundle/tests/package-identity.test.ts b/packages/agent-bundle/tests/package-identity.test.ts index a799cf88a..24b92a7fd 100644 --- a/packages/agent-bundle/tests/package-identity.test.ts +++ b/packages/agent-bundle/tests/package-identity.test.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -15,6 +15,7 @@ import { snapshotPackageIdentity, } from '../src/core/project-context.ts'; import type { AgentBundleConfig } from '../src/core/types.ts'; +import { ProjectService } from '../src/dev/project-service.ts'; const registry: NormalizationTargetRegistry = { configExtensions: () => [], @@ -59,12 +60,20 @@ it('accepts npm package names and strict semver versions only', () => { expect(isValidPackageName('.hidden')).toBe(false); expect(isValidPackageName('a'.repeat(215))).toBe(false); expect(isValidPackageName('')).toBe(false); + // npm-reserved names fail even though the grammar matches. + expect(isValidPackageName('node_modules')).toBe(false); + expect(isValidPackageName('favicon.ico')).toBe(false); expect(isValidPackageVersion('1.0.0')).toBe(true); + expect(isValidPackageVersion('0.0.0-dev')).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); + // Invalid prerelease identifiers: leading-zero numerics and empty parts. + expect(isValidPackageVersion('1.0.0-01')).toBe(false); + expect(isValidPackageVersion('1.0.0-alpha..1')).toBe(false); + expect(isValidPackageVersion('1.0.0-.')).toBe(false); }); it('derives both package identity axes from a packaged project', async () => { @@ -101,7 +110,7 @@ it('withholds invalid identity values as issues instead of crashing', async () = 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)'); + expect(fallback).toBe('0.0.0-dev.abc123def456 (development fallback — no package.json version)'); }); it('carries the derived axes through the normalized model into the project context', async () => { @@ -181,3 +190,45 @@ it('warns with AB4009/AB4010/AB4011 for invalid package identity values', async ]); }); }); + +it('ignores a package.json symlinked outside the project root', async () => { + const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-outside-identity-')); + await writeFile(join(outside, 'package.json'), JSON.stringify({ name: '@scope/outside', version: '9.9.9' })); + await withProject(undefined, async (root) => { + await symlink(join(outside, 'package.json'), join(root, 'package.json')); + const identity = snapshotPackageIdentity(root); + expect(identity.packageName).toBeUndefined(); + expect(identity.packageVersion).toBeUndefined(); + expect(identity.issues).toMatchObject([{ kind: 'outside-root' }]); + expect(validateSource(loadedProject(root), { skills: [] }, registry)).toMatchObject([ + { code: 'AB4011', severity: 'warning' }, + ]); + }); + await rm(outside, { force: true, recursive: true }); +}); + +it('exposes the derived axes on the development source status', async () => { + await withProject(JSON.stringify({ name: '@scope/pkg', version: '2.3.4' }), async (root) => { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'identity-fixture', version: '2.3.4' }, targets: ['portable'] };\n", + ); + const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect'); + expect(prepared.source).toMatchObject({ + packageName: '@scope/pkg', + packageVersion: '2.3.4', + state: 'ready', + }); + expect(prepared.projectContext).toMatchObject({ packageName: '@scope/pkg', packageVersion: '2.3.4' }); + }); + await withProject(JSON.stringify({ name: '@scope/pkg' }), async (root) => { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'identity-fixture', version: '1.0.0' }, targets: ['portable'] };\n", + ); + const prepared = await new ProjectService({ root, targets: ['portable'] }).prepare('inspect'); + expect(prepared.source.packageName).toBe('@scope/pkg'); + expect(prepared.source.packageVersion).toBeUndefined(); + expect(prepared.source.state).toBe('ready'); + }); +}); From 316b1504804da5d468ad60de9d203d34bb038e7c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 02:41:56 +0000 Subject: [PATCH 3/3] fix(workbench): accept the derived package identity fields in strict status and artifact decoders The workbench client decodes /api/project/status and the artifact inspection route with exact-key validation, so the new optional packageName/packageVersion axes made every decode fail and the dashboard never settled (all browser e2e suites timed out on visibility). Allow both optional fields in sourceStatusSchema, artifactEpochSchema, and the artifact-client isProject check, and flip the overview.e2e source-status pin to include the derived packageName. --- packages/workbench/src/artifacts/artifact-client.ts | 6 ++++-- packages/workbench/src/project-client.ts | 4 ++++ packages/workbench/tests/overview.e2e.test.ts | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index a12cc5166..694231636 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -66,9 +66,11 @@ const isTreeNode = (value: unknown): boolean => { }; const isProject = (value: unknown): boolean => - exactRecord(value, ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs']) && + exactRecord(value, ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs'], ['packageName', 'packageVersion']) && typeof value.configDigest === 'string' && typeof value.configPath === 'string' && - typeof value.modelDigest === 'string' && typeof value.revision === 'string' && arrayOf(value.sourceInputs, isSourceInput); + typeof value.modelDigest === 'string' && typeof value.revision === 'string' && arrayOf(value.sourceInputs, isSourceInput) && + (!Object.hasOwn(value, 'packageName') || typeof value.packageName === 'string') && + (!Object.hasOwn(value, 'packageVersion') || typeof value.packageVersion === 'string'); const isProvenance = (value: unknown): boolean => exactRecord(value, ['outputPath', 'sourceInputs']) && typeof value.outputPath === 'string' && diff --git a/packages/workbench/src/project-client.ts b/packages/workbench/src/project-client.ts index ac0a1d993..4c30c7e69 100644 --- a/packages/workbench/src/project-client.ts +++ b/packages/workbench/src/project-client.ts @@ -118,12 +118,16 @@ const artifactEpochSchema: z.ZodType = z.strictObject({ id: z.string(), manifestPath: z.string(), modelDigest: z.string(), + packageName: z.string().optional(), + packageVersion: z.string().optional(), projectRevision: z.string(), targetDigests: z.record(z.string(), z.string()), }); const sourceStatusSchema: z.ZodType = z.strictObject({ diagnostics: z.array(diagnosticSchema), + packageName: z.string().optional(), + packageVersion: z.string().optional(), revision: z.string().optional(), state: z.enum(['invalid', 'ready', 'unknown']), }); diff --git a/packages/workbench/tests/overview.e2e.test.ts b/packages/workbench/tests/overview.e2e.test.ts index 58024e4a5..cc924b623 100644 --- a/packages/workbench/tests/overview.e2e.test.ts +++ b/packages/workbench/tests/overview.e2e.test.ts @@ -311,7 +311,8 @@ e2e('offers the host-owned MCP playground handoff only after a selected Runtime return (await response.json() as { readonly status: RuntimeStatus }).status; }, fixture.url); const initialProjectSource = await readProjectSource(); - expect(initialProjectSource).toEqual({ diagnostics: [], revision: sourceRevision, state: 'ready' }); + // Pin flip (#94 stages 1-2): source status now carries the package identity derived from package.json. + expect(initialProjectSource).toEqual({ diagnostics: [], packageName: '@agent-bundle/rsc-agent-runtime-demo', revision: sourceRevision, state: 'ready' }); const expectRuntimeProfileInspection = async (preview: Locator, expectedSourceRevision: string): Promise => { await expect(preview.getByLabel('Simulated MCP App profile')).toContainText('Portable MCP Apps'); await expect(preview.getByLabel('Simulated MCP App profile')).toContainText('agent-bundle:mcp-apps:2026-01-26');