@xemahq/xema 0.27.0 → 0.29.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.
Files changed (63) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +8 -7
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/dev/env-defaults.d.ts.map +1 -1
  5. package/dist/dev/env-defaults.js +0 -1
  6. package/dist/dev/env-defaults.js.map +1 -1
  7. package/dist/edition/verify-image-evidence.bundle.mjs +231 -50
  8. package/dist/edition/verify-image-evidence.bundle.provenance.json +55 -43
  9. package/dist/runtime/edition/verb-map.d.ts +2 -1
  10. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  11. package/dist/runtime/edition/verb-map.js +2 -0
  12. package/dist/runtime/edition/verb-map.js.map +1 -1
  13. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  14. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  15. package/dist/runtime/runner/chart-set-lock.js +39 -4
  16. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  17. package/edition/README.md +76 -41
  18. package/edition/build-biome-index.mjs +1 -1
  19. package/edition/build-platform-service-index.mjs +1 -1
  20. package/edition/build-runtime-image-source-index.mjs +64 -20
  21. package/edition/catalogue-fetch.mjs +36 -9
  22. package/edition/catalogue-pin.mjs +71 -0
  23. package/edition/catalogue.mjs +11 -0
  24. package/edition/chart-set-source-commit.mjs +17 -7
  25. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  26. package/edition/customer-flags.mjs +0 -1
  27. package/edition/develop-customer-edition.mjs +23 -5
  28. package/edition/develop-edition.mjs +147 -48
  29. package/edition/develop-freeze.mjs +69 -25
  30. package/edition/develop-seal.mjs +39 -25
  31. package/edition/dist-check.mjs +1 -1
  32. package/edition/edition-artifact.mjs +79 -18
  33. package/edition/edition-biome-manifests.mjs +2 -3
  34. package/edition/edition-chart-set.mjs +59 -6
  35. package/edition/edition-config.mjs +45 -21
  36. package/edition/edition-constants.mjs +10 -4
  37. package/edition/edition-contract-parse.mjs +52 -0
  38. package/edition/edition-contract.json +21 -5
  39. package/edition/edition-workspace.mjs +23 -9
  40. package/edition/evidence-checkout.mjs +22 -1
  41. package/edition/evidence-trust-policy.mjs +142 -78
  42. package/edition/external-source-walk.mjs +48 -61
  43. package/edition/git-source-auth.mjs +104 -25
  44. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  45. package/edition/image-evidence.mjs +21 -3
  46. package/edition/kernel-contract-authority.mjs +23 -1
  47. package/edition/lib.mjs +16 -24
  48. package/edition/oras.mjs +1 -1
  49. package/edition/pin-source.mjs +6 -1
  50. package/edition/produce-image-delivery-entitlement-evidence.mjs +12 -12
  51. package/edition/registry-credentials.mjs +11 -1
  52. package/edition/release-channel-guard.mjs +11 -1
  53. package/edition/release-edition-artifact.mjs +5 -6
  54. package/edition/resolve-chart-set.mjs +50 -30
  55. package/edition/resolve-customer-edition.mjs +63 -7
  56. package/edition/resolve-distribution.mjs +1 -1
  57. package/edition/resolve-edition.mjs +12 -11
  58. package/edition/resolve-images.mjs +36 -15
  59. package/edition/seal-image-digests.mjs +31 -7
  60. package/edition/source-closure.mjs +1 -2
  61. package/edition/trust-policy-arming-verb.mjs +68 -0
  62. package/edition/verify-image-evidence.mjs +40 -12
  63. package/package.json +3 -3
@@ -78,6 +78,22 @@ function requireSourceAuthUsername(config) {
78
78
  return value;
79
79
  }
80
80
 
