@xemahq/xema 0.41.1 → 0.43.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.
@@ -370,7 +370,7 @@ export async function checkImagesLock(id, checkSourceEvidence) {
370
370
  }
371
371
  }
372
372
 
373
- async function checkCommittedRuntimeImageSourceIndex(checkSources) {
373
+ export async function checkCommittedRuntimeImageSourceIndex(checkSources) {
374
374
  try {
375
375
  const schemas = await loadKernelDistributionSchemas();
376
376
  schemas.parseRuntimeImageSourceIndex(
@@ -394,7 +394,7 @@ async function checkCommittedRuntimeImageSourceIndex(checkSources) {
394
394
  }
395
395
  }
396
396
 
397
- function checkCommittedIndex() {
397
+ export function checkCommittedIndex() {
398
398
  try {
399
399
  const index = validateImmutableBiomeIndex(
400
400
  JSON.parse(readFileSync(biomeIndexPath(), 'utf8')),
@@ -464,7 +464,7 @@ export function checkCommittedDataRootReaders() {
464
464
  return green;
465
465
  }
466
466
 
467
- async function checkCommittedPlatformServiceIndex() {
467
+ export async function checkCommittedPlatformServiceIndex() {
468
468
  try {
469
469
  const schemas = await loadKernelDistributionSchemas();
470
470
  const index = schemas.parsePlatformServiceSourceIndex(
@@ -493,7 +493,7 @@ async function checkCommittedPlatformServiceIndex() {
493
493
  }
494
494
  }
495
495
 
496
- function discoverDistributionIds() {
496
+ export function discoverDistributionIds() {
497
497
  if (!existsSync(editionsRoot())) {
498
498
  console.error(`[dist:check] ${editionsRoot()} does not exist.`);
499
499
  process.exit(1);
@@ -514,37 +514,16 @@ function discoverDistributionIds() {
514
514
  }
515
515
 
516
516
  /**
517
- * Guard for the capability-domain OWNERSHIP (reservation) table.
518
- *
519
- * This repo has no test runner (no package.json, no CI workflow of its own) —
520
- * `dist:check` IS its gate — so the invariant is asserted here, where it
521
- * actually runs, rather than in a spec file nothing would execute.
522
- *
523
- * THE INVARIANT THAT MATTERS: **every edition carries the IDENTICAL table.**
517
+ * The lock-free half of the ownership guard: the kernel's reader, exercised
518
+ * against synthetic tables. (a) two biomes claiming one domain is refused,
519
+ * naming it; (b) a listed domain resolves to its owner and an unlisted one is
520
+ * not first-party; (c) an invalid domain is refused. Reads no lock, so the
521
+ * authored check runs it too.
524
522
  *
525
- * Ownership is a property of the platform, not of what an edition preloads.
526
- * An earlier design put the claim on each `LockedBiome`, which made a trimmed
527
- * edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
528
- * org admin could install the owning biome from the store and THEN bind
529
- * `kb:page.read@1` to a URL they control, silently shadowing the platform
530
- * data plane. The table is now built from the FULL biome index and reserves
531
- * the domain even when its owning biome is absent from that edition — which
532
- * is exactly the case the reservation exists to cover.
533
- *
534
- * Also re-asserts the two structural rules, through the KERNEL's own reader:
535
- * (a) two biomes claiming the SAME domain is refused (ambiguous ownership has
536
- * no safe resolution — fail the build, never pick a winner);
537
- * (b) a domain in NO table entry is not first-party (so a claim that never
538
- * made it into a resolved, committed, drift-checked lock carries no
539
- * weight), and an invalid domain is refused rather than silently carried.
540
- *
541
- * @param {string[]} ids every distribution id discovered in this repo.
542
- * @returns {Promise<boolean>} true when green.
523
+ * @param {(lock: object) => Map<string, string>} resolveFirstPartyCapabilityDomains the kernel's reader
524
+ * @returns {string[]} the failures, empty when green
543
525
  */
544
- async function checkOwnershipInvariant(ids) {
545
- const { resolveFirstPartyCapabilityDomains } =
546
- await loadKernelDistributionSchemas();
547
-
526
+ export function ownershipSelfTestFailures(resolveFirstPartyCapabilityDomains) {
548
527
  const lockOf = (capabilityDomainOwners) => ({
549
528
  schemaVersion: 1,
550
529
  distributionId: 'dist-check-selftest',
@@ -599,6 +578,43 @@ async function checkOwnershipInvariant(ids) {
599
578
  /* expected */
600
579
  }
601
580
 
581
+ return failures;
582
+ }
583
+
584
+ /**
585
+ * Guard for the capability-domain OWNERSHIP (reservation) table.
586
+ *
587
+ * This repo has no test runner (no package.json, no CI workflow of its own) —
588
+ * `dist:check` IS its gate — so the invariant is asserted here, where it
589
+ * actually runs, rather than in a spec file nothing would execute.
590
+ *
591
+ * THE INVARIANT THAT MATTERS: **every edition carries the IDENTICAL table.**
592
+ *
593
+ * Ownership is a property of the platform, not of what an edition preloads.
594
+ * An earlier design put the claim on each `LockedBiome`, which made a trimmed
595
+ * edition (`appliance-lean`) leave `kb`/`agent`/… unowned — so an
596
+ * org admin could install the owning biome from the store and THEN bind
597
+ * `kb:page.read@1` to a URL they control, silently shadowing the platform
598
+ * data plane. The table is now built from the FULL biome index and reserves
599
+ * the domain even when its owning biome is absent from that edition — which
600
+ * is exactly the case the reservation exists to cover.
601
+ *
602
+ * Also re-asserts the two structural rules, through the KERNEL's own reader:
603
+ * (a) two biomes claiming the SAME domain is refused (ambiguous ownership has
604
+ * no safe resolution — fail the build, never pick a winner);
605
+ * (b) a domain in NO table entry is not first-party (so a claim that never
606
+ * made it into a resolved, committed, drift-checked lock carries no
607
+ * weight), and an invalid domain is refused rather than silently carried.
608
+ *
609
+ * @param {string[]} ids every distribution id discovered in this repo.
610
+ * @returns {Promise<boolean>} true when green.
611
+ */
612
+ async function checkOwnershipInvariant(ids) {
613
+ const { resolveFirstPartyCapabilityDomains } =
614
+ await loadKernelDistributionSchemas();
615
+
616
+ const failures = ownershipSelfTestFailures(resolveFirstPartyCapabilityDomains);
617
+
602
618
  // (d) THE headline invariant — every committed edition carries the same,
603
619
  // non-empty table. An edition-specific table is the fail-open bug.
604
620
  let reference;
@@ -919,13 +935,16 @@ export async function checkBiomeContentHashes(id) {
919
935
  }
920
936
  }
921
937
 
922
- async function main() {
923
- let failed = false;
924
- // The tests spawned below resolve the editions root from THEIR OWN argv and
925
- // env; a root stated here by --editions-root must reach them, or they
926
- // would grade the vendor's editions under a customer's dist-check.
927
- const checkSources = process.argv.includes('--check-sources');
928
-
938
+ /**
939
+ * The data-root plane both checks grade before any edition: the kernel speaks
940
+ * a supported edition protocol (a refusal throws), the committed biome,
941
+ * platform-service and runtime-image-source indexes validate offline, the
942
+ * evidence trust policy and the data-root readers are sound. `checkSources`
943
+ * adds the trusted live-source audit of the indexes (release path only).
944
+ * Returns true when green.
945
+ */
946
+ export async function checkDataRootPlane({ checkSources }) {
947
+ let green = true;
929
948
  // 0. The pinned kernel speaks an edition protocol this CLI supports. This is
930
949
  // the ONE comparison between the CLI and the kernel; a refusal throws
931
950
  // EditionProtocolMismatchError and stops the check before anything is read.
@@ -938,13 +957,13 @@ async function main() {
938
957
  // A live sibling-source scan is an explicit trusted regeneration audit only;
939
958
  // CI/shared working trees must not make unrelated uncommitted sources into
940
959
  // packaging inputs.
941
- if (!checkCommittedIndex()) {failed = true;}
942
- if (!(await checkCommittedPlatformServiceIndex())) {failed = true;}
960
+ if (!checkCommittedIndex()) {green = false;}
961
+ if (!(await checkCommittedPlatformServiceIndex())) {green = false;}
943
962
  if (!(await checkCommittedRuntimeImageSourceIndex(checkSources))) {
944
- failed = true;
963
+ green = false;
945
964
  }
946
- if (!(await checkEvidenceTrustPolicy())) {failed = true;}
947
- if (!checkCommittedDataRootReaders()) {failed = true;}
965
+ if (!(await checkEvidenceTrustPolicy())) {green = false;}
966
+ if (!checkCommittedDataRootReaders()) {green = false;}
948
967
  if (checkSources) {
949
968
  try {
950
969
  execFileSync(
@@ -958,9 +977,48 @@ async function main() {
958
977
  { stdio: 'inherit' },
959
978
  );
960
979
  } catch {
961
- failed = true;
980
+ green = false;
962
981
  }
963
982
  }
983
+ return green;
984
+ }
985
+
986
+ /**
987
+ * The pinned kernel's authorization composer (`kernel` null when the pin
988
+ * predates it). `green` is false when the composer could not be loaded.
989
+ */
990
+ export async function loadPlaneAuthorizationKernel() {
991
+ try {
992
+ return { green: true, kernel: await loadKernelAuthorizationComposer({ kernelPackageDir: kernelPackageDir() }) };
993
+ } catch (err) {
994
+ console.error(`[dist:check] ${err.message}`);
995
+ return { green: false, kernel: undefined };
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * The fleet chart-set pin every edition must carry (edition-chart-set.mjs), as
1001
+ * committed text, or null when it is absent or not a rostered chart-set lock.
1002
+ */
1003
+ export function readFleetChartSetPin() {
1004
+ try {
1005
+ const text = readFileSync(fleetChartSetLockPath(), 'utf8');
1006
+ assertRosteredChartSetLock(JSON.parse(text), EDITION_CHART_SET_FILE);
1007
+ return text;
1008
+ } catch (err) {
1009
+ console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
1010
+ return null;
1011
+ }
1012
+ }
1013
+
1014
+ async function main() {
1015
+ let failed = false;
1016
+ // The tests spawned below resolve the editions root from THEIR OWN argv and
1017
+ // env; a root stated here by --editions-root must reach them, or they
1018
+ // would grade the vendor's editions under a customer's dist-check.
1019
+ const checkSources = process.argv.includes('--check-sources');
1020
+
1021
+ if (!(await checkDataRootPlane({ checkSources }))) {failed = true;}
964
1022
 
965
1023
  const distributionIds = discoverDistributionIds();
966
1024
 
@@ -972,25 +1030,12 @@ async function main() {
972
1030
  if (!(await checkOwnershipInvariant(distributionIds))) {failed = true;}
973
1031
 
974
1032
  // The pinned kernel's authorization composer (null when the pin predates it).
975
- let authorizationKernel;
976
- try {
977
- authorizationKernel = await loadKernelAuthorizationComposer({
978
- kernelPackageDir: kernelPackageDir(),
979
- });
980
- } catch (err) {
981
- console.error(`[dist:check] ${err.message}`);
982
- failed = true;
983
- }
1033
+ const { green: authorizationKernelGreen, kernel: authorizationKernel } = await loadPlaneAuthorizationKernel();
1034
+ if (!authorizationKernelGreen) {failed = true;}
984
1035
 
985
1036
  // The fleet chart-set pin every edition must carry (edition-chart-set.mjs).
986
- let chartSetPinText = null;
987
- try {
988
- chartSetPinText = readFileSync(fleetChartSetLockPath(), 'utf8');
989
- assertRosteredChartSetLock(JSON.parse(chartSetPinText), EDITION_CHART_SET_FILE);
990
- } catch (err) {
991
- console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
992
- failed = true;
993
- }
1037
+ const chartSetPinText = readFleetChartSetPin();
1038
+ if (chartSetPinText === null) {failed = true;}
994
1039
 
995
1040
  // 3 + 4 + 5 + 6 + 7. Every distribution: distribution.lock, images.lock,
996
1041
  // authorization.lock, chart-set.lock.
@@ -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
+ }