@xemahq/xema 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/edition/README.md CHANGED
@@ -100,7 +100,9 @@ The edition library builds, validates and verifies evidence statements; it never
100
100
 
101
101
  ## Kernel protocol and pins
102
102
 
103
- The CLI is coupled to the kernel's edition protocol, not to the kernel package. `@xemahq/contracts` exports the protocol identity as data (`dist/edition-protocol/edition-protocol.json`: `{contentDigest, provisional, version}`). This CLI supports exactly protocol v8 (a data root whose pinned kernel speaks v5 stays on CLI 0.33.x) and REFUSES an identity with `provisional: true`; every loader that executes the kernel, `xema edition check` first, and the evidence verifier compare only that identity and refuse an unsupported one, naming the supported and the declared versions. A protocol change needs a CLI release first; only then does the data root repin its kernel authority.
103
+ The CLI is coupled to the kernel's edition protocol, not to the kernel package. `@xemahq/contracts` exports the protocol identity as data (`dist/edition-protocol/edition-protocol.json`: `{contentDigest, provisional, version}`). This CLI supports protocols v9 to v14, each pinned to its digest and vendored (v10 to v14 grow the kernel source closure only; a data root whose pinned kernel speaks v8 stays on CLI 0.41.x, v5 on 0.33.x) and REFUSES an identity with `provisional: true`; every loader that executes the kernel, `xema edition check` first, and the evidence verifier compare only that identity and refuse an unsupported one, naming the supported and the declared versions. A protocol change needs a CLI release first; only then does the data root repin its kernel authority.
104
+
105
+ A develop edition does not take the kernel's develop head blindly: `xema edition develop choose-kernel --kernel <package dir> [--kernel-pin <40-hex>] --out <file>` reads the declared protocol of each commit on the checkout's first-parent history (from source, unbuilt) and chooses the newest one this CLI supports. It writes `{chosenSha, headSha, held, reason}` (`reason`: `head-supported` | `head-ahead` | `explicit-pin`; `held` is `head-ahead`), and when held prints `kernel held at v<N> (<sha12>), head at v<M> (<sha12>): raise the CLI` (stdout only, never persisted). The head is the checkout's `refs/remotes/origin/develop` (a full-history checkout). An explicit pin wins but is still checked, and must be on that head's first-parent history (`not-on-develop` otherwise); when no commit is supported it refuses (`EditionProtocolMismatchError`, `no-supported-commit`; the walk ends at the first commit with no declaration file) and never falls back to the head; a declaration that exists but does not parse, at the head or any walked commit, is refused by name (`unparseable`), never skipped. `freeze-graph` applies the same check to `--kernel-sha`.
104
106
 
105
107
  - `xema edition evidence statement entitlement stage` writes entitlement predicate v2 (`lockDigest`, `catalogueDigest`, read from the edition's committed locks), so it takes `--editions-root` besides `--data-root` and `--kernel`.
106
108
  - A source repository JOINS a data root that has committed pins with one command: `xema edition index runtime-image-source --trusted-write --repo <repository> [--at <40-hex>] [--kernel-root <checkout>]`. The repository is resolved once and the runtime image source, biome and platform-service indexes are all written from the committed pins plus that one pin; every committed pin is kept field for field (a differing one is refused by name), a second declared repository with no pin is refused, and the summary names each pin added, moved and dropped. Then `xema edition index biome --check`, `xema edition index runtime-image-source --check` and `pnpm check`. The biome builder takes no `--repo`: it would resolve the repository a second time.
@@ -22,8 +22,10 @@ import { relative, sep } from 'node:path';
22
22
  import { assertCustomerLockWithinCatalogueCeiling } from './catalogue.mjs';
23
23
  import { readGitJsonAtCommit } from './lib.mjs';
24
24
  import {
25
+ assertInstalledOperationCeilingAdmitted,
25
26
  assertOperationGrantsAdmitted,
26
27
  assertOrganizationSweepsAdmitted,
28
+ assertStoreWorkloadCeilingAdmitted,
27
29
  } from './operation-grant-admission.mjs';
28
30
 
29
31
  /** A biome whose recomputed content hash is not the lock's, or whose inputs cannot be recomputed. */
@@ -289,6 +291,8 @@ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredC
289
291
  const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById });
290
292
  assertOperationGrantsAdmitted(schemas, unsealed);
291
293
  assertOrganizationSweepsAdmitted(schemas, unsealed);
