@patronage/software-factory 1.0.0-alpha.7 → 1.0.0-alpha.8
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.d.ts +18 -18
- package/dist/index.js +723 -19
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -743,8 +743,8 @@ declare const mergeFreezeStateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
743
743
|
generationId: z.ZodNumber;
|
|
744
744
|
headSha: z.ZodString;
|
|
745
745
|
outcome: z.ZodEnum<{
|
|
746
|
-
stale: "stale";
|
|
747
746
|
active: "active";
|
|
747
|
+
stale: "stale";
|
|
748
748
|
}>;
|
|
749
749
|
reason: z.ZodString;
|
|
750
750
|
recordedAt: z.ZodISODateTime;
|
|
@@ -1224,8 +1224,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1224
1224
|
"run-interior-cycle": "run-interior-cycle";
|
|
1225
1225
|
}>;
|
|
1226
1226
|
stage: z.ZodEnum<{
|
|
1227
|
-
interior: "interior";
|
|
1228
1227
|
gate: "gate";
|
|
1228
|
+
interior: "interior";
|
|
1229
1229
|
"interior-complete": "interior-complete";
|
|
1230
1230
|
}>;
|
|
1231
1231
|
}, z.core.$strip>>;
|
|
@@ -1241,10 +1241,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1241
1241
|
reviewedHeadSha: z.ZodOptional<z.ZodString>;
|
|
1242
1242
|
reviewedPatchId: z.ZodOptional<z.ZodString>;
|
|
1243
1243
|
status: z.ZodEnum<{
|
|
1244
|
-
"not-required": "not-required";
|
|
1245
1244
|
blocked: "blocked";
|
|
1246
|
-
|
|
1245
|
+
"not-required": "not-required";
|
|
1247
1246
|
stale: "stale";
|
|
1247
|
+
current: "current";
|
|
1248
1248
|
missing: "missing";
|
|
1249
1249
|
}>;
|
|
1250
1250
|
}, z.core.$strip>;
|
|
@@ -1253,10 +1253,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1253
1253
|
reviewedHeadSha: z.ZodOptional<z.ZodString>;
|
|
1254
1254
|
reviewedPatchId: z.ZodOptional<z.ZodString>;
|
|
1255
1255
|
status: z.ZodEnum<{
|
|
1256
|
-
"not-required": "not-required";
|
|
1257
1256
|
blocked: "blocked";
|
|
1258
|
-
|
|
1257
|
+
"not-required": "not-required";
|
|
1259
1258
|
stale: "stale";
|
|
1259
|
+
current: "current";
|
|
1260
1260
|
missing: "missing";
|
|
1261
1261
|
}>;
|
|
1262
1262
|
}, z.core.$strip>>;
|
|
@@ -1267,8 +1267,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1267
1267
|
docsOnlyDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
|
|
1268
1268
|
docsOnlyVerifiedHeadSha: z.ZodOptional<z.ZodString>;
|
|
1269
1269
|
prVerify: z.ZodEnum<{
|
|
1270
|
-
passed: "passed";
|
|
1271
1270
|
stale: "stale";
|
|
1271
|
+
passed: "passed";
|
|
1272
1272
|
missing: "missing";
|
|
1273
1273
|
}>;
|
|
1274
1274
|
trivialDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -1768,10 +1768,10 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
|
|
|
1768
1768
|
//#region src/review-proof-applicability.d.ts
|
|
1769
1769
|
declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
|
|
1770
1770
|
declare const reviewStatusSchema: z.ZodEnum<{
|
|
1771
|
-
"not-required": "not-required";
|
|
1772
1771
|
blocked: "blocked";
|
|
1773
|
-
|
|
1772
|
+
"not-required": "not-required";
|
|
1774
1773
|
stale: "stale";
|
|
1774
|
+
current: "current";
|
|
1775
1775
|
missing: "missing";
|
|
1776
1776
|
}>;
|
|
1777
1777
|
type ReviewStatus = z.infer<typeof reviewStatusSchema>;
|
|
@@ -2177,9 +2177,9 @@ declare const retroEnvelopeV1Schema: z.ZodObject<{
|
|
|
2177
2177
|
kind: z.ZodLiteral<"retro-envelope">;
|
|
2178
2178
|
outcome: z.ZodOptional<z.ZodObject<{
|
|
2179
2179
|
status: z.ZodEnum<{
|
|
2180
|
+
blocked: "blocked";
|
|
2180
2181
|
success: "success";
|
|
2181
2182
|
fail: "fail";
|
|
2182
|
-
blocked: "blocked";
|
|
2183
2183
|
"ship-with-followups": "ship-with-followups";
|
|
2184
2184
|
}>;
|
|
2185
2185
|
verdict: z.ZodOptional<z.ZodString>;
|
|
@@ -2939,8 +2939,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
2939
2939
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
2940
2940
|
}, z.core.$strict>>;
|
|
2941
2941
|
stage: z.ZodEnum<{
|
|
2942
|
-
interior: "interior";
|
|
2943
2942
|
gate: "gate";
|
|
2943
|
+
interior: "interior";
|
|
2944
2944
|
}>;
|
|
2945
2945
|
usage: z.ZodOptional<z.ZodObject<{
|
|
2946
2946
|
estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
@@ -2992,8 +2992,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
2992
2992
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
2993
2993
|
}, z.core.$strict>>;
|
|
2994
2994
|
stage: z.ZodEnum<{
|
|
2995
|
-
interior: "interior";
|
|
2996
2995
|
gate: "gate";
|
|
2996
|
+
interior: "interior";
|
|
2997
2997
|
}>;
|
|
2998
2998
|
usage: z.ZodOptional<z.ZodObject<{
|
|
2999
2999
|
estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
@@ -3036,8 +3036,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3036
3036
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
3037
3037
|
}, z.core.$strict>>;
|
|
3038
3038
|
stage: z.ZodEnum<{
|
|
3039
|
-
interior: "interior";
|
|
3040
3039
|
gate: "gate";
|
|
3040
|
+
interior: "interior";
|
|
3041
3041
|
}>;
|
|
3042
3042
|
usage: z.ZodOptional<z.ZodObject<{
|
|
3043
3043
|
estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
@@ -3067,7 +3067,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3067
3067
|
staleRepeats: number;
|
|
3068
3068
|
};
|
|
3069
3069
|
observedAt: string;
|
|
3070
|
-
stage: "
|
|
3070
|
+
stage: "gate" | "interior";
|
|
3071
3071
|
issue?: number | undefined;
|
|
3072
3072
|
pr?: number | undefined;
|
|
3073
3073
|
threadId?: string | undefined;
|
|
@@ -4066,13 +4066,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
|
|
|
4066
4066
|
reviews: {
|
|
4067
4067
|
correctness: {
|
|
4068
4068
|
required: boolean;
|
|
4069
|
-
status: "
|
|
4069
|
+
status: "blocked" | "not-required" | "stale" | "current" | "missing";
|
|
4070
4070
|
reviewedHeadSha?: string | undefined;
|
|
4071
4071
|
reviewedPatchId?: string | undefined;
|
|
4072
4072
|
};
|
|
4073
4073
|
security?: {
|
|
4074
4074
|
required: boolean;
|
|
4075
|
-
status: "
|
|
4075
|
+
status: "blocked" | "not-required" | "stale" | "current" | "missing";
|
|
4076
4076
|
reviewedHeadSha?: string | undefined;
|
|
4077
4077
|
reviewedPatchId?: string | undefined;
|
|
4078
4078
|
} | undefined;
|
|
@@ -4080,7 +4080,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
|
|
|
4080
4080
|
schemaVersion: 1;
|
|
4081
4081
|
verification: {
|
|
4082
4082
|
command: "patronage-factory pr:verify";
|
|
4083
|
-
prVerify: "
|
|
4083
|
+
prVerify: "stale" | "passed" | "missing";
|
|
4084
4084
|
docsOnlyDeltaAccepted?: boolean | undefined;
|
|
4085
4085
|
docsOnlyVerifiedHeadSha?: string | undefined;
|
|
4086
4086
|
trivialDeltaAccepted?: boolean | undefined;
|
|
@@ -4123,7 +4123,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
|
|
|
4123
4123
|
interior: number;
|
|
4124
4124
|
};
|
|
4125
4125
|
nextAction: "run-gate-cycle" | "accept-nonblocking-findings" | "escalate-to-triage" | "ready-for-human" | "advance-to-gate" | "run-interior-cycle";
|
|
4126
|
-
stage: "
|
|
4126
|
+
stage: "gate" | "interior" | "interior-complete";
|
|
4127
4127
|
forcedTransition?: "gate-cap-exhausted" | "interior-cap-reached" | undefined;
|
|
4128
4128
|
} | undefined;
|
|
4129
4129
|
reviewRuns?: PrReviewResult[] | undefined;
|
package/dist/index.js
CHANGED
|
@@ -8,16 +8,16 @@ import crypto, { createHash, randomUUID } from "node:crypto";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
11
|
-
import os, { homedir } from "node:os";
|
|
11
|
+
import os, { homedir, tmpdir } from "node:os";
|
|
12
12
|
import { setImmediate } from "node:timers";
|
|
13
13
|
import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
14
14
|
import { Worker } from "node:worker_threads";
|
|
15
15
|
import picomatch from "picomatch";
|
|
16
16
|
import { parse } from "yaml";
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
|
-
import { GitHubApiError, mintInstallationToken } from "@patronage/factory-ci";
|
|
18
|
+
import { FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, readVitestProfileDocument } from "@patronage/factory-ci";
|
|
19
19
|
//#region package.json
|
|
20
|
-
var version = "1.0.0-alpha.
|
|
20
|
+
var version = "1.0.0-alpha.8";
|
|
21
21
|
//#endregion
|
|
22
22
|
//#region src/review-rungs.ts
|
|
23
23
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -1082,7 +1082,7 @@ const factoryProjectProfileSchema = z.object({
|
|
|
1082
1082
|
mappedChecks.add(command.requiredCheck);
|
|
1083
1083
|
}
|
|
1084
1084
|
}).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
|
|
1085
|
-
const isRecord$
|
|
1085
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1086
1086
|
const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1087
1087
|
const quoteList = (values) => JSON.stringify(values);
|
|
1088
1088
|
const namesExistingFile = (root, entry) => {
|
|
@@ -1131,13 +1131,13 @@ const migratedPolicyLines = (policy, root) => {
|
|
|
1131
1131
|
*/
|
|
1132
1132
|
function v2MigrationLines(input, root) {
|
|
1133
1133
|
const lines = [];
|
|
1134
|
-
const proof = isRecord$
|
|
1135
|
-
if (proof && isRecord$
|
|
1136
|
-
const commands = isRecord$
|
|
1134
|
+
const proof = isRecord$2(input.proof) ? input.proof : void 0;
|
|
1135
|
+
if (proof && isRecord$2(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
|
|
1136
|
+
const commands = isRecord$2(input.verification) ? input.verification.commands : void 0;
|
|
1137
1137
|
if (Array.isArray(commands)) {
|
|
1138
|
-
for (const [index, command] of commands.entries()) if (isRecord$
|
|
1138
|
+
for (const [index, command] of commands.entries()) if (isRecord$2(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
|
|
1139
1139
|
}
|
|
1140
|
-
if (isRecord$
|
|
1140
|
+
if (isRecord$2(input.hq) && typeof input.hq.endpoint === "string") try {
|
|
1141
1141
|
const url = new URL(input.hq.endpoint);
|
|
1142
1142
|
if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
|
|
1143
1143
|
} catch {}
|
|
@@ -1149,25 +1149,25 @@ function hasCorrectnessOnlyReview(review) {
|
|
|
1149
1149
|
}
|
|
1150
1150
|
const v4MigrationLines = (input) => {
|
|
1151
1151
|
const lines = [];
|
|
1152
|
-
if (isRecord$
|
|
1153
|
-
if (isRecord$
|
|
1154
|
-
if (isRecord$
|
|
1155
|
-
if (isRecord$
|
|
1152
|
+
if (isRecord$2(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
|
|
1153
|
+
if (isRecord$2(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
|
|
1154
|
+
if (isRecord$2(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
|
|
1155
|
+
if (isRecord$2(input.review)) {
|
|
1156
1156
|
const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
|
|
1157
1157
|
if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
|
|
1158
1158
|
if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
|
|
1159
1159
|
if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
|
|
1160
1160
|
if (Array.isArray(input.review.conditional)) {
|
|
1161
|
-
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$
|
|
1161
|
+
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$2(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
|
|
1162
1162
|
}
|
|
1163
1163
|
}
|
|
1164
1164
|
if (Array.isArray(input.requiredChecks)) {
|
|
1165
|
-
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$
|
|
1165
|
+
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$2(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
|
|
1166
1166
|
}
|
|
1167
1167
|
return lines;
|
|
1168
1168
|
};
|
|
1169
1169
|
function legacyProfileMigration(input, root) {
|
|
1170
|
-
if (!(isRecord$
|
|
1170
|
+
if (!(isRecord$2(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
|
|
1171
1171
|
const foundVersion = input.schemaVersion;
|
|
1172
1172
|
const lines = [`schemaVersion ${foundVersion} -> 5`];
|
|
1173
1173
|
if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
|
|
@@ -3690,17 +3690,17 @@ const pnpmLockfileSchema = z.looseObject({
|
|
|
3690
3690
|
packages: z.record(z.string(), z.unknown()).optional(),
|
|
3691
3691
|
snapshots: z.record(z.string(), snapshotSectionSchema).optional()
|
|
3692
3692
|
});
|
|
3693
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3693
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3694
3694
|
const canonical = (value) => {
|
|
3695
3695
|
if (Array.isArray(value)) return value.map(canonical);
|
|
3696
|
-
if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3696
|
+
if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3697
3697
|
return value;
|
|
3698
3698
|
};
|
|
3699
3699
|
const stableStringify$1 = (value) => JSON.stringify(canonical(value));
|
|
3700
3700
|
const parseLockfile = (content) => {
|
|
3701
3701
|
try {
|
|
3702
3702
|
const parsed = parse(content);
|
|
3703
|
-
if (!isRecord(parsed)) return;
|
|
3703
|
+
if (!isRecord$1(parsed)) return;
|
|
3704
3704
|
const validated = pnpmLockfileSchema.safeParse(parsed);
|
|
3705
3705
|
return validated.success ? validated.data : void 0;
|
|
3706
3706
|
} catch {
|
|
@@ -8634,6 +8634,709 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8634
8634
|
if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
|
|
8635
8635
|
})));
|
|
8636
8636
|
}
|
|
8637
|
+
/** `tool` discriminator every emitted report carries. */
|
|
8638
|
+
const CI_ANALYZE_TOOL = "psf-ci-analyze";
|
|
8639
|
+
/** Cohort label for runs the caller did not assign to a cohort. */
|
|
8640
|
+
const UNGROUPED_COHORT = "ungrouped";
|
|
8641
|
+
const available = (value) => ({
|
|
8642
|
+
available: true,
|
|
8643
|
+
value
|
|
8644
|
+
});
|
|
8645
|
+
const unavailable = (reason) => ({
|
|
8646
|
+
available: false,
|
|
8647
|
+
reason
|
|
8648
|
+
});
|
|
8649
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8650
|
+
const stringField$1 = (source, key, where) => {
|
|
8651
|
+
const value = source[key];
|
|
8652
|
+
return typeof value === "string" && value.length > 0 ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8653
|
+
};
|
|
8654
|
+
const numberField = (source, key, where) => {
|
|
8655
|
+
const value = source[key];
|
|
8656
|
+
return typeof value === "number" && Number.isFinite(value) ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8657
|
+
};
|
|
8658
|
+
/**
|
|
8659
|
+
* An instant is only usable when it names its own timezone. `Date.parse`
|
|
8660
|
+
* interprets an offset-less `2026-08-06T12:00:00` in the *host's* zone, which
|
|
8661
|
+
* would make a duration depend on where the analyzer ran. The GitHub API
|
|
8662
|
+
* always sends `Z`-form, so requiring an explicit `Z` or numeric offset
|
|
8663
|
+
* rejects nothing genuine — anything else becomes explicitly unavailable.
|
|
8664
|
+
*/
|
|
8665
|
+
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/u;
|
|
8666
|
+
const parseInstantMs = (value) => {
|
|
8667
|
+
if (!value.available) return;
|
|
8668
|
+
if (!EXPLICIT_OFFSET_PATTERN.test(value.value)) return;
|
|
8669
|
+
const parsed = Date.parse(value.value);
|
|
8670
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
8671
|
+
};
|
|
8672
|
+
/**
|
|
8673
|
+
* Wall clock between two ISO instants. Unavailable — with the missing side
|
|
8674
|
+
* named — whenever either instant is absent or unparsable; a negative span is
|
|
8675
|
+
* refused rather than clamped to a fake zero.
|
|
8676
|
+
*/
|
|
8677
|
+
const durationBetween = (start, end, label) => {
|
|
8678
|
+
const startMs = parseInstantMs(start);
|
|
8679
|
+
if (startMs === void 0) return unavailable(`${label}: start instant is ${start.available ? "unparsable" : `unavailable (${start.reason})`}`);
|
|
8680
|
+
const endMs = parseInstantMs(end);
|
|
8681
|
+
if (endMs === void 0) return unavailable(`${label}: end instant is ${end.available ? "unparsable" : `unavailable (${end.reason})`}`);
|
|
8682
|
+
if (endMs < startMs) return unavailable(`${label}: end instant precedes start instant`);
|
|
8683
|
+
return available(endMs - startMs);
|
|
8684
|
+
};
|
|
8685
|
+
const GITHUB_SETUP_STEP_NAME = "Set up job";
|
|
8686
|
+
const INSTALL_STEP_PATTERN = /install/iu;
|
|
8687
|
+
const analyzeStep = (step) => {
|
|
8688
|
+
const startedAt = stringField$1(step, "started_at", "step");
|
|
8689
|
+
const completedAt = stringField$1(step, "completed_at", "step");
|
|
8690
|
+
return {
|
|
8691
|
+
completedAt,
|
|
8692
|
+
conclusion: stringField$1(step, "conclusion", "step"),
|
|
8693
|
+
durationMs: durationBetween(startedAt, completedAt, "step duration"),
|
|
8694
|
+
name: typeof step.name === "string" ? step.name : "(unnamed step)",
|
|
8695
|
+
number: numberField(step, "number", "step"),
|
|
8696
|
+
startedAt
|
|
8697
|
+
};
|
|
8698
|
+
};
|
|
8699
|
+
const namedStepDuration = (steps, matches, description) => {
|
|
8700
|
+
const step = steps.find((candidate) => matches(candidate.name));
|
|
8701
|
+
return step === void 0 ? unavailable(`no ${description} step in this job`) : step.durationMs;
|
|
8702
|
+
};
|
|
8703
|
+
const analyzeJob = (job) => {
|
|
8704
|
+
const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord).map(analyzeStep) : [];
|
|
8705
|
+
const createdAt = stringField$1(job, "created_at", "job");
|
|
8706
|
+
const startedAt = stringField$1(job, "started_at", "job");
|
|
8707
|
+
const completedAt = stringField$1(job, "completed_at", "job");
|
|
8708
|
+
const labels = Array.isArray(job.labels) ? job.labels.filter((label) => typeof label === "string") : void 0;
|
|
8709
|
+
return {
|
|
8710
|
+
completedAt,
|
|
8711
|
+
conclusion: stringField$1(job, "conclusion", "job"),
|
|
8712
|
+
createdAt,
|
|
8713
|
+
installMs: namedStepDuration(steps, (name) => INSTALL_STEP_PATTERN.test(name), "install"),
|
|
8714
|
+
jobId: numberField(job, "id", "job"),
|
|
8715
|
+
name: typeof job.name === "string" ? job.name : "(unnamed job)",
|
|
8716
|
+
queueMs: durationBetween(createdAt, startedAt, "job queue"),
|
|
8717
|
+
runnerLabels: labels === void 0 ? unavailable("job has no labels array") : available(labels),
|
|
8718
|
+
setupMs: namedStepDuration(steps, (name) => name === GITHUB_SETUP_STEP_NAME, `"${GITHUB_SETUP_STEP_NAME}"`),
|
|
8719
|
+
startedAt,
|
|
8720
|
+
steps,
|
|
8721
|
+
totalMs: durationBetween(startedAt, completedAt, "job duration"),
|
|
8722
|
+
url: stringField$1(job, "html_url", "job")
|
|
8723
|
+
};
|
|
8724
|
+
};
|
|
8725
|
+
/**
|
|
8726
|
+
* Classify proof reuse from step conclusions alone. A job whose gate step is
|
|
8727
|
+
* followed by at least one skipped step reused proof; a job whose post-gate
|
|
8728
|
+
* steps all executed ran the full fallback. No gate step anywhere means the
|
|
8729
|
+
* workflow does not expose the outcome, which is unavailable — not
|
|
8730
|
+
* full-fallback.
|
|
8731
|
+
*/
|
|
8732
|
+
const classifyProofReuse = (jobs) => {
|
|
8733
|
+
const gateJobs = [];
|
|
8734
|
+
const verdicts = /* @__PURE__ */ new Set();
|
|
8735
|
+
for (const job of jobs) {
|
|
8736
|
+
const gateIndex = job.steps.findIndex((step) => step.name === FACTORY_PROOF_GATE_STEP_NAME);
|
|
8737
|
+
if (gateIndex === -1) continue;
|
|
8738
|
+
const skippedStepsAfterGate = job.steps.slice(gateIndex + 1).filter((step) => step.conclusion.available && step.conclusion.value === "skipped").length;
|
|
8739
|
+
verdicts.add(skippedStepsAfterGate > 0 ? "proof-reuse" : "full-fallback");
|
|
8740
|
+
gateJobs.push({
|
|
8741
|
+
gateConclusion: job.steps[gateIndex]?.conclusion ?? unavailable("gate step vanished"),
|
|
8742
|
+
jobName: job.name,
|
|
8743
|
+
skippedStepsAfterGate
|
|
8744
|
+
});
|
|
8745
|
+
}
|
|
8746
|
+
if (gateJobs.length === 0) return unavailable(`no "${FACTORY_PROOF_GATE_STEP_NAME}" step in any job; this workflow does not expose a proof-reuse outcome`);
|
|
8747
|
+
const [single] = [...verdicts];
|
|
8748
|
+
return available({
|
|
8749
|
+
classification: verdicts.size === 1 && single ? single : "mixed",
|
|
8750
|
+
gateJobs
|
|
8751
|
+
});
|
|
8752
|
+
};
|
|
8753
|
+
const parseArtifactListing = (value) => {
|
|
8754
|
+
if (!isRecord(value) || !Array.isArray(value.artifacts)) return {
|
|
8755
|
+
artifacts: [],
|
|
8756
|
+
totalCount: void 0
|
|
8757
|
+
};
|
|
8758
|
+
return {
|
|
8759
|
+
artifacts: value.artifacts.filter(isRecord).flatMap((artifact) => {
|
|
8760
|
+
const { id, name } = artifact;
|
|
8761
|
+
return typeof id === "number" && typeof name === "string" ? [{
|
|
8762
|
+
expired: artifact.expired === true,
|
|
8763
|
+
id,
|
|
8764
|
+
name
|
|
8765
|
+
}] : [];
|
|
8766
|
+
}),
|
|
8767
|
+
totalCount: typeof value.total_count === "number" ? value.total_count : void 0
|
|
8768
|
+
};
|
|
8769
|
+
};
|
|
8770
|
+
const profileFromDocument = (artifactName, value) => {
|
|
8771
|
+
const read = readVitestProfileDocument(value);
|
|
8772
|
+
if (read.kind === "unrecognized") return {
|
|
8773
|
+
artifactName,
|
|
8774
|
+
kind: "unreadable",
|
|
8775
|
+
reason: read.reason
|
|
8776
|
+
};
|
|
8777
|
+
const { profile } = read;
|
|
8778
|
+
const testCounts = profile.runs.flatMap((run) => run.counts === null ? [] : [run.counts.tests]);
|
|
8779
|
+
return {
|
|
8780
|
+
artifactName,
|
|
8781
|
+
kind: "profile",
|
|
8782
|
+
profile: {
|
|
8783
|
+
cpuCount: profile.environment.cpuCount,
|
|
8784
|
+
cpuModel: profile.environment.cpuModel,
|
|
8785
|
+
maxWorkers: profile.options.maxWorkers,
|
|
8786
|
+
sampleDurationsMs: profile.runs.map((run) => run.durationMs),
|
|
8787
|
+
scope: profile.command.join(" "),
|
|
8788
|
+
slowFiles: profile.summary.slowFiles.map((file) => ({
|
|
8789
|
+
medianMs: file.durationMs.median,
|
|
8790
|
+
path: file.path
|
|
8791
|
+
})),
|
|
8792
|
+
slowTests: profile.summary.slowTests.map((test) => ({
|
|
8793
|
+
file: test.file,
|
|
8794
|
+
medianMs: test.durationMs.median,
|
|
8795
|
+
name: test.name
|
|
8796
|
+
})),
|
|
8797
|
+
summaryDurationMs: profile.summary.durationMs,
|
|
8798
|
+
testCount: testCounts.length === 0 ? unavailable("no sample in this profile carries test counts") : available(Math.max(...testCounts))
|
|
8799
|
+
}
|
|
8800
|
+
};
|
|
8801
|
+
};
|
|
8802
|
+
const collectProfiles = async (client, runId) => {
|
|
8803
|
+
let listing;
|
|
8804
|
+
try {
|
|
8805
|
+
listing = parseArtifactListing(await client.listArtifacts(runId));
|
|
8806
|
+
} catch (error) {
|
|
8807
|
+
return unavailable(`artifact listing failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8808
|
+
}
|
|
8809
|
+
const { artifacts } = listing;
|
|
8810
|
+
if (listing.totalCount !== void 0 && listing.totalCount > artifacts.length) return unavailable(`artifact listing is truncated: ${artifacts.length} of ${listing.totalCount} artifacts served`);
|
|
8811
|
+
if (artifacts.length === 0) return unavailable("this run has no artifacts");
|
|
8812
|
+
const analyses = [];
|
|
8813
|
+
for (const artifact of artifacts) {
|
|
8814
|
+
if (artifact.expired) {
|
|
8815
|
+
analyses.push({
|
|
8816
|
+
artifactName: artifact.name,
|
|
8817
|
+
kind: "unreadable",
|
|
8818
|
+
reason: "the artifact is expired and can no longer be downloaded"
|
|
8819
|
+
});
|
|
8820
|
+
continue;
|
|
8821
|
+
}
|
|
8822
|
+
let files;
|
|
8823
|
+
try {
|
|
8824
|
+
files = await client.readArtifactTextFiles(artifact.id);
|
|
8825
|
+
} catch (error) {
|
|
8826
|
+
analyses.push({
|
|
8827
|
+
artifactName: artifact.name,
|
|
8828
|
+
kind: "unreadable",
|
|
8829
|
+
reason: `artifact download failed: ${error instanceof Error ? error.message : String(error)}`
|
|
8830
|
+
});
|
|
8831
|
+
continue;
|
|
8832
|
+
}
|
|
8833
|
+
for (const file of files) {
|
|
8834
|
+
let parsed;
|
|
8835
|
+
try {
|
|
8836
|
+
parsed = JSON.parse(file.text);
|
|
8837
|
+
} catch {
|
|
8838
|
+
analyses.push({
|
|
8839
|
+
artifactName: `${artifact.name}/${file.name}`,
|
|
8840
|
+
kind: "unreadable",
|
|
8841
|
+
reason: "the file is not valid JSON"
|
|
8842
|
+
});
|
|
8843
|
+
continue;
|
|
8844
|
+
}
|
|
8845
|
+
const analysis = profileFromDocument(`${artifact.name}/${file.name}`, parsed);
|
|
8846
|
+
if (analysis.kind === "profile" || isRecord(parsed) && parsed.tool === "factory-ci-vitest-profile") analyses.push(analysis);
|
|
8847
|
+
}
|
|
8848
|
+
}
|
|
8849
|
+
return analyses.length === 0 ? unavailable("no compatible factory-ci Vitest profile artifacts on this run") : available(analyses);
|
|
8850
|
+
};
|
|
8851
|
+
const earliest = (instants) => instants.length === 0 ? void 0 : Math.min(...instants);
|
|
8852
|
+
const latest = (instants) => instants.length === 0 ? void 0 : Math.max(...instants);
|
|
8853
|
+
const runQueueDuration = (createdAt, jobs) => {
|
|
8854
|
+
const createdMs = parseInstantMs(createdAt);
|
|
8855
|
+
if (createdMs === void 0) return unavailable("the run object has no parsable created_at");
|
|
8856
|
+
const firstStart = earliest(jobs.flatMap((job) => {
|
|
8857
|
+
const started = parseInstantMs(job.startedAt);
|
|
8858
|
+
return started === void 0 ? [] : [started];
|
|
8859
|
+
}));
|
|
8860
|
+
if (firstStart === void 0) return unavailable("no job carries a parsable started_at");
|
|
8861
|
+
return firstStart < createdMs ? unavailable("the earliest job start precedes the run creation instant") : available(firstStart - createdMs);
|
|
8862
|
+
};
|
|
8863
|
+
const runCompletionInstant = (jobs) => {
|
|
8864
|
+
const completions = jobs.flatMap((job) => {
|
|
8865
|
+
const completed = parseInstantMs(job.completedAt);
|
|
8866
|
+
return completed === void 0 ? [] : [{
|
|
8867
|
+
completed,
|
|
8868
|
+
iso: job.completedAt
|
|
8869
|
+
}];
|
|
8870
|
+
});
|
|
8871
|
+
const last = latest(completions.map((entry) => entry.completed));
|
|
8872
|
+
const match = completions.find((entry) => entry.completed === last);
|
|
8873
|
+
return match?.iso.available === true ? available(match.iso.value) : unavailable("no job carries a parsable completed_at");
|
|
8874
|
+
};
|
|
8875
|
+
/**
|
|
8876
|
+
* `cpuModel` never comes from the Actions API — only readable profile
|
|
8877
|
+
* artifacts carry silicon attribution, so both absence cases say so.
|
|
8878
|
+
*/
|
|
8879
|
+
const cpuModelsField = (cpuModels, profiles) => {
|
|
8880
|
+
if (cpuModels === void 0) return unavailable(`CPU models come only from profile artifacts (${profiles.available ? "none usable" : profiles.reason}); the Actions API does not expose silicon`);
|
|
8881
|
+
if (cpuModels.length === 0) return unavailable("no readable profile artifact recorded a CPU model");
|
|
8882
|
+
return available(cpuModels);
|
|
8883
|
+
};
|
|
8884
|
+
const analyzeOneRun = async (client, request, entry) => {
|
|
8885
|
+
const cohort = entry.cohort ?? "ungrouped";
|
|
8886
|
+
let runValue;
|
|
8887
|
+
let jobsValue;
|
|
8888
|
+
try {
|
|
8889
|
+
runValue = await client.getRun(entry.runId);
|
|
8890
|
+
jobsValue = await client.listJobs(entry.runId);
|
|
8891
|
+
} catch (error) {
|
|
8892
|
+
return {
|
|
8893
|
+
cohort,
|
|
8894
|
+
kind: "unreadable",
|
|
8895
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
8896
|
+
repository: request.repository,
|
|
8897
|
+
runId: entry.runId
|
|
8898
|
+
};
|
|
8899
|
+
}
|
|
8900
|
+
if (!isRecord(runValue)) return {
|
|
8901
|
+
cohort,
|
|
8902
|
+
kind: "unreadable",
|
|
8903
|
+
reason: "the run response is not an object",
|
|
8904
|
+
repository: request.repository,
|
|
8905
|
+
runId: entry.runId
|
|
8906
|
+
};
|
|
8907
|
+
const jobRows = isRecord(jobsValue) && Array.isArray(jobsValue.jobs) ? jobsValue.jobs.filter(isRecord) : [];
|
|
8908
|
+
if (isRecord(jobsValue) && typeof jobsValue.total_count === "number" && jobsValue.total_count > jobRows.length) return {
|
|
8909
|
+
cohort,
|
|
8910
|
+
kind: "unreadable",
|
|
8911
|
+
reason: `jobs listing is truncated: ${jobRows.length} of ${jobsValue.total_count} jobs served`,
|
|
8912
|
+
repository: request.repository,
|
|
8913
|
+
runId: entry.runId
|
|
8914
|
+
};
|
|
8915
|
+
const jobs = jobRows.map(analyzeJob);
|
|
8916
|
+
const createdAt = stringField$1(runValue, "created_at", "run");
|
|
8917
|
+
const startedAt = stringField$1(runValue, "run_started_at", "run");
|
|
8918
|
+
const completedAt = runCompletionInstant(jobs);
|
|
8919
|
+
const profiles = request.includeProfiles ? await collectProfiles(client, entry.runId) : unavailable("profile artifacts were not requested (pass --profiles)");
|
|
8920
|
+
const cpuModels = profiles.available ? [...new Set(profiles.value.flatMap((profile) => profile.kind === "profile" && profile.profile.cpuModel !== null ? [profile.profile.cpuModel] : []))] : void 0;
|
|
8921
|
+
const labels = [...new Set(jobs.flatMap((job) => job.runnerLabels.available ? job.runnerLabels.value : []))];
|
|
8922
|
+
return {
|
|
8923
|
+
cohort,
|
|
8924
|
+
completedAt,
|
|
8925
|
+
conclusion: stringField$1(runValue, "conclusion", "run"),
|
|
8926
|
+
cpuModels: cpuModelsField(cpuModels, profiles),
|
|
8927
|
+
createdAt,
|
|
8928
|
+
durations: {
|
|
8929
|
+
queueMs: runQueueDuration(createdAt, jobs),
|
|
8930
|
+
totalMs: durationBetween(startedAt, completedAt, "run duration")
|
|
8931
|
+
},
|
|
8932
|
+
event: stringField$1(runValue, "event", "run"),
|
|
8933
|
+
headSha: stringField$1(runValue, "head_sha", "run"),
|
|
8934
|
+
jobs,
|
|
8935
|
+
kind: "analyzed",
|
|
8936
|
+
profiles,
|
|
8937
|
+
proofReuse: classifyProofReuse(jobs),
|
|
8938
|
+
repository: request.repository,
|
|
8939
|
+
runId: entry.runId,
|
|
8940
|
+
runnerLabels: labels.length === 0 ? unavailable("no job carries runner labels") : available(labels),
|
|
8941
|
+
startedAt,
|
|
8942
|
+
url: stringField$1(runValue, "html_url", "run"),
|
|
8943
|
+
workflowName: stringField$1(runValue, "name", "run")
|
|
8944
|
+
};
|
|
8945
|
+
};
|
|
8946
|
+
/**
|
|
8947
|
+
* Quantile by the floor-index convention: over `sorted` (ascending), the
|
|
8948
|
+
* q-quantile is the element at index `floor(q * (n - 1))`. No interpolation —
|
|
8949
|
+
* every reported quantile is a duration that actually happened, and the
|
|
8950
|
+
* convention is stable for the small sample counts (3–10 runs) this command
|
|
8951
|
+
* exists for. median = q(0.5), p25 = q(0.25), p75 = q(0.75).
|
|
8952
|
+
*/
|
|
8953
|
+
const quantileFloorIndex = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))] ?? NaN;
|
|
8954
|
+
const quantileSummary = (values) => {
|
|
8955
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
8956
|
+
return {
|
|
8957
|
+
count: sorted.length,
|
|
8958
|
+
maximum: sorted.at(-1) ?? NaN,
|
|
8959
|
+
median: quantileFloorIndex(sorted, .5),
|
|
8960
|
+
minimum: sorted[0] ?? NaN,
|
|
8961
|
+
p25: quantileFloorIndex(sorted, .25),
|
|
8962
|
+
p75: quantileFloorIndex(sorted, .75)
|
|
8963
|
+
};
|
|
8964
|
+
};
|
|
8965
|
+
const classificationOf = (run) => run.proofReuse.available ? run.proofReuse.value.classification : "unclassified";
|
|
8966
|
+
const aggregateCohorts = (runs) => {
|
|
8967
|
+
const cells = /* @__PURE__ */ new Map();
|
|
8968
|
+
for (const run of runs) {
|
|
8969
|
+
if (run.kind !== "analyzed") continue;
|
|
8970
|
+
const key = `${run.cohort}\0${classificationOf(run)}`;
|
|
8971
|
+
const cell = cells.get(key) ?? [];
|
|
8972
|
+
cell.push(run);
|
|
8973
|
+
cells.set(key, cell);
|
|
8974
|
+
}
|
|
8975
|
+
return [...cells.entries()].map(([key, cellRuns]) => {
|
|
8976
|
+
const [cohort = UNGROUPED_COHORT] = key.split("\0");
|
|
8977
|
+
const totals = cellRuns.flatMap((run) => run.durations.totalMs.available ? [run.durations.totalMs.value] : []);
|
|
8978
|
+
const excludedRunIds = cellRuns.filter((run) => !run.durations.totalMs.available).map((run) => run.runId);
|
|
8979
|
+
return {
|
|
8980
|
+
classification: classificationOf(cellRuns[0]),
|
|
8981
|
+
cohort,
|
|
8982
|
+
count: cellRuns.length,
|
|
8983
|
+
excludedRunIds,
|
|
8984
|
+
runIds: cellRuns.map((run) => run.runId),
|
|
8985
|
+
totalDurationMs: totals.length === 0 ? unavailable("no run in this cell has an available total duration") : available(quantileSummary(totals))
|
|
8986
|
+
};
|
|
8987
|
+
}).toSorted((left, right) => `${left.cohort}\0${left.classification}`.localeCompare(`${right.cohort}\0${right.classification}`));
|
|
8988
|
+
};
|
|
8989
|
+
const cohortWarnings = (runs) => {
|
|
8990
|
+
const warnings = [];
|
|
8991
|
+
const byCohort = /* @__PURE__ */ new Map();
|
|
8992
|
+
for (const run of runs) {
|
|
8993
|
+
if (run.kind !== "analyzed") continue;
|
|
8994
|
+
const cohort = byCohort.get(run.cohort) ?? [];
|
|
8995
|
+
cohort.push(run);
|
|
8996
|
+
byCohort.set(run.cohort, cohort);
|
|
8997
|
+
}
|
|
8998
|
+
for (const [cohort, cohortRuns] of [...byCohort.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
8999
|
+
const workflows = [...new Set(cohortRuns.flatMap((run) => run.workflowName.available ? [run.workflowName.value] : []))].toSorted();
|
|
9000
|
+
if (workflows.length > 1) warnings.push(`cohort "${cohort}" spans workflow shapes: ${workflows.join(", ")} — durations across different workflows are not comparable`);
|
|
9001
|
+
const models = [...new Set(cohortRuns.flatMap((run) => run.cpuModels.available ? run.cpuModels.value : []))].toSorted();
|
|
9002
|
+
if (models.length > 1) warnings.push(`cohort "${cohort}" spans CPU models: ${models.join(", ")} — stratify by cpuModel before comparing (see the CI runner analysis docs)`);
|
|
9003
|
+
}
|
|
9004
|
+
return warnings;
|
|
9005
|
+
};
|
|
9006
|
+
/**
|
|
9007
|
+
* Analyze the requested runs. Read-only: the client is only ever asked to GET.
|
|
9008
|
+
* One unreadable run never aborts the report — it is carried as an explicit
|
|
9009
|
+
* `unreadable` entry so a partially-served API answer stays auditable.
|
|
9010
|
+
*/
|
|
9011
|
+
const analyzeCiRuns = async (request, client, dependencies = {}) => {
|
|
9012
|
+
if (request.runs.length === 0) throw new Error("ci:analyze requires at least one run id (--run, --runs-file, or --cohort).");
|
|
9013
|
+
const runs = [];
|
|
9014
|
+
for (const entry of request.runs) runs.push(await analyzeOneRun(client, request, entry));
|
|
9015
|
+
return {
|
|
9016
|
+
cohorts: aggregateCohorts(runs),
|
|
9017
|
+
generatedAt: (dependencies.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
9018
|
+
repository: request.repository,
|
|
9019
|
+
runs,
|
|
9020
|
+
schemaVersion: 1,
|
|
9021
|
+
tool: CI_ANALYZE_TOOL,
|
|
9022
|
+
warnings: cohortWarnings(runs)
|
|
9023
|
+
};
|
|
9024
|
+
};
|
|
9025
|
+
const formatSeconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
9026
|
+
const formatMaybeDuration = (value) => value.available ? formatSeconds(value.value) : `unavailable (${value.reason})`;
|
|
9027
|
+
const CLASSIFICATION_HEADINGS = {
|
|
9028
|
+
"full-fallback": "full hosted fallback",
|
|
9029
|
+
mixed: "mixed proof outcomes",
|
|
9030
|
+
"proof-reuse": "proof reuse",
|
|
9031
|
+
unclassified: "no proof-gate signal"
|
|
9032
|
+
};
|
|
9033
|
+
const renderRunLine = (run) => {
|
|
9034
|
+
const lines = [
|
|
9035
|
+
` run ${run.runId} — ${run.workflowName.available ? run.workflowName.value : "unknown workflow"} (${run.conclusion.available ? run.conclusion.value : "no conclusion"})`,
|
|
9036
|
+
` total ${formatMaybeDuration(run.durations.totalMs)}, queue ${formatMaybeDuration(run.durations.queueMs)}`,
|
|
9037
|
+
` ${run.url.available ? run.url.value : "run URL unavailable"}`
|
|
9038
|
+
];
|
|
9039
|
+
if (run.cpuModels.available) lines.push(` cpu: ${run.cpuModels.value.join(", ")}`);
|
|
9040
|
+
if (run.profiles.available) for (const profile of run.profiles.value) {
|
|
9041
|
+
if (profile.kind === "unreadable") {
|
|
9042
|
+
lines.push(` profile ${profile.artifactName}: unreadable (${profile.reason})`);
|
|
9043
|
+
continue;
|
|
9044
|
+
}
|
|
9045
|
+
const p = profile.profile;
|
|
9046
|
+
lines.push(` profile ${profile.artifactName}: median ${formatSeconds(p.summaryDurationMs.median)} over ${p.sampleDurationsMs.length} sample(s), ${p.testCount.available ? `${p.testCount.value} tests` : "test count unavailable"}, cpu ${p.cpuModel ?? "unrecorded"}`);
|
|
9047
|
+
const [slowFile] = p.slowFiles;
|
|
9048
|
+
if (slowFile) lines.push(` slowest file ${slowFile.path} (${formatSeconds(slowFile.medianMs)} median)`);
|
|
9049
|
+
const [slowTest] = p.slowTests;
|
|
9050
|
+
if (slowTest) lines.push(` slowest test ${slowTest.name} (${formatSeconds(slowTest.medianMs)} median)`);
|
|
9051
|
+
}
|
|
9052
|
+
return lines;
|
|
9053
|
+
};
|
|
9054
|
+
/**
|
|
9055
|
+
* Human rendering. Structure mirrors the aggregation rule: one section per
|
|
9056
|
+
* cohort × classification cell, so full fallback and proof reuse are never
|
|
9057
|
+
* visually blended, and before/after cohorts stay separate.
|
|
9058
|
+
*/
|
|
9059
|
+
const renderCiAnalyzeReport = (report) => {
|
|
9060
|
+
const lines = [`ci:analyze — ${report.repository} (schema v${report.schemaVersion})`];
|
|
9061
|
+
const analyzed = report.runs.filter((run) => run.kind === "analyzed");
|
|
9062
|
+
for (const cohort of report.cohorts) {
|
|
9063
|
+
lines.push("", `cohort "${cohort.cohort}" — ${CLASSIFICATION_HEADINGS[cohort.classification]} (${cohort.count} run(s))`);
|
|
9064
|
+
if (cohort.totalDurationMs.available) {
|
|
9065
|
+
const stats = cohort.totalDurationMs.value;
|
|
9066
|
+
lines.push(` total duration over ${stats.count} run(s): median ${formatSeconds(stats.median)}, p25 ${formatSeconds(stats.p25)}, p75 ${formatSeconds(stats.p75)}, min ${formatSeconds(stats.minimum)}, max ${formatSeconds(stats.maximum)}`);
|
|
9067
|
+
} else lines.push(` total duration: unavailable (${cohort.totalDurationMs.reason})`);
|
|
9068
|
+
if (cohort.excludedRunIds.length > 0) lines.push(` excluded from aggregates (total unavailable): ${cohort.excludedRunIds.join(", ")}`);
|
|
9069
|
+
for (const run of analyzed.filter((candidate) => candidate.cohort === cohort.cohort && classificationOf(candidate) === cohort.classification)) lines.push(...renderRunLine(run));
|
|
9070
|
+
}
|
|
9071
|
+
const unreadable = report.runs.filter((run) => run.kind === "unreadable");
|
|
9072
|
+
const unreadableCohorts = [...new Set(unreadable.map((run) => run.cohort))].toSorted((left, right) => left.localeCompare(right));
|
|
9073
|
+
for (const cohort of unreadableCohorts) {
|
|
9074
|
+
lines.push("", `cohort "${cohort}" — unreadable runs:`);
|
|
9075
|
+
for (const run of unreadable.filter((candidate) => candidate.cohort === cohort)) lines.push(` run ${run.runId}: ${run.reason}`);
|
|
9076
|
+
}
|
|
9077
|
+
if (report.warnings.length > 0) {
|
|
9078
|
+
lines.push("", "warnings:");
|
|
9079
|
+
for (const warning of report.warnings) lines.push(` - ${warning}`);
|
|
9080
|
+
}
|
|
9081
|
+
return `${lines.join("\n")}\n`;
|
|
9082
|
+
};
|
|
9083
|
+
//#endregion
|
|
9084
|
+
//#region src/commands/ci-analyze.ts
|
|
9085
|
+
/**
|
|
9086
|
+
* `ci:analyze` command wiring (#647).
|
|
9087
|
+
*
|
|
9088
|
+
* The analysis itself lives in `../ci-analyze.js`; this module owns the flag
|
|
9089
|
+
* grammar, the run-id assembly (`--run`, `--runs-file`, `--cohort`), the
|
|
9090
|
+
* report output paths, and the default `gh`-backed {@link CiAnalyzeClient}.
|
|
9091
|
+
* Authentication is entirely the caller's: every request goes through the
|
|
9092
|
+
* `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
|
|
9093
|
+
* no credential is stored or minted here.
|
|
9094
|
+
*/
|
|
9095
|
+
const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
9096
|
+
const parseRepository = (value) => {
|
|
9097
|
+
if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
|
|
9098
|
+
return value;
|
|
9099
|
+
};
|
|
9100
|
+
const parseRunId = (value) => {
|
|
9101
|
+
const parsed = Number(value);
|
|
9102
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`run id must be a positive integer, got "${value}"`);
|
|
9103
|
+
return parsed;
|
|
9104
|
+
};
|
|
9105
|
+
const collectRunOption = (value, previous = []) => [...previous, parseRunId(value)];
|
|
9106
|
+
/**
|
|
9107
|
+
* `--cohort label=id,id` — the same `name=value` grammar the existing psf
|
|
9108
|
+
* flags use for structured values, repeatable once per label.
|
|
9109
|
+
*
|
|
9110
|
+
* The label `ungrouped` is reserved: it is what the report calls runs the
|
|
9111
|
+
* caller left unlabeled, so accepting it as a caller cohort would silently
|
|
9112
|
+
* blend a named population with the genuinely unlabeled one.
|
|
9113
|
+
*/
|
|
9114
|
+
const parseCohortOption = (value, previous = []) => {
|
|
9115
|
+
const separator = value.indexOf("=");
|
|
9116
|
+
const label = separator === -1 ? "" : value.slice(0, separator).trim();
|
|
9117
|
+
const ids = separator === -1 ? "" : value.slice(separator + 1);
|
|
9118
|
+
if (label === "" || ids.trim() === "") throw new Error(`--cohort must be <label>=<runId>[,<runId>...], got "${value}"`);
|
|
9119
|
+
if (label === "ungrouped") throw new Error(`--cohort label "${UNGROUPED_COHORT}" is reserved for runs passed without a cohort; pick another label`);
|
|
9120
|
+
return [...previous, {
|
|
9121
|
+
label,
|
|
9122
|
+
runIds: ids.split(",").map((id) => parseRunId(id.trim()))
|
|
9123
|
+
}];
|
|
9124
|
+
};
|
|
9125
|
+
/** One run id per line; blank lines and `#` comments are ignored. */
|
|
9126
|
+
const parseRunsFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map(parseRunId);
|
|
9127
|
+
/**
|
|
9128
|
+
* Assemble the requested run set. A run id may appear in exactly one cohort;
|
|
9129
|
+
* uncohorted ids (from `--run` / `--runs-file`) stay unlabeled. Conflicting
|
|
9130
|
+
* cohort labels for one id are a hard error rather than a silent pick.
|
|
9131
|
+
*/
|
|
9132
|
+
const assembleRunRequests = (input) => {
|
|
9133
|
+
const cohortByRun = /* @__PURE__ */ new Map();
|
|
9134
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) {
|
|
9135
|
+
const existing = cohortByRun.get(runId);
|
|
9136
|
+
if (existing !== void 0 && existing !== cohort.label) throw new Error(`run ${runId} is assigned to both cohort "${existing}" and cohort "${cohort.label}"`);
|
|
9137
|
+
cohortByRun.set(runId, cohort.label);
|
|
9138
|
+
}
|
|
9139
|
+
const ordered = [];
|
|
9140
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9141
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) if (!seen.has(runId)) {
|
|
9142
|
+
seen.add(runId);
|
|
9143
|
+
ordered.push({
|
|
9144
|
+
cohort: cohort.label,
|
|
9145
|
+
runId
|
|
9146
|
+
});
|
|
9147
|
+
}
|
|
9148
|
+
for (const runId of input.runIds) if (!seen.has(runId)) {
|
|
9149
|
+
seen.add(runId);
|
|
9150
|
+
ordered.push({
|
|
9151
|
+
cohort: cohortByRun.get(runId) ?? null,
|
|
9152
|
+
runId
|
|
9153
|
+
});
|
|
9154
|
+
}
|
|
9155
|
+
return ordered;
|
|
9156
|
+
};
|
|
9157
|
+
const GH_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024;
|
|
9158
|
+
const PAGE_SIZE = 100;
|
|
9159
|
+
const MAX_PAGES = 50;
|
|
9160
|
+
/**
|
|
9161
|
+
* Follow GitHub's pagination for one collection endpoint: request pages until
|
|
9162
|
+
* the served rows reach the response's `total_count` (or a page comes back
|
|
9163
|
+
* empty). The merged document keeps the `total_count` field, so if the page
|
|
9164
|
+
* budget is ever exhausted the analyzer's truncation guard reports the
|
|
9165
|
+
* shortfall as explicit unavailability instead of a silent partial read.
|
|
9166
|
+
*/
|
|
9167
|
+
const listActionsCollection = (basePath, key) => {
|
|
9168
|
+
const rows = [];
|
|
9169
|
+
let totalCount;
|
|
9170
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
9171
|
+
const response = runGhJson(["api", `${basePath}?per_page=${PAGE_SIZE}&page=${page}`]);
|
|
9172
|
+
const served = Array.isArray(response[key]) ? response[key] : [];
|
|
9173
|
+
rows.push(...served);
|
|
9174
|
+
totalCount = typeof response.total_count === "number" ? response.total_count : totalCount;
|
|
9175
|
+
if (served.length === 0 || rows.length >= (totalCount ?? 0)) break;
|
|
9176
|
+
}
|
|
9177
|
+
return {
|
|
9178
|
+
[key]: rows,
|
|
9179
|
+
total_count: totalCount ?? rows.length
|
|
9180
|
+
};
|
|
9181
|
+
};
|
|
9182
|
+
/**
|
|
9183
|
+
* A Unix-host entry line: permissions, zip version, host OS, decompressed
|
|
9184
|
+
* size, text/binary flags, method, date, time, then the member name (which
|
|
9185
|
+
* may itself contain spaces). This is the one form GitHub's artifact service
|
|
9186
|
+
* actually emits; other host forms are deliberately *not* supported — they
|
|
9187
|
+
* fail reconciliation below rather than being half-parsed.
|
|
9188
|
+
*/
|
|
9189
|
+
const ZIP_ENTRY_PATTERN = /^(?<perms>[-dl][a-srwxt-]{9})\s+\d+\.\d+\s+\S+\s+(?<size>\d+)\s+\S\S\s+\S+\s+\S+\s+\S+\s(?<name>.+)$/u;
|
|
9190
|
+
/** `Zip file size: 12109 bytes, number of entries: 2` */
|
|
9191
|
+
const ZIP_HEADER_PATTERN = /^Zip file size: \d+ bytes, number of entries: (?<count>\d+)$/u;
|
|
9192
|
+
/** `2 files, 81410 bytes uncompressed, 11689 bytes compressed: 85.6%` */
|
|
9193
|
+
const ZIP_TRAILER_PATTERN = /^(?<count>\d+) files?, \d+ bytes? uncompressed, \d+ bytes? compressed:\s+-?[\d.]+%$/u;
|
|
9194
|
+
/**
|
|
9195
|
+
* Parse zipinfo's default listing and *reconcile* it against the archive's
|
|
9196
|
+
* own declared entry count — fail closed, never a partial view.
|
|
9197
|
+
*
|
|
9198
|
+
* The security property the caps and member policy rest on is that the parsed
|
|
9199
|
+
* set is the WHOLE central directory. A listing line this parser does not
|
|
9200
|
+
* recognize (a FAT-host form, a future zipinfo change) therefore refuses the
|
|
9201
|
+
* whole artifact rather than being skipped: a skipped line would let
|
|
9202
|
+
* `unzip <name>` extract members the caps never counted. Three refusals:
|
|
9203
|
+
* an unrecognized line, a parsed count that differs from the declared count
|
|
9204
|
+
* (every header and trailer declaration must agree — a listing that
|
|
9205
|
+
* contradicts itself has no unambiguous count and is refused), and any
|
|
9206
|
+
* duplicate member name across the full parsed set — a parsed safe name
|
|
9207
|
+
* shadowed by an unparsed or differently-shaped duplicate is exactly the
|
|
9208
|
+
* bypass this closes.
|
|
9209
|
+
*/
|
|
9210
|
+
const parseZipInfoListing = (text) => {
|
|
9211
|
+
const members = [];
|
|
9212
|
+
const declaredCounts = [];
|
|
9213
|
+
for (const line of text.split("\n")) {
|
|
9214
|
+
if (line.trim() === "" || line.startsWith("Archive:")) continue;
|
|
9215
|
+
const header = ZIP_HEADER_PATTERN.exec(line);
|
|
9216
|
+
if (header?.groups) {
|
|
9217
|
+
declaredCounts.push(Number(header.groups.count));
|
|
9218
|
+
continue;
|
|
9219
|
+
}
|
|
9220
|
+
const trailer = ZIP_TRAILER_PATTERN.exec(line);
|
|
9221
|
+
if (trailer?.groups) {
|
|
9222
|
+
declaredCounts.push(Number(trailer.groups.count));
|
|
9223
|
+
continue;
|
|
9224
|
+
}
|
|
9225
|
+
const entry = ZIP_ENTRY_PATTERN.exec(line);
|
|
9226
|
+
if (entry?.groups) {
|
|
9227
|
+
members.push({
|
|
9228
|
+
isSymlink: entry.groups.perms?.startsWith("l") === true,
|
|
9229
|
+
name: entry.groups.name ?? "",
|
|
9230
|
+
sizeBytes: Number(entry.groups.size)
|
|
9231
|
+
});
|
|
9232
|
+
continue;
|
|
9233
|
+
}
|
|
9234
|
+
throw new Error(`zip listing reconciliation failed: unrecognized listing line "${line}"`);
|
|
9235
|
+
}
|
|
9236
|
+
const [declaredEntries] = declaredCounts;
|
|
9237
|
+
if (declaredEntries === void 0) throw new Error("zip listing reconciliation failed: the listing declares no entry count");
|
|
9238
|
+
if (declaredCounts.some((count) => count !== declaredEntries)) throw new Error(`zip listing reconciliation failed: contradictory declared entry counts (${declaredCounts.join(", ")})`);
|
|
9239
|
+
if (members.length !== declaredEntries) throw new Error(`zip listing reconciliation failed: parsed ${members.length} of ${declaredEntries} declared entries`);
|
|
9240
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9241
|
+
for (const member of members) {
|
|
9242
|
+
if (seen.has(member.name)) throw new Error(`zip listing reconciliation failed: duplicate member name "${member.name}"`);
|
|
9243
|
+
seen.add(member.name);
|
|
9244
|
+
}
|
|
9245
|
+
return members;
|
|
9246
|
+
};
|
|
9247
|
+
const MAX_ARTIFACT_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
9248
|
+
const UNSAFE_MEMBER_CHARACTERS = /[*?[\]]/u;
|
|
9249
|
+
/**
|
|
9250
|
+
* The member policy for artifact zips, as a pure function so the security
|
|
9251
|
+
* decisions are testable without `unzip`. The archive is refused outright —
|
|
9252
|
+
* not partially read — when any member is a symlink, an absolute path, a
|
|
9253
|
+
* `..` traversal, option-shaped (leading `-`), or glob-ambiguous, or when the
|
|
9254
|
+
* archive exceeds the count or decompressed-size caps. What survives is the
|
|
9255
|
+
* `*.json` member list actually handed to extraction.
|
|
9256
|
+
*/
|
|
9257
|
+
const selectProfileZipMembers = (members) => {
|
|
9258
|
+
if (members.length > 512) throw new Error(`artifact zip refused: ${members.length} members exceeds the 512-member cap`);
|
|
9259
|
+
let totalBytes = 0;
|
|
9260
|
+
for (const member of members) {
|
|
9261
|
+
if (member.isSymlink) throw new Error(`artifact zip refused: member "${member.name}" is a symlink`);
|
|
9262
|
+
if (member.name.startsWith("/") || /^[A-Za-z]:/u.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" is an absolute path`);
|
|
9263
|
+
if (member.name.split("/").includes("..")) throw new Error(`artifact zip refused: member "${member.name}" traverses with ..`);
|
|
9264
|
+
if (member.name.startsWith("-")) throw new Error(`artifact zip refused: member "${member.name}" is option-shaped`);
|
|
9265
|
+
if (UNSAFE_MEMBER_CHARACTERS.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" contains glob metacharacters`);
|
|
9266
|
+
if (!Number.isFinite(member.sizeBytes) || member.sizeBytes < 0) throw new Error(`artifact zip refused: member "${member.name}" reports no usable size`);
|
|
9267
|
+
totalBytes += member.sizeBytes;
|
|
9268
|
+
}
|
|
9269
|
+
if (totalBytes > 268435456) throw new Error(`artifact zip refused: ${totalBytes} decompressed bytes exceeds the ${MAX_ARTIFACT_DECOMPRESSED_BYTES}-byte cap`);
|
|
9270
|
+
return members.filter((member) => !member.name.endsWith("/") && member.name.endsWith(".json")).map((member) => member.name).toSorted();
|
|
9271
|
+
};
|
|
9272
|
+
/**
|
|
9273
|
+
* The default client: `gh api` for JSON endpoints (paginated), `gh api` plus
|
|
9274
|
+
* system `unzip` for artifact zips, with every member vetted by
|
|
9275
|
+
* {@link selectProfileZipMembers} before extraction. The exec adapter itself
|
|
9276
|
+
* is deliberately thin and untested networked code — every decision made from
|
|
9277
|
+
* these bytes lives behind {@link CiAnalyzeClient} or the pure policy
|
|
9278
|
+
* functions above, where fixtures drive it.
|
|
9279
|
+
*/
|
|
9280
|
+
const createGhCiAnalyzeClient = (repository) => ({
|
|
9281
|
+
getRun: (runId) => Promise.resolve(runGhJson(["api", `/repos/${repository}/actions/runs/${runId}`])),
|
|
9282
|
+
listArtifacts: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/artifacts`, "artifacts")),
|
|
9283
|
+
listJobs: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/jobs`, "jobs")),
|
|
9284
|
+
readArtifactTextFiles: (artifactId) => {
|
|
9285
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "psf-ci-analyze-"));
|
|
9286
|
+
try {
|
|
9287
|
+
const zip = execFileSync("gh", ["api", `/repos/${repository}/actions/artifacts/${artifactId}/zip`], { maxBuffer: GH_ARTIFACT_MAX_BYTES });
|
|
9288
|
+
const zipPath = path.join(scratch, "artifact.zip");
|
|
9289
|
+
writeFileSync(zipPath, zip);
|
|
9290
|
+
const selected = selectProfileZipMembers(parseZipInfoListing(execFileSync("unzip", ["-Z", zipPath], {
|
|
9291
|
+
encoding: "utf-8",
|
|
9292
|
+
maxBuffer: GH_ARTIFACT_MAX_BYTES
|
|
9293
|
+
})));
|
|
9294
|
+
if (selected.length === 0) return Promise.resolve([]);
|
|
9295
|
+
const extracted = path.join(scratch, "extracted");
|
|
9296
|
+
execFileSync("unzip", [
|
|
9297
|
+
"-o",
|
|
9298
|
+
"-qq",
|
|
9299
|
+
zipPath,
|
|
9300
|
+
"-d",
|
|
9301
|
+
extracted,
|
|
9302
|
+
...selected
|
|
9303
|
+
], { stdio: [
|
|
9304
|
+
"ignore",
|
|
9305
|
+
"ignore",
|
|
9306
|
+
"pipe"
|
|
9307
|
+
] });
|
|
9308
|
+
return Promise.resolve(selected.map((name) => ({
|
|
9309
|
+
name,
|
|
9310
|
+
text: readFileSync(path.join(extracted, name), "utf-8")
|
|
9311
|
+
})));
|
|
9312
|
+
} finally {
|
|
9313
|
+
rmSync(scratch, {
|
|
9314
|
+
force: true,
|
|
9315
|
+
recursive: true
|
|
9316
|
+
});
|
|
9317
|
+
}
|
|
9318
|
+
}
|
|
9319
|
+
});
|
|
9320
|
+
const defaultAction = (request) => analyzeCiRuns(request, createGhCiAnalyzeClient(request.repository));
|
|
9321
|
+
function createCiAnalyzeCommand(output, action = defaultAction) {
|
|
9322
|
+
return new Command("ci:analyze").description("Read-only GitHub Actions run lifecycle analysis: queue/setup/install/step/total durations, proof-reuse classification, cohort aggregates, and optional factory-ci Vitest profile readback").requiredOption("--repo <owner/name>", "repository the run ids belong to", parseRepository).option("--run <id>", "workflow run id to analyze (repeatable)", collectRunOption).option("--runs-file <path>", "file of run ids, one per line (# comments allowed)").option("--cohort <label=ids>", "label a comma-separated run-id list as one cohort, e.g. --cohort before=1,2 (repeatable; the label \"ungrouped\" is reserved for runs passed without a cohort)", parseCohortOption).option("--profiles", "download run artifacts and read compatible factory-ci Vitest profiles (the only source of cpuModel)").option("--output <path>", "write the versioned JSON report to a path").option("--json", "print the JSON report to stdout instead of prose").action(async (options) => {
|
|
9323
|
+
const fromFile = options.runsFile === void 0 ? [] : parseRunsFile(readFileSync(path.resolve(options.runsFile), "utf-8"));
|
|
9324
|
+
const report = await action({
|
|
9325
|
+
includeProfiles: options.profiles === true,
|
|
9326
|
+
repository: options.repo,
|
|
9327
|
+
runs: assembleRunRequests({
|
|
9328
|
+
cohorts: options.cohort ?? [],
|
|
9329
|
+
runIds: [...options.run ?? [], ...fromFile]
|
|
9330
|
+
})
|
|
9331
|
+
});
|
|
9332
|
+
if (options.output !== void 0) {
|
|
9333
|
+
const outputPath = path.resolve(options.output);
|
|
9334
|
+
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
9335
|
+
output.stderr.write(`ci:analyze report written to ${outputPath}\n`);
|
|
9336
|
+
}
|
|
9337
|
+
output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCiAnalyzeReport(report));
|
|
9338
|
+
});
|
|
9339
|
+
}
|
|
8637
9340
|
//#endregion
|
|
8638
9341
|
//#region src/checkout-repository.ts
|
|
8639
9342
|
function normalizeRepositoryIdentity(repository) {
|
|
@@ -16130,6 +16833,7 @@ function createProgram(options = {}) {
|
|
|
16130
16833
|
program.addCommand(createDoctorCommand(output));
|
|
16131
16834
|
program.addCommand(createConfigCommand(output));
|
|
16132
16835
|
program.addCommand(createEvidenceEmitCommand(output));
|
|
16836
|
+
program.addCommand(createCiAnalyzeCommand(output));
|
|
16133
16837
|
program.addCommand(createHqFlushCommand(output));
|
|
16134
16838
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
16135
16839
|
program.addCommand(createPrVerifyCommand(output));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/software-factory",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
4
4
|
"description": "Shared Patronage software factory CLI and project-profile validation tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"picomatch": "^4.0.5",
|
|
38
38
|
"yaml": "^2.9.0",
|
|
39
39
|
"zod": "4.4.3",
|
|
40
|
-
"@patronage/factory-ci": "1.0.0-alpha.
|
|
40
|
+
"@patronage/factory-ci": "1.0.0-alpha.8"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "24.13.3",
|