@patronage/software-factory 1.0.0-alpha.6 → 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/README.md +1 -1
- package/dist/index.d.ts +22 -22
- package/dist/index.js +771 -44
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ A stacked release train has one terminal release-owning slice. Interior slices d
|
|
|
37
37
|
A stable cut has two phases. Phase 1 is the verified package candidate: steps 1 through 4 below, ending in the attended npm publish, tag, and GitHub release. Phase 2 is post-publication stable-doc convergence: steps 5 and 6, a normal admitted docs PR followed by the read-only convergence assertion from a clean `main`. Stable docs must never claim an unpublished version, so they cannot land in the phase-1 change. Prerelease behavior is unchanged: prerelease notes may land before publish while stable docs keep naming the prior stable version.
|
|
38
38
|
|
|
39
39
|
1. Bump `package.json` to valid SemVer. The assertion derives its expected stable or prerelease channel from the version; it accepts no channel flag.
|
|
40
|
-
2. Publish to npm per the [repository operations runbook](../docs/repository-operations.md#publishing) (attended, hand-cut, `pnpm publish` only).
|
|
40
|
+
2. Publish to npm per the [repository operations runbook](../docs/repository-operations.md#publishing) (attended, hand-cut, `pnpm publish` only). **Always pass `--tag` explicitly, on every publish, prerelease or stable** — `pnpm --filter @patronage/software-factory publish --tag next` for a prerelease, `--tag latest` for a stable cut. A filtered publish takes pnpm's recursive path, which passes its own `--tag` (defaulting to `latest`) to npm and therefore ignores the `publishConfig.tag: next` pin both factory packages carry; that is how 1.0.0-alpha.6 briefly took `latest` (#658). The pins stay as belt and braces, but they are not the control. Omitting the flag is not a safe failure: on the filtered command it moves `latest`, which the convergence assertion can only report after the fact.
|
|
41
41
|
3. Cut the `software-factory-vX.Y.Z` tag and push it.
|
|
42
42
|
4. Cut the GitHub release for that tag, with notes covering features, deletions, breaking changes, and closed issues. Mark it as a prerelease exactly when its SemVer version has a prerelease component. When the release changes the profile `schemaVersion`, state it in the notes — the profile schema version is a separate axis from the package version.
|
|
43
43
|
5. Converge the stable docs after publication. Update affected `software-factory-docs/` pages through a normal admitted docs PR. Stable docs must state the current stable version. Prerelease docs may record the candidate and its migration notes, but must not represent it as the current stable release.
|
package/dist/index.d.ts
CHANGED
|
@@ -743,8 +743,8 @@ declare const mergeFreezeStateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
743
743
|
generationId: z.ZodNumber;
|
|
744
744
|
headSha: z.ZodString;
|
|
745
745
|
outcome: z.ZodEnum<{
|
|
746
|
-
stale: "stale";
|
|
747
746
|
active: "active";
|
|
747
|
+
stale: "stale";
|
|
748
748
|
}>;
|
|
749
749
|
reason: z.ZodString;
|
|
750
750
|
recordedAt: z.ZodISODateTime;
|
|
@@ -1224,8 +1224,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1224
1224
|
"run-interior-cycle": "run-interior-cycle";
|
|
1225
1225
|
}>;
|
|
1226
1226
|
stage: z.ZodEnum<{
|
|
1227
|
-
interior: "interior";
|
|
1228
1227
|
gate: "gate";
|
|
1228
|
+
interior: "interior";
|
|
1229
1229
|
"interior-complete": "interior-complete";
|
|
1230
1230
|
}>;
|
|
1231
1231
|
}, z.core.$strip>>;
|
|
@@ -1241,10 +1241,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1241
1241
|
reviewedHeadSha: z.ZodOptional<z.ZodString>;
|
|
1242
1242
|
reviewedPatchId: z.ZodOptional<z.ZodString>;
|
|
1243
1243
|
status: z.ZodEnum<{
|
|
1244
|
-
"not-required": "not-required";
|
|
1245
1244
|
blocked: "blocked";
|
|
1246
|
-
|
|
1245
|
+
"not-required": "not-required";
|
|
1247
1246
|
stale: "stale";
|
|
1247
|
+
current: "current";
|
|
1248
1248
|
missing: "missing";
|
|
1249
1249
|
}>;
|
|
1250
1250
|
}, z.core.$strip>;
|
|
@@ -1253,10 +1253,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1253
1253
|
reviewedHeadSha: z.ZodOptional<z.ZodString>;
|
|
1254
1254
|
reviewedPatchId: z.ZodOptional<z.ZodString>;
|
|
1255
1255
|
status: z.ZodEnum<{
|
|
1256
|
-
"not-required": "not-required";
|
|
1257
1256
|
blocked: "blocked";
|
|
1258
|
-
|
|
1257
|
+
"not-required": "not-required";
|
|
1259
1258
|
stale: "stale";
|
|
1259
|
+
current: "current";
|
|
1260
1260
|
missing: "missing";
|
|
1261
1261
|
}>;
|
|
1262
1262
|
}, z.core.$strip>>;
|
|
@@ -1267,8 +1267,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
|
|
|
1267
1267
|
docsOnlyDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
|
|
1268
1268
|
docsOnlyVerifiedHeadSha: z.ZodOptional<z.ZodString>;
|
|
1269
1269
|
prVerify: z.ZodEnum<{
|
|
1270
|
-
passed: "passed";
|
|
1271
1270
|
stale: "stale";
|
|
1271
|
+
passed: "passed";
|
|
1272
1272
|
missing: "missing";
|
|
1273
1273
|
}>;
|
|
1274
1274
|
trivialDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -1768,10 +1768,10 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
|
|
|
1768
1768
|
//#region src/review-proof-applicability.d.ts
|
|
1769
1769
|
declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
|
|
1770
1770
|
declare const reviewStatusSchema: z.ZodEnum<{
|
|
1771
|
-
"not-required": "not-required";
|
|
1772
1771
|
blocked: "blocked";
|
|
1773
|
-
|
|
1772
|
+
"not-required": "not-required";
|
|
1774
1773
|
stale: "stale";
|
|
1774
|
+
current: "current";
|
|
1775
1775
|
missing: "missing";
|
|
1776
1776
|
}>;
|
|
1777
1777
|
type ReviewStatus = z.infer<typeof reviewStatusSchema>;
|
|
@@ -2177,9 +2177,9 @@ declare const retroEnvelopeV1Schema: z.ZodObject<{
|
|
|
2177
2177
|
kind: z.ZodLiteral<"retro-envelope">;
|
|
2178
2178
|
outcome: z.ZodOptional<z.ZodObject<{
|
|
2179
2179
|
status: z.ZodEnum<{
|
|
2180
|
+
blocked: "blocked";
|
|
2180
2181
|
success: "success";
|
|
2181
2182
|
fail: "fail";
|
|
2182
|
-
blocked: "blocked";
|
|
2183
2183
|
"ship-with-followups": "ship-with-followups";
|
|
2184
2184
|
}>;
|
|
2185
2185
|
verdict: z.ZodOptional<z.ZodString>;
|
|
@@ -2939,8 +2939,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
2939
2939
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
2940
2940
|
}, z.core.$strict>>;
|
|
2941
2941
|
stage: z.ZodEnum<{
|
|
2942
|
-
interior: "interior";
|
|
2943
2942
|
gate: "gate";
|
|
2943
|
+
interior: "interior";
|
|
2944
2944
|
}>;
|
|
2945
2945
|
usage: z.ZodOptional<z.ZodObject<{
|
|
2946
2946
|
estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
@@ -2992,8 +2992,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
2992
2992
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
2993
2993
|
}, z.core.$strict>>;
|
|
2994
2994
|
stage: z.ZodEnum<{
|
|
2995
|
-
interior: "interior";
|
|
2996
2995
|
gate: "gate";
|
|
2996
|
+
interior: "interior";
|
|
2997
2997
|
}>;
|
|
2998
2998
|
usage: z.ZodOptional<z.ZodObject<{
|
|
2999
2999
|
estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
@@ -3036,8 +3036,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3036
3036
|
sessionId: z.ZodOptional<z.ZodString>;
|
|
3037
3037
|
}, z.core.$strict>>;
|
|
3038
3038
|
stage: z.ZodEnum<{
|
|
3039
|
-
interior: "interior";
|
|
3040
3039
|
gate: "gate";
|
|
3040
|
+
interior: "interior";
|
|
3041
3041
|
}>;
|
|
3042
3042
|
usage: z.ZodOptional<z.ZodObject<{
|
|
3043
3043
|
estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
@@ -3067,7 +3067,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3067
3067
|
staleRepeats: number;
|
|
3068
3068
|
};
|
|
3069
3069
|
observedAt: string;
|
|
3070
|
-
stage: "
|
|
3070
|
+
stage: "gate" | "interior";
|
|
3071
3071
|
issue?: number | undefined;
|
|
3072
3072
|
pr?: number | undefined;
|
|
3073
3073
|
threadId?: string | undefined;
|
|
@@ -3323,8 +3323,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3323
3323
|
interiorCycle: z.ZodOptional<z.ZodNumber>;
|
|
3324
3324
|
observedAt: z.ZodString;
|
|
3325
3325
|
purpose: z.ZodEnum<{
|
|
3326
|
-
code: "code";
|
|
3327
3326
|
review: "review";
|
|
3327
|
+
code: "code";
|
|
3328
3328
|
}>;
|
|
3329
3329
|
slotResolution: z.ZodObject<{
|
|
3330
3330
|
effort: z.ZodEnum<{
|
|
@@ -3402,8 +3402,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3402
3402
|
interiorCycle: z.ZodOptional<z.ZodNumber>;
|
|
3403
3403
|
observedAt: z.ZodString;
|
|
3404
3404
|
purpose: z.ZodEnum<{
|
|
3405
|
-
code: "code";
|
|
3406
3405
|
review: "review";
|
|
3406
|
+
code: "code";
|
|
3407
3407
|
}>;
|
|
3408
3408
|
slotResolution: z.ZodObject<{
|
|
3409
3409
|
effort: z.ZodEnum<{
|
|
@@ -3472,8 +3472,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3472
3472
|
interiorCycle: z.ZodOptional<z.ZodNumber>;
|
|
3473
3473
|
observedAt: z.ZodString;
|
|
3474
3474
|
purpose: z.ZodEnum<{
|
|
3475
|
-
code: "code";
|
|
3476
3475
|
review: "review";
|
|
3476
|
+
code: "code";
|
|
3477
3477
|
}>;
|
|
3478
3478
|
slotResolution: z.ZodObject<{
|
|
3479
3479
|
effort: z.ZodEnum<{
|
|
@@ -3545,7 +3545,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3545
3545
|
exitCode: number | null;
|
|
3546
3546
|
harness: string;
|
|
3547
3547
|
observedAt: string;
|
|
3548
|
-
purpose: "
|
|
3548
|
+
purpose: "review" | "code";
|
|
3549
3549
|
slotResolution: {
|
|
3550
3550
|
effort: "high" | "low" | "medium" | "xhigh";
|
|
3551
3551
|
engine: string;
|
|
@@ -4066,13 +4066,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
|
|
|
4066
4066
|
reviews: {
|
|
4067
4067
|
correctness: {
|
|
4068
4068
|
required: boolean;
|
|
4069
|
-
status: "
|
|
4069
|
+
status: "blocked" | "not-required" | "stale" | "current" | "missing";
|
|
4070
4070
|
reviewedHeadSha?: string | undefined;
|
|
4071
4071
|
reviewedPatchId?: string | undefined;
|
|
4072
4072
|
};
|
|
4073
4073
|
security?: {
|
|
4074
4074
|
required: boolean;
|
|
4075
|
-
status: "
|
|
4075
|
+
status: "blocked" | "not-required" | "stale" | "current" | "missing";
|
|
4076
4076
|
reviewedHeadSha?: string | undefined;
|
|
4077
4077
|
reviewedPatchId?: string | undefined;
|
|
4078
4078
|
} | undefined;
|
|
@@ -4080,7 +4080,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
|
|
|
4080
4080
|
schemaVersion: 1;
|
|
4081
4081
|
verification: {
|
|
4082
4082
|
command: "patronage-factory pr:verify";
|
|
4083
|
-
prVerify: "
|
|
4083
|
+
prVerify: "stale" | "passed" | "missing";
|
|
4084
4084
|
docsOnlyDeltaAccepted?: boolean | undefined;
|
|
4085
4085
|
docsOnlyVerifiedHeadSha?: string | undefined;
|
|
4086
4086
|
trivialDeltaAccepted?: boolean | undefined;
|
|
@@ -4123,7 +4123,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
|
|
|
4123
4123
|
interior: number;
|
|
4124
4124
|
};
|
|
4125
4125
|
nextAction: "run-gate-cycle" | "accept-nonblocking-findings" | "escalate-to-triage" | "ready-for-human" | "advance-to-gate" | "run-interior-cycle";
|
|
4126
|
-
stage: "
|
|
4126
|
+
stage: "gate" | "interior" | "interior-complete";
|
|
4127
4127
|
forcedTransition?: "gate-cap-exhausted" | "interior-cap-reached" | undefined;
|
|
4128
4128
|
} | undefined;
|
|
4129
4129
|
reviewRuns?: PrReviewResult[] | undefined;
|
package/dist/index.js
CHANGED
|
@@ -8,16 +8,16 @@ import crypto, { createHash, randomUUID } from "node:crypto";
|
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
10
|
import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
11
|
-
import os, { homedir } from "node:os";
|
|
11
|
+
import os, { homedir, tmpdir } from "node:os";
|
|
12
12
|
import { setImmediate } from "node:timers";
|
|
13
13
|
import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
14
14
|
import { Worker } from "node:worker_threads";
|
|
15
15
|
import picomatch from "picomatch";
|
|
16
16
|
import { parse } from "yaml";
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
|
-
import { GitHubApiError, mintInstallationToken } from "@patronage/factory-ci";
|
|
18
|
+
import { FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, readVitestProfileDocument } from "@patronage/factory-ci";
|
|
19
19
|
//#region package.json
|
|
20
|
-
var version = "1.0.0-alpha.
|
|
20
|
+
var version = "1.0.0-alpha.8";
|
|
21
21
|
//#endregion
|
|
22
22
|
//#region src/review-rungs.ts
|
|
23
23
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -1082,7 +1082,7 @@ const factoryProjectProfileSchema = z.object({
|
|
|
1082
1082
|
mappedChecks.add(command.requiredCheck);
|
|
1083
1083
|
}
|
|
1084
1084
|
}).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
|
|
1085
|
-
const isRecord$
|
|
1085
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1086
1086
|
const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1087
1087
|
const quoteList = (values) => JSON.stringify(values);
|
|
1088
1088
|
const namesExistingFile = (root, entry) => {
|
|
@@ -1131,13 +1131,13 @@ const migratedPolicyLines = (policy, root) => {
|
|
|
1131
1131
|
*/
|
|
1132
1132
|
function v2MigrationLines(input, root) {
|
|
1133
1133
|
const lines = [];
|
|
1134
|
-
const proof = isRecord$
|
|
1135
|
-
if (proof && isRecord$
|
|
1136
|
-
const commands = isRecord$
|
|
1134
|
+
const proof = isRecord$2(input.proof) ? input.proof : void 0;
|
|
1135
|
+
if (proof && isRecord$2(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
|
|
1136
|
+
const commands = isRecord$2(input.verification) ? input.verification.commands : void 0;
|
|
1137
1137
|
if (Array.isArray(commands)) {
|
|
1138
|
-
for (const [index, command] of commands.entries()) if (isRecord$
|
|
1138
|
+
for (const [index, command] of commands.entries()) if (isRecord$2(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
|
|
1139
1139
|
}
|
|
1140
|
-
if (isRecord$
|
|
1140
|
+
if (isRecord$2(input.hq) && typeof input.hq.endpoint === "string") try {
|
|
1141
1141
|
const url = new URL(input.hq.endpoint);
|
|
1142
1142
|
if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
|
|
1143
1143
|
} catch {}
|
|
@@ -1149,25 +1149,25 @@ function hasCorrectnessOnlyReview(review) {
|
|
|
1149
1149
|
}
|
|
1150
1150
|
const v4MigrationLines = (input) => {
|
|
1151
1151
|
const lines = [];
|
|
1152
|
-
if (isRecord$
|
|
1153
|
-
if (isRecord$
|
|
1154
|
-
if (isRecord$
|
|
1155
|
-
if (isRecord$
|
|
1152
|
+
if (isRecord$2(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
|
|
1153
|
+
if (isRecord$2(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
|
|
1154
|
+
if (isRecord$2(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
|
|
1155
|
+
if (isRecord$2(input.review)) {
|
|
1156
1156
|
const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
|
|
1157
1157
|
if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
|
|
1158
1158
|
if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
|
|
1159
1159
|
if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
|
|
1160
1160
|
if (Array.isArray(input.review.conditional)) {
|
|
1161
|
-
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$
|
|
1161
|
+
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$2(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
|
|
1162
1162
|
}
|
|
1163
1163
|
}
|
|
1164
1164
|
if (Array.isArray(input.requiredChecks)) {
|
|
1165
|
-
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$
|
|
1165
|
+
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$2(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
|
|
1166
1166
|
}
|
|
1167
1167
|
return lines;
|
|
1168
1168
|
};
|
|
1169
1169
|
function legacyProfileMigration(input, root) {
|
|
1170
|
-
if (!(isRecord$
|
|
1170
|
+
if (!(isRecord$2(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
|
|
1171
1171
|
const foundVersion = input.schemaVersion;
|
|
1172
1172
|
const lines = [`schemaVersion ${foundVersion} -> 5`];
|
|
1173
1173
|
if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
|
|
@@ -3690,17 +3690,17 @@ const pnpmLockfileSchema = z.looseObject({
|
|
|
3690
3690
|
packages: z.record(z.string(), z.unknown()).optional(),
|
|
3691
3691
|
snapshots: z.record(z.string(), snapshotSectionSchema).optional()
|
|
3692
3692
|
});
|
|
3693
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3693
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3694
3694
|
const canonical = (value) => {
|
|
3695
3695
|
if (Array.isArray(value)) return value.map(canonical);
|
|
3696
|
-
if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3696
|
+
if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3697
3697
|
return value;
|
|
3698
3698
|
};
|
|
3699
3699
|
const stableStringify$1 = (value) => JSON.stringify(canonical(value));
|
|
3700
3700
|
const parseLockfile = (content) => {
|
|
3701
3701
|
try {
|
|
3702
3702
|
const parsed = parse(content);
|
|
3703
|
-
if (!isRecord(parsed)) return;
|
|
3703
|
+
if (!isRecord$1(parsed)) return;
|
|
3704
3704
|
const validated = pnpmLockfileSchema.safeParse(parsed);
|
|
3705
3705
|
return validated.success ? validated.data : void 0;
|
|
3706
3706
|
} catch {
|
|
@@ -8553,8 +8553,24 @@ function positiveInteger(name) {
|
|
|
8553
8553
|
};
|
|
8554
8554
|
}
|
|
8555
8555
|
function resolveCwdOption(cwd) {
|
|
8556
|
-
|
|
8557
|
-
|
|
8556
|
+
const value = typeof cwd === "string" ? cwd : ".";
|
|
8557
|
+
if (value.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
|
|
8558
|
+
return path.resolve(value);
|
|
8559
|
+
}
|
|
8560
|
+
function collectCwdOption(rawValue, previous) {
|
|
8561
|
+
if (previous === void 0) return rawValue;
|
|
8562
|
+
const previousResolved = resolveCwdOption(previous);
|
|
8563
|
+
const currentResolved = resolveCwdOption(rawValue);
|
|
8564
|
+
if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
|
|
8565
|
+
return rawValue;
|
|
8566
|
+
}
|
|
8567
|
+
function markCwdOptionDefault(command) {
|
|
8568
|
+
const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
|
|
8569
|
+
if (option) {
|
|
8570
|
+
option.defaultValue = ".";
|
|
8571
|
+
option.defaultValueDescription = ".";
|
|
8572
|
+
}
|
|
8573
|
+
return command;
|
|
8558
8574
|
}
|
|
8559
8575
|
function resolveCheckoutPath(cwd, target) {
|
|
8560
8576
|
return path.resolve(cwd, target);
|
|
@@ -8577,7 +8593,7 @@ const renderReport = (record) => {
|
|
|
8577
8593
|
return `${lines.join("\n")}\n`;
|
|
8578
8594
|
};
|
|
8579
8595
|
function createBoundaryCheckCommand(output, action) {
|
|
8580
|
-
return new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "epic issue number carrying the factory-boundary manifest", positiveInteger("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory",
|
|
8596
|
+
return markCwdOptionDefault(new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "epic issue number carrying the factory-boundary manifest", positiveInteger("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--output <path>", "write an additional proof copy to this path").option("--profile <path>", "path to the project profile JSON file").option("--json", "print the full proof record as JSON").action(withGateTiming({
|
|
8581
8597
|
gate: "boundary:check",
|
|
8582
8598
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
8583
8599
|
stderr: output.stderr
|
|
@@ -8616,7 +8632,710 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8616
8632
|
}
|
|
8617
8633
|
output.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : renderReport(record));
|
|
8618
8634
|
if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
|
|
8635
|
+
})));
|
|
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];
|
|
8619
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
|
+
});
|
|
8620
9339
|
}
|
|
8621
9340
|
//#endregion
|
|
8622
9341
|
//#region src/checkout-repository.ts
|
|
@@ -11219,7 +11938,7 @@ const runDemandWaive = (args, dependencies = {}) => {
|
|
|
11219
11938
|
* offers no way to declare a demand met, only to waive it on the record.
|
|
11220
11939
|
*/
|
|
11221
11940
|
function createDemandWaiveCommand(_output, action = runDemandWaive) {
|
|
11222
|
-
return new Command("demand:waive").description("Waive one resolved demand for one candidate, on the operator's identity, with the rationale recorded in the proof").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).requiredOption("--demand <key>", "resolved demand key, e.g. review-rung:human, required-check:core, merge-freeze").requiredOption("--rationale <text>", "why this demand is being waived").option("--cwd <path>", "working directory to evaluate",
|
|
11941
|
+
return markCwdOptionDefault(new Command("demand:waive").description("Waive one resolved demand for one candidate, on the operator's identity, with the rationale recorded in the proof").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).requiredOption("--demand <key>", "resolved demand key, e.g. review-rung:human, required-check:core, merge-freeze").requiredOption("--rationale <text>", "why this demand is being waived").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--json", "print the recorded waiver as JSON").option("--output <path>", "waiver record JSON path").action((options) => {
|
|
11223
11942
|
action({
|
|
11224
11943
|
cwd: resolveCwdOption(options.cwd),
|
|
11225
11944
|
demand: options.demand,
|
|
@@ -11228,7 +11947,7 @@ function createDemandWaiveCommand(_output, action = runDemandWaive) {
|
|
|
11228
11947
|
pr: options.pr,
|
|
11229
11948
|
rationale: options.rationale
|
|
11230
11949
|
});
|
|
11231
|
-
});
|
|
11950
|
+
}));
|
|
11232
11951
|
}
|
|
11233
11952
|
//#endregion
|
|
11234
11953
|
//#region src/demand-resolution.ts
|
|
@@ -11821,7 +12540,7 @@ const describeOrphans = (orphans) => {
|
|
|
11821
12540
|
return ` ${orphans.length} spool location(s) hold this repository's evidence under an earlier key it no longer resolves to — a key-format change strands evidence there where no drain looks: ${described}. Drain each with \`psf hq:flush --dir <path>\`.`;
|
|
11822
12541
|
};
|
|
11823
12542
|
/**
|
|
11824
|
-
*
|
|
12543
|
+
* Warns when HQ evidence is waiting for this repository: its own spool or a
|
|
11825
12544
|
* sibling location written under an earlier key of its own that it no longer
|
|
11826
12545
|
* resolves to (#420). Another repository's spool under the shared root is that
|
|
11827
12546
|
* repository's business, never this check's (#446).
|
|
@@ -11834,8 +12553,15 @@ const describeOrphans = (orphans) => {
|
|
|
11834
12553
|
* the spool resolve exactly one key, so evidence written under an older one is
|
|
11835
12554
|
* invisible to a drain and, before this, to doctor: the failure epic #389 was
|
|
11836
12555
|
* chartered to end is evidence that is neither delivered nor visibly stranded.
|
|
11837
|
-
* An orphan holding pending events
|
|
11838
|
-
* spool
|
|
12556
|
+
* An orphan holding pending events warns for the same reason the repo-keyed
|
|
12557
|
+
* spool does.
|
|
12558
|
+
*
|
|
12559
|
+
* `warning`, not `error` (#659, epic #663 wave 1): a spooled event is real
|
|
12560
|
+
* HQ-delivery lag, but doctor is a read-only diagnostic that must never gain a
|
|
12561
|
+
* flush side-effect, and a preflight gate that treats doctor's exit code as
|
|
12562
|
+
* the bar must not block admission on delivery lag it cannot fix from here.
|
|
12563
|
+
* `psf hq:flush` — a trusted local session, not doctor — is still how the
|
|
12564
|
+
* spool actually drains.
|
|
11839
12565
|
*/
|
|
11840
12566
|
async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
11841
12567
|
const countSpool = dependencies.countSpool ?? countHqSpoolWork;
|
|
@@ -11852,17 +12578,17 @@ async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
|
11852
12578
|
if (counted.pending > 0) return {
|
|
11853
12579
|
message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
|
|
11854
12580
|
name: HQ_SPOOL_CHECK_NAME,
|
|
11855
|
-
status: "
|
|
12581
|
+
status: "warning"
|
|
11856
12582
|
};
|
|
11857
12583
|
if (counted.unlistable > 0) return {
|
|
11858
12584
|
message: `${counted.unlistable} spool location(s) exist but could not be listed within budget, so this cannot be reported as empty.${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
|
|
11859
12585
|
name: HQ_SPOOL_CHECK_NAME,
|
|
11860
|
-
status: "
|
|
12586
|
+
status: "warning"
|
|
11861
12587
|
};
|
|
11862
12588
|
return {
|
|
11863
12589
|
message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
|
|
11864
12590
|
name: HQ_SPOOL_CHECK_NAME,
|
|
11865
|
-
status: "
|
|
12591
|
+
status: "warning"
|
|
11866
12592
|
};
|
|
11867
12593
|
}
|
|
11868
12594
|
const readJsonFiles = async (dir, readFileImpl) => {
|
|
@@ -12213,7 +12939,7 @@ function repositoryUrlMatches(origin, profile) {
|
|
|
12213
12939
|
//#endregion
|
|
12214
12940
|
//#region src/commands/doctor.ts
|
|
12215
12941
|
function createDoctorCommand(output) {
|
|
12216
|
-
return new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate",
|
|
12942
|
+
return markCwdOptionDefault(new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", collectCwdOption).option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
|
|
12217
12943
|
const report = await doctorProjectProfile({
|
|
12218
12944
|
base: options.base,
|
|
12219
12945
|
cwd: resolveCwdOption(options.cwd),
|
|
@@ -12223,7 +12949,7 @@ function createDoctorCommand(output) {
|
|
|
12223
12949
|
if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
12224
12950
|
else output.stdout.write(formatHumanReport(report));
|
|
12225
12951
|
if (!report.ok) process.exitCode = 1;
|
|
12226
|
-
});
|
|
12952
|
+
}));
|
|
12227
12953
|
}
|
|
12228
12954
|
function formatHumanReport(report) {
|
|
12229
12955
|
return [
|
|
@@ -12631,7 +13357,7 @@ function optionalString(key, flagValue, stdinValue) {
|
|
|
12631
13357
|
return resolved ? { [key]: resolved } : {};
|
|
12632
13358
|
}
|
|
12633
13359
|
function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput = readStdin) {
|
|
12634
|
-
return new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory",
|
|
13360
|
+
return markCwdOptionDefault(new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--base <ref>", "base branch or ref for the three-way binding", "origin/main").option("--check <name>", "declared requiredChecks name this evidence satisfies").option("--outcome <pass|fail>", "the check outcome").option("--producer <id>", "the producing identity (recorded, not gated)").option("--policy-version <version>", "canonical prompt / Warden rules version").option("--session-id <id>", "producer session id (independence join key)").option("--request-id <id>", "producer request id (join key)").option("--findings-pointer <ref>", "pointer to findings (never embedded)").option("--profile <path>", "path to the project profile JSON file (accepted for a uniform consumer-wrapper flag set and loaded strictly when supplied)").option("--output <path>", "write the envelope to a specific path").option("--stdin", "merge JSON field values from stdin (flags win)").option("--json", "print the written envelope as JSON").action((options) => {
|
|
12635
13361
|
const cwd = resolveCwdOption(options.cwd);
|
|
12636
13362
|
const fromStdin = options.stdin ? readInput() : {};
|
|
12637
13363
|
rejectRetiredStdinFields(fromStdin);
|
|
@@ -12658,7 +13384,7 @@ function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput =
|
|
|
12658
13384
|
});
|
|
12659
13385
|
output.stdout.write(`evidence:emit wrote envelope for "${result.envelope.check}" to ${result.path}\n`);
|
|
12660
13386
|
if (options.json && "envelope" in result) output.stdout.write(`${JSON.stringify(result.envelope, null, 2)}\n`);
|
|
12661
|
-
});
|
|
13387
|
+
}));
|
|
12662
13388
|
}
|
|
12663
13389
|
//#endregion
|
|
12664
13390
|
//#region src/hq-flush.ts
|
|
@@ -12821,7 +13547,7 @@ const hqFlushExitCode = (result) => {
|
|
|
12821
13547
|
//#endregion
|
|
12822
13548
|
//#region src/commands/hq-flush.ts
|
|
12823
13549
|
function createHqFlushCommand(output, action = runHqFlush) {
|
|
12824
|
-
return new Command("hq:flush").description("Drain this repository's spooled HQ evidence and report every event; exits 1 when transport failed and 2 when the drain did not finish").option("--cwd <path>", "working directory to evaluate",
|
|
13550
|
+
return markCwdOptionDefault(new Command("hq:flush").description("Drain this repository's spooled HQ evidence and report every event; exits 1 when transport failed and 2 when the drain did not finish").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--dir <path>", "drain an explicit spool directory instead of the default locations; repeatable", (value, previous = []) => [...previous, value]).option("--json", "print the flush result as JSON").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
|
|
12825
13551
|
const result = await action({
|
|
12826
13552
|
cwd: resolveCwdOption(options.cwd),
|
|
12827
13553
|
...options.dir ? { dir: options.dir } : {},
|
|
@@ -12831,7 +13557,7 @@ function createHqFlushCommand(output, action = runHqFlush) {
|
|
|
12831
13557
|
output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
|
|
12832
13558
|
const exitCode = hqFlushExitCode(result);
|
|
12833
13559
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
12834
|
-
});
|
|
13560
|
+
}));
|
|
12835
13561
|
}
|
|
12836
13562
|
//#endregion
|
|
12837
13563
|
//#region src/follow-up.ts
|
|
@@ -15694,7 +16420,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
|
|
|
15694
16420
|
//#endregion
|
|
15695
16421
|
//#region src/commands/pr-publish.ts
|
|
15696
16422
|
function createPrPublishCommand(_output, action = runPrPublish) {
|
|
15697
|
-
return new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; 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",
|
|
16423
|
+
return markCwdOptionDefault(new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; 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) => {
|
|
15698
16424
|
await action({
|
|
15699
16425
|
authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
|
|
15700
16426
|
base: options.base ?? "",
|
|
@@ -15708,12 +16434,12 @@ function createPrPublishCommand(_output, action = runPrPublish) {
|
|
|
15708
16434
|
reviewProof: options.reviewProof,
|
|
15709
16435
|
verifyProof: options.verifyProof
|
|
15710
16436
|
});
|
|
15711
|
-
});
|
|
16437
|
+
}));
|
|
15712
16438
|
}
|
|
15713
16439
|
//#endregion
|
|
15714
16440
|
//#region src/commands/pr-ready.ts
|
|
15715
16441
|
function createPrReadyCommand(output, action) {
|
|
15716
|
-
return new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate",
|
|
16442
|
+
return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "epic issue containing the factory-boundary manifest; readiness then evaluates the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write 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(withGateTiming({
|
|
15717
16443
|
gate: "pr:ready",
|
|
15718
16444
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
15719
16445
|
stderr: output.stderr
|
|
@@ -15731,7 +16457,7 @@ function createPrReadyCommand(output, action) {
|
|
|
15731
16457
|
verifyProof: options.verifyProof
|
|
15732
16458
|
};
|
|
15733
16459
|
await (action ? action(args) : runPrReady(args));
|
|
15734
|
-
}));
|
|
16460
|
+
})));
|
|
15735
16461
|
}
|
|
15736
16462
|
//#endregion
|
|
15737
16463
|
//#region src/gate-foreground-guard.ts
|
|
@@ -15760,7 +16486,7 @@ function assertForegroundGate({ gate, env = process.env }) {
|
|
|
15760
16486
|
//#endregion
|
|
15761
16487
|
//#region src/commands/pr-review.ts
|
|
15762
16488
|
function createPrReviewCommand(output, action) {
|
|
15763
|
-
return new Command("pr:review").description("Validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review",
|
|
16489
|
+
return markCwdOptionDefault(new Command("pr:review").description("Validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", collectCwdOption).option("--cycle <number>", "review cycle number", positiveInteger("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--findings <path>", "typed clean-session findings JSON file").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings").option("--issue <number>", "issue number recorded on the review ladder trace", positiveInteger("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
|
|
15764
16490
|
gate: "pr:review",
|
|
15765
16491
|
resolveCycle: (options) => options.cycle,
|
|
15766
16492
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
@@ -15785,12 +16511,12 @@ function createPrReviewCommand(output, action) {
|
|
|
15785
16511
|
verifyProof: options.verifyProof
|
|
15786
16512
|
};
|
|
15787
16513
|
await (action ? action(args) : runPrReview(args, { publishCheckRun: createFactoryCheckPublisher(output) }));
|
|
15788
|
-
}));
|
|
16514
|
+
})));
|
|
15789
16515
|
}
|
|
15790
16516
|
//#endregion
|
|
15791
16517
|
//#region src/commands/pr-verify.ts
|
|
15792
16518
|
function createPrVerifyCommand(output, action = runPrVerify) {
|
|
15793
|
-
return new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify",
|
|
16519
|
+
return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--trivial", "run the trivial verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
|
|
15794
16520
|
gate: "pr:verify",
|
|
15795
16521
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
15796
16522
|
stderr: output.stderr
|
|
@@ -15812,7 +16538,7 @@ function createPrVerifyCommand(output, action = runPrVerify) {
|
|
|
15812
16538
|
requireKnownAuthoringSession: true
|
|
15813
16539
|
}, dependencies);
|
|
15814
16540
|
if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
|
|
15815
|
-
}));
|
|
16541
|
+
})));
|
|
15816
16542
|
}
|
|
15817
16543
|
function modeFor(options) {
|
|
15818
16544
|
if (options.docsOnly) return "docs-only";
|
|
@@ -16107,6 +16833,7 @@ function createProgram(options = {}) {
|
|
|
16107
16833
|
program.addCommand(createDoctorCommand(output));
|
|
16108
16834
|
program.addCommand(createConfigCommand(output));
|
|
16109
16835
|
program.addCommand(createEvidenceEmitCommand(output));
|
|
16836
|
+
program.addCommand(createCiAnalyzeCommand(output));
|
|
16110
16837
|
program.addCommand(createHqFlushCommand(output));
|
|
16111
16838
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
16112
16839
|
program.addCommand(createPrVerifyCommand(output));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/software-factory",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
4
4
|
"description": "Shared Patronage software factory CLI and project-profile validation tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"picomatch": "^4.0.5",
|
|
38
38
|
"yaml": "^2.9.0",
|
|
39
39
|
"zod": "4.4.3",
|
|
40
|
-
"@patronage/factory-ci": "1.0.0-alpha.
|
|
40
|
+
"@patronage/factory-ci": "1.0.0-alpha.8"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "24.13.3",
|