Skip to content
Merged
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': 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 (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).
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
64 changes: 63 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,63 @@ 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 'outside-root':
return 'AB4011';
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 +1448,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
125 changes: 123 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,128 @@ 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' | 'outside-root' | '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;

/** 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 &&
!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);

/**
* 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 packageJsonPath: string;
let canonicalRoot: string;
try {
canonicalRoot = realpathSync(resolve(root));
packageJsonPath = realpathSync(join(resolve(root), 'package.json'));
} catch {
return deepFreeze({ issues: [] });
Comment on lines +81 to +85

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 Report package manifests that cannot be read

When package.json exists but resolving it fails because of permissions, a dangling symlink, or another filesystem error, this catch reports the same empty, issue-free snapshot as a genuinely missing manifest. The later readFileSync catch does the same for readable-path/I/O failures, so validation silently omits package identity instead of producing the advertised AB4011 unusable-manifest diagnostic; distinguish ENOENT from failures involving an existing manifest and return an unparsable/unusable issue for the latter.

Useful? React with 👍 / 👎.

}
// 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(packageJsonPath, '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<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 ??
`0.0.0-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 +411,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
Loading
Loading