Skip to content

Commit e8908cc

Browse files
feat(identity): derive package name/version into project identity (stages 1-2) (#121)
* 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. * 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. * 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.
1 parent 8bf9618 commit e8908cc

23 files changed

Lines changed: 624 additions & 18 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'agent-bundle': minor
3+
---
4+
5+
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).

‎examples/audiobook-curator/agent-bundle.config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@ export default defineConfig({
1313
plugin: {
1414
description:
1515
'Complete plan-first audiobook inventory, matching, conversion, repair, and integrity audit.',
16+
// `name` is the host-native plugin slug — deliberately not the npm
17+
// package name (`@agent-bundle-example/audiobook-curator`); scoped npm
18+
// names never become slugs.
1619
name: 'audiobook-curator',
20+
// Release identity is derived from package.json: `packageName` and
21+
// `packageVersion` flow into the project context, artifact manifests,
22+
// inspect output, and dev status. This declared version must match the
23+
// package.json version — a mismatch reports the AB4008 warning. The
24+
// package.json version is the single version source; this field only
25+
// restates it until plugin.version becomes optional (issue #94 stage 3).
1726
version: '1.0.0',
1827
},
1928
runtime: { node: '22.19.0' },

‎packages/agent-bundle/src/build/manifest.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
parseRuntimeVersion,
55
satisfiesGeneratedRuntimeFloor,
66
} from '../core/runtime.ts';
7+
import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts';
78
import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';
89

910
export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt';
@@ -43,6 +44,10 @@ export interface ArtifactManifestProject {
4344
readonly configDigest: string;
4445
readonly configPath: string;
4546
readonly modelDigest: string;
47+
/** The validated npm package name axis; absent for unpackaged development projects. */
48+
readonly packageName?: string;
49+
/** The validated semantic release-version axis; absent for unpackaged development projects. */
50+
readonly packageVersion?: string;
4651
readonly revision: string;
4752
readonly sourceInputs: readonly ArtifactManifestSourceInput[];
4853
}
@@ -307,7 +312,24 @@ const validateManifest = (value: unknown): ArtifactManifest => {
307312
if (producer.name !== 'agent-bundle') fail('producer.name must be "agent-bundle".');
308313

309314
const project = requireRecord(manifest.project, 'project');
310-
requireExactKeys(project, 'project', ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs']);
315+
requireExactKeys(
316+
project,
317+
'project',
318+
['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs'],
319+
['packageName', 'packageVersion'],
320+
);
321+
const packageName = project.packageName === undefined
322+
? undefined
323+
: requireString(project.packageName, 'project.packageName');
324+
if (packageName !== undefined && !isValidPackageName(packageName)) {
325+
fail('project.packageName must be a valid npm package name.');
326+
}
327+
const packageVersion = project.packageVersion === undefined
328+
? undefined
329+
: requireString(project.packageVersion, 'project.packageVersion');
330+
if (packageVersion !== undefined && !isValidPackageVersion(packageVersion)) {
331+
fail('project.packageVersion must be a valid semantic version.');
332+
}
311333
const sourceInputs = parseSourceInputs(project.sourceInputs, 'project.sourceInputs');
312334
const configPath = requirePath(project.configPath, 'project.configPath');
313335
const configDigest = requireHash(project.configDigest, 'project.configDigest');
@@ -354,6 +376,8 @@ const validateManifest = (value: unknown): ArtifactManifest => {
354376
configDigest,
355377
configPath,
356378
modelDigest: requireHash(project.modelDigest, 'project.modelDigest'),
379+
...(packageName === undefined ? {} : { packageName }),
380+
...(packageVersion === undefined ? {} : { packageVersion }),
357381
revision,
358382
sourceInputs,
359383
},

‎packages/agent-bundle/src/cli.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
ProjectOptions,
2020
} from './api.ts';
2121
import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts';
22+
import { projectVersionLabel } from './core/project-context.ts';
2223
import { stableJson } from './core/digest.ts';
2324
import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts';
2425

@@ -215,6 +216,12 @@ const writeHumanInspect = (output: Output, result: Awaited<ReturnType<typeof ins
215216
return;
216217
}
217218
output.write(`Inspected ${result.model.metadata.name}: ${result.plans.map((plan) => plan.target).join(', ')}\n`);
219+
// Release identity is derived from package.json (issue #94); a project
220+
// without a package version gets a clearly labeled development fallback.
221+
if (result.projectContext.packageName !== undefined) {
222+
output.write(`Package: ${result.projectContext.packageName}\n`);
223+
}
224+
output.write(`Version: ${projectVersionLabel(result.projectContext)}\n`);
218225
};
219226

220227
const emptyEvalSummary = Object.freeze({ cases: 0, fail: 0, inconclusive: 0, pass: 0, trials: 0 });

‎packages/agent-bundle/src/config/normalize.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
parseRuntimeVersion,
1212
satisfiesGeneratedRuntimeFloor,
1313
} from '../core/runtime.ts';
14+
import { snapshotPackageIdentity } from '../core/project-context.ts';
1415
import { isRecord } from '../core/strict-json.ts';
1516
import { isPrebuiltEntryInput, parseNativeHookToolSelector, pathTokens } from '../core/types.ts';
1617
import type {
@@ -773,6 +774,10 @@ export const normalizeProject = async (
773774
};
774775
});
775776
const description = loaded.config.plugin.description;
777+
// The npm package axes are derived, never authored in config: package.json
778+
// is authoritative for release identity (issue #94), while plugin.version
779+
// remains the host-facing declared version during the migration.
780+
const packageIdentity = snapshotPackageIdentity(loaded.context.projectRoot);
776781
const nativeHooks = await normalizeNativeHooks(loaded, targetNames, registry);
777782
const payloads = normalizePayloads(loaded, discovered, targetNames);
778783
const mcpServers = normalizeMcpServers(loaded, targetNames, payloads);
@@ -787,6 +792,8 @@ export const normalizeProject = async (
787792
...(typeof description === 'string' ? { description } : {}),
788793
id: `plugin:${loaded.config.plugin.name}`,
789794
name: loaded.config.plugin.name,
795+
...(packageIdentity.packageName === undefined ? {} : { packageName: packageIdentity.packageName }),
796+
...(packageIdentity.packageVersion === undefined ? {} : { packageVersion: packageIdentity.packageVersion }),
790797
provenance: configProvenance,
791798
version: loaded.config.plugin.version,
792799
},

