@patronage/factory-ci 1.0.0-alpha.4 → 1.0.0-alpha.6

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/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { createRequire } from "node:module";
2
- import { mkdir } from "node:fs/promises";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { build } from "esbuild";
5
- import { createSign } from "node:crypto";
5
+ import { createSign, randomUUID } from "node:crypto";
6
6
  import { readFileSync } from "node:fs";
7
- import { execFileSync, spawnSync } from "node:child_process";
7
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
8
+ import { once } from "node:events";
9
+ import { arch, availableParallelism, cpus, platform, release, totalmem } from "node:os";
10
+ import { performance } from "node:perf_hooks";
8
11
  //#region src/actions.ts
9
12
  /**
10
13
  * The canonical Node 24 family shared by factory-project workflows.
@@ -550,6 +553,44 @@ const proofReuseRequiredCommands = (commands) => {
550
553
  return names.toSorted();
551
554
  };
552
555
  /**
556
+ * Resolve command *identities* to their `ProofReuseCommand` objects (ADR
557
+ * 0021).
558
+ *
559
+ * Selection by name is deliberately a consumer decision (proof-surfaces.ts in
560
+ * software-factory-hq, the `paitronage:verify` filter in paitronage's
561
+ * `verify.ts`): only the consumer knows which named commands a guarded
562
+ * surface requires. What both of those implementations independently
563
+ * hand-rolled is the same lookup — find each name in the profile's command
564
+ * catalog, and refuse to silently shrink the required set when a name has no
565
+ * entry. That lookup is what this function is: the mechanic, not the
566
+ * selection.
567
+ *
568
+ * Throwing at generation time (rather than returning `undefined` or an empty
569
+ * array) is deliberate: a name with no catalog entry is a mistake in the
570
+ * generator source, not a runtime condition a consumer should have to check
571
+ * for, and a required set that quietly loses an entry is exactly what makes a
572
+ * passing proof trivially "covering".
573
+ *
574
+ * `selectionLabel` names the failure, nothing else: it is not part of the
575
+ * selection this function resolves, only prose a consumer supplies for its
576
+ * own thrown error (e.g. HQ's surface name, "core" or "docs"). The message
577
+ * deliberately says "the profile's command catalog" rather than naming
578
+ * `software-factory.profile.json`: a fleet-generic library must not assume
579
+ * every consumer's catalog is that exact file, so this wording differs
580
+ * on purpose from the HQ-local message it replaced.
581
+ */
582
+ const resolveProofReuseCommands = (catalog, names, selectionLabel) => names.map((name) => {
583
+ const command = catalog.find((entry) => entry.name === name);
584
+ if (!command) {
585
+ const location = selectionLabel ? `Proof-reuse selection for the ${selectionLabel} surface names` : "Proof-reuse selection names";
586
+ throw new Error(`${location} "${name}", which the profile's command catalog does not define.`);
587
+ }
588
+ return {
589
+ command: command.command,
590
+ name: command.name
591
+ };
592
+ });
593
+ /**
553
594
  * jq program: every page of the Checks API result in, three sanitized lines
554
595
  * (`reason`, `mode`, missing commands) out.
555
596
  *
@@ -898,6 +939,328 @@ const assertProofReuseCoverage = (input) => {
898
939
  throw new Error(`Proof-reuse coverage failed: the ${surface} surface ${problem}. Add the command to software-factory.profile.json (and to this surface's selection), or stop skipping it.`);
899
940
  };
900
941
  //#endregion
942
+ //#region src/vitest-profile.ts
943
+ /**
944
+ * Vitest suite profiling — the measurement mechanics behind a CI runner
945
+ * comparison (#640, #647).
946
+ *
947
+ * A profile is a machine-readable record of N serial Vitest runs at one worker
948
+ * count, carrying per-file and per-test timings, aggregate duration statistics,
949
+ * and the hardware the samples actually ran on. The hardware capture is the
950
+ * point: the #640 Depot comparison only resolved because every sample recorded
951
+ * its `cpuModel`, which split otherwise-identical 4-CPU runs into two
952
+ * non-overlapping populations.
953
+ *
954
+ * Everything a repository decides stays with the repository: worker counts,
955
+ * sample counts, output paths, runner labels, and which suite to run at all.
956
+ * This module owns the schema, the report parsing, the environment capture, and
957
+ * the atomic write.
958
+ */
959
+ /** Schema version of the emitted profile document. */
960
+ const VITEST_PROFILE_SCHEMA_VERSION = 1;
961
+ /** `tool` discriminator every emitted profile carries. */
962
+ const VITEST_PROFILE_TOOL = "factory-ci-vitest-profile";
963
+ const RUN_TIMEOUT_MS = 15 * 6e4;
964
+ const TERMINATION_GRACE_MS = 5e3;
965
+ /**
966
+ * A sample failed. The partial profile is already on disk; `exitCode` is the
967
+ * status a caller should exit with.
968
+ */
969
+ var VitestProfileError = class extends Error {
970
+ exitCode;
971
+ constructor(message, exitCode) {
972
+ super(message);
973
+ this.name = "VitestProfileError";
974
+ this.exitCode = exitCode;
975
+ }
976
+ };
977
+ const durationSummary = (durations) => {
978
+ const sorted = durations.toSorted((left, right) => left - right);
979
+ const middle = Math.floor(sorted.length / 2);
980
+ const median = sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : sorted[middle] ?? 0;
981
+ return {
982
+ maximum: sorted.at(-1) ?? 0,
983
+ mean: durations.reduce((sum, value) => sum + value, 0) / durations.length,
984
+ median,
985
+ minimum: sorted[0] ?? 0
986
+ };
987
+ };
988
+ const relativePath = (cwd, file) => path.relative(cwd, file).split(path.sep).join("/");
989
+ /**
990
+ * Fold one Vitest JSON report into a profile sample: file and test timings,
991
+ * both sorted slowest first, plus the run's counts. A missing report (crash,
992
+ * timeout, unwritable output) yields a sample with `reportAvailable: false`
993
+ * rather than nothing at all.
994
+ */
995
+ const normalizeVitestProfileSample = (report, input) => {
996
+ const files = (report?.testResults ?? []).map((file) => ({
997
+ durationMs: Math.max(0, file.endTime - file.startTime),
998
+ path: relativePath(input.cwd, file.name),
999
+ status: file.status
1000
+ })).toSorted((left, right) => right.durationMs - left.durationMs);
1001
+ const tests = (report?.testResults ?? []).flatMap((file) => file.assertionResults.map((test) => ({
1002
+ durationMs: Math.max(0, test.duration ?? 0),
1003
+ file: relativePath(input.cwd, file.name),
1004
+ name: test.fullName,
1005
+ status: test.status
1006
+ }))).toSorted((left, right) => right.durationMs - left.durationMs);
1007
+ return {
1008
+ counts: report ? {
1009
+ failed: report.numFailedTests,
1010
+ passed: report.numPassedTests,
1011
+ pending: report.numPendingTests,
1012
+ suites: report.numTotalTestSuites,
1013
+ tests: report.numTotalTests,
1014
+ todo: report.numTodoTests
1015
+ } : null,
1016
+ durationMs: input.durationMs,
1017
+ endedAt: input.endedAt.toISOString(),
1018
+ exitCode: input.exitCode,
1019
+ failure: input.failure ?? null,
1020
+ files,
1021
+ reportAvailable: report !== null,
1022
+ sample: input.sample,
1023
+ startedAt: input.startedAt.toISOString(),
1024
+ tests
1025
+ };
1026
+ };
1027
+ const aggregateSlowFiles = (runs, limit) => {
1028
+ const durations = /* @__PURE__ */ new Map();
1029
+ for (const file of runs.flatMap((run) => run.files)) {
1030
+ const recorded = durations.get(file.path) ?? [];
1031
+ recorded.push(file.durationMs);
1032
+ durations.set(file.path, recorded);
1033
+ }
1034
+ return [...durations].map(([filePath, values]) => ({
1035
+ durationMs: durationSummary(values),
1036
+ path: filePath,
1037
+ samples: values.length
1038
+ })).toSorted((left, right) => right.durationMs.median - left.durationMs.median).slice(0, limit);
1039
+ };
1040
+ const aggregateSlowTests = (runs, limit) => {
1041
+ const timings = /* @__PURE__ */ new Map();
1042
+ for (const test of runs.flatMap((run) => run.tests)) {
1043
+ const key = `${test.file}\0${test.name}`;
1044
+ const recorded = timings.get(key) ?? {
1045
+ durations: [],
1046
+ file: test.file,
1047
+ name: test.name
1048
+ };
1049
+ recorded.durations.push(test.durationMs);
1050
+ timings.set(key, recorded);
1051
+ }
1052
+ return [...timings.values()].map(({ durations, file, name }) => ({
1053
+ durationMs: durationSummary(durations),
1054
+ file,
1055
+ name,
1056
+ samples: durations.length
1057
+ })).toSorted((left, right) => right.durationMs.median - left.durationMs.median).slice(0, limit);
1058
+ };
1059
+ const gitValue = (cwd, args) => {
1060
+ try {
1061
+ return execFileSync("git", args, {
1062
+ cwd,
1063
+ encoding: "utf-8",
1064
+ stdio: [
1065
+ "ignore",
1066
+ "pipe",
1067
+ "ignore"
1068
+ ]
1069
+ }).trim();
1070
+ } catch {
1071
+ return null;
1072
+ }
1073
+ };
1074
+ const resolveVitestPackage = (cwd) => createRequire(path.join(path.resolve(cwd), "noop.js")).resolve("vitest/package.json");
1075
+ /**
1076
+ * Record the machine and commit a profile was taken on. Vitest's version is
1077
+ * resolved from `cwd`, so it is the consumer's Vitest and not this package's.
1078
+ * Git failures degrade to `null` — an artifact from a tarball checkout is still
1079
+ * a usable measurement.
1080
+ */
1081
+ const captureVitestProfileEnvironment = async (options) => {
1082
+ const gitDirectory = options.gitDirectory ?? options.cwd;
1083
+ let vitestVersion = "unknown";
1084
+ try {
1085
+ vitestVersion = JSON.parse(await readFile(resolveVitestPackage(options.cwd), "utf-8")).version ?? "unknown";
1086
+ } catch {
1087
+ vitestVersion = "unknown";
1088
+ }
1089
+ const processors = cpus();
1090
+ const status = gitValue(gitDirectory, ["status", "--porcelain"]);
1091
+ return {
1092
+ arch: arch(),
1093
+ availableParallelism: availableParallelism(),
1094
+ cpuCount: processors.length,
1095
+ cpuModel: processors[0]?.model ?? null,
1096
+ gitDirty: status === null ? null : status.length > 0,
1097
+ gitHead: gitValue(gitDirectory, ["rev-parse", "HEAD"]),
1098
+ node: process.version,
1099
+ osRelease: release(),
1100
+ platform: platform(),
1101
+ totalMemoryBytes: totalmem(),
1102
+ vitest: vitestVersion
1103
+ };
1104
+ };
1105
+ const spawnVitest = async ({ cwd, maxWorkers, reportPath, stdio }) => {
1106
+ const vitestCli = path.join(path.dirname(resolveVitestPackage(cwd)), "vitest.mjs");
1107
+ const started = performance.now();
1108
+ const child = spawn(process.execPath, [
1109
+ vitestCli,
1110
+ "run",
1111
+ "--reporter=json",
1112
+ `--outputFile=${reportPath}`,
1113
+ `--maxWorkers=${maxWorkers}`
1114
+ ], {
1115
+ cwd,
1116
+ stdio
1117
+ });
1118
+ let timedOut = false;
1119
+ let forceKillTimer;
1120
+ const timeoutTimer = setTimeout(() => {
1121
+ timedOut = true;
1122
+ child.kill("SIGTERM");
1123
+ forceKillTimer = setTimeout(() => {
1124
+ child.kill("SIGKILL");
1125
+ }, TERMINATION_GRACE_MS);
1126
+ }, RUN_TIMEOUT_MS);
1127
+ let exitCode = 0;
1128
+ let failure = null;
1129
+ try {
1130
+ const [code, signal] = await once(child, "exit");
1131
+ exitCode = code ?? (signal ? 1 : 0);
1132
+ } catch (error) {
1133
+ exitCode = 1;
1134
+ failure = `Vitest could not start: ${error instanceof Error ? error.message : String(error)}`;
1135
+ } finally {
1136
+ clearTimeout(timeoutTimer);
1137
+ if (forceKillTimer) clearTimeout(forceKillTimer);
1138
+ }
1139
+ if (timedOut) failure = `Vitest profile sample exceeded ${RUN_TIMEOUT_MS}ms and was terminated.`;
1140
+ let report = null;
1141
+ try {
1142
+ report = JSON.parse(await readFile(reportPath, "utf-8"));
1143
+ } catch (error) {
1144
+ const reportFailure = `Vitest JSON report unavailable: ${error instanceof Error ? error.message : String(error)}`;
1145
+ failure = failure ? `${failure} ${reportFailure}` : reportFailure;
1146
+ }
1147
+ return {
1148
+ durationMs: Math.max(0, performance.now() - started),
1149
+ exitCode: timedOut ? 1 : exitCode,
1150
+ failure,
1151
+ report
1152
+ };
1153
+ };
1154
+ /**
1155
+ * Write a profile document atomically: a partial file must never be readable
1156
+ * as a complete measurement, and the profile is rewritten after every sample.
1157
+ */
1158
+ const writeVitestProfile = async (outputPath, profile) => {
1159
+ await mkdir(path.dirname(outputPath), { recursive: true });
1160
+ const temporaryPath = `${outputPath}.tmp`;
1161
+ await writeFile(temporaryPath, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
1162
+ await rename(temporaryPath, outputPath);
1163
+ };
1164
+ /**
1165
+ * Take `samples` serial Vitest runs at one worker count and persist the profile
1166
+ * after each one. Samples never overlap: concurrent runs would measure CPU and
1167
+ * I/O contention instead of the worker count under test. A failing sample
1168
+ * throws `VitestProfileError` with the partial profile already written.
1169
+ */
1170
+ const runVitestProfile = async (options, dependencies = {}) => {
1171
+ if (!Number.isSafeInteger(options.samples) || options.samples < 1) throw new Error("runVitestProfile: samples must be a positive integer.");
1172
+ const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
1173
+ const runSample = dependencies.runSample ?? spawnVitest;
1174
+ const writeResult = dependencies.writeResult ?? writeVitestProfile;
1175
+ const startedAt = now();
1176
+ const rawRoot = path.join(`${options.outputPath}.raw`, randomUUID());
1177
+ await mkdir(rawRoot, { recursive: true });
1178
+ const profile = {
1179
+ command: [
1180
+ "vitest",
1181
+ "run",
1182
+ "--reporter=json",
1183
+ `--maxWorkers=${options.maxWorkers}`
1184
+ ],
1185
+ endedAt: startedAt.toISOString(),
1186
+ environment: await captureVitestProfileEnvironment({
1187
+ cwd: options.cwd,
1188
+ gitDirectory: options.gitDirectory
1189
+ }),
1190
+ options: {
1191
+ maxWorkers: options.maxWorkers,
1192
+ samples: options.samples,
1193
+ slowLimit: options.slowLimit
1194
+ },
1195
+ rawReportDirectory: rawRoot,
1196
+ runs: [],
1197
+ schemaVersion: 1,
1198
+ startedAt: startedAt.toISOString(),
1199
+ summary: {
1200
+ durationMs: {
1201
+ maximum: 0,
1202
+ mean: 0,
1203
+ median: 0,
1204
+ minimum: 0
1205
+ },
1206
+ slowFiles: [],
1207
+ slowTests: []
1208
+ },
1209
+ tool: VITEST_PROFILE_TOOL
1210
+ };
1211
+ for (let sample = 1; sample <= options.samples; sample += 1) {
1212
+ let hookFailure;
1213
+ try {
1214
+ options.onSampleStart?.({
1215
+ maxWorkers: options.maxWorkers,
1216
+ sample,
1217
+ samples: options.samples
1218
+ });
1219
+ } catch (error) {
1220
+ hookFailure = { error };
1221
+ }
1222
+ const sampleStartedAt = now();
1223
+ const reportPath = path.join(rawRoot, `sample-${sample}.json`);
1224
+ const execution = await runSample({
1225
+ cwd: options.cwd,
1226
+ maxWorkers: options.maxWorkers,
1227
+ reportPath,
1228
+ sample,
1229
+ stdio: options.stdio ?? "inherit"
1230
+ });
1231
+ const sampleEndedAt = now();
1232
+ const normalized = normalizeVitestProfileSample(execution.report, {
1233
+ cwd: options.cwd,
1234
+ durationMs: execution.durationMs,
1235
+ endedAt: sampleEndedAt,
1236
+ exitCode: execution.exitCode,
1237
+ failure: execution.failure,
1238
+ sample,
1239
+ startedAt: sampleStartedAt
1240
+ });
1241
+ profile.runs.push(normalized);
1242
+ profile.endedAt = sampleEndedAt.toISOString();
1243
+ profile.summary = {
1244
+ durationMs: durationSummary(profile.runs.map((run) => run.durationMs)),
1245
+ slowFiles: aggregateSlowFiles(profile.runs, options.slowLimit),
1246
+ slowTests: aggregateSlowTests(profile.runs, options.slowLimit)
1247
+ };
1248
+ await writeResult(options.outputPath, profile);
1249
+ try {
1250
+ options.onSampleComplete?.({
1251
+ result: normalized,
1252
+ sample,
1253
+ samples: options.samples
1254
+ });
1255
+ } catch (error) {
1256
+ hookFailure ??= { error };
1257
+ }
1258
+ if (execution.exitCode !== 0 || execution.report?.success !== true) throw new VitestProfileError(`Vitest profile sample ${sample} failed; partial results saved.`, execution.exitCode || 1);
1259
+ if (hookFailure) throw hookFailure.error;
1260
+ }
1261
+ return profile;
1262
+ };
1263
+ //#endregion
901
1264
  //#region src/workflow-shell-lint.ts
902
1265
  const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
903
1266
  /**
@@ -1162,4 +1525,4 @@ const assertWorkflowShellParses = (yaml, options) => {
1162
1525
  throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
1163
1526
  };
1164
1527
  //#endregion
1165
- export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, workflowRunBlocks, workflowShellParseFailures };
1528
+ export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0-alpha.6",
4
4
  "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -22,16 +22,13 @@
22
22
  "type": "module",
23
23
  "exports": {
24
24
  ".": {
25
- "development": {
26
- "types": "./src/index.ts",
27
- "default": "./src/index.ts"
28
- },
29
25
  "types": "./dist/index.d.ts",
30
26
  "default": "./dist/index.js"
31
27
  }
32
28
  },
33
29
  "publishConfig": {
34
- "access": "public"
30
+ "access": "public",
31
+ "tag": "next"
35
32
  },
36
33
  "dependencies": {
37
34
  "esbuild": "0.28.1"
package/src/index.ts CHANGED
@@ -70,7 +70,26 @@ export {
70
70
  type ProofReuseCoverageInput,
71
71
  type ProofReuseCoverageReport,
72
72
  proofReuseRequiredCommands,
73
+ resolveProofReuseCommands,
73
74
  } from "./proof-reuse-gate.ts";
75
+ export {
76
+ captureVitestProfileEnvironment,
77
+ normalizeVitestProfileSample,
78
+ runVitestProfile,
79
+ VITEST_PROFILE_SCHEMA_VERSION,
80
+ VITEST_PROFILE_TOOL,
81
+ type VitestJsonReport,
82
+ type VitestProfile,
83
+ type VitestProfileDependencies,
84
+ type VitestProfileDurationSummary,
85
+ type VitestProfileEnvironment,
86
+ VitestProfileError,
87
+ type VitestProfileOptions,
88
+ type VitestProfileSample,
89
+ type VitestProfileSampleExecution,
90
+ type VitestTestStatus,
91
+ writeVitestProfile,
92
+ } from "./vitest-profile.ts";
74
93
  export {
75
94
  assertWorkflowShellParses,
76
95
  workflowRunBlocks,
@@ -215,6 +215,51 @@ export const proofReuseRequiredCommands = (
215
215
  return names.toSorted();
216
216
  };
217
217
 
218
+ /**
219
+ * Resolve command *identities* to their `ProofReuseCommand` objects (ADR
220
+ * 0021).
221
+ *
222
+ * Selection by name is deliberately a consumer decision (proof-surfaces.ts in
223
+ * software-factory-hq, the `paitronage:verify` filter in paitronage's
224
+ * `verify.ts`): only the consumer knows which named commands a guarded
225
+ * surface requires. What both of those implementations independently
226
+ * hand-rolled is the same lookup — find each name in the profile's command
227
+ * catalog, and refuse to silently shrink the required set when a name has no
228
+ * entry. That lookup is what this function is: the mechanic, not the
229
+ * selection.
230
+ *
231
+ * Throwing at generation time (rather than returning `undefined` or an empty
232
+ * array) is deliberate: a name with no catalog entry is a mistake in the
233
+ * generator source, not a runtime condition a consumer should have to check
234
+ * for, and a required set that quietly loses an entry is exactly what makes a
235
+ * passing proof trivially "covering".
236
+ *
237
+ * `selectionLabel` names the failure, nothing else: it is not part of the
238
+ * selection this function resolves, only prose a consumer supplies for its
239
+ * own thrown error (e.g. HQ's surface name, "core" or "docs"). The message
240
+ * deliberately says "the profile's command catalog" rather than naming
241
+ * `software-factory.profile.json`: a fleet-generic library must not assume
242
+ * every consumer's catalog is that exact file, so this wording differs
243
+ * on purpose from the HQ-local message it replaced.
244
+ */
245
+ export const resolveProofReuseCommands = (
246
+ catalog: readonly ProofReuseCommand[],
247
+ names: readonly string[],
248
+ selectionLabel?: string
249
+ ): readonly ProofReuseCommand[] =>
250
+ names.map((name) => {
251
+ const command = catalog.find((entry) => entry.name === name);
252
+ if (!command) {
253
+ const location = selectionLabel
254
+ ? `Proof-reuse selection for the ${selectionLabel} surface names`
255
+ : "Proof-reuse selection names";
256
+ throw new Error(
257
+ `${location} "${name}", which the profile's command catalog does not define.`
258
+ );
259
+ }
260
+ return { command: command.command, name: command.name };
261
+ });
262
+
218
263
  /**
219
264
  * jq program: every page of the Checks API result in, three sanitized lines
220
265
  * (`reason`, `mode`, missing commands) out.