294
+ assertInstalledOperationCeilingAdmitted(schemas, unsealed);
295
+ assertStoreWorkloadCeilingAdmitted(schemas, unsealed);
292
296
  return schemas.sealDistributionLock(unsealed, hashes);
293
297
  }
294
298
 
@@ -214,6 +214,7 @@ const COMMANDS = Object.freeze({
214
214
  required: ['--pins', '--cosign'],
215
215
  },
216
216
  'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
217
+ 'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
217
218
  'check-existing': {
218
219
  positional: true,
219
220
  values: ['--pins', '--artifact', '--engine-image'],
@@ -30,7 +30,8 @@
30
30
  // [--overrides <json>] --out <file>
31
31
  // resolve refs/heads/develop once per graph repository (and the editions
32
32
  // root) from this checkout's runtime-image-source-index.json; the three checked-out
33
- // repositories are pinned at what the workflow checked out; write the pins.
33
+ // repositories are pinned at what the workflow checked out; write the pins. --kernel-sha is refused
34
+ // unless its declared edition protocol is one this CLI supports (the check choose-kernel applies to a pin).
34
35
  // check-existing <id> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
35
36
  // refuse to reuse a pulled artifact whose descriptor disagrees with this snapshot.
36
37
  // check-existing --definition <file> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
@@ -44,6 +45,13 @@
44
45
  // annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
45
46
  // and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
46
47
  // not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
48
+ // choose-kernel [--kernel-pin <40-hex>] --out <file>
49
+ // choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
50
+ // the newest first-parent commit whose declared edition protocol this CLI supports
51
+ // (kernel-protocol-choice.mjs). Writes {chosenSha, headSha, held, reason} (reason: head-supported |
52
+ // head-ahead | explicit-pin); when held, prints `kernel held at v<N> (<sha12>), head at v<M> (<sha12>):
53
+ // raise the CLI` to stdout (never persisted). A pin wins and is still checked; nothing supported is refused.
54
+ // Takes only --kernel (the checkout need not be built).
47
55
  // check-overrides --overrides <json>
48
56
  // refuse a pins override that would select the tooling this run executes
49
57
  // (the edition tooling's own repository); the workflow runs it before any other checkout.
@@ -68,8 +76,9 @@ import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
68
76
  import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
69
77
  import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
70
78
  import { EditionChannel } from './edition-constants.mjs';
71
- import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
79
+ import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
72
80
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
81
+ import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
73
82
  import { loadKernelDistributionSchemas } from './lib.mjs';
74
83
  import { snapshotPinSource } from './pin-source.mjs';
75
84
  import { composeEdition } from './resolve-distribution.mjs';
@@ -85,11 +94,15 @@ const VENDOR_ROOTS = Object.freeze([WorkspaceRoot.Data, WorkspaceRoot.Editions,
85
94
  const COVERAGE_COMMAND = 'coverage';
86
95
  const COVERAGE_OPERATIONAL_EXIT = CoverageExit.Operational;
87
96
  const DATA_ROOT_ONLY_COMMANDS = Object.freeze(['check-overrides']);
97
+ // `choose-kernel` reads the kernel checkout's git history only, before the kernel is built.
98
+ const KERNEL_ROOT_ONLY_COMMANDS = Object.freeze(['choose-kernel']);
88
99
 
89
100
  if (isEntryModule(import.meta.url)) {
90
101
  const [command] = process.argv.slice(2);
91
102
  if (DATA_ROOT_ONLY_COMMANDS.includes(command)) {
92
103
  configureEditionWorkspaceFromProcess([WorkspaceRoot.Data], { refuseOthers: true });
104
+ } else if (KERNEL_ROOT_ONLY_COMMANDS.includes(command)) {
105
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Kernel], { refuseOthers: true });
93
106
  } else {
94
107
  const customerForm = process.argv.includes(DEFINITION_FLAG);
95
108
  configureEditionWorkspaceFromProcess(customerForm ? (CUSTOMER_ROOTS[command] ?? VENDOR_ROOTS) : VENDOR_ROOTS, {
@@ -165,7 +178,14 @@ async function run(argv, env) {
165
178
  assertNoToolingOverride(parseOverrides(options['--overrides']));
166
179
  return;
167
180
  }
181
+ case 'choose-kernel': {
182
+ const { choice, notice } = chooseKernelCommit({ repoDir: kernelRepoDir(), pin: options['--kernel-pin'] });
183
+ writeOnce(options['--out'], choice);
184
+ if (notice !== undefined) {console.log(notice);}
185
+ return;
186
+ }
168
187
  case 'freeze-graph': {
188
+ assertKernelCommitSupported({ repoDir: kernelRepoDir(), commit: options['--kernel-sha'], label: `--kernel-sha ${options['--kernel-sha']}` });
169
189
  const pins = freezeGraph({
170
190
  runtimeSourceIndex: readStrictJsonFile(runtimeImageSourceIndexPath()),
171
191
  dataSha: options['--data-sha'],
@@ -1,5 +1,71 @@
1
1
  import { compareStrings } from './string-order.mjs';
2
2
 
3
+ /** A `runtime-image` edge target that is neither `{biomeId, componentKey}` nor `{supplementalRuntimeComponentId}`, or is both. */
4
+ export class DispatchedEdgeError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'DispatchedEdgeError';
8
+ }
9
+ }
10
+
11
+ /** A dispatched `supplemental:<id>` the runtime-image source index does not declare as a supplemental subject. */
12
+ export class UndeclaredSupplementalRuntimeComponentError extends Error {
13
+ constructor(supplementalRuntimeComponentId, dispatchingBiomes, context) {
14
+ super(
15
+ `${context}: supplemental runtime component "${supplementalRuntimeComponentId}", dispatched by biome ` +
16
+ `${dispatchingBiomes.map((id) => `"${id}"`).join(', ')}, is not declared as a runtime-component subject ` +
17
+ 'by the runtime image source index — refusing rather than shipping the dispatcher without the image it launches.',
18
+ );
19
+ this.name = 'UndeclaredSupplementalRuntimeComponentError';
20
+ this.supplementalRuntimeComponentId = supplementalRuntimeComponentId;
21
+ this.dispatchingBiomes = dispatchingBiomes;
22
+ }
23
+ }
24
+
25
+ // The `supplementalRuntimeComponentId` field is read as plain data. Since edition
26
+ // protocol v10 the kernel types it (`DispatchedRuntimeComponentSchema`, a union of
27
+ // two strict objects); this projection keeps reading it as data and refuses a target
28
+ // that is neither form, or both.
29
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
30
+
31
+ /** True for the supplemental form of a projected edge. */
32
+ export const isSupplementalEdge = (edge) => edge.supplementalRuntimeComponentId !== undefined;
33
+
34
+ /** The identity of a projected edge: `supplemental:<id>` or `<biomeId>\0<componentKey>`. */
35
+ export const dispatchedEdgeIdentity = (edge) =>
36
+ isSupplementalEdge(edge)
37
+ ? `supplemental:${edge.supplementalRuntimeComponentId}`
38
+ : `${edge.biomeId}\0${edge.componentKey}`;
39
+
40
+ /**
41
+ * Project one generated-index `runtime-image` edge target into the typed union
42
+ * the index entry carries. A target that is neither form, or both, is refused
43
+ * naming the dispatching biome; no `undefined` identity can form.
44
+ */
45
+ export function projectDispatchedTarget(dispatchingBiomeId, target) {
46
+ const supplemental = target?.supplementalRuntimeComponentId;
47
+ const biomeForm = target?.biomeId !== undefined || target?.componentKey !== undefined;
48
+ if (supplemental !== undefined) {
49
+ if (biomeForm || !isNonEmptyString(supplemental)) {
50
+ throw new DispatchedEdgeError(
51
+ `biome "${dispatchingBiomeId}" has a runtime-image edge target that mixes supplementalRuntimeComponentId with ` +
52
+ 'biomeId/componentKey, or names an empty id — a target is exactly one form',
53
+ );
54
+ }
55
+ return { supplementalRuntimeComponentId: supplemental };
56
+ }
57
+ if (!isNonEmptyString(target?.biomeId) || !isNonEmptyString(target?.componentKey)) {
58
+ throw new DispatchedEdgeError(
59
+ `biome "${dispatchingBiomeId}" has a runtime-image edge target that is neither { biomeId, componentKey } nor ` +
60
+ '{ supplementalRuntimeComponentId }',
61
+ );
62
+ }
63
+ return { biomeId: target.biomeId, componentKey: target.componentKey };
64
+ }
65
+
66
+ const edgeLabel = (edge) =>
67
+ isSupplementalEdge(edge) ? `supplemental:${edge.supplementalRuntimeComponentId}` : `${edge.biomeId}/${edge.componentKey}`;
68
+
3
69
  /**
4
70
  * The runtime components an edition must carry because a biome it rosters
5
71
  * DISPATCHES them: the kernel's `runtime-image` edges, projected per biome into
@@ -21,6 +87,10 @@ import { compareStrings } from './string-order.mjs';
21
87
  * no biome drawn from a declared source. A biome drawn from a declared source
22
88
  * that dispatches is refused for the same reason.
23
89
  *
90
+ * A supplemental edge (`{supplementalRuntimeComponentId}`) names no biome: it
91
+ * is carried as a row `{ supplementalRuntimeComponentId, consumers }` without
92
+ * a biome lookup.
93
+ *
24
94
  * Returns one row per distinct (target biome, component key), sorted, with the
25
95
  * rostered biomes that dispatch it. Every unresolvable edge is refused, naming
26
96
  * the consumer and the target.
@@ -37,12 +107,24 @@ export function resolveDispatchedRuntimeComponents(context, consumers, index) {
37
107
  if (consumer.source !== undefined) {
38
108
  throw new Error(
39
109
  `${context}: biome "${consumer.id}", drawn from declared source "${consumer.source}", dispatches ` +
40
- `runtime components (${edges.map((edge) => `${edge.biomeId}/${edge.componentKey}`).join(', ')}). ` +
110
+ `runtime components (${edges.map(edgeLabel).join(', ')}). ` +
41
111
  'A dispatched image is resolved from the first-party biome index, which carries no biome from a ' +
42
112
  'declared source — refusing rather than shipping the dispatcher without the images it launches.',
43
113
  );
44
114
  }
45
115
  for (const edge of edges) {
116
+ if (isSupplementalEdge(edge)) {
117
+ // Carried as a requirement owned by the dispatching biome. Whether the
118
+ // runtime image source index declares it is the image plane's refusal.
119
+ const supplementalKey = dispatchedEdgeIdentity(edge);
120
+ const row = rows.get(supplementalKey) ?? {
121
+ supplementalRuntimeComponentId: edge.supplementalRuntimeComponentId,
122
+ consumers: [],
123
+ };
124
+ if (!row.consumers.includes(consumer.id)) {row.consumers.push(consumer.id);}
125
+ rows.set(supplementalKey, row);
126
+ continue;
127
+ }
46
128
  const edgeText = `biome "${consumer.id}" dispatches runtime component "${edge.componentKey}" of biome "${edge.biomeId}"`;
47
129
  const candidates = index.filter(
48
130
  (entry) => entry.id === edge.biomeId && entry.target === 'server' && entry.source === undefined,
@@ -80,3 +162,11 @@ export function resolveDispatchedRuntimeComponents(context, consumers, index) {
80
162
  .sort(([a], [b]) => compareStrings(a, b))
81
163
  .map(([, row]) => row);
82
164
  }
165
+
166
+ /** Sort order of projected edges: biome edges by (biomeId, componentKey), then supplemental edges by id. */
167
+ export function compareDispatchedEdges(a, b) {
168
+ if (isSupplementalEdge(a) !== isSupplementalEdge(b)) {return isSupplementalEdge(a) ? 1 : -1;}
169
+ return isSupplementalEdge(a)
170
+ ? compareStrings(a.supplementalRuntimeComponentId, b.supplementalRuntimeComponentId)
171
+ : compareStrings(a.biomeId, b.biomeId) || compareStrings(a.componentKey, b.componentKey);
172
+ }
@@ -28,12 +28,15 @@ import { realpathSync } from 'node:fs';
28
28
 
29
29
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
30
30
 
31
- /** The edition protocols this library interprets: version -> content digest. */
31
+ /** The edition protocols this library interprets: version -> content digest. v10..v14 grow the kernel
32
+ * source closure only (entry subpaths and hashing unchanged), so one library speaks every row. */
32
33
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
33
- Object.freeze({
34
- version: 8,
35
- contentDigest: '5f878e9eaa2cd0ab39b645e4a67259b1e39383e4931e9849c4cc746f7ae43047',
36
- }),
34
+ Object.freeze({ version: 9, contentDigest: '7cd6d760e7e7caaec3634a08c739debbdeeeaac4f1bad6791246a12f4f248e37' }),
35
+ Object.freeze({ version: 10, contentDigest: '660129065650a8300f7c0ad54f957b4d43507e1a44e093c9812d2b1bbf143104' }),
36
+ Object.freeze({ version: 11, contentDigest: '600451d2253c470c7d21bafe87260dfc3bfd43b6ad3eee2ef4d8b1f7ad08eb6e' }),
37
+ Object.freeze({ version: 12, contentDigest: 'cff51ea06b6ba7d95d02dcd6305b6a55061b428da48ae74dcbff8a5c8107ce41' }),
38
+ Object.freeze({ version: 13, contentDigest: '5f69fe73d1dea1650ef1757204a53ccd4030ade78bfa91959c2d01c937fc0e16' }),
39
+ Object.freeze({ version: 14, contentDigest: '403b6a491b5922df1a423ba8a2d6ae3d3900bb1caff4e8c0caa48de90a47eb9b' }),
37
40
  ]);
38
41
 
39
42
  /** Where a kernel package publishes its identity and its contract, package-relative. */
@@ -64,6 +67,12 @@ export const EditionProtocolMismatchReason = Object.freeze({
64
67
  ContractMismatch: 'contract-mismatch',
65
68
  /** The kernel's protocol is not the one compiled into the running verifier. */
66
69
  BundleMismatch: 'bundle-mismatch',
70
+ /** No commit on the kernel checkout's first-parent history declares a protocol this library supports. */
71
+ NoSupportedCommit: 'no-supported-commit',
72
+ /** A kernel commit's protocol declaration source exists but does not parse; refused, never skipped. */
73
+ Unparseable: 'unparseable',
74
+ /** An explicit kernel commit is not on the first-parent history of the checkout's develop head. */
75
+ NotOnDevelop: 'not-on-develop',
67
76
  });
68
77
 
69
78
  export class EditionProtocolMismatchError extends Error {
@@ -76,7 +85,8 @@ export class EditionProtocolMismatchError extends Error {
76
85
 
77
86
  const describe = ({ version, contentDigest }) => `edition protocol v${version} (${contentDigest})`;
78
87
 
79
- const supportedList = () =>
88
+ /** `v<N>, v<M>`: the versions this library supports, as every refusal names them. */
89
+ export const supportedList = () =>
80
90
  SUPPORTED_EDITION_PROTOCOLS.map((protocol) => `v${protocol.version}`).join(', ');
81
91
 
82
92
  /** Refuse an identity that states `provisional: true`; nothing provisional is ever read as a protocol. */
@@ -147,10 +157,11 @@ function contractIdentity(contractBytes) {
147
157
  }
148
158
 
149
159
  /**
150
- * Refuse an identity this library does not support, and a contract file that
151
- * does not state that identity. Returns the identity.
160
+ * Refuse an identity this library does not support: a provisional one, an
161
+ * unsupported version, or a supported version at another digest. Returns it.
152
162
  */
153
- export function assertSupportedEditionProtocol({ identity, contractBytes, source }) {
163
+ export function assertSupportedEditionIdentity(identity, source) {
164
+ assertFinalIdentity(identity, source);
154
165
  const supported = SUPPORTED_EDITION_PROTOCOLS.find(
155
166
  (protocol) => protocol.version === identity.version,
156
167
  );
@@ -169,6 +180,22 @@ export function assertSupportedEditionProtocol({ identity, contractBytes, source
169
180
  'contract without a new protocol version.',
170
181
  );
171
182
  }
183
+ return identity;
184
+ }
185
+
186
+ /** True when `identity` is final and is a row of the supported table (version and digest). */
187
+ export const isSupportedEditionIdentity = (identity) =>
188
+ identity?.provisional === false &&
189
+ SUPPORTED_EDITION_PROTOCOLS.some(
190
+ (protocol) => protocol.version === identity.version && protocol.contentDigest === identity.contentDigest,
191
+ );
192
+
193
+ /**
194
+ * Refuse an identity this library does not support, and a contract file that
195
+ * does not state that identity. Returns the identity.
196
+ */
197
+ export function assertSupportedEditionProtocol({ identity, contractBytes, source }) {
198
+ assertSupportedEditionIdentity(identity, source);
172
199
  const stated = contractIdentity(contractBytes);
173
200
  if (stated?.version !== identity.version || stated?.contentDigest !== identity.contentDigest) {
174
201
  throw new EditionProtocolMismatchError(
@@ -38,6 +38,8 @@ const ROOT_MEANING = Object.freeze({
38
38
  });
39
39
 
40
40
  const KERNEL_PACKAGE_SUBPATH = ['packages', 'kernel', 'kernel-contracts'];
41
+ /** The kernel package's path inside the kernel-contracts repository, `/`-separated (a git tree path). */
42
+ export const KERNEL_PACKAGE_REPO_PATH = KERNEL_PACKAGE_SUBPATH.join('/');
41
43
 
42
44
  let configured = Object.freeze({});
43
45
 
@@ -0,0 +1,239 @@
1
+ // kernel-protocol-choice — which kernel commit a develop edition runs.
2
+ //
3
+ // The CLI runs the kernel's code and refuses a protocol it does not support
4
+ // (edition-protocol.mjs), so a develop edition that always took the kernel's
5
+ // develop HEAD would break on every kernel protocol bump until a CLI release
6
+ // caught up. Commits that declare the same protocol identity (version AND
7
+ // digest) are interchangeable by the digest contract, so the develop kernel is
8
+ // the NEWEST commit on the checkout's first-parent history whose declared
9
+ // identity is in this CLI's supported set: a deterministic choice, never a
10
+ // fallback.
11
+ //
12
+ // - head supported -> the head (reason head-supported)
13
+ // - head ahead of the CLI -> the newest supported (reason head-ahead, held)
14
+ // - an explicit 40-hex pin -> the pin, still checked (reason explicit-pin); it must be on the first-parent
15
+ // history of the develop head (refs/remotes/origin/develop), else not-on-develop
16
+ // - nothing supported -> refused (EditionProtocolMismatchError, no-supported-commit); never the head.
17
+ // The walk ends at the first commit with no declaration file (it predates the protocol).
18
+ // - a declaration that exists but does not parse, at the head or any walked commit
19
+ // -> refused (EditionProtocolMismatchError, unparseable) naming the commit; never skipped
20
+ //
21
+ // The declared identity is read from each commit's SOURCE
22
+ // (`<kernel package>/src/edition-protocol/lib/edition-protocol.ts`, the literal
23
+ // `EDITION_PROTOCOL = Object.freeze({ version, provisional, contentDigest })`),
24
+ // because the history is not built; the built kernel is checked again from its
25
+ // dist files by every loader. Reads git objects only; writes nothing.
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+
29
+ import {
30
+ EditionProtocolMismatchError,
31
+ EditionProtocolMismatchReason,
32
+ assertSupportedEditionIdentity,
33
+ isSupportedEditionIdentity,
34
+ supportedList,
35
+ } from './edition-protocol.mjs';
36
+ import { KERNEL_PACKAGE_REPO_PATH } from './edition-workspace.mjs';
37
+
38
+ /** The kernel source file that declares the edition protocol identity, repository-relative. */
39
+ export const KERNEL_EDITION_PROTOCOL_SOURCE = `${KERNEL_PACKAGE_REPO_PATH}/src/edition-protocol/lib/edition-protocol.ts`;
40
+
41
+ /**
42
+ * The kernel checkout's develop head: the remote-tracking ref a full-history checkout (`fetch-depth: 0`)
43
+ * always has. Every choice and every explicit commit is judged against its first-parent history.
44
+ */
45
+ export const KERNEL_DEVELOP_REF = 'refs/remotes/origin/develop';
46
+
47
+ /** Why a kernel commit was chosen. A closed set; `held` is exactly `head-ahead`. */
48
+ export const KernelChoiceReason = Object.freeze({
49
+ HeadSupported: 'head-supported',
50
+ HeadAhead: 'head-ahead',
51
+ ExplicitPin: 'explicit-pin',
52
+ });
53
+
54
+ const SHA_PATTERN = /^[0-9a-f]{40}$/;
55
+ const MAX_GIT_OUTPUT = 256 * 1024 * 1024;
56
+ const DECLARATION = /export const EDITION_PROTOCOL\b[^=]*=\s*Object\.freeze\(\{([^}]*)\}\)/g;
57
+ const FIELDS = Object.freeze([
58
+ ['version', /\bversion:\s*([1-9][0-9]*)\s*(?:,|$)/],
59
+ ['provisional', /\bprovisional:\s*(true|false)\s*(?:,|$)/],
60
+ ['contentDigest', /\bcontentDigest:\s*'([a-f0-9]{64})'\s*(?:,|$)/],
61
+ ]);
62
+
63
+ /** A declaration source that exists but whose `EDITION_PROTOCOL` literal is absent or not exactly the three fields. */
64
+ const malformed = (label) =>
65
+ new EditionProtocolMismatchError(
66
+ EditionProtocolMismatchReason.Unparseable,
67
+ `${label} does not parse: it must declare exactly one EDITION_PROTOCOL = Object.freeze({ version: <positive integer>, ` +
68
+ "provisional: <boolean>, contentDigest: '<64 lowercase hex>' })",
69
+ );
70
+
71
+ /**
72
+ * The identity `{contentDigest, provisional, version}` the source text declares. Strict: exactly one
73
+ * declaration, each field exactly once, nothing else in the literal. Throws Unparseable otherwise.
74
+ */
75
+ export function parseDeclaredEditionProtocol(text, label) {
76
+ const declarations = [...text.matchAll(DECLARATION)];
77
+ if (declarations.length !== 1) {throw malformed(label);}
78
+ let rest = declarations[0][1];
79
+ const fields = {};
80
+ for (const [name, pattern] of FIELDS) {
81
+ const all = rest.match(new RegExp(pattern.source, 'g')) ?? [];
82
+ if (all.length !== 1) {throw malformed(label);}
83
+ fields[name] = all[0].match(pattern)[1];
84
+ rest = rest.replace(all[0], '');
85
+ }
86
+ if (rest.replace(/\/\/[^\n]*/g, '').trim() !== '') {throw malformed(label);}
87
+ return Object.freeze({
88
+ contentDigest: fields.contentDigest,
89
+ provisional: fields.provisional === 'true',
90
+ version: Number(fields.version),
91
+ });
92
+ }
93
+
94
+ const describeShort = (identity) =>
95
+ identity === undefined ? 'no declared protocol' : `v${identity.version}${identity.provisional ? '-provisional' : ''}`;
96
+
97
+ /** Git against one checkout; `runGit(args, input?)` returns stdout as a string. */
98
+ function gitIn(repoDir, runGit) {
99
+ return (args, input) =>
100
+ runGit('git', ['-C', repoDir, ...args], {
101
+ encoding: 'utf8',
102
+ maxBuffer: MAX_GIT_OUTPUT,
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ ...(input === undefined ? {} : { input }),
105
+ });
106
+ }
107
+
108
+ function assertCommit(git, sha, label) {
109
+ if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) {
110
+ throw new Error(`${label} is not a 40-hex commit SHA: ${JSON.stringify(sha)}`);
111
+ }
112
+ let resolved;
113
+ try {
114
+ resolved = git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]).trim();
115
+ } catch {
116
+ resolved = '';
117
+ }
118
+ if (resolved !== sha) {throw new Error(`${label} is not a commit in the kernel checkout`);}
119
+ }
120
+
121
+ /** The commit {@link KERNEL_DEVELOP_REF} names; refused when the checkout has no such ref. */
122
+ function readDevelopHead(git) {
123
+ let head;
124
+ try {
125
+ head = git(['rev-parse', '--verify', '--quiet', `${KERNEL_DEVELOP_REF}^{commit}`]).trim();
126
+ } catch {
127
+ head = '';
128
+ }
129
+ if (!SHA_PATTERN.test(head)) {
130
+ throw new Error(`the kernel checkout has no ${KERNEL_DEVELOP_REF}; check it out with full history (fetch-depth: 0)`);
131
+ }
132
+ return head;
133
+ }
134
+
135
+ /** The first-parent history of `head`, newest first. */
136
+ const firstParentHistory = (git, head) =>
137
+ git(['rev-list', '--first-parent', head]).split('\n').filter((line) => line.length > 0);
138
+
139
+ /**
140
+ * The declaring blob of every commit, by one `git cat-file --batch-check`: a Map commit -> blob sha, or
141
+ * undefined when the commit has no such file.
142
+ */
143
+ function declaringBlobs(git, commits) {
144
+ const input = commits.map((commit) => `${commit}:${KERNEL_EDITION_PROTOCOL_SOURCE}\n`).join('');
145
+ const lines = git(['cat-file', '--batch-check'], input).split('\n').filter((line) => line.length > 0);
146
+ if (lines.length !== commits.length) {
147
+ throw new Error(`git cat-file answered ${lines.length} lines for ${commits.length} kernel commits`);
148
+ }
149
+ return new Map(
150
+ commits.map((commit, index) => {
151
+ const [sha, type] = lines[index].split(' ');
152
+ return [commit, type === 'blob' && SHA_PATTERN.test(sha) ? sha : undefined];
153
+ }),
154
+ );
155
+ }
156
+
157
+ /** A reader of the declared identity per blob (memoized); `undefined` for an absent file. Throws Unparseable. */
158
+ function identityReader(git) {
159
+ const byBlob = new Map();
160
+ return (commit, blob) => {
161
+ if (blob === undefined) {return undefined;}
162
+ if (!byBlob.has(blob)) {
163
+ const label = `the kernel commit ${commit}: ${KERNEL_EDITION_PROTOCOL_SOURCE}`;
164
+ byBlob.set(blob, parseDeclaredEditionProtocol(git(['cat-file', 'blob', blob]), label));
165
+ }
166
+ return byBlob.get(blob);
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Refuse a kernel commit whose declared identity this CLI does not support (absent, provisional, another
172
+ * version, or a supported version at another digest), naming `label`. The one check behind an explicit pin
173
+ * and `freeze-graph --kernel-sha`. Returns the identity.
174
+ */
175
+ export function assertKernelCommitSupported({ repoDir, commit, label = `the kernel commit ${commit}`, runGit = execFileSync }) {
176
+ const git = gitIn(repoDir, runGit);
177
+ assertCommit(git, commit, label);
178
+ const developHead = readDevelopHead(git);
179
+ if (!firstParentHistory(git, developHead).includes(commit)) {
180
+ throw new EditionProtocolMismatchError(
181
+ EditionProtocolMismatchReason.NotOnDevelop,
182
+ `${label} is not on the first-parent history of the kernel develop head ${developHead} (${KERNEL_DEVELOP_REF}); ` +
183
+ 'an explicit kernel commit must be one that kernel develop actually passed through.',
184
+ );
185
+ }
186
+ const identity = identityReader(git)(commit, declaringBlobs(git, [commit]).get(commit));
187
+ if (identity === undefined) {
188
+ throw new EditionProtocolMismatchError(
189
+ EditionProtocolMismatchReason.Undeclared,
190
+ `${label} declares no edition protocol (${KERNEL_EDITION_PROTOCOL_SOURCE} is absent); this CLI supports ${supportedList()}.`,
191
+ );
192
+ }
193
+ return assertSupportedEditionIdentity(identity, `${label} (${KERNEL_EDITION_PROTOCOL_SOURCE})`);
194
+ }
195
+
196
+ /**
197
+ * Choose the develop kernel commit in the checkout at `repoDir` (fetched with full history).
198
+ *
199
+ * @param {object} request
200
+ * @param {string} request.repoDir the kernel-contracts checkout
201
+ * @param {string} [request.pin] an explicit 40-hex commit; it wins, and is still checked
202
+ * @returns {{ choice: {chosenSha: string, headSha: string, held: boolean, reason: string}, notice?: string }}
203
+ * `choice` is what is recorded; `notice` (only when held) is the run-summary line, never persisted.
204
+ */
205
+ export function chooseKernelCommit({ repoDir, pin, runGit = execFileSync }) {
206
+ const git = gitIn(repoDir, runGit);
207
+ const headSha = readDevelopHead(git);
208
+ if (pin !== undefined) {
209
+ assertKernelCommitSupported({ repoDir, commit: pin, label: `the kernel pin ${pin}`, runGit });
210
+ return { choice: { chosenSha: pin, headSha, held: false, reason: KernelChoiceReason.ExplicitPin } };
211
+ }
212
+ const commits = firstParentHistory(git, headSha);
213
+ const blobs = declaringBlobs(git, commits);
214
+ const identityAt = identityReader(git);
215
+ // Fail closed: an unparseable declaration (head or walked) is refused by name, never skipped; a commit
216
+ // with no declaration file predates the protocol, so the walk ends there.
217
+ const headIdentity = identityAt(headSha, blobs.get(headSha));
218
+ if (isSupportedEditionIdentity(headIdentity)) {
219
+ return { choice: { chosenSha: headSha, headSha, held: false, reason: KernelChoiceReason.HeadSupported } };
220
+ }
221
+ for (const commit of headIdentity === undefined ? [] : commits.slice(1)) {
222
+ const identity = identityAt(commit, blobs.get(commit));
223
+ if (identity === undefined) {break;}
224
+ if (isSupportedEditionIdentity(identity)) {
225
+ return {
226
+ choice: { chosenSha: commit, headSha, held: true, reason: KernelChoiceReason.HeadAhead },
227
+ notice:
228
+ `kernel held at ${describeShort(identity)} (${commit.slice(0, 12)}), ` +
229
+ `head at ${describeShort(headIdentity)} (${headSha.slice(0, 12)}): raise the CLI`,
230
+ };
231
+ }
232
+ }
233
+ throw new EditionProtocolMismatchError(
234
+ EditionProtocolMismatchReason.NoSupportedCommit,
235
+ `no commit on the first-parent history of the kernel head ${headSha} declares an edition protocol this CLI ` +
236
+ `supports (this CLI supports ${supportedList()}; the head declares ${describeShort(headIdentity)}` +
237
+ `${headIdentity === undefined ? '' : ` at ${headIdentity.contentDigest}`}). Raise the CLI; the head is never taken instead.`,
238
+ );
239
+ }