@patronage/software-factory 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +14 -6
- package/dist/index.d.ts +26 -27
- package/dist/index.js +1299 -491
- package/dist/schemas.d.ts +6 -4
- package/dist/schemas.js +10 -8
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { t as __exportAll } from "./chunk-pbuEa-1d.js";
|
|
3
|
-
import { appendFileSync, constants, cpSync,
|
|
3
|
+
import { appendFileSync, constants, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { Command, InvalidArgumentError } from "commander";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import crypto, { createHash,
|
|
7
|
+
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";
|
|
12
|
-
import { createInterface } from "node:readline";
|
|
11
|
+
import os, { homedir, tmpdir } from "node:os";
|
|
13
12
|
import { setImmediate } from "node:timers";
|
|
14
13
|
import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
15
14
|
import { Worker } from "node:worker_threads";
|
|
16
15
|
import picomatch from "picomatch";
|
|
17
16
|
import { parse } from "yaml";
|
|
18
17
|
import { promisify } from "node:util";
|
|
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.10";
|
|
21
21
|
//#endregion
|
|
22
22
|
//#region src/review-rungs.ts
|
|
23
23
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -44,6 +44,21 @@ const FACTORY_BOUNDARY_FENCE_LANG = "factory-boundary";
|
|
|
44
44
|
const BOUNDARY_CLOSEOUT_DEFAULT_RUNG = "oracle";
|
|
45
45
|
const rungMeetsMinimum = (rung, minimum) => RUNG_ORDER[rung] >= RUNG_ORDER[minimum];
|
|
46
46
|
const rungSchema = z.enum(EVIDENCE_REVIEW_RUNGS$1);
|
|
47
|
+
const BOUNDARY_TOPOLOGIES = [
|
|
48
|
+
"each-to-main",
|
|
49
|
+
"flagged",
|
|
50
|
+
"stacked",
|
|
51
|
+
"each-to-epic"
|
|
52
|
+
];
|
|
53
|
+
const isValidEpicIntegrationBranch = (branch) => {
|
|
54
|
+
if (!branch.startsWith("epic/") || branch.length === 5) return false;
|
|
55
|
+
if (branch.includes("//") || branch.includes("..") || branch.includes("@{") || branch.endsWith("/")) return false;
|
|
56
|
+
for (const character of branch) {
|
|
57
|
+
const codePoint = character.codePointAt(0);
|
|
58
|
+
if (codePoint === void 0 || codePoint <= 32 || codePoint === 127 || "~^:?*[\\".includes(character)) return false;
|
|
59
|
+
}
|
|
60
|
+
return branch.split("/").every((component) => component.length > 0 && !component.startsWith(".") && !component.endsWith(".") && !component.endsWith(".lock"));
|
|
61
|
+
};
|
|
47
62
|
const GITHUB_LOGIN_PATTERN = /^[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d])){0,38}(?:\[[bB][oO][tT]\])?$/u;
|
|
48
63
|
const waveSchema = z.object({
|
|
49
64
|
autoMerge: z.boolean().optional(),
|
|
@@ -55,15 +70,17 @@ const boundaryManifestSchema = z.object({
|
|
|
55
70
|
boundary: z.string().min(1),
|
|
56
71
|
closeout: z.object({ review: rungSchema }).passthrough().optional(),
|
|
57
72
|
declaredBy: z.string().regex(GITHUB_LOGIN_PATTERN, { message: "must be a GitHub login (letters, digits, single hyphens, max 39 chars), optionally suffixed with \"[bot]\" for a GitHub App identity — not an email address or display name" }),
|
|
73
|
+
integrationBranch: z.string().refine(isValidEpicIntegrationBranch, { message: "must name a valid non-empty Git branch under \"epic/**\" (git check-ref-format --branch rules)" }).optional(),
|
|
58
74
|
prs: z.record(z.string().regex(/^[0-9]+$/u), z.string().min(1)).optional(),
|
|
59
75
|
schemaVersion: z.literal(1),
|
|
60
|
-
topology: z.enum(
|
|
61
|
-
"each-to-main",
|
|
62
|
-
"flagged",
|
|
63
|
-
"stacked"
|
|
64
|
-
]),
|
|
76
|
+
topology: z.enum(BOUNDARY_TOPOLOGIES),
|
|
65
77
|
waves: z.array(waveSchema).min(1)
|
|
66
78
|
}).passthrough().superRefine((manifest, context) => {
|
|
79
|
+
if (manifest.topology === "each-to-epic" && manifest.integrationBranch === void 0) context.addIssue({
|
|
80
|
+
code: "custom",
|
|
81
|
+
message: "topology \"each-to-epic\" requires the shared epic/** branch name",
|
|
82
|
+
path: ["integrationBranch"]
|
|
83
|
+
});
|
|
67
84
|
const names = manifest.waves.map((wave) => wave.name);
|
|
68
85
|
const seen = /* @__PURE__ */ new Set();
|
|
69
86
|
for (const name of names) {
|
|
@@ -233,6 +250,69 @@ const boundaryReviewProofAuthorsSeen = (comments, boundary) => {
|
|
|
233
250
|
};
|
|
234
251
|
const undispositionedBlockingFindings = (proof) => proof.findings.filter((finding) => (finding.category !== "maintainability" || finding.blockingAfterCap === true) && finding.disposition === void 0).map((finding) => `boundary finding "${finding.title}" (category ${finding.category ?? "unknown"}) has no disposition; fix it or record waived / follow-up-filed`);
|
|
235
252
|
//#endregion
|
|
253
|
+
//#region src/boundary-topology.ts
|
|
254
|
+
const eachToEpicBranch = (manifest) => manifest.topology === "each-to-epic" ? manifest.integrationBranch : void 0;
|
|
255
|
+
/**
|
|
256
|
+
* Validate only the current member during admission. This deliberately does
|
|
257
|
+
* not inspect sibling membership or require the terminal PR: an epic branch
|
|
258
|
+
* is assembled one admitted interior candidate at a time.
|
|
259
|
+
*/
|
|
260
|
+
const pullRequestTopologyReasons = ({ manifest, pullRequest }) => {
|
|
261
|
+
const integrationBranch = eachToEpicBranch(manifest);
|
|
262
|
+
if (!integrationBranch) return [];
|
|
263
|
+
const { baseRefName, headRefName, number } = pullRequest;
|
|
264
|
+
if (!headRefName) return [`PR #${number} has no live head branch name; each-to-epic admission fails closed until GitHub reports the member's headRefName.`];
|
|
265
|
+
if (baseRefName === integrationBranch && headRefName !== integrationBranch) return [];
|
|
266
|
+
if (baseRefName === "main" && headRefName === integrationBranch) return [];
|
|
267
|
+
return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or the terminal ${integrationBranch} -> main PR.`];
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Validate the complete declared member set at boundary closeout. Callers
|
|
271
|
+
* supply only issue-linked and explicit-override PRs; branch names classify
|
|
272
|
+
* those authoritative members' roles but never discover membership.
|
|
273
|
+
*/
|
|
274
|
+
const boundaryTopologyReasons = ({ manifest, pullRequests }) => {
|
|
275
|
+
const integrationBranch = eachToEpicBranch(manifest);
|
|
276
|
+
if (!integrationBranch) return [];
|
|
277
|
+
const reasons = [];
|
|
278
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
279
|
+
for (const pullRequest of pullRequests) {
|
|
280
|
+
const previous = byNumber.get(pullRequest.number);
|
|
281
|
+
if (previous && (previous.baseRefName !== pullRequest.baseRefName || previous.headRefName !== pullRequest.headRefName)) {
|
|
282
|
+
reasons.push(`PR #${pullRequest.number} has contradictory live branch data across its declared memberships.`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
byNumber.set(pullRequest.number, pullRequest);
|
|
286
|
+
}
|
|
287
|
+
const terminalPullRequests = [];
|
|
288
|
+
for (const pullRequest of byNumber.values()) {
|
|
289
|
+
if (!pullRequest.headRefName) {
|
|
290
|
+
reasons.push(`PR #${pullRequest.number} has no live head branch name; each-to-epic closeout fails closed until GitHub reports headRefName.`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (pullRequest.state === "CLOSED") {
|
|
294
|
+
reasons.push(`PR #${pullRequest.number} is closed without merge and cannot satisfy the each-to-epic branch graph.`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (pullRequest.baseRefName === integrationBranch) {
|
|
298
|
+
if (pullRequest.state === "OPEN") reasons.push(`PR #${pullRequest.number} is an open interior member targeting ${integrationBranch}; each-to-epic closeout requires every interior member to be merged before the integration branch is complete.`);
|
|
299
|
+
if (pullRequest.headRefName === integrationBranch) reasons.push(`PR #${pullRequest.number} cannot use ${integrationBranch} as both head and base.`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (pullRequest.baseRefName === "main") {
|
|
303
|
+
if (pullRequest.headRefName !== integrationBranch) {
|
|
304
|
+
reasons.push(`PR #${pullRequest.number} targets main from ${pullRequest.headRefName ?? "(unknown)"}; the each-to-epic terminal must originate from declared integrationBranch ${integrationBranch}.`);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
terminalPullRequests.push(pullRequest.number);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
reasons.push(`PR #${pullRequest.number} targets ${pullRequest.baseRefName ?? "(unknown)"}; every each-to-epic interior member must target declared integrationBranch ${integrationBranch}.`);
|
|
311
|
+
}
|
|
312
|
+
if (terminalPullRequests.length !== 1) reasons.push(`each-to-epic requires exactly one terminal member PR from ${integrationBranch} to main; found ${terminalPullRequests.length}.`);
|
|
313
|
+
return reasons;
|
|
314
|
+
};
|
|
315
|
+
//#endregion
|
|
236
316
|
//#region src/comment-provenance.ts
|
|
237
317
|
var comment_provenance_exports = /* @__PURE__ */ __exportAll({
|
|
238
318
|
SHA_MATCH_MIN_LENGTH: () => 7,
|
|
@@ -894,8 +974,15 @@ function isValidGlob(pattern) {
|
|
|
894
974
|
return false;
|
|
895
975
|
}
|
|
896
976
|
}
|
|
977
|
+
function isRepoRelativePath(value) {
|
|
978
|
+
const segments = value.split("/");
|
|
979
|
+
return value.length > 0 && !value.startsWith("/") && !/^[A-Za-z]:\//u.test(value) && !value.includes("\\") && !segments.some((segment) => segment === "" || segment === "." || segment === "..");
|
|
980
|
+
}
|
|
981
|
+
function isValidRepoRelativeGlob(pattern) {
|
|
982
|
+
return isRepoRelativePath(pattern) && !(pattern.startsWith("!") && !pattern.startsWith("!(")) && isValidGlob(pattern);
|
|
983
|
+
}
|
|
897
984
|
function matchesAnyGlob(patterns, file) {
|
|
898
|
-
return patterns.length > 0 && picomatch.isMatch(file, patterns);
|
|
985
|
+
return patterns.length > 0 && picomatch.isMatch(file, [...patterns]);
|
|
899
986
|
}
|
|
900
987
|
//#endregion
|
|
901
988
|
//#region src/diff-classification.ts
|
|
@@ -939,8 +1026,9 @@ const LEGACY_PROFILE_SCHEMA_VERSIONS = [
|
|
|
939
1026
|
3,
|
|
940
1027
|
4
|
|
941
1028
|
];
|
|
942
|
-
const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema.";
|
|
1029
|
+
const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema. Impact target path patterns encode repo-relative safety in JSON Schema, while complete picomatch syntax validity remains a runtime-only refinement.";
|
|
943
1030
|
const globSchema = z.string().min(1).refine(isValidGlob, "must be a valid glob");
|
|
1031
|
+
const repoRelativeGlobSchema = z.string().min(1).regex(/^(?!(?:\/[\s\S]*|[A-Za-z]:\/[\s\S]*|!(?!\()[\s\S]*|[\s\S]*\\[\s\S]*|[\s\S]*\/\/[\s\S]*|[\s\S]*\/$|\.{1,2}(?:\/|$)[\s\S]*|[\s\S]*\/\.{1,2}(?:\/|$)[\s\S]*)).+$/u, "must be a repo-relative positive glob").refine(isValidRepoRelativeGlob, "must be a valid repo-relative glob");
|
|
944
1032
|
const commandSchema = z.object({
|
|
945
1033
|
command: z.string().min(1),
|
|
946
1034
|
description: z.string().min(1),
|
|
@@ -972,7 +1060,8 @@ const requiredCheckSchema = z.object({
|
|
|
972
1060
|
}).strict();
|
|
973
1061
|
const impactTargetSchema = z.object({
|
|
974
1062
|
importers: z.array(z.string().min(1)).min(1),
|
|
975
|
-
name: z.string().min(1)
|
|
1063
|
+
name: z.string().min(1),
|
|
1064
|
+
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.")
|
|
976
1065
|
}).strict();
|
|
977
1066
|
const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
|
|
978
1067
|
const names = targets.map((target) => target.name);
|
|
@@ -1073,7 +1162,7 @@ const factoryProjectProfileSchema = z.object({
|
|
|
1073
1162
|
mappedChecks.add(command.requiredCheck);
|
|
1074
1163
|
}
|
|
1075
1164
|
}).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
|
|
1076
|
-
const isRecord$
|
|
1165
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1077
1166
|
const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1078
1167
|
const quoteList = (values) => JSON.stringify(values);
|
|
1079
1168
|
const namesExistingFile = (root, entry) => {
|
|
@@ -1122,13 +1211,13 @@ const migratedPolicyLines = (policy, root) => {
|
|
|
1122
1211
|
*/
|
|
1123
1212
|
function v2MigrationLines(input, root) {
|
|
1124
1213
|
const lines = [];
|
|
1125
|
-
const proof = isRecord$
|
|
1126
|
-
if (proof && isRecord$
|
|
1127
|
-
const commands = isRecord$
|
|
1214
|
+
const proof = isRecord$2(input.proof) ? input.proof : void 0;
|
|
1215
|
+
if (proof && isRecord$2(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
|
|
1216
|
+
const commands = isRecord$2(input.verification) ? input.verification.commands : void 0;
|
|
1128
1217
|
if (Array.isArray(commands)) {
|
|
1129
|
-
for (const [index, command] of commands.entries()) if (isRecord$
|
|
1218
|
+
for (const [index, command] of commands.entries()) if (isRecord$2(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
|
|
1130
1219
|
}
|
|
1131
|
-
if (isRecord$
|
|
1220
|
+
if (isRecord$2(input.hq) && typeof input.hq.endpoint === "string") try {
|
|
1132
1221
|
const url = new URL(input.hq.endpoint);
|
|
1133
1222
|
if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
|
|
1134
1223
|
} catch {}
|
|
@@ -1140,25 +1229,25 @@ function hasCorrectnessOnlyReview(review) {
|
|
|
1140
1229
|
}
|
|
1141
1230
|
const v4MigrationLines = (input) => {
|
|
1142
1231
|
const lines = [];
|
|
1143
|
-
if (isRecord$
|
|
1144
|
-
if (isRecord$
|
|
1145
|
-
if (isRecord$
|
|
1146
|
-
if (isRecord$
|
|
1232
|
+
if (isRecord$2(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
|
|
1233
|
+
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");
|
|
1234
|
+
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");
|
|
1235
|
+
if (isRecord$2(input.review)) {
|
|
1147
1236
|
const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
|
|
1148
1237
|
if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
|
|
1149
1238
|
if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
|
|
1150
1239
|
if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
|
|
1151
1240
|
if (Array.isArray(input.review.conditional)) {
|
|
1152
|
-
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$
|
|
1241
|
+
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`);
|
|
1153
1242
|
}
|
|
1154
1243
|
}
|
|
1155
1244
|
if (Array.isArray(input.requiredChecks)) {
|
|
1156
|
-
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$
|
|
1245
|
+
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`);
|
|
1157
1246
|
}
|
|
1158
1247
|
return lines;
|
|
1159
1248
|
};
|
|
1160
1249
|
function legacyProfileMigration(input, root) {
|
|
1161
|
-
if (!(isRecord$
|
|
1250
|
+
if (!(isRecord$2(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
|
|
1162
1251
|
const foundVersion = input.schemaVersion;
|
|
1163
1252
|
const lines = [`schemaVersion ${foundVersion} -> 5`];
|
|
1164
1253
|
if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
|
|
@@ -1338,7 +1427,6 @@ function formatUserConfigError(configPath, error) {
|
|
|
1338
1427
|
}
|
|
1339
1428
|
const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
|
|
1340
1429
|
const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
|
|
1341
|
-
const HQ_RETRY_JOURNAL_BASENAME = "hq-retry-journal.jsonl";
|
|
1342
1430
|
/**
|
|
1343
1431
|
* Appended to a spooled event that can never be delivered (#445).
|
|
1344
1432
|
*
|
|
@@ -1379,9 +1467,6 @@ const SPOOL_REPOSITORY_MARKER = ".repository-identity";
|
|
|
1379
1467
|
* and the extra level is slack for a key scheme that nests one deeper.
|
|
1380
1468
|
*/
|
|
1381
1469
|
const HQ_SPOOL_SWEEP_MAX_DEPTH = 4;
|
|
1382
|
-
`${HQ_RETRY_JOURNAL_BASENAME}`;
|
|
1383
|
-
`${HQ_RETRY_SPOOL_DIRNAME}`;
|
|
1384
|
-
const LEGACY_FACTORY_MEMORY_DIRNAME = ".factory-memory";
|
|
1385
1470
|
const HQ_SPOOL_STATE_SEGMENTS = ["patronage-factory", "hq-spool"];
|
|
1386
1471
|
const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
|
|
1387
1472
|
const MAX_HQ_INGEST_PAYLOAD_BYTES = 256 * 1024;
|
|
@@ -1493,12 +1578,6 @@ const repositorySpoolLayout = (repository, env = process.env) => ({
|
|
|
1493
1578
|
HQ_RETRY_SPOOL_DIRNAME
|
|
1494
1579
|
]
|
|
1495
1580
|
});
|
|
1496
|
-
/** Read-only drain source for spools written before the relocation. */
|
|
1497
|
-
const legacySpoolLayout = (cwd) => ({
|
|
1498
|
-
create: false,
|
|
1499
|
-
root: cwd,
|
|
1500
|
-
segments: [LEGACY_FACTORY_MEMORY_DIRNAME, HQ_RETRY_SPOOL_DIRNAME]
|
|
1501
|
-
});
|
|
1502
1581
|
/**
|
|
1503
1582
|
* An operator-named directory (`hq:flush --dir`). Accepts either the spool
|
|
1504
1583
|
* itself or the directory holding it, so a `.factory-memory` path and a
|
|
@@ -1570,30 +1649,10 @@ const secureSpoolLayout = async (layout, deadline) => {
|
|
|
1570
1649
|
const memoryIdentity = identities.at(-2);
|
|
1571
1650
|
if (spool === void 0 || memory === void 0 || spoolIdentity === void 0 || memoryIdentity === void 0 || !await directoryIdentityMatches(memory, memoryIdentity, deadline) || !await directoryIdentityMatches(spool, spoolIdentity, deadline)) return;
|
|
1572
1651
|
return {
|
|
1573
|
-
memory,
|
|
1574
|
-
memoryIdentity,
|
|
1575
1652
|
spool,
|
|
1576
1653
|
spoolIdentity
|
|
1577
1654
|
};
|
|
1578
1655
|
};
|
|
1579
|
-
/**
|
|
1580
|
-
* The legacy JSONL journal lives one level above the spool. Securing it on its
|
|
1581
|
-
* own lets a pre-spool `.factory-memory` (journal but no spool directory)
|
|
1582
|
-
* drain without the read path creating anything inside the worktree.
|
|
1583
|
-
*/
|
|
1584
|
-
const secureJournalDirectory = async (layout, deadline) => {
|
|
1585
|
-
const segments = layout.segments.slice(0, -1);
|
|
1586
|
-
if (segments.length === 0) return;
|
|
1587
|
-
const chain = await secureChain(layout, segments, deadline);
|
|
1588
|
-
if (chain === void 0) return;
|
|
1589
|
-
const directory = chain.directories.at(-1);
|
|
1590
|
-
const identity = chain.identities.at(-1);
|
|
1591
|
-
if (directory === void 0 || identity === void 0 || !await directoryIdentityMatches(directory, identity, deadline)) return;
|
|
1592
|
-
return {
|
|
1593
|
-
directory,
|
|
1594
|
-
identity
|
|
1595
|
-
};
|
|
1596
|
-
};
|
|
1597
1656
|
const readDirectoryIdentity = async (directory, deadline) => {
|
|
1598
1657
|
const opened = await openWithin(directory, constants.O_RDONLY + constants.O_DIRECTORY + constants.O_NOFOLLOW, remainingMs(deadline));
|
|
1599
1658
|
if (opened.status !== "fulfilled") return;
|
|
@@ -2323,225 +2382,6 @@ const replayCloseoutSpool = async (layout, endpoint, clientId, clientSecret, req
|
|
|
2323
2382
|
if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, () => outcome.ok ? unlink(claimPath) : rename(claimPath, eventPath))) return;
|
|
2324
2383
|
}
|
|
2325
2384
|
};
|
|
2326
|
-
const writeLegacyCursor = async (directory, directoryIdentity, cursorPath, offset, deadline) => {
|
|
2327
|
-
if (path.dirname(cursorPath) !== directory || !await directoryIdentityMatches(directory, directoryIdentity, deadline)) return false;
|
|
2328
|
-
const temporary = `${cursorPath}.${randomUUID()}.tmp`;
|
|
2329
|
-
const opened = await openWithin(temporary, constants.O_CREAT + constants.O_EXCL + constants.O_WRONLY + constants.O_NOFOLLOW, remainingMs(deadline), 384);
|
|
2330
|
-
if (opened.status !== "fulfilled") return false;
|
|
2331
|
-
const wrote = await settleWithin((async () => {
|
|
2332
|
-
await opened.value.writeFile(String(offset), "utf-8");
|
|
2333
|
-
await opened.value.sync();
|
|
2334
|
-
})(), remainingMs(deadline));
|
|
2335
|
-
const closed = await settleWithin(opened.value.close(), remainingMs(deadline));
|
|
2336
|
-
if (wrote.status !== "fulfilled" || closed.status !== "fulfilled") {
|
|
2337
|
-
await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(temporary));
|
|
2338
|
-
return false;
|
|
2339
|
-
}
|
|
2340
|
-
if (!await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(temporary, cursorPath))) {
|
|
2341
|
-
await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(temporary));
|
|
2342
|
-
return false;
|
|
2343
|
-
}
|
|
2344
|
-
return await syncBoundDirectory(directory, directoryIdentity, deadline);
|
|
2345
|
-
};
|
|
2346
|
-
const readLegacyCursor = async (cursorPath, deadline) => {
|
|
2347
|
-
const file = await readBoundedTextFileNoFollow(cursorPath, deadline, 128);
|
|
2348
|
-
if (file === void 0) return 0;
|
|
2349
|
-
const offset = Number(file.contents);
|
|
2350
|
-
return Number.isSafeInteger(offset) && offset >= 0 ? offset : 0;
|
|
2351
|
-
};
|
|
2352
|
-
const claimLegacySource = async (sourcePath, target, directory, directoryIdentity, deadline) => {
|
|
2353
|
-
if (path.dirname(sourcePath) !== directory || !await directoryIdentityMatches(directory, directoryIdentity, deadline)) return;
|
|
2354
|
-
const metadata = await settleWithin(lstat(sourcePath), remainingMs(deadline));
|
|
2355
|
-
if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink()) return;
|
|
2356
|
-
const claimPath = `${target}.legacy-claim-${randomUUID()}`;
|
|
2357
|
-
if (!await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(sourcePath, claimPath))) return;
|
|
2358
|
-
return claimPath;
|
|
2359
|
-
};
|
|
2360
|
-
const releaseLegacyClaim = async (claimPath, target, directory, directoryIdentity, deadline) => {
|
|
2361
|
-
if (path.dirname(claimPath) !== directory) return false;
|
|
2362
|
-
const readyPath = `${target}.legacy-ready-${randomUUID()}`;
|
|
2363
|
-
return mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(claimPath, readyPath));
|
|
2364
|
-
};
|
|
2365
|
-
async function replayRetryJournal(layout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, setupDeadline, replayDeadline, options = {}) {
|
|
2366
|
-
const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
|
|
2367
|
-
const { report } = options;
|
|
2368
|
-
const secured = await settleWithin(secureJournalDirectory(layout, setupDeadline), remainingMs(setupDeadline));
|
|
2369
|
-
if (secured.status !== "fulfilled" || secured.value === void 0) return;
|
|
2370
|
-
const { directory, identity: directoryIdentity } = secured.value;
|
|
2371
|
-
const target = path.join(directory, HQ_RETRY_JOURNAL_BASENAME);
|
|
2372
|
-
const baseName = path.basename(target);
|
|
2373
|
-
const listing = await settleWithin(readdir(directory), remainingMs(setupDeadline));
|
|
2374
|
-
if (listing.status !== "fulfilled") return;
|
|
2375
|
-
let delivered = 0;
|
|
2376
|
-
let corruptDropped = 0;
|
|
2377
|
-
const sources = listing.value.filter((name) => name === baseName || name.startsWith(`${baseName}.legacy-ready-`) || name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)).filter((name) => !name.includes(".cursor")).toSorted();
|
|
2378
|
-
for (const name of sources) {
|
|
2379
|
-
if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0) break;
|
|
2380
|
-
const sourcePath = path.join(directory, name);
|
|
2381
|
-
if (name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)) {
|
|
2382
|
-
const metadata = await settleWithin(lstat(sourcePath), remainingMs(setupDeadline));
|
|
2383
|
-
if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink() || Date.now() - Math.max(metadata.value.mtimeMs, metadata.value.ctimeMs) < STALE_SPOOL_ARTIFACT_MS) continue;
|
|
2384
|
-
}
|
|
2385
|
-
const claimPath = await claimLegacySource(sourcePath, target, directory, directoryIdentity, setupDeadline);
|
|
2386
|
-
if (claimPath === void 0) continue;
|
|
2387
|
-
const opened = await openWithin(claimPath, constants.O_RDONLY + constants.O_NOFOLLOW, remainingMs(setupDeadline));
|
|
2388
|
-
if (opened.status !== "fulfilled") {
|
|
2389
|
-
await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
|
|
2390
|
-
continue;
|
|
2391
|
-
}
|
|
2392
|
-
const sourceMetadata = await settleWithin(opened.value.stat(), remainingMs(setupDeadline));
|
|
2393
|
-
if (sourceMetadata.status !== "fulfilled" || !sourceMetadata.value.isFile()) {
|
|
2394
|
-
await settleWithin(opened.value.close(), remainingMs(setupDeadline));
|
|
2395
|
-
await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
|
|
2396
|
-
continue;
|
|
2397
|
-
}
|
|
2398
|
-
const cursorPath = `${target}.legacy-cursor-${String(sourceMetadata.value.dev)}-${String(sourceMetadata.value.ino)}`;
|
|
2399
|
-
let offset = await readLegacyCursor(cursorPath, setupDeadline);
|
|
2400
|
-
if (offset >= sourceMetadata.value.size) {
|
|
2401
|
-
await settleWithin(opened.value.close(), remainingMs(setupDeadline));
|
|
2402
|
-
const current = await settleWithin(lstat(claimPath), remainingMs(setupDeadline));
|
|
2403
|
-
if (current.status === "fulfilled" && current.value.isFile() && !current.value.isSymbolicLink() && current.value.dev === sourceMetadata.value.dev && current.value.ino === sourceMetadata.value.ino && current.value.size === sourceMetadata.value.size) {
|
|
2404
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(claimPath));
|
|
2405
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(cursorPath));
|
|
2406
|
-
}
|
|
2407
|
-
continue;
|
|
2408
|
-
}
|
|
2409
|
-
const abort = new AbortController();
|
|
2410
|
-
const streamBudget = Math.min(remainingMs(setupDeadline), remainingMs(replayDeadline));
|
|
2411
|
-
const abortTimer = setTimeout(() => abort.abort(), Math.max(0, streamBudget));
|
|
2412
|
-
abortTimer.unref();
|
|
2413
|
-
const stream = createReadStream(claimPath, {
|
|
2414
|
-
autoClose: false,
|
|
2415
|
-
encoding: "utf-8",
|
|
2416
|
-
fd: opened.value.fd,
|
|
2417
|
-
signal: abort.signal,
|
|
2418
|
-
start: offset
|
|
2419
|
-
});
|
|
2420
|
-
const lines = createInterface({
|
|
2421
|
-
crlfDelay: Infinity,
|
|
2422
|
-
input: stream
|
|
2423
|
-
});
|
|
2424
|
-
let reachedEof = true;
|
|
2425
|
-
try {
|
|
2426
|
-
for await (const line of lines) {
|
|
2427
|
-
if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0 || remainingMs(setupDeadline) <= 0) {
|
|
2428
|
-
reachedEof = false;
|
|
2429
|
-
break;
|
|
2430
|
-
}
|
|
2431
|
-
const nextOffset = Math.min(sourceMetadata.value.size, offset + Buffer.byteLength(line, "utf-8") + 1);
|
|
2432
|
-
if (line.trim().length === 0) {
|
|
2433
|
-
offset = nextOffset;
|
|
2434
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2435
|
-
reachedEof = false;
|
|
2436
|
-
break;
|
|
2437
|
-
}
|
|
2438
|
-
continue;
|
|
2439
|
-
}
|
|
2440
|
-
let parsed;
|
|
2441
|
-
try {
|
|
2442
|
-
parsed = JSON.parse(line);
|
|
2443
|
-
} catch {
|
|
2444
|
-
corruptDropped += 1;
|
|
2445
|
-
offset = nextOffset;
|
|
2446
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2447
|
-
reachedEof = false;
|
|
2448
|
-
break;
|
|
2449
|
-
}
|
|
2450
|
-
continue;
|
|
2451
|
-
}
|
|
2452
|
-
if (!isReplayableEntry(parsed)) {
|
|
2453
|
-
offset = nextOffset;
|
|
2454
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2455
|
-
reachedEof = false;
|
|
2456
|
-
break;
|
|
2457
|
-
}
|
|
2458
|
-
continue;
|
|
2459
|
-
}
|
|
2460
|
-
const entryEndpoint = validatedEndpoint(parsed.endpoint);
|
|
2461
|
-
let handled = false;
|
|
2462
|
-
const row = {
|
|
2463
|
-
eventId: parsed.event.eventId,
|
|
2464
|
-
kind: parsed.event.kind
|
|
2465
|
-
};
|
|
2466
|
-
let rowOutcome = {
|
|
2467
|
-
detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}`,
|
|
2468
|
-
...row,
|
|
2469
|
-
spool: directory,
|
|
2470
|
-
status: "migrated"
|
|
2471
|
-
};
|
|
2472
|
-
if (entryEndpoint?.origin === endpoint.origin) {
|
|
2473
|
-
rowOutcome = {
|
|
2474
|
-
...rowOutcome,
|
|
2475
|
-
detail: "event could not be serialized for delivery"
|
|
2476
|
-
};
|
|
2477
|
-
let body;
|
|
2478
|
-
try {
|
|
2479
|
-
body = JSON.stringify(parsed.event);
|
|
2480
|
-
} catch {
|
|
2481
|
-
body = void 0;
|
|
2482
|
-
}
|
|
2483
|
-
if (body !== void 0) {
|
|
2484
|
-
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
|
|
2485
|
-
handled = outcome.ok;
|
|
2486
|
-
if (handled) delivered += 1;
|
|
2487
|
-
rowOutcome = outcome.ok ? {
|
|
2488
|
-
...row,
|
|
2489
|
-
spool: directory,
|
|
2490
|
-
status: outcome.duplicate === true ? "duplicate" : "delivered"
|
|
2491
|
-
} : {
|
|
2492
|
-
...row,
|
|
2493
|
-
detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
|
|
2494
|
-
spool: directory,
|
|
2495
|
-
status: drainFailureStatus(outcome.reason)
|
|
2496
|
-
};
|
|
2497
|
-
}
|
|
2498
|
-
}
|
|
2499
|
-
if (!handled) {
|
|
2500
|
-
const retained = await appendCloseoutSpool(writeLayout, parsed, performance.now() + journalFlushBudgetFor(dependencies));
|
|
2501
|
-
if (retained === void 0) options.onMigrate?.();
|
|
2502
|
-
if (retained !== void 0) {
|
|
2503
|
-
report?.({
|
|
2504
|
-
...rowOutcome,
|
|
2505
|
-
detail: `${rowOutcome.detail ?? rowOutcome.status}; migration into the current spool ${retained}`,
|
|
2506
|
-
status: "unreachable"
|
|
2507
|
-
});
|
|
2508
|
-
reachedEof = false;
|
|
2509
|
-
break;
|
|
2510
|
-
}
|
|
2511
|
-
}
|
|
2512
|
-
report?.(rowOutcome);
|
|
2513
|
-
offset = nextOffset;
|
|
2514
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2515
|
-
reachedEof = false;
|
|
2516
|
-
break;
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
2519
|
-
} catch {
|
|
2520
|
-
reachedEof = false;
|
|
2521
|
-
} finally {
|
|
2522
|
-
clearTimeout(abortTimer);
|
|
2523
|
-
abort.abort();
|
|
2524
|
-
lines.close();
|
|
2525
|
-
stream.destroy();
|
|
2526
|
-
await settleWithin(opened.value.close(), remainingMs(setupDeadline));
|
|
2527
|
-
}
|
|
2528
|
-
if (reachedEof && offset >= sourceMetadata.value.size) {
|
|
2529
|
-
const current = await settleWithin(lstat(claimPath), remainingMs(setupDeadline));
|
|
2530
|
-
if (current.status === "fulfilled" && current.value.isFile() && !current.value.isSymbolicLink() && current.value.dev === sourceMetadata.value.dev && current.value.ino === sourceMetadata.value.ino && current.value.size === sourceMetadata.value.size && await directoryIdentityMatches(directory, directoryIdentity, setupDeadline)) {
|
|
2531
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(claimPath));
|
|
2532
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(cursorPath));
|
|
2533
|
-
}
|
|
2534
|
-
} else await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
|
|
2535
|
-
}
|
|
2536
|
-
await reportReplayOutcome(dependencies, delivered, corruptDropped);
|
|
2537
|
-
}
|
|
2538
|
-
async function reportReplayOutcome(dependencies, delivered, corruptDropped) {
|
|
2539
|
-
if (delivered <= 0 && corruptDropped <= 0) return;
|
|
2540
|
-
const parts = [];
|
|
2541
|
-
if (delivered > 0) parts.push(`${delivered} queued event(s) delivered from the retry journal`);
|
|
2542
|
-
if (corruptDropped > 0) parts.push(`${corruptDropped} unparseable journal line(s) dropped (partial write recovered, #120)`);
|
|
2543
|
-
await reportDiagnostic(dependencies, performance.now() + journalFlushBudgetFor(dependencies), parts.join("; "), delivered > 0 ? "HQ ingest confirmed" : "HQ ingest recovered");
|
|
2544
|
-
}
|
|
2545
2385
|
async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, transportBudgetMs) {
|
|
2546
2386
|
let config;
|
|
2547
2387
|
try {
|
|
@@ -2585,7 +2425,6 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2585
2425
|
owner: input.profile.repository.owner,
|
|
2586
2426
|
repo: input.profile.repository.name
|
|
2587
2427
|
}, env);
|
|
2588
|
-
const readLayouts = [writeLayout, legacySpoolLayout(input.cwd)];
|
|
2589
2428
|
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
2590
2429
|
const makeEventId = dependencies.randomUUID ?? randomUUID;
|
|
2591
2430
|
let event;
|
|
@@ -2656,10 +2495,7 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2656
2495
|
throw new Error(reason);
|
|
2657
2496
|
}
|
|
2658
2497
|
const replayDeadline = performance.now() + timeoutMs;
|
|
2659
|
-
|
|
2660
|
-
await replayCloseoutSpool(readLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
|
|
2661
|
-
await replayRetryJournal(readLayout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, replayDeadline, replayDeadline);
|
|
2662
|
-
}
|
|
2498
|
+
await replayCloseoutSpool(writeLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
|
|
2663
2499
|
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs);
|
|
2664
2500
|
if (!outcome.ok) {
|
|
2665
2501
|
({reason, unconfirmed} = outcome);
|
|
@@ -2738,20 +2574,7 @@ async function awaitPendingHqIngest() {
|
|
|
2738
2574
|
const DEFAULT_HQ_FLUSH_BUDGET_MS = 600 * 1e3;
|
|
2739
2575
|
/** ENOENT is the only filesystem answer that means "this location is absent". */
|
|
2740
2576
|
const isMissingEntryError = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
2741
|
-
const
|
|
2742
|
-
const journalLineTimestamps = (contents) => {
|
|
2743
|
-
const timestamps = [];
|
|
2744
|
-
for (const line of contents.split("\n")) {
|
|
2745
|
-
const trimmed = line.trim();
|
|
2746
|
-
if (trimmed === "") continue;
|
|
2747
|
-
try {
|
|
2748
|
-
const parsed = JSON.parse(trimmed);
|
|
2749
|
-
if (typeof parsed.failedAt === "string") timestamps.push(parsed.failedAt);
|
|
2750
|
-
} catch {}
|
|
2751
|
-
}
|
|
2752
|
-
return timestamps;
|
|
2753
|
-
};
|
|
2754
|
-
const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
|
|
2577
|
+
const countRemainingSpoolWork = async (spools, deadline) => {
|
|
2755
2578
|
let remaining = 0;
|
|
2756
2579
|
let unlistableScans = 0;
|
|
2757
2580
|
let oldestMs;
|
|
@@ -2780,27 +2603,6 @@ const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
|
|
|
2780
2603
|
if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
|
|
2781
2604
|
}
|
|
2782
2605
|
}
|
|
2783
|
-
for (const layout of drainLayouts) {
|
|
2784
|
-
const journalDir = path.dirname(layoutPath(layout));
|
|
2785
|
-
const journalListing = await settleWithin(readdir(journalDir), remainingMs(deadline));
|
|
2786
|
-
if (journalListing.status !== "fulfilled") {
|
|
2787
|
-
await countUnlistable(journalDir);
|
|
2788
|
-
continue;
|
|
2789
|
-
}
|
|
2790
|
-
const journalNames = journalListing.value.filter((name) => name === "hq-retry-journal.jsonl" || name.startsWith(`hq-retry-journal.jsonl.legacy-ready-`) || name.startsWith(`hq-retry-journal.jsonl.legacy-claim-`) || name.startsWith(`hq-retry-journal.jsonl.draining-`));
|
|
2791
|
-
for (const name of journalNames) {
|
|
2792
|
-
const journalFilePath = path.join(journalDir, name);
|
|
2793
|
-
const read = await settleWithin(readFile(journalFilePath, "utf-8"), remainingMs(deadline));
|
|
2794
|
-
if (read.status === "fulfilled") {
|
|
2795
|
-
remaining += journalRowCount(read.value);
|
|
2796
|
-
for (const timestamp of journalLineTimestamps(read.value)) noteTimestamp(timestamp);
|
|
2797
|
-
continue;
|
|
2798
|
-
}
|
|
2799
|
-
remaining += 1;
|
|
2800
|
-
const probed = await settleWithin(lstat(journalFilePath), remainingMs(deadline));
|
|
2801
|
-
if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
|
|
2802
|
-
}
|
|
2803
|
-
}
|
|
2804
2606
|
return {
|
|
2805
2607
|
...oldestMs === void 0 ? {} : { oldestQueuedAt: new Date(oldestMs).toISOString() },
|
|
2806
2608
|
remaining,
|
|
@@ -2821,10 +2623,10 @@ const DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS = 5e3;
|
|
|
2821
2623
|
*/
|
|
2822
2624
|
async function countHqSpoolWork(input, dependencies = {}) {
|
|
2823
2625
|
const env = dependencies.env ?? process.env;
|
|
2824
|
-
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)
|
|
2626
|
+
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)];
|
|
2825
2627
|
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS);
|
|
2826
2628
|
const paths = layouts.map((layout) => layoutPath(layout));
|
|
2827
|
-
const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths,
|
|
2629
|
+
const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths, deadline);
|
|
2828
2630
|
const existing = await Promise.all(paths.map(async (spool) => {
|
|
2829
2631
|
return (await settleWithin(lstat(spool), remainingMs(deadline))).status === "fulfilled" ? spool : void 0;
|
|
2830
2632
|
}));
|
|
@@ -2992,7 +2794,7 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
|
2992
2794
|
});
|
|
2993
2795
|
continue;
|
|
2994
2796
|
}
|
|
2995
|
-
const counted = await countRemainingSpoolWork([spool],
|
|
2797
|
+
const counted = await countRemainingSpoolWork([spool], deadline);
|
|
2996
2798
|
if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
|
|
2997
2799
|
orphans.push({
|
|
2998
2800
|
directory: spool,
|
|
@@ -3012,7 +2814,7 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
|
3012
2814
|
});
|
|
3013
2815
|
continue;
|
|
3014
2816
|
}
|
|
3015
|
-
const counted = await countRemainingSpoolWork([spool],
|
|
2817
|
+
const counted = await countRemainingSpoolWork([spool], deadline);
|
|
3016
2818
|
if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
|
|
3017
2819
|
orphans.push({
|
|
3018
2820
|
directory: spool,
|
|
@@ -3032,8 +2834,8 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
|
3032
2834
|
* This is the deliberate counterpart to the sink's advisory emit path: the
|
|
3033
2835
|
* caller has already resolved credentials explicitly, so there is no cap, no
|
|
3034
2836
|
* daemon, and no background retry — one pass, bounded, over the repo-keyed
|
|
3035
|
-
* location
|
|
3036
|
-
*
|
|
2837
|
+
* location (or the operator's `--dir`). Delivery itself is the sink's own
|
|
2838
|
+
* replay, so there is exactly one POST path.
|
|
3037
2839
|
*/
|
|
3038
2840
|
async function flushHqSpool(input, dependencies = {}) {
|
|
3039
2841
|
const endpoint = validatedIngestEndpoint(input.endpoint);
|
|
@@ -3041,10 +2843,12 @@ async function flushHqSpool(input, dependencies = {}) {
|
|
|
3041
2843
|
const env = dependencies.env ?? process.env;
|
|
3042
2844
|
const request = dependencies.fetch ?? fetch;
|
|
3043
2845
|
const transportBudgetMs = dependencies.transportTimeoutMs ?? DEFAULT_HQ_TRANSPORT_TIMEOUT_MS;
|
|
3044
|
-
const flushBudgetMs =
|
|
2846
|
+
const flushBudgetMs = journalFlushBudgetFor({
|
|
2847
|
+
env,
|
|
2848
|
+
journalFlushBudgetMs: dependencies.journalFlushBudgetMs
|
|
2849
|
+
});
|
|
3045
2850
|
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_FLUSH_BUDGET_MS);
|
|
3046
|
-
const
|
|
3047
|
-
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [writeLayout, legacySpoolLayout(input.cwd)];
|
|
2851
|
+
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)];
|
|
3048
2852
|
const outcomeById = /* @__PURE__ */ new Map();
|
|
3049
2853
|
let rejectedFiles = 0;
|
|
3050
2854
|
let unreachableFiles = 0;
|
|
@@ -3053,31 +2857,13 @@ async function flushHqSpool(input, dependencies = {}) {
|
|
|
3053
2857
|
outcomeById.set(outcome.eventId, outcome);
|
|
3054
2858
|
dependencies.report?.(outcome);
|
|
3055
2859
|
};
|
|
3056
|
-
const journalReport = (outcome) => {
|
|
3057
|
-
if (outcome.status === "unreachable" && outcome.detail?.includes("migration into the current spool")) unreachableFiles += 1;
|
|
3058
|
-
record(outcome);
|
|
3059
|
-
};
|
|
3060
2860
|
const spoolReport = (outcome) => {
|
|
3061
2861
|
if (outcome.status === "rejected") rejectedFiles += 1;
|
|
3062
2862
|
if (outcome.status === "unreachable") unreachableFiles += 1;
|
|
3063
2863
|
record(outcome);
|
|
3064
2864
|
};
|
|
3065
2865
|
const spools = [];
|
|
3066
|
-
|
|
3067
|
-
const journalDrainOptions = {
|
|
3068
|
-
maxEntries: Number.POSITIVE_INFINITY,
|
|
3069
|
-
onMigrate: () => {
|
|
3070
|
-
migratedIntoWriteLayout = true;
|
|
3071
|
-
},
|
|
3072
|
-
report: journalReport
|
|
3073
|
-
};
|
|
3074
|
-
for (const layout of layouts) await replayRetryJournal(layout, writeLayout, endpoint, input.clientId, input.clientSecret, request, {
|
|
3075
|
-
env,
|
|
3076
|
-
fetch: request,
|
|
3077
|
-
journalFlushBudgetMs: flushBudgetMs
|
|
3078
|
-
}, transportBudgetMs, deadline, deadline, journalDrainOptions);
|
|
3079
|
-
const drainLayouts = migratedIntoWriteLayout && !layouts.some((layout) => layoutPath(layout) === layoutPath(writeLayout)) ? [...layouts, writeLayout] : layouts;
|
|
3080
|
-
for (const layout of drainLayouts) {
|
|
2866
|
+
for (const layout of layouts) {
|
|
3081
2867
|
await replayCloseoutSpool(layout, endpoint, input.clientId, input.clientSecret, request, transportBudgetMs, deadline, deadline, flushBudgetMs, void 0, {
|
|
3082
2868
|
maxEntries: Number.POSITIVE_INFINITY,
|
|
3083
2869
|
report: spoolReport
|
|
@@ -3087,7 +2873,7 @@ async function flushHqSpool(input, dependencies = {}) {
|
|
|
3087
2873
|
if ((await settleWithin(lstat(layoutPath(layout)), remainingMs(deadline))).status === "fulfilled") unsecurableSpools += 1;
|
|
3088
2874
|
} else spools.push(secured.spool);
|
|
3089
2875
|
}
|
|
3090
|
-
const { remaining, unlistableScans } = await countRemainingSpoolWork(spools,
|
|
2876
|
+
const { remaining, unlistableScans } = await countRemainingSpoolWork(spools, deadline);
|
|
3091
2877
|
const outcomes = [...outcomeById.values()];
|
|
3092
2878
|
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
3093
2879
|
const rejected = count("rejected");
|
|
@@ -3845,17 +3631,20 @@ const impactStampTargetSchema = z.object({
|
|
|
3845
3631
|
* input widened every target to full impact.
|
|
3846
3632
|
*/
|
|
3847
3633
|
const impactStampSchema = z.object({
|
|
3848
|
-
basis: z.enum(["
|
|
3634
|
+
basis: z.enum(["target-scoped", "conservative"]),
|
|
3849
3635
|
reasons: z.array(z.string()),
|
|
3850
|
-
stampVersion: z.literal(
|
|
3636
|
+
stampVersion: z.literal(3),
|
|
3851
3637
|
targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
|
|
3852
3638
|
const names = targets.map((target) => target.name);
|
|
3853
3639
|
if (new Set(names).size !== names.length) context.addIssue({
|
|
3854
3640
|
code: "custom",
|
|
3855
3641
|
message: "impact stamp targets must have distinct names."
|
|
3856
3642
|
});
|
|
3857
|
-
})
|
|
3643
|
+
}),
|
|
3644
|
+
unsubscribedPaths: z.array(z.string())
|
|
3858
3645
|
});
|
|
3646
|
+
/** Human-readable lines for every impact decision, including quiet releases. */
|
|
3647
|
+
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(", ")}`];
|
|
3859
3648
|
/**
|
|
3860
3649
|
* A configuration contradiction between the profile's impact declarations and
|
|
3861
3650
|
* the repository's actual lockfile — e.g. a declared importer root that
|
|
@@ -3886,7 +3675,7 @@ const IMPACT_SCOPE_SURFACES = {
|
|
|
3886
3675
|
* external proof demand (#542 wave 2), a verification-battery command, or a
|
|
3887
3676
|
* preview (Alchemy) lifecycle stack (#430 wave 3).
|
|
3888
3677
|
*
|
|
3889
|
-
* The only release path is a `
|
|
3678
|
+
* The only release path is a `target-scoped` stamp of exactly this build's
|
|
3890
3679
|
* stamp version whose target entry for exactly this name is provably
|
|
3891
3680
|
* `not-affected`. Every other input — no trusted stamp, an unknown stamp
|
|
3892
3681
|
* version, a conservative stamp, a name the stamp does not classify, or an
|
|
@@ -3905,11 +3694,11 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
|
|
|
3905
3694
|
reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
|
|
3906
3695
|
scoped: false
|
|
3907
3696
|
};
|
|
3908
|
-
if (stamp.stampVersion !==
|
|
3909
|
-
reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version
|
|
3697
|
+
if (stamp.stampVersion !== 3) return {
|
|
3698
|
+
reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 3; ${floor}`,
|
|
3910
3699
|
scoped: false
|
|
3911
3700
|
};
|
|
3912
|
-
if (stamp.basis !== "
|
|
3701
|
+
if (stamp.basis !== "target-scoped") return {
|
|
3913
3702
|
reason: `impact stamp is conservative (full impact assumed); ${floor}`,
|
|
3914
3703
|
scoped: false
|
|
3915
3704
|
};
|
|
@@ -3948,15 +3737,16 @@ const impactStampDemandRelease = ({ checkName, stamp }) => {
|
|
|
3948
3737
|
* every doubt path lands on, and the stamp a producer must fall back to if
|
|
3949
3738
|
* stamp computation itself fails for any reason.
|
|
3950
3739
|
*/
|
|
3951
|
-
const conservativeImpactStamp = (targets, reasons) => ({
|
|
3740
|
+
const conservativeImpactStamp = (targets, reasons, unsubscribedPaths = []) => ({
|
|
3952
3741
|
basis: "conservative",
|
|
3953
3742
|
reasons,
|
|
3954
|
-
stampVersion:
|
|
3743
|
+
stampVersion: 3,
|
|
3955
3744
|
targets: targets.map((target) => ({
|
|
3956
3745
|
basis: CONSERVATIVE_TARGET_BASIS,
|
|
3957
3746
|
impact: "affected",
|
|
3958
3747
|
name: target.name
|
|
3959
|
-
}))
|
|
3748
|
+
})),
|
|
3749
|
+
unsubscribedPaths
|
|
3960
3750
|
});
|
|
3961
3751
|
const conservativeStamp = conservativeImpactStamp;
|
|
3962
3752
|
const DEPENDENCY_FIELDS = [
|
|
@@ -3984,17 +3774,17 @@ const pnpmLockfileSchema = z.looseObject({
|
|
|
3984
3774
|
packages: z.record(z.string(), z.unknown()).optional(),
|
|
3985
3775
|
snapshots: z.record(z.string(), snapshotSectionSchema).optional()
|
|
3986
3776
|
});
|
|
3987
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3777
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3988
3778
|
const canonical = (value) => {
|
|
3989
3779
|
if (Array.isArray(value)) return value.map(canonical);
|
|
3990
|
-
if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3780
|
+
if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3991
3781
|
return value;
|
|
3992
3782
|
};
|
|
3993
3783
|
const stableStringify$1 = (value) => JSON.stringify(canonical(value));
|
|
3994
3784
|
const parseLockfile = (content) => {
|
|
3995
3785
|
try {
|
|
3996
3786
|
const parsed = parse(content);
|
|
3997
|
-
if (!isRecord(parsed)) return;
|
|
3787
|
+
if (!isRecord$1(parsed)) return;
|
|
3998
3788
|
const validated = pnpmLockfileSchema.safeParse(parsed);
|
|
3999
3789
|
return validated.success ? validated.data : void 0;
|
|
4000
3790
|
} catch {
|
|
@@ -4087,11 +3877,11 @@ const targetImpactForLockfileDelta = (target, delta) => {
|
|
|
4087
3877
|
/**
|
|
4088
3878
|
* Compute the impact stamp for one candidate: every declared target recorded
|
|
4089
3879
|
* as affected/not-affected with its exact basis. `not-affected` is only ever
|
|
4090
|
-
* returned
|
|
4091
|
-
*
|
|
4092
|
-
*
|
|
4093
|
-
*
|
|
4094
|
-
* full impact.
|
|
3880
|
+
* returned only when every changed input is a valid source path or an
|
|
3881
|
+
* analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability are
|
|
3882
|
+
* unioned per target. A valid source path matched by no target is recorded in
|
|
3883
|
+
* `unsubscribedPaths` and affects none. Invalid, unreadable, unsupported, or
|
|
3884
|
+
* otherwise doubtful input widens the whole stamp to full impact.
|
|
4095
3885
|
*
|
|
4096
3886
|
* Total by contract: this function never throws. Any unexpected failure in
|
|
4097
3887
|
* reading, parsing, or traversal is itself a doubt path and returns the
|
|
@@ -4154,35 +3944,88 @@ const readLockfileSides = (readLockfile) => {
|
|
|
4154
3944
|
const assertDeclaredImporterRoots = (targets, sides) => {
|
|
4155
3945
|
for (const target of targets) for (const root of target.importers) if (!sides.some((lockfile) => Object.keys(lockfile.importers).some((importerPath) => importerMatchesTarget(importerPath, [root])))) throw new ImpactStampConfigError(`impact target "${target.name}" declares importer root "${root}", but no importer under that root exists in pnpm-lock.yaml on either side of the delta; fix the profile's impact.targets declaration`);
|
|
4156
3946
|
};
|
|
4157
|
-
const
|
|
3947
|
+
const assertDeclaredPathGlobs = (targets) => {
|
|
3948
|
+
for (const target of targets) for (const pattern of target.paths ?? []) if (!isValidRepoRelativeGlob(pattern)) throw new ImpactStampConfigError(`impact target "${target.name}" declares invalid repo-relative path glob "${pattern}"; fix the profile's impact.targets declaration`);
|
|
3949
|
+
};
|
|
3950
|
+
const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
|
|
3951
|
+
for (const file of sourceFiles) {
|
|
3952
|
+
if (!isRepoRelativePath(file)) return {
|
|
3953
|
+
kind: "conservative",
|
|
3954
|
+
reason: `changed source path "${file}" is not a valid repo-relative path; fail closed to full impact`
|
|
3955
|
+
};
|
|
3956
|
+
if (file === profilePath) return {
|
|
3957
|
+
kind: "conservative",
|
|
3958
|
+
reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
|
|
3959
|
+
};
|
|
3960
|
+
}
|
|
3961
|
+
return {
|
|
3962
|
+
kind: "ok",
|
|
3963
|
+
targets: targets.map((target) => {
|
|
3964
|
+
const matched = sourceFiles.find((file) => matchesAnyGlob(target.paths ?? [], file));
|
|
3965
|
+
return matched === void 0 ? {
|
|
3966
|
+
basis: "no changed source path matches this target's declared paths",
|
|
3967
|
+
impact: "not-affected",
|
|
3968
|
+
name: target.name
|
|
3969
|
+
} : {
|
|
3970
|
+
basis: `changed source path "${matched}" matches this target's declared paths`,
|
|
3971
|
+
impact: "affected",
|
|
3972
|
+
name: target.name
|
|
3973
|
+
};
|
|
3974
|
+
}),
|
|
3975
|
+
unsubscribedPaths: sourceFiles.filter((file) => !targets.some((target) => matchesAnyGlob(target.paths ?? [], file)))
|
|
3976
|
+
};
|
|
3977
|
+
};
|
|
3978
|
+
const unionTargetImpact = (source, lockfile) => {
|
|
3979
|
+
if (source.impact === "affected") return source;
|
|
3980
|
+
if (lockfile.impact === "affected") return lockfile;
|
|
3981
|
+
return {
|
|
3982
|
+
basis: `${source.basis}; ${lockfile.basis}`,
|
|
3983
|
+
impact: "not-affected",
|
|
3984
|
+
name: source.name
|
|
3985
|
+
};
|
|
3986
|
+
};
|
|
3987
|
+
const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-factory.profile.json", readLockfile }, targets) => {
|
|
4158
3988
|
if (targets.length === 0) return {
|
|
4159
3989
|
basis: "conservative",
|
|
4160
3990
|
reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
|
|
4161
|
-
stampVersion:
|
|
4162
|
-
targets: []
|
|
3991
|
+
stampVersion: 3,
|
|
3992
|
+
targets: [],
|
|
3993
|
+
unsubscribedPaths: changedFiles.filter((file) => file !== "pnpm-lock.yaml" && isRepoRelativePath(file))
|
|
4163
3994
|
};
|
|
3995
|
+
assertDeclaredPathGlobs(targets);
|
|
4164
3996
|
const sides = readLockfileSides(readLockfile);
|
|
4165
3997
|
if (sides.kind === "ok") assertDeclaredImporterRoots(targets, sides.parsed);
|
|
4166
3998
|
if (changedFiles.length === 0) return conservativeStamp(targets, ["no changed files detected; full impact assumed"]);
|
|
4167
|
-
const
|
|
4168
|
-
|
|
4169
|
-
if (
|
|
4170
|
-
if (sides.kind === "
|
|
4171
|
-
if (sides.kind === "
|
|
4172
|
-
if (sides.kind === "
|
|
3999
|
+
const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
|
|
4000
|
+
const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
|
|
4001
|
+
if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
|
|
4002
|
+
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);
|
|
4003
|
+
if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
|
|
4004
|
+
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);
|
|
4005
|
+
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);
|
|
4006
|
+
if (!changedFiles.includes("pnpm-lock.yaml")) return {
|
|
4007
|
+
basis: "target-scoped",
|
|
4008
|
+
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`],
|
|
4009
|
+
stampVersion: 3,
|
|
4010
|
+
targets: source.targets,
|
|
4011
|
+
unsubscribedPaths: source.unsubscribedPaths
|
|
4012
|
+
};
|
|
4173
4013
|
const [baseLock, headLock] = sides.parsed;
|
|
4174
|
-
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"]);
|
|
4014
|
+
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);
|
|
4175
4015
|
const delta = {
|
|
4176
4016
|
changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
|
|
4177
4017
|
changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
|
|
4178
4018
|
changedSnapshots: changedSectionKeys(baseLock.snapshots, headLock.snapshots),
|
|
4179
4019
|
sides: [baseLock, headLock]
|
|
4180
4020
|
};
|
|
4021
|
+
const lockfileTargets = targets.map((target) => targetImpactForLockfileDelta(target, delta));
|
|
4022
|
+
const mixed = sourceFiles.length > 0;
|
|
4181
4023
|
return {
|
|
4182
|
-
basis: "
|
|
4183
|
-
reasons: ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
|
|
4184
|
-
stampVersion:
|
|
4185
|
-
targets: targets.map((
|
|
4024
|
+
basis: "target-scoped",
|
|
4025
|
+
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"],
|
|
4026
|
+
stampVersion: 3,
|
|
4027
|
+
targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets,
|
|
4028
|
+
unsubscribedPaths: source.unsubscribedPaths
|
|
4186
4029
|
};
|
|
4187
4030
|
};
|
|
4188
4031
|
//#endregion
|
|
@@ -4361,7 +4204,7 @@ const assertBatteryCompleteness = (proof, context) => {
|
|
|
4361
4204
|
* so authorization is bound to the same identities the proof binds. The
|
|
4362
4205
|
* predicate is the shared {@link impactStampScopeDecision}, not a second
|
|
4363
4206
|
* reading of the stamp: an entry is authorized exactly when the stamp would
|
|
4364
|
-
* have released that target's command in the first place (
|
|
4207
|
+
* have released that target's command in the first place (target-scoped
|
|
4365
4208
|
* basis, this build's stamp version, exactly one row for the target,
|
|
4366
4209
|
* `not-affected`). A conservative stamp, an unknown version, a
|
|
4367
4210
|
* self-contradictory stamp, an unclassified name, or an `affected` verdict all
|
|
@@ -5480,7 +5323,7 @@ const CLOSING_PRS_QUERY = `query($owner: String!, $repo: String!, $issue: Int!)
|
|
|
5480
5323
|
repository(owner: $owner, name: $repo) {
|
|
5481
5324
|
issue(number: $issue) {
|
|
5482
5325
|
closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
|
|
5483
|
-
nodes { number state headRefOid mergeCommit { oid } }
|
|
5326
|
+
nodes { number state baseRefName headRefName headRefOid mergeCommit { oid } }
|
|
5484
5327
|
}
|
|
5485
5328
|
}
|
|
5486
5329
|
}
|
|
@@ -5508,7 +5351,9 @@ const defaultBoundaryCheckGithub = () => ({
|
|
|
5508
5351
|
"-F",
|
|
5509
5352
|
`issue=${issue}`
|
|
5510
5353
|
]).data?.repository?.issue?.closedByPullRequestsReferences?.nodes ?? []).map((node) => ({
|
|
5354
|
+
...node.baseRefName ? { baseRefName: node.baseRefName } : {},
|
|
5511
5355
|
...node.headRefOid ? { headRefOid: node.headRefOid } : {},
|
|
5356
|
+
...node.headRefName ? { headRefName: node.headRefName } : {},
|
|
5512
5357
|
...node.mergeCommit?.oid ? { mergeCommitOid: node.mergeCommit.oid } : {},
|
|
5513
5358
|
number: node.number,
|
|
5514
5359
|
state: node.state
|
|
@@ -5532,10 +5377,12 @@ const defaultBoundaryCheckGithub = () => ({
|
|
|
5532
5377
|
"--repo",
|
|
5533
5378
|
repo,
|
|
5534
5379
|
"--json",
|
|
5535
|
-
"number,state,headRefOid,mergeCommit"
|
|
5380
|
+
"number,state,baseRefName,headRefName,headRefOid,mergeCommit"
|
|
5536
5381
|
]);
|
|
5537
5382
|
return {
|
|
5383
|
+
...raw.baseRefName ? { baseRefName: raw.baseRefName } : {},
|
|
5538
5384
|
...raw.headRefOid ? { headRefOid: raw.headRefOid } : {},
|
|
5385
|
+
...raw.headRefName ? { headRefName: raw.headRefName } : {},
|
|
5539
5386
|
...raw.mergeCommit?.oid ? { mergeCommitOid: raw.mergeCommit.oid } : {},
|
|
5540
5387
|
number: raw.number,
|
|
5541
5388
|
state: raw.state
|
|
@@ -5574,10 +5421,12 @@ const coveredEntryFromRef = (ref, wave, issue, notices) => {
|
|
|
5574
5421
|
const computeCoveredSet = (manifest, repo, github) => {
|
|
5575
5422
|
const notices = [];
|
|
5576
5423
|
const byPr = /* @__PURE__ */ new Map();
|
|
5424
|
+
const pullRequests = /* @__PURE__ */ new Map();
|
|
5577
5425
|
for (const wave of manifest.waves) for (const issue of wave.issues) {
|
|
5578
5426
|
const refs = github.fetchClosingPullRequests(repo, issue);
|
|
5579
5427
|
if (refs.length === 0) notices.push(`issue #${issue} (wave ${wave.name}) has no linked closing PR yet`);
|
|
5580
5428
|
for (const ref of refs) {
|
|
5429
|
+
if (!pullRequests.has(ref.number)) pullRequests.set(ref.number, ref);
|
|
5581
5430
|
const entry = coveredEntryFromRef(ref, wave.name, issue, notices);
|
|
5582
5431
|
if (entry && !byPr.has(entry.pr)) byPr.set(entry.pr, entry);
|
|
5583
5432
|
}
|
|
@@ -5585,12 +5434,15 @@ const computeCoveredSet = (manifest, repo, github) => {
|
|
|
5585
5434
|
for (const [prKey, waveName] of Object.entries(manifest.prs ?? {})) {
|
|
5586
5435
|
const prNumber = Number(prKey);
|
|
5587
5436
|
if (byPr.has(prNumber)) continue;
|
|
5588
|
-
const
|
|
5437
|
+
const ref = github.fetchPullRequest(repo, prNumber);
|
|
5438
|
+
pullRequests.set(prNumber, ref);
|
|
5439
|
+
const entry = coveredEntryFromRef(ref, waveName, void 0, notices);
|
|
5589
5440
|
if (entry) byPr.set(entry.pr, entry);
|
|
5590
5441
|
}
|
|
5591
5442
|
return {
|
|
5592
5443
|
entries: [...byPr.values()].toSorted((a, b) => a.pr - b.pr),
|
|
5593
|
-
notices
|
|
5444
|
+
notices,
|
|
5445
|
+
pullRequests: [...pullRequests.values()].toSorted((a, b) => a.number - b.number)
|
|
5594
5446
|
};
|
|
5595
5447
|
};
|
|
5596
5448
|
const coveredSetDivergences = (computed, proof) => {
|
|
@@ -5657,6 +5509,10 @@ function runBoundaryCheck(args, dependencies = {}) {
|
|
|
5657
5509
|
manifestHash: parsed.manifestHash,
|
|
5658
5510
|
selected
|
|
5659
5511
|
});
|
|
5512
|
+
blockingReasons.unshift(...boundaryTopologyReasons({
|
|
5513
|
+
manifest: parsed.manifest,
|
|
5514
|
+
pullRequests: computed.pullRequests
|
|
5515
|
+
}));
|
|
5660
5516
|
record = {
|
|
5661
5517
|
blockingReasons,
|
|
5662
5518
|
boundary: parsed.manifest.boundary,
|
|
@@ -5785,17 +5641,21 @@ const asClassification = (value) => DIFF_CLASSIFICATIONS$1.includes(value) ? val
|
|
|
5785
5641
|
const prVerifyCheckPayloadFor = (proof) => {
|
|
5786
5642
|
const value = proof;
|
|
5787
5643
|
if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
|
|
5644
|
+
const carriesReleases = value.notRequiredCommands !== void 0;
|
|
5788
5645
|
return {
|
|
5789
5646
|
classification: asClassification(value.classification),
|
|
5790
5647
|
executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
|
|
5791
5648
|
headSha: value.headSha,
|
|
5649
|
+
...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
|
|
5792
5650
|
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
5793
5651
|
mode: value.mode,
|
|
5652
|
+
...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
|
|
5794
5653
|
outcome: outcomeFor(value),
|
|
5795
5654
|
proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
|
|
5796
5655
|
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
5797
5656
|
...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
|
|
5798
|
-
...typeof value.repository === "string" ? { repository: value.repository } : {}
|
|
5657
|
+
...typeof value.repository === "string" ? { repository: value.repository } : {},
|
|
5658
|
+
...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
|
|
5799
5659
|
};
|
|
5800
5660
|
};
|
|
5801
5661
|
const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
@@ -7609,31 +7469,6 @@ const defaultSleep = async (ms) => {
|
|
|
7609
7469
|
const { setTimeout: delay } = await import("node:timers/promises");
|
|
7610
7470
|
await delay(ms);
|
|
7611
7471
|
};
|
|
7612
|
-
const base64url = (value) => Buffer.from(value).toString("base64url");
|
|
7613
|
-
function appJwt(config, now) {
|
|
7614
|
-
const issuedAt = Math.floor(now / 1e3) - 60;
|
|
7615
|
-
const unsigned = `${base64url(JSON.stringify({
|
|
7616
|
-
alg: "RS256",
|
|
7617
|
-
typ: "JWT"
|
|
7618
|
-
}))}.${base64url(JSON.stringify({
|
|
7619
|
-
exp: issuedAt + 600,
|
|
7620
|
-
iat: issuedAt,
|
|
7621
|
-
iss: config.appId
|
|
7622
|
-
}))}`;
|
|
7623
|
-
const signer = createSign("RSA-SHA256");
|
|
7624
|
-
signer.update(unsigned);
|
|
7625
|
-
signer.end();
|
|
7626
|
-
return `${unsigned}.${signer.sign(readFileSync(config.privateKeyPath), "base64url")}`;
|
|
7627
|
-
}
|
|
7628
|
-
/** Carries the HTTP status so a caller can tell a retryable failure apart. */
|
|
7629
|
-
var GitHubApiError = class extends Error {
|
|
7630
|
-
status;
|
|
7631
|
-
constructor(status, statusText) {
|
|
7632
|
-
super(`GitHub API ${status} ${statusText}`);
|
|
7633
|
-
this.name = "GitHubApiError";
|
|
7634
|
-
this.status = status;
|
|
7635
|
-
}
|
|
7636
|
-
};
|
|
7637
7472
|
/**
|
|
7638
7473
|
* Worth another attempt: 422 is what GitHub answers for a head SHA it has not
|
|
7639
7474
|
* seen yet (the whole reason the retry exists), 5xx and rate limiting are
|
|
@@ -7688,30 +7523,36 @@ async function githubJson(request, url, init, timeoutMs = GITHUB_PUBLISH_TIMEOUT
|
|
|
7688
7523
|
if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
|
|
7689
7524
|
return await response.json();
|
|
7690
7525
|
}
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
7697
|
-
|
|
7698
|
-
|
|
7699
|
-
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
|
|
7705
|
-
|
|
7706
|
-
|
|
7707
|
-
|
|
7708
|
-
}
|
|
7526
|
+
/**
|
|
7527
|
+
* Mint an installation token for the Patronage Factory App.
|
|
7528
|
+
*
|
|
7529
|
+
* The mechanism — app JWT, installation lookup, token exchange — lives in
|
|
7530
|
+
* `@patronage/factory-ci` (#617), because paitronage's proof-comment publisher
|
|
7531
|
+
* had grown a second copy of it. What stays here is what is this repository's:
|
|
7532
|
+
* where the credentials come from (`user-config`), and the publish timeout the
|
|
7533
|
+
* rest of these calls are bound by. Nothing is cached, as before.
|
|
7534
|
+
*/
|
|
7535
|
+
const installationToken = (repository, config, request, now, timeoutMs = GITHUB_PUBLISH_TIMEOUT_MS) => mintInstallationToken({
|
|
7536
|
+
credentials: config,
|
|
7537
|
+
owner: repository.owner,
|
|
7538
|
+
repo: repository.repo
|
|
7539
|
+
}, {
|
|
7540
|
+
fetch: request,
|
|
7541
|
+
now: () => now,
|
|
7542
|
+
timeoutMs
|
|
7543
|
+
});
|
|
7709
7544
|
function verifyOutput(proof) {
|
|
7710
7545
|
const value = proof;
|
|
7711
7546
|
const commands = value.executedCommands?.length ?? 0;
|
|
7547
|
+
const unsubscribedBasis = value.impactStamp?.unsubscribedPaths;
|
|
7548
|
+
let unsubscribedSummary;
|
|
7549
|
+
if (Array.isArray(unsubscribedBasis)) {
|
|
7550
|
+
const unsubscribed = unsubscribedBasis.filter((path) => typeof path === "string");
|
|
7551
|
+
unsubscribedSummary = unsubscribed.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${unsubscribed.join(", ")}.`;
|
|
7552
|
+
} else unsubscribedSummary = "Unsubscribed changed paths: unavailable (legacy or malformed stamp).";
|
|
7712
7553
|
const payload = prVerifyCheckPayloadFor(proof);
|
|
7713
7554
|
return {
|
|
7714
|
-
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed
|
|
7555
|
+
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed. ${unsubscribedSummary}`,
|
|
7715
7556
|
...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
|
|
7716
7557
|
title: `pr:verify ${value.outcome ?? "unknown"}`
|
|
7717
7558
|
};
|
|
@@ -7725,8 +7566,10 @@ function reviewOutput(proof) {
|
|
|
7725
7566
|
for (const { disposition } of value.ladder ? ladderLedgerFor(value.ladder) : []) dispositionCounts[disposition] = (dispositionCounts[disposition] ?? 0) + 1;
|
|
7726
7567
|
if (!value.ladder && findings > 0) dispositionCounts.open = findings;
|
|
7727
7568
|
const dispositions = Object.entries(dispositionCounts).map(([name, count]) => `${name} ${count}`).join(", ");
|
|
7569
|
+
const unavailable = "unavailable (legacy proof)";
|
|
7570
|
+
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(" | ");
|
|
7728
7571
|
return {
|
|
7729
|
-
summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}.`,
|
|
7572
|
+
summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}. Review provenance: ${provenance}.`,
|
|
7730
7573
|
title: findings === 0 ? "pr:review clean" : "pr:review proof recorded"
|
|
7731
7574
|
};
|
|
7732
7575
|
}
|
|
@@ -8820,8 +8663,24 @@ function positiveInteger(name) {
|
|
|
8820
8663
|
};
|
|
8821
8664
|
}
|
|
8822
8665
|
function resolveCwdOption(cwd) {
|
|
8823
|
-
|
|
8824
|
-
|
|
8666
|
+
const value = typeof cwd === "string" ? cwd : ".";
|
|
8667
|
+
if (value.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
|
|
8668
|
+
return path.resolve(value);
|
|
8669
|
+
}
|
|
8670
|
+
function collectCwdOption(rawValue, previous) {
|
|
8671
|
+
if (previous === void 0) return rawValue;
|
|
8672
|
+
const previousResolved = resolveCwdOption(previous);
|
|
8673
|
+
const currentResolved = resolveCwdOption(rawValue);
|
|
8674
|
+
if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
|
|
8675
|
+
return rawValue;
|
|
8676
|
+
}
|
|
8677
|
+
function markCwdOptionDefault(command) {
|
|
8678
|
+
const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
|
|
8679
|
+
if (option) {
|
|
8680
|
+
option.defaultValue = ".";
|
|
8681
|
+
option.defaultValueDescription = ".";
|
|
8682
|
+
}
|
|
8683
|
+
return command;
|
|
8825
8684
|
}
|
|
8826
8685
|
function resolveCheckoutPath(cwd, target) {
|
|
8827
8686
|
return path.resolve(cwd, target);
|
|
@@ -8844,7 +8703,7 @@ const renderReport = (record) => {
|
|
|
8844
8703
|
return `${lines.join("\n")}\n`;
|
|
8845
8704
|
};
|
|
8846
8705
|
function createBoundaryCheckCommand(output, action) {
|
|
8847
|
-
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",
|
|
8706
|
+
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({
|
|
8848
8707
|
gate: "boundary:check",
|
|
8849
8708
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
8850
8709
|
stderr: output.stderr
|
|
@@ -8860,7 +8719,10 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8860
8719
|
if (!action) {
|
|
8861
8720
|
const [owner, name] = record.repo.split("/");
|
|
8862
8721
|
if (owner && name) {
|
|
8863
|
-
const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({
|
|
8722
|
+
const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({
|
|
8723
|
+
cwd: args.cwd,
|
|
8724
|
+
profilePath: options.profile
|
|
8725
|
+
})?.profile);
|
|
8864
8726
|
try {
|
|
8865
8727
|
publishFactoryCheckSafely(createFactoryCheckPublisher(output), {
|
|
8866
8728
|
conclusion: record.status === "ready" ? "success" : "failure",
|
|
@@ -8880,7 +8742,710 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8880
8742
|
}
|
|
8881
8743
|
output.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : renderReport(record));
|
|
8882
8744
|
if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
|
|
8745
|
+
})));
|
|
8746
|
+
}
|
|
8747
|
+
/** `tool` discriminator every emitted report carries. */
|
|
8748
|
+
const CI_ANALYZE_TOOL = "psf-ci-analyze";
|
|
8749
|
+
/** Cohort label for runs the caller did not assign to a cohort. */
|
|
8750
|
+
const UNGROUPED_COHORT = "ungrouped";
|
|
8751
|
+
const available = (value) => ({
|
|
8752
|
+
available: true,
|
|
8753
|
+
value
|
|
8754
|
+
});
|
|
8755
|
+
const unavailable = (reason) => ({
|
|
8756
|
+
available: false,
|
|
8757
|
+
reason
|
|
8758
|
+
});
|
|
8759
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8760
|
+
const stringField$1 = (source, key, where) => {
|
|
8761
|
+
const value = source[key];
|
|
8762
|
+
return typeof value === "string" && value.length > 0 ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8763
|
+
};
|
|
8764
|
+
const numberField = (source, key, where) => {
|
|
8765
|
+
const value = source[key];
|
|
8766
|
+
return typeof value === "number" && Number.isFinite(value) ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8767
|
+
};
|
|
8768
|
+
/**
|
|
8769
|
+
* An instant is only usable when it names its own timezone. `Date.parse`
|
|
8770
|
+
* interprets an offset-less `2026-08-06T12:00:00` in the *host's* zone, which
|
|
8771
|
+
* would make a duration depend on where the analyzer ran. The GitHub API
|
|
8772
|
+
* always sends `Z`-form, so requiring an explicit `Z` or numeric offset
|
|
8773
|
+
* rejects nothing genuine — anything else becomes explicitly unavailable.
|
|
8774
|
+
*/
|
|
8775
|
+
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/u;
|
|
8776
|
+
const parseInstantMs = (value) => {
|
|
8777
|
+
if (!value.available) return;
|
|
8778
|
+
if (!EXPLICIT_OFFSET_PATTERN.test(value.value)) return;
|
|
8779
|
+
const parsed = Date.parse(value.value);
|
|
8780
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
8781
|
+
};
|
|
8782
|
+
/**
|
|
8783
|
+
* Wall clock between two ISO instants. Unavailable — with the missing side
|
|
8784
|
+
* named — whenever either instant is absent or unparsable; a negative span is
|
|
8785
|
+
* refused rather than clamped to a fake zero.
|
|
8786
|
+
*/
|
|
8787
|
+
const durationBetween = (start, end, label) => {
|
|
8788
|
+
const startMs = parseInstantMs(start);
|
|
8789
|
+
if (startMs === void 0) return unavailable(`${label}: start instant is ${start.available ? "unparsable" : `unavailable (${start.reason})`}`);
|
|
8790
|
+
const endMs = parseInstantMs(end);
|
|
8791
|
+
if (endMs === void 0) return unavailable(`${label}: end instant is ${end.available ? "unparsable" : `unavailable (${end.reason})`}`);
|
|
8792
|
+
if (endMs < startMs) return unavailable(`${label}: end instant precedes start instant`);
|
|
8793
|
+
return available(endMs - startMs);
|
|
8794
|
+
};
|
|
8795
|
+
const GITHUB_SETUP_STEP_NAME = "Set up job";
|
|
8796
|
+
const INSTALL_STEP_PATTERN = /install/iu;
|
|
8797
|
+
const analyzeStep = (step) => {
|
|
8798
|
+
const startedAt = stringField$1(step, "started_at", "step");
|
|
8799
|
+
const completedAt = stringField$1(step, "completed_at", "step");
|
|
8800
|
+
return {
|
|
8801
|
+
completedAt,
|
|
8802
|
+
conclusion: stringField$1(step, "conclusion", "step"),
|
|
8803
|
+
durationMs: durationBetween(startedAt, completedAt, "step duration"),
|
|
8804
|
+
name: typeof step.name === "string" ? step.name : "(unnamed step)",
|
|
8805
|
+
number: numberField(step, "number", "step"),
|
|
8806
|
+
startedAt
|
|
8807
|
+
};
|
|
8808
|
+
};
|
|
8809
|
+
const namedStepDuration = (steps, matches, description) => {
|
|
8810
|
+
const step = steps.find((candidate) => matches(candidate.name));
|
|
8811
|
+
return step === void 0 ? unavailable(`no ${description} step in this job`) : step.durationMs;
|
|
8812
|
+
};
|
|
8813
|
+
const analyzeJob = (job) => {
|
|
8814
|
+
const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord).map(analyzeStep) : [];
|
|
8815
|
+
const createdAt = stringField$1(job, "created_at", "job");
|
|
8816
|
+
const startedAt = stringField$1(job, "started_at", "job");
|
|
8817
|
+
const completedAt = stringField$1(job, "completed_at", "job");
|
|
8818
|
+
const labels = Array.isArray(job.labels) ? job.labels.filter((label) => typeof label === "string") : void 0;
|
|
8819
|
+
return {
|
|
8820
|
+
completedAt,
|
|
8821
|
+
conclusion: stringField$1(job, "conclusion", "job"),
|
|
8822
|
+
createdAt,
|
|
8823
|
+
installMs: namedStepDuration(steps, (name) => INSTALL_STEP_PATTERN.test(name), "install"),
|
|
8824
|
+
jobId: numberField(job, "id", "job"),
|
|
8825
|
+
name: typeof job.name === "string" ? job.name : "(unnamed job)",
|
|
8826
|
+
queueMs: durationBetween(createdAt, startedAt, "job queue"),
|
|
8827
|
+
runnerLabels: labels === void 0 ? unavailable("job has no labels array") : available(labels),
|
|
8828
|
+
setupMs: namedStepDuration(steps, (name) => name === GITHUB_SETUP_STEP_NAME, `"${GITHUB_SETUP_STEP_NAME}"`),
|
|
8829
|
+
startedAt,
|
|
8830
|
+
steps,
|
|
8831
|
+
totalMs: durationBetween(startedAt, completedAt, "job duration"),
|
|
8832
|
+
url: stringField$1(job, "html_url", "job")
|
|
8833
|
+
};
|
|
8834
|
+
};
|
|
8835
|
+
/**
|
|
8836
|
+
* Classify proof reuse from step conclusions alone. A job whose gate step is
|
|
8837
|
+
* followed by at least one skipped step reused proof; a job whose post-gate
|
|
8838
|
+
* steps all executed ran the full fallback. No gate step anywhere means the
|
|
8839
|
+
* workflow does not expose the outcome, which is unavailable — not
|
|
8840
|
+
* full-fallback.
|
|
8841
|
+
*/
|
|
8842
|
+
const classifyProofReuse = (jobs) => {
|
|
8843
|
+
const gateJobs = [];
|
|
8844
|
+
const verdicts = /* @__PURE__ */ new Set();
|
|
8845
|
+
for (const job of jobs) {
|
|
8846
|
+
const gateIndex = job.steps.findIndex((step) => step.name === FACTORY_PROOF_GATE_STEP_NAME);
|
|
8847
|
+
if (gateIndex === -1) continue;
|
|
8848
|
+
const skippedStepsAfterGate = job.steps.slice(gateIndex + 1).filter((step) => step.conclusion.available && step.conclusion.value === "skipped").length;
|
|
8849
|
+
verdicts.add(skippedStepsAfterGate > 0 ? "proof-reuse" : "full-fallback");
|
|
8850
|
+
gateJobs.push({
|
|
8851
|
+
gateConclusion: job.steps[gateIndex]?.conclusion ?? unavailable("gate step vanished"),
|
|
8852
|
+
jobName: job.name,
|
|
8853
|
+
skippedStepsAfterGate
|
|
8854
|
+
});
|
|
8855
|
+
}
|
|
8856
|
+
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`);
|
|
8857
|
+
const [single] = [...verdicts];
|
|
8858
|
+
return available({
|
|
8859
|
+
classification: verdicts.size === 1 && single ? single : "mixed",
|
|
8860
|
+
gateJobs
|
|
8861
|
+
});
|
|
8862
|
+
};
|
|
8863
|
+
const parseArtifactListing = (value) => {
|
|
8864
|
+
if (!isRecord(value) || !Array.isArray(value.artifacts)) return {
|
|
8865
|
+
artifacts: [],
|
|
8866
|
+
totalCount: void 0
|
|
8867
|
+
};
|
|
8868
|
+
return {
|
|
8869
|
+
artifacts: value.artifacts.filter(isRecord).flatMap((artifact) => {
|
|
8870
|
+
const { id, name } = artifact;
|
|
8871
|
+
return typeof id === "number" && typeof name === "string" ? [{
|
|
8872
|
+
expired: artifact.expired === true,
|
|
8873
|
+
id,
|
|
8874
|
+
name
|
|
8875
|
+
}] : [];
|
|
8876
|
+
}),
|
|
8877
|
+
totalCount: typeof value.total_count === "number" ? value.total_count : void 0
|
|
8878
|
+
};
|
|
8879
|
+
};
|
|
8880
|
+
const profileFromDocument = (artifactName, value) => {
|
|
8881
|
+
const read = readVitestProfileDocument(value);
|
|
8882
|
+
if (read.kind === "unrecognized") return {
|
|
8883
|
+
artifactName,
|
|
8884
|
+
kind: "unreadable",
|
|
8885
|
+
reason: read.reason
|
|
8886
|
+
};
|
|
8887
|
+
const { profile } = read;
|
|
8888
|
+
const testCounts = profile.runs.flatMap((run) => run.counts === null ? [] : [run.counts.tests]);
|
|
8889
|
+
return {
|
|
8890
|
+
artifactName,
|
|
8891
|
+
kind: "profile",
|
|
8892
|
+
profile: {
|
|
8893
|
+
cpuCount: profile.environment.cpuCount,
|
|
8894
|
+
cpuModel: profile.environment.cpuModel,
|
|
8895
|
+
maxWorkers: profile.options.maxWorkers,
|
|
8896
|
+
sampleDurationsMs: profile.runs.map((run) => run.durationMs),
|
|
8897
|
+
scope: profile.command.join(" "),
|
|
8898
|
+
slowFiles: profile.summary.slowFiles.map((file) => ({
|
|
8899
|
+
medianMs: file.durationMs.median,
|
|
8900
|
+
path: file.path
|
|
8901
|
+
})),
|
|
8902
|
+
slowTests: profile.summary.slowTests.map((test) => ({
|
|
8903
|
+
file: test.file,
|
|
8904
|
+
medianMs: test.durationMs.median,
|
|
8905
|
+
name: test.name
|
|
8906
|
+
})),
|
|
8907
|
+
summaryDurationMs: profile.summary.durationMs,
|
|
8908
|
+
testCount: testCounts.length === 0 ? unavailable("no sample in this profile carries test counts") : available(Math.max(...testCounts))
|
|
8909
|
+
}
|
|
8910
|
+
};
|
|
8911
|
+
};
|
|
8912
|
+
const collectProfiles = async (client, runId) => {
|
|
8913
|
+
let listing;
|
|
8914
|
+
try {
|
|
8915
|
+
listing = parseArtifactListing(await client.listArtifacts(runId));
|
|
8916
|
+
} catch (error) {
|
|
8917
|
+
return unavailable(`artifact listing failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8918
|
+
}
|
|
8919
|
+
const { artifacts } = listing;
|
|
8920
|
+
if (listing.totalCount !== void 0 && listing.totalCount > artifacts.length) return unavailable(`artifact listing is truncated: ${artifacts.length} of ${listing.totalCount} artifacts served`);
|
|
8921
|
+
if (artifacts.length === 0) return unavailable("this run has no artifacts");
|
|
8922
|
+
const analyses = [];
|
|
8923
|
+
for (const artifact of artifacts) {
|
|
8924
|
+
if (artifact.expired) {
|
|
8925
|
+
analyses.push({
|
|
8926
|
+
artifactName: artifact.name,
|
|
8927
|
+
kind: "unreadable",
|
|
8928
|
+
reason: "the artifact is expired and can no longer be downloaded"
|
|
8929
|
+
});
|
|
8930
|
+
continue;
|
|
8931
|
+
}
|
|
8932
|
+
let files;
|
|
8933
|
+
try {
|
|
8934
|
+
files = await client.readArtifactTextFiles(artifact.id);
|
|
8935
|
+
} catch (error) {
|
|
8936
|
+
analyses.push({
|
|
8937
|
+
artifactName: artifact.name,
|
|
8938
|
+
kind: "unreadable",
|
|
8939
|
+
reason: `artifact download failed: ${error instanceof Error ? error.message : String(error)}`
|
|
8940
|
+
});
|
|
8941
|
+
continue;
|
|
8942
|
+
}
|
|
8943
|
+
for (const file of files) {
|
|
8944
|
+
let parsed;
|
|
8945
|
+
try {
|
|
8946
|
+
parsed = JSON.parse(file.text);
|
|
8947
|
+
} catch {
|
|
8948
|
+
analyses.push({
|
|
8949
|
+
artifactName: `${artifact.name}/${file.name}`,
|
|
8950
|
+
kind: "unreadable",
|
|
8951
|
+
reason: "the file is not valid JSON"
|
|
8952
|
+
});
|
|
8953
|
+
continue;
|
|
8954
|
+
}
|
|
8955
|
+
const analysis = profileFromDocument(`${artifact.name}/${file.name}`, parsed);
|
|
8956
|
+
if (analysis.kind === "profile" || isRecord(parsed) && parsed.tool === "factory-ci-vitest-profile") analyses.push(analysis);
|
|
8957
|
+
}
|
|
8958
|
+
}
|
|
8959
|
+
return analyses.length === 0 ? unavailable("no compatible factory-ci Vitest profile artifacts on this run") : available(analyses);
|
|
8960
|
+
};
|
|
8961
|
+
const earliest = (instants) => instants.length === 0 ? void 0 : Math.min(...instants);
|
|
8962
|
+
const latest = (instants) => instants.length === 0 ? void 0 : Math.max(...instants);
|
|
8963
|
+
const runQueueDuration = (createdAt, jobs) => {
|
|
8964
|
+
const createdMs = parseInstantMs(createdAt);
|
|
8965
|
+
if (createdMs === void 0) return unavailable("the run object has no parsable created_at");
|
|
8966
|
+
const firstStart = earliest(jobs.flatMap((job) => {
|
|
8967
|
+
const started = parseInstantMs(job.startedAt);
|
|
8968
|
+
return started === void 0 ? [] : [started];
|
|
8883
8969
|
}));
|
|
8970
|
+
if (firstStart === void 0) return unavailable("no job carries a parsable started_at");
|
|
8971
|
+
return firstStart < createdMs ? unavailable("the earliest job start precedes the run creation instant") : available(firstStart - createdMs);
|
|
8972
|
+
};
|
|
8973
|
+
const runCompletionInstant = (jobs) => {
|
|
8974
|
+
const completions = jobs.flatMap((job) => {
|
|
8975
|
+
const completed = parseInstantMs(job.completedAt);
|
|
8976
|
+
return completed === void 0 ? [] : [{
|
|
8977
|
+
completed,
|
|
8978
|
+
iso: job.completedAt
|
|
8979
|
+
}];
|
|
8980
|
+
});
|
|
8981
|
+
const last = latest(completions.map((entry) => entry.completed));
|
|
8982
|
+
const match = completions.find((entry) => entry.completed === last);
|
|
8983
|
+
return match?.iso.available === true ? available(match.iso.value) : unavailable("no job carries a parsable completed_at");
|
|
8984
|
+
};
|
|
8985
|
+
/**
|
|
8986
|
+
* `cpuModel` never comes from the Actions API — only readable profile
|
|
8987
|
+
* artifacts carry silicon attribution, so both absence cases say so.
|
|
8988
|
+
*/
|
|
8989
|
+
const cpuModelsField = (cpuModels, profiles) => {
|
|
8990
|
+
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`);
|
|
8991
|
+
if (cpuModels.length === 0) return unavailable("no readable profile artifact recorded a CPU model");
|
|
8992
|
+
return available(cpuModels);
|
|
8993
|
+
};
|
|
8994
|
+
const analyzeOneRun = async (client, request, entry) => {
|
|
8995
|
+
const cohort = entry.cohort ?? "ungrouped";
|
|
8996
|
+
let runValue;
|
|
8997
|
+
let jobsValue;
|
|
8998
|
+
try {
|
|
8999
|
+
runValue = await client.getRun(entry.runId);
|
|
9000
|
+
jobsValue = await client.listJobs(entry.runId);
|
|
9001
|
+
} catch (error) {
|
|
9002
|
+
return {
|
|
9003
|
+
cohort,
|
|
9004
|
+
kind: "unreadable",
|
|
9005
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
9006
|
+
repository: request.repository,
|
|
9007
|
+
runId: entry.runId
|
|
9008
|
+
};
|
|
9009
|
+
}
|
|
9010
|
+
if (!isRecord(runValue)) return {
|
|
9011
|
+
cohort,
|
|
9012
|
+
kind: "unreadable",
|
|
9013
|
+
reason: "the run response is not an object",
|
|
9014
|
+
repository: request.repository,
|
|
9015
|
+
runId: entry.runId
|
|
9016
|
+
};
|
|
9017
|
+
const jobRows = isRecord(jobsValue) && Array.isArray(jobsValue.jobs) ? jobsValue.jobs.filter(isRecord) : [];
|
|
9018
|
+
if (isRecord(jobsValue) && typeof jobsValue.total_count === "number" && jobsValue.total_count > jobRows.length) return {
|
|
9019
|
+
cohort,
|
|
9020
|
+
kind: "unreadable",
|
|
9021
|
+
reason: `jobs listing is truncated: ${jobRows.length} of ${jobsValue.total_count} jobs served`,
|
|
9022
|
+
repository: request.repository,
|
|
9023
|
+
runId: entry.runId
|
|
9024
|
+
};
|
|
9025
|
+
const jobs = jobRows.map(analyzeJob);
|
|
9026
|
+
const createdAt = stringField$1(runValue, "created_at", "run");
|
|
9027
|
+
const startedAt = stringField$1(runValue, "run_started_at", "run");
|
|
9028
|
+
const completedAt = runCompletionInstant(jobs);
|
|
9029
|
+
const profiles = request.includeProfiles ? await collectProfiles(client, entry.runId) : unavailable("profile artifacts were not requested (pass --profiles)");
|
|
9030
|
+
const cpuModels = profiles.available ? [...new Set(profiles.value.flatMap((profile) => profile.kind === "profile" && profile.profile.cpuModel !== null ? [profile.profile.cpuModel] : []))] : void 0;
|
|
9031
|
+
const labels = [...new Set(jobs.flatMap((job) => job.runnerLabels.available ? job.runnerLabels.value : []))];
|
|
9032
|
+
return {
|
|
9033
|
+
cohort,
|
|
9034
|
+
completedAt,
|
|
9035
|
+
conclusion: stringField$1(runValue, "conclusion", "run"),
|
|
9036
|
+
cpuModels: cpuModelsField(cpuModels, profiles),
|
|
9037
|
+
createdAt,
|
|
9038
|
+
durations: {
|
|
9039
|
+
queueMs: runQueueDuration(createdAt, jobs),
|
|
9040
|
+
totalMs: durationBetween(startedAt, completedAt, "run duration")
|
|
9041
|
+
},
|
|
9042
|
+
event: stringField$1(runValue, "event", "run"),
|
|
9043
|
+
headSha: stringField$1(runValue, "head_sha", "run"),
|
|
9044
|
+
jobs,
|
|
9045
|
+
kind: "analyzed",
|
|
9046
|
+
profiles,
|
|
9047
|
+
proofReuse: classifyProofReuse(jobs),
|
|
9048
|
+
repository: request.repository,
|
|
9049
|
+
runId: entry.runId,
|
|
9050
|
+
runnerLabels: labels.length === 0 ? unavailable("no job carries runner labels") : available(labels),
|
|
9051
|
+
startedAt,
|
|
9052
|
+
url: stringField$1(runValue, "html_url", "run"),
|
|
9053
|
+
workflowName: stringField$1(runValue, "name", "run")
|
|
9054
|
+
};
|
|
9055
|
+
};
|
|
9056
|
+
/**
|
|
9057
|
+
* Quantile by the floor-index convention: over `sorted` (ascending), the
|
|
9058
|
+
* q-quantile is the element at index `floor(q * (n - 1))`. No interpolation —
|
|
9059
|
+
* every reported quantile is a duration that actually happened, and the
|
|
9060
|
+
* convention is stable for the small sample counts (3–10 runs) this command
|
|
9061
|
+
* exists for. median = q(0.5), p25 = q(0.25), p75 = q(0.75).
|
|
9062
|
+
*/
|
|
9063
|
+
const quantileFloorIndex = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))] ?? NaN;
|
|
9064
|
+
const quantileSummary = (values) => {
|
|
9065
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
9066
|
+
return {
|
|
9067
|
+
count: sorted.length,
|
|
9068
|
+
maximum: sorted.at(-1) ?? NaN,
|
|
9069
|
+
median: quantileFloorIndex(sorted, .5),
|
|
9070
|
+
minimum: sorted[0] ?? NaN,
|
|
9071
|
+
p25: quantileFloorIndex(sorted, .25),
|
|
9072
|
+
p75: quantileFloorIndex(sorted, .75)
|
|
9073
|
+
};
|
|
9074
|
+
};
|
|
9075
|
+
const classificationOf = (run) => run.proofReuse.available ? run.proofReuse.value.classification : "unclassified";
|
|
9076
|
+
const aggregateCohorts = (runs) => {
|
|
9077
|
+
const cells = /* @__PURE__ */ new Map();
|
|
9078
|
+
for (const run of runs) {
|
|
9079
|
+
if (run.kind !== "analyzed") continue;
|
|
9080
|
+
const key = `${run.cohort}\0${classificationOf(run)}`;
|
|
9081
|
+
const cell = cells.get(key) ?? [];
|
|
9082
|
+
cell.push(run);
|
|
9083
|
+
cells.set(key, cell);
|
|
9084
|
+
}
|
|
9085
|
+
return [...cells.entries()].map(([key, cellRuns]) => {
|
|
9086
|
+
const [cohort = UNGROUPED_COHORT] = key.split("\0");
|
|
9087
|
+
const totals = cellRuns.flatMap((run) => run.durations.totalMs.available ? [run.durations.totalMs.value] : []);
|
|
9088
|
+
const excludedRunIds = cellRuns.filter((run) => !run.durations.totalMs.available).map((run) => run.runId);
|
|
9089
|
+
return {
|
|
9090
|
+
classification: classificationOf(cellRuns[0]),
|
|
9091
|
+
cohort,
|
|
9092
|
+
count: cellRuns.length,
|
|
9093
|
+
excludedRunIds,
|
|
9094
|
+
runIds: cellRuns.map((run) => run.runId),
|
|
9095
|
+
totalDurationMs: totals.length === 0 ? unavailable("no run in this cell has an available total duration") : available(quantileSummary(totals))
|
|
9096
|
+
};
|
|
9097
|
+
}).toSorted((left, right) => `${left.cohort}\0${left.classification}`.localeCompare(`${right.cohort}\0${right.classification}`));
|
|
9098
|
+
};
|
|
9099
|
+
const cohortWarnings = (runs) => {
|
|
9100
|
+
const warnings = [];
|
|
9101
|
+
const byCohort = /* @__PURE__ */ new Map();
|
|
9102
|
+
for (const run of runs) {
|
|
9103
|
+
if (run.kind !== "analyzed") continue;
|
|
9104
|
+
const cohort = byCohort.get(run.cohort) ?? [];
|
|
9105
|
+
cohort.push(run);
|
|
9106
|
+
byCohort.set(run.cohort, cohort);
|
|
9107
|
+
}
|
|
9108
|
+
for (const [cohort, cohortRuns] of [...byCohort.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
9109
|
+
const workflows = [...new Set(cohortRuns.flatMap((run) => run.workflowName.available ? [run.workflowName.value] : []))].toSorted();
|
|
9110
|
+
if (workflows.length > 1) warnings.push(`cohort "${cohort}" spans workflow shapes: ${workflows.join(", ")} — durations across different workflows are not comparable`);
|
|
9111
|
+
const models = [...new Set(cohortRuns.flatMap((run) => run.cpuModels.available ? run.cpuModels.value : []))].toSorted();
|
|
9112
|
+
if (models.length > 1) warnings.push(`cohort "${cohort}" spans CPU models: ${models.join(", ")} — stratify by cpuModel before comparing (see the CI runner analysis docs)`);
|
|
9113
|
+
}
|
|
9114
|
+
return warnings;
|
|
9115
|
+
};
|
|
9116
|
+
/**
|
|
9117
|
+
* Analyze the requested runs. Read-only: the client is only ever asked to GET.
|
|
9118
|
+
* One unreadable run never aborts the report — it is carried as an explicit
|
|
9119
|
+
* `unreadable` entry so a partially-served API answer stays auditable.
|
|
9120
|
+
*/
|
|
9121
|
+
const analyzeCiRuns = async (request, client, dependencies = {}) => {
|
|
9122
|
+
if (request.runs.length === 0) throw new Error("ci:analyze requires at least one run id (--run, --runs-file, or --cohort).");
|
|
9123
|
+
const runs = [];
|
|
9124
|
+
for (const entry of request.runs) runs.push(await analyzeOneRun(client, request, entry));
|
|
9125
|
+
return {
|
|
9126
|
+
cohorts: aggregateCohorts(runs),
|
|
9127
|
+
generatedAt: (dependencies.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
9128
|
+
repository: request.repository,
|
|
9129
|
+
runs,
|
|
9130
|
+
schemaVersion: 1,
|
|
9131
|
+
tool: CI_ANALYZE_TOOL,
|
|
9132
|
+
warnings: cohortWarnings(runs)
|
|
9133
|
+
};
|
|
9134
|
+
};
|
|
9135
|
+
const formatSeconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
9136
|
+
const formatMaybeDuration = (value) => value.available ? formatSeconds(value.value) : `unavailable (${value.reason})`;
|
|
9137
|
+
const CLASSIFICATION_HEADINGS = {
|
|
9138
|
+
"full-fallback": "full hosted fallback",
|
|
9139
|
+
mixed: "mixed proof outcomes",
|
|
9140
|
+
"proof-reuse": "proof reuse",
|
|
9141
|
+
unclassified: "no proof-gate signal"
|
|
9142
|
+
};
|
|
9143
|
+
const renderRunLine = (run) => {
|
|
9144
|
+
const lines = [
|
|
9145
|
+
` run ${run.runId} — ${run.workflowName.available ? run.workflowName.value : "unknown workflow"} (${run.conclusion.available ? run.conclusion.value : "no conclusion"})`,
|
|
9146
|
+
` total ${formatMaybeDuration(run.durations.totalMs)}, queue ${formatMaybeDuration(run.durations.queueMs)}`,
|
|
9147
|
+
` ${run.url.available ? run.url.value : "run URL unavailable"}`
|
|
9148
|
+
];
|
|
9149
|
+
if (run.cpuModels.available) lines.push(` cpu: ${run.cpuModels.value.join(", ")}`);
|
|
9150
|
+
if (run.profiles.available) for (const profile of run.profiles.value) {
|
|
9151
|
+
if (profile.kind === "unreadable") {
|
|
9152
|
+
lines.push(` profile ${profile.artifactName}: unreadable (${profile.reason})`);
|
|
9153
|
+
continue;
|
|
9154
|
+
}
|
|
9155
|
+
const p = profile.profile;
|
|
9156
|
+
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"}`);
|
|
9157
|
+
const [slowFile] = p.slowFiles;
|
|
9158
|
+
if (slowFile) lines.push(` slowest file ${slowFile.path} (${formatSeconds(slowFile.medianMs)} median)`);
|
|
9159
|
+
const [slowTest] = p.slowTests;
|
|
9160
|
+
if (slowTest) lines.push(` slowest test ${slowTest.name} (${formatSeconds(slowTest.medianMs)} median)`);
|
|
9161
|
+
}
|
|
9162
|
+
return lines;
|
|
9163
|
+
};
|
|
9164
|
+
/**
|
|
9165
|
+
* Human rendering. Structure mirrors the aggregation rule: one section per
|
|
9166
|
+
* cohort × classification cell, so full fallback and proof reuse are never
|
|
9167
|
+
* visually blended, and before/after cohorts stay separate.
|
|
9168
|
+
*/
|
|
9169
|
+
const renderCiAnalyzeReport = (report) => {
|
|
9170
|
+
const lines = [`ci:analyze — ${report.repository} (schema v${report.schemaVersion})`];
|
|
9171
|
+
const analyzed = report.runs.filter((run) => run.kind === "analyzed");
|
|
9172
|
+
for (const cohort of report.cohorts) {
|
|
9173
|
+
lines.push("", `cohort "${cohort.cohort}" — ${CLASSIFICATION_HEADINGS[cohort.classification]} (${cohort.count} run(s))`);
|
|
9174
|
+
if (cohort.totalDurationMs.available) {
|
|
9175
|
+
const stats = cohort.totalDurationMs.value;
|
|
9176
|
+
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)}`);
|
|
9177
|
+
} else lines.push(` total duration: unavailable (${cohort.totalDurationMs.reason})`);
|
|
9178
|
+
if (cohort.excludedRunIds.length > 0) lines.push(` excluded from aggregates (total unavailable): ${cohort.excludedRunIds.join(", ")}`);
|
|
9179
|
+
for (const run of analyzed.filter((candidate) => candidate.cohort === cohort.cohort && classificationOf(candidate) === cohort.classification)) lines.push(...renderRunLine(run));
|
|
9180
|
+
}
|
|
9181
|
+
const unreadable = report.runs.filter((run) => run.kind === "unreadable");
|
|
9182
|
+
const unreadableCohorts = [...new Set(unreadable.map((run) => run.cohort))].toSorted((left, right) => left.localeCompare(right));
|
|
9183
|
+
for (const cohort of unreadableCohorts) {
|
|
9184
|
+
lines.push("", `cohort "${cohort}" — unreadable runs:`);
|
|
9185
|
+
for (const run of unreadable.filter((candidate) => candidate.cohort === cohort)) lines.push(` run ${run.runId}: ${run.reason}`);
|
|
9186
|
+
}
|
|
9187
|
+
if (report.warnings.length > 0) {
|
|
9188
|
+
lines.push("", "warnings:");
|
|
9189
|
+
for (const warning of report.warnings) lines.push(` - ${warning}`);
|
|
9190
|
+
}
|
|
9191
|
+
return `${lines.join("\n")}\n`;
|
|
9192
|
+
};
|
|
9193
|
+
//#endregion
|
|
9194
|
+
//#region src/commands/ci-analyze.ts
|
|
9195
|
+
/**
|
|
9196
|
+
* `ci:analyze` command wiring (#647).
|
|
9197
|
+
*
|
|
9198
|
+
* The analysis itself lives in `../ci-analyze.js`; this module owns the flag
|
|
9199
|
+
* grammar, the run-id assembly (`--run`, `--runs-file`, `--cohort`), the
|
|
9200
|
+
* report output paths, and the default `gh`-backed {@link CiAnalyzeClient}.
|
|
9201
|
+
* Authentication is entirely the caller's: every request goes through the
|
|
9202
|
+
* `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
|
|
9203
|
+
* no credential is stored or minted here.
|
|
9204
|
+
*/
|
|
9205
|
+
const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
9206
|
+
const parseRepository = (value) => {
|
|
9207
|
+
if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
|
|
9208
|
+
return value;
|
|
9209
|
+
};
|
|
9210
|
+
const parseRunId = (value) => {
|
|
9211
|
+
const parsed = Number(value);
|
|
9212
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`run id must be a positive integer, got "${value}"`);
|
|
9213
|
+
return parsed;
|
|
9214
|
+
};
|
|
9215
|
+
const collectRunOption = (value, previous = []) => [...previous, parseRunId(value)];
|
|
9216
|
+
/**
|
|
9217
|
+
* `--cohort label=id,id` — the same `name=value` grammar the existing psf
|
|
9218
|
+
* flags use for structured values, repeatable once per label.
|
|
9219
|
+
*
|
|
9220
|
+
* The label `ungrouped` is reserved: it is what the report calls runs the
|
|
9221
|
+
* caller left unlabeled, so accepting it as a caller cohort would silently
|
|
9222
|
+
* blend a named population with the genuinely unlabeled one.
|
|
9223
|
+
*/
|
|
9224
|
+
const parseCohortOption = (value, previous = []) => {
|
|
9225
|
+
const separator = value.indexOf("=");
|
|
9226
|
+
const label = separator === -1 ? "" : value.slice(0, separator).trim();
|
|
9227
|
+
const ids = separator === -1 ? "" : value.slice(separator + 1);
|
|
9228
|
+
if (label === "" || ids.trim() === "") throw new Error(`--cohort must be <label>=<runId>[,<runId>...], got "${value}"`);
|
|
9229
|
+
if (label === "ungrouped") throw new Error(`--cohort label "${UNGROUPED_COHORT}" is reserved for runs passed without a cohort; pick another label`);
|
|
9230
|
+
return [...previous, {
|
|
9231
|
+
label,
|
|
9232
|
+
runIds: ids.split(",").map((id) => parseRunId(id.trim()))
|
|
9233
|
+
}];
|
|
9234
|
+
};
|
|
9235
|
+
/** One run id per line; blank lines and `#` comments are ignored. */
|
|
9236
|
+
const parseRunsFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map(parseRunId);
|
|
9237
|
+
/**
|
|
9238
|
+
* Assemble the requested run set. A run id may appear in exactly one cohort;
|
|
9239
|
+
* uncohorted ids (from `--run` / `--runs-file`) stay unlabeled. Conflicting
|
|
9240
|
+
* cohort labels for one id are a hard error rather than a silent pick.
|
|
9241
|
+
*/
|
|
9242
|
+
const assembleRunRequests = (input) => {
|
|
9243
|
+
const cohortByRun = /* @__PURE__ */ new Map();
|
|
9244
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) {
|
|
9245
|
+
const existing = cohortByRun.get(runId);
|
|
9246
|
+
if (existing !== void 0 && existing !== cohort.label) throw new Error(`run ${runId} is assigned to both cohort "${existing}" and cohort "${cohort.label}"`);
|
|
9247
|
+
cohortByRun.set(runId, cohort.label);
|
|
9248
|
+
}
|
|
9249
|
+
const ordered = [];
|
|
9250
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9251
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) if (!seen.has(runId)) {
|
|
9252
|
+
seen.add(runId);
|
|
9253
|
+
ordered.push({
|
|
9254
|
+
cohort: cohort.label,
|
|
9255
|
+
runId
|
|
9256
|
+
});
|
|
9257
|
+
}
|
|
9258
|
+
for (const runId of input.runIds) if (!seen.has(runId)) {
|
|
9259
|
+
seen.add(runId);
|
|
9260
|
+
ordered.push({
|
|
9261
|
+
cohort: cohortByRun.get(runId) ?? null,
|
|
9262
|
+
runId
|
|
9263
|
+
});
|
|
9264
|
+
}
|
|
9265
|
+
return ordered;
|
|
9266
|
+
};
|
|
9267
|
+
const GH_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024;
|
|
9268
|
+
const PAGE_SIZE = 100;
|
|
9269
|
+
const MAX_PAGES = 50;
|
|
9270
|
+
/**
|
|
9271
|
+
* Follow GitHub's pagination for one collection endpoint: request pages until
|
|
9272
|
+
* the served rows reach the response's `total_count` (or a page comes back
|
|
9273
|
+
* empty). The merged document keeps the `total_count` field, so if the page
|
|
9274
|
+
* budget is ever exhausted the analyzer's truncation guard reports the
|
|
9275
|
+
* shortfall as explicit unavailability instead of a silent partial read.
|
|
9276
|
+
*/
|
|
9277
|
+
const listActionsCollection = (basePath, key) => {
|
|
9278
|
+
const rows = [];
|
|
9279
|
+
let totalCount;
|
|
9280
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
9281
|
+
const response = runGhJson(["api", `${basePath}?per_page=${PAGE_SIZE}&page=${page}`]);
|
|
9282
|
+
const served = Array.isArray(response[key]) ? response[key] : [];
|
|
9283
|
+
rows.push(...served);
|
|
9284
|
+
totalCount = typeof response.total_count === "number" ? response.total_count : totalCount;
|
|
9285
|
+
if (served.length === 0 || rows.length >= (totalCount ?? 0)) break;
|
|
9286
|
+
}
|
|
9287
|
+
return {
|
|
9288
|
+
[key]: rows,
|
|
9289
|
+
total_count: totalCount ?? rows.length
|
|
9290
|
+
};
|
|
9291
|
+
};
|
|
9292
|
+
/**
|
|
9293
|
+
* A Unix-host entry line: permissions, zip version, host OS, decompressed
|
|
9294
|
+
* size, text/binary flags, method, date, time, then the member name (which
|
|
9295
|
+
* may itself contain spaces). This is the one form GitHub's artifact service
|
|
9296
|
+
* actually emits; other host forms are deliberately *not* supported — they
|
|
9297
|
+
* fail reconciliation below rather than being half-parsed.
|
|
9298
|
+
*/
|
|
9299
|
+
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;
|
|
9300
|
+
/** `Zip file size: 12109 bytes, number of entries: 2` */
|
|
9301
|
+
const ZIP_HEADER_PATTERN = /^Zip file size: \d+ bytes, number of entries: (?<count>\d+)$/u;
|
|
9302
|
+
/** `2 files, 81410 bytes uncompressed, 11689 bytes compressed: 85.6%` */
|
|
9303
|
+
const ZIP_TRAILER_PATTERN = /^(?<count>\d+) files?, \d+ bytes? uncompressed, \d+ bytes? compressed:\s+-?[\d.]+%$/u;
|
|
9304
|
+
/**
|
|
9305
|
+
* Parse zipinfo's default listing and *reconcile* it against the archive's
|
|
9306
|
+
* own declared entry count — fail closed, never a partial view.
|
|
9307
|
+
*
|
|
9308
|
+
* The security property the caps and member policy rest on is that the parsed
|
|
9309
|
+
* set is the WHOLE central directory. A listing line this parser does not
|
|
9310
|
+
* recognize (a FAT-host form, a future zipinfo change) therefore refuses the
|
|
9311
|
+
* whole artifact rather than being skipped: a skipped line would let
|
|
9312
|
+
* `unzip <name>` extract members the caps never counted. Three refusals:
|
|
9313
|
+
* an unrecognized line, a parsed count that differs from the declared count
|
|
9314
|
+
* (every header and trailer declaration must agree — a listing that
|
|
9315
|
+
* contradicts itself has no unambiguous count and is refused), and any
|
|
9316
|
+
* duplicate member name across the full parsed set — a parsed safe name
|
|
9317
|
+
* shadowed by an unparsed or differently-shaped duplicate is exactly the
|
|
9318
|
+
* bypass this closes.
|
|
9319
|
+
*/
|
|
9320
|
+
const parseZipInfoListing = (text) => {
|
|
9321
|
+
const members = [];
|
|
9322
|
+
const declaredCounts = [];
|
|
9323
|
+
for (const line of text.split("\n")) {
|
|
9324
|
+
if (line.trim() === "" || line.startsWith("Archive:")) continue;
|
|
9325
|
+
const header = ZIP_HEADER_PATTERN.exec(line);
|
|
9326
|
+
if (header?.groups) {
|
|
9327
|
+
declaredCounts.push(Number(header.groups.count));
|
|
9328
|
+
continue;
|
|
9329
|
+
}
|
|
9330
|
+
const trailer = ZIP_TRAILER_PATTERN.exec(line);
|
|
9331
|
+
if (trailer?.groups) {
|
|
9332
|
+
declaredCounts.push(Number(trailer.groups.count));
|
|
9333
|
+
continue;
|
|
9334
|
+
}
|
|
9335
|
+
const entry = ZIP_ENTRY_PATTERN.exec(line);
|
|
9336
|
+
if (entry?.groups) {
|
|
9337
|
+
members.push({
|
|
9338
|
+
isSymlink: entry.groups.perms?.startsWith("l") === true,
|
|
9339
|
+
name: entry.groups.name ?? "",
|
|
9340
|
+
sizeBytes: Number(entry.groups.size)
|
|
9341
|
+
});
|
|
9342
|
+
continue;
|
|
9343
|
+
}
|
|
9344
|
+
throw new Error(`zip listing reconciliation failed: unrecognized listing line "${line}"`);
|
|
9345
|
+
}
|
|
9346
|
+
const [declaredEntries] = declaredCounts;
|
|
9347
|
+
if (declaredEntries === void 0) throw new Error("zip listing reconciliation failed: the listing declares no entry count");
|
|
9348
|
+
if (declaredCounts.some((count) => count !== declaredEntries)) throw new Error(`zip listing reconciliation failed: contradictory declared entry counts (${declaredCounts.join(", ")})`);
|
|
9349
|
+
if (members.length !== declaredEntries) throw new Error(`zip listing reconciliation failed: parsed ${members.length} of ${declaredEntries} declared entries`);
|
|
9350
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9351
|
+
for (const member of members) {
|
|
9352
|
+
if (seen.has(member.name)) throw new Error(`zip listing reconciliation failed: duplicate member name "${member.name}"`);
|
|
9353
|
+
seen.add(member.name);
|
|
9354
|
+
}
|
|
9355
|
+
return members;
|
|
9356
|
+
};
|
|
9357
|
+
const MAX_ARTIFACT_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
9358
|
+
const UNSAFE_MEMBER_CHARACTERS = /[*?[\]]/u;
|
|
9359
|
+
/**
|
|
9360
|
+
* The member policy for artifact zips, as a pure function so the security
|
|
9361
|
+
* decisions are testable without `unzip`. The archive is refused outright —
|
|
9362
|
+
* not partially read — when any member is a symlink, an absolute path, a
|
|
9363
|
+
* `..` traversal, option-shaped (leading `-`), or glob-ambiguous, or when the
|
|
9364
|
+
* archive exceeds the count or decompressed-size caps. What survives is the
|
|
9365
|
+
* `*.json` member list actually handed to extraction.
|
|
9366
|
+
*/
|
|
9367
|
+
const selectProfileZipMembers = (members) => {
|
|
9368
|
+
if (members.length > 512) throw new Error(`artifact zip refused: ${members.length} members exceeds the 512-member cap`);
|
|
9369
|
+
let totalBytes = 0;
|
|
9370
|
+
for (const member of members) {
|
|
9371
|
+
if (member.isSymlink) throw new Error(`artifact zip refused: member "${member.name}" is a symlink`);
|
|
9372
|
+
if (member.name.startsWith("/") || /^[A-Za-z]:/u.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" is an absolute path`);
|
|
9373
|
+
if (member.name.split("/").includes("..")) throw new Error(`artifact zip refused: member "${member.name}" traverses with ..`);
|
|
9374
|
+
if (member.name.startsWith("-")) throw new Error(`artifact zip refused: member "${member.name}" is option-shaped`);
|
|
9375
|
+
if (UNSAFE_MEMBER_CHARACTERS.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" contains glob metacharacters`);
|
|
9376
|
+
if (!Number.isFinite(member.sizeBytes) || member.sizeBytes < 0) throw new Error(`artifact zip refused: member "${member.name}" reports no usable size`);
|
|
9377
|
+
totalBytes += member.sizeBytes;
|
|
9378
|
+
}
|
|
9379
|
+
if (totalBytes > 268435456) throw new Error(`artifact zip refused: ${totalBytes} decompressed bytes exceeds the ${MAX_ARTIFACT_DECOMPRESSED_BYTES}-byte cap`);
|
|
9380
|
+
return members.filter((member) => !member.name.endsWith("/") && member.name.endsWith(".json")).map((member) => member.name).toSorted();
|
|
9381
|
+
};
|
|
9382
|
+
/**
|
|
9383
|
+
* The default client: `gh api` for JSON endpoints (paginated), `gh api` plus
|
|
9384
|
+
* system `unzip` for artifact zips, with every member vetted by
|
|
9385
|
+
* {@link selectProfileZipMembers} before extraction. The exec adapter itself
|
|
9386
|
+
* is deliberately thin and untested networked code — every decision made from
|
|
9387
|
+
* these bytes lives behind {@link CiAnalyzeClient} or the pure policy
|
|
9388
|
+
* functions above, where fixtures drive it.
|
|
9389
|
+
*/
|
|
9390
|
+
const createGhCiAnalyzeClient = (repository) => ({
|
|
9391
|
+
getRun: (runId) => Promise.resolve(runGhJson(["api", `/repos/${repository}/actions/runs/${runId}`])),
|
|
9392
|
+
listArtifacts: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/artifacts`, "artifacts")),
|
|
9393
|
+
listJobs: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/jobs`, "jobs")),
|
|
9394
|
+
readArtifactTextFiles: (artifactId) => {
|
|
9395
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "psf-ci-analyze-"));
|
|
9396
|
+
try {
|
|
9397
|
+
const zip = execFileSync("gh", ["api", `/repos/${repository}/actions/artifacts/${artifactId}/zip`], { maxBuffer: GH_ARTIFACT_MAX_BYTES });
|
|
9398
|
+
const zipPath = path.join(scratch, "artifact.zip");
|
|
9399
|
+
writeFileSync(zipPath, zip);
|
|
9400
|
+
const selected = selectProfileZipMembers(parseZipInfoListing(execFileSync("unzip", ["-Z", zipPath], {
|
|
9401
|
+
encoding: "utf-8",
|
|
9402
|
+
maxBuffer: GH_ARTIFACT_MAX_BYTES
|
|
9403
|
+
})));
|
|
9404
|
+
if (selected.length === 0) return Promise.resolve([]);
|
|
9405
|
+
const extracted = path.join(scratch, "extracted");
|
|
9406
|
+
execFileSync("unzip", [
|
|
9407
|
+
"-o",
|
|
9408
|
+
"-qq",
|
|
9409
|
+
zipPath,
|
|
9410
|
+
"-d",
|
|
9411
|
+
extracted,
|
|
9412
|
+
...selected
|
|
9413
|
+
], { stdio: [
|
|
9414
|
+
"ignore",
|
|
9415
|
+
"ignore",
|
|
9416
|
+
"pipe"
|
|
9417
|
+
] });
|
|
9418
|
+
return Promise.resolve(selected.map((name) => ({
|
|
9419
|
+
name,
|
|
9420
|
+
text: readFileSync(path.join(extracted, name), "utf-8")
|
|
9421
|
+
})));
|
|
9422
|
+
} finally {
|
|
9423
|
+
rmSync(scratch, {
|
|
9424
|
+
force: true,
|
|
9425
|
+
recursive: true
|
|
9426
|
+
});
|
|
9427
|
+
}
|
|
9428
|
+
}
|
|
9429
|
+
});
|
|
9430
|
+
const defaultAction = (request) => analyzeCiRuns(request, createGhCiAnalyzeClient(request.repository));
|
|
9431
|
+
function createCiAnalyzeCommand(output, action = defaultAction) {
|
|
9432
|
+
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) => {
|
|
9433
|
+
const fromFile = options.runsFile === void 0 ? [] : parseRunsFile(readFileSync(path.resolve(options.runsFile), "utf-8"));
|
|
9434
|
+
const report = await action({
|
|
9435
|
+
includeProfiles: options.profiles === true,
|
|
9436
|
+
repository: options.repo,
|
|
9437
|
+
runs: assembleRunRequests({
|
|
9438
|
+
cohorts: options.cohort ?? [],
|
|
9439
|
+
runIds: [...options.run ?? [], ...fromFile]
|
|
9440
|
+
})
|
|
9441
|
+
});
|
|
9442
|
+
if (options.output !== void 0) {
|
|
9443
|
+
const outputPath = path.resolve(options.output);
|
|
9444
|
+
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
9445
|
+
output.stderr.write(`ci:analyze report written to ${outputPath}\n`);
|
|
9446
|
+
}
|
|
9447
|
+
output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCiAnalyzeReport(report));
|
|
9448
|
+
});
|
|
8884
9449
|
}
|
|
8885
9450
|
//#endregion
|
|
8886
9451
|
//#region src/checkout-repository.ts
|
|
@@ -9547,6 +10112,8 @@ const buildRetroEnvelope = (input) => {
|
|
|
9547
10112
|
const RETRO_ENVELOPE_LEDGER_DIR = ".factory-memory/retro-envelope";
|
|
9548
10113
|
const safeEnvelopeName = (agentRunId) => agentRunId.replaceAll(/[^\w.-]/gu, "-").slice(0, 120);
|
|
9549
10114
|
const retroEnvelopePath = (root, agentRunId) => path.join(root, RETRO_ENVELOPE_LEDGER_DIR, `${safeEnvelopeName(agentRunId)}.json`);
|
|
10115
|
+
const CLAUDE_CODE_SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID";
|
|
10116
|
+
const resolveNativeSessionId = (env) => normalizeSessionId(env[CLAUDE_CODE_SESSION_ID_ENV]);
|
|
9550
10117
|
const splitRepo = (repo) => {
|
|
9551
10118
|
const [owner, name] = repo.split("/");
|
|
9552
10119
|
return {
|
|
@@ -9572,11 +10139,11 @@ const otherLedgersExist = (repoRoot) => {
|
|
|
9572
10139
|
const buildAndPersistRetroEnvelope = (input) => {
|
|
9573
10140
|
const env = input.env ?? process.env;
|
|
9574
10141
|
const now = input.now ?? (() => /* @__PURE__ */ new Date());
|
|
9575
|
-
const laneIdentity = resolveGateTimingAgentRunId(env);
|
|
10142
|
+
const laneIdentity = resolveGateTimingAgentRunId(env) ?? resolveNativeSessionId(env);
|
|
9576
10143
|
const agentRunId = laneIdentity ?? `closeout-${safeEnvelopeName(input.epic)}`;
|
|
9577
10144
|
const ledgerPath = gateTimingLedgerPath(input.repoRoot, agentRunId);
|
|
9578
10145
|
const ledger = laneIdentity === void 0 ? [] : readGateTimingLedger(ledgerPath);
|
|
9579
|
-
const claudeSessionId = discoverFactorySessionId(env);
|
|
10146
|
+
const claudeSessionId = discoverFactorySessionId(env) ?? resolveNativeSessionId(env);
|
|
9580
10147
|
const dataGaps = [];
|
|
9581
10148
|
if (laneIdentity === void 0) dataGaps.push("agentRunId is synthetic (no lane run identity exported): keyed to the epic, ledger not folded.");
|
|
9582
10149
|
else if (!existsSync(ledgerPath) && otherLedgersExist(input.repoRoot)) dataGaps.push(`gates[] empty: no gate-timing ledger for agentRunId "${agentRunId}" although other lane ledgers exist under ${GATE_TIMING_LEDGER_DIR} — possible identity drift between ledger writes and closeout.`);
|
|
@@ -9855,6 +10422,7 @@ function createCloseoutCommand(output, dependencies = {}) {
|
|
|
9855
10422
|
});
|
|
9856
10423
|
const retro = buildRetroGate({
|
|
9857
10424
|
codexThreadIds: codexThreadIdsForCloseout(dependencies.env),
|
|
10425
|
+
...dependencies.env === void 0 ? {} : { env: dependencies.env },
|
|
9858
10426
|
epic: options.epic,
|
|
9859
10427
|
...options.issue === void 0 ? {} : { issue: options.issue },
|
|
9860
10428
|
...options.pr === void 0 ? {} : { pr: options.pr },
|
|
@@ -11167,12 +11735,14 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
11167
11735
|
impactStamp = computeImpactStamp({
|
|
11168
11736
|
changedFiles: files,
|
|
11169
11737
|
profile,
|
|
11738
|
+
profilePath: relativeProfile,
|
|
11170
11739
|
readLockfile: (side) => readLockfileAtRef(cwd, side === "base" ? proofMergeBaseSha : headSha, path.resolve(cwd, LOCKFILE_PATH))
|
|
11171
11740
|
});
|
|
11172
11741
|
} catch (error) {
|
|
11173
11742
|
if (error instanceof ImpactStampConfigError) throw error;
|
|
11174
11743
|
impactStamp = conservativeImpactStamp(profile.impact?.targets ?? [], [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
|
|
11175
11744
|
}
|
|
11745
|
+
for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
|
|
11176
11746
|
const vetoedTargets = boundFailingCheckNames({
|
|
11177
11747
|
candidate: {
|
|
11178
11748
|
headSha,
|
|
@@ -11479,7 +12049,7 @@ const runDemandWaive = (args, dependencies = {}) => {
|
|
|
11479
12049
|
* offers no way to declare a demand met, only to waive it on the record.
|
|
11480
12050
|
*/
|
|
11481
12051
|
function createDemandWaiveCommand(_output, action = runDemandWaive) {
|
|
11482
|
-
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",
|
|
12052
|
+
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) => {
|
|
11483
12053
|
action({
|
|
11484
12054
|
cwd: resolveCwdOption(options.cwd),
|
|
11485
12055
|
demand: options.demand,
|
|
@@ -11488,7 +12058,7 @@ function createDemandWaiveCommand(_output, action = runDemandWaive) {
|
|
|
11488
12058
|
pr: options.pr,
|
|
11489
12059
|
rationale: options.rationale
|
|
11490
12060
|
});
|
|
11491
|
-
});
|
|
12061
|
+
}));
|
|
11492
12062
|
}
|
|
11493
12063
|
//#endregion
|
|
11494
12064
|
//#region src/demand-resolution.ts
|
|
@@ -11592,20 +12162,32 @@ const defaultFetchIssueBody = ({ issue, owner, repo }) => runGhJson([
|
|
|
11592
12162
|
* closed: an absent, unparseable, or unmatched manifest binds nothing and
|
|
11593
12163
|
* names its refusal.
|
|
11594
12164
|
*/
|
|
11595
|
-
const resolveWaveDemand = ({ epic, fetchClosingPullRequests = defaultFetchClosingPullRequests, fetchIssueBody = defaultFetchIssueBody, owner, pr, repo }) => {
|
|
12165
|
+
const resolveWaveDemand = ({ baseRefName, epic, fetchClosingPullRequests = defaultFetchClosingPullRequests, fetchIssueBody = defaultFetchIssueBody, headRefName, owner, pr, repo }) => {
|
|
11596
12166
|
const parsed = parseBoundaryManifest(fetchIssueBody({
|
|
11597
12167
|
issue: epic,
|
|
11598
12168
|
owner,
|
|
11599
12169
|
repo
|
|
11600
12170
|
}));
|
|
11601
12171
|
if (!parsed.ok) return { reasons: [`Epic #${epic} boundary manifest refused: ${parsed.error}`] };
|
|
11602
|
-
|
|
12172
|
+
const resolution = waveDemandForPullRequest({
|
|
11603
12173
|
fetchClosingPullRequests,
|
|
11604
12174
|
manifest: parsed.manifest,
|
|
11605
12175
|
owner,
|
|
11606
12176
|
pr,
|
|
11607
12177
|
repo
|
|
11608
12178
|
});
|
|
12179
|
+
if (!resolution.demand) return resolution;
|
|
12180
|
+
return {
|
|
12181
|
+
demand: resolution.demand,
|
|
12182
|
+
reasons: pullRequestTopologyReasons({
|
|
12183
|
+
manifest: parsed.manifest,
|
|
12184
|
+
pullRequest: {
|
|
12185
|
+
...baseRefName === void 0 ? {} : { baseRefName },
|
|
12186
|
+
...headRefName === void 0 ? {} : { headRefName },
|
|
12187
|
+
number: pr
|
|
12188
|
+
}
|
|
12189
|
+
})
|
|
12190
|
+
};
|
|
11609
12191
|
};
|
|
11610
12192
|
/**
|
|
11611
12193
|
* Whether recorded typed review evidence satisfies the demanded review rung.
|
|
@@ -12081,10 +12663,10 @@ const describeOrphans = (orphans) => {
|
|
|
12081
12663
|
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>\`.`;
|
|
12082
12664
|
};
|
|
12083
12665
|
/**
|
|
12084
|
-
*
|
|
12085
|
-
*
|
|
12086
|
-
*
|
|
12087
|
-
*
|
|
12666
|
+
* Warns when HQ evidence is waiting for this repository: its own spool or a
|
|
12667
|
+
* sibling location written under an earlier key of its own that it no longer
|
|
12668
|
+
* resolves to (#420). Another repository's spool under the shared root is that
|
|
12669
|
+
* repository's business, never this check's (#446).
|
|
12088
12670
|
*
|
|
12089
12671
|
* Reuses `countHqSpoolWork` (#414) — the same read-only, credential-free
|
|
12090
12672
|
* inspection `hq:flush` itself consults before ever resolving a secret — so
|
|
@@ -12094,19 +12676,23 @@ const describeOrphans = (orphans) => {
|
|
|
12094
12676
|
* the spool resolve exactly one key, so evidence written under an older one is
|
|
12095
12677
|
* invisible to a drain and, before this, to doctor: the failure epic #389 was
|
|
12096
12678
|
* chartered to end is evidence that is neither delivered nor visibly stranded.
|
|
12097
|
-
* An orphan holding pending events
|
|
12098
|
-
* spool
|
|
12679
|
+
* An orphan holding pending events warns for the same reason the repo-keyed
|
|
12680
|
+
* spool does.
|
|
12681
|
+
*
|
|
12682
|
+
* `warning`, not `error` (#659, epic #663 wave 1): a spooled event is real
|
|
12683
|
+
* HQ-delivery lag, but doctor is a read-only diagnostic that must never gain a
|
|
12684
|
+
* flush side-effect, and a preflight gate that treats doctor's exit code as
|
|
12685
|
+
* the bar must not block admission on delivery lag it cannot fix from here.
|
|
12686
|
+
* `psf hq:flush` — a trusted local session, not doctor — is still how the
|
|
12687
|
+
* spool actually drains.
|
|
12099
12688
|
*/
|
|
12100
12689
|
async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
12101
12690
|
const countSpool = dependencies.countSpool ?? countHqSpoolWork;
|
|
12102
12691
|
const sweepOrphans = dependencies.sweepOrphans ?? sweepHqSpoolOrphans;
|
|
12103
|
-
const [counted, swept] = await Promise.all([countSpool({
|
|
12104
|
-
cwd: input.cwd,
|
|
12105
|
-
repository: input.repository
|
|
12106
|
-
}, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
|
|
12692
|
+
const [counted, swept] = await Promise.all([countSpool({ repository: input.repository }, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
|
|
12107
12693
|
const orphanNote = swept.orphans.length === 0 ? "" : describeOrphans(swept.orphans);
|
|
12108
12694
|
if (counted.pending === 0 && counted.unlistable === 0 && swept.orphans.length === 0) return {
|
|
12109
|
-
message: "HQ spool
|
|
12695
|
+
message: "HQ spool is empty; no evidence is waiting to be drained.",
|
|
12110
12696
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12111
12697
|
status: "ok"
|
|
12112
12698
|
};
|
|
@@ -12115,17 +12701,17 @@ async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
|
12115
12701
|
if (counted.pending > 0) return {
|
|
12116
12702
|
message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
|
|
12117
12703
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12118
|
-
status: "
|
|
12704
|
+
status: "warning"
|
|
12119
12705
|
};
|
|
12120
12706
|
if (counted.unlistable > 0) return {
|
|
12121
12707
|
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}`,
|
|
12122
12708
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12123
|
-
status: "
|
|
12709
|
+
status: "warning"
|
|
12124
12710
|
};
|
|
12125
12711
|
return {
|
|
12126
12712
|
message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
|
|
12127
12713
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12128
|
-
status: "
|
|
12714
|
+
status: "warning"
|
|
12129
12715
|
};
|
|
12130
12716
|
}
|
|
12131
12717
|
const readJsonFiles = async (dir, readFileImpl) => {
|
|
@@ -12310,7 +12896,6 @@ async function doctorProjectProfile(input = {}) {
|
|
|
12310
12896
|
const cwd = input.cwd ?? process.cwd();
|
|
12311
12897
|
const userConfig = resolveDoctorUserConfig(env, input.userConfig);
|
|
12312
12898
|
const [hqSpoolCheck, hqRetroReadbackCheck] = await Promise.all([hqSpoolDoctorCheck({
|
|
12313
|
-
cwd,
|
|
12314
12899
|
env,
|
|
12315
12900
|
repository: {
|
|
12316
12901
|
owner: profile.repository.owner,
|
|
@@ -12477,7 +13062,7 @@ function repositoryUrlMatches(origin, profile) {
|
|
|
12477
13062
|
//#endregion
|
|
12478
13063
|
//#region src/commands/doctor.ts
|
|
12479
13064
|
function createDoctorCommand(output) {
|
|
12480
|
-
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",
|
|
13065
|
+
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) => {
|
|
12481
13066
|
const report = await doctorProjectProfile({
|
|
12482
13067
|
base: options.base,
|
|
12483
13068
|
cwd: resolveCwdOption(options.cwd),
|
|
@@ -12487,7 +13072,7 @@ function createDoctorCommand(output) {
|
|
|
12487
13072
|
if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
12488
13073
|
else output.stdout.write(formatHumanReport(report));
|
|
12489
13074
|
if (!report.ok) process.exitCode = 1;
|
|
12490
|
-
});
|
|
13075
|
+
}));
|
|
12491
13076
|
}
|
|
12492
13077
|
function formatHumanReport(report) {
|
|
12493
13078
|
return [
|
|
@@ -12895,7 +13480,7 @@ function optionalString(key, flagValue, stdinValue) {
|
|
|
12895
13480
|
return resolved ? { [key]: resolved } : {};
|
|
12896
13481
|
}
|
|
12897
13482
|
function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput = readStdin) {
|
|
12898
|
-
return new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory",
|
|
13483
|
+
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) => {
|
|
12899
13484
|
const cwd = resolveCwdOption(options.cwd);
|
|
12900
13485
|
const fromStdin = options.stdin ? readInput() : {};
|
|
12901
13486
|
rejectRetiredStdinFields(fromStdin);
|
|
@@ -12922,7 +13507,7 @@ function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput =
|
|
|
12922
13507
|
});
|
|
12923
13508
|
output.stdout.write(`evidence:emit wrote envelope for "${result.envelope.check}" to ${result.path}\n`);
|
|
12924
13509
|
if (options.json && "envelope" in result) output.stdout.write(`${JSON.stringify(result.envelope, null, 2)}\n`);
|
|
12925
|
-
});
|
|
13510
|
+
}));
|
|
12926
13511
|
}
|
|
12927
13512
|
//#endregion
|
|
12928
13513
|
//#region src/hq-flush.ts
|
|
@@ -12988,7 +13573,6 @@ async function runHqFlush(args, dependencies = {}) {
|
|
|
12988
13573
|
};
|
|
12989
13574
|
const explicitDirectories = args.dir && args.dir.length > 0 ? { explicitDirectories: args.dir } : {};
|
|
12990
13575
|
const pending = await (dependencies.countSpool ?? countHqSpoolWork)({
|
|
12991
|
-
cwd: args.cwd,
|
|
12992
13576
|
...explicitDirectories,
|
|
12993
13577
|
repository
|
|
12994
13578
|
}, { env });
|
|
@@ -13033,7 +13617,6 @@ async function runHqFlush(args, dependencies = {}) {
|
|
|
13033
13617
|
...await (dependencies.flush ?? flushHqSpool)({
|
|
13034
13618
|
clientId: resolution.credentials.clientId,
|
|
13035
13619
|
clientSecret: resolution.credentials.clientSecret,
|
|
13036
|
-
cwd: args.cwd,
|
|
13037
13620
|
endpoint: profile.hq.endpoint,
|
|
13038
13621
|
...explicitDirectories,
|
|
13039
13622
|
repository
|
|
@@ -13060,7 +13643,7 @@ function renderHqFlush(result) {
|
|
|
13060
13643
|
`hq:flush ${result.endpoint}`,
|
|
13061
13644
|
...result.spools.length === 0 ? ["no spool directory found"] : result.spools.map((spool) => `spool: ${spool}`),
|
|
13062
13645
|
...result.outcomes.map(outcomeLine),
|
|
13063
|
-
`delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}
|
|
13646
|
+
`delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}`,
|
|
13064
13647
|
...result.undeliverable > 0 ? [`${result.undeliverable} event(s) can never be delivered and were dispositioned in place, renamed with \`.undeliverable\` and left readable; they no longer count as work waiting.`] : [],
|
|
13065
13648
|
result.incomplete ? `INCOMPLETE: ${result.remaining} event(s) still spooled; the drain did not finish. Run hq:flush again.` : `spool drained: ${result.remaining} event(s) remain (rejections stay until HQ accepts them)`,
|
|
13066
13649
|
...orphanLines(result.orphans)
|
|
@@ -13087,7 +13670,7 @@ const hqFlushExitCode = (result) => {
|
|
|
13087
13670
|
//#endregion
|
|
13088
13671
|
//#region src/commands/hq-flush.ts
|
|
13089
13672
|
function createHqFlushCommand(output, action = runHqFlush) {
|
|
13090
|
-
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",
|
|
13673
|
+
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) => {
|
|
13091
13674
|
const result = await action({
|
|
13092
13675
|
cwd: resolveCwdOption(options.cwd),
|
|
13093
13676
|
...options.dir ? { dir: options.dir } : {},
|
|
@@ -13097,7 +13680,7 @@ function createHqFlushCommand(output, action = runHqFlush) {
|
|
|
13097
13680
|
output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
|
|
13098
13681
|
const exitCode = hqFlushExitCode(result);
|
|
13099
13682
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
13100
|
-
});
|
|
13683
|
+
}));
|
|
13101
13684
|
}
|
|
13102
13685
|
//#endregion
|
|
13103
13686
|
//#region src/follow-up.ts
|
|
@@ -13133,6 +13716,13 @@ const prVerifyFollowUp = (authoringSession) => followUpFromArgv([
|
|
|
13133
13716
|
"pr:verify",
|
|
13134
13717
|
...authoringSession ? ["--authoring-session", authoringSession] : []
|
|
13135
13718
|
]);
|
|
13719
|
+
/** The route-owned Promotion from a GitHub draft into a Candidate. */
|
|
13720
|
+
const prUndraftFollowUp = (pr) => followUpFromArgv([
|
|
13721
|
+
"gh",
|
|
13722
|
+
"pr",
|
|
13723
|
+
"ready",
|
|
13724
|
+
String(pr)
|
|
13725
|
+
]);
|
|
13136
13726
|
/** Canonical zod schema for the optional follow-up field on JSON outputs. */
|
|
13137
13727
|
const FollowUpActionSchema = z.object({
|
|
13138
13728
|
argv: z.array(z.string()),
|
|
@@ -14248,15 +14838,18 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
14248
14838
|
command: `gh pr checks ${input.pr} --watch`
|
|
14249
14839
|
} } : {}
|
|
14250
14840
|
});
|
|
14251
|
-
if (input.draft)
|
|
14252
|
-
|
|
14253
|
-
|
|
14254
|
-
|
|
14255
|
-
|
|
14256
|
-
|
|
14257
|
-
|
|
14258
|
-
|
|
14259
|
-
|
|
14841
|
+
if (input.draft) {
|
|
14842
|
+
const promotion = prUndraftFollowUp(input.pr);
|
|
14843
|
+
blockers.push({
|
|
14844
|
+
demand: DEMAND_KEYS.draft,
|
|
14845
|
+
reason: DRAFT_BLOCKER_REASON,
|
|
14846
|
+
repair: {
|
|
14847
|
+
action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
|
|
14848
|
+
code: "undraft-pr",
|
|
14849
|
+
command: promotion.command
|
|
14850
|
+
}
|
|
14851
|
+
});
|
|
14852
|
+
}
|
|
14260
14853
|
return blockers;
|
|
14261
14854
|
};
|
|
14262
14855
|
const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
|
|
@@ -14615,6 +15208,7 @@ const PR_VIEW_JSON_FIELDS = [
|
|
|
14615
15208
|
"autoMergeRequest",
|
|
14616
15209
|
"baseRefName",
|
|
14617
15210
|
"body",
|
|
15211
|
+
"headRefName",
|
|
14618
15212
|
"headRefOid",
|
|
14619
15213
|
"isDraft",
|
|
14620
15214
|
"mergeStateStatus",
|
|
@@ -14817,9 +15411,11 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
14817
15411
|
});
|
|
14818
15412
|
const { ladderPolicy } = demands;
|
|
14819
15413
|
const waveDemand = args.epic === void 0 ? void 0 : resolveWaveDemand({
|
|
15414
|
+
baseRefName: pr.baseRefName,
|
|
14820
15415
|
epic: args.epic,
|
|
14821
15416
|
...github.fetchClosingPullRequests ? { fetchClosingPullRequests: github.fetchClosingPullRequests } : {},
|
|
14822
15417
|
...github.fetchIssueBody ? { fetchIssueBody: github.fetchIssueBody } : {},
|
|
15418
|
+
headRefName: pr.headRefName,
|
|
14823
15419
|
owner: repository.owner,
|
|
14824
15420
|
pr: args.pr,
|
|
14825
15421
|
repo: repository.name
|
|
@@ -14920,12 +15516,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
14920
15516
|
]);
|
|
14921
15517
|
let followUp;
|
|
14922
15518
|
if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
|
|
14923
|
-
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp =
|
|
14924
|
-
"gh",
|
|
14925
|
-
"pr",
|
|
14926
|
-
"ready",
|
|
14927
|
-
String(args.pr)
|
|
14928
|
-
]);
|
|
15519
|
+
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = prUndraftFollowUp(args.pr);
|
|
14929
15520
|
else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
|
|
14930
15521
|
const notices = [
|
|
14931
15522
|
...evaluation.notices,
|
|
@@ -15468,6 +16059,15 @@ const defaultPrPublishCliInvocation = () => {
|
|
|
15468
16059
|
};
|
|
15469
16060
|
const prPublishCliInvocation = (explicit) => explicit ?? defaultPrPublishCliInvocation();
|
|
15470
16061
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
16062
|
+
/**
|
|
16063
|
+
* GitHub creates a pull request against a branch name, while the rest of the
|
|
16064
|
+
* publish transaction treats `--base` as an exact local Git-ref assertion.
|
|
16065
|
+
* Keep those representations separate: only the creation adapter drops the
|
|
16066
|
+
* canonical remote qualifier, and callers downstream retain the original ref.
|
|
16067
|
+
*/
|
|
16068
|
+
function githubCreationBranchForBaseAssertion(base) {
|
|
16069
|
+
return base.startsWith("origin/") ? base.slice(7) : base;
|
|
16070
|
+
}
|
|
15471
16071
|
function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
|
|
15472
16072
|
if (args.pr !== void 0) return args.pr;
|
|
15473
16073
|
if (!branch) throw new Error("pr:publish cannot discover a PR from a detached HEAD; pass --pr.");
|
|
@@ -15483,12 +16083,14 @@ function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
|
|
|
15483
16083
|
];
|
|
15484
16084
|
const existing = JSON.parse(gh(lookupArgs, cwd));
|
|
15485
16085
|
if (existing[0]) return existing[0].number;
|
|
15486
|
-
|
|
16086
|
+
const createArgs = [
|
|
15487
16087
|
"pr",
|
|
15488
16088
|
"create",
|
|
15489
16089
|
"--draft",
|
|
15490
16090
|
"--fill"
|
|
15491
|
-
]
|
|
16091
|
+
];
|
|
16092
|
+
if (args.base.length > 0) createArgs.push("--base", githubCreationBranchForBaseAssertion(args.base));
|
|
16093
|
+
gh(createArgs, cwd);
|
|
15492
16094
|
const created = JSON.parse(gh(lookupArgs, cwd));
|
|
15493
16095
|
if (!created[0]) throw new Error(`gh pr create completed but no PR was found for branch ${branch}.`);
|
|
15494
16096
|
return created[0].number;
|
|
@@ -15724,6 +16326,50 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
|
|
|
15724
16326
|
...args,
|
|
15725
16327
|
throwWhenBlocked: false
|
|
15726
16328
|
}, dependencies);
|
|
16329
|
+
const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
|
|
16330
|
+
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.`);
|
|
16331
|
+
};
|
|
16332
|
+
/**
|
|
16333
|
+
* Promote a proved draft before the first Candidate demand evaluation (#649).
|
|
16334
|
+
* The route-owned action is shared with pr:ready; this helper only orders it.
|
|
16335
|
+
*/
|
|
16336
|
+
const promoteDraftBeforeDemandEvaluation = async ({ cwd, expectedHeadSha, factoryCliInvocation, fetchPr, pr, prSnapshot, runFollowUp }) => {
|
|
16337
|
+
if (!prSnapshot.isDraft) return { status: "not-requested" };
|
|
16338
|
+
const before = fetchPr();
|
|
16339
|
+
requirePromotionHead({
|
|
16340
|
+
actualHeadSha: before.headRefOid,
|
|
16341
|
+
expectedHeadSha,
|
|
16342
|
+
phase: "Promotion refused before transition"
|
|
16343
|
+
});
|
|
16344
|
+
if (!before.isDraft) return { status: "not-requested" };
|
|
16345
|
+
const promotion = prUndraftFollowUp(pr);
|
|
16346
|
+
try {
|
|
16347
|
+
await (runFollowUp ?? defaultFollowUpRunner(factoryCliInvocation))(promotion, cwd);
|
|
16348
|
+
} catch (error) {
|
|
16349
|
+
throw new PrPublishTransactionAbortedError(`Promotion failed (${promotion.command}): ${errorMessage(error)}`);
|
|
16350
|
+
}
|
|
16351
|
+
const after = fetchPr();
|
|
16352
|
+
requirePromotionHead({
|
|
16353
|
+
actualHeadSha: after.headRefOid,
|
|
16354
|
+
expectedHeadSha,
|
|
16355
|
+
phase: "Promotion invalidated during transition"
|
|
16356
|
+
});
|
|
16357
|
+
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.`);
|
|
16358
|
+
return {
|
|
16359
|
+
action: promotion,
|
|
16360
|
+
reason: "Promotion precedes Candidate demand evaluation on the unchanged head",
|
|
16361
|
+
status: "succeeded"
|
|
16362
|
+
};
|
|
16363
|
+
};
|
|
16364
|
+
const requirePromotedProofHead = ({ expectedHeadSha, promotion, proof }) => {
|
|
16365
|
+
if (promotion.status !== "succeeded") return;
|
|
16366
|
+
requirePromotionHead({
|
|
16367
|
+
actualHeadSha: proof.ledger.headSha,
|
|
16368
|
+
expectedHeadSha,
|
|
16369
|
+
phase: "Promotion invalidated before Candidate demand evaluation completed"
|
|
16370
|
+
});
|
|
16371
|
+
};
|
|
16372
|
+
const candidateFollowUpAfterPromotion = (promotion, proof) => promotion.status === "not-requested" ? proof.followUp : void 0;
|
|
15727
16373
|
/**
|
|
15728
16374
|
* A proof with no follow-up that is not `ready` means `pr:ready` found the
|
|
15729
16375
|
* candidate blocked solely on a pending hosted check for the family excluded
|
|
@@ -15858,6 +16504,11 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15858
16504
|
reviewProofPath
|
|
15859
16505
|
});
|
|
15860
16506
|
const pr = fetchPr();
|
|
16507
|
+
requirePromotionHead({
|
|
16508
|
+
actualHeadSha: pr.headRefOid,
|
|
16509
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16510
|
+
phase: "Publish refused before managed body update"
|
|
16511
|
+
});
|
|
15861
16512
|
const parts = renderPrBodySectionParts({
|
|
15862
16513
|
reviewProof,
|
|
15863
16514
|
verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
|
|
@@ -15882,13 +16533,28 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15882
16533
|
json: false,
|
|
15883
16534
|
report: false
|
|
15884
16535
|
};
|
|
16536
|
+
const promotion = await promoteDraftBeforeDemandEvaluation({
|
|
16537
|
+
cwd,
|
|
16538
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16539
|
+
factoryCliInvocation,
|
|
16540
|
+
fetchPr,
|
|
16541
|
+
pr: prNumber,
|
|
16542
|
+
prSnapshot: pr,
|
|
16543
|
+
runFollowUp: dependencies.runFollowUp
|
|
16544
|
+
});
|
|
15885
16545
|
const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
16546
|
+
requirePromotedProofHead({
|
|
16547
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16548
|
+
promotion,
|
|
16549
|
+
proof: initialProof
|
|
16550
|
+
});
|
|
15886
16551
|
const initialHandoff = summarizeHumanHandoff(initialProof);
|
|
15887
|
-
let followUp =
|
|
16552
|
+
let followUp = promotion;
|
|
15888
16553
|
let currentProof = initialProof;
|
|
15889
|
-
|
|
16554
|
+
const candidateFollowUp = candidateFollowUpAfterPromotion(promotion, initialProof);
|
|
16555
|
+
if (candidateFollowUp) {
|
|
15890
16556
|
followUp = await settleFollowUp({
|
|
15891
|
-
action:
|
|
16557
|
+
action: candidateFollowUp,
|
|
15892
16558
|
cwd,
|
|
15893
16559
|
factoryCliInvocation,
|
|
15894
16560
|
onFailure: (failed, error) => {
|
|
@@ -15903,13 +16569,13 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15903
16569
|
},
|
|
15904
16570
|
plan: planPublishFollowUp({
|
|
15905
16571
|
binding: verifyProofBinding,
|
|
15906
|
-
followUp:
|
|
16572
|
+
followUp: candidateFollowUp,
|
|
15907
16573
|
proofHeadSha: verifyProof.headSha,
|
|
15908
16574
|
verifiedHeadSha: verified.verifiedHeadSha
|
|
15909
16575
|
}),
|
|
15910
16576
|
runFollowUp: dependencies.runFollowUp
|
|
15911
16577
|
});
|
|
15912
|
-
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(
|
|
16578
|
+
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(candidateFollowUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
15913
16579
|
}
|
|
15914
16580
|
const proof = await awaitCurrentReadyVerdict({
|
|
15915
16581
|
args: readyEvalArgs,
|
|
@@ -15960,7 +16626,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
|
|
|
15960
16626
|
//#endregion
|
|
15961
16627
|
//#region src/commands/pr-publish.ts
|
|
15962
16628
|
function createPrPublishCommand(_output, action = runPrPublish) {
|
|
15963
|
-
return new Command("pr:publish").description("
|
|
16629
|
+
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>", "local base Git ref assertion; origin/<branch> creates against GitHub <branch>").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) => {
|
|
15964
16630
|
await action({
|
|
15965
16631
|
authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
|
|
15966
16632
|
base: options.base ?? "",
|
|
@@ -15974,12 +16640,12 @@ function createPrPublishCommand(_output, action = runPrPublish) {
|
|
|
15974
16640
|
reviewProof: options.reviewProof,
|
|
15975
16641
|
verifyProof: options.verifyProof
|
|
15976
16642
|
});
|
|
15977
|
-
});
|
|
16643
|
+
}));
|
|
15978
16644
|
}
|
|
15979
16645
|
//#endregion
|
|
15980
16646
|
//#region src/commands/pr-ready.ts
|
|
15981
16647
|
function createPrReadyCommand(output, action) {
|
|
15982
|
-
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",
|
|
16648
|
+
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({
|
|
15983
16649
|
gate: "pr:ready",
|
|
15984
16650
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
15985
16651
|
stderr: output.stderr
|
|
@@ -15997,7 +16663,7 @@ function createPrReadyCommand(output, action) {
|
|
|
15997
16663
|
verifyProof: options.verifyProof
|
|
15998
16664
|
};
|
|
15999
16665
|
await (action ? action(args) : runPrReady(args));
|
|
16000
|
-
}));
|
|
16666
|
+
})));
|
|
16001
16667
|
}
|
|
16002
16668
|
//#endregion
|
|
16003
16669
|
//#region src/gate-foreground-guard.ts
|
|
@@ -16026,7 +16692,7 @@ function assertForegroundGate({ gate, env = process.env }) {
|
|
|
16026
16692
|
//#endregion
|
|
16027
16693
|
//#region src/commands/pr-review.ts
|
|
16028
16694
|
function createPrReviewCommand(output, action) {
|
|
16029
|
-
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",
|
|
16695
|
+
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({
|
|
16030
16696
|
gate: "pr:review",
|
|
16031
16697
|
resolveCycle: (options) => options.cycle,
|
|
16032
16698
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
@@ -16051,12 +16717,12 @@ function createPrReviewCommand(output, action) {
|
|
|
16051
16717
|
verifyProof: options.verifyProof
|
|
16052
16718
|
};
|
|
16053
16719
|
await (action ? action(args) : runPrReview(args, { publishCheckRun: createFactoryCheckPublisher(output) }));
|
|
16054
|
-
}));
|
|
16720
|
+
})));
|
|
16055
16721
|
}
|
|
16056
16722
|
//#endregion
|
|
16057
16723
|
//#region src/commands/pr-verify.ts
|
|
16058
16724
|
function createPrVerifyCommand(output, action = runPrVerify) {
|
|
16059
|
-
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",
|
|
16725
|
+
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({
|
|
16060
16726
|
gate: "pr:verify",
|
|
16061
16727
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
16062
16728
|
stderr: output.stderr
|
|
@@ -16078,7 +16744,7 @@ function createPrVerifyCommand(output, action = runPrVerify) {
|
|
|
16078
16744
|
requireKnownAuthoringSession: true
|
|
16079
16745
|
}, dependencies);
|
|
16080
16746
|
if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
|
|
16081
|
-
}));
|
|
16747
|
+
})));
|
|
16082
16748
|
}
|
|
16083
16749
|
function modeFor(options) {
|
|
16084
16750
|
if (options.docsOnly) return "docs-only";
|
|
@@ -16086,6 +16752,146 @@ function modeFor(options) {
|
|
|
16086
16752
|
if (options.full) return "full";
|
|
16087
16753
|
return "auto";
|
|
16088
16754
|
}
|
|
16755
|
+
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
|
|
16756
|
+
const ZERO_SHA_PATTERN = /^0{40}$/u;
|
|
16757
|
+
const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
|
|
16758
|
+
basis: reason,
|
|
16759
|
+
name,
|
|
16760
|
+
state: "demanded"
|
|
16761
|
+
}));
|
|
16762
|
+
const refusal = ({ after, before, profile, reason }) => ({
|
|
16763
|
+
...after ? { afterSha: after } : {},
|
|
16764
|
+
basis: {
|
|
16765
|
+
changedFiles: [],
|
|
16766
|
+
classification: "refused",
|
|
16767
|
+
reasons: [reason]
|
|
16768
|
+
},
|
|
16769
|
+
...before ? { beforeSha: before } : {},
|
|
16770
|
+
decision: "refused",
|
|
16771
|
+
schemaVersion: 1,
|
|
16772
|
+
targets: demandedTargets(profile, reason),
|
|
16773
|
+
unsubscribedPaths: []
|
|
16774
|
+
});
|
|
16775
|
+
const assertCommitIdentity = (value, name) => {
|
|
16776
|
+
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`);
|
|
16777
|
+
return value.toLowerCase();
|
|
16778
|
+
};
|
|
16779
|
+
const assertReachableCommit = (cwd, sha, name) => {
|
|
16780
|
+
try {
|
|
16781
|
+
runCapture("git", [
|
|
16782
|
+
"cat-file",
|
|
16783
|
+
"-e",
|
|
16784
|
+
`${sha}^{commit}`
|
|
16785
|
+
], cwd);
|
|
16786
|
+
} catch {
|
|
16787
|
+
throw new Error(`${name} commit ${sha} is not reachable in this checkout`);
|
|
16788
|
+
}
|
|
16789
|
+
};
|
|
16790
|
+
const writeGithubOutputs = (outputPath, decision) => {
|
|
16791
|
+
appendFileSync(outputPath, `${[
|
|
16792
|
+
`decision=${decision.decision}`,
|
|
16793
|
+
`basis=${JSON.stringify(decision.basis)}`,
|
|
16794
|
+
`unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
|
|
16795
|
+
...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
|
|
16796
|
+
].join("\n")}\n`, "utf-8");
|
|
16797
|
+
};
|
|
16798
|
+
/**
|
|
16799
|
+
* Recompute impact from one merge push's exact before/after commits. The
|
|
16800
|
+
* result may withdraw consumer work only; every doubt path returns a refused
|
|
16801
|
+
* decision whose known targets remain demanded.
|
|
16802
|
+
*/
|
|
16803
|
+
const runProductionImpact = (args) => {
|
|
16804
|
+
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
16805
|
+
let profile;
|
|
16806
|
+
let profilePath;
|
|
16807
|
+
let decision;
|
|
16808
|
+
try {
|
|
16809
|
+
const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
|
|
16810
|
+
cwd,
|
|
16811
|
+
profilePath: args.profilePath
|
|
16812
|
+
});
|
|
16813
|
+
profile = loadedProfile;
|
|
16814
|
+
profilePath = loadedPath;
|
|
16815
|
+
const before = assertCommitIdentity(args.before, "before");
|
|
16816
|
+
const after = assertCommitIdentity(args.after, "after");
|
|
16817
|
+
assertReachableCommit(cwd, before, "before");
|
|
16818
|
+
assertReachableCommit(cwd, after, "after");
|
|
16819
|
+
const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
|
|
16820
|
+
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`);
|
|
16821
|
+
try {
|
|
16822
|
+
runCapture("git", [
|
|
16823
|
+
"merge-base",
|
|
16824
|
+
"--is-ancestor",
|
|
16825
|
+
before,
|
|
16826
|
+
after
|
|
16827
|
+
], cwd);
|
|
16828
|
+
} catch {
|
|
16829
|
+
throw new Error(`before commit ${before} is not an ancestor of after commit ${after}`);
|
|
16830
|
+
}
|
|
16831
|
+
const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
|
|
16832
|
+
"diff",
|
|
16833
|
+
"--find-renames",
|
|
16834
|
+
"--name-status",
|
|
16835
|
+
before,
|
|
16836
|
+
after
|
|
16837
|
+
], cwd).stdout))].toSorted();
|
|
16838
|
+
const stamp = computeImpactStamp({
|
|
16839
|
+
changedFiles,
|
|
16840
|
+
profile,
|
|
16841
|
+
profilePath: path.relative(cwd, profilePath),
|
|
16842
|
+
readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, path.join(cwd, LOCKFILE_PATH))
|
|
16843
|
+
});
|
|
16844
|
+
const usable = stamp.basis === "target-scoped";
|
|
16845
|
+
decision = {
|
|
16846
|
+
afterSha: after,
|
|
16847
|
+
basis: {
|
|
16848
|
+
changedFiles,
|
|
16849
|
+
classification: stamp.basis,
|
|
16850
|
+
reasons: stamp.reasons
|
|
16851
|
+
},
|
|
16852
|
+
beforeSha: before,
|
|
16853
|
+
decision: usable ? "usable" : "refused",
|
|
16854
|
+
schemaVersion: 1,
|
|
16855
|
+
targets: stamp.targets.map((target) => ({
|
|
16856
|
+
basis: target.basis,
|
|
16857
|
+
name: target.name,
|
|
16858
|
+
state: usable && target.impact === "not-affected" ? "withdrawn" : "demanded"
|
|
16859
|
+
})),
|
|
16860
|
+
unsubscribedPaths: stamp.unsubscribedPaths
|
|
16861
|
+
};
|
|
16862
|
+
} catch (error) {
|
|
16863
|
+
decision = refusal({
|
|
16864
|
+
after: args.after,
|
|
16865
|
+
before: args.before,
|
|
16866
|
+
profile,
|
|
16867
|
+
reason: `production impact classification refused: ${error instanceof Error ? error.message : String(error)}`
|
|
16868
|
+
});
|
|
16869
|
+
}
|
|
16870
|
+
if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
|
|
16871
|
+
if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
|
|
16872
|
+
return decision;
|
|
16873
|
+
};
|
|
16874
|
+
const productionImpactSummaryLines = (decision) => decision.basis.classification === "refused" ? [
|
|
16875
|
+
"Production impact: refused; all consumer work remains demanded.",
|
|
16876
|
+
...decision.basis.reasons,
|
|
16877
|
+
"Unsubscribed changed paths: unavailable because classification was refused."
|
|
16878
|
+
] : [`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(", ")}`];
|
|
16879
|
+
//#endregion
|
|
16880
|
+
//#region src/commands/production-impact.ts
|
|
16881
|
+
function createProductionImpactCommand(output) {
|
|
16882
|
+
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) => {
|
|
16883
|
+
const decision = runProductionImpact({
|
|
16884
|
+
after: options.after,
|
|
16885
|
+
before: options.before,
|
|
16886
|
+
cwd: resolveCwdOption(options.cwd),
|
|
16887
|
+
githubOutput: options.githubOutput,
|
|
16888
|
+
githubSummary: options.githubSummary,
|
|
16889
|
+
profilePath: options.profile
|
|
16890
|
+
});
|
|
16891
|
+
if (options.json) output.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
|
|
16892
|
+
else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
|
|
16893
|
+
}));
|
|
16894
|
+
}
|
|
16089
16895
|
//#endregion
|
|
16090
16896
|
//#region src/worktree-scratch-files.ts
|
|
16091
16897
|
var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -16373,9 +17179,11 @@ function createProgram(options = {}) {
|
|
|
16373
17179
|
program.addCommand(createDoctorCommand(output));
|
|
16374
17180
|
program.addCommand(createConfigCommand(output));
|
|
16375
17181
|
program.addCommand(createEvidenceEmitCommand(output));
|
|
17182
|
+
program.addCommand(createCiAnalyzeCommand(output));
|
|
16376
17183
|
program.addCommand(createHqFlushCommand(output));
|
|
16377
17184
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
16378
17185
|
program.addCommand(createPrVerifyCommand(output));
|
|
17186
|
+
program.addCommand(createProductionImpactCommand(output));
|
|
16379
17187
|
program.addCommand(createCloseoutCommand(output));
|
|
16380
17188
|
program.addCommand(createEpicPublishStructureCommand(output));
|
|
16381
17189
|
program.addCommand(createPrReviewCommand(output, options.actions?.prReview));
|