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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/identity-package-axes.md
Original file line number Diff line number Diff line change
@@ -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).
9 changes: 9 additions & 0 deletions examples/audiobook-curator/agent-bundle.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
26 changes: 25 additions & 1 deletion packages/agent-bundle/src/build/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[];
}
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
},
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -215,6 +216,12 @@ const writeHumanInspect = (output: Output, result: Awaited<ReturnType<typeof ins
return;
}
output.write(`Inspected ${result.model.metadata.name}: ${result.plans.map((plan) => 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 });
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-bundle/src/config/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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,
},
Expand Down
62 changes: 61 additions & 1 deletion packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, unknown>).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,
Expand Down Expand Up @@ -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));
Expand Down
103 changes: 101 additions & 2 deletions packages/agent-bundle/src/core/project-context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Comment on lines +51 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject npm-reserved package names

Reject reserved names such as node_modules and favicon.ico, which this regex currently accepts even though npm's package-name validator marks them invalid. With either value in package.json, snapshotPackageIdentity emits it as a validated packageName, AB4009 is skipped, and artifact-manifest validation also accepts the invalid release identity.

Useful? React with 👍 / 👎.


/** 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 `<root>/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');
Comment on lines +74 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep package identity inside the project root

Resolve and contain-check package.json before reading it. When a project-root package.json is a symlink to a file outside the root, this read derives release identity from that external file, while the source snapshot traversal ignores symlink entries; consequently the external bytes are absent from sourceInputs and the dev publication drift check cannot detect changes to them during a build.

Useful? React with 👍 / 👎.

} 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<Record<string, unknown>>;
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<ProjectContext, 'packageVersion' | 'revision'>,
): string =>
context.packageVersion ??
`dev.${context.revision.slice(0, 12)} (development fallback — no package.json version)`;

export interface CreateProjectContextOptions {
readonly configPath: string;
readonly model: NormalizedPlugin;
Expand Down Expand Up @@ -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,
});
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]),
});
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/dev/artifacts/artifact-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Expand Down
Loading
Loading