‎packages/agent-bundle/src/config/validate.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
2-
import { basename, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path';
2+
import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
33

44
import { scanEntryExportsSource } from '../build/entry-exports.ts';
55
import type { Diagnostic } from '../core/diagnostics.ts';
66
import { stableJson } from '../core/digest.ts';
77
import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts';
8+
import {
9+
snapshotPackageIdentity,
10+
type PackageIdentityIssueKind,
11+
} from '../core/project-context.ts';
812
import {
913
defaultGeneratedRuntime,
1014
parseRuntimeVersion,
@@ -1332,6 +1336,63 @@ export interface ValidateSourceOptions {
13321336
readonly payloadFreshness?: boolean;
13331337
}
13341338

1339+
/**
1340+
* AB4008-AB4011: the package-identity axes derived from `package.json`
1341+
* (issue #94). The package version is authoritative release identity, so a
1342+
* conflicting `plugin.version` and any invalid derived value surface as
1343+
* warnings — never errors: a missing package.json (or missing name/version
1344+
* fields) stays a normal, silent development state with a labeled fallback.
1345+
*/
1346+
const packageIdentityIssueCode = (kind: PackageIdentityIssueKind): string => {
1347+
switch (kind) {
1348+
case 'invalid-name':
1349+
return 'AB4009';
1350+
case 'invalid-version':
1351+
return 'AB4010';
1352+
case 'outside-root':
1353+
return 'AB4011';
1354+
case 'unparsable':
1355+
return 'AB4011';
1356+
default: {
1357+
const exhaustive: never = kind;
1358+
throw new TypeError(`Unknown package identity issue kind ${String(exhaustive)}.`);
1359+
}
1360+
}
1361+
};
1362+
1363+
const validatePackageIdentity = (loaded: LoadedConfig): Diagnostic[] => {
1364+
const diagnostics: Diagnostic[] = [];
1365+
const identity = snapshotPackageIdentity(loaded.context.projectRoot);
1366+
const packageJsonPath = join(loaded.context.projectRoot, 'package.json');
1367+
for (const issue of identity.issues) {
1368+
diagnostics.push(warningDiagnostic(
1369+
packageIdentityIssueCode(issue.kind),
1370+
issue.message,
1371+
packageJsonPath,
1372+
'Correct the package.json field so the derived package identity is valid, then validate again.',
1373+
));
1374+
}
1375+
const plugin = loaded.config.plugin as unknown;
1376+
const pluginVersion =
1377+
typeof plugin === 'object' && plugin !== null && !Array.isArray(plugin)
1378+
? (plugin as Record<string, unknown>).version
1379+
: undefined;
1380+
if (
1381+
identity.packageVersion !== undefined &&
1382+
typeof pluginVersion === 'string' &&
1383+
pluginVersion.trim().length > 0 &&
1384+
pluginVersion !== identity.packageVersion
1385+
) {
1386+
diagnostics.push(warningDiagnostic(
1387+
'AB4008',
1388+
`Config plugin.version ${JSON.stringify(pluginVersion)} differs from package.json version ${JSON.stringify(identity.packageVersion)}; the package version is authoritative for release identity.`,
1389+
loaded.configPath,
1390+
'Align plugin.version with the package.json version, or update package.json.',
1391+
));
1392+
}
1393+
return diagnostics;
1394+
};
1395+
13351396
export const validateSource = (
13361397
loaded: LoadedConfig,
13371398
discovered: DiscoveredProject,
@@ -1387,6 +1448,7 @@ export const validateSource = (
13871448

13881449
const payloads = declaredPayloads(loaded, registry);
13891450
diagnostics.push(...validateAssets(loaded));
1451+
diagnostics.push(...validatePackageIdentity(loaded));
13901452
diagnostics.push(...validateBin(loaded));
13911453
diagnostics.push(...validateHooks(loaded, registry, payloads));
13921454
diagnostics.push(...validateLib(loaded));

‎packages/agent-bundle/src/core/project-context.ts‎

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { realpathSync } from 'node:fs';
2-
import { isAbsolute, relative, resolve } from 'node:path';
1+
import { readFileSync, realpathSync } from 'node:fs';
2+
import { isAbsolute, join, relative, resolve } from 'node:path';
33

44
import { digest } from './digest.ts';
55
import { deepFreeze } from './freeze.ts';
@@ -25,10 +25,128 @@ export interface ProjectContext {
2525
readonly configDigest: string;
2626
readonly configPath: string;
2727
readonly modelDigest: string;
28+
/** The validated npm package name axis; absent for unpackaged development projects. */
29+
readonly packageName?: string;
30+
/** The validated semantic release-version axis; absent for unpackaged development projects. */
31+
readonly packageVersion?: string;
2832
readonly revision: string;
2933
readonly sourceInputs: readonly ProjectSourceInput[];
3034
}
3135

36+
export type PackageIdentityIssueKind = 'invalid-name' | 'invalid-version' | 'outside-root' | 'unparsable';
37+
38+
/** One problem found while deriving package identity from `package.json`. */
39+
export interface PackageIdentityIssue {
40+
readonly kind: PackageIdentityIssueKind;
41+
readonly message: string;
42+
}
43+
44+
/** The release-identity axes derived from a project's `package.json`. */
45+
export interface PackageIdentitySnapshot {
46+
readonly issues: readonly PackageIdentityIssue[];
47+
readonly packageName?: string;
48+
readonly packageVersion?: string;
49+
}
50+
51+
/** npm's naming rules for new packages: lowercase, URL-safe, optional scope. */
52+
const packageNamePattern = /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/u;
53+
54+
/** Names npm's validator rejects outright even though the grammar matches. */
55+
const reservedPackageNames = new Set(['node_modules', 'favicon.ico']);
56+
57+
/** The strict semver 2.0.0 grammar, without any leading `v`. */
58+
const packageVersionPattern =
59+
/^(?: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;
60+
61+
/** True for a name npm would accept for a new package. */
62+
export const isValidPackageName = (value: string): boolean =>
63+
value.length > 0 &&
64+
value.length <= 214 &&
65+
!reservedPackageNames.has(value.toLowerCase()) &&
66+
packageNamePattern.test(value);
67+
68+
/** True for a strict semver 2.0.0 version. */
69+
export const isValidPackageVersion = (value: string): boolean => packageVersionPattern.test(value);
70+
71+
/**
72+
* Derives the release-identity axes from `<root>/package.json`. A missing
73+
* package.json (or missing name/version fields) is a normal development
74+
* state: no identity and no issues. An invalid name or version becomes an
75+
* issue for the caller to surface as a diagnostic, never a crash, and the
76+
* invalid value is withheld from the derived identity.
77+
*/
78+
export const snapshotPackageIdentity = (root: string): PackageIdentitySnapshot => {
79+
let packageJsonPath: string;
80+
let canonicalRoot: string;
81+
try {
82+
canonicalRoot = realpathSync(resolve(root));
83+
packageJsonPath = realpathSync(join(resolve(root), 'package.json'));
84+
} catch {
85+
return deepFreeze({ issues: [] });
86+
}
87+
// A package.json symlinked outside the project cannot join the identity:
88+
// its bytes are invisible to the source snapshot, so deriving release
89+
// identity from it would let identity drift without a revision change.
90+
if (!isInsideOrEqual(canonicalRoot, packageJsonPath)) {
91+
return deepFreeze({
92+
issues: [{ kind: 'outside-root', message: 'package.json resolves outside the project root; package identity is ignored.' }],
93+
});
94+
}
95+
let bytes: string;
96+
try {
97+
bytes = readFileSync(packageJsonPath, 'utf8');
98+
} catch {
99+
return deepFreeze({ issues: [] });
100+
}
101+
let parsed: unknown;
102+
try {
103+
parsed = JSON.parse(bytes);
104+
} catch {
105+
return deepFreeze({ issues: [{ kind: 'unparsable', message: 'package.json is not valid JSON.' }] });
106+
}
107+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
108+
return deepFreeze({ issues: [{ kind: 'unparsable', message: 'package.json must contain a JSON object.' }] });
109+
}
110+
const record = parsed as Readonly<Record<string, unknown>>;
111+
const issues: PackageIdentityIssue[] = [];
112+
let packageName: string | undefined;
113+
if (record.name !== undefined) {
114+
if (typeof record.name === 'string' && isValidPackageName(record.name)) packageName = record.name;
115+
else {
116+
issues.push({
117+
kind: 'invalid-name',
118+
message: `package.json name ${JSON.stringify(record.name)} is not a valid npm package name.`,
119+
});
120+
}
121+
}
122+
let packageVersion: string | undefined;
123+
if (record.version !== undefined) {
124+
if (typeof record.version === 'string' && isValidPackageVersion(record.version)) packageVersion = record.version;
125+
else {
126+
issues.push({
127+
kind: 'invalid-version',
128+
message: `package.json version ${JSON.stringify(record.version)} is not a valid semantic version.`,
129+
});
130+
}
131+
}
132+
return deepFreeze({
133+
issues,
134+
...(packageName === undefined ? {} : { packageName }),
135+
...(packageVersion === undefined ? {} : { packageVersion }),
136+
});
137+
};
138+
139+
/**
140+
* The human display label for the release-version axis. Without a package
141+
* version there is no release identity, so the label is a clearly marked
142+
* development fallback over the source revision — never a semantic version.
143+
*/
144+
export const projectVersionLabel = (
145+
context: Pick<ProjectContext, 'packageVersion' | 'revision'>,
146+
): string =>
147+
context.packageVersion ??
148+
`0.0.0-dev.${context.revision.slice(0, 12)} (development fallback — no package.json version)`;
149+
32150
export interface CreateProjectContextOptions {
33151
readonly configPath: string;
34152
readonly model: NormalizedPlugin;
@@ -293,10 +411,13 @@ export const createProjectContext = (options: CreateProjectContextOptions): Proj
293411
throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`);
294412
}
295413
assertModelPathsResolveInsideProject(canonicalRoot, options.model);
414+
const { packageName, packageVersion } = options.model.metadata;
296415
return deepFreeze({
297416
configDigest: configInput.sha256,
298417
configPath,
299418
modelDigest: digest(canonicalizeNormalizedModel(canonicalRoot, options.model)),
419+
...(packageName === undefined ? {} : { packageName }),
420+
...(packageVersion === undefined ? {} : { packageVersion }),
300421
revision: digest({ inputs: sourceInputs }),
301422
sourceInputs,
302423
});

‎packages/agent-bundle/src/core/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ export interface NormalizedMetadata {
245245
readonly description?: string;
246246
readonly id: string;
247247
readonly name: string;
248+
/** The validated npm package name derived from the project's package.json. */
249+
readonly packageName?: string;
250+
/** The validated semantic version derived from the project's package.json. */
251+
readonly packageVersion?: string;
248252
readonly provenance: SourceProvenance;
249253
readonly version: string;
250254
}

0 commit comments

Comments
 (0)