@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 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
- current: "current";
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
- current: "current";
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
- current: "current";
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: "interior" | "gate";
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: "not-required" | "blocked" | "current" | "stale" | "missing";
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: "not-required" | "blocked" | "current" | "stale" | "missing";
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: "passed" | "stale" | "missing";
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: "interior" | "gate" | "interior-complete";
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.7";
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$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
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$1(input.proof) ? input.proof : void 0;
1135
- if (proof && isRecord$1(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1136
- const commands = isRecord$1(input.verification) ? input.verification.commands : void 0;
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$1(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
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$1(input.hq) && typeof input.hq.endpoint === "string") try {
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$1(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1153
- if (isRecord$1(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$1(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$1(input.review)) {
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$1(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
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$1(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
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$1(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
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.7",
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.7"
40
+ "@patronage/factory-ci": "1.0.0-alpha.8"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",