-
Notifications
You must be signed in to change notification settings - Fork 0
feat(identity): derive package identity from package.json (#94 Wave 1 stages 1-2) #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f504a1e
f70a5f4
642e143
826b2ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| "agent-bundle": minor | ||
| --- | ||
|
|
||
| Derive project package identity from `package.json` (#94 Wave 1 stages 1–2). | ||
|
|
||
| Normalized models, artifact manifests, inspect results, and development | ||
| source status now expose validated `packageName`/`packageVersion`. | ||
| `plugin.version` still authors the native plugin version but no longer | ||
| silently wins: a mismatch warns (`AB4008`) and `package.json` remains | ||
| authoritative. `plugin.name` is unchanged (G9). Unpackaged or unversioned | ||
| projects receive the labeled `0.0.0-dev` development fallback. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,13 @@ | ||
| import { realpathSync } from 'node:fs'; | ||
| import { readFileSync, realpathSync } from 'node:fs'; | ||
| import { isAbsolute, relative, resolve } from 'node:path'; | ||
|
|
||
| import { digest } from './digest.ts'; | ||
| import type { Diagnostic } from './diagnostics.ts'; | ||
| import { digest, sha256Hex } from './digest.ts'; | ||
| import { isErrno } from './errors.ts'; | ||
| import { deepFreeze } from './freeze.ts'; | ||
| import { isInsideOrEqual } from './paths.ts'; | ||
| import { snapshotStrictJsonValue } from './strict-json.ts'; | ||
| import { parseSemanticVersion } from './semver.ts'; | ||
| import { isRecord, snapshotStrictJsonValue } from './strict-json.ts'; | ||
| import type { NormalizedPlugin, SourceProvenance } from './types.ts'; | ||
|
|
||
| /** One deterministic, byte-addressed authored input in a project identity. */ | ||
|
|
@@ -25,13 +28,16 @@ export interface ProjectContext { | |
| readonly configDigest: string; | ||
| readonly configPath: string; | ||
| readonly modelDigest: string; | ||
| readonly packageName?: string; | ||
| readonly packageVersion?: string; | ||
| readonly revision: string; | ||
| readonly sourceInputs: readonly ProjectSourceInput[]; | ||
| } | ||
|
|
||
| export interface CreateProjectContextOptions { | ||
| readonly configPath: string; | ||
| readonly model: NormalizedPlugin; | ||
| readonly requirePackageIdentity?: boolean; | ||
| readonly root: string; | ||
| readonly sourceInputs: readonly ProjectSourceSnapshotInput[]; | ||
| } | ||
|
|
@@ -283,11 +289,151 @@ const canonicalSourceInputs = ( | |
| return deepFreeze(canonical); | ||
| }; | ||
|
|
||
| export interface ProjectPackageJsonSnapshot { | ||
| readonly identity?: { | ||
| readonly packageName: string; | ||
| readonly packageVersion: string; | ||
| }; | ||
| readonly packageName?: string; | ||
| readonly sha256: string; | ||
| } | ||
|
|
||
| /** Labeled development fallback used when package.json has no valid name. */ | ||
| export const fallbackDevPackageName = 'agent-bundle-dev'; | ||
|
|
||
| /** Labeled development fallback used when package.json has no valid version. */ | ||
| export const fallbackDevPackageVersion = '0.0.0-dev'; | ||
|
|
||
| export interface DerivedPackageIdentity { | ||
| readonly packageName: string; | ||
| readonly packageVersion: string; | ||
| readonly source: 'dev-fallback' | 'package.json'; | ||
| } | ||
|
|
||
| const packageVersionPattern = | ||
| /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; | ||
|
|
||
| export const isPackageName = (value: unknown): value is string => | ||
| typeof value === 'string' && value.trim().length > 0 && value.trim() === value; | ||
|
|
||
| export const isSemanticPackageVersion = (value: unknown): value is string => | ||
| typeof value === 'string' && | ||
| packageVersionPattern.test(value) && | ||
| parseSemanticVersion(value) !== undefined; | ||
|
|
||
| const invalidPackageIdentity = (root: string): never => { | ||
| throw new TypeError( | ||
| `Project package.json in ${JSON.stringify(root)} must declare a nonempty name and valid semantic version.`, | ||
| ); | ||
| }; | ||
|
|
||
| export const readProjectPackageJson = (root: string): ProjectPackageJsonSnapshot | undefined => { | ||
| const packageJsonPath = resolve(root, 'package.json'); | ||
| let bytes: string; | ||
| try { | ||
| bytes = readFileSync(packageJsonPath, 'utf8'); | ||
| } catch (error) { | ||
| if (isErrno(error, 'ENOENT')) return undefined; | ||
| throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} could not be read.`); | ||
| } | ||
| const sha256 = sha256Hex(bytes); | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(bytes); | ||
| } catch { | ||
| throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be valid JSON.`); | ||
| } | ||
| if (!isRecord(parsed)) { | ||
| throw new TypeError(`Project package.json ${JSON.stringify(packageJsonPath)} must be a JSON object.`); | ||
| } | ||
| const packageName = isPackageName(parsed.name) ? parsed.name : undefined; | ||
| const packageVersion = isSemanticPackageVersion(parsed.version) ? parsed.version : undefined; | ||
| return { | ||
| sha256, | ||
| ...(packageName === undefined ? {} : { packageName }), | ||
| ...(packageName === undefined || packageVersion === undefined | ||
| ? {} | ||
| : { identity: { packageName, packageVersion } }), | ||
| }; | ||
| }; | ||
|
|
||
| const derivedIdentityFromSnapshot = ( | ||
| snapshot: ProjectPackageJsonSnapshot | undefined, | ||
| ): DerivedPackageIdentity => { | ||
| if (snapshot?.identity !== undefined) { | ||
| return { | ||
| packageName: snapshot.identity.packageName, | ||
| packageVersion: snapshot.identity.packageVersion, | ||
| source: 'package.json', | ||
| }; | ||
| } | ||
| if (snapshot?.packageName !== undefined) { | ||
| return { | ||
| packageName: snapshot.packageName, | ||
| packageVersion: fallbackDevPackageVersion, | ||
| source: 'dev-fallback', | ||
| }; | ||
| } | ||
| return { | ||
| packageName: fallbackDevPackageName, | ||
| packageVersion: fallbackDevPackageVersion, | ||
| source: 'dev-fallback', | ||
| }; | ||
| }; | ||
|
|
||
| /** Resolves package.json identity, or the labeled development fallback. */ | ||
| export const derivePackageIdentity = (root: string): DerivedPackageIdentity => | ||
| derivedIdentityFromSnapshot(readProjectPackageJson(root)); | ||
|
|
||
| export const packageVersionMismatchDiagnostic = ( | ||
| pluginVersion: string, | ||
| packageVersion: string, | ||
| sourcePath: string, | ||
| ): Diagnostic | undefined => { | ||
| if (pluginVersion === packageVersion) return undefined; | ||
| return { | ||
| code: 'AB4008', | ||
| message: | ||
| `plugin.version ${JSON.stringify(pluginVersion)} differs from package.json version ${JSON.stringify(packageVersion)}; package.json is authoritative.`, | ||
| recovery: | ||
| 'Keep package.json version as the package identity and update plugin.version to match. plugin.version does not override package.json.', | ||
| severity: 'warning', | ||
| sourcePath, | ||
| }; | ||
| }; | ||
|
|
||
| const withPackageJsonSourceInput = ( | ||
| root: string, | ||
| inputs: readonly ProjectSourceSnapshotInput[], | ||
| packageSnapshot: ProjectPackageJsonSnapshot | undefined, | ||
| ): readonly ProjectSourceSnapshotInput[] => { | ||
| if (packageSnapshot === undefined) return inputs; | ||
| const packageJsonPath = resolve(root, 'package.json'); | ||
| const canonicalPath = resolvedProjectPath(root, packageJsonPath, 'Package manifest path'); | ||
| const alreadyDeclared = inputs.some((input) => { | ||
| try { | ||
| return resolvedProjectPath(root, input.path, 'Project source input path') === canonicalPath; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }); | ||
| if (alreadyDeclared) return inputs; | ||
| return [...inputs, { path: packageJsonPath, sha256: packageSnapshot.sha256 }]; | ||
|
Comment on lines
+420
to
+421
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| }; | ||
|
|
||
| /** Creates the single canonical identity carried from preparation to publication. */ | ||
| export const createProjectContext = (options: CreateProjectContextOptions): ProjectContext => { | ||
| const canonicalRoot = realpathSync(resolve(options.root)); | ||
| const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path'); | ||
| const sourceInputs = canonicalSourceInputs(options.root, options.sourceInputs); | ||
| const packageSnapshot = readProjectPackageJson(canonicalRoot); | ||
| const derived = derivedIdentityFromSnapshot(packageSnapshot); | ||
| if (options.requirePackageIdentity === true && derived.source !== 'package.json') { | ||
| invalidPackageIdentity(canonicalRoot); | ||
| } | ||
| const sourceInputs = canonicalSourceInputs( | ||
| options.root, | ||
| withPackageJsonSourceInput(canonicalRoot, options.sourceInputs, packageSnapshot), | ||
| ); | ||
| const configInput = sourceInputs.find((input) => input.path === configPath); | ||
| if (configInput === undefined) { | ||
| throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`); | ||
|
|
@@ -297,6 +443,8 @@ export const createProjectContext = (options: CreateProjectContextOptions): Proj | |
| configDigest: configInput.sha256, | ||
| configPath, | ||
| modelDigest: digest(canonicalizeNormalizedModel(canonicalRoot, options.model)), | ||
| packageName: derived.packageName, | ||
| packageVersion: derived.packageVersion, | ||
| revision: digest({ inputs: sourceInputs }), | ||
| sourceInputs, | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For package versions containing an invalid SemVer prerelease, such as
1.0.0-01,1.0.0-., or1.0.0-alpha..1, this pattern returns true because it treats the entire prerelease as an unrestricted character run;parseSemanticVersion()applies the same loose rule. These values are therefore recorded as authoritative semantic package versions and accepted by artifact-manifest validation instead of receiving the development fallback or being rejected. Validate dot-separated identifiers, including the no-empty-identifiers and no-leading-zero numeric rules.Useful? React with 👍 / 👎.