@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/README.md +23 -0
- package/dist/index.d.ts +241 -1
- package/dist/index.js +367 -4
- package/package.json +3 -6
- package/src/index.ts +19 -0
- package/src/proof-reuse-gate.ts +45 -0
- package/src/vitest-profile.ts +631 -0
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.
|
|
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,
|
package/src/proof-reuse-gate.ts
CHANGED
|
@@ -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.
|