@patronage/software-factory 1.0.0-alpha.7 → 1.0.0-alpha.9
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/CONTEXT.md +10 -0
- package/README.md +3 -1
- package/dist/index.d.ts +17 -16
- package/dist/index.js +1004 -72
- package/dist/schemas.d.ts +4 -2
- package/dist/schemas.js +5 -4
- package/package.json +2 -2
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, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
|
|
19
19
|
//#region package.json
|
|
20
|
-
var version = "1.0.0-alpha.
|
|
20
|
+
var version = "1.0.0-alpha.9";
|
|
21
21
|
//#endregion
|
|
22
22
|
//#region src/review-rungs.ts
|
|
23
23
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -981,7 +981,7 @@ const requiredCheckSchema = z.object({
|
|
|
981
981
|
const impactTargetSchema = z.object({
|
|
982
982
|
importers: z.array(z.string().min(1)).min(1),
|
|
983
983
|
name: z.string().min(1),
|
|
984
|
-
paths: z.array(repoRelativeGlobSchema).min(1).optional()
|
|
984
|
+
paths: z.array(repoRelativeGlobSchema).min(1).optional().describe("Opt-in repo-relative path subscriptions. A target is affected only when a valid changed path matches one of its globs; valid unmatched paths affect no target and are recorded as unsubscribed. Shared files must be listed by every subscribing target.")
|
|
985
985
|
}).strict();
|
|
986
986
|
const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
|
|
987
987
|
const names = targets.map((target) => target.name);
|
|
@@ -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));
|
|
@@ -3553,15 +3553,18 @@ const impactStampTargetSchema = z.object({
|
|
|
3553
3553
|
const impactStampSchema = z.object({
|
|
3554
3554
|
basis: z.enum(["target-scoped", "conservative"]),
|
|
3555
3555
|
reasons: z.array(z.string()),
|
|
3556
|
-
stampVersion: z.literal(
|
|
3556
|
+
stampVersion: z.literal(3),
|
|
3557
3557
|
targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
|
|
3558
3558
|
const names = targets.map((target) => target.name);
|
|
3559
3559
|
if (new Set(names).size !== names.length) context.addIssue({
|
|
3560
3560
|
code: "custom",
|
|
3561
3561
|
message: "impact stamp targets must have distinct names."
|
|
3562
3562
|
});
|
|
3563
|
-
})
|
|
3563
|
+
}),
|
|
3564
|
+
unsubscribedPaths: z.array(z.string())
|
|
3564
3565
|
});
|
|
3566
|
+
/** Human-readable lines for every impact decision, including quiet releases. */
|
|
3567
|
+
const impactStampSummaryLines = (stamp) => [`Impact stamp: ${stamp.basis}; ${stamp.targets.filter(({ impact }) => impact === "affected").length}/${stamp.targets.length} target(s) affected.`, stamp.unsubscribedPaths.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${stamp.unsubscribedPaths.join(", ")}`];
|
|
3565
3568
|
/**
|
|
3566
3569
|
* A configuration contradiction between the profile's impact declarations and
|
|
3567
3570
|
* the repository's actual lockfile — e.g. a declared importer root that
|
|
@@ -3611,8 +3614,8 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
|
|
|
3611
3614
|
reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
|
|
3612
3615
|
scoped: false
|
|
3613
3616
|
};
|
|
3614
|
-
if (stamp.stampVersion !==
|
|
3615
|
-
reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version
|
|
3617
|
+
if (stamp.stampVersion !== 3) return {
|
|
3618
|
+
reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 3; ${floor}`,
|
|
3616
3619
|
scoped: false
|
|
3617
3620
|
};
|
|
3618
3621
|
if (stamp.basis !== "target-scoped") return {
|
|
@@ -3654,15 +3657,16 @@ const impactStampDemandRelease = ({ checkName, stamp }) => {
|
|
|
3654
3657
|
* every doubt path lands on, and the stamp a producer must fall back to if
|
|
3655
3658
|
* stamp computation itself fails for any reason.
|
|
3656
3659
|
*/
|
|
3657
|
-
const conservativeImpactStamp = (targets, reasons) => ({
|
|
3660
|
+
const conservativeImpactStamp = (targets, reasons, unsubscribedPaths = []) => ({
|
|
3658
3661
|
basis: "conservative",
|
|
3659
3662
|
reasons,
|
|
3660
|
-
stampVersion:
|
|
3663
|
+
stampVersion: 3,
|
|
3661
3664
|
targets: targets.map((target) => ({
|
|
3662
3665
|
basis: CONSERVATIVE_TARGET_BASIS,
|
|
3663
3666
|
impact: "affected",
|
|
3664
3667
|
name: target.name
|
|
3665
|
-
}))
|
|
3668
|
+
})),
|
|
3669
|
+
unsubscribedPaths
|
|
3666
3670
|
});
|
|
3667
3671
|
const conservativeStamp = conservativeImpactStamp;
|
|
3668
3672
|
const DEPENDENCY_FIELDS = [
|
|
@@ -3690,17 +3694,17 @@ const pnpmLockfileSchema = z.looseObject({
|
|
|
3690
3694
|
packages: z.record(z.string(), z.unknown()).optional(),
|
|
3691
3695
|
snapshots: z.record(z.string(), snapshotSectionSchema).optional()
|
|
3692
3696
|
});
|
|
3693
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3697
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3694
3698
|
const canonical = (value) => {
|
|
3695
3699
|
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])]));
|
|
3700
|
+
if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3697
3701
|
return value;
|
|
3698
3702
|
};
|
|
3699
3703
|
const stableStringify$1 = (value) => JSON.stringify(canonical(value));
|
|
3700
3704
|
const parseLockfile = (content) => {
|
|
3701
3705
|
try {
|
|
3702
3706
|
const parsed = parse(content);
|
|
3703
|
-
if (!isRecord(parsed)) return;
|
|
3707
|
+
if (!isRecord$1(parsed)) return;
|
|
3704
3708
|
const validated = pnpmLockfileSchema.safeParse(parsed);
|
|
3705
3709
|
return validated.success ? validated.data : void 0;
|
|
3706
3710
|
} catch {
|
|
@@ -3793,9 +3797,10 @@ const targetImpactForLockfileDelta = (target, delta) => {
|
|
|
3793
3797
|
/**
|
|
3794
3798
|
* Compute the impact stamp for one candidate: every declared target recorded
|
|
3795
3799
|
* as affected/not-affected with its exact basis. `not-affected` is only ever
|
|
3796
|
-
* returned only when every changed input is
|
|
3797
|
-
*
|
|
3798
|
-
*
|
|
3800
|
+
* returned only when every changed input is a valid source path or an
|
|
3801
|
+
* analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability are
|
|
3802
|
+
* unioned per target. A valid source path matched by no target is recorded in
|
|
3803
|
+
* `unsubscribedPaths` and affects none. Invalid, unreadable, unsupported, or
|
|
3799
3804
|
* otherwise doubtful input widens the whole stamp to full impact.
|
|
3800
3805
|
*
|
|
3801
3806
|
* Total by contract: this function never throws. Any unexpected failure in
|
|
@@ -3872,10 +3877,6 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
|
|
|
3872
3877
|
kind: "conservative",
|
|
3873
3878
|
reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
|
|
3874
3879
|
};
|
|
3875
|
-
if (!targets.some((target) => matchesAnyGlob(target.paths ?? [], file))) return {
|
|
3876
|
-
kind: "conservative",
|
|
3877
|
-
reason: `changed source path "${file}" has no declared impact target owner; fail closed to full impact`
|
|
3878
|
-
};
|
|
3879
3880
|
}
|
|
3880
3881
|
return {
|
|
3881
3882
|
kind: "ok",
|
|
@@ -3890,7 +3891,8 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
|
|
|
3890
3891
|
impact: "affected",
|
|
3891
3892
|
name: target.name
|
|
3892
3893
|
};
|
|
3893
|
-
})
|
|
3894
|
+
}),
|
|
3895
|
+
unsubscribedPaths: sourceFiles.filter((file) => !targets.some((target) => matchesAnyGlob(target.paths ?? [], file)))
|
|
3894
3896
|
};
|
|
3895
3897
|
};
|
|
3896
3898
|
const unionTargetImpact = (source, lockfile) => {
|
|
@@ -3906,8 +3908,9 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
|
|
|
3906
3908
|
if (targets.length === 0) return {
|
|
3907
3909
|
basis: "conservative",
|
|
3908
3910
|
reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
|
|
3909
|
-
stampVersion:
|
|
3910
|
-
targets: []
|
|
3911
|
+
stampVersion: 3,
|
|
3912
|
+
targets: [],
|
|
3913
|
+
unsubscribedPaths: changedFiles.filter((file) => file !== "pnpm-lock.yaml" && isRepoRelativePath(file))
|
|
3911
3914
|
};
|
|
3912
3915
|
assertDeclaredPathGlobs(targets);
|
|
3913
3916
|
const sides = readLockfileSides(readLockfile);
|
|
@@ -3916,18 +3919,19 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
|
|
|
3916
3919
|
const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
|
|
3917
3920
|
const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
|
|
3918
3921
|
if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
|
|
3919
|
-
if (sides.kind === "threw")
|
|
3920
|
-
if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`]);
|
|
3921
|
-
if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`]);
|
|
3922
|
-
if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`]);
|
|
3922
|
+
if (sides.kind === "threw") return conservativeStamp(targets, [`impact stamp computation failed (${sides.error instanceof Error ? sides.error.message : String(sides.error)}); fail closed to full impact`], source.unsubscribedPaths);
|
|
3923
|
+
if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
|
|
3924
|
+
if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
|
|
3925
|
+
if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`], source.unsubscribedPaths);
|
|
3923
3926
|
if (!changedFiles.includes("pnpm-lock.yaml")) return {
|
|
3924
3927
|
basis: "target-scoped",
|
|
3925
|
-
reasons: ["all changed source paths
|
|
3926
|
-
stampVersion:
|
|
3927
|
-
targets: source.targets
|
|
3928
|
+
reasons: [source.unsubscribedPaths.length === 0 ? "all changed source paths match at least one declared target subscription; impact scoped by repo-relative path globs" : `${source.unsubscribedPaths.length} changed source path(s) match no declared target subscription and affect no target`],
|
|
3929
|
+
stampVersion: 3,
|
|
3930
|
+
targets: source.targets,
|
|
3931
|
+
unsubscribedPaths: source.unsubscribedPaths
|
|
3928
3932
|
};
|
|
3929
3933
|
const [baseLock, headLock] = sides.parsed;
|
|
3930
|
-
if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"]);
|
|
3934
|
+
if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"], source.unsubscribedPaths);
|
|
3931
3935
|
const delta = {
|
|
3932
3936
|
changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
|
|
3933
3937
|
changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
|
|
@@ -3938,9 +3942,10 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
|
|
|
3938
3942
|
const mixed = sourceFiles.length > 0;
|
|
3939
3943
|
return {
|
|
3940
3944
|
basis: "target-scoped",
|
|
3941
|
-
reasons: mixed ? ["all changed source paths
|
|
3942
|
-
stampVersion:
|
|
3943
|
-
targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets
|
|
3945
|
+
reasons: mixed ? [source.unsubscribedPaths.length === 0 ? "all changed source paths match at least one declared target subscription and pnpm-lock.yaml is analyzable; impact is the union of path subscriptions and importer/snapshot graph analysis" : `${source.unsubscribedPaths.length} changed source path(s) match no declared target subscription; subscribed path impact is unioned with importer/snapshot graph analysis`] : ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
|
|
3946
|
+
stampVersion: 3,
|
|
3947
|
+
targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets,
|
|
3948
|
+
unsubscribedPaths: source.unsubscribedPaths
|
|
3944
3949
|
};
|
|
3945
3950
|
};
|
|
3946
3951
|
//#endregion
|
|
@@ -5543,17 +5548,21 @@ const asClassification = (value) => DIFF_CLASSIFICATIONS$1.includes(value) ? val
|
|
|
5543
5548
|
const prVerifyCheckPayloadFor = (proof) => {
|
|
5544
5549
|
const value = proof;
|
|
5545
5550
|
if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
|
|
5551
|
+
const carriesReleases = value.notRequiredCommands !== void 0;
|
|
5546
5552
|
return {
|
|
5547
5553
|
classification: asClassification(value.classification),
|
|
5548
5554
|
executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
|
|
5549
5555
|
headSha: value.headSha,
|
|
5556
|
+
...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
|
|
5550
5557
|
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
5551
5558
|
mode: value.mode,
|
|
5559
|
+
...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
|
|
5552
5560
|
outcome: outcomeFor(value),
|
|
5553
5561
|
proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
|
|
5554
5562
|
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
5555
5563
|
...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
|
|
5556
|
-
...typeof value.repository === "string" ? { repository: value.repository } : {}
|
|
5564
|
+
...typeof value.repository === "string" ? { repository: value.repository } : {},
|
|
5565
|
+
...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
|
|
5557
5566
|
};
|
|
5558
5567
|
};
|
|
5559
5568
|
const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
@@ -7442,9 +7451,15 @@ const installationToken = (repository, config, request, now, timeoutMs = GITHUB_
|
|
|
7442
7451
|
function verifyOutput(proof) {
|
|
7443
7452
|
const value = proof;
|
|
7444
7453
|
const commands = value.executedCommands?.length ?? 0;
|
|
7454
|
+
const unsubscribedBasis = value.impactStamp?.unsubscribedPaths;
|
|
7455
|
+
let unsubscribedSummary;
|
|
7456
|
+
if (Array.isArray(unsubscribedBasis)) {
|
|
7457
|
+
const unsubscribed = unsubscribedBasis.filter((path) => typeof path === "string");
|
|
7458
|
+
unsubscribedSummary = unsubscribed.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${unsubscribed.join(", ")}.`;
|
|
7459
|
+
} else unsubscribedSummary = "Unsubscribed changed paths: unavailable (legacy or malformed stamp).";
|
|
7445
7460
|
const payload = prVerifyCheckPayloadFor(proof);
|
|
7446
7461
|
return {
|
|
7447
|
-
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed
|
|
7462
|
+
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed. ${unsubscribedSummary}`,
|
|
7448
7463
|
...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
|
|
7449
7464
|
title: `pr:verify ${value.outcome ?? "unknown"}`
|
|
7450
7465
|
};
|
|
@@ -7458,8 +7473,10 @@ function reviewOutput(proof) {
|
|
|
7458
7473
|
for (const { disposition } of value.ladder ? ladderLedgerFor(value.ladder) : []) dispositionCounts[disposition] = (dispositionCounts[disposition] ?? 0) + 1;
|
|
7459
7474
|
if (!value.ladder && findings > 0) dispositionCounts.open = findings;
|
|
7460
7475
|
const dispositions = Object.entries(dispositionCounts).map(([name, count]) => `${name} ${count}`).join(", ");
|
|
7476
|
+
const unavailable = "unavailable (legacy proof)";
|
|
7477
|
+
const provenance = reviews.length === 0 ? "not required" : reviews.map(({ kind, model, producer, rung }) => `${kind ?? "unknown mode"}: rung ${rung ?? unavailable}; producer ${producer ?? unavailable}; model ${model ?? unavailable}`).join(" | ");
|
|
7461
7478
|
return {
|
|
7462
|
-
summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}.`,
|
|
7479
|
+
summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}. Review provenance: ${provenance}.`,
|
|
7463
7480
|
title: findings === 0 ? "pr:review clean" : "pr:review proof recorded"
|
|
7464
7481
|
};
|
|
7465
7482
|
}
|
|
@@ -8634,6 +8651,709 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8634
8651
|
if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
|
|
8635
8652
|
})));
|
|
8636
8653
|
}
|
|
8654
|
+
/** `tool` discriminator every emitted report carries. */
|
|
8655
|
+
const CI_ANALYZE_TOOL = "psf-ci-analyze";
|
|
8656
|
+
/** Cohort label for runs the caller did not assign to a cohort. */
|
|
8657
|
+
const UNGROUPED_COHORT = "ungrouped";
|
|
8658
|
+
const available = (value) => ({
|
|
8659
|
+
available: true,
|
|
8660
|
+
value
|
|
8661
|
+
});
|
|
8662
|
+
const unavailable = (reason) => ({
|
|
8663
|
+
available: false,
|
|
8664
|
+
reason
|
|
8665
|
+
});
|
|
8666
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8667
|
+
const stringField$1 = (source, key, where) => {
|
|
8668
|
+
const value = source[key];
|
|
8669
|
+
return typeof value === "string" && value.length > 0 ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8670
|
+
};
|
|
8671
|
+
const numberField = (source, key, where) => {
|
|
8672
|
+
const value = source[key];
|
|
8673
|
+
return typeof value === "number" && Number.isFinite(value) ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8674
|
+
};
|
|
8675
|
+
/**
|
|
8676
|
+
* An instant is only usable when it names its own timezone. `Date.parse`
|
|
8677
|
+
* interprets an offset-less `2026-08-06T12:00:00` in the *host's* zone, which
|
|
8678
|
+
* would make a duration depend on where the analyzer ran. The GitHub API
|
|
8679
|
+
* always sends `Z`-form, so requiring an explicit `Z` or numeric offset
|
|
8680
|
+
* rejects nothing genuine — anything else becomes explicitly unavailable.
|
|
8681
|
+
*/
|
|
8682
|
+
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/u;
|
|
8683
|
+
const parseInstantMs = (value) => {
|
|
8684
|
+
if (!value.available) return;
|
|
8685
|
+
if (!EXPLICIT_OFFSET_PATTERN.test(value.value)) return;
|
|
8686
|
+
const parsed = Date.parse(value.value);
|
|
8687
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
8688
|
+
};
|
|
8689
|
+
/**
|
|
8690
|
+
* Wall clock between two ISO instants. Unavailable — with the missing side
|
|
8691
|
+
* named — whenever either instant is absent or unparsable; a negative span is
|
|
8692
|
+
* refused rather than clamped to a fake zero.
|
|
8693
|
+
*/
|
|
8694
|
+
const durationBetween = (start, end, label) => {
|
|
8695
|
+
const startMs = parseInstantMs(start);
|
|
8696
|
+
if (startMs === void 0) return unavailable(`${label}: start instant is ${start.available ? "unparsable" : `unavailable (${start.reason})`}`);
|
|
8697
|
+
const endMs = parseInstantMs(end);
|
|
8698
|
+
if (endMs === void 0) return unavailable(`${label}: end instant is ${end.available ? "unparsable" : `unavailable (${end.reason})`}`);
|
|
8699
|
+
if (endMs < startMs) return unavailable(`${label}: end instant precedes start instant`);
|
|
8700
|
+
return available(endMs - startMs);
|
|
8701
|
+
};
|
|
8702
|
+
const GITHUB_SETUP_STEP_NAME = "Set up job";
|
|
8703
|
+
const INSTALL_STEP_PATTERN = /install/iu;
|
|
8704
|
+
const analyzeStep = (step) => {
|
|
8705
|
+
const startedAt = stringField$1(step, "started_at", "step");
|
|
8706
|
+
const completedAt = stringField$1(step, "completed_at", "step");
|
|
8707
|
+
return {
|
|
8708
|
+
completedAt,
|
|
8709
|
+
conclusion: stringField$1(step, "conclusion", "step"),
|
|
8710
|
+
durationMs: durationBetween(startedAt, completedAt, "step duration"),
|
|
8711
|
+
name: typeof step.name === "string" ? step.name : "(unnamed step)",
|
|
8712
|
+
number: numberField(step, "number", "step"),
|
|
8713
|
+
startedAt
|
|
8714
|
+
};
|
|
8715
|
+
};
|
|
8716
|
+
const namedStepDuration = (steps, matches, description) => {
|
|
8717
|
+
const step = steps.find((candidate) => matches(candidate.name));
|
|
8718
|
+
return step === void 0 ? unavailable(`no ${description} step in this job`) : step.durationMs;
|
|
8719
|
+
};
|
|
8720
|
+
const analyzeJob = (job) => {
|
|
8721
|
+
const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord).map(analyzeStep) : [];
|
|
8722
|
+
const createdAt = stringField$1(job, "created_at", "job");
|
|
8723
|
+
const startedAt = stringField$1(job, "started_at", "job");
|
|
8724
|
+
const completedAt = stringField$1(job, "completed_at", "job");
|
|
8725
|
+
const labels = Array.isArray(job.labels) ? job.labels.filter((label) => typeof label === "string") : void 0;
|
|
8726
|
+
return {
|
|
8727
|
+
completedAt,
|
|
8728
|
+
conclusion: stringField$1(job, "conclusion", "job"),
|
|
8729
|
+
createdAt,
|
|
8730
|
+
installMs: namedStepDuration(steps, (name) => INSTALL_STEP_PATTERN.test(name), "install"),
|
|
8731
|
+
jobId: numberField(job, "id", "job"),
|
|
8732
|
+
name: typeof job.name === "string" ? job.name : "(unnamed job)",
|
|
8733
|
+
queueMs: durationBetween(createdAt, startedAt, "job queue"),
|
|
8734
|
+
runnerLabels: labels === void 0 ? unavailable("job has no labels array") : available(labels),
|
|
8735
|
+
setupMs: namedStepDuration(steps, (name) => name === GITHUB_SETUP_STEP_NAME, `"${GITHUB_SETUP_STEP_NAME}"`),
|
|
8736
|
+
startedAt,
|
|
8737
|
+
steps,
|
|
8738
|
+
totalMs: durationBetween(startedAt, completedAt, "job duration"),
|
|
8739
|
+
url: stringField$1(job, "html_url", "job")
|
|
8740
|
+
};
|
|
8741
|
+
};
|
|
8742
|
+
/**
|
|
8743
|
+
* Classify proof reuse from step conclusions alone. A job whose gate step is
|
|
8744
|
+
* followed by at least one skipped step reused proof; a job whose post-gate
|
|
8745
|
+
* steps all executed ran the full fallback. No gate step anywhere means the
|
|
8746
|
+
* workflow does not expose the outcome, which is unavailable — not
|
|
8747
|
+
* full-fallback.
|
|
8748
|
+
*/
|
|
8749
|
+
const classifyProofReuse = (jobs) => {
|
|
8750
|
+
const gateJobs = [];
|
|
8751
|
+
const verdicts = /* @__PURE__ */ new Set();
|
|
8752
|
+
for (const job of jobs) {
|
|
8753
|
+
const gateIndex = job.steps.findIndex((step) => step.name === FACTORY_PROOF_GATE_STEP_NAME);
|
|
8754
|
+
if (gateIndex === -1) continue;
|
|
8755
|
+
const skippedStepsAfterGate = job.steps.slice(gateIndex + 1).filter((step) => step.conclusion.available && step.conclusion.value === "skipped").length;
|
|
8756
|
+
verdicts.add(skippedStepsAfterGate > 0 ? "proof-reuse" : "full-fallback");
|
|
8757
|
+
gateJobs.push({
|
|
8758
|
+
gateConclusion: job.steps[gateIndex]?.conclusion ?? unavailable("gate step vanished"),
|
|
8759
|
+
jobName: job.name,
|
|
8760
|
+
skippedStepsAfterGate
|
|
8761
|
+
});
|
|
8762
|
+
}
|
|
8763
|
+
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`);
|
|
8764
|
+
const [single] = [...verdicts];
|
|
8765
|
+
return available({
|
|
8766
|
+
classification: verdicts.size === 1 && single ? single : "mixed",
|
|
8767
|
+
gateJobs
|
|
8768
|
+
});
|
|
8769
|
+
};
|
|
8770
|
+
const parseArtifactListing = (value) => {
|
|
8771
|
+
if (!isRecord(value) || !Array.isArray(value.artifacts)) return {
|
|
8772
|
+
artifacts: [],
|
|
8773
|
+
totalCount: void 0
|
|
8774
|
+
};
|
|
8775
|
+
return {
|
|
8776
|
+
artifacts: value.artifacts.filter(isRecord).flatMap((artifact) => {
|
|
8777
|
+
const { id, name } = artifact;
|
|
8778
|
+
return typeof id === "number" && typeof name === "string" ? [{
|
|
8779
|
+
expired: artifact.expired === true,
|
|
8780
|
+
id,
|
|
8781
|
+
name
|
|
8782
|
+
}] : [];
|
|
8783
|
+
}),
|
|
8784
|
+
totalCount: typeof value.total_count === "number" ? value.total_count : void 0
|
|
8785
|
+
};
|
|
8786
|
+
};
|
|
8787
|
+
const profileFromDocument = (artifactName, value) => {
|
|
8788
|
+
const read = readVitestProfileDocument(value);
|
|
8789
|
+
if (read.kind === "unrecognized") return {
|
|
8790
|
+
artifactName,
|
|
8791
|
+
kind: "unreadable",
|
|
8792
|
+
reason: read.reason
|
|
8793
|
+
};
|
|
8794
|
+
const { profile } = read;
|
|
8795
|
+
const testCounts = profile.runs.flatMap((run) => run.counts === null ? [] : [run.counts.tests]);
|
|
8796
|
+
return {
|
|
8797
|
+
artifactName,
|
|
8798
|
+
kind: "profile",
|
|
8799
|
+
profile: {
|
|
8800
|
+
cpuCount: profile.environment.cpuCount,
|
|
8801
|
+
cpuModel: profile.environment.cpuModel,
|
|
8802
|
+
maxWorkers: profile.options.maxWorkers,
|
|
8803
|
+
sampleDurationsMs: profile.runs.map((run) => run.durationMs),
|
|
8804
|
+
scope: profile.command.join(" "),
|
|
8805
|
+
slowFiles: profile.summary.slowFiles.map((file) => ({
|
|
8806
|
+
medianMs: file.durationMs.median,
|
|
8807
|
+
path: file.path
|
|
8808
|
+
})),
|
|
8809
|
+
slowTests: profile.summary.slowTests.map((test) => ({
|
|
8810
|
+
file: test.file,
|
|
8811
|
+
medianMs: test.durationMs.median,
|
|
8812
|
+
name: test.name
|
|
8813
|
+
})),
|
|
8814
|
+
summaryDurationMs: profile.summary.durationMs,
|
|
8815
|
+
testCount: testCounts.length === 0 ? unavailable("no sample in this profile carries test counts") : available(Math.max(...testCounts))
|
|
8816
|
+
}
|
|
8817
|
+
};
|
|
8818
|
+
};
|
|
8819
|
+
const collectProfiles = async (client, runId) => {
|
|
8820
|
+
let listing;
|
|
8821
|
+
try {
|
|
8822
|
+
listing = parseArtifactListing(await client.listArtifacts(runId));
|
|
8823
|
+
} catch (error) {
|
|
8824
|
+
return unavailable(`artifact listing failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8825
|
+
}
|
|
8826
|
+
const { artifacts } = listing;
|
|
8827
|
+
if (listing.totalCount !== void 0 && listing.totalCount > artifacts.length) return unavailable(`artifact listing is truncated: ${artifacts.length} of ${listing.totalCount} artifacts served`);
|
|
8828
|
+
if (artifacts.length === 0) return unavailable("this run has no artifacts");
|
|
8829
|
+
const analyses = [];
|
|
8830
|
+
for (const artifact of artifacts) {
|
|
8831
|
+
if (artifact.expired) {
|
|
8832
|
+
analyses.push({
|
|
8833
|
+
artifactName: artifact.name,
|
|
8834
|
+
kind: "unreadable",
|
|
8835
|
+
reason: "the artifact is expired and can no longer be downloaded"
|
|
8836
|
+
});
|
|
8837
|
+
continue;
|
|
8838
|
+
}
|
|
8839
|
+
let files;
|
|
8840
|
+
try {
|
|
8841
|
+
files = await client.readArtifactTextFiles(artifact.id);
|
|
8842
|
+
} catch (error) {
|
|
8843
|
+
analyses.push({
|
|
8844
|
+
artifactName: artifact.name,
|
|
8845
|
+
kind: "unreadable",
|
|
8846
|
+
reason: `artifact download failed: ${error instanceof Error ? error.message : String(error)}`
|
|
8847
|
+
});
|
|
8848
|
+
continue;
|
|
8849
|
+
}
|
|
8850
|
+
for (const file of files) {
|
|
8851
|
+
let parsed;
|
|
8852
|
+
try {
|
|
8853
|
+
parsed = JSON.parse(file.text);
|
|
8854
|
+
} catch {
|
|
8855
|
+
analyses.push({
|
|
8856
|
+
artifactName: `${artifact.name}/${file.name}`,
|
|
8857
|
+
kind: "unreadable",
|
|
8858
|
+
reason: "the file is not valid JSON"
|
|
8859
|
+
});
|
|
8860
|
+
continue;
|
|
8861
|
+
}
|
|
8862
|
+
const analysis = profileFromDocument(`${artifact.name}/${file.name}`, parsed);
|
|
8863
|
+
if (analysis.kind === "profile" || isRecord(parsed) && parsed.tool === "factory-ci-vitest-profile") analyses.push(analysis);
|
|
8864
|
+
}
|
|
8865
|
+
}
|
|
8866
|
+
return analyses.length === 0 ? unavailable("no compatible factory-ci Vitest profile artifacts on this run") : available(analyses);
|
|
8867
|
+
};
|
|
8868
|
+
const earliest = (instants) => instants.length === 0 ? void 0 : Math.min(...instants);
|
|
8869
|
+
const latest = (instants) => instants.length === 0 ? void 0 : Math.max(...instants);
|
|
8870
|
+
const runQueueDuration = (createdAt, jobs) => {
|
|
8871
|
+
const createdMs = parseInstantMs(createdAt);
|
|
8872
|
+
if (createdMs === void 0) return unavailable("the run object has no parsable created_at");
|
|
8873
|
+
const firstStart = earliest(jobs.flatMap((job) => {
|
|
8874
|
+
const started = parseInstantMs(job.startedAt);
|
|
8875
|
+
return started === void 0 ? [] : [started];
|
|
8876
|
+
}));
|
|
8877
|
+
if (firstStart === void 0) return unavailable("no job carries a parsable started_at");
|
|
8878
|
+
return firstStart < createdMs ? unavailable("the earliest job start precedes the run creation instant") : available(firstStart - createdMs);
|
|
8879
|
+
};
|
|
8880
|
+
const runCompletionInstant = (jobs) => {
|
|
8881
|
+
const completions = jobs.flatMap((job) => {
|
|
8882
|
+
const completed = parseInstantMs(job.completedAt);
|
|
8883
|
+
return completed === void 0 ? [] : [{
|
|
8884
|
+
completed,
|
|
8885
|
+
iso: job.completedAt
|
|
8886
|
+
}];
|
|
8887
|
+
});
|
|
8888
|
+
const last = latest(completions.map((entry) => entry.completed));
|
|
8889
|
+
const match = completions.find((entry) => entry.completed === last);
|
|
8890
|
+
return match?.iso.available === true ? available(match.iso.value) : unavailable("no job carries a parsable completed_at");
|
|
8891
|
+
};
|
|
8892
|
+
/**
|
|
8893
|
+
* `cpuModel` never comes from the Actions API — only readable profile
|
|
8894
|
+
* artifacts carry silicon attribution, so both absence cases say so.
|
|
8895
|
+
*/
|
|
8896
|
+
const cpuModelsField = (cpuModels, profiles) => {
|
|
8897
|
+
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`);
|
|
8898
|
+
if (cpuModels.length === 0) return unavailable("no readable profile artifact recorded a CPU model");
|
|
8899
|
+
return available(cpuModels);
|
|
8900
|
+
};
|
|
8901
|
+
const analyzeOneRun = async (client, request, entry) => {
|
|
8902
|
+
const cohort = entry.cohort ?? "ungrouped";
|
|
8903
|
+
let runValue;
|
|
8904
|
+
let jobsValue;
|
|
8905
|
+
try {
|
|
8906
|
+
runValue = await client.getRun(entry.runId);
|
|
8907
|
+
jobsValue = await client.listJobs(entry.runId);
|
|
8908
|
+
} catch (error) {
|
|
8909
|
+
return {
|
|
8910
|
+
cohort,
|
|
8911
|
+
kind: "unreadable",
|
|
8912
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
8913
|
+
repository: request.repository,
|
|
8914
|
+
runId: entry.runId
|
|
8915
|
+
};
|
|
8916
|
+
}
|
|
8917
|
+
if (!isRecord(runValue)) return {
|
|
8918
|
+
cohort,
|
|
8919
|
+
kind: "unreadable",
|
|
8920
|
+
reason: "the run response is not an object",
|
|
8921
|
+
repository: request.repository,
|
|
8922
|
+
runId: entry.runId
|
|
8923
|
+
};
|
|
8924
|
+
const jobRows = isRecord(jobsValue) && Array.isArray(jobsValue.jobs) ? jobsValue.jobs.filter(isRecord) : [];
|
|
8925
|
+
if (isRecord(jobsValue) && typeof jobsValue.total_count === "number" && jobsValue.total_count > jobRows.length) return {
|
|
8926
|
+
cohort,
|
|
8927
|
+
kind: "unreadable",
|
|
8928
|
+
reason: `jobs listing is truncated: ${jobRows.length} of ${jobsValue.total_count} jobs served`,
|
|
8929
|
+
repository: request.repository,
|
|
8930
|
+
runId: entry.runId
|
|
8931
|
+
};
|
|
8932
|
+
const jobs = jobRows.map(analyzeJob);
|
|
8933
|
+
const createdAt = stringField$1(runValue, "created_at", "run");
|
|
8934
|
+
const startedAt = stringField$1(runValue, "run_started_at", "run");
|
|
8935
|
+
const completedAt = runCompletionInstant(jobs);
|
|
8936
|
+
const profiles = request.includeProfiles ? await collectProfiles(client, entry.runId) : unavailable("profile artifacts were not requested (pass --profiles)");
|
|
8937
|
+
const cpuModels = profiles.available ? [...new Set(profiles.value.flatMap((profile) => profile.kind === "profile" && profile.profile.cpuModel !== null ? [profile.profile.cpuModel] : []))] : void 0;
|
|
8938
|
+
const labels = [...new Set(jobs.flatMap((job) => job.runnerLabels.available ? job.runnerLabels.value : []))];
|
|
8939
|
+
return {
|
|
8940
|
+
cohort,
|
|
8941
|
+
completedAt,
|
|
8942
|
+
conclusion: stringField$1(runValue, "conclusion", "run"),
|
|
8943
|
+
cpuModels: cpuModelsField(cpuModels, profiles),
|
|
8944
|
+
createdAt,
|
|
8945
|
+
durations: {
|
|
8946
|
+
queueMs: runQueueDuration(createdAt, jobs),
|
|
8947
|
+
totalMs: durationBetween(startedAt, completedAt, "run duration")
|
|
8948
|
+
},
|
|
8949
|
+
event: stringField$1(runValue, "event", "run"),
|
|
8950
|
+
headSha: stringField$1(runValue, "head_sha", "run"),
|
|
8951
|
+
jobs,
|
|
8952
|
+
kind: "analyzed",
|
|
8953
|
+
profiles,
|
|
8954
|
+
proofReuse: classifyProofReuse(jobs),
|
|
8955
|
+
repository: request.repository,
|
|
8956
|
+
runId: entry.runId,
|
|
8957
|
+
runnerLabels: labels.length === 0 ? unavailable("no job carries runner labels") : available(labels),
|
|
8958
|
+
startedAt,
|
|
8959
|
+
url: stringField$1(runValue, "html_url", "run"),
|
|
8960
|
+
workflowName: stringField$1(runValue, "name", "run")
|
|
8961
|
+
};
|
|
8962
|
+
};
|
|
8963
|
+
/**
|
|
8964
|
+
* Quantile by the floor-index convention: over `sorted` (ascending), the
|
|
8965
|
+
* q-quantile is the element at index `floor(q * (n - 1))`. No interpolation —
|
|
8966
|
+
* every reported quantile is a duration that actually happened, and the
|
|
8967
|
+
* convention is stable for the small sample counts (3–10 runs) this command
|
|
8968
|
+
* exists for. median = q(0.5), p25 = q(0.25), p75 = q(0.75).
|
|
8969
|
+
*/
|
|
8970
|
+
const quantileFloorIndex = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))] ?? NaN;
|
|
8971
|
+
const quantileSummary = (values) => {
|
|
8972
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
8973
|
+
return {
|
|
8974
|
+
count: sorted.length,
|
|
8975
|
+
maximum: sorted.at(-1) ?? NaN,
|
|
8976
|
+
median: quantileFloorIndex(sorted, .5),
|
|
8977
|
+
minimum: sorted[0] ?? NaN,
|
|
8978
|
+
p25: quantileFloorIndex(sorted, .25),
|
|
8979
|
+
p75: quantileFloorIndex(sorted, .75)
|
|
8980
|
+
};
|
|
8981
|
+
};
|
|
8982
|
+
const classificationOf = (run) => run.proofReuse.available ? run.proofReuse.value.classification : "unclassified";
|
|
8983
|
+
const aggregateCohorts = (runs) => {
|
|
8984
|
+
const cells = /* @__PURE__ */ new Map();
|
|
8985
|
+
for (const run of runs) {
|
|
8986
|
+
if (run.kind !== "analyzed") continue;
|
|
8987
|
+
const key = `${run.cohort}\0${classificationOf(run)}`;
|
|
8988
|
+
const cell = cells.get(key) ?? [];
|
|
8989
|
+
cell.push(run);
|
|
8990
|
+
cells.set(key, cell);
|
|
8991
|
+
}
|
|
8992
|
+
return [...cells.entries()].map(([key, cellRuns]) => {
|
|
8993
|
+
const [cohort = UNGROUPED_COHORT] = key.split("\0");
|
|
8994
|
+
const totals = cellRuns.flatMap((run) => run.durations.totalMs.available ? [run.durations.totalMs.value] : []);
|
|
8995
|
+
const excludedRunIds = cellRuns.filter((run) => !run.durations.totalMs.available).map((run) => run.runId);
|
|
8996
|
+
return {
|
|
8997
|
+
classification: classificationOf(cellRuns[0]),
|
|
8998
|
+
cohort,
|
|
8999
|
+
count: cellRuns.length,
|
|
9000
|
+
excludedRunIds,
|
|
9001
|
+
runIds: cellRuns.map((run) => run.runId),
|
|
9002
|
+
totalDurationMs: totals.length === 0 ? unavailable("no run in this cell has an available total duration") : available(quantileSummary(totals))
|
|
9003
|
+
};
|
|
9004
|
+
}).toSorted((left, right) => `${left.cohort}\0${left.classification}`.localeCompare(`${right.cohort}\0${right.classification}`));
|
|
9005
|
+
};
|
|
9006
|
+
const cohortWarnings = (runs) => {
|
|
9007
|
+
const warnings = [];
|
|
9008
|
+
const byCohort = /* @__PURE__ */ new Map();
|
|
9009
|
+
for (const run of runs) {
|
|
9010
|
+
if (run.kind !== "analyzed") continue;
|
|
9011
|
+
const cohort = byCohort.get(run.cohort) ?? [];
|
|
9012
|
+
cohort.push(run);
|
|
9013
|
+
byCohort.set(run.cohort, cohort);
|
|
9014
|
+
}
|
|
9015
|
+
for (const [cohort, cohortRuns] of [...byCohort.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
9016
|
+
const workflows = [...new Set(cohortRuns.flatMap((run) => run.workflowName.available ? [run.workflowName.value] : []))].toSorted();
|
|
9017
|
+
if (workflows.length > 1) warnings.push(`cohort "${cohort}" spans workflow shapes: ${workflows.join(", ")} — durations across different workflows are not comparable`);
|
|
9018
|
+
const models = [...new Set(cohortRuns.flatMap((run) => run.cpuModels.available ? run.cpuModels.value : []))].toSorted();
|
|
9019
|
+
if (models.length > 1) warnings.push(`cohort "${cohort}" spans CPU models: ${models.join(", ")} — stratify by cpuModel before comparing (see the CI runner analysis docs)`);
|
|
9020
|
+
}
|
|
9021
|
+
return warnings;
|
|
9022
|
+
};
|
|
9023
|
+
/**
|
|
9024
|
+
* Analyze the requested runs. Read-only: the client is only ever asked to GET.
|
|
9025
|
+
* One unreadable run never aborts the report — it is carried as an explicit
|
|
9026
|
+
* `unreadable` entry so a partially-served API answer stays auditable.
|
|
9027
|
+
*/
|
|
9028
|
+
const analyzeCiRuns = async (request, client, dependencies = {}) => {
|
|
9029
|
+
if (request.runs.length === 0) throw new Error("ci:analyze requires at least one run id (--run, --runs-file, or --cohort).");
|
|
9030
|
+
const runs = [];
|
|
9031
|
+
for (const entry of request.runs) runs.push(await analyzeOneRun(client, request, entry));
|
|
9032
|
+
return {
|
|
9033
|
+
cohorts: aggregateCohorts(runs),
|
|
9034
|
+
generatedAt: (dependencies.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
9035
|
+
repository: request.repository,
|
|
9036
|
+
runs,
|
|
9037
|
+
schemaVersion: 1,
|
|
9038
|
+
tool: CI_ANALYZE_TOOL,
|
|
9039
|
+
warnings: cohortWarnings(runs)
|
|
9040
|
+
};
|
|
9041
|
+
};
|
|
9042
|
+
const formatSeconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
9043
|
+
const formatMaybeDuration = (value) => value.available ? formatSeconds(value.value) : `unavailable (${value.reason})`;
|
|
9044
|
+
const CLASSIFICATION_HEADINGS = {
|
|
9045
|
+
"full-fallback": "full hosted fallback",
|
|
9046
|
+
mixed: "mixed proof outcomes",
|
|
9047
|
+
"proof-reuse": "proof reuse",
|
|
9048
|
+
unclassified: "no proof-gate signal"
|
|
9049
|
+
};
|
|
9050
|
+
const renderRunLine = (run) => {
|
|
9051
|
+
const lines = [
|
|
9052
|
+
` run ${run.runId} — ${run.workflowName.available ? run.workflowName.value : "unknown workflow"} (${run.conclusion.available ? run.conclusion.value : "no conclusion"})`,
|
|
9053
|
+
` total ${formatMaybeDuration(run.durations.totalMs)}, queue ${formatMaybeDuration(run.durations.queueMs)}`,
|
|
9054
|
+
` ${run.url.available ? run.url.value : "run URL unavailable"}`
|
|
9055
|
+
];
|
|
9056
|
+
if (run.cpuModels.available) lines.push(` cpu: ${run.cpuModels.value.join(", ")}`);
|
|
9057
|
+
if (run.profiles.available) for (const profile of run.profiles.value) {
|
|
9058
|
+
if (profile.kind === "unreadable") {
|
|
9059
|
+
lines.push(` profile ${profile.artifactName}: unreadable (${profile.reason})`);
|
|
9060
|
+
continue;
|
|
9061
|
+
}
|
|
9062
|
+
const p = profile.profile;
|
|
9063
|
+
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"}`);
|
|
9064
|
+
const [slowFile] = p.slowFiles;
|
|
9065
|
+
if (slowFile) lines.push(` slowest file ${slowFile.path} (${formatSeconds(slowFile.medianMs)} median)`);
|
|
9066
|
+
const [slowTest] = p.slowTests;
|
|
9067
|
+
if (slowTest) lines.push(` slowest test ${slowTest.name} (${formatSeconds(slowTest.medianMs)} median)`);
|
|
9068
|
+
}
|
|
9069
|
+
return lines;
|
|
9070
|
+
};
|
|
9071
|
+
/**
|
|
9072
|
+
* Human rendering. Structure mirrors the aggregation rule: one section per
|
|
9073
|
+
* cohort × classification cell, so full fallback and proof reuse are never
|
|
9074
|
+
* visually blended, and before/after cohorts stay separate.
|
|
9075
|
+
*/
|
|
9076
|
+
const renderCiAnalyzeReport = (report) => {
|
|
9077
|
+
const lines = [`ci:analyze — ${report.repository} (schema v${report.schemaVersion})`];
|
|
9078
|
+
const analyzed = report.runs.filter((run) => run.kind === "analyzed");
|
|
9079
|
+
for (const cohort of report.cohorts) {
|
|
9080
|
+
lines.push("", `cohort "${cohort.cohort}" — ${CLASSIFICATION_HEADINGS[cohort.classification]} (${cohort.count} run(s))`);
|
|
9081
|
+
if (cohort.totalDurationMs.available) {
|
|
9082
|
+
const stats = cohort.totalDurationMs.value;
|
|
9083
|
+
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)}`);
|
|
9084
|
+
} else lines.push(` total duration: unavailable (${cohort.totalDurationMs.reason})`);
|
|
9085
|
+
if (cohort.excludedRunIds.length > 0) lines.push(` excluded from aggregates (total unavailable): ${cohort.excludedRunIds.join(", ")}`);
|
|
9086
|
+
for (const run of analyzed.filter((candidate) => candidate.cohort === cohort.cohort && classificationOf(candidate) === cohort.classification)) lines.push(...renderRunLine(run));
|
|
9087
|
+
}
|
|
9088
|
+
const unreadable = report.runs.filter((run) => run.kind === "unreadable");
|
|
9089
|
+
const unreadableCohorts = [...new Set(unreadable.map((run) => run.cohort))].toSorted((left, right) => left.localeCompare(right));
|
|
9090
|
+
for (const cohort of unreadableCohorts) {
|
|
9091
|
+
lines.push("", `cohort "${cohort}" — unreadable runs:`);
|
|
9092
|
+
for (const run of unreadable.filter((candidate) => candidate.cohort === cohort)) lines.push(` run ${run.runId}: ${run.reason}`);
|
|
9093
|
+
}
|
|
9094
|
+
if (report.warnings.length > 0) {
|
|
9095
|
+
lines.push("", "warnings:");
|
|
9096
|
+
for (const warning of report.warnings) lines.push(` - ${warning}`);
|
|
9097
|
+
}
|
|
9098
|
+
return `${lines.join("\n")}\n`;
|
|
9099
|
+
};
|
|
9100
|
+
//#endregion
|
|
9101
|
+
//#region src/commands/ci-analyze.ts
|
|
9102
|
+
/**
|
|
9103
|
+
* `ci:analyze` command wiring (#647).
|
|
9104
|
+
*
|
|
9105
|
+
* The analysis itself lives in `../ci-analyze.js`; this module owns the flag
|
|
9106
|
+
* grammar, the run-id assembly (`--run`, `--runs-file`, `--cohort`), the
|
|
9107
|
+
* report output paths, and the default `gh`-backed {@link CiAnalyzeClient}.
|
|
9108
|
+
* Authentication is entirely the caller's: every request goes through the
|
|
9109
|
+
* `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
|
|
9110
|
+
* no credential is stored or minted here.
|
|
9111
|
+
*/
|
|
9112
|
+
const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
9113
|
+
const parseRepository = (value) => {
|
|
9114
|
+
if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
|
|
9115
|
+
return value;
|
|
9116
|
+
};
|
|
9117
|
+
const parseRunId = (value) => {
|
|
9118
|
+
const parsed = Number(value);
|
|
9119
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`run id must be a positive integer, got "${value}"`);
|
|
9120
|
+
return parsed;
|
|
9121
|
+
};
|
|
9122
|
+
const collectRunOption = (value, previous = []) => [...previous, parseRunId(value)];
|
|
9123
|
+
/**
|
|
9124
|
+
* `--cohort label=id,id` — the same `name=value` grammar the existing psf
|
|
9125
|
+
* flags use for structured values, repeatable once per label.
|
|
9126
|
+
*
|
|
9127
|
+
* The label `ungrouped` is reserved: it is what the report calls runs the
|
|
9128
|
+
* caller left unlabeled, so accepting it as a caller cohort would silently
|
|
9129
|
+
* blend a named population with the genuinely unlabeled one.
|
|
9130
|
+
*/
|
|
9131
|
+
const parseCohortOption = (value, previous = []) => {
|
|
9132
|
+
const separator = value.indexOf("=");
|
|
9133
|
+
const label = separator === -1 ? "" : value.slice(0, separator).trim();
|
|
9134
|
+
const ids = separator === -1 ? "" : value.slice(separator + 1);
|
|
9135
|
+
if (label === "" || ids.trim() === "") throw new Error(`--cohort must be <label>=<runId>[,<runId>...], got "${value}"`);
|
|
9136
|
+
if (label === "ungrouped") throw new Error(`--cohort label "${UNGROUPED_COHORT}" is reserved for runs passed without a cohort; pick another label`);
|
|
9137
|
+
return [...previous, {
|
|
9138
|
+
label,
|
|
9139
|
+
runIds: ids.split(",").map((id) => parseRunId(id.trim()))
|
|
9140
|
+
}];
|
|
9141
|
+
};
|
|
9142
|
+
/** One run id per line; blank lines and `#` comments are ignored. */
|
|
9143
|
+
const parseRunsFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map(parseRunId);
|
|
9144
|
+
/**
|
|
9145
|
+
* Assemble the requested run set. A run id may appear in exactly one cohort;
|
|
9146
|
+
* uncohorted ids (from `--run` / `--runs-file`) stay unlabeled. Conflicting
|
|
9147
|
+
* cohort labels for one id are a hard error rather than a silent pick.
|
|
9148
|
+
*/
|
|
9149
|
+
const assembleRunRequests = (input) => {
|
|
9150
|
+
const cohortByRun = /* @__PURE__ */ new Map();
|
|
9151
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) {
|
|
9152
|
+
const existing = cohortByRun.get(runId);
|
|
9153
|
+
if (existing !== void 0 && existing !== cohort.label) throw new Error(`run ${runId} is assigned to both cohort "${existing}" and cohort "${cohort.label}"`);
|
|
9154
|
+
cohortByRun.set(runId, cohort.label);
|
|
9155
|
+
}
|
|
9156
|
+
const ordered = [];
|
|
9157
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9158
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) if (!seen.has(runId)) {
|
|
9159
|
+
seen.add(runId);
|
|
9160
|
+
ordered.push({
|
|
9161
|
+
cohort: cohort.label,
|
|
9162
|
+
runId
|
|
9163
|
+
});
|
|
9164
|
+
}
|
|
9165
|
+
for (const runId of input.runIds) if (!seen.has(runId)) {
|
|
9166
|
+
seen.add(runId);
|
|
9167
|
+
ordered.push({
|
|
9168
|
+
cohort: cohortByRun.get(runId) ?? null,
|
|
9169
|
+
runId
|
|
9170
|
+
});
|
|
9171
|
+
}
|
|
9172
|
+
return ordered;
|
|
9173
|
+
};
|
|
9174
|
+
const GH_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024;
|
|
9175
|
+
const PAGE_SIZE = 100;
|
|
9176
|
+
const MAX_PAGES = 50;
|
|
9177
|
+
/**
|
|
9178
|
+
* Follow GitHub's pagination for one collection endpoint: request pages until
|
|
9179
|
+
* the served rows reach the response's `total_count` (or a page comes back
|
|
9180
|
+
* empty). The merged document keeps the `total_count` field, so if the page
|
|
9181
|
+
* budget is ever exhausted the analyzer's truncation guard reports the
|
|
9182
|
+
* shortfall as explicit unavailability instead of a silent partial read.
|
|
9183
|
+
*/
|
|
9184
|
+
const listActionsCollection = (basePath, key) => {
|
|
9185
|
+
const rows = [];
|
|
9186
|
+
let totalCount;
|
|
9187
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
9188
|
+
const response = runGhJson(["api", `${basePath}?per_page=${PAGE_SIZE}&page=${page}`]);
|
|
9189
|
+
const served = Array.isArray(response[key]) ? response[key] : [];
|
|
9190
|
+
rows.push(...served);
|
|
9191
|
+
totalCount = typeof response.total_count === "number" ? response.total_count : totalCount;
|
|
9192
|
+
if (served.length === 0 || rows.length >= (totalCount ?? 0)) break;
|
|
9193
|
+
}
|
|
9194
|
+
return {
|
|
9195
|
+
[key]: rows,
|
|
9196
|
+
total_count: totalCount ?? rows.length
|
|
9197
|
+
};
|
|
9198
|
+
};
|
|
9199
|
+
/**
|
|
9200
|
+
* A Unix-host entry line: permissions, zip version, host OS, decompressed
|
|
9201
|
+
* size, text/binary flags, method, date, time, then the member name (which
|
|
9202
|
+
* may itself contain spaces). This is the one form GitHub's artifact service
|
|
9203
|
+
* actually emits; other host forms are deliberately *not* supported — they
|
|
9204
|
+
* fail reconciliation below rather than being half-parsed.
|
|
9205
|
+
*/
|
|
9206
|
+
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;
|
|
9207
|
+
/** `Zip file size: 12109 bytes, number of entries: 2` */
|
|
9208
|
+
const ZIP_HEADER_PATTERN = /^Zip file size: \d+ bytes, number of entries: (?<count>\d+)$/u;
|
|
9209
|
+
/** `2 files, 81410 bytes uncompressed, 11689 bytes compressed: 85.6%` */
|
|
9210
|
+
const ZIP_TRAILER_PATTERN = /^(?<count>\d+) files?, \d+ bytes? uncompressed, \d+ bytes? compressed:\s+-?[\d.]+%$/u;
|
|
9211
|
+
/**
|
|
9212
|
+
* Parse zipinfo's default listing and *reconcile* it against the archive's
|
|
9213
|
+
* own declared entry count — fail closed, never a partial view.
|
|
9214
|
+
*
|
|
9215
|
+
* The security property the caps and member policy rest on is that the parsed
|
|
9216
|
+
* set is the WHOLE central directory. A listing line this parser does not
|
|
9217
|
+
* recognize (a FAT-host form, a future zipinfo change) therefore refuses the
|
|
9218
|
+
* whole artifact rather than being skipped: a skipped line would let
|
|
9219
|
+
* `unzip <name>` extract members the caps never counted. Three refusals:
|
|
9220
|
+
* an unrecognized line, a parsed count that differs from the declared count
|
|
9221
|
+
* (every header and trailer declaration must agree — a listing that
|
|
9222
|
+
* contradicts itself has no unambiguous count and is refused), and any
|
|
9223
|
+
* duplicate member name across the full parsed set — a parsed safe name
|
|
9224
|
+
* shadowed by an unparsed or differently-shaped duplicate is exactly the
|
|
9225
|
+
* bypass this closes.
|
|
9226
|
+
*/
|
|
9227
|
+
const parseZipInfoListing = (text) => {
|
|
9228
|
+
const members = [];
|
|
9229
|
+
const declaredCounts = [];
|
|
9230
|
+
for (const line of text.split("\n")) {
|
|
9231
|
+
if (line.trim() === "" || line.startsWith("Archive:")) continue;
|
|
9232
|
+
const header = ZIP_HEADER_PATTERN.exec(line);
|
|
9233
|
+
if (header?.groups) {
|
|
9234
|
+
declaredCounts.push(Number(header.groups.count));
|
|
9235
|
+
continue;
|
|
9236
|
+
}
|
|
9237
|
+
const trailer = ZIP_TRAILER_PATTERN.exec(line);
|
|
9238
|
+
if (trailer?.groups) {
|
|
9239
|
+
declaredCounts.push(Number(trailer.groups.count));
|
|
9240
|
+
continue;
|
|
9241
|
+
}
|
|
9242
|
+
const entry = ZIP_ENTRY_PATTERN.exec(line);
|
|
9243
|
+
if (entry?.groups) {
|
|
9244
|
+
members.push({
|
|
9245
|
+
isSymlink: entry.groups.perms?.startsWith("l") === true,
|
|
9246
|
+
name: entry.groups.name ?? "",
|
|
9247
|
+
sizeBytes: Number(entry.groups.size)
|
|
9248
|
+
});
|
|
9249
|
+
continue;
|
|
9250
|
+
}
|
|
9251
|
+
throw new Error(`zip listing reconciliation failed: unrecognized listing line "${line}"`);
|
|
9252
|
+
}
|
|
9253
|
+
const [declaredEntries] = declaredCounts;
|
|
9254
|
+
if (declaredEntries === void 0) throw new Error("zip listing reconciliation failed: the listing declares no entry count");
|
|
9255
|
+
if (declaredCounts.some((count) => count !== declaredEntries)) throw new Error(`zip listing reconciliation failed: contradictory declared entry counts (${declaredCounts.join(", ")})`);
|
|
9256
|
+
if (members.length !== declaredEntries) throw new Error(`zip listing reconciliation failed: parsed ${members.length} of ${declaredEntries} declared entries`);
|
|
9257
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9258
|
+
for (const member of members) {
|
|
9259
|
+
if (seen.has(member.name)) throw new Error(`zip listing reconciliation failed: duplicate member name "${member.name}"`);
|
|
9260
|
+
seen.add(member.name);
|
|
9261
|
+
}
|
|
9262
|
+
return members;
|
|
9263
|
+
};
|
|
9264
|
+
const MAX_ARTIFACT_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
9265
|
+
const UNSAFE_MEMBER_CHARACTERS = /[*?[\]]/u;
|
|
9266
|
+
/**
|
|
9267
|
+
* The member policy for artifact zips, as a pure function so the security
|
|
9268
|
+
* decisions are testable without `unzip`. The archive is refused outright —
|
|
9269
|
+
* not partially read — when any member is a symlink, an absolute path, a
|
|
9270
|
+
* `..` traversal, option-shaped (leading `-`), or glob-ambiguous, or when the
|
|
9271
|
+
* archive exceeds the count or decompressed-size caps. What survives is the
|
|
9272
|
+
* `*.json` member list actually handed to extraction.
|
|
9273
|
+
*/
|
|
9274
|
+
const selectProfileZipMembers = (members) => {
|
|
9275
|
+
if (members.length > 512) throw new Error(`artifact zip refused: ${members.length} members exceeds the 512-member cap`);
|
|
9276
|
+
let totalBytes = 0;
|
|
9277
|
+
for (const member of members) {
|
|
9278
|
+
if (member.isSymlink) throw new Error(`artifact zip refused: member "${member.name}" is a symlink`);
|
|
9279
|
+
if (member.name.startsWith("/") || /^[A-Za-z]:/u.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" is an absolute path`);
|
|
9280
|
+
if (member.name.split("/").includes("..")) throw new Error(`artifact zip refused: member "${member.name}" traverses with ..`);
|
|
9281
|
+
if (member.name.startsWith("-")) throw new Error(`artifact zip refused: member "${member.name}" is option-shaped`);
|
|
9282
|
+
if (UNSAFE_MEMBER_CHARACTERS.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" contains glob metacharacters`);
|
|
9283
|
+
if (!Number.isFinite(member.sizeBytes) || member.sizeBytes < 0) throw new Error(`artifact zip refused: member "${member.name}" reports no usable size`);
|
|
9284
|
+
totalBytes += member.sizeBytes;
|
|
9285
|
+
}
|
|
9286
|
+
if (totalBytes > 268435456) throw new Error(`artifact zip refused: ${totalBytes} decompressed bytes exceeds the ${MAX_ARTIFACT_DECOMPRESSED_BYTES}-byte cap`);
|
|
9287
|
+
return members.filter((member) => !member.name.endsWith("/") && member.name.endsWith(".json")).map((member) => member.name).toSorted();
|
|
9288
|
+
};
|
|
9289
|
+
/**
|
|
9290
|
+
* The default client: `gh api` for JSON endpoints (paginated), `gh api` plus
|
|
9291
|
+
* system `unzip` for artifact zips, with every member vetted by
|
|
9292
|
+
* {@link selectProfileZipMembers} before extraction. The exec adapter itself
|
|
9293
|
+
* is deliberately thin and untested networked code — every decision made from
|
|
9294
|
+
* these bytes lives behind {@link CiAnalyzeClient} or the pure policy
|
|
9295
|
+
* functions above, where fixtures drive it.
|
|
9296
|
+
*/
|
|
9297
|
+
const createGhCiAnalyzeClient = (repository) => ({
|
|
9298
|
+
getRun: (runId) => Promise.resolve(runGhJson(["api", `/repos/${repository}/actions/runs/${runId}`])),
|
|
9299
|
+
listArtifacts: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/artifacts`, "artifacts")),
|
|
9300
|
+
listJobs: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/jobs`, "jobs")),
|
|
9301
|
+
readArtifactTextFiles: (artifactId) => {
|
|
9302
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "psf-ci-analyze-"));
|
|
9303
|
+
try {
|
|
9304
|
+
const zip = execFileSync("gh", ["api", `/repos/${repository}/actions/artifacts/${artifactId}/zip`], { maxBuffer: GH_ARTIFACT_MAX_BYTES });
|
|
9305
|
+
const zipPath = path.join(scratch, "artifact.zip");
|
|
9306
|
+
writeFileSync(zipPath, zip);
|
|
9307
|
+
const selected = selectProfileZipMembers(parseZipInfoListing(execFileSync("unzip", ["-Z", zipPath], {
|
|
9308
|
+
encoding: "utf-8",
|
|
9309
|
+
maxBuffer: GH_ARTIFACT_MAX_BYTES
|
|
9310
|
+
})));
|
|
9311
|
+
if (selected.length === 0) return Promise.resolve([]);
|
|
9312
|
+
const extracted = path.join(scratch, "extracted");
|
|
9313
|
+
execFileSync("unzip", [
|
|
9314
|
+
"-o",
|
|
9315
|
+
"-qq",
|
|
9316
|
+
zipPath,
|
|
9317
|
+
"-d",
|
|
9318
|
+
extracted,
|
|
9319
|
+
...selected
|
|
9320
|
+
], { stdio: [
|
|
9321
|
+
"ignore",
|
|
9322
|
+
"ignore",
|
|
9323
|
+
"pipe"
|
|
9324
|
+
] });
|
|
9325
|
+
return Promise.resolve(selected.map((name) => ({
|
|
9326
|
+
name,
|
|
9327
|
+
text: readFileSync(path.join(extracted, name), "utf-8")
|
|
9328
|
+
})));
|
|
9329
|
+
} finally {
|
|
9330
|
+
rmSync(scratch, {
|
|
9331
|
+
force: true,
|
|
9332
|
+
recursive: true
|
|
9333
|
+
});
|
|
9334
|
+
}
|
|
9335
|
+
}
|
|
9336
|
+
});
|
|
9337
|
+
const defaultAction = (request) => analyzeCiRuns(request, createGhCiAnalyzeClient(request.repository));
|
|
9338
|
+
function createCiAnalyzeCommand(output, action = defaultAction) {
|
|
9339
|
+
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) => {
|
|
9340
|
+
const fromFile = options.runsFile === void 0 ? [] : parseRunsFile(readFileSync(path.resolve(options.runsFile), "utf-8"));
|
|
9341
|
+
const report = await action({
|
|
9342
|
+
includeProfiles: options.profiles === true,
|
|
9343
|
+
repository: options.repo,
|
|
9344
|
+
runs: assembleRunRequests({
|
|
9345
|
+
cohorts: options.cohort ?? [],
|
|
9346
|
+
runIds: [...options.run ?? [], ...fromFile]
|
|
9347
|
+
})
|
|
9348
|
+
});
|
|
9349
|
+
if (options.output !== void 0) {
|
|
9350
|
+
const outputPath = path.resolve(options.output);
|
|
9351
|
+
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
9352
|
+
output.stderr.write(`ci:analyze report written to ${outputPath}\n`);
|
|
9353
|
+
}
|
|
9354
|
+
output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCiAnalyzeReport(report));
|
|
9355
|
+
});
|
|
9356
|
+
}
|
|
8637
9357
|
//#endregion
|
|
8638
9358
|
//#region src/checkout-repository.ts
|
|
8639
9359
|
function normalizeRepositoryIdentity(repository) {
|
|
@@ -10929,6 +11649,7 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
10929
11649
|
if (error instanceof ImpactStampConfigError) throw error;
|
|
10930
11650
|
impactStamp = conservativeImpactStamp(profile.impact?.targets ?? [], [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
|
|
10931
11651
|
}
|
|
11652
|
+
for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
|
|
10932
11653
|
const vetoedTargets = boundFailingCheckNames({
|
|
10933
11654
|
candidate: {
|
|
10934
11655
|
headSha,
|
|
@@ -12890,6 +13611,13 @@ const prVerifyFollowUp = (authoringSession) => followUpFromArgv([
|
|
|
12890
13611
|
"pr:verify",
|
|
12891
13612
|
...authoringSession ? ["--authoring-session", authoringSession] : []
|
|
12892
13613
|
]);
|
|
13614
|
+
/** The route-owned Promotion from a GitHub draft into a Candidate. */
|
|
13615
|
+
const prUndraftFollowUp = (pr) => followUpFromArgv([
|
|
13616
|
+
"gh",
|
|
13617
|
+
"pr",
|
|
13618
|
+
"ready",
|
|
13619
|
+
String(pr)
|
|
13620
|
+
]);
|
|
12893
13621
|
/** Canonical zod schema for the optional follow-up field on JSON outputs. */
|
|
12894
13622
|
const FollowUpActionSchema = z.object({
|
|
12895
13623
|
argv: z.array(z.string()),
|
|
@@ -14005,15 +14733,18 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
14005
14733
|
command: `gh pr checks ${input.pr} --watch`
|
|
14006
14734
|
} } : {}
|
|
14007
14735
|
});
|
|
14008
|
-
if (input.draft)
|
|
14009
|
-
|
|
14010
|
-
|
|
14011
|
-
|
|
14012
|
-
|
|
14013
|
-
|
|
14014
|
-
|
|
14015
|
-
|
|
14016
|
-
|
|
14736
|
+
if (input.draft) {
|
|
14737
|
+
const promotion = prUndraftFollowUp(input.pr);
|
|
14738
|
+
blockers.push({
|
|
14739
|
+
demand: DEMAND_KEYS.draft,
|
|
14740
|
+
reason: DRAFT_BLOCKER_REASON,
|
|
14741
|
+
repair: {
|
|
14742
|
+
action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
|
|
14743
|
+
code: "undraft-pr",
|
|
14744
|
+
command: promotion.command
|
|
14745
|
+
}
|
|
14746
|
+
});
|
|
14747
|
+
}
|
|
14017
14748
|
return blockers;
|
|
14018
14749
|
};
|
|
14019
14750
|
const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
|
|
@@ -14677,12 +15408,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
14677
15408
|
]);
|
|
14678
15409
|
let followUp;
|
|
14679
15410
|
if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
|
|
14680
|
-
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp =
|
|
14681
|
-
"gh",
|
|
14682
|
-
"pr",
|
|
14683
|
-
"ready",
|
|
14684
|
-
String(args.pr)
|
|
14685
|
-
]);
|
|
15411
|
+
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = prUndraftFollowUp(args.pr);
|
|
14686
15412
|
else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
|
|
14687
15413
|
const notices = [
|
|
14688
15414
|
...evaluation.notices,
|
|
@@ -15481,6 +16207,50 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
|
|
|
15481
16207
|
...args,
|
|
15482
16208
|
throwWhenBlocked: false
|
|
15483
16209
|
}, dependencies);
|
|
16210
|
+
const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
|
|
16211
|
+
if (!sameHeadSha(actualHeadSha, expectedHeadSha)) throw new PrPublishTransactionAbortedError(`${phase}: live PR head ${actualHeadSha} differs from the proved publish head ${expectedHeadSha}. Re-run pr:publish against the current head.`);
|
|
16212
|
+
};
|
|
16213
|
+
/**
|
|
16214
|
+
* Promote a proved draft before the first Candidate demand evaluation (#649).
|
|
16215
|
+
* The route-owned action is shared with pr:ready; this helper only orders it.
|
|
16216
|
+
*/
|
|
16217
|
+
const promoteDraftBeforeDemandEvaluation = async ({ cwd, expectedHeadSha, factoryCliInvocation, fetchPr, pr, prSnapshot, runFollowUp }) => {
|
|
16218
|
+
if (!prSnapshot.isDraft) return { status: "not-requested" };
|
|
16219
|
+
const before = fetchPr();
|
|
16220
|
+
requirePromotionHead({
|
|
16221
|
+
actualHeadSha: before.headRefOid,
|
|
16222
|
+
expectedHeadSha,
|
|
16223
|
+
phase: "Promotion refused before transition"
|
|
16224
|
+
});
|
|
16225
|
+
if (!before.isDraft) return { status: "not-requested" };
|
|
16226
|
+
const promotion = prUndraftFollowUp(pr);
|
|
16227
|
+
try {
|
|
16228
|
+
await (runFollowUp ?? defaultFollowUpRunner(factoryCliInvocation))(promotion, cwd);
|
|
16229
|
+
} catch (error) {
|
|
16230
|
+
throw new PrPublishTransactionAbortedError(`Promotion failed (${promotion.command}): ${errorMessage(error)}`);
|
|
16231
|
+
}
|
|
16232
|
+
const after = fetchPr();
|
|
16233
|
+
requirePromotionHead({
|
|
16234
|
+
actualHeadSha: after.headRefOid,
|
|
16235
|
+
expectedHeadSha,
|
|
16236
|
+
phase: "Promotion invalidated during transition"
|
|
16237
|
+
});
|
|
16238
|
+
if (after.isDraft) throw new PrPublishTransactionAbortedError(`Promotion did not make PR #${pr} ready for review at proved head ${expectedHeadSha}. Re-run pr:publish after GitHub settles.`);
|
|
16239
|
+
return {
|
|
16240
|
+
action: promotion,
|
|
16241
|
+
reason: "Promotion precedes Candidate demand evaluation on the unchanged head",
|
|
16242
|
+
status: "succeeded"
|
|
16243
|
+
};
|
|
16244
|
+
};
|
|
16245
|
+
const requirePromotedProofHead = ({ expectedHeadSha, promotion, proof }) => {
|
|
16246
|
+
if (promotion.status !== "succeeded") return;
|
|
16247
|
+
requirePromotionHead({
|
|
16248
|
+
actualHeadSha: proof.ledger.headSha,
|
|
16249
|
+
expectedHeadSha,
|
|
16250
|
+
phase: "Promotion invalidated before Candidate demand evaluation completed"
|
|
16251
|
+
});
|
|
16252
|
+
};
|
|
16253
|
+
const candidateFollowUpAfterPromotion = (promotion, proof) => promotion.status === "not-requested" ? proof.followUp : void 0;
|
|
15484
16254
|
/**
|
|
15485
16255
|
* A proof with no follow-up that is not `ready` means `pr:ready` found the
|
|
15486
16256
|
* candidate blocked solely on a pending hosted check for the family excluded
|
|
@@ -15615,6 +16385,11 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15615
16385
|
reviewProofPath
|
|
15616
16386
|
});
|
|
15617
16387
|
const pr = fetchPr();
|
|
16388
|
+
requirePromotionHead({
|
|
16389
|
+
actualHeadSha: pr.headRefOid,
|
|
16390
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16391
|
+
phase: "Publish refused before managed body update"
|
|
16392
|
+
});
|
|
15618
16393
|
const parts = renderPrBodySectionParts({
|
|
15619
16394
|
reviewProof,
|
|
15620
16395
|
verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
|
|
@@ -15639,13 +16414,28 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15639
16414
|
json: false,
|
|
15640
16415
|
report: false
|
|
15641
16416
|
};
|
|
16417
|
+
const promotion = await promoteDraftBeforeDemandEvaluation({
|
|
16418
|
+
cwd,
|
|
16419
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16420
|
+
factoryCliInvocation,
|
|
16421
|
+
fetchPr,
|
|
16422
|
+
pr: prNumber,
|
|
16423
|
+
prSnapshot: pr,
|
|
16424
|
+
runFollowUp: dependencies.runFollowUp
|
|
16425
|
+
});
|
|
15642
16426
|
const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
16427
|
+
requirePromotedProofHead({
|
|
16428
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16429
|
+
promotion,
|
|
16430
|
+
proof: initialProof
|
|
16431
|
+
});
|
|
15643
16432
|
const initialHandoff = summarizeHumanHandoff(initialProof);
|
|
15644
|
-
let followUp =
|
|
16433
|
+
let followUp = promotion;
|
|
15645
16434
|
let currentProof = initialProof;
|
|
15646
|
-
|
|
16435
|
+
const candidateFollowUp = candidateFollowUpAfterPromotion(promotion, initialProof);
|
|
16436
|
+
if (candidateFollowUp) {
|
|
15647
16437
|
followUp = await settleFollowUp({
|
|
15648
|
-
action:
|
|
16438
|
+
action: candidateFollowUp,
|
|
15649
16439
|
cwd,
|
|
15650
16440
|
factoryCliInvocation,
|
|
15651
16441
|
onFailure: (failed, error) => {
|
|
@@ -15660,13 +16450,13 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15660
16450
|
},
|
|
15661
16451
|
plan: planPublishFollowUp({
|
|
15662
16452
|
binding: verifyProofBinding,
|
|
15663
|
-
followUp:
|
|
16453
|
+
followUp: candidateFollowUp,
|
|
15664
16454
|
proofHeadSha: verifyProof.headSha,
|
|
15665
16455
|
verifiedHeadSha: verified.verifiedHeadSha
|
|
15666
16456
|
}),
|
|
15667
16457
|
runFollowUp: dependencies.runFollowUp
|
|
15668
16458
|
});
|
|
15669
|
-
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(
|
|
16459
|
+
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(candidateFollowUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
15670
16460
|
}
|
|
15671
16461
|
const proof = await awaitCurrentReadyVerdict({
|
|
15672
16462
|
args: readyEvalArgs,
|
|
@@ -15717,7 +16507,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
|
|
|
15717
16507
|
//#endregion
|
|
15718
16508
|
//#region src/commands/pr-publish.ts
|
|
15719
16509
|
function createPrPublishCommand(_output, action = runPrPublish) {
|
|
15720
|
-
return markCwdOptionDefault(new Command("pr:publish").description("
|
|
16510
|
+
return markCwdOptionDefault(new Command("pr:publish").description("Promote a proved draft, then evaluate Candidate demands; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "epic issue containing the factory-boundary manifest; the composed readiness evaluation then applies the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--findings <path>", "clean-session findings to validate when no current review proof is available").option("--json", "print the publish result as JSON").option("--output <path>", "write readiness proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(async (options) => {
|
|
15721
16511
|
await action({
|
|
15722
16512
|
authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
|
|
15723
16513
|
base: options.base ?? "",
|
|
@@ -15843,6 +16633,146 @@ function modeFor(options) {
|
|
|
15843
16633
|
if (options.full) return "full";
|
|
15844
16634
|
return "auto";
|
|
15845
16635
|
}
|
|
16636
|
+
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
|
|
16637
|
+
const ZERO_SHA_PATTERN = /^0{40}$/u;
|
|
16638
|
+
const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
|
|
16639
|
+
basis: reason,
|
|
16640
|
+
name,
|
|
16641
|
+
state: "demanded"
|
|
16642
|
+
}));
|
|
16643
|
+
const refusal = ({ after, before, profile, reason }) => ({
|
|
16644
|
+
...after ? { afterSha: after } : {},
|
|
16645
|
+
basis: {
|
|
16646
|
+
changedFiles: [],
|
|
16647
|
+
classification: "refused",
|
|
16648
|
+
reasons: [reason]
|
|
16649
|
+
},
|
|
16650
|
+
...before ? { beforeSha: before } : {},
|
|
16651
|
+
decision: "refused",
|
|
16652
|
+
schemaVersion: 1,
|
|
16653
|
+
targets: demandedTargets(profile, reason),
|
|
16654
|
+
unsubscribedPaths: []
|
|
16655
|
+
});
|
|
16656
|
+
const assertCommitIdentity = (value, name) => {
|
|
16657
|
+
if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
|
|
16658
|
+
return value.toLowerCase();
|
|
16659
|
+
};
|
|
16660
|
+
const assertReachableCommit = (cwd, sha, name) => {
|
|
16661
|
+
try {
|
|
16662
|
+
runCapture("git", [
|
|
16663
|
+
"cat-file",
|
|
16664
|
+
"-e",
|
|
16665
|
+
`${sha}^{commit}`
|
|
16666
|
+
], cwd);
|
|
16667
|
+
} catch {
|
|
16668
|
+
throw new Error(`${name} commit ${sha} is not reachable in this checkout`);
|
|
16669
|
+
}
|
|
16670
|
+
};
|
|
16671
|
+
const writeGithubOutputs = (outputPath, decision) => {
|
|
16672
|
+
appendFileSync(outputPath, `${[
|
|
16673
|
+
`decision=${decision.decision}`,
|
|
16674
|
+
`basis=${JSON.stringify(decision.basis)}`,
|
|
16675
|
+
`unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
|
|
16676
|
+
...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
|
|
16677
|
+
].join("\n")}\n`, "utf-8");
|
|
16678
|
+
};
|
|
16679
|
+
/**
|
|
16680
|
+
* Recompute impact from one merge push's exact before/after commits. The
|
|
16681
|
+
* result may withdraw consumer work only; every doubt path returns a refused
|
|
16682
|
+
* decision whose known targets remain demanded.
|
|
16683
|
+
*/
|
|
16684
|
+
const runProductionImpact = (args) => {
|
|
16685
|
+
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
16686
|
+
let profile;
|
|
16687
|
+
let profilePath;
|
|
16688
|
+
let decision;
|
|
16689
|
+
try {
|
|
16690
|
+
const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
|
|
16691
|
+
cwd,
|
|
16692
|
+
profilePath: args.profilePath
|
|
16693
|
+
});
|
|
16694
|
+
profile = loadedProfile;
|
|
16695
|
+
profilePath = loadedPath;
|
|
16696
|
+
const before = assertCommitIdentity(args.before, "before");
|
|
16697
|
+
const after = assertCommitIdentity(args.after, "after");
|
|
16698
|
+
assertReachableCommit(cwd, before, "before");
|
|
16699
|
+
assertReachableCommit(cwd, after, "after");
|
|
16700
|
+
const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
|
|
16701
|
+
if (checkedOutHead !== after) throw new Error(`after commit ${after} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
|
|
16702
|
+
try {
|
|
16703
|
+
runCapture("git", [
|
|
16704
|
+
"merge-base",
|
|
16705
|
+
"--is-ancestor",
|
|
16706
|
+
before,
|
|
16707
|
+
after
|
|
16708
|
+
], cwd);
|
|
16709
|
+
} catch {
|
|
16710
|
+
throw new Error(`before commit ${before} is not an ancestor of after commit ${after}`);
|
|
16711
|
+
}
|
|
16712
|
+
const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
|
|
16713
|
+
"diff",
|
|
16714
|
+
"--find-renames",
|
|
16715
|
+
"--name-status",
|
|
16716
|
+
before,
|
|
16717
|
+
after
|
|
16718
|
+
], cwd).stdout))].toSorted();
|
|
16719
|
+
const stamp = computeImpactStamp({
|
|
16720
|
+
changedFiles,
|
|
16721
|
+
profile,
|
|
16722
|
+
profilePath: path.relative(cwd, profilePath),
|
|
16723
|
+
readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, path.join(cwd, LOCKFILE_PATH))
|
|
16724
|
+
});
|
|
16725
|
+
const usable = stamp.basis === "target-scoped";
|
|
16726
|
+
decision = {
|
|
16727
|
+
afterSha: after,
|
|
16728
|
+
basis: {
|
|
16729
|
+
changedFiles,
|
|
16730
|
+
classification: stamp.basis,
|
|
16731
|
+
reasons: stamp.reasons
|
|
16732
|
+
},
|
|
16733
|
+
beforeSha: before,
|
|
16734
|
+
decision: usable ? "usable" : "refused",
|
|
16735
|
+
schemaVersion: 1,
|
|
16736
|
+
targets: stamp.targets.map((target) => ({
|
|
16737
|
+
basis: target.basis,
|
|
16738
|
+
name: target.name,
|
|
16739
|
+
state: usable && target.impact === "not-affected" ? "withdrawn" : "demanded"
|
|
16740
|
+
})),
|
|
16741
|
+
unsubscribedPaths: stamp.unsubscribedPaths
|
|
16742
|
+
};
|
|
16743
|
+
} catch (error) {
|
|
16744
|
+
decision = refusal({
|
|
16745
|
+
after: args.after,
|
|
16746
|
+
before: args.before,
|
|
16747
|
+
profile,
|
|
16748
|
+
reason: `production impact classification refused: ${error instanceof Error ? error.message : String(error)}`
|
|
16749
|
+
});
|
|
16750
|
+
}
|
|
16751
|
+
if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
|
|
16752
|
+
if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
|
|
16753
|
+
return decision;
|
|
16754
|
+
};
|
|
16755
|
+
const productionImpactSummaryLines = (decision) => decision.basis.classification === "refused" ? [
|
|
16756
|
+
"Production impact: refused; all consumer work remains demanded.",
|
|
16757
|
+
...decision.basis.reasons,
|
|
16758
|
+
"Unsubscribed changed paths: unavailable because classification was refused."
|
|
16759
|
+
] : [`Production impact: ${decision.basis.classification}; ${decision.targets.filter(({ state }) => state === "demanded").length}/${decision.targets.length} target(s) demanded.`, decision.unsubscribedPaths.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${decision.unsubscribedPaths.join(", ")}`];
|
|
16760
|
+
//#endregion
|
|
16761
|
+
//#region src/commands/production-impact.ts
|
|
16762
|
+
function createProductionImpactCommand(output) {
|
|
16763
|
+
return markCwdOptionDefault(new Command("production:impact").description("Classify production targets from an exact merge-push before/after diff").option("--before <sha>", "merge-push before commit SHA").option("--after <sha>", "merge-push after commit SHA").option("--cwd <path>", "repository working directory", collectCwdOption).option("--profile <path>", "path to the project profile JSON file").option("--github-output <path>", "append stable decision outputs to a GitHub Actions output file").option("--github-summary <path>", "append a human-readable decision to a GitHub Actions summary file").option("--json", "print the complete decision as JSON").action((options) => {
|
|
16764
|
+
const decision = runProductionImpact({
|
|
16765
|
+
after: options.after,
|
|
16766
|
+
before: options.before,
|
|
16767
|
+
cwd: resolveCwdOption(options.cwd),
|
|
16768
|
+
githubOutput: options.githubOutput,
|
|
16769
|
+
githubSummary: options.githubSummary,
|
|
16770
|
+
profilePath: options.profile
|
|
16771
|
+
});
|
|
16772
|
+
if (options.json) output.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
|
|
16773
|
+
else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
|
|
16774
|
+
}));
|
|
16775
|
+
}
|
|
15846
16776
|
//#endregion
|
|
15847
16777
|
//#region src/worktree-scratch-files.ts
|
|
15848
16778
|
var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -16130,9 +17060,11 @@ function createProgram(options = {}) {
|
|
|
16130
17060
|
program.addCommand(createDoctorCommand(output));
|
|
16131
17061
|
program.addCommand(createConfigCommand(output));
|
|
16132
17062
|
program.addCommand(createEvidenceEmitCommand(output));
|
|
17063
|
+
program.addCommand(createCiAnalyzeCommand(output));
|
|
16133
17064
|
program.addCommand(createHqFlushCommand(output));
|
|
16134
17065
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
16135
17066
|
program.addCommand(createPrVerifyCommand(output));
|
|
17067
|
+
program.addCommand(createProductionImpactCommand(output));
|
|
16136
17068
|
program.addCommand(createCloseoutCommand(output));
|
|
16137
17069
|
program.addCommand(createEpicPublishStructureCommand(output));
|
|
16138
17070
|
program.addCommand(createPrReviewCommand(output, options.actions?.prReview));
|