81
+ /** `chartRegistry` and `chartPublishers` are a vendor-only pair: both declared or both absent. */
82
+ function optionalChartSet(document) {
83
+ const registry = document.chartRegistry !== undefined;
84
+ const publishers = document.chartPublishers !== undefined;
85
+ if (registry !== publishers) {
86
+ throw new EditionWorkspaceConfigError(
87
+ `${EDITION_WORKSPACE_CONFIG_FILE}: "chartRegistry" and "chartPublishers" are a vendor-only pair, both declared or both absent; only "${registry ? 'chartRegistry' : 'chartPublishers'}" is`,
88
+ );
89
+ }
90
+ if (!registry) {return {};}
91
+ return {
92
+ chartRegistry: requireString(document, 'chartRegistry', CHART_REGISTRY, 'oci://<host>/<path>'),
93
+ chartPublishers: requirePublishers(document),
94
+ };
95
+ }
96
+
81
97
  function requirePublishers(config) {
82
98
  const value = config.chartPublishers;
83
99
  if (!Array.isArray(value) || value.length === 0) {
@@ -99,7 +115,7 @@ function requirePublishers(config) {
99
115
  );
100
116
  }
101
117
 
102
- const EVIDENCE_TRUST_POLICY_KEYS = Object.freeze(['pin', 'policy', 'required']);
118
+ const EVIDENCE_TRUST_POLICY_KEYS = Object.freeze(['pin', 'policy']);
103
119
 
104
120
  function isDataRootRelativePath(value) {
105
121
  return (
@@ -110,33 +126,31 @@ function isDataRootRelativePath(value) {
110
126
  }
111
127
 
112
128
  /**
113
- * The MANDATORY `evidenceTrustPolicy` declaration. `required` is a boolean with
114
- * no default: whether this data root's editions MUST be evidence-gated. The
115
- * policy is either declared (`policy` and `pin`, two data-root-relative paths)
116
- * or absent (both null); a declared policy is enforced whether or not it is
117
- * required. `required: true` with no policy is a state the shape allows and
118
- * `xema edition check` refuses (EvidenceTrustRequiredError), so the refusal
119
- * names the missing policy rather than a malformed file.
129
+ * The MANDATORY `evidenceTrustPolicy` declaration: `{policy, pin}` (two
130
+ * data-root-relative paths) or `null`, the explicit statement that this data
131
+ * root declares no policy. There is no switch that admits an ungated release:
132
+ * a release edition is written only against a policy ARMED on the release
133
+ * channel, so a data root that declares none serves develop editions only.
120
134
  */
121
135
  function requireEvidenceTrustPolicy(config) {
122
136
  if (!Object.hasOwn(config, 'evidenceTrustPolicy')) {
123
137
  throw new EditionWorkspaceConfigError(
124
- `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is required: {"required": true, "policy": ..., "pin": ...} ` +
125
- 'or {"required": false} (no default: say whether this data root\'s editions must be evidence-gated)',
138
+ `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is required: {"policy": ..., "pin": ...}, ` +
139
+ 'or null to state that this data root declares no policy (no release edition can then be sealed from it)',
126
140
  );
127
141
  }
128
142
  const value = config.evidenceTrustPolicy;
129
- const shapeError = () =>
143
+ if (value === null) {return Object.freeze({ policy: null, pin: null });}
144
+ const shapeError = (detail) =>
130
145
  new EditionWorkspaceConfigError(
131
- `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is exactly {required: boolean} or {required, policy, pin} with both paths data-root-relative`,
146
+ `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is exactly null or {policy, pin} with both paths data-root-relative${detail}`,
132
147
  );
133
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {throw shapeError();}
148
+ if (typeof value !== 'object' || Array.isArray(value)) {throw shapeError('');}
134
149
  const keys = Object.keys(value).sort();
135
- if (!keys.every((key) => EVIDENCE_TRUST_POLICY_KEYS.includes(key)) || typeof value.required !== 'boolean') {throw shapeError();}
136
- const declared = keys.length > 1;
137
- if (declared && keys.join() !== EVIDENCE_TRUST_POLICY_KEYS.join()) {throw shapeError();}
138
- if (!declared) {return Object.freeze({ required: value.required, policy: null, pin: null });}
139
- for (const key of ['policy', 'pin']) {
150
+ const unknown = keys.filter((key) => !EVIDENCE_TRUST_POLICY_KEYS.includes(key));
151
+ if (unknown.length > 0) {throw shapeError(`; unknown key ${unknown.map((key) => `"${key}"`).join(', ')}`);}
152
+ if (keys.join() !== EVIDENCE_TRUST_POLICY_KEYS.join()) {throw shapeError('');}
153
+ for (const key of EVIDENCE_TRUST_POLICY_KEYS) {
140
154
  if (!isDataRootRelativePath(value[key])) {
141
155
  throw new EditionWorkspaceConfigError(
142
156
  `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy.${key}" must be a data-root-relative POSIX path without "." or ".." segments; got ${JSON.stringify(value[key])}`,
@@ -146,7 +160,7 @@ function requireEvidenceTrustPolicy(config) {
146
160
  if (value.policy === value.pin) {
147
161
  throw new EditionWorkspaceConfigError(`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy.policy" and ".pin" must be different files`);
148
162
  }
149
- return Object.freeze({ required: value.required, policy: value.policy, pin: value.pin });
163
+ return Object.freeze({ policy: value.policy, pin: value.pin });
150
164
  }
151
165
 
152
166
  /** Validate a parsed config document; returns the frozen, typed config. */
@@ -168,8 +182,7 @@ export function parseEditionWorkspaceConfig(document) {
168
182
  return Object.freeze({
169
183
  ...sections,
170
184
  imageRegistry: requireString(document, 'imageRegistry', IMAGE_REGISTRY, '<host>/<namespace> with no scheme or trailing slash'),
171
- chartRegistry: requireString(document, 'chartRegistry', CHART_REGISTRY, 'oci://<host>/<path>'),
172
- chartPublishers: requirePublishers(document),
185
+ ...optionalChartSet(document),
173
186
  sourceRepositoryUrlPrefix: requireString(document, 'sourceRepositoryUrlPrefix', SOURCE_URL_PREFIX, 'https://<host>/<organisation>/ with a trailing slash'),
174
187
  sourceAuthUsername: requireSourceAuthUsername(document),
175
188
  evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
@@ -192,6 +205,17 @@ export function resetEditionWorkspaceConfig() {
192
205
  cached = undefined;
193
206
  }
194
207
 
208
+ /** The vendor chart set's coordinates `{chartRegistry, chartPublishers}`; a data root that declares neither (a customer's) is refused, naming the file and both keys. */
209
+ export function vendorChartSetConfig() {
210
+ const { chartRegistry, chartPublishers } = editionWorkspaceConfig();
211
+ if (chartRegistry === undefined) {
212
+ throw new EditionWorkspaceConfigError(
213
+ `${EDITION_WORKSPACE_CONFIG_FILE} in ${dataRoot()} declares no "chartRegistry" and "chartPublishers", which vendor chart-set code needs`,
214
+ );
215
+ }
216
+ return { chartRegistry, chartPublishers };
217
+ }
218
+
195
219
  /**
196
220
  * One product-data section, refused by name when the data root does not carry
197
221
  * it. `needed` says which operation wanted it, so the refusal is actionable.
@@ -1,11 +1,12 @@
1
1
  // edition-constants — the closed vocabulary of the edition OCI artifact, in one
2
2
  // place so the writer, the guards and the workflow cannot disagree about what an
3
3
  // edition IS. ONE layout serves both channels: an artifact at
4
- // `<registry>/xema-edition-<id>`, addressed by digest, carrying a titled
5
- // descriptor layer plus the lock layers.
4
+ // `<registry root>/<channel segment>/xema-edition-<id>`, addressed by digest,
5
+ // carrying a titled descriptor layer plus the lock layers.
6
6
 
7
7
  import { EditionChannel } from './edition-channel.mjs';
8
8
  import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
9
+ import { parseEditionContract } from './edition-contract-parse.mjs';
9
10
  import editionContract from './edition-contract.json' with { type: 'json' };
10
11
 
11
12
  /**
@@ -15,16 +16,21 @@ import editionContract from './edition-contract.json' with { type: 'json' };
15
16
  * It is a JSON import, not a read at run time, so a bundle (the evidence
16
17
  * verifier) carries it.
17
18
  */
18
- export const EDITION_CONTRACT = Object.freeze(structuredClone(editionContract));
19
+ export const EDITION_CONTRACT = Object.freeze(structuredClone(parseEditionContract(editionContract)));
19
20
 
20
21
  /** The descriptor layer's file name; its `org.opencontainers.image.title`. */
21
22
  export const EDITION_DESCRIPTOR_FILE = EDITION_CONTRACT.descriptorFile;
22
23
  export const EDITION_DESCRIPTOR_SCHEMA = EDITION_CONTRACT.descriptorSchema;
23
24
  /** The ONE artifactType of every edition artifact, develop or release. */
24
25
  export const EDITION_ARTIFACT_TYPE = EDITION_CONTRACT.artifactType;
25
- /** The repository name prefix: `<registry>/<prefix><id>`. */
26
+ /** The repository name prefix: `<registry root>/<channel segment>/<prefix><id>`. */
26
27
  export const EDITION_REPOSITORY_PREFIX = EDITION_CONTRACT.repositoryPrefix;
27
28
 
29
+ /** The namespace segment of each channel's edition repositories, keyed by channel: `<registry root>/<segment>/<prefix><id>`. */
30
+ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
31
+ Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
32
+ );
33
+
28
34
  export { EditionChannel };
29
35
 
30
36
  /** Whether the data root's editions are evidence-gated, as the descriptor records it. */
@@ -0,0 +1,52 @@
1
+ // edition-contract-parse — the strict parse of edition-contract.json. A consumer
2
+ // that vendors the contract holds it to this same shape: a contract that is not
3
+ // v2, or lacks `repositories`, or names a channel this CLI does not know, is
4
+ // refused rather than read with a default.
5
+
6
+ import { EditionChannel } from './edition-channel.mjs';
7
+
8
+ export const EDITION_CONTRACT_SCHEMA = 'xema.edition-contract/v2';
9
+
10
+ const SEGMENT = /^[a-z0-9][a-z0-9.-]*$/;
11
+
12
+ /** The strict parse of the contract; returns it unchanged. */
13
+ export function parseEditionContract(contract) {
14
+ if (contract === null || typeof contract !== 'object' || Array.isArray(contract)) {
15
+ throw new Error('edition-contract.json must be a JSON object');
16
+ }
17
+ if (contract.schema !== EDITION_CONTRACT_SCHEMA) {
18
+ throw new Error(`edition-contract.json: schema is ${JSON.stringify(contract.schema)}, expected ${JSON.stringify(EDITION_CONTRACT_SCHEMA)}`);
19
+ }
20
+ if (typeof contract.vendoring !== 'string' || contract.vendoring === '') {
21
+ throw new Error('edition-contract.json: "vendoring" must be the non-empty statement of how a consumer vendors this file');
22
+ }
23
+ if (typeof contract.repositoryPrefix !== 'string' || contract.repositoryPrefix === '') {
24
+ throw new Error('edition-contract.json: "repositoryPrefix" must be a non-empty string');
25
+ }
26
+ const repositories = contract.repositories;
27
+ if (repositories === null || typeof repositories !== 'object' || Array.isArray(repositories)) {
28
+ throw new Error('edition-contract.json has no "repositories": the namespace segment of each channel is read from it and nowhere else');
29
+ }
30
+ const channels = Object.values(EditionChannel);
31
+ const keys = Object.keys(repositories);
32
+ const unknown = keys.filter((key) => !channels.includes(key));
33
+ if (unknown.length > 0 || keys.length !== channels.length) {
34
+ throw new Error(
35
+ `edition-contract.json: "repositories" is exactly one entry per channel (${channels.join(' | ')}); ` +
36
+ (unknown.length > 0 ? `unknown channel ${unknown.map((key) => JSON.stringify(key)).join(', ')}` : `got ${keys.join(', ') || 'none'}`),
37
+ );
38
+ }
39
+ const segments = new Set();
40
+ for (const channel of channels) {
41
+ const entry = repositories[channel];
42
+ const entryKeys = entry !== null && typeof entry === 'object' && !Array.isArray(entry) ? Object.keys(entry) : [];
43
+ if (entryKeys.join() !== 'namespaceSegment' || typeof entry.namespaceSegment !== 'string' || !SEGMENT.test(entry.namespaceSegment)) {
44
+ throw new Error(`edition-contract.json: repositories.${channel} is exactly {namespaceSegment}, one lowercase path segment`);
45
+ }
46
+ segments.add(entry.namespaceSegment);
47
+ }
48
+ if (segments.size !== channels.length) {
49
+ throw new Error('edition-contract.json: the channels\' namespace segments must differ');
50
+ }
51
+ return contract;
52
+ }
@@ -1,11 +1,27 @@
1
1
  {
2
- "schema": "xema.edition-contract/v1",
2
+ "schema": "xema.edition-contract/v2",
3
+ "vendoring": "This file is the single source of the edition artifact coordinates and names. A consumer vendors it BYTE-IDENTICAL from a stated xema-cli commit and never edits it. It reads both repository namespace segments (repositories.<channel>.namespaceSegment) and the repository prefix (repositoryPrefix) only from this file, and refuses a contract that has no `repositories`.",
3
4
  "artifactType": "application/vnd.xema.edition.v1+json",
4
5
  "repositoryPrefix": "xema-edition-",
6
+ "repositories": {
7
+ "release": {
8
+ "namespaceSegment": "editions"
9
+ },
10
+ "develop": {
11
+ "namespaceSegment": "develop-editions"
12
+ }
13
+ },
5
14
  "descriptorFile": "xema-edition.json",
6
15
  "descriptorSchema": "xema.edition/v1",
7
- "locks": ["distribution.lock.json", "images.lock.json", "authorization.lock.json", "chart-set.lock.json"],
8
- "offeringLayers": { "catalogue": "catalogue.json" },
16
+ "locks": [
17
+ "distribution.lock.json",
18
+ "images.lock.json",
19
+ "authorization.lock.json",
20
+ "chart-set.lock.json"
21
+ ],
22
+ "offeringLayers": {
23
+ "catalogue": "catalogue.json"
24
+ },
9
25
  "provenancePins": {
10
26
  "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against; every other pin is a 40-hex commit"
11
27
  },
@@ -13,11 +29,11 @@
13
29
  "evidenceTrust": {
14
30
  "states": {
15
31
  "armed": "the data root declares an evidence trust policy that is pinned and ARMED on the channel of the edition",
16
- "not-armed": "a release edition: exactly, the data root's evidenceTrustPolicy.required is false and it has no policy armed on the release channel; a develop edition: its policy is not armed on the develop channel, whatever required says"
32
+ "not-armed": "a develop edition only: its data root declares no policy, or one that is not armed on the develop channel. A release edition never records not-armed"
17
33
  },
18
34
  "refusal": {
19
35
  "code": "EVIDENCE_TRUST_REQUIRED_UNARMED",
20
- "when": "evidenceTrustPolicy.required is true while the policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there): `xema edition check` fails, no release edition is sealed and no release artifact is written, so a release descriptor never records not-armed for a required data root. Arming is judged per channel: a develop edition admits not-armed"
36
+ "when": "a release edition is written while the data root's evidence trust policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there): no release descriptor or artifact is written, and no configuration key admits one. Arming is judged per channel: a develop edition admits not-armed"
21
37
  }
22
38
  }
23
39
  }
@@ -14,7 +14,6 @@ export const WorkspaceRoot = Object.freeze({
14
14
  Kernel: 'kernelPackageDir',
15
15
  Deploy: 'deployRoot',
16
16
  Cache: 'cacheDir',
17
- SourceHash: 'sourceHashPackageDir',
18
17
  });
19
18
 
20
19
  export const WORKSPACE_FLAGS = Object.freeze({
@@ -24,7 +23,6 @@ export const WORKSPACE_FLAGS = Object.freeze({
24
23
  [WorkspaceRoot.Kernel]: '--kernel',
25
24
  [WorkspaceRoot.Deploy]: '--deploy-root',
26
25
  [WorkspaceRoot.Cache]: '--cache-dir',
27
- [WorkspaceRoot.SourceHash]: '--source-hash',
28
26
  });
29
27
 
30
28
  const ROOT_MEANING = Object.freeze({
@@ -37,8 +35,6 @@ const ROOT_MEANING = Object.freeze({
37
35
  [WorkspaceRoot.Kernel]: 'the BUILT kernel-contracts package directory',
38
36
  [WorkspaceRoot.Deploy]: 'the deploy (chart tree) checkout',
39
37
  [WorkspaceRoot.Cache]: 'a writable directory for fetched artifacts',
40
- [WorkspaceRoot.SourceHash]:
41
- 'the BUILT distribution-source-hash package directory (the source-tree and image-input hash implementation)',
42
38
  });
43
39
 
44
40
  const KERNEL_PACKAGE_SUBPATH = ['packages', 'kernel', 'kernel-contracts'];
@@ -90,7 +86,6 @@ export const sourcesRoot = () => root(WorkspaceRoot.Sources);
90
86
  export const kernelPackageDir = () => root(WorkspaceRoot.Kernel);
91
87
  export const deployRoot = () => root(WorkspaceRoot.Deploy);
92
88
  export const cacheDir = () => root(WorkspaceRoot.Cache);
93
- export const sourceHashPackageDir = () => root(WorkspaceRoot.SourceHash);
94
89
 
95
90
  /** A source checkout at its aggregator-relative path, e.g. `repos/xema-base`. */
96
91
  export const sourcePath = (repo) => join(sourcesRoot(), repo);
@@ -113,9 +108,11 @@ export const runtimeImageSourceIndexPath = () =>
113
108
  /**
114
109
  * Parse the workspace flags out of an argv (both `--flag v` and `--flag=v`),
115
110
  * configure the roots, and return the remaining argv. `needs` names the roots
116
- * the entry requires: a missing one is refused here, before any work.
111
+ * the entry requires: a missing one is refused here, before any work. With
112
+ * `refuseOthers` a root flag whose root `needs` does not name is refused too
113
+ * (a customer verb takes only the roots it needs).
117
114
  */
118
- export function configureEditionWorkspaceFromArgv(argv, needs) {
115
+ export function configureEditionWorkspaceFromArgv(argv, needs, { refuseOthers = false } = {}) {
119
116
  const byFlag = new Map(
120
117
  Object.entries(WORKSPACE_FLAGS).map(([name, flag]) => [flag, name]),
121
118
  );
@@ -130,6 +127,11 @@ export function configureEditionWorkspaceFromArgv(argv, needs) {
130
127
  rest.push(arg);
131
128
  continue;
132
129
  }
130
+ if (refuseOthers && !needs.includes(name)) {
131
+ throw new Error(
132
+ `${flag} is not a root this command takes (it takes: ${needs.length === 0 ? 'no root' : needs.map((root) => WORKSPACE_FLAGS[root]).join(' ')})`,
133
+ );
134
+ }
133
135
  const value = eq > 0 ? arg.slice(eq + 1) : argv[(i += 1)];
134
136
  if (typeof value !== 'string' || value.length === 0 || value.startsWith('--')) {
135
137
  throw new Error(`${flag} requires a directory path`);
@@ -169,8 +171,20 @@ export function isEntryModule(moduleUrl) {
169
171
  /**
170
172
  * The entry's one call: configure the roots from `process.argv` and remove the
171
173
  * root flags from it, so the entry's own parser sees only its own arguments.
174
+ * `options` are those of `configureEditionWorkspaceFromArgv`.
172
175
  */
173
- export function configureEditionWorkspaceFromProcess(needs) {
174
- const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs);
176
+ export function configureEditionWorkspaceFromProcess(needs, options) {
177
+ let rest;
178
+ try {
179
+ rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs, options);
180
+ } catch (error) {
181
+ // An entry calls this before it has a `main` to catch anything: a missing
182
+ // or malformed root is the operator's mistake, answered with the roots the
183
+ // command takes, not with a stack trace.
184
+ process.stderr.write(
185
+ `${error.message}\nusage: this command takes ${needs.map((name) => `${WORKSPACE_FLAGS[name]} <dir>`).join(' ')}\n`,
186
+ );
187
+ process.exit(1);
188
+ }
175
189
  process.argv.splice(2, process.argv.length - 2, ...rest);
176
190
  }
@@ -386,6 +386,24 @@ export function gradeTrustedCheckouts({ grades, trustedGit, expected }) {
386
386
  group.requirements.push({ root: canonicalRoot, label, requiredPaths });
387
387
  byRepository.set(location.repositoryRoot, group);
388
388
  }
389
+ // A re-grade covers exactly the roots first graded: a root the first grade
390
+ // never saw has no HEAD to be held to, and is refused rather than re-graded
391
+ // unchecked.
392
+ if (expected !== undefined) {
393
+ const regraded = new Set(
394
+ [...byRepository.values()].flatMap((group) => group.requirements.map(({ root }) => root)),
395
+ );
396
+ for (const root of regraded) {
397
+ if (typeof expected.get(root)?.commit !== 'string') {
398
+ throw new Error(`${root} was not graded first, so its HEAD cannot be held unchanged`);
399
+ }
400
+ }
401
+ for (const root of expected.keys()) {
402
+ if (!regraded.has(root)) {
403
+ throw new Error(`${root} was graded first and is missing from the re-grade`);
404
+ }
405
+ }
406
+ }
389
407
  const result = new Map();
390
408
  for (const [repositoryRoot, group] of byRepository) {
391
409
  assertTrustedCheckoutLocation(group.location, group.labels.join(' + '));
@@ -393,9 +411,12 @@ export function gradeTrustedCheckouts({ grades, trustedGit, expected }) {
393
411
  repositoryRoot,
394
412
  requirements: group.requirements,
395
413
  trustedGit,
396
- expectedCommit: expected?.get(group.requirements[0].root)?.commit,
414
+ expectedCommit: expected?.get(group.requirements[0].root).commit,
397
415
  });
398
416
  for (const { root } of group.requirements) {
417
+ if (expected !== undefined && expected.get(root).commit !== graded.commit) {
418
+ throw new Error(`${repositoryRoot} HEAD changed during verification`);
419
+ }
399
420
  result.set(root, graded);
400
421
  }
401
422
  }