@patronage/software-factory 1.0.0-alpha.1 → 1.0.0-alpha.13
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 +51 -53
- package/dist/index.js +1302 -522
- package/dist/schemas.d.ts +4 -2
- package/dist/schemas.js +5 -4
- 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.13";
|
|
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,70 @@ 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 PRs and unlinked manifest-fallback PRs; branch
|
|
272
|
+
* names classify those authoritative members' roles but never discover
|
|
273
|
+
* membership.
|
|
274
|
+
*/
|
|
275
|
+
const boundaryTopologyReasons = ({ manifest, pullRequests }) => {
|
|
276
|
+
const integrationBranch = eachToEpicBranch(manifest);
|
|
277
|
+
if (!integrationBranch) return [];
|
|
278
|
+
const reasons = [];
|
|
279
|
+
const byNumber = /* @__PURE__ */ new Map();
|
|
280
|
+
for (const pullRequest of pullRequests) {
|
|
281
|
+
const previous = byNumber.get(pullRequest.number);
|
|
282
|
+
if (previous && (previous.baseRefName !== pullRequest.baseRefName || previous.headRefName !== pullRequest.headRefName)) {
|
|
283
|
+
reasons.push(`PR #${pullRequest.number} has contradictory live branch data across its declared memberships.`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
byNumber.set(pullRequest.number, pullRequest);
|
|
287
|
+
}
|
|
288
|
+
const terminalPullRequests = [];
|
|
289
|
+
for (const pullRequest of byNumber.values()) {
|
|
290
|
+
if (!pullRequest.headRefName) {
|
|
291
|
+
reasons.push(`PR #${pullRequest.number} has no live head branch name; each-to-epic closeout fails closed until GitHub reports headRefName.`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (pullRequest.state === "CLOSED") {
|
|
295
|
+
reasons.push(`PR #${pullRequest.number} is closed without merge and cannot satisfy the each-to-epic branch graph.`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (pullRequest.baseRefName === integrationBranch) {
|
|
299
|
+
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.`);
|
|
300
|
+
if (pullRequest.headRefName === integrationBranch) reasons.push(`PR #${pullRequest.number} cannot use ${integrationBranch} as both head and base.`);
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (pullRequest.baseRefName === "main") {
|
|
304
|
+
if (pullRequest.headRefName !== integrationBranch) {
|
|
305
|
+
reasons.push(`PR #${pullRequest.number} targets main from ${pullRequest.headRefName ?? "(unknown)"}; the each-to-epic terminal must originate from declared integrationBranch ${integrationBranch}.`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
terminalPullRequests.push(pullRequest.number);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
reasons.push(`PR #${pullRequest.number} targets ${pullRequest.baseRefName ?? "(unknown)"}; every each-to-epic interior member must target declared integrationBranch ${integrationBranch}.`);
|
|
312
|
+
}
|
|
313
|
+
if (terminalPullRequests.length !== 1) reasons.push(`each-to-epic requires exactly one terminal member PR from ${integrationBranch} to main; found ${terminalPullRequests.length}.`);
|
|
314
|
+
return reasons;
|
|
315
|
+
};
|
|
316
|
+
//#endregion
|
|
236
317
|
//#region src/comment-provenance.ts
|
|
237
318
|
var comment_provenance_exports = /* @__PURE__ */ __exportAll({
|
|
238
319
|
SHA_MATCH_MIN_LENGTH: () => 7,
|
|
@@ -981,7 +1062,7 @@ const requiredCheckSchema = z.object({
|
|
|
981
1062
|
const impactTargetSchema = z.object({
|
|
982
1063
|
importers: z.array(z.string().min(1)).min(1),
|
|
983
1064
|
name: z.string().min(1),
|
|
984
|
-
paths: z.array(repoRelativeGlobSchema).min(1).optional()
|
|
1065
|
+
paths: z.array(repoRelativeGlobSchema).min(1).optional().describe("Opt-in repo-relative path subscriptions. A target is affected only when a valid changed path matches one of its globs; valid unmatched paths affect no target and are recorded as unsubscribed. Shared files must be listed by every subscribing target.")
|
|
985
1066
|
}).strict();
|
|
986
1067
|
const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
|
|
987
1068
|
const names = targets.map((target) => target.name);
|
|
@@ -1082,7 +1163,7 @@ const factoryProjectProfileSchema = z.object({
|
|
|
1082
1163
|
mappedChecks.add(command.requiredCheck);
|
|
1083
1164
|
}
|
|
1084
1165
|
}).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
|
|
1085
|
-
const isRecord$
|
|
1166
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1086
1167
|
const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1087
1168
|
const quoteList = (values) => JSON.stringify(values);
|
|
1088
1169
|
const namesExistingFile = (root, entry) => {
|
|
@@ -1131,13 +1212,13 @@ const migratedPolicyLines = (policy, root) => {
|
|
|
1131
1212
|
*/
|
|
1132
1213
|
function v2MigrationLines(input, root) {
|
|
1133
1214
|
const lines = [];
|
|
1134
|
-
const proof = isRecord$
|
|
1135
|
-
if (proof && isRecord$
|
|
1136
|
-
const commands = isRecord$
|
|
1215
|
+
const proof = isRecord$2(input.proof) ? input.proof : void 0;
|
|
1216
|
+
if (proof && isRecord$2(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
|
|
1217
|
+
const commands = isRecord$2(input.verification) ? input.verification.commands : void 0;
|
|
1137
1218
|
if (Array.isArray(commands)) {
|
|
1138
|
-
for (const [index, command] of commands.entries()) if (isRecord$
|
|
1219
|
+
for (const [index, command] of commands.entries()) if (isRecord$2(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
|
|
1139
1220
|
}
|
|
1140
|
-
if (isRecord$
|
|
1221
|
+
if (isRecord$2(input.hq) && typeof input.hq.endpoint === "string") try {
|
|
1141
1222
|
const url = new URL(input.hq.endpoint);
|
|
1142
1223
|
if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
|
|
1143
1224
|
} catch {}
|
|
@@ -1149,25 +1230,25 @@ function hasCorrectnessOnlyReview(review) {
|
|
|
1149
1230
|
}
|
|
1150
1231
|
const v4MigrationLines = (input) => {
|
|
1151
1232
|
const lines = [];
|
|
1152
|
-
if (isRecord$
|
|
1153
|
-
if (isRecord$
|
|
1154
|
-
if (isRecord$
|
|
1155
|
-
if (isRecord$
|
|
1233
|
+
if (isRecord$2(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
|
|
1234
|
+
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");
|
|
1235
|
+
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");
|
|
1236
|
+
if (isRecord$2(input.review)) {
|
|
1156
1237
|
const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
|
|
1157
1238
|
if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
|
|
1158
1239
|
if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
|
|
1159
1240
|
if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
|
|
1160
1241
|
if (Array.isArray(input.review.conditional)) {
|
|
1161
|
-
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$
|
|
1242
|
+
for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$2(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
|
|
1162
1243
|
}
|
|
1163
1244
|
}
|
|
1164
1245
|
if (Array.isArray(input.requiredChecks)) {
|
|
1165
|
-
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$
|
|
1246
|
+
for (const [index, check] of input.requiredChecks.entries()) if (isRecord$2(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
|
|
1166
1247
|
}
|
|
1167
1248
|
return lines;
|
|
1168
1249
|
};
|
|
1169
1250
|
function legacyProfileMigration(input, root) {
|
|
1170
|
-
if (!(isRecord$
|
|
1251
|
+
if (!(isRecord$2(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
|
|
1171
1252
|
const foundVersion = input.schemaVersion;
|
|
1172
1253
|
const lines = [`schemaVersion ${foundVersion} -> 5`];
|
|
1173
1254
|
if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
|
|
@@ -1347,7 +1428,6 @@ function formatUserConfigError(configPath, error) {
|
|
|
1347
1428
|
}
|
|
1348
1429
|
const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
|
|
1349
1430
|
const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
|
|
1350
|
-
const HQ_RETRY_JOURNAL_BASENAME = "hq-retry-journal.jsonl";
|
|
1351
1431
|
/**
|
|
1352
1432
|
* Appended to a spooled event that can never be delivered (#445).
|
|
1353
1433
|
*
|
|
@@ -1388,9 +1468,6 @@ const SPOOL_REPOSITORY_MARKER = ".repository-identity";
|
|
|
1388
1468
|
* and the extra level is slack for a key scheme that nests one deeper.
|
|
1389
1469
|
*/
|
|
1390
1470
|
const HQ_SPOOL_SWEEP_MAX_DEPTH = 4;
|
|
1391
|
-
`${HQ_RETRY_JOURNAL_BASENAME}`;
|
|
1392
|
-
`${HQ_RETRY_SPOOL_DIRNAME}`;
|
|
1393
|
-
const LEGACY_FACTORY_MEMORY_DIRNAME = ".factory-memory";
|
|
1394
1471
|
const HQ_SPOOL_STATE_SEGMENTS = ["patronage-factory", "hq-spool"];
|
|
1395
1472
|
const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
|
|
1396
1473
|
const MAX_HQ_INGEST_PAYLOAD_BYTES = 256 * 1024;
|
|
@@ -1502,12 +1579,6 @@ const repositorySpoolLayout = (repository, env = process.env) => ({
|
|
|
1502
1579
|
HQ_RETRY_SPOOL_DIRNAME
|
|
1503
1580
|
]
|
|
1504
1581
|
});
|
|
1505
|
-
/** Read-only drain source for spools written before the relocation. */
|
|
1506
|
-
const legacySpoolLayout = (cwd) => ({
|
|
1507
|
-
create: false,
|
|
1508
|
-
root: cwd,
|
|
1509
|
-
segments: [LEGACY_FACTORY_MEMORY_DIRNAME, HQ_RETRY_SPOOL_DIRNAME]
|
|
1510
|
-
});
|
|
1511
1582
|
/**
|
|
1512
1583
|
* An operator-named directory (`hq:flush --dir`). Accepts either the spool
|
|
1513
1584
|
* itself or the directory holding it, so a `.factory-memory` path and a
|
|
@@ -1579,30 +1650,10 @@ const secureSpoolLayout = async (layout, deadline) => {
|
|
|
1579
1650
|
const memoryIdentity = identities.at(-2);
|
|
1580
1651
|
if (spool === void 0 || memory === void 0 || spoolIdentity === void 0 || memoryIdentity === void 0 || !await directoryIdentityMatches(memory, memoryIdentity, deadline) || !await directoryIdentityMatches(spool, spoolIdentity, deadline)) return;
|
|
1581
1652
|
return {
|
|
1582
|
-
memory,
|
|
1583
|
-
memoryIdentity,
|
|
1584
1653
|
spool,
|
|
1585
1654
|
spoolIdentity
|
|
1586
1655
|
};
|
|
1587
1656
|
};
|
|
1588
|
-
/**
|
|
1589
|
-
* The legacy JSONL journal lives one level above the spool. Securing it on its
|
|
1590
|
-
* own lets a pre-spool `.factory-memory` (journal but no spool directory)
|
|
1591
|
-
* drain without the read path creating anything inside the worktree.
|
|
1592
|
-
*/
|
|
1593
|
-
const secureJournalDirectory = async (layout, deadline) => {
|
|
1594
|
-
const segments = layout.segments.slice(0, -1);
|
|
1595
|
-
if (segments.length === 0) return;
|
|
1596
|
-
const chain = await secureChain(layout, segments, deadline);
|
|
1597
|
-
if (chain === void 0) return;
|
|
1598
|
-
const directory = chain.directories.at(-1);
|
|
1599
|
-
const identity = chain.identities.at(-1);
|
|
1600
|
-
if (directory === void 0 || identity === void 0 || !await directoryIdentityMatches(directory, identity, deadline)) return;
|
|
1601
|
-
return {
|
|
1602
|
-
directory,
|
|
1603
|
-
identity
|
|
1604
|
-
};
|
|
1605
|
-
};
|
|
1606
1657
|
const readDirectoryIdentity = async (directory, deadline) => {
|
|
1607
1658
|
const opened = await openWithin(directory, constants.O_RDONLY + constants.O_DIRECTORY + constants.O_NOFOLLOW, remainingMs(deadline));
|
|
1608
1659
|
if (opened.status !== "fulfilled") return;
|
|
@@ -2332,225 +2383,6 @@ const replayCloseoutSpool = async (layout, endpoint, clientId, clientSecret, req
|
|
|
2332
2383
|
if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, () => outcome.ok ? unlink(claimPath) : rename(claimPath, eventPath))) return;
|
|
2333
2384
|
}
|
|
2334
2385
|
};
|
|
2335
|
-
const writeLegacyCursor = async (directory, directoryIdentity, cursorPath, offset, deadline) => {
|
|
2336
|
-
if (path.dirname(cursorPath) !== directory || !await directoryIdentityMatches(directory, directoryIdentity, deadline)) return false;
|
|
2337
|
-
const temporary = `${cursorPath}.${randomUUID()}.tmp`;
|
|
2338
|
-
const opened = await openWithin(temporary, constants.O_CREAT + constants.O_EXCL + constants.O_WRONLY + constants.O_NOFOLLOW, remainingMs(deadline), 384);
|
|
2339
|
-
if (opened.status !== "fulfilled") return false;
|
|
2340
|
-
const wrote = await settleWithin((async () => {
|
|
2341
|
-
await opened.value.writeFile(String(offset), "utf-8");
|
|
2342
|
-
await opened.value.sync();
|
|
2343
|
-
})(), remainingMs(deadline));
|
|
2344
|
-
const closed = await settleWithin(opened.value.close(), remainingMs(deadline));
|
|
2345
|
-
if (wrote.status !== "fulfilled" || closed.status !== "fulfilled") {
|
|
2346
|
-
await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(temporary));
|
|
2347
|
-
return false;
|
|
2348
|
-
}
|
|
2349
|
-
if (!await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(temporary, cursorPath))) {
|
|
2350
|
-
await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => unlink(temporary));
|
|
2351
|
-
return false;
|
|
2352
|
-
}
|
|
2353
|
-
return await syncBoundDirectory(directory, directoryIdentity, deadline);
|
|
2354
|
-
};
|
|
2355
|
-
const readLegacyCursor = async (cursorPath, deadline) => {
|
|
2356
|
-
const file = await readBoundedTextFileNoFollow(cursorPath, deadline, 128);
|
|
2357
|
-
if (file === void 0) return 0;
|
|
2358
|
-
const offset = Number(file.contents);
|
|
2359
|
-
return Number.isSafeInteger(offset) && offset >= 0 ? offset : 0;
|
|
2360
|
-
};
|
|
2361
|
-
const claimLegacySource = async (sourcePath, target, directory, directoryIdentity, deadline) => {
|
|
2362
|
-
if (path.dirname(sourcePath) !== directory || !await directoryIdentityMatches(directory, directoryIdentity, deadline)) return;
|
|
2363
|
-
const metadata = await settleWithin(lstat(sourcePath), remainingMs(deadline));
|
|
2364
|
-
if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.isSymbolicLink()) return;
|
|
2365
|
-
const claimPath = `${target}.legacy-claim-${randomUUID()}`;
|
|
2366
|
-
if (!await mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(sourcePath, claimPath))) return;
|
|
2367
|
-
return claimPath;
|
|
2368
|
-
};
|
|
2369
|
-
const releaseLegacyClaim = async (claimPath, target, directory, directoryIdentity, deadline) => {
|
|
2370
|
-
if (path.dirname(claimPath) !== directory) return false;
|
|
2371
|
-
const readyPath = `${target}.legacy-ready-${randomUUID()}`;
|
|
2372
|
-
return mutateBoundDirectory(directory, directoryIdentity, deadline, async () => rename(claimPath, readyPath));
|
|
2373
|
-
};
|
|
2374
|
-
async function replayRetryJournal(layout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, setupDeadline, replayDeadline, options = {}) {
|
|
2375
|
-
const maxEntries = options.maxEntries ?? MAX_HQ_REPLAY_ENTRIES;
|
|
2376
|
-
const { report } = options;
|
|
2377
|
-
const secured = await settleWithin(secureJournalDirectory(layout, setupDeadline), remainingMs(setupDeadline));
|
|
2378
|
-
if (secured.status !== "fulfilled" || secured.value === void 0) return;
|
|
2379
|
-
const { directory, identity: directoryIdentity } = secured.value;
|
|
2380
|
-
const target = path.join(directory, HQ_RETRY_JOURNAL_BASENAME);
|
|
2381
|
-
const baseName = path.basename(target);
|
|
2382
|
-
const listing = await settleWithin(readdir(directory), remainingMs(setupDeadline));
|
|
2383
|
-
if (listing.status !== "fulfilled") return;
|
|
2384
|
-
let delivered = 0;
|
|
2385
|
-
let corruptDropped = 0;
|
|
2386
|
-
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();
|
|
2387
|
-
for (const name of sources) {
|
|
2388
|
-
if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0) break;
|
|
2389
|
-
const sourcePath = path.join(directory, name);
|
|
2390
|
-
if (name.startsWith(`${baseName}.draining-`) || name.startsWith(`${baseName}.legacy-claim-`)) {
|
|
2391
|
-
const metadata = await settleWithin(lstat(sourcePath), remainingMs(setupDeadline));
|
|
2392
|
-
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;
|
|
2393
|
-
}
|
|
2394
|
-
const claimPath = await claimLegacySource(sourcePath, target, directory, directoryIdentity, setupDeadline);
|
|
2395
|
-
if (claimPath === void 0) continue;
|
|
2396
|
-
const opened = await openWithin(claimPath, constants.O_RDONLY + constants.O_NOFOLLOW, remainingMs(setupDeadline));
|
|
2397
|
-
if (opened.status !== "fulfilled") {
|
|
2398
|
-
await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
|
|
2399
|
-
continue;
|
|
2400
|
-
}
|
|
2401
|
-
const sourceMetadata = await settleWithin(opened.value.stat(), remainingMs(setupDeadline));
|
|
2402
|
-
if (sourceMetadata.status !== "fulfilled" || !sourceMetadata.value.isFile()) {
|
|
2403
|
-
await settleWithin(opened.value.close(), remainingMs(setupDeadline));
|
|
2404
|
-
await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
|
|
2405
|
-
continue;
|
|
2406
|
-
}
|
|
2407
|
-
const cursorPath = `${target}.legacy-cursor-${String(sourceMetadata.value.dev)}-${String(sourceMetadata.value.ino)}`;
|
|
2408
|
-
let offset = await readLegacyCursor(cursorPath, setupDeadline);
|
|
2409
|
-
if (offset >= sourceMetadata.value.size) {
|
|
2410
|
-
await settleWithin(opened.value.close(), remainingMs(setupDeadline));
|
|
2411
|
-
const current = await settleWithin(lstat(claimPath), remainingMs(setupDeadline));
|
|
2412
|
-
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) {
|
|
2413
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(claimPath));
|
|
2414
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(cursorPath));
|
|
2415
|
-
}
|
|
2416
|
-
continue;
|
|
2417
|
-
}
|
|
2418
|
-
const abort = new AbortController();
|
|
2419
|
-
const streamBudget = Math.min(remainingMs(setupDeadline), remainingMs(replayDeadline));
|
|
2420
|
-
const abortTimer = setTimeout(() => abort.abort(), Math.max(0, streamBudget));
|
|
2421
|
-
abortTimer.unref();
|
|
2422
|
-
const stream = createReadStream(claimPath, {
|
|
2423
|
-
autoClose: false,
|
|
2424
|
-
encoding: "utf-8",
|
|
2425
|
-
fd: opened.value.fd,
|
|
2426
|
-
signal: abort.signal,
|
|
2427
|
-
start: offset
|
|
2428
|
-
});
|
|
2429
|
-
const lines = createInterface({
|
|
2430
|
-
crlfDelay: Infinity,
|
|
2431
|
-
input: stream
|
|
2432
|
-
});
|
|
2433
|
-
let reachedEof = true;
|
|
2434
|
-
try {
|
|
2435
|
-
for await (const line of lines) {
|
|
2436
|
-
if (delivered >= maxEntries || remainingMs(replayDeadline) <= 0 || remainingMs(setupDeadline) <= 0) {
|
|
2437
|
-
reachedEof = false;
|
|
2438
|
-
break;
|
|
2439
|
-
}
|
|
2440
|
-
const nextOffset = Math.min(sourceMetadata.value.size, offset + Buffer.byteLength(line, "utf-8") + 1);
|
|
2441
|
-
if (line.trim().length === 0) {
|
|
2442
|
-
offset = nextOffset;
|
|
2443
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2444
|
-
reachedEof = false;
|
|
2445
|
-
break;
|
|
2446
|
-
}
|
|
2447
|
-
continue;
|
|
2448
|
-
}
|
|
2449
|
-
let parsed;
|
|
2450
|
-
try {
|
|
2451
|
-
parsed = JSON.parse(line);
|
|
2452
|
-
} catch {
|
|
2453
|
-
corruptDropped += 1;
|
|
2454
|
-
offset = nextOffset;
|
|
2455
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2456
|
-
reachedEof = false;
|
|
2457
|
-
break;
|
|
2458
|
-
}
|
|
2459
|
-
continue;
|
|
2460
|
-
}
|
|
2461
|
-
if (!isReplayableEntry(parsed)) {
|
|
2462
|
-
offset = nextOffset;
|
|
2463
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2464
|
-
reachedEof = false;
|
|
2465
|
-
break;
|
|
2466
|
-
}
|
|
2467
|
-
continue;
|
|
2468
|
-
}
|
|
2469
|
-
const entryEndpoint = validatedEndpoint(parsed.endpoint);
|
|
2470
|
-
let handled = false;
|
|
2471
|
-
const row = {
|
|
2472
|
-
eventId: parsed.event.eventId,
|
|
2473
|
-
kind: parsed.event.kind
|
|
2474
|
-
};
|
|
2475
|
-
let rowOutcome = {
|
|
2476
|
-
detail: `recorded endpoint ${entryEndpoint?.origin ?? "(unusable)"} is not this repository's authorized HQ origin ${endpoint.origin}`,
|
|
2477
|
-
...row,
|
|
2478
|
-
spool: directory,
|
|
2479
|
-
status: "migrated"
|
|
2480
|
-
};
|
|
2481
|
-
if (entryEndpoint?.origin === endpoint.origin) {
|
|
2482
|
-
rowOutcome = {
|
|
2483
|
-
...rowOutcome,
|
|
2484
|
-
detail: "event could not be serialized for delivery"
|
|
2485
|
-
};
|
|
2486
|
-
let body;
|
|
2487
|
-
try {
|
|
2488
|
-
body = JSON.stringify(parsed.event);
|
|
2489
|
-
} catch {
|
|
2490
|
-
body = void 0;
|
|
2491
|
-
}
|
|
2492
|
-
if (body !== void 0) {
|
|
2493
|
-
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, Math.min(transportBudgetMs, remainingMs(replayDeadline)), report !== void 0);
|
|
2494
|
-
handled = outcome.ok;
|
|
2495
|
-
if (handled) delivered += 1;
|
|
2496
|
-
rowOutcome = outcome.ok ? {
|
|
2497
|
-
...row,
|
|
2498
|
-
spool: directory,
|
|
2499
|
-
status: outcome.duplicate === true ? "duplicate" : "delivered"
|
|
2500
|
-
} : {
|
|
2501
|
-
...row,
|
|
2502
|
-
detail: outcome.detail === void 0 ? outcome.reason : `${outcome.reason}: ${outcome.detail}`,
|
|
2503
|
-
spool: directory,
|
|
2504
|
-
status: drainFailureStatus(outcome.reason)
|
|
2505
|
-
};
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
if (!handled) {
|
|
2509
|
-
const retained = await appendCloseoutSpool(writeLayout, parsed, performance.now() + journalFlushBudgetFor(dependencies));
|
|
2510
|
-
if (retained === void 0) options.onMigrate?.();
|
|
2511
|
-
if (retained !== void 0) {
|
|
2512
|
-
report?.({
|
|
2513
|
-
...rowOutcome,
|
|
2514
|
-
detail: `${rowOutcome.detail ?? rowOutcome.status}; migration into the current spool ${retained}`,
|
|
2515
|
-
status: "unreachable"
|
|
2516
|
-
});
|
|
2517
|
-
reachedEof = false;
|
|
2518
|
-
break;
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
|
-
report?.(rowOutcome);
|
|
2522
|
-
offset = nextOffset;
|
|
2523
|
-
if (!await writeLegacyCursor(directory, directoryIdentity, cursorPath, offset, setupDeadline)) {
|
|
2524
|
-
reachedEof = false;
|
|
2525
|
-
break;
|
|
2526
|
-
}
|
|
2527
|
-
}
|
|
2528
|
-
} catch {
|
|
2529
|
-
reachedEof = false;
|
|
2530
|
-
} finally {
|
|
2531
|
-
clearTimeout(abortTimer);
|
|
2532
|
-
abort.abort();
|
|
2533
|
-
lines.close();
|
|
2534
|
-
stream.destroy();
|
|
2535
|
-
await settleWithin(opened.value.close(), remainingMs(setupDeadline));
|
|
2536
|
-
}
|
|
2537
|
-
if (reachedEof && offset >= sourceMetadata.value.size) {
|
|
2538
|
-
const current = await settleWithin(lstat(claimPath), remainingMs(setupDeadline));
|
|
2539
|
-
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)) {
|
|
2540
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(claimPath));
|
|
2541
|
-
await mutateBoundDirectory(directory, directoryIdentity, setupDeadline, async () => unlink(cursorPath));
|
|
2542
|
-
}
|
|
2543
|
-
} else await releaseLegacyClaim(claimPath, target, directory, directoryIdentity, setupDeadline);
|
|
2544
|
-
}
|
|
2545
|
-
await reportReplayOutcome(dependencies, delivered, corruptDropped);
|
|
2546
|
-
}
|
|
2547
|
-
async function reportReplayOutcome(dependencies, delivered, corruptDropped) {
|
|
2548
|
-
if (delivered <= 0 && corruptDropped <= 0) return;
|
|
2549
|
-
const parts = [];
|
|
2550
|
-
if (delivered > 0) parts.push(`${delivered} queued event(s) delivered from the retry journal`);
|
|
2551
|
-
if (corruptDropped > 0) parts.push(`${corruptDropped} unparseable journal line(s) dropped (partial write recovered, #120)`);
|
|
2552
|
-
await reportDiagnostic(dependencies, performance.now() + journalFlushBudgetFor(dependencies), parts.join("; "), delivered > 0 ? "HQ ingest confirmed" : "HQ ingest recovered");
|
|
2553
|
-
}
|
|
2554
2386
|
async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, transportBudgetMs) {
|
|
2555
2387
|
let config;
|
|
2556
2388
|
try {
|
|
@@ -2594,7 +2426,6 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2594
2426
|
owner: input.profile.repository.owner,
|
|
2595
2427
|
repo: input.profile.repository.name
|
|
2596
2428
|
}, env);
|
|
2597
|
-
const readLayouts = [writeLayout, legacySpoolLayout(input.cwd)];
|
|
2598
2429
|
const now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
2599
2430
|
const makeEventId = dependencies.randomUUID ?? randomUUID;
|
|
2600
2431
|
let event;
|
|
@@ -2665,10 +2496,7 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
|
|
|
2665
2496
|
throw new Error(reason);
|
|
2666
2497
|
}
|
|
2667
2498
|
const replayDeadline = performance.now() + timeoutMs;
|
|
2668
|
-
|
|
2669
|
-
await replayCloseoutSpool(readLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
|
|
2670
|
-
await replayRetryJournal(readLayout, writeLayout, endpoint, clientId, clientSecret, request, dependencies, transportBudgetMs, replayDeadline, replayDeadline);
|
|
2671
|
-
}
|
|
2499
|
+
await replayCloseoutSpool(writeLayout, endpoint, clientId, clientSecret, request, transportBudgetMs, replayDeadline, replayDeadline, journalFlushBudgetFor(dependencies), persistedEventPath);
|
|
2672
2500
|
const outcome = await attemptTransport(request, endpoint, clientId, clientSecret, body, transportBudgetMs);
|
|
2673
2501
|
if (!outcome.ok) {
|
|
2674
2502
|
({reason, unconfirmed} = outcome);
|
|
@@ -2747,20 +2575,7 @@ async function awaitPendingHqIngest() {
|
|
|
2747
2575
|
const DEFAULT_HQ_FLUSH_BUDGET_MS = 600 * 1e3;
|
|
2748
2576
|
/** ENOENT is the only filesystem answer that means "this location is absent". */
|
|
2749
2577
|
const isMissingEntryError = (error) => typeof error === "object" && error !== null && error.code === "ENOENT";
|
|
2750
|
-
const
|
|
2751
|
-
const journalLineTimestamps = (contents) => {
|
|
2752
|
-
const timestamps = [];
|
|
2753
|
-
for (const line of contents.split("\n")) {
|
|
2754
|
-
const trimmed = line.trim();
|
|
2755
|
-
if (trimmed === "") continue;
|
|
2756
|
-
try {
|
|
2757
|
-
const parsed = JSON.parse(trimmed);
|
|
2758
|
-
if (typeof parsed.failedAt === "string") timestamps.push(parsed.failedAt);
|
|
2759
|
-
} catch {}
|
|
2760
|
-
}
|
|
2761
|
-
return timestamps;
|
|
2762
|
-
};
|
|
2763
|
-
const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
|
|
2578
|
+
const countRemainingSpoolWork = async (spools, deadline) => {
|
|
2764
2579
|
let remaining = 0;
|
|
2765
2580
|
let unlistableScans = 0;
|
|
2766
2581
|
let oldestMs;
|
|
@@ -2789,27 +2604,6 @@ const countRemainingSpoolWork = async (spools, drainLayouts, deadline) => {
|
|
|
2789
2604
|
if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
|
|
2790
2605
|
}
|
|
2791
2606
|
}
|
|
2792
|
-
for (const layout of drainLayouts) {
|
|
2793
|
-
const journalDir = path.dirname(layoutPath(layout));
|
|
2794
|
-
const journalListing = await settleWithin(readdir(journalDir), remainingMs(deadline));
|
|
2795
|
-
if (journalListing.status !== "fulfilled") {
|
|
2796
|
-
await countUnlistable(journalDir);
|
|
2797
|
-
continue;
|
|
2798
|
-
}
|
|
2799
|
-
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-`));
|
|
2800
|
-
for (const name of journalNames) {
|
|
2801
|
-
const journalFilePath = path.join(journalDir, name);
|
|
2802
|
-
const read = await settleWithin(readFile(journalFilePath, "utf-8"), remainingMs(deadline));
|
|
2803
|
-
if (read.status === "fulfilled") {
|
|
2804
|
-
remaining += journalRowCount(read.value);
|
|
2805
|
-
for (const timestamp of journalLineTimestamps(read.value)) noteTimestamp(timestamp);
|
|
2806
|
-
continue;
|
|
2807
|
-
}
|
|
2808
|
-
remaining += 1;
|
|
2809
|
-
const probed = await settleWithin(lstat(journalFilePath), remainingMs(deadline));
|
|
2810
|
-
if (probed.status === "fulfilled") noteTimestamp(probed.value.mtime.toISOString());
|
|
2811
|
-
}
|
|
2812
|
-
}
|
|
2813
2607
|
return {
|
|
2814
2608
|
...oldestMs === void 0 ? {} : { oldestQueuedAt: new Date(oldestMs).toISOString() },
|
|
2815
2609
|
remaining,
|
|
@@ -2830,10 +2624,10 @@ const DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS = 5e3;
|
|
|
2830
2624
|
*/
|
|
2831
2625
|
async function countHqSpoolWork(input, dependencies = {}) {
|
|
2832
2626
|
const env = dependencies.env ?? process.env;
|
|
2833
|
-
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)
|
|
2627
|
+
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)];
|
|
2834
2628
|
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_SPOOL_INSPECT_BUDGET_MS);
|
|
2835
2629
|
const paths = layouts.map((layout) => layoutPath(layout));
|
|
2836
|
-
const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths,
|
|
2630
|
+
const { oldestQueuedAt, remaining, unlistableScans } = await countRemainingSpoolWork(paths, deadline);
|
|
2837
2631
|
const existing = await Promise.all(paths.map(async (spool) => {
|
|
2838
2632
|
return (await settleWithin(lstat(spool), remainingMs(deadline))).status === "fulfilled" ? spool : void 0;
|
|
2839
2633
|
}));
|
|
@@ -3001,7 +2795,7 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
|
3001
2795
|
});
|
|
3002
2796
|
continue;
|
|
3003
2797
|
}
|
|
3004
|
-
const counted = await countRemainingSpoolWork([spool],
|
|
2798
|
+
const counted = await countRemainingSpoolWork([spool], deadline);
|
|
3005
2799
|
if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
|
|
3006
2800
|
orphans.push({
|
|
3007
2801
|
directory: spool,
|
|
@@ -3021,7 +2815,7 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
|
3021
2815
|
});
|
|
3022
2816
|
continue;
|
|
3023
2817
|
}
|
|
3024
|
-
const counted = await countRemainingSpoolWork([spool],
|
|
2818
|
+
const counted = await countRemainingSpoolWork([spool], deadline);
|
|
3025
2819
|
if (counted.remaining === 0 && counted.unlistableScans === 0) continue;
|
|
3026
2820
|
orphans.push({
|
|
3027
2821
|
directory: spool,
|
|
@@ -3041,8 +2835,8 @@ async function sweepHqSpoolOrphans(input, dependencies = {}) {
|
|
|
3041
2835
|
* This is the deliberate counterpart to the sink's advisory emit path: the
|
|
3042
2836
|
* caller has already resolved credentials explicitly, so there is no cap, no
|
|
3043
2837
|
* daemon, and no background retry — one pass, bounded, over the repo-keyed
|
|
3044
|
-
* location
|
|
3045
|
-
*
|
|
2838
|
+
* location (or the operator's `--dir`). Delivery itself is the sink's own
|
|
2839
|
+
* replay, so there is exactly one POST path.
|
|
3046
2840
|
*/
|
|
3047
2841
|
async function flushHqSpool(input, dependencies = {}) {
|
|
3048
2842
|
const endpoint = validatedIngestEndpoint(input.endpoint);
|
|
@@ -3050,10 +2844,12 @@ async function flushHqSpool(input, dependencies = {}) {
|
|
|
3050
2844
|
const env = dependencies.env ?? process.env;
|
|
3051
2845
|
const request = dependencies.fetch ?? fetch;
|
|
3052
2846
|
const transportBudgetMs = dependencies.transportTimeoutMs ?? DEFAULT_HQ_TRANSPORT_TIMEOUT_MS;
|
|
3053
|
-
const flushBudgetMs =
|
|
2847
|
+
const flushBudgetMs = journalFlushBudgetFor({
|
|
2848
|
+
env,
|
|
2849
|
+
journalFlushBudgetMs: dependencies.journalFlushBudgetMs
|
|
2850
|
+
});
|
|
3054
2851
|
const deadline = performance.now() + (dependencies.budgetMs ?? DEFAULT_HQ_FLUSH_BUDGET_MS);
|
|
3055
|
-
const
|
|
3056
|
-
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [writeLayout, legacySpoolLayout(input.cwd)];
|
|
2852
|
+
const layouts = input.explicitDirectories && input.explicitDirectories.length > 0 ? input.explicitDirectories.map(explicitSpoolLayout) : [repositorySpoolLayout(input.repository, env)];
|
|
3057
2853
|
const outcomeById = /* @__PURE__ */ new Map();
|
|
3058
2854
|
let rejectedFiles = 0;
|
|
3059
2855
|
let unreachableFiles = 0;
|
|
@@ -3062,31 +2858,13 @@ async function flushHqSpool(input, dependencies = {}) {
|
|
|
3062
2858
|
outcomeById.set(outcome.eventId, outcome);
|
|
3063
2859
|
dependencies.report?.(outcome);
|
|
3064
2860
|
};
|
|
3065
|
-
const journalReport = (outcome) => {
|
|
3066
|
-
if (outcome.status === "unreachable" && outcome.detail?.includes("migration into the current spool")) unreachableFiles += 1;
|
|
3067
|
-
record(outcome);
|
|
3068
|
-
};
|
|
3069
2861
|
const spoolReport = (outcome) => {
|
|
3070
2862
|
if (outcome.status === "rejected") rejectedFiles += 1;
|
|
3071
2863
|
if (outcome.status === "unreachable") unreachableFiles += 1;
|
|
3072
2864
|
record(outcome);
|
|
3073
2865
|
};
|
|
3074
2866
|
const spools = [];
|
|
3075
|
-
|
|
3076
|
-
const journalDrainOptions = {
|
|
3077
|
-
maxEntries: Number.POSITIVE_INFINITY,
|
|
3078
|
-
onMigrate: () => {
|
|
3079
|
-
migratedIntoWriteLayout = true;
|
|
3080
|
-
},
|
|
3081
|
-
report: journalReport
|
|
3082
|
-
};
|
|
3083
|
-
for (const layout of layouts) await replayRetryJournal(layout, writeLayout, endpoint, input.clientId, input.clientSecret, request, {
|
|
3084
|
-
env,
|
|
3085
|
-
fetch: request,
|
|
3086
|
-
journalFlushBudgetMs: flushBudgetMs
|
|
3087
|
-
}, transportBudgetMs, deadline, deadline, journalDrainOptions);
|
|
3088
|
-
const drainLayouts = migratedIntoWriteLayout && !layouts.some((layout) => layoutPath(layout) === layoutPath(writeLayout)) ? [...layouts, writeLayout] : layouts;
|
|
3089
|
-
for (const layout of drainLayouts) {
|
|
2867
|
+
for (const layout of layouts) {
|
|
3090
2868
|
await replayCloseoutSpool(layout, endpoint, input.clientId, input.clientSecret, request, transportBudgetMs, deadline, deadline, flushBudgetMs, void 0, {
|
|
3091
2869
|
maxEntries: Number.POSITIVE_INFINITY,
|
|
3092
2870
|
report: spoolReport
|
|
@@ -3096,7 +2874,7 @@ async function flushHqSpool(input, dependencies = {}) {
|
|
|
3096
2874
|
if ((await settleWithin(lstat(layoutPath(layout)), remainingMs(deadline))).status === "fulfilled") unsecurableSpools += 1;
|
|
3097
2875
|
} else spools.push(secured.spool);
|
|
3098
2876
|
}
|
|
3099
|
-
const { remaining, unlistableScans } = await countRemainingSpoolWork(spools,
|
|
2877
|
+
const { remaining, unlistableScans } = await countRemainingSpoolWork(spools, deadline);
|
|
3100
2878
|
const outcomes = [...outcomeById.values()];
|
|
3101
2879
|
const count = (status) => outcomes.filter((outcome) => outcome.status === status).length;
|
|
3102
2880
|
const rejected = count("rejected");
|
|
@@ -3856,15 +3634,18 @@ const impactStampTargetSchema = z.object({
|
|
|
3856
3634
|
const impactStampSchema = z.object({
|
|
3857
3635
|
basis: z.enum(["target-scoped", "conservative"]),
|
|
3858
3636
|
reasons: z.array(z.string()),
|
|
3859
|
-
stampVersion: z.literal(
|
|
3637
|
+
stampVersion: z.literal(3),
|
|
3860
3638
|
targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
|
|
3861
3639
|
const names = targets.map((target) => target.name);
|
|
3862
3640
|
if (new Set(names).size !== names.length) context.addIssue({
|
|
3863
3641
|
code: "custom",
|
|
3864
3642
|
message: "impact stamp targets must have distinct names."
|
|
3865
3643
|
});
|
|
3866
|
-
})
|
|
3644
|
+
}),
|
|
3645
|
+
unsubscribedPaths: z.array(z.string())
|
|
3867
3646
|
});
|
|
3647
|
+
/** Human-readable lines for every impact decision, including quiet releases. */
|
|
3648
|
+
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(", ")}`];
|
|
3868
3649
|
/**
|
|
3869
3650
|
* A configuration contradiction between the profile's impact declarations and
|
|
3870
3651
|
* the repository's actual lockfile — e.g. a declared importer root that
|
|
@@ -3914,8 +3695,8 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
|
|
|
3914
3695
|
reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
|
|
3915
3696
|
scoped: false
|
|
3916
3697
|
};
|
|
3917
|
-
if (stamp.stampVersion !==
|
|
3918
|
-
reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version
|
|
3698
|
+
if (stamp.stampVersion !== 3) return {
|
|
3699
|
+
reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 3; ${floor}`,
|
|
3919
3700
|
scoped: false
|
|
3920
3701
|
};
|
|
3921
3702
|
if (stamp.basis !== "target-scoped") return {
|
|
@@ -3957,15 +3738,16 @@ const impactStampDemandRelease = ({ checkName, stamp }) => {
|
|
|
3957
3738
|
* every doubt path lands on, and the stamp a producer must fall back to if
|
|
3958
3739
|
* stamp computation itself fails for any reason.
|
|
3959
3740
|
*/
|
|
3960
|
-
const conservativeImpactStamp = (targets, reasons) => ({
|
|
3741
|
+
const conservativeImpactStamp = (targets, reasons, unsubscribedPaths = []) => ({
|
|
3961
3742
|
basis: "conservative",
|
|
3962
3743
|
reasons,
|
|
3963
|
-
stampVersion:
|
|
3744
|
+
stampVersion: 3,
|
|
3964
3745
|
targets: targets.map((target) => ({
|
|
3965
3746
|
basis: CONSERVATIVE_TARGET_BASIS,
|
|
3966
3747
|
impact: "affected",
|
|
3967
3748
|
name: target.name
|
|
3968
|
-
}))
|
|
3749
|
+
})),
|
|
3750
|
+
unsubscribedPaths
|
|
3969
3751
|
});
|
|
3970
3752
|
const conservativeStamp = conservativeImpactStamp;
|
|
3971
3753
|
const DEPENDENCY_FIELDS = [
|
|
@@ -3993,17 +3775,17 @@ const pnpmLockfileSchema = z.looseObject({
|
|
|
3993
3775
|
packages: z.record(z.string(), z.unknown()).optional(),
|
|
3994
3776
|
snapshots: z.record(z.string(), snapshotSectionSchema).optional()
|
|
3995
3777
|
});
|
|
3996
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3778
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3997
3779
|
const canonical = (value) => {
|
|
3998
3780
|
if (Array.isArray(value)) return value.map(canonical);
|
|
3999
|
-
if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
3781
|
+
if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
|
|
4000
3782
|
return value;
|
|
4001
3783
|
};
|
|
4002
3784
|
const stableStringify$1 = (value) => JSON.stringify(canonical(value));
|
|
4003
3785
|
const parseLockfile = (content) => {
|
|
4004
3786
|
try {
|
|
4005
3787
|
const parsed = parse(content);
|
|
4006
|
-
if (!isRecord(parsed)) return;
|
|
3788
|
+
if (!isRecord$1(parsed)) return;
|
|
4007
3789
|
const validated = pnpmLockfileSchema.safeParse(parsed);
|
|
4008
3790
|
return validated.success ? validated.data : void 0;
|
|
4009
3791
|
} catch {
|
|
@@ -4096,9 +3878,10 @@ const targetImpactForLockfileDelta = (target, delta) => {
|
|
|
4096
3878
|
/**
|
|
4097
3879
|
* Compute the impact stamp for one candidate: every declared target recorded
|
|
4098
3880
|
* as affected/not-affected with its exact basis. `not-affected` is only ever
|
|
4099
|
-
* returned only when every changed input is
|
|
4100
|
-
*
|
|
4101
|
-
*
|
|
3881
|
+
* returned only when every changed input is a valid source path or an
|
|
3882
|
+
* analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability are
|
|
3883
|
+
* unioned per target. A valid source path matched by no target is recorded in
|
|
3884
|
+
* `unsubscribedPaths` and affects none. Invalid, unreadable, unsupported, or
|
|
4102
3885
|
* otherwise doubtful input widens the whole stamp to full impact.
|
|
4103
3886
|
*
|
|
4104
3887
|
* Total by contract: this function never throws. Any unexpected failure in
|
|
@@ -4175,10 +3958,6 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
|
|
|
4175
3958
|
kind: "conservative",
|
|
4176
3959
|
reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
|
|
4177
3960
|
};
|
|
4178
|
-
if (!targets.some((target) => matchesAnyGlob(target.paths ?? [], file))) return {
|
|
4179
|
-
kind: "conservative",
|
|
4180
|
-
reason: `changed source path "${file}" has no declared impact target owner; fail closed to full impact`
|
|
4181
|
-
};
|
|
4182
3961
|
}
|
|
4183
3962
|
return {
|
|
4184
3963
|
kind: "ok",
|
|
@@ -4193,7 +3972,8 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
|
|
|
4193
3972
|
impact: "affected",
|
|
4194
3973
|
name: target.name
|
|
4195
3974
|
};
|
|
4196
|
-
})
|
|
3975
|
+
}),
|
|
3976
|
+
unsubscribedPaths: sourceFiles.filter((file) => !targets.some((target) => matchesAnyGlob(target.paths ?? [], file)))
|
|
4197
3977
|
};
|
|
4198
3978
|
};
|
|
4199
3979
|
const unionTargetImpact = (source, lockfile) => {
|
|
@@ -4209,8 +3989,9 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
|
|
|
4209
3989
|
if (targets.length === 0) return {
|
|
4210
3990
|
basis: "conservative",
|
|
4211
3991
|
reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
|
|
4212
|
-
stampVersion:
|
|
4213
|
-
targets: []
|
|
3992
|
+
stampVersion: 3,
|
|
3993
|
+
targets: [],
|
|
3994
|
+
unsubscribedPaths: changedFiles.filter((file) => file !== "pnpm-lock.yaml" && isRepoRelativePath(file))
|
|
4214
3995
|
};
|
|
4215
3996
|
assertDeclaredPathGlobs(targets);
|
|
4216
3997
|
const sides = readLockfileSides(readLockfile);
|
|
@@ -4219,18 +4000,19 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
|
|
|
4219
4000
|
const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
|
|
4220
4001
|
const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
|
|
4221
4002
|
if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
|
|
4222
|
-
if (sides.kind === "threw")
|
|
4223
|
-
if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`]);
|
|
4224
|
-
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`]);
|
|
4225
|
-
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`]);
|
|
4003
|
+
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);
|
|
4004
|
+
if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
|
|
4005
|
+
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);
|
|
4006
|
+
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);
|
|
4226
4007
|
if (!changedFiles.includes("pnpm-lock.yaml")) return {
|
|
4227
4008
|
basis: "target-scoped",
|
|
4228
|
-
reasons: ["all changed source paths
|
|
4229
|
-
stampVersion:
|
|
4230
|
-
targets: source.targets
|
|
4009
|
+
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`],
|
|
4010
|
+
stampVersion: 3,
|
|
4011
|
+
targets: source.targets,
|
|
4012
|
+
unsubscribedPaths: source.unsubscribedPaths
|
|
4231
4013
|
};
|
|
4232
4014
|
const [baseLock, headLock] = sides.parsed;
|
|
4233
|
-
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"]);
|
|
4015
|
+
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);
|
|
4234
4016
|
const delta = {
|
|
4235
4017
|
changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
|
|
4236
4018
|
changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
|
|
@@ -4241,9 +4023,10 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
|
|
|
4241
4023
|
const mixed = sourceFiles.length > 0;
|
|
4242
4024
|
return {
|
|
4243
4025
|
basis: "target-scoped",
|
|
4244
|
-
reasons: mixed ? ["all changed source paths
|
|
4245
|
-
stampVersion:
|
|
4246
|
-
targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets
|
|
4026
|
+
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"],
|
|
4027
|
+
stampVersion: 3,
|
|
4028
|
+
targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets,
|
|
4029
|
+
unsubscribedPaths: source.unsubscribedPaths
|
|
4247
4030
|
};
|
|
4248
4031
|
};
|
|
4249
4032
|
//#endregion
|
|
@@ -5537,11 +5320,29 @@ z.object({
|
|
|
5537
5320
|
//#endregion
|
|
5538
5321
|
//#region src/boundary-check.ts
|
|
5539
5322
|
const DEFAULT_BOUNDARY_CHECK_PROOF_PATH = ".factory-memory/boundary-check.json";
|
|
5323
|
+
const liveMembershipLabel = (entry) => entry.issue === void 0 ? `PR #${entry.pr} (wave ${entry.wave})` : `PR #${entry.pr} membership for issue #${entry.issue} (wave ${entry.wave})`;
|
|
5324
|
+
const removedMembershipMessage = (reviewed) => reviewed.issue === void 0 ? `PR #${reviewed.pr} was covered by the proof but is no longer in the delivery boundary's live covered set` : `PR #${reviewed.pr} membership for issue #${reviewed.issue} was covered by the proof but is no longer in the delivery boundary's live covered set`;
|
|
5325
|
+
const coveredEntriesByPr = (source) => {
|
|
5326
|
+
const byPr = /* @__PURE__ */ new Map();
|
|
5327
|
+
for (const entry of source) {
|
|
5328
|
+
const entries = byPr.get(entry.pr);
|
|
5329
|
+
if (entries) entries.push(entry);
|
|
5330
|
+
else byPr.set(entry.pr, [entry]);
|
|
5331
|
+
}
|
|
5332
|
+
return byPr;
|
|
5333
|
+
};
|
|
5334
|
+
const contributionDivergences = (entry, reviewed) => {
|
|
5335
|
+
const label = liveMembershipLabel(entry);
|
|
5336
|
+
if (reviewed.state !== entry.state) return [`${label} changed state since the proof (reviewed ${reviewed.state}, now ${entry.state})`];
|
|
5337
|
+
const reviewedSha = reviewed.mergedSha ?? reviewed.headSha;
|
|
5338
|
+
if (!reviewedSha || !sameHeadSha(reviewedSha, entry.sha)) return [entry.state === "open" ? `${label} open head moved since the proof (reviewed ${reviewedSha ?? "unknown"}, now ${entry.sha})` : `${label} merged SHA does not match the proof (reviewed ${reviewedSha ?? "unknown"}, now ${entry.sha})`];
|
|
5339
|
+
return [];
|
|
5340
|
+
};
|
|
5540
5341
|
const CLOSING_PRS_QUERY = `query($owner: String!, $repo: String!, $issue: Int!) {
|
|
5541
5342
|
repository(owner: $owner, name: $repo) {
|
|
5542
5343
|
issue(number: $issue) {
|
|
5543
5344
|
closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
|
|
5544
|
-
nodes { number state headRefOid mergeCommit { oid } }
|
|
5345
|
+
nodes { number state baseRefName headRefName headRefOid mergeCommit { oid } }
|
|
5545
5346
|
}
|
|
5546
5347
|
}
|
|
5547
5348
|
}
|
|
@@ -5569,7 +5370,9 @@ const defaultBoundaryCheckGithub = () => ({
|
|
|
5569
5370
|
"-F",
|
|
5570
5371
|
`issue=${issue}`
|
|
5571
5372
|
]).data?.repository?.issue?.closedByPullRequestsReferences?.nodes ?? []).map((node) => ({
|
|
5373
|
+
...node.baseRefName ? { baseRefName: node.baseRefName } : {},
|
|
5572
5374
|
...node.headRefOid ? { headRefOid: node.headRefOid } : {},
|
|
5375
|
+
...node.headRefName ? { headRefName: node.headRefName } : {},
|
|
5573
5376
|
...node.mergeCommit?.oid ? { mergeCommitOid: node.mergeCommit.oid } : {},
|
|
5574
5377
|
number: node.number,
|
|
5575
5378
|
state: node.state
|
|
@@ -5593,10 +5396,12 @@ const defaultBoundaryCheckGithub = () => ({
|
|
|
5593
5396
|
"--repo",
|
|
5594
5397
|
repo,
|
|
5595
5398
|
"--json",
|
|
5596
|
-
"number,state,headRefOid,mergeCommit"
|
|
5399
|
+
"number,state,baseRefName,headRefName,headRefOid,mergeCommit"
|
|
5597
5400
|
]);
|
|
5598
5401
|
return {
|
|
5402
|
+
...raw.baseRefName ? { baseRefName: raw.baseRefName } : {},
|
|
5599
5403
|
...raw.headRefOid ? { headRefOid: raw.headRefOid } : {},
|
|
5404
|
+
...raw.headRefName ? { headRefName: raw.headRefName } : {},
|
|
5600
5405
|
...raw.mergeCommit?.oid ? { mergeCommitOid: raw.mergeCommit.oid } : {},
|
|
5601
5406
|
number: raw.number,
|
|
5602
5407
|
state: raw.state
|
|
@@ -5634,44 +5439,65 @@ const coveredEntryFromRef = (ref, wave, issue, notices) => {
|
|
|
5634
5439
|
};
|
|
5635
5440
|
const computeCoveredSet = (manifest, repo, github) => {
|
|
5636
5441
|
const notices = [];
|
|
5637
|
-
const
|
|
5442
|
+
const entries = [];
|
|
5443
|
+
const linkedPrs = /* @__PURE__ */ new Set();
|
|
5444
|
+
const pullRequests = [];
|
|
5638
5445
|
for (const wave of manifest.waves) for (const issue of wave.issues) {
|
|
5639
5446
|
const refs = github.fetchClosingPullRequests(repo, issue);
|
|
5640
5447
|
if (refs.length === 0) notices.push(`issue #${issue} (wave ${wave.name}) has no linked closing PR yet`);
|
|
5641
5448
|
for (const ref of refs) {
|
|
5449
|
+
pullRequests.push(ref);
|
|
5450
|
+
linkedPrs.add(ref.number);
|
|
5642
5451
|
const entry = coveredEntryFromRef(ref, wave.name, issue, notices);
|
|
5643
|
-
if (entry
|
|
5452
|
+
if (entry) entries.push(entry);
|
|
5644
5453
|
}
|
|
5645
5454
|
}
|
|
5646
5455
|
for (const [prKey, waveName] of Object.entries(manifest.prs ?? {})) {
|
|
5647
5456
|
const prNumber = Number(prKey);
|
|
5648
|
-
if (
|
|
5649
|
-
const
|
|
5650
|
-
|
|
5457
|
+
if (linkedPrs.has(prNumber)) continue;
|
|
5458
|
+
const ref = github.fetchPullRequest(repo, prNumber);
|
|
5459
|
+
pullRequests.push(ref);
|
|
5460
|
+
const entry = coveredEntryFromRef(ref, waveName, void 0, notices);
|
|
5461
|
+
if (entry) entries.push(entry);
|
|
5651
5462
|
}
|
|
5652
5463
|
return {
|
|
5653
|
-
entries:
|
|
5654
|
-
notices
|
|
5464
|
+
entries: entries.toSorted((a, b) => a.pr - b.pr || (a.issue ?? 0) - (b.issue ?? 0)),
|
|
5465
|
+
notices,
|
|
5466
|
+
pullRequests: pullRequests.toSorted((a, b) => a.number - b.number)
|
|
5655
5467
|
};
|
|
5656
5468
|
};
|
|
5657
5469
|
const coveredSetDivergences = (computed, proof) => {
|
|
5658
5470
|
const divergences = [];
|
|
5659
|
-
const
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5471
|
+
const computedByPr = coveredEntriesByPr(computed);
|
|
5472
|
+
const proofByPr = coveredEntriesByPr(proof.coveredSet);
|
|
5473
|
+
for (const [pr, entries] of computedByPr) {
|
|
5474
|
+
const reviewedEntries = proofByPr.get(pr) ?? [];
|
|
5475
|
+
if (reviewedEntries.length === 0) {
|
|
5476
|
+
divergences.push(...entries.map((entry) => `${liveMembershipLabel(entry)} joined the delivery boundary after the proof was minted`));
|
|
5664
5477
|
continue;
|
|
5665
5478
|
}
|
|
5666
|
-
|
|
5667
|
-
|
|
5479
|
+
const [firstEntry] = entries;
|
|
5480
|
+
const [firstReviewed] = reviewedEntries;
|
|
5481
|
+
if (!(entries.length > 1 || reviewedEntries.length > 1 || firstEntry?.issue !== firstReviewed?.issue)) {
|
|
5482
|
+
if (firstEntry && firstReviewed) divergences.push(...contributionDivergences(firstEntry, firstReviewed));
|
|
5668
5483
|
continue;
|
|
5669
5484
|
}
|
|
5670
|
-
const
|
|
5671
|
-
|
|
5485
|
+
const unmatchedReviewed = [...reviewedEntries];
|
|
5486
|
+
for (const entry of entries) {
|
|
5487
|
+
const reviewedIndex = unmatchedReviewed.findIndex((candidate) => candidate.issue === entry.issue);
|
|
5488
|
+
if (reviewedIndex === -1) {
|
|
5489
|
+
divergences.push(`${liveMembershipLabel(entry)} joined the delivery boundary after the proof was minted`);
|
|
5490
|
+
continue;
|
|
5491
|
+
}
|
|
5492
|
+
const [reviewed] = unmatchedReviewed.splice(reviewedIndex, 1);
|
|
5493
|
+
if (reviewed) divergences.push(...contributionDivergences(entry, reviewed));
|
|
5494
|
+
}
|
|
5495
|
+
for (const reviewed of unmatchedReviewed) divergences.push(removedMembershipMessage(reviewed));
|
|
5496
|
+
}
|
|
5497
|
+
for (const [pr, reviewedEntries] of proofByPr) {
|
|
5498
|
+
if (computedByPr.has(pr)) continue;
|
|
5499
|
+
divergences.push(...reviewedEntries.map(removedMembershipMessage));
|
|
5672
5500
|
}
|
|
5673
|
-
const computedPrs = new Set(computed.map((entry) => entry.pr));
|
|
5674
|
-
for (const reviewed of proof.coveredSet) if (!computedPrs.has(reviewed.pr)) divergences.push(`PR #${reviewed.pr} was covered by the proof but is no longer in the boundary's live covered set`);
|
|
5675
5501
|
return divergences;
|
|
5676
5502
|
};
|
|
5677
5503
|
const defaultResolveRepo = (cwd) => runCapture("gh", [
|
|
@@ -5718,6 +5544,10 @@ function runBoundaryCheck(args, dependencies = {}) {
|
|
|
5718
5544
|
manifestHash: parsed.manifestHash,
|
|
5719
5545
|
selected
|
|
5720
5546
|
});
|
|
5547
|
+
blockingReasons.unshift(...boundaryTopologyReasons({
|
|
5548
|
+
manifest: parsed.manifest,
|
|
5549
|
+
pullRequests: computed.pullRequests
|
|
5550
|
+
}));
|
|
5721
5551
|
record = {
|
|
5722
5552
|
blockingReasons,
|
|
5723
5553
|
boundary: parsed.manifest.boundary,
|
|
@@ -5846,17 +5676,21 @@ const asClassification = (value) => DIFF_CLASSIFICATIONS$1.includes(value) ? val
|
|
|
5846
5676
|
const prVerifyCheckPayloadFor = (proof) => {
|
|
5847
5677
|
const value = proof;
|
|
5848
5678
|
if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
|
|
5679
|
+
const carriesReleases = value.notRequiredCommands !== void 0;
|
|
5849
5680
|
return {
|
|
5850
5681
|
classification: asClassification(value.classification),
|
|
5851
5682
|
executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
|
|
5852
5683
|
headSha: value.headSha,
|
|
5684
|
+
...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
|
|
5853
5685
|
kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
|
|
5854
5686
|
mode: value.mode,
|
|
5687
|
+
...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
|
|
5855
5688
|
outcome: outcomeFor(value),
|
|
5856
5689
|
proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
|
|
5857
5690
|
schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
|
|
5858
5691
|
...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
|
|
5859
|
-
...typeof value.repository === "string" ? { repository: value.repository } : {}
|
|
5692
|
+
...typeof value.repository === "string" ? { repository: value.repository } : {},
|
|
5693
|
+
...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
|
|
5860
5694
|
};
|
|
5861
5695
|
};
|
|
5862
5696
|
const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
@@ -7670,31 +7504,6 @@ const defaultSleep = async (ms) => {
|
|
|
7670
7504
|
const { setTimeout: delay } = await import("node:timers/promises");
|
|
7671
7505
|
await delay(ms);
|
|
7672
7506
|
};
|
|
7673
|
-
const base64url = (value) => Buffer.from(value).toString("base64url");
|
|
7674
|
-
function appJwt(config, now) {
|
|
7675
|
-
const issuedAt = Math.floor(now / 1e3) - 60;
|
|
7676
|
-
const unsigned = `${base64url(JSON.stringify({
|
|
7677
|
-
alg: "RS256",
|
|
7678
|
-
typ: "JWT"
|
|
7679
|
-
}))}.${base64url(JSON.stringify({
|
|
7680
|
-
exp: issuedAt + 600,
|
|
7681
|
-
iat: issuedAt,
|
|
7682
|
-
iss: config.appId
|
|
7683
|
-
}))}`;
|
|
7684
|
-
const signer = createSign("RSA-SHA256");
|
|
7685
|
-
signer.update(unsigned);
|
|
7686
|
-
signer.end();
|
|
7687
|
-
return `${unsigned}.${signer.sign(readFileSync(config.privateKeyPath), "base64url")}`;
|
|
7688
|
-
}
|
|
7689
|
-
/** Carries the HTTP status so a caller can tell a retryable failure apart. */
|
|
7690
|
-
var GitHubApiError = class extends Error {
|
|
7691
|
-
status;
|
|
7692
|
-
constructor(status, statusText) {
|
|
7693
|
-
super(`GitHub API ${status} ${statusText}`);
|
|
7694
|
-
this.name = "GitHubApiError";
|
|
7695
|
-
this.status = status;
|
|
7696
|
-
}
|
|
7697
|
-
};
|
|
7698
7507
|
/**
|
|
7699
7508
|
* Worth another attempt: 422 is what GitHub answers for a head SHA it has not
|
|
7700
7509
|
* seen yet (the whole reason the retry exists), 5xx and rate limiting are
|
|
@@ -7749,30 +7558,36 @@ async function githubJson(request, url, init, timeoutMs = GITHUB_PUBLISH_TIMEOUT
|
|
|
7749
7558
|
if (!response.ok) throw new GitHubApiError(response.status, response.statusText);
|
|
7750
7559
|
return await response.json();
|
|
7751
7560
|
}
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7760
|
-
|
|
7761
|
-
|
|
7762
|
-
|
|
7763
|
-
|
|
7764
|
-
|
|
7765
|
-
|
|
7766
|
-
|
|
7767
|
-
|
|
7768
|
-
|
|
7769
|
-
}
|
|
7561
|
+
/**
|
|
7562
|
+
* Mint an installation token for the Patronage Factory App.
|
|
7563
|
+
*
|
|
7564
|
+
* The mechanism — app JWT, installation lookup, token exchange — lives in
|
|
7565
|
+
* `@patronage/factory-ci` (#617), because paitronage's proof-comment publisher
|
|
7566
|
+
* had grown a second copy of it. What stays here is what is this repository's:
|
|
7567
|
+
* where the credentials come from (`user-config`), and the publish timeout the
|
|
7568
|
+
* rest of these calls are bound by. Nothing is cached, as before.
|
|
7569
|
+
*/
|
|
7570
|
+
const installationToken = (repository, config, request, now, timeoutMs = GITHUB_PUBLISH_TIMEOUT_MS) => mintInstallationToken({
|
|
7571
|
+
credentials: config,
|
|
7572
|
+
owner: repository.owner,
|
|
7573
|
+
repo: repository.repo
|
|
7574
|
+
}, {
|
|
7575
|
+
fetch: request,
|
|
7576
|
+
now: () => now,
|
|
7577
|
+
timeoutMs
|
|
7578
|
+
});
|
|
7770
7579
|
function verifyOutput(proof) {
|
|
7771
7580
|
const value = proof;
|
|
7772
7581
|
const commands = value.executedCommands?.length ?? 0;
|
|
7582
|
+
const unsubscribedBasis = value.impactStamp?.unsubscribedPaths;
|
|
7583
|
+
let unsubscribedSummary;
|
|
7584
|
+
if (Array.isArray(unsubscribedBasis)) {
|
|
7585
|
+
const unsubscribed = unsubscribedBasis.filter((path) => typeof path === "string");
|
|
7586
|
+
unsubscribedSummary = unsubscribed.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${unsubscribed.join(", ")}.`;
|
|
7587
|
+
} else unsubscribedSummary = "Unsubscribed changed paths: unavailable (legacy or malformed stamp).";
|
|
7773
7588
|
const payload = prVerifyCheckPayloadFor(proof);
|
|
7774
7589
|
return {
|
|
7775
|
-
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed
|
|
7590
|
+
summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed. ${unsubscribedSummary}`,
|
|
7776
7591
|
...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
|
|
7777
7592
|
title: `pr:verify ${value.outcome ?? "unknown"}`
|
|
7778
7593
|
};
|
|
@@ -7786,8 +7601,10 @@ function reviewOutput(proof) {
|
|
|
7786
7601
|
for (const { disposition } of value.ladder ? ladderLedgerFor(value.ladder) : []) dispositionCounts[disposition] = (dispositionCounts[disposition] ?? 0) + 1;
|
|
7787
7602
|
if (!value.ladder && findings > 0) dispositionCounts.open = findings;
|
|
7788
7603
|
const dispositions = Object.entries(dispositionCounts).map(([name, count]) => `${name} ${count}`).join(", ");
|
|
7604
|
+
const unavailable = "unavailable (legacy proof)";
|
|
7605
|
+
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(" | ");
|
|
7789
7606
|
return {
|
|
7790
|
-
summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}.`,
|
|
7607
|
+
summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}. Review provenance: ${provenance}.`,
|
|
7791
7608
|
title: findings === 0 ? "pr:review clean" : "pr:review proof recorded"
|
|
7792
7609
|
};
|
|
7793
7610
|
}
|
|
@@ -8881,8 +8698,24 @@ function positiveInteger(name) {
|
|
|
8881
8698
|
};
|
|
8882
8699
|
}
|
|
8883
8700
|
function resolveCwdOption(cwd) {
|
|
8884
|
-
|
|
8885
|
-
|
|
8701
|
+
const value = typeof cwd === "string" ? cwd : ".";
|
|
8702
|
+
if (value.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
|
|
8703
|
+
return path.resolve(value);
|
|
8704
|
+
}
|
|
8705
|
+
function collectCwdOption(rawValue, previous) {
|
|
8706
|
+
if (previous === void 0) return rawValue;
|
|
8707
|
+
const previousResolved = resolveCwdOption(previous);
|
|
8708
|
+
const currentResolved = resolveCwdOption(rawValue);
|
|
8709
|
+
if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
|
|
8710
|
+
return rawValue;
|
|
8711
|
+
}
|
|
8712
|
+
function markCwdOptionDefault(command) {
|
|
8713
|
+
const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
|
|
8714
|
+
if (option) {
|
|
8715
|
+
option.defaultValue = ".";
|
|
8716
|
+
option.defaultValueDescription = ".";
|
|
8717
|
+
}
|
|
8718
|
+
return command;
|
|
8886
8719
|
}
|
|
8887
8720
|
function resolveCheckoutPath(cwd, target) {
|
|
8888
8721
|
return path.resolve(cwd, target);
|
|
@@ -8905,7 +8738,7 @@ const renderReport = (record) => {
|
|
|
8905
8738
|
return `${lines.join("\n")}\n`;
|
|
8906
8739
|
};
|
|
8907
8740
|
function createBoundaryCheckCommand(output, action) {
|
|
8908
|
-
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",
|
|
8741
|
+
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({
|
|
8909
8742
|
gate: "boundary:check",
|
|
8910
8743
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
8911
8744
|
stderr: output.stderr
|
|
@@ -8921,7 +8754,10 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8921
8754
|
if (!action) {
|
|
8922
8755
|
const [owner, name] = record.repo.split("/");
|
|
8923
8756
|
if (owner && name) {
|
|
8924
|
-
const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({
|
|
8757
|
+
const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({
|
|
8758
|
+
cwd: args.cwd,
|
|
8759
|
+
profilePath: options.profile
|
|
8760
|
+
})?.profile);
|
|
8925
8761
|
try {
|
|
8926
8762
|
publishFactoryCheckSafely(createFactoryCheckPublisher(output), {
|
|
8927
8763
|
conclusion: record.status === "ready" ? "success" : "failure",
|
|
@@ -8941,7 +8777,710 @@ function createBoundaryCheckCommand(output, action) {
|
|
|
8941
8777
|
}
|
|
8942
8778
|
output.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : renderReport(record));
|
|
8943
8779
|
if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
|
|
8780
|
+
})));
|
|
8781
|
+
}
|
|
8782
|
+
/** `tool` discriminator every emitted report carries. */
|
|
8783
|
+
const CI_ANALYZE_TOOL = "psf-ci-analyze";
|
|
8784
|
+
/** Cohort label for runs the caller did not assign to a cohort. */
|
|
8785
|
+
const UNGROUPED_COHORT = "ungrouped";
|
|
8786
|
+
const available = (value) => ({
|
|
8787
|
+
available: true,
|
|
8788
|
+
value
|
|
8789
|
+
});
|
|
8790
|
+
const unavailable = (reason) => ({
|
|
8791
|
+
available: false,
|
|
8792
|
+
reason
|
|
8793
|
+
});
|
|
8794
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8795
|
+
const stringField$1 = (source, key, where) => {
|
|
8796
|
+
const value = source[key];
|
|
8797
|
+
return typeof value === "string" && value.length > 0 ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8798
|
+
};
|
|
8799
|
+
const numberField = (source, key, where) => {
|
|
8800
|
+
const value = source[key];
|
|
8801
|
+
return typeof value === "number" && Number.isFinite(value) ? available(value) : unavailable(`${where} has no ${key}`);
|
|
8802
|
+
};
|
|
8803
|
+
/**
|
|
8804
|
+
* An instant is only usable when it names its own timezone. `Date.parse`
|
|
8805
|
+
* interprets an offset-less `2026-08-06T12:00:00` in the *host's* zone, which
|
|
8806
|
+
* would make a duration depend on where the analyzer ran. The GitHub API
|
|
8807
|
+
* always sends `Z`-form, so requiring an explicit `Z` or numeric offset
|
|
8808
|
+
* rejects nothing genuine — anything else becomes explicitly unavailable.
|
|
8809
|
+
*/
|
|
8810
|
+
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/u;
|
|
8811
|
+
const parseInstantMs = (value) => {
|
|
8812
|
+
if (!value.available) return;
|
|
8813
|
+
if (!EXPLICIT_OFFSET_PATTERN.test(value.value)) return;
|
|
8814
|
+
const parsed = Date.parse(value.value);
|
|
8815
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
8816
|
+
};
|
|
8817
|
+
/**
|
|
8818
|
+
* Wall clock between two ISO instants. Unavailable — with the missing side
|
|
8819
|
+
* named — whenever either instant is absent or unparsable; a negative span is
|
|
8820
|
+
* refused rather than clamped to a fake zero.
|
|
8821
|
+
*/
|
|
8822
|
+
const durationBetween = (start, end, label) => {
|
|
8823
|
+
const startMs = parseInstantMs(start);
|
|
8824
|
+
if (startMs === void 0) return unavailable(`${label}: start instant is ${start.available ? "unparsable" : `unavailable (${start.reason})`}`);
|
|
8825
|
+
const endMs = parseInstantMs(end);
|
|
8826
|
+
if (endMs === void 0) return unavailable(`${label}: end instant is ${end.available ? "unparsable" : `unavailable (${end.reason})`}`);
|
|
8827
|
+
if (endMs < startMs) return unavailable(`${label}: end instant precedes start instant`);
|
|
8828
|
+
return available(endMs - startMs);
|
|
8829
|
+
};
|
|
8830
|
+
const GITHUB_SETUP_STEP_NAME = "Set up job";
|
|
8831
|
+
const INSTALL_STEP_PATTERN = /install/iu;
|
|
8832
|
+
const analyzeStep = (step) => {
|
|
8833
|
+
const startedAt = stringField$1(step, "started_at", "step");
|
|
8834
|
+
const completedAt = stringField$1(step, "completed_at", "step");
|
|
8835
|
+
return {
|
|
8836
|
+
completedAt,
|
|
8837
|
+
conclusion: stringField$1(step, "conclusion", "step"),
|
|
8838
|
+
durationMs: durationBetween(startedAt, completedAt, "step duration"),
|
|
8839
|
+
name: typeof step.name === "string" ? step.name : "(unnamed step)",
|
|
8840
|
+
number: numberField(step, "number", "step"),
|
|
8841
|
+
startedAt
|
|
8842
|
+
};
|
|
8843
|
+
};
|
|
8844
|
+
const namedStepDuration = (steps, matches, description) => {
|
|
8845
|
+
const step = steps.find((candidate) => matches(candidate.name));
|
|
8846
|
+
return step === void 0 ? unavailable(`no ${description} step in this job`) : step.durationMs;
|
|
8847
|
+
};
|
|
8848
|
+
const analyzeJob = (job) => {
|
|
8849
|
+
const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord).map(analyzeStep) : [];
|
|
8850
|
+
const createdAt = stringField$1(job, "created_at", "job");
|
|
8851
|
+
const startedAt = stringField$1(job, "started_at", "job");
|
|
8852
|
+
const completedAt = stringField$1(job, "completed_at", "job");
|
|
8853
|
+
const labels = Array.isArray(job.labels) ? job.labels.filter((label) => typeof label === "string") : void 0;
|
|
8854
|
+
return {
|
|
8855
|
+
completedAt,
|
|
8856
|
+
conclusion: stringField$1(job, "conclusion", "job"),
|
|
8857
|
+
createdAt,
|
|
8858
|
+
installMs: namedStepDuration(steps, (name) => INSTALL_STEP_PATTERN.test(name), "install"),
|
|
8859
|
+
jobId: numberField(job, "id", "job"),
|
|
8860
|
+
name: typeof job.name === "string" ? job.name : "(unnamed job)",
|
|
8861
|
+
queueMs: durationBetween(createdAt, startedAt, "job queue"),
|
|
8862
|
+
runnerLabels: labels === void 0 ? unavailable("job has no labels array") : available(labels),
|
|
8863
|
+
setupMs: namedStepDuration(steps, (name) => name === GITHUB_SETUP_STEP_NAME, `"${GITHUB_SETUP_STEP_NAME}"`),
|
|
8864
|
+
startedAt,
|
|
8865
|
+
steps,
|
|
8866
|
+
totalMs: durationBetween(startedAt, completedAt, "job duration"),
|
|
8867
|
+
url: stringField$1(job, "html_url", "job")
|
|
8868
|
+
};
|
|
8869
|
+
};
|
|
8870
|
+
/**
|
|
8871
|
+
* Classify proof reuse from step conclusions alone. A job whose gate step is
|
|
8872
|
+
* followed by at least one skipped step reused proof; a job whose post-gate
|
|
8873
|
+
* steps all executed ran the full fallback. No gate step anywhere means the
|
|
8874
|
+
* workflow does not expose the outcome, which is unavailable — not
|
|
8875
|
+
* full-fallback.
|
|
8876
|
+
*/
|
|
8877
|
+
const classifyProofReuse = (jobs) => {
|
|
8878
|
+
const gateJobs = [];
|
|
8879
|
+
const verdicts = /* @__PURE__ */ new Set();
|
|
8880
|
+
for (const job of jobs) {
|
|
8881
|
+
const gateIndex = job.steps.findIndex((step) => step.name === FACTORY_PROOF_GATE_STEP_NAME);
|
|
8882
|
+
if (gateIndex === -1) continue;
|
|
8883
|
+
const skippedStepsAfterGate = job.steps.slice(gateIndex + 1).filter((step) => step.conclusion.available && step.conclusion.value === "skipped").length;
|
|
8884
|
+
verdicts.add(skippedStepsAfterGate > 0 ? "proof-reuse" : "full-fallback");
|
|
8885
|
+
gateJobs.push({
|
|
8886
|
+
gateConclusion: job.steps[gateIndex]?.conclusion ?? unavailable("gate step vanished"),
|
|
8887
|
+
jobName: job.name,
|
|
8888
|
+
skippedStepsAfterGate
|
|
8889
|
+
});
|
|
8890
|
+
}
|
|
8891
|
+
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`);
|
|
8892
|
+
const [single] = [...verdicts];
|
|
8893
|
+
return available({
|
|
8894
|
+
classification: verdicts.size === 1 && single ? single : "mixed",
|
|
8895
|
+
gateJobs
|
|
8896
|
+
});
|
|
8897
|
+
};
|
|
8898
|
+
const parseArtifactListing = (value) => {
|
|
8899
|
+
if (!isRecord(value) || !Array.isArray(value.artifacts)) return {
|
|
8900
|
+
artifacts: [],
|
|
8901
|
+
totalCount: void 0
|
|
8902
|
+
};
|
|
8903
|
+
return {
|
|
8904
|
+
artifacts: value.artifacts.filter(isRecord).flatMap((artifact) => {
|
|
8905
|
+
const { id, name } = artifact;
|
|
8906
|
+
return typeof id === "number" && typeof name === "string" ? [{
|
|
8907
|
+
expired: artifact.expired === true,
|
|
8908
|
+
id,
|
|
8909
|
+
name
|
|
8910
|
+
}] : [];
|
|
8911
|
+
}),
|
|
8912
|
+
totalCount: typeof value.total_count === "number" ? value.total_count : void 0
|
|
8913
|
+
};
|
|
8914
|
+
};
|
|
8915
|
+
const profileFromDocument = (artifactName, value) => {
|
|
8916
|
+
const read = readVitestProfileDocument(value);
|
|
8917
|
+
if (read.kind === "unrecognized") return {
|
|
8918
|
+
artifactName,
|
|
8919
|
+
kind: "unreadable",
|
|
8920
|
+
reason: read.reason
|
|
8921
|
+
};
|
|
8922
|
+
const { profile } = read;
|
|
8923
|
+
const testCounts = profile.runs.flatMap((run) => run.counts === null ? [] : [run.counts.tests]);
|
|
8924
|
+
return {
|
|
8925
|
+
artifactName,
|
|
8926
|
+
kind: "profile",
|
|
8927
|
+
profile: {
|
|
8928
|
+
cpuCount: profile.environment.cpuCount,
|
|
8929
|
+
cpuModel: profile.environment.cpuModel,
|
|
8930
|
+
maxWorkers: profile.options.maxWorkers,
|
|
8931
|
+
sampleDurationsMs: profile.runs.map((run) => run.durationMs),
|
|
8932
|
+
scope: profile.command.join(" "),
|
|
8933
|
+
slowFiles: profile.summary.slowFiles.map((file) => ({
|
|
8934
|
+
medianMs: file.durationMs.median,
|
|
8935
|
+
path: file.path
|
|
8936
|
+
})),
|
|
8937
|
+
slowTests: profile.summary.slowTests.map((test) => ({
|
|
8938
|
+
file: test.file,
|
|
8939
|
+
medianMs: test.durationMs.median,
|
|
8940
|
+
name: test.name
|
|
8941
|
+
})),
|
|
8942
|
+
summaryDurationMs: profile.summary.durationMs,
|
|
8943
|
+
testCount: testCounts.length === 0 ? unavailable("no sample in this profile carries test counts") : available(Math.max(...testCounts))
|
|
8944
|
+
}
|
|
8945
|
+
};
|
|
8946
|
+
};
|
|
8947
|
+
const collectProfiles = async (client, runId) => {
|
|
8948
|
+
let listing;
|
|
8949
|
+
try {
|
|
8950
|
+
listing = parseArtifactListing(await client.listArtifacts(runId));
|
|
8951
|
+
} catch (error) {
|
|
8952
|
+
return unavailable(`artifact listing failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
8953
|
+
}
|
|
8954
|
+
const { artifacts } = listing;
|
|
8955
|
+
if (listing.totalCount !== void 0 && listing.totalCount > artifacts.length) return unavailable(`artifact listing is truncated: ${artifacts.length} of ${listing.totalCount} artifacts served`);
|
|
8956
|
+
if (artifacts.length === 0) return unavailable("this run has no artifacts");
|
|
8957
|
+
const analyses = [];
|
|
8958
|
+
for (const artifact of artifacts) {
|
|
8959
|
+
if (artifact.expired) {
|
|
8960
|
+
analyses.push({
|
|
8961
|
+
artifactName: artifact.name,
|
|
8962
|
+
kind: "unreadable",
|
|
8963
|
+
reason: "the artifact is expired and can no longer be downloaded"
|
|
8964
|
+
});
|
|
8965
|
+
continue;
|
|
8966
|
+
}
|
|
8967
|
+
let files;
|
|
8968
|
+
try {
|
|
8969
|
+
files = await client.readArtifactTextFiles(artifact.id);
|
|
8970
|
+
} catch (error) {
|
|
8971
|
+
analyses.push({
|
|
8972
|
+
artifactName: artifact.name,
|
|
8973
|
+
kind: "unreadable",
|
|
8974
|
+
reason: `artifact download failed: ${error instanceof Error ? error.message : String(error)}`
|
|
8975
|
+
});
|
|
8976
|
+
continue;
|
|
8977
|
+
}
|
|
8978
|
+
for (const file of files) {
|
|
8979
|
+
let parsed;
|
|
8980
|
+
try {
|
|
8981
|
+
parsed = JSON.parse(file.text);
|
|
8982
|
+
} catch {
|
|
8983
|
+
analyses.push({
|
|
8984
|
+
artifactName: `${artifact.name}/${file.name}`,
|
|
8985
|
+
kind: "unreadable",
|
|
8986
|
+
reason: "the file is not valid JSON"
|
|
8987
|
+
});
|
|
8988
|
+
continue;
|
|
8989
|
+
}
|
|
8990
|
+
const analysis = profileFromDocument(`${artifact.name}/${file.name}`, parsed);
|
|
8991
|
+
if (analysis.kind === "profile" || isRecord(parsed) && parsed.tool === "factory-ci-vitest-profile") analyses.push(analysis);
|
|
8992
|
+
}
|
|
8993
|
+
}
|
|
8994
|
+
return analyses.length === 0 ? unavailable("no compatible factory-ci Vitest profile artifacts on this run") : available(analyses);
|
|
8995
|
+
};
|
|
8996
|
+
const earliest = (instants) => instants.length === 0 ? void 0 : Math.min(...instants);
|
|
8997
|
+
const latest = (instants) => instants.length === 0 ? void 0 : Math.max(...instants);
|
|
8998
|
+
const runQueueDuration = (createdAt, jobs) => {
|
|
8999
|
+
const createdMs = parseInstantMs(createdAt);
|
|
9000
|
+
if (createdMs === void 0) return unavailable("the run object has no parsable created_at");
|
|
9001
|
+
const firstStart = earliest(jobs.flatMap((job) => {
|
|
9002
|
+
const started = parseInstantMs(job.startedAt);
|
|
9003
|
+
return started === void 0 ? [] : [started];
|
|
8944
9004
|
}));
|
|
9005
|
+
if (firstStart === void 0) return unavailable("no job carries a parsable started_at");
|
|
9006
|
+
return firstStart < createdMs ? unavailable("the earliest job start precedes the run creation instant") : available(firstStart - createdMs);
|
|
9007
|
+
};
|
|
9008
|
+
const runCompletionInstant = (jobs) => {
|
|
9009
|
+
const completions = jobs.flatMap((job) => {
|
|
9010
|
+
const completed = parseInstantMs(job.completedAt);
|
|
9011
|
+
return completed === void 0 ? [] : [{
|
|
9012
|
+
completed,
|
|
9013
|
+
iso: job.completedAt
|
|
9014
|
+
}];
|
|
9015
|
+
});
|
|
9016
|
+
const last = latest(completions.map((entry) => entry.completed));
|
|
9017
|
+
const match = completions.find((entry) => entry.completed === last);
|
|
9018
|
+
return match?.iso.available === true ? available(match.iso.value) : unavailable("no job carries a parsable completed_at");
|
|
9019
|
+
};
|
|
9020
|
+
/**
|
|
9021
|
+
* `cpuModel` never comes from the Actions API — only readable profile
|
|
9022
|
+
* artifacts carry silicon attribution, so both absence cases say so.
|
|
9023
|
+
*/
|
|
9024
|
+
const cpuModelsField = (cpuModels, profiles) => {
|
|
9025
|
+
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`);
|
|
9026
|
+
if (cpuModels.length === 0) return unavailable("no readable profile artifact recorded a CPU model");
|
|
9027
|
+
return available(cpuModels);
|
|
9028
|
+
};
|
|
9029
|
+
const analyzeOneRun = async (client, request, entry) => {
|
|
9030
|
+
const cohort = entry.cohort ?? "ungrouped";
|
|
9031
|
+
let runValue;
|
|
9032
|
+
let jobsValue;
|
|
9033
|
+
try {
|
|
9034
|
+
runValue = await client.getRun(entry.runId);
|
|
9035
|
+
jobsValue = await client.listJobs(entry.runId);
|
|
9036
|
+
} catch (error) {
|
|
9037
|
+
return {
|
|
9038
|
+
cohort,
|
|
9039
|
+
kind: "unreadable",
|
|
9040
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
9041
|
+
repository: request.repository,
|
|
9042
|
+
runId: entry.runId
|
|
9043
|
+
};
|
|
9044
|
+
}
|
|
9045
|
+
if (!isRecord(runValue)) return {
|
|
9046
|
+
cohort,
|
|
9047
|
+
kind: "unreadable",
|
|
9048
|
+
reason: "the run response is not an object",
|
|
9049
|
+
repository: request.repository,
|
|
9050
|
+
runId: entry.runId
|
|
9051
|
+
};
|
|
9052
|
+
const jobRows = isRecord(jobsValue) && Array.isArray(jobsValue.jobs) ? jobsValue.jobs.filter(isRecord) : [];
|
|
9053
|
+
if (isRecord(jobsValue) && typeof jobsValue.total_count === "number" && jobsValue.total_count > jobRows.length) return {
|
|
9054
|
+
cohort,
|
|
9055
|
+
kind: "unreadable",
|
|
9056
|
+
reason: `jobs listing is truncated: ${jobRows.length} of ${jobsValue.total_count} jobs served`,
|
|
9057
|
+
repository: request.repository,
|
|
9058
|
+
runId: entry.runId
|
|
9059
|
+
};
|
|
9060
|
+
const jobs = jobRows.map(analyzeJob);
|
|
9061
|
+
const createdAt = stringField$1(runValue, "created_at", "run");
|
|
9062
|
+
const startedAt = stringField$1(runValue, "run_started_at", "run");
|
|
9063
|
+
const completedAt = runCompletionInstant(jobs);
|
|
9064
|
+
const profiles = request.includeProfiles ? await collectProfiles(client, entry.runId) : unavailable("profile artifacts were not requested (pass --profiles)");
|
|
9065
|
+
const cpuModels = profiles.available ? [...new Set(profiles.value.flatMap((profile) => profile.kind === "profile" && profile.profile.cpuModel !== null ? [profile.profile.cpuModel] : []))] : void 0;
|
|
9066
|
+
const labels = [...new Set(jobs.flatMap((job) => job.runnerLabels.available ? job.runnerLabels.value : []))];
|
|
9067
|
+
return {
|
|
9068
|
+
cohort,
|
|
9069
|
+
completedAt,
|
|
9070
|
+
conclusion: stringField$1(runValue, "conclusion", "run"),
|
|
9071
|
+
cpuModels: cpuModelsField(cpuModels, profiles),
|
|
9072
|
+
createdAt,
|
|
9073
|
+
durations: {
|
|
9074
|
+
queueMs: runQueueDuration(createdAt, jobs),
|
|
9075
|
+
totalMs: durationBetween(startedAt, completedAt, "run duration")
|
|
9076
|
+
},
|
|
9077
|
+
event: stringField$1(runValue, "event", "run"),
|
|
9078
|
+
headSha: stringField$1(runValue, "head_sha", "run"),
|
|
9079
|
+
jobs,
|
|
9080
|
+
kind: "analyzed",
|
|
9081
|
+
profiles,
|
|
9082
|
+
proofReuse: classifyProofReuse(jobs),
|
|
9083
|
+
repository: request.repository,
|
|
9084
|
+
runId: entry.runId,
|
|
9085
|
+
runnerLabels: labels.length === 0 ? unavailable("no job carries runner labels") : available(labels),
|
|
9086
|
+
startedAt,
|
|
9087
|
+
url: stringField$1(runValue, "html_url", "run"),
|
|
9088
|
+
workflowName: stringField$1(runValue, "name", "run")
|
|
9089
|
+
};
|
|
9090
|
+
};
|
|
9091
|
+
/**
|
|
9092
|
+
* Quantile by the floor-index convention: over `sorted` (ascending), the
|
|
9093
|
+
* q-quantile is the element at index `floor(q * (n - 1))`. No interpolation —
|
|
9094
|
+
* every reported quantile is a duration that actually happened, and the
|
|
9095
|
+
* convention is stable for the small sample counts (3–10 runs) this command
|
|
9096
|
+
* exists for. median = q(0.5), p25 = q(0.25), p75 = q(0.75).
|
|
9097
|
+
*/
|
|
9098
|
+
const quantileFloorIndex = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))] ?? NaN;
|
|
9099
|
+
const quantileSummary = (values) => {
|
|
9100
|
+
const sorted = values.toSorted((left, right) => left - right);
|
|
9101
|
+
return {
|
|
9102
|
+
count: sorted.length,
|
|
9103
|
+
maximum: sorted.at(-1) ?? NaN,
|
|
9104
|
+
median: quantileFloorIndex(sorted, .5),
|
|
9105
|
+
minimum: sorted[0] ?? NaN,
|
|
9106
|
+
p25: quantileFloorIndex(sorted, .25),
|
|
9107
|
+
p75: quantileFloorIndex(sorted, .75)
|
|
9108
|
+
};
|
|
9109
|
+
};
|
|
9110
|
+
const classificationOf = (run) => run.proofReuse.available ? run.proofReuse.value.classification : "unclassified";
|
|
9111
|
+
const aggregateCohorts = (runs) => {
|
|
9112
|
+
const cells = /* @__PURE__ */ new Map();
|
|
9113
|
+
for (const run of runs) {
|
|
9114
|
+
if (run.kind !== "analyzed") continue;
|
|
9115
|
+
const key = `${run.cohort}\0${classificationOf(run)}`;
|
|
9116
|
+
const cell = cells.get(key) ?? [];
|
|
9117
|
+
cell.push(run);
|
|
9118
|
+
cells.set(key, cell);
|
|
9119
|
+
}
|
|
9120
|
+
return [...cells.entries()].map(([key, cellRuns]) => {
|
|
9121
|
+
const [cohort = UNGROUPED_COHORT] = key.split("\0");
|
|
9122
|
+
const totals = cellRuns.flatMap((run) => run.durations.totalMs.available ? [run.durations.totalMs.value] : []);
|
|
9123
|
+
const excludedRunIds = cellRuns.filter((run) => !run.durations.totalMs.available).map((run) => run.runId);
|
|
9124
|
+
return {
|
|
9125
|
+
classification: classificationOf(cellRuns[0]),
|
|
9126
|
+
cohort,
|
|
9127
|
+
count: cellRuns.length,
|
|
9128
|
+
excludedRunIds,
|
|
9129
|
+
runIds: cellRuns.map((run) => run.runId),
|
|
9130
|
+
totalDurationMs: totals.length === 0 ? unavailable("no run in this cell has an available total duration") : available(quantileSummary(totals))
|
|
9131
|
+
};
|
|
9132
|
+
}).toSorted((left, right) => `${left.cohort}\0${left.classification}`.localeCompare(`${right.cohort}\0${right.classification}`));
|
|
9133
|
+
};
|
|
9134
|
+
const cohortWarnings = (runs) => {
|
|
9135
|
+
const warnings = [];
|
|
9136
|
+
const byCohort = /* @__PURE__ */ new Map();
|
|
9137
|
+
for (const run of runs) {
|
|
9138
|
+
if (run.kind !== "analyzed") continue;
|
|
9139
|
+
const cohort = byCohort.get(run.cohort) ?? [];
|
|
9140
|
+
cohort.push(run);
|
|
9141
|
+
byCohort.set(run.cohort, cohort);
|
|
9142
|
+
}
|
|
9143
|
+
for (const [cohort, cohortRuns] of [...byCohort.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
9144
|
+
const workflows = [...new Set(cohortRuns.flatMap((run) => run.workflowName.available ? [run.workflowName.value] : []))].toSorted();
|
|
9145
|
+
if (workflows.length > 1) warnings.push(`cohort "${cohort}" spans workflow shapes: ${workflows.join(", ")} — durations across different workflows are not comparable`);
|
|
9146
|
+
const models = [...new Set(cohortRuns.flatMap((run) => run.cpuModels.available ? run.cpuModels.value : []))].toSorted();
|
|
9147
|
+
if (models.length > 1) warnings.push(`cohort "${cohort}" spans CPU models: ${models.join(", ")} — stratify by cpuModel before comparing (see the CI runner analysis docs)`);
|
|
9148
|
+
}
|
|
9149
|
+
return warnings;
|
|
9150
|
+
};
|
|
9151
|
+
/**
|
|
9152
|
+
* Analyze the requested runs. Read-only: the client is only ever asked to GET.
|
|
9153
|
+
* One unreadable run never aborts the report — it is carried as an explicit
|
|
9154
|
+
* `unreadable` entry so a partially-served API answer stays auditable.
|
|
9155
|
+
*/
|
|
9156
|
+
const analyzeCiRuns = async (request, client, dependencies = {}) => {
|
|
9157
|
+
if (request.runs.length === 0) throw new Error("ci:analyze requires at least one run id (--run, --runs-file, or --cohort).");
|
|
9158
|
+
const runs = [];
|
|
9159
|
+
for (const entry of request.runs) runs.push(await analyzeOneRun(client, request, entry));
|
|
9160
|
+
return {
|
|
9161
|
+
cohorts: aggregateCohorts(runs),
|
|
9162
|
+
generatedAt: (dependencies.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
9163
|
+
repository: request.repository,
|
|
9164
|
+
runs,
|
|
9165
|
+
schemaVersion: 1,
|
|
9166
|
+
tool: CI_ANALYZE_TOOL,
|
|
9167
|
+
warnings: cohortWarnings(runs)
|
|
9168
|
+
};
|
|
9169
|
+
};
|
|
9170
|
+
const formatSeconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
9171
|
+
const formatMaybeDuration = (value) => value.available ? formatSeconds(value.value) : `unavailable (${value.reason})`;
|
|
9172
|
+
const CLASSIFICATION_HEADINGS = {
|
|
9173
|
+
"full-fallback": "full hosted fallback",
|
|
9174
|
+
mixed: "mixed proof outcomes",
|
|
9175
|
+
"proof-reuse": "proof reuse",
|
|
9176
|
+
unclassified: "no proof-gate signal"
|
|
9177
|
+
};
|
|
9178
|
+
const renderRunLine = (run) => {
|
|
9179
|
+
const lines = [
|
|
9180
|
+
` run ${run.runId} — ${run.workflowName.available ? run.workflowName.value : "unknown workflow"} (${run.conclusion.available ? run.conclusion.value : "no conclusion"})`,
|
|
9181
|
+
` total ${formatMaybeDuration(run.durations.totalMs)}, queue ${formatMaybeDuration(run.durations.queueMs)}`,
|
|
9182
|
+
` ${run.url.available ? run.url.value : "run URL unavailable"}`
|
|
9183
|
+
];
|
|
9184
|
+
if (run.cpuModels.available) lines.push(` cpu: ${run.cpuModels.value.join(", ")}`);
|
|
9185
|
+
if (run.profiles.available) for (const profile of run.profiles.value) {
|
|
9186
|
+
if (profile.kind === "unreadable") {
|
|
9187
|
+
lines.push(` profile ${profile.artifactName}: unreadable (${profile.reason})`);
|
|
9188
|
+
continue;
|
|
9189
|
+
}
|
|
9190
|
+
const p = profile.profile;
|
|
9191
|
+
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"}`);
|
|
9192
|
+
const [slowFile] = p.slowFiles;
|
|
9193
|
+
if (slowFile) lines.push(` slowest file ${slowFile.path} (${formatSeconds(slowFile.medianMs)} median)`);
|
|
9194
|
+
const [slowTest] = p.slowTests;
|
|
9195
|
+
if (slowTest) lines.push(` slowest test ${slowTest.name} (${formatSeconds(slowTest.medianMs)} median)`);
|
|
9196
|
+
}
|
|
9197
|
+
return lines;
|
|
9198
|
+
};
|
|
9199
|
+
/**
|
|
9200
|
+
* Human rendering. Structure mirrors the aggregation rule: one section per
|
|
9201
|
+
* cohort × classification cell, so full fallback and proof reuse are never
|
|
9202
|
+
* visually blended, and before/after cohorts stay separate.
|
|
9203
|
+
*/
|
|
9204
|
+
const renderCiAnalyzeReport = (report) => {
|
|
9205
|
+
const lines = [`ci:analyze — ${report.repository} (schema v${report.schemaVersion})`];
|
|
9206
|
+
const analyzed = report.runs.filter((run) => run.kind === "analyzed");
|
|
9207
|
+
for (const cohort of report.cohorts) {
|
|
9208
|
+
lines.push("", `cohort "${cohort.cohort}" — ${CLASSIFICATION_HEADINGS[cohort.classification]} (${cohort.count} run(s))`);
|
|
9209
|
+
if (cohort.totalDurationMs.available) {
|
|
9210
|
+
const stats = cohort.totalDurationMs.value;
|
|
9211
|
+
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)}`);
|
|
9212
|
+
} else lines.push(` total duration: unavailable (${cohort.totalDurationMs.reason})`);
|
|
9213
|
+
if (cohort.excludedRunIds.length > 0) lines.push(` excluded from aggregates (total unavailable): ${cohort.excludedRunIds.join(", ")}`);
|
|
9214
|
+
for (const run of analyzed.filter((candidate) => candidate.cohort === cohort.cohort && classificationOf(candidate) === cohort.classification)) lines.push(...renderRunLine(run));
|
|
9215
|
+
}
|
|
9216
|
+
const unreadable = report.runs.filter((run) => run.kind === "unreadable");
|
|
9217
|
+
const unreadableCohorts = [...new Set(unreadable.map((run) => run.cohort))].toSorted((left, right) => left.localeCompare(right));
|
|
9218
|
+
for (const cohort of unreadableCohorts) {
|
|
9219
|
+
lines.push("", `cohort "${cohort}" — unreadable runs:`);
|
|
9220
|
+
for (const run of unreadable.filter((candidate) => candidate.cohort === cohort)) lines.push(` run ${run.runId}: ${run.reason}`);
|
|
9221
|
+
}
|
|
9222
|
+
if (report.warnings.length > 0) {
|
|
9223
|
+
lines.push("", "warnings:");
|
|
9224
|
+
for (const warning of report.warnings) lines.push(` - ${warning}`);
|
|
9225
|
+
}
|
|
9226
|
+
return `${lines.join("\n")}\n`;
|
|
9227
|
+
};
|
|
9228
|
+
//#endregion
|
|
9229
|
+
//#region src/commands/ci-analyze.ts
|
|
9230
|
+
/**
|
|
9231
|
+
* `ci:analyze` command wiring (#647).
|
|
9232
|
+
*
|
|
9233
|
+
* The analysis itself lives in `../ci-analyze.js`; this module owns the flag
|
|
9234
|
+
* grammar, the run-id assembly (`--run`, `--runs-file`, `--cohort`), the
|
|
9235
|
+
* report output paths, and the default `gh`-backed {@link CiAnalyzeClient}.
|
|
9236
|
+
* Authentication is entirely the caller's: every request goes through the
|
|
9237
|
+
* `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
|
|
9238
|
+
* no credential is stored or minted here.
|
|
9239
|
+
*/
|
|
9240
|
+
const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
|
|
9241
|
+
const parseRepository = (value) => {
|
|
9242
|
+
if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
|
|
9243
|
+
return value;
|
|
9244
|
+
};
|
|
9245
|
+
const parseRunId = (value) => {
|
|
9246
|
+
const parsed = Number(value);
|
|
9247
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`run id must be a positive integer, got "${value}"`);
|
|
9248
|
+
return parsed;
|
|
9249
|
+
};
|
|
9250
|
+
const collectRunOption = (value, previous = []) => [...previous, parseRunId(value)];
|
|
9251
|
+
/**
|
|
9252
|
+
* `--cohort label=id,id` — the same `name=value` grammar the existing psf
|
|
9253
|
+
* flags use for structured values, repeatable once per label.
|
|
9254
|
+
*
|
|
9255
|
+
* The label `ungrouped` is reserved: it is what the report calls runs the
|
|
9256
|
+
* caller left unlabeled, so accepting it as a caller cohort would silently
|
|
9257
|
+
* blend a named population with the genuinely unlabeled one.
|
|
9258
|
+
*/
|
|
9259
|
+
const parseCohortOption = (value, previous = []) => {
|
|
9260
|
+
const separator = value.indexOf("=");
|
|
9261
|
+
const label = separator === -1 ? "" : value.slice(0, separator).trim();
|
|
9262
|
+
const ids = separator === -1 ? "" : value.slice(separator + 1);
|
|
9263
|
+
if (label === "" || ids.trim() === "") throw new Error(`--cohort must be <label>=<runId>[,<runId>...], got "${value}"`);
|
|
9264
|
+
if (label === "ungrouped") throw new Error(`--cohort label "${UNGROUPED_COHORT}" is reserved for runs passed without a cohort; pick another label`);
|
|
9265
|
+
return [...previous, {
|
|
9266
|
+
label,
|
|
9267
|
+
runIds: ids.split(",").map((id) => parseRunId(id.trim()))
|
|
9268
|
+
}];
|
|
9269
|
+
};
|
|
9270
|
+
/** One run id per line; blank lines and `#` comments are ignored. */
|
|
9271
|
+
const parseRunsFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map(parseRunId);
|
|
9272
|
+
/**
|
|
9273
|
+
* Assemble the requested run set. A run id may appear in exactly one cohort;
|
|
9274
|
+
* uncohorted ids (from `--run` / `--runs-file`) stay unlabeled. Conflicting
|
|
9275
|
+
* cohort labels for one id are a hard error rather than a silent pick.
|
|
9276
|
+
*/
|
|
9277
|
+
const assembleRunRequests = (input) => {
|
|
9278
|
+
const cohortByRun = /* @__PURE__ */ new Map();
|
|
9279
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) {
|
|
9280
|
+
const existing = cohortByRun.get(runId);
|
|
9281
|
+
if (existing !== void 0 && existing !== cohort.label) throw new Error(`run ${runId} is assigned to both cohort "${existing}" and cohort "${cohort.label}"`);
|
|
9282
|
+
cohortByRun.set(runId, cohort.label);
|
|
9283
|
+
}
|
|
9284
|
+
const ordered = [];
|
|
9285
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9286
|
+
for (const cohort of input.cohorts) for (const runId of cohort.runIds) if (!seen.has(runId)) {
|
|
9287
|
+
seen.add(runId);
|
|
9288
|
+
ordered.push({
|
|
9289
|
+
cohort: cohort.label,
|
|
9290
|
+
runId
|
|
9291
|
+
});
|
|
9292
|
+
}
|
|
9293
|
+
for (const runId of input.runIds) if (!seen.has(runId)) {
|
|
9294
|
+
seen.add(runId);
|
|
9295
|
+
ordered.push({
|
|
9296
|
+
cohort: cohortByRun.get(runId) ?? null,
|
|
9297
|
+
runId
|
|
9298
|
+
});
|
|
9299
|
+
}
|
|
9300
|
+
return ordered;
|
|
9301
|
+
};
|
|
9302
|
+
const GH_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024;
|
|
9303
|
+
const PAGE_SIZE = 100;
|
|
9304
|
+
const MAX_PAGES = 50;
|
|
9305
|
+
/**
|
|
9306
|
+
* Follow GitHub's pagination for one collection endpoint: request pages until
|
|
9307
|
+
* the served rows reach the response's `total_count` (or a page comes back
|
|
9308
|
+
* empty). The merged document keeps the `total_count` field, so if the page
|
|
9309
|
+
* budget is ever exhausted the analyzer's truncation guard reports the
|
|
9310
|
+
* shortfall as explicit unavailability instead of a silent partial read.
|
|
9311
|
+
*/
|
|
9312
|
+
const listActionsCollection = (basePath, key) => {
|
|
9313
|
+
const rows = [];
|
|
9314
|
+
let totalCount;
|
|
9315
|
+
for (let page = 1; page <= MAX_PAGES; page += 1) {
|
|
9316
|
+
const response = runGhJson(["api", `${basePath}?per_page=${PAGE_SIZE}&page=${page}`]);
|
|
9317
|
+
const served = Array.isArray(response[key]) ? response[key] : [];
|
|
9318
|
+
rows.push(...served);
|
|
9319
|
+
totalCount = typeof response.total_count === "number" ? response.total_count : totalCount;
|
|
9320
|
+
if (served.length === 0 || rows.length >= (totalCount ?? 0)) break;
|
|
9321
|
+
}
|
|
9322
|
+
return {
|
|
9323
|
+
[key]: rows,
|
|
9324
|
+
total_count: totalCount ?? rows.length
|
|
9325
|
+
};
|
|
9326
|
+
};
|
|
9327
|
+
/**
|
|
9328
|
+
* A Unix-host entry line: permissions, zip version, host OS, decompressed
|
|
9329
|
+
* size, text/binary flags, method, date, time, then the member name (which
|
|
9330
|
+
* may itself contain spaces). This is the one form GitHub's artifact service
|
|
9331
|
+
* actually emits; other host forms are deliberately *not* supported — they
|
|
9332
|
+
* fail reconciliation below rather than being half-parsed.
|
|
9333
|
+
*/
|
|
9334
|
+
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;
|
|
9335
|
+
/** `Zip file size: 12109 bytes, number of entries: 2` */
|
|
9336
|
+
const ZIP_HEADER_PATTERN = /^Zip file size: \d+ bytes, number of entries: (?<count>\d+)$/u;
|
|
9337
|
+
/** `2 files, 81410 bytes uncompressed, 11689 bytes compressed: 85.6%` */
|
|
9338
|
+
const ZIP_TRAILER_PATTERN = /^(?<count>\d+) files?, \d+ bytes? uncompressed, \d+ bytes? compressed:\s+-?[\d.]+%$/u;
|
|
9339
|
+
/**
|
|
9340
|
+
* Parse zipinfo's default listing and *reconcile* it against the archive's
|
|
9341
|
+
* own declared entry count — fail closed, never a partial view.
|
|
9342
|
+
*
|
|
9343
|
+
* The security property the caps and member policy rest on is that the parsed
|
|
9344
|
+
* set is the WHOLE central directory. A listing line this parser does not
|
|
9345
|
+
* recognize (a FAT-host form, a future zipinfo change) therefore refuses the
|
|
9346
|
+
* whole artifact rather than being skipped: a skipped line would let
|
|
9347
|
+
* `unzip <name>` extract members the caps never counted. Three refusals:
|
|
9348
|
+
* an unrecognized line, a parsed count that differs from the declared count
|
|
9349
|
+
* (every header and trailer declaration must agree — a listing that
|
|
9350
|
+
* contradicts itself has no unambiguous count and is refused), and any
|
|
9351
|
+
* duplicate member name across the full parsed set — a parsed safe name
|
|
9352
|
+
* shadowed by an unparsed or differently-shaped duplicate is exactly the
|
|
9353
|
+
* bypass this closes.
|
|
9354
|
+
*/
|
|
9355
|
+
const parseZipInfoListing = (text) => {
|
|
9356
|
+
const members = [];
|
|
9357
|
+
const declaredCounts = [];
|
|
9358
|
+
for (const line of text.split("\n")) {
|
|
9359
|
+
if (line.trim() === "" || line.startsWith("Archive:")) continue;
|
|
9360
|
+
const header = ZIP_HEADER_PATTERN.exec(line);
|
|
9361
|
+
if (header?.groups) {
|
|
9362
|
+
declaredCounts.push(Number(header.groups.count));
|
|
9363
|
+
continue;
|
|
9364
|
+
}
|
|
9365
|
+
const trailer = ZIP_TRAILER_PATTERN.exec(line);
|
|
9366
|
+
if (trailer?.groups) {
|
|
9367
|
+
declaredCounts.push(Number(trailer.groups.count));
|
|
9368
|
+
continue;
|
|
9369
|
+
}
|
|
9370
|
+
const entry = ZIP_ENTRY_PATTERN.exec(line);
|
|
9371
|
+
if (entry?.groups) {
|
|
9372
|
+
members.push({
|
|
9373
|
+
isSymlink: entry.groups.perms?.startsWith("l") === true,
|
|
9374
|
+
name: entry.groups.name ?? "",
|
|
9375
|
+
sizeBytes: Number(entry.groups.size)
|
|
9376
|
+
});
|
|
9377
|
+
continue;
|
|
9378
|
+
}
|
|
9379
|
+
throw new Error(`zip listing reconciliation failed: unrecognized listing line "${line}"`);
|
|
9380
|
+
}
|
|
9381
|
+
const [declaredEntries] = declaredCounts;
|
|
9382
|
+
if (declaredEntries === void 0) throw new Error("zip listing reconciliation failed: the listing declares no entry count");
|
|
9383
|
+
if (declaredCounts.some((count) => count !== declaredEntries)) throw new Error(`zip listing reconciliation failed: contradictory declared entry counts (${declaredCounts.join(", ")})`);
|
|
9384
|
+
if (members.length !== declaredEntries) throw new Error(`zip listing reconciliation failed: parsed ${members.length} of ${declaredEntries} declared entries`);
|
|
9385
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9386
|
+
for (const member of members) {
|
|
9387
|
+
if (seen.has(member.name)) throw new Error(`zip listing reconciliation failed: duplicate member name "${member.name}"`);
|
|
9388
|
+
seen.add(member.name);
|
|
9389
|
+
}
|
|
9390
|
+
return members;
|
|
9391
|
+
};
|
|
9392
|
+
const MAX_ARTIFACT_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
|
|
9393
|
+
const UNSAFE_MEMBER_CHARACTERS = /[*?[\]]/u;
|
|
9394
|
+
/**
|
|
9395
|
+
* The member policy for artifact zips, as a pure function so the security
|
|
9396
|
+
* decisions are testable without `unzip`. The archive is refused outright —
|
|
9397
|
+
* not partially read — when any member is a symlink, an absolute path, a
|
|
9398
|
+
* `..` traversal, option-shaped (leading `-`), or glob-ambiguous, or when the
|
|
9399
|
+
* archive exceeds the count or decompressed-size caps. What survives is the
|
|
9400
|
+
* `*.json` member list actually handed to extraction.
|
|
9401
|
+
*/
|
|
9402
|
+
const selectProfileZipMembers = (members) => {
|
|
9403
|
+
if (members.length > 512) throw new Error(`artifact zip refused: ${members.length} members exceeds the 512-member cap`);
|
|
9404
|
+
let totalBytes = 0;
|
|
9405
|
+
for (const member of members) {
|
|
9406
|
+
if (member.isSymlink) throw new Error(`artifact zip refused: member "${member.name}" is a symlink`);
|
|
9407
|
+
if (member.name.startsWith("/") || /^[A-Za-z]:/u.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" is an absolute path`);
|
|
9408
|
+
if (member.name.split("/").includes("..")) throw new Error(`artifact zip refused: member "${member.name}" traverses with ..`);
|
|
9409
|
+
if (member.name.startsWith("-")) throw new Error(`artifact zip refused: member "${member.name}" is option-shaped`);
|
|
9410
|
+
if (UNSAFE_MEMBER_CHARACTERS.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" contains glob metacharacters`);
|
|
9411
|
+
if (!Number.isFinite(member.sizeBytes) || member.sizeBytes < 0) throw new Error(`artifact zip refused: member "${member.name}" reports no usable size`);
|
|
9412
|
+
totalBytes += member.sizeBytes;
|
|
9413
|
+
}
|
|
9414
|
+
if (totalBytes > 268435456) throw new Error(`artifact zip refused: ${totalBytes} decompressed bytes exceeds the ${MAX_ARTIFACT_DECOMPRESSED_BYTES}-byte cap`);
|
|
9415
|
+
return members.filter((member) => !member.name.endsWith("/") && member.name.endsWith(".json")).map((member) => member.name).toSorted();
|
|
9416
|
+
};
|
|
9417
|
+
/**
|
|
9418
|
+
* The default client: `gh api` for JSON endpoints (paginated), `gh api` plus
|
|
9419
|
+
* system `unzip` for artifact zips, with every member vetted by
|
|
9420
|
+
* {@link selectProfileZipMembers} before extraction. The exec adapter itself
|
|
9421
|
+
* is deliberately thin and untested networked code — every decision made from
|
|
9422
|
+
* these bytes lives behind {@link CiAnalyzeClient} or the pure policy
|
|
9423
|
+
* functions above, where fixtures drive it.
|
|
9424
|
+
*/
|
|
9425
|
+
const createGhCiAnalyzeClient = (repository) => ({
|
|
9426
|
+
getRun: (runId) => Promise.resolve(runGhJson(["api", `/repos/${repository}/actions/runs/${runId}`])),
|
|
9427
|
+
listArtifacts: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/artifacts`, "artifacts")),
|
|
9428
|
+
listJobs: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/jobs`, "jobs")),
|
|
9429
|
+
readArtifactTextFiles: (artifactId) => {
|
|
9430
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "psf-ci-analyze-"));
|
|
9431
|
+
try {
|
|
9432
|
+
const zip = execFileSync("gh", ["api", `/repos/${repository}/actions/artifacts/${artifactId}/zip`], { maxBuffer: GH_ARTIFACT_MAX_BYTES });
|
|
9433
|
+
const zipPath = path.join(scratch, "artifact.zip");
|
|
9434
|
+
writeFileSync(zipPath, zip);
|
|
9435
|
+
const selected = selectProfileZipMembers(parseZipInfoListing(execFileSync("unzip", ["-Z", zipPath], {
|
|
9436
|
+
encoding: "utf-8",
|
|
9437
|
+
maxBuffer: GH_ARTIFACT_MAX_BYTES
|
|
9438
|
+
})));
|
|
9439
|
+
if (selected.length === 0) return Promise.resolve([]);
|
|
9440
|
+
const extracted = path.join(scratch, "extracted");
|
|
9441
|
+
execFileSync("unzip", [
|
|
9442
|
+
"-o",
|
|
9443
|
+
"-qq",
|
|
9444
|
+
zipPath,
|
|
9445
|
+
"-d",
|
|
9446
|
+
extracted,
|
|
9447
|
+
...selected
|
|
9448
|
+
], { stdio: [
|
|
9449
|
+
"ignore",
|
|
9450
|
+
"ignore",
|
|
9451
|
+
"pipe"
|
|
9452
|
+
] });
|
|
9453
|
+
return Promise.resolve(selected.map((name) => ({
|
|
9454
|
+
name,
|
|
9455
|
+
text: readFileSync(path.join(extracted, name), "utf-8")
|
|
9456
|
+
})));
|
|
9457
|
+
} finally {
|
|
9458
|
+
rmSync(scratch, {
|
|
9459
|
+
force: true,
|
|
9460
|
+
recursive: true
|
|
9461
|
+
});
|
|
9462
|
+
}
|
|
9463
|
+
}
|
|
9464
|
+
});
|
|
9465
|
+
const defaultAction = (request) => analyzeCiRuns(request, createGhCiAnalyzeClient(request.repository));
|
|
9466
|
+
function createCiAnalyzeCommand(output, action = defaultAction) {
|
|
9467
|
+
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) => {
|
|
9468
|
+
const fromFile = options.runsFile === void 0 ? [] : parseRunsFile(readFileSync(path.resolve(options.runsFile), "utf-8"));
|
|
9469
|
+
const report = await action({
|
|
9470
|
+
includeProfiles: options.profiles === true,
|
|
9471
|
+
repository: options.repo,
|
|
9472
|
+
runs: assembleRunRequests({
|
|
9473
|
+
cohorts: options.cohort ?? [],
|
|
9474
|
+
runIds: [...options.run ?? [], ...fromFile]
|
|
9475
|
+
})
|
|
9476
|
+
});
|
|
9477
|
+
if (options.output !== void 0) {
|
|
9478
|
+
const outputPath = path.resolve(options.output);
|
|
9479
|
+
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
9480
|
+
output.stderr.write(`ci:analyze report written to ${outputPath}\n`);
|
|
9481
|
+
}
|
|
9482
|
+
output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCiAnalyzeReport(report));
|
|
9483
|
+
});
|
|
8945
9484
|
}
|
|
8946
9485
|
//#endregion
|
|
8947
9486
|
//#region src/checkout-repository.ts
|
|
@@ -9608,6 +10147,8 @@ const buildRetroEnvelope = (input) => {
|
|
|
9608
10147
|
const RETRO_ENVELOPE_LEDGER_DIR = ".factory-memory/retro-envelope";
|
|
9609
10148
|
const safeEnvelopeName = (agentRunId) => agentRunId.replaceAll(/[^\w.-]/gu, "-").slice(0, 120);
|
|
9610
10149
|
const retroEnvelopePath = (root, agentRunId) => path.join(root, RETRO_ENVELOPE_LEDGER_DIR, `${safeEnvelopeName(agentRunId)}.json`);
|
|
10150
|
+
const CLAUDE_CODE_SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID";
|
|
10151
|
+
const resolveNativeSessionId = (env) => normalizeSessionId(env[CLAUDE_CODE_SESSION_ID_ENV]);
|
|
9611
10152
|
const splitRepo = (repo) => {
|
|
9612
10153
|
const [owner, name] = repo.split("/");
|
|
9613
10154
|
return {
|
|
@@ -9633,11 +10174,11 @@ const otherLedgersExist = (repoRoot) => {
|
|
|
9633
10174
|
const buildAndPersistRetroEnvelope = (input) => {
|
|
9634
10175
|
const env = input.env ?? process.env;
|
|
9635
10176
|
const now = input.now ?? (() => /* @__PURE__ */ new Date());
|
|
9636
|
-
const laneIdentity = resolveGateTimingAgentRunId(env);
|
|
10177
|
+
const laneIdentity = resolveGateTimingAgentRunId(env) ?? resolveNativeSessionId(env);
|
|
9637
10178
|
const agentRunId = laneIdentity ?? `closeout-${safeEnvelopeName(input.epic)}`;
|
|
9638
10179
|
const ledgerPath = gateTimingLedgerPath(input.repoRoot, agentRunId);
|
|
9639
10180
|
const ledger = laneIdentity === void 0 ? [] : readGateTimingLedger(ledgerPath);
|
|
9640
|
-
const claudeSessionId = discoverFactorySessionId(env);
|
|
10181
|
+
const claudeSessionId = discoverFactorySessionId(env) ?? resolveNativeSessionId(env);
|
|
9641
10182
|
const dataGaps = [];
|
|
9642
10183
|
if (laneIdentity === void 0) dataGaps.push("agentRunId is synthetic (no lane run identity exported): keyed to the epic, ledger not folded.");
|
|
9643
10184
|
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.`);
|
|
@@ -9916,6 +10457,7 @@ function createCloseoutCommand(output, dependencies = {}) {
|
|
|
9916
10457
|
});
|
|
9917
10458
|
const retro = buildRetroGate({
|
|
9918
10459
|
codexThreadIds: codexThreadIdsForCloseout(dependencies.env),
|
|
10460
|
+
...dependencies.env === void 0 ? {} : { env: dependencies.env },
|
|
9919
10461
|
epic: options.epic,
|
|
9920
10462
|
...options.issue === void 0 ? {} : { issue: options.issue },
|
|
9921
10463
|
...options.pr === void 0 ? {} : { pr: options.pr },
|
|
@@ -11235,6 +11777,7 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
11235
11777
|
if (error instanceof ImpactStampConfigError) throw error;
|
|
11236
11778
|
impactStamp = conservativeImpactStamp(profile.impact?.targets ?? [], [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
|
|
11237
11779
|
}
|
|
11780
|
+
for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
|
|
11238
11781
|
const vetoedTargets = boundFailingCheckNames({
|
|
11239
11782
|
candidate: {
|
|
11240
11783
|
headSha,
|
|
@@ -11541,7 +12084,7 @@ const runDemandWaive = (args, dependencies = {}) => {
|
|
|
11541
12084
|
* offers no way to declare a demand met, only to waive it on the record.
|
|
11542
12085
|
*/
|
|
11543
12086
|
function createDemandWaiveCommand(_output, action = runDemandWaive) {
|
|
11544
|
-
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",
|
|
12087
|
+
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) => {
|
|
11545
12088
|
action({
|
|
11546
12089
|
cwd: resolveCwdOption(options.cwd),
|
|
11547
12090
|
demand: options.demand,
|
|
@@ -11550,7 +12093,7 @@ function createDemandWaiveCommand(_output, action = runDemandWaive) {
|
|
|
11550
12093
|
pr: options.pr,
|
|
11551
12094
|
rationale: options.rationale
|
|
11552
12095
|
});
|
|
11553
|
-
});
|
|
12096
|
+
}));
|
|
11554
12097
|
}
|
|
11555
12098
|
//#endregion
|
|
11556
12099
|
//#region src/demand-resolution.ts
|
|
@@ -11579,24 +12122,12 @@ const resolveCandidateDemands = ({ changedFiles, profile }) => ({
|
|
|
11579
12122
|
});
|
|
11580
12123
|
/**
|
|
11581
12124
|
* The wave demand that binds one PR under a boundary manifest — moved from the
|
|
11582
|
-
* private resolution inside `pr:merge-check` (#351).
|
|
11583
|
-
*
|
|
11584
|
-
*
|
|
12125
|
+
* private resolution inside `pr:merge-check` (#351). Closing issue links are
|
|
12126
|
+
* authoritative: the PR must close exactly one declared issue in exactly one
|
|
12127
|
+
* wave. A manifest `prs` entry is consulted only when no declared closing link
|
|
12128
|
+
* exists. Every refusal is a named reason, fail closed.
|
|
11585
12129
|
*/
|
|
11586
12130
|
const waveDemandForPullRequest = ({ fetchClosingPullRequests, manifest, owner, pr, repo }) => {
|
|
11587
|
-
const override = manifest.prs?.[String(pr)];
|
|
11588
|
-
if (override) {
|
|
11589
|
-
const wave = manifest.waves.find((candidate) => candidate.name === override);
|
|
11590
|
-
if (!wave) return { reasons: [`PR #${pr} override names unknown wave "${override}".`] };
|
|
11591
|
-
return {
|
|
11592
|
-
demand: {
|
|
11593
|
-
autoMerge: wave.autoMerge === true,
|
|
11594
|
-
review: wave.review,
|
|
11595
|
-
wave: wave.name
|
|
11596
|
-
},
|
|
11597
|
-
reasons: []
|
|
11598
|
-
};
|
|
11599
|
-
}
|
|
11600
12131
|
const matches = manifest.waves.flatMap((wave) => wave.issues.filter((issue) => fetchClosingPullRequests({
|
|
11601
12132
|
issue,
|
|
11602
12133
|
owner,
|
|
@@ -11605,10 +12136,9 @@ const waveDemandForPullRequest = ({ fetchClosingPullRequests, manifest, owner, p
|
|
|
11605
12136
|
issue,
|
|
11606
12137
|
wave
|
|
11607
12138
|
})));
|
|
11608
|
-
if (matches.length
|
|
12139
|
+
if (matches.length > 1) return { reasons: [`PR #${pr} must close exactly one issue in exactly one boundary wave; found ${matches.length} matching issue${matches.length === 1 ? "" : "s"}.`] };
|
|
11609
12140
|
const [match] = matches;
|
|
11610
|
-
if (
|
|
11611
|
-
return {
|
|
12141
|
+
if (match) return {
|
|
11612
12142
|
demand: {
|
|
11613
12143
|
autoMerge: match.wave.autoMerge === true,
|
|
11614
12144
|
review: match.wave.review,
|
|
@@ -11616,6 +12146,18 @@ const waveDemandForPullRequest = ({ fetchClosingPullRequests, manifest, owner, p
|
|
|
11616
12146
|
},
|
|
11617
12147
|
reasons: []
|
|
11618
12148
|
};
|
|
12149
|
+
const fallback = manifest.prs?.[String(pr)];
|
|
12150
|
+
if (!fallback) return { reasons: [`PR #${pr} must close exactly one issue in exactly one boundary wave; found 0 matching issues.`] };
|
|
12151
|
+
const wave = manifest.waves.find((candidate) => candidate.name === fallback);
|
|
12152
|
+
if (!wave) return { reasons: [`PR #${pr} fallback names unknown wave "${fallback}".`] };
|
|
12153
|
+
return {
|
|
12154
|
+
demand: {
|
|
12155
|
+
autoMerge: wave.autoMerge === true,
|
|
12156
|
+
review: wave.review,
|
|
12157
|
+
wave: wave.name
|
|
12158
|
+
},
|
|
12159
|
+
reasons: []
|
|
12160
|
+
};
|
|
11619
12161
|
};
|
|
11620
12162
|
const closingPullRequestsQuery = `query($owner: String!, $repo: String!, $issue: Int!) {
|
|
11621
12163
|
repository(owner: $owner, name: $repo) {
|
|
@@ -11654,20 +12196,32 @@ const defaultFetchIssueBody = ({ issue, owner, repo }) => runGhJson([
|
|
|
11654
12196
|
* closed: an absent, unparseable, or unmatched manifest binds nothing and
|
|
11655
12197
|
* names its refusal.
|
|
11656
12198
|
*/
|
|
11657
|
-
const resolveWaveDemand = ({ epic, fetchClosingPullRequests = defaultFetchClosingPullRequests, fetchIssueBody = defaultFetchIssueBody, owner, pr, repo }) => {
|
|
12199
|
+
const resolveWaveDemand = ({ baseRefName, epic, fetchClosingPullRequests = defaultFetchClosingPullRequests, fetchIssueBody = defaultFetchIssueBody, headRefName, owner, pr, repo }) => {
|
|
11658
12200
|
const parsed = parseBoundaryManifest(fetchIssueBody({
|
|
11659
12201
|
issue: epic,
|
|
11660
12202
|
owner,
|
|
11661
12203
|
repo
|
|
11662
12204
|
}));
|
|
11663
12205
|
if (!parsed.ok) return { reasons: [`Epic #${epic} boundary manifest refused: ${parsed.error}`] };
|
|
11664
|
-
|
|
12206
|
+
const resolution = waveDemandForPullRequest({
|
|
11665
12207
|
fetchClosingPullRequests,
|
|
11666
12208
|
manifest: parsed.manifest,
|
|
11667
12209
|
owner,
|
|
11668
12210
|
pr,
|
|
11669
12211
|
repo
|
|
11670
12212
|
});
|
|
12213
|
+
if (!resolution.demand) return resolution;
|
|
12214
|
+
return {
|
|
12215
|
+
demand: resolution.demand,
|
|
12216
|
+
reasons: pullRequestTopologyReasons({
|
|
12217
|
+
manifest: parsed.manifest,
|
|
12218
|
+
pullRequest: {
|
|
12219
|
+
...baseRefName === void 0 ? {} : { baseRefName },
|
|
12220
|
+
...headRefName === void 0 ? {} : { headRefName },
|
|
12221
|
+
number: pr
|
|
12222
|
+
}
|
|
12223
|
+
})
|
|
12224
|
+
};
|
|
11671
12225
|
};
|
|
11672
12226
|
/**
|
|
11673
12227
|
* Whether recorded typed review evidence satisfies the demanded review rung.
|
|
@@ -12143,10 +12697,10 @@ const describeOrphans = (orphans) => {
|
|
|
12143
12697
|
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>\`.`;
|
|
12144
12698
|
};
|
|
12145
12699
|
/**
|
|
12146
|
-
*
|
|
12147
|
-
*
|
|
12148
|
-
*
|
|
12149
|
-
*
|
|
12700
|
+
* Warns when HQ evidence is waiting for this repository: its own spool or a
|
|
12701
|
+
* sibling location written under an earlier key of its own that it no longer
|
|
12702
|
+
* resolves to (#420). Another repository's spool under the shared root is that
|
|
12703
|
+
* repository's business, never this check's (#446).
|
|
12150
12704
|
*
|
|
12151
12705
|
* Reuses `countHqSpoolWork` (#414) — the same read-only, credential-free
|
|
12152
12706
|
* inspection `hq:flush` itself consults before ever resolving a secret — so
|
|
@@ -12156,19 +12710,23 @@ const describeOrphans = (orphans) => {
|
|
|
12156
12710
|
* the spool resolve exactly one key, so evidence written under an older one is
|
|
12157
12711
|
* invisible to a drain and, before this, to doctor: the failure epic #389 was
|
|
12158
12712
|
* chartered to end is evidence that is neither delivered nor visibly stranded.
|
|
12159
|
-
* An orphan holding pending events
|
|
12160
|
-
* spool
|
|
12713
|
+
* An orphan holding pending events warns for the same reason the repo-keyed
|
|
12714
|
+
* spool does.
|
|
12715
|
+
*
|
|
12716
|
+
* `warning`, not `error` (#659, epic #663 wave 1): a spooled event is real
|
|
12717
|
+
* HQ-delivery lag, but doctor is a read-only diagnostic that must never gain a
|
|
12718
|
+
* flush side-effect, and a preflight gate that treats doctor's exit code as
|
|
12719
|
+
* the bar must not block admission on delivery lag it cannot fix from here.
|
|
12720
|
+
* `psf hq:flush` — a trusted local session, not doctor — is still how the
|
|
12721
|
+
* spool actually drains.
|
|
12161
12722
|
*/
|
|
12162
12723
|
async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
12163
12724
|
const countSpool = dependencies.countSpool ?? countHqSpoolWork;
|
|
12164
12725
|
const sweepOrphans = dependencies.sweepOrphans ?? sweepHqSpoolOrphans;
|
|
12165
|
-
const [counted, swept] = await Promise.all([countSpool({
|
|
12166
|
-
cwd: input.cwd,
|
|
12167
|
-
repository: input.repository
|
|
12168
|
-
}, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
|
|
12726
|
+
const [counted, swept] = await Promise.all([countSpool({ repository: input.repository }, { env: input.env }), sweepOrphans({ repository: input.repository }, { env: input.env })]);
|
|
12169
12727
|
const orphanNote = swept.orphans.length === 0 ? "" : describeOrphans(swept.orphans);
|
|
12170
12728
|
if (counted.pending === 0 && counted.unlistable === 0 && swept.orphans.length === 0) return {
|
|
12171
|
-
message: "HQ spool
|
|
12729
|
+
message: "HQ spool is empty; no evidence is waiting to be drained.",
|
|
12172
12730
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12173
12731
|
status: "ok"
|
|
12174
12732
|
};
|
|
@@ -12177,17 +12735,17 @@ async function hqSpoolDoctorCheck(input, dependencies = {}) {
|
|
|
12177
12735
|
if (counted.pending > 0) return {
|
|
12178
12736
|
message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
|
|
12179
12737
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12180
|
-
status: "
|
|
12738
|
+
status: "warning"
|
|
12181
12739
|
};
|
|
12182
12740
|
if (counted.unlistable > 0) return {
|
|
12183
12741
|
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}`,
|
|
12184
12742
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12185
|
-
status: "
|
|
12743
|
+
status: "warning"
|
|
12186
12744
|
};
|
|
12187
12745
|
return {
|
|
12188
12746
|
message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
|
|
12189
12747
|
name: HQ_SPOOL_CHECK_NAME,
|
|
12190
|
-
status: "
|
|
12748
|
+
status: "warning"
|
|
12191
12749
|
};
|
|
12192
12750
|
}
|
|
12193
12751
|
const readJsonFiles = async (dir, readFileImpl) => {
|
|
@@ -12372,7 +12930,6 @@ async function doctorProjectProfile(input = {}) {
|
|
|
12372
12930
|
const cwd = input.cwd ?? process.cwd();
|
|
12373
12931
|
const userConfig = resolveDoctorUserConfig(env, input.userConfig);
|
|
12374
12932
|
const [hqSpoolCheck, hqRetroReadbackCheck] = await Promise.all([hqSpoolDoctorCheck({
|
|
12375
|
-
cwd,
|
|
12376
12933
|
env,
|
|
12377
12934
|
repository: {
|
|
12378
12935
|
owner: profile.repository.owner,
|
|
@@ -12539,7 +13096,7 @@ function repositoryUrlMatches(origin, profile) {
|
|
|
12539
13096
|
//#endregion
|
|
12540
13097
|
//#region src/commands/doctor.ts
|
|
12541
13098
|
function createDoctorCommand(output) {
|
|
12542
|
-
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",
|
|
13099
|
+
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) => {
|
|
12543
13100
|
const report = await doctorProjectProfile({
|
|
12544
13101
|
base: options.base,
|
|
12545
13102
|
cwd: resolveCwdOption(options.cwd),
|
|
@@ -12549,7 +13106,7 @@ function createDoctorCommand(output) {
|
|
|
12549
13106
|
if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
12550
13107
|
else output.stdout.write(formatHumanReport(report));
|
|
12551
13108
|
if (!report.ok) process.exitCode = 1;
|
|
12552
|
-
});
|
|
13109
|
+
}));
|
|
12553
13110
|
}
|
|
12554
13111
|
function formatHumanReport(report) {
|
|
12555
13112
|
return [
|
|
@@ -12957,7 +13514,7 @@ function optionalString(key, flagValue, stdinValue) {
|
|
|
12957
13514
|
return resolved ? { [key]: resolved } : {};
|
|
12958
13515
|
}
|
|
12959
13516
|
function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput = readStdin) {
|
|
12960
|
-
return new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory",
|
|
13517
|
+
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) => {
|
|
12961
13518
|
const cwd = resolveCwdOption(options.cwd);
|
|
12962
13519
|
const fromStdin = options.stdin ? readInput() : {};
|
|
12963
13520
|
rejectRetiredStdinFields(fromStdin);
|
|
@@ -12984,7 +13541,7 @@ function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput =
|
|
|
12984
13541
|
});
|
|
12985
13542
|
output.stdout.write(`evidence:emit wrote envelope for "${result.envelope.check}" to ${result.path}\n`);
|
|
12986
13543
|
if (options.json && "envelope" in result) output.stdout.write(`${JSON.stringify(result.envelope, null, 2)}\n`);
|
|
12987
|
-
});
|
|
13544
|
+
}));
|
|
12988
13545
|
}
|
|
12989
13546
|
//#endregion
|
|
12990
13547
|
//#region src/hq-flush.ts
|
|
@@ -13050,7 +13607,6 @@ async function runHqFlush(args, dependencies = {}) {
|
|
|
13050
13607
|
};
|
|
13051
13608
|
const explicitDirectories = args.dir && args.dir.length > 0 ? { explicitDirectories: args.dir } : {};
|
|
13052
13609
|
const pending = await (dependencies.countSpool ?? countHqSpoolWork)({
|
|
13053
|
-
cwd: args.cwd,
|
|
13054
13610
|
...explicitDirectories,
|
|
13055
13611
|
repository
|
|
13056
13612
|
}, { env });
|
|
@@ -13095,7 +13651,6 @@ async function runHqFlush(args, dependencies = {}) {
|
|
|
13095
13651
|
...await (dependencies.flush ?? flushHqSpool)({
|
|
13096
13652
|
clientId: resolution.credentials.clientId,
|
|
13097
13653
|
clientSecret: resolution.credentials.clientSecret,
|
|
13098
|
-
cwd: args.cwd,
|
|
13099
13654
|
endpoint: profile.hq.endpoint,
|
|
13100
13655
|
...explicitDirectories,
|
|
13101
13656
|
repository
|
|
@@ -13122,7 +13677,7 @@ function renderHqFlush(result) {
|
|
|
13122
13677
|
`hq:flush ${result.endpoint}`,
|
|
13123
13678
|
...result.spools.length === 0 ? ["no spool directory found"] : result.spools.map((spool) => `spool: ${spool}`),
|
|
13124
13679
|
...result.outcomes.map(outcomeLine),
|
|
13125
|
-
`delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}
|
|
13680
|
+
`delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}`,
|
|
13126
13681
|
...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.`] : [],
|
|
13127
13682
|
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)`,
|
|
13128
13683
|
...orphanLines(result.orphans)
|
|
@@ -13149,7 +13704,7 @@ const hqFlushExitCode = (result) => {
|
|
|
13149
13704
|
//#endregion
|
|
13150
13705
|
//#region src/commands/hq-flush.ts
|
|
13151
13706
|
function createHqFlushCommand(output, action = runHqFlush) {
|
|
13152
|
-
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",
|
|
13707
|
+
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) => {
|
|
13153
13708
|
const result = await action({
|
|
13154
13709
|
cwd: resolveCwdOption(options.cwd),
|
|
13155
13710
|
...options.dir ? { dir: options.dir } : {},
|
|
@@ -13159,7 +13714,7 @@ function createHqFlushCommand(output, action = runHqFlush) {
|
|
|
13159
13714
|
output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
|
|
13160
13715
|
const exitCode = hqFlushExitCode(result);
|
|
13161
13716
|
if (exitCode !== 0) process.exitCode = exitCode;
|
|
13162
|
-
});
|
|
13717
|
+
}));
|
|
13163
13718
|
}
|
|
13164
13719
|
//#endregion
|
|
13165
13720
|
//#region src/follow-up.ts
|
|
@@ -13195,6 +13750,13 @@ const prVerifyFollowUp = (authoringSession) => followUpFromArgv([
|
|
|
13195
13750
|
"pr:verify",
|
|
13196
13751
|
...authoringSession ? ["--authoring-session", authoringSession] : []
|
|
13197
13752
|
]);
|
|
13753
|
+
/** The route-owned Promotion from a GitHub draft into a Candidate. */
|
|
13754
|
+
const prUndraftFollowUp = (pr) => followUpFromArgv([
|
|
13755
|
+
"gh",
|
|
13756
|
+
"pr",
|
|
13757
|
+
"ready",
|
|
13758
|
+
String(pr)
|
|
13759
|
+
]);
|
|
13198
13760
|
/** Canonical zod schema for the optional follow-up field on JSON outputs. */
|
|
13199
13761
|
const FollowUpActionSchema = z.object({
|
|
13200
13762
|
argv: z.array(z.string()),
|
|
@@ -14310,15 +14872,18 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
14310
14872
|
command: `gh pr checks ${input.pr} --watch`
|
|
14311
14873
|
} } : {}
|
|
14312
14874
|
});
|
|
14313
|
-
if (input.draft)
|
|
14314
|
-
|
|
14315
|
-
|
|
14316
|
-
|
|
14317
|
-
|
|
14318
|
-
|
|
14319
|
-
|
|
14320
|
-
|
|
14321
|
-
|
|
14875
|
+
if (input.draft) {
|
|
14876
|
+
const promotion = prUndraftFollowUp(input.pr);
|
|
14877
|
+
blockers.push({
|
|
14878
|
+
demand: DEMAND_KEYS.draft,
|
|
14879
|
+
reason: DRAFT_BLOCKER_REASON,
|
|
14880
|
+
repair: {
|
|
14881
|
+
action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
|
|
14882
|
+
code: "undraft-pr",
|
|
14883
|
+
command: promotion.command
|
|
14884
|
+
}
|
|
14885
|
+
});
|
|
14886
|
+
}
|
|
14322
14887
|
return blockers;
|
|
14323
14888
|
};
|
|
14324
14889
|
const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
|
|
@@ -14677,6 +15242,7 @@ const PR_VIEW_JSON_FIELDS = [
|
|
|
14677
15242
|
"autoMergeRequest",
|
|
14678
15243
|
"baseRefName",
|
|
14679
15244
|
"body",
|
|
15245
|
+
"headRefName",
|
|
14680
15246
|
"headRefOid",
|
|
14681
15247
|
"isDraft",
|
|
14682
15248
|
"mergeStateStatus",
|
|
@@ -14879,9 +15445,11 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
14879
15445
|
});
|
|
14880
15446
|
const { ladderPolicy } = demands;
|
|
14881
15447
|
const waveDemand = args.epic === void 0 ? void 0 : resolveWaveDemand({
|
|
15448
|
+
baseRefName: pr.baseRefName,
|
|
14882
15449
|
epic: args.epic,
|
|
14883
15450
|
...github.fetchClosingPullRequests ? { fetchClosingPullRequests: github.fetchClosingPullRequests } : {},
|
|
14884
15451
|
...github.fetchIssueBody ? { fetchIssueBody: github.fetchIssueBody } : {},
|
|
15452
|
+
headRefName: pr.headRefName,
|
|
14885
15453
|
owner: repository.owner,
|
|
14886
15454
|
pr: args.pr,
|
|
14887
15455
|
repo: repository.name
|
|
@@ -14982,12 +15550,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
14982
15550
|
]);
|
|
14983
15551
|
let followUp;
|
|
14984
15552
|
if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
|
|
14985
|
-
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp =
|
|
14986
|
-
"gh",
|
|
14987
|
-
"pr",
|
|
14988
|
-
"ready",
|
|
14989
|
-
String(args.pr)
|
|
14990
|
-
]);
|
|
15553
|
+
else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = prUndraftFollowUp(args.pr);
|
|
14991
15554
|
else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
|
|
14992
15555
|
const notices = [
|
|
14993
15556
|
...evaluation.notices,
|
|
@@ -15530,6 +16093,15 @@ const defaultPrPublishCliInvocation = () => {
|
|
|
15530
16093
|
};
|
|
15531
16094
|
const prPublishCliInvocation = (explicit) => explicit ?? defaultPrPublishCliInvocation();
|
|
15532
16095
|
const errorMessage = (error) => error instanceof Error ? error.message : String(error);
|
|
16096
|
+
/**
|
|
16097
|
+
* GitHub creates a pull request against a branch name, while the rest of the
|
|
16098
|
+
* publish transaction treats `--base` as an exact local Git-ref assertion.
|
|
16099
|
+
* Keep those representations separate: only the creation adapter drops the
|
|
16100
|
+
* canonical remote qualifier, and callers downstream retain the original ref.
|
|
16101
|
+
*/
|
|
16102
|
+
function githubCreationBranchForBaseAssertion(base) {
|
|
16103
|
+
return base.startsWith("origin/") ? base.slice(7) : base;
|
|
16104
|
+
}
|
|
15533
16105
|
function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
|
|
15534
16106
|
if (args.pr !== void 0) return args.pr;
|
|
15535
16107
|
if (!branch) throw new Error("pr:publish cannot discover a PR from a detached HEAD; pass --pr.");
|
|
@@ -15545,12 +16117,14 @@ function discoverOrCreatePullRequestNumber({ args, branch, cwd, gh }) {
|
|
|
15545
16117
|
];
|
|
15546
16118
|
const existing = JSON.parse(gh(lookupArgs, cwd));
|
|
15547
16119
|
if (existing[0]) return existing[0].number;
|
|
15548
|
-
|
|
16120
|
+
const createArgs = [
|
|
15549
16121
|
"pr",
|
|
15550
16122
|
"create",
|
|
15551
16123
|
"--draft",
|
|
15552
16124
|
"--fill"
|
|
15553
|
-
]
|
|
16125
|
+
];
|
|
16126
|
+
if (args.base.length > 0) createArgs.push("--base", githubCreationBranchForBaseAssertion(args.base));
|
|
16127
|
+
gh(createArgs, cwd);
|
|
15554
16128
|
const created = JSON.parse(gh(lookupArgs, cwd));
|
|
15555
16129
|
if (!created[0]) throw new Error(`gh pr create completed but no PR was found for branch ${branch}.`);
|
|
15556
16130
|
return created[0].number;
|
|
@@ -15786,6 +16360,50 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
|
|
|
15786
16360
|
...args,
|
|
15787
16361
|
throwWhenBlocked: false
|
|
15788
16362
|
}, dependencies);
|
|
16363
|
+
const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
|
|
16364
|
+
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.`);
|
|
16365
|
+
};
|
|
16366
|
+
/**
|
|
16367
|
+
* Promote a proved draft before the first Candidate demand evaluation (#649).
|
|
16368
|
+
* The route-owned action is shared with pr:ready; this helper only orders it.
|
|
16369
|
+
*/
|
|
16370
|
+
const promoteDraftBeforeDemandEvaluation = async ({ cwd, expectedHeadSha, factoryCliInvocation, fetchPr, pr, prSnapshot, runFollowUp }) => {
|
|
16371
|
+
if (!prSnapshot.isDraft) return { status: "not-requested" };
|
|
16372
|
+
const before = fetchPr();
|
|
16373
|
+
requirePromotionHead({
|
|
16374
|
+
actualHeadSha: before.headRefOid,
|
|
16375
|
+
expectedHeadSha,
|
|
16376
|
+
phase: "Promotion refused before transition"
|
|
16377
|
+
});
|
|
16378
|
+
if (!before.isDraft) return { status: "not-requested" };
|
|
16379
|
+
const promotion = prUndraftFollowUp(pr);
|
|
16380
|
+
try {
|
|
16381
|
+
await (runFollowUp ?? defaultFollowUpRunner(factoryCliInvocation))(promotion, cwd);
|
|
16382
|
+
} catch (error) {
|
|
16383
|
+
throw new PrPublishTransactionAbortedError(`Promotion failed (${promotion.command}): ${errorMessage(error)}`);
|
|
16384
|
+
}
|
|
16385
|
+
const after = fetchPr();
|
|
16386
|
+
requirePromotionHead({
|
|
16387
|
+
actualHeadSha: after.headRefOid,
|
|
16388
|
+
expectedHeadSha,
|
|
16389
|
+
phase: "Promotion invalidated during transition"
|
|
16390
|
+
});
|
|
16391
|
+
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.`);
|
|
16392
|
+
return {
|
|
16393
|
+
action: promotion,
|
|
16394
|
+
reason: "Promotion precedes Candidate demand evaluation on the unchanged head",
|
|
16395
|
+
status: "succeeded"
|
|
16396
|
+
};
|
|
16397
|
+
};
|
|
16398
|
+
const requirePromotedProofHead = ({ expectedHeadSha, promotion, proof }) => {
|
|
16399
|
+
if (promotion.status !== "succeeded") return;
|
|
16400
|
+
requirePromotionHead({
|
|
16401
|
+
actualHeadSha: proof.ledger.headSha,
|
|
16402
|
+
expectedHeadSha,
|
|
16403
|
+
phase: "Promotion invalidated before Candidate demand evaluation completed"
|
|
16404
|
+
});
|
|
16405
|
+
};
|
|
16406
|
+
const candidateFollowUpAfterPromotion = (promotion, proof) => promotion.status === "not-requested" ? proof.followUp : void 0;
|
|
15789
16407
|
/**
|
|
15790
16408
|
* A proof with no follow-up that is not `ready` means `pr:ready` found the
|
|
15791
16409
|
* candidate blocked solely on a pending hosted check for the family excluded
|
|
@@ -15920,6 +16538,11 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15920
16538
|
reviewProofPath
|
|
15921
16539
|
});
|
|
15922
16540
|
const pr = fetchPr();
|
|
16541
|
+
requirePromotionHead({
|
|
16542
|
+
actualHeadSha: pr.headRefOid,
|
|
16543
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16544
|
+
phase: "Publish refused before managed body update"
|
|
16545
|
+
});
|
|
15923
16546
|
const parts = renderPrBodySectionParts({
|
|
15924
16547
|
reviewProof,
|
|
15925
16548
|
verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
|
|
@@ -15944,13 +16567,28 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15944
16567
|
json: false,
|
|
15945
16568
|
report: false
|
|
15946
16569
|
};
|
|
16570
|
+
const promotion = await promoteDraftBeforeDemandEvaluation({
|
|
16571
|
+
cwd,
|
|
16572
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16573
|
+
factoryCliInvocation,
|
|
16574
|
+
fetchPr,
|
|
16575
|
+
pr: prNumber,
|
|
16576
|
+
prSnapshot: pr,
|
|
16577
|
+
runFollowUp: dependencies.runFollowUp
|
|
16578
|
+
});
|
|
15947
16579
|
const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
16580
|
+
requirePromotedProofHead({
|
|
16581
|
+
expectedHeadSha: verifyProof.headSha,
|
|
16582
|
+
promotion,
|
|
16583
|
+
proof: initialProof
|
|
16584
|
+
});
|
|
15948
16585
|
const initialHandoff = summarizeHumanHandoff(initialProof);
|
|
15949
|
-
let followUp =
|
|
16586
|
+
let followUp = promotion;
|
|
15950
16587
|
let currentProof = initialProof;
|
|
15951
|
-
|
|
16588
|
+
const candidateFollowUp = candidateFollowUpAfterPromotion(promotion, initialProof);
|
|
16589
|
+
if (candidateFollowUp) {
|
|
15952
16590
|
followUp = await settleFollowUp({
|
|
15953
|
-
action:
|
|
16591
|
+
action: candidateFollowUp,
|
|
15954
16592
|
cwd,
|
|
15955
16593
|
factoryCliInvocation,
|
|
15956
16594
|
onFailure: (failed, error) => {
|
|
@@ -15965,13 +16603,13 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
15965
16603
|
},
|
|
15966
16604
|
plan: planPublishFollowUp({
|
|
15967
16605
|
binding: verifyProofBinding,
|
|
15968
|
-
followUp:
|
|
16606
|
+
followUp: candidateFollowUp,
|
|
15969
16607
|
proofHeadSha: verifyProof.headSha,
|
|
15970
16608
|
verifiedHeadSha: verified.verifiedHeadSha
|
|
15971
16609
|
}),
|
|
15972
16610
|
runFollowUp: dependencies.runFollowUp
|
|
15973
16611
|
});
|
|
15974
|
-
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(
|
|
16612
|
+
if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(candidateFollowUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
15975
16613
|
}
|
|
15976
16614
|
const proof = await awaitCurrentReadyVerdict({
|
|
15977
16615
|
args: readyEvalArgs,
|
|
@@ -16022,7 +16660,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
|
|
|
16022
16660
|
//#endregion
|
|
16023
16661
|
//#region src/commands/pr-publish.ts
|
|
16024
16662
|
function createPrPublishCommand(_output, action = runPrPublish) {
|
|
16025
|
-
return new Command("pr:publish").description("
|
|
16663
|
+
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) => {
|
|
16026
16664
|
await action({
|
|
16027
16665
|
authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
|
|
16028
16666
|
base: options.base ?? "",
|
|
@@ -16036,12 +16674,12 @@ function createPrPublishCommand(_output, action = runPrPublish) {
|
|
|
16036
16674
|
reviewProof: options.reviewProof,
|
|
16037
16675
|
verifyProof: options.verifyProof
|
|
16038
16676
|
});
|
|
16039
|
-
});
|
|
16677
|
+
}));
|
|
16040
16678
|
}
|
|
16041
16679
|
//#endregion
|
|
16042
16680
|
//#region src/commands/pr-ready.ts
|
|
16043
16681
|
function createPrReadyCommand(output, action) {
|
|
16044
|
-
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",
|
|
16682
|
+
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({
|
|
16045
16683
|
gate: "pr:ready",
|
|
16046
16684
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
16047
16685
|
stderr: output.stderr
|
|
@@ -16059,7 +16697,7 @@ function createPrReadyCommand(output, action) {
|
|
|
16059
16697
|
verifyProof: options.verifyProof
|
|
16060
16698
|
};
|
|
16061
16699
|
await (action ? action(args) : runPrReady(args));
|
|
16062
|
-
}));
|
|
16700
|
+
})));
|
|
16063
16701
|
}
|
|
16064
16702
|
//#endregion
|
|
16065
16703
|
//#region src/gate-foreground-guard.ts
|
|
@@ -16088,7 +16726,7 @@ function assertForegroundGate({ gate, env = process.env }) {
|
|
|
16088
16726
|
//#endregion
|
|
16089
16727
|
//#region src/commands/pr-review.ts
|
|
16090
16728
|
function createPrReviewCommand(output, action) {
|
|
16091
|
-
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",
|
|
16729
|
+
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({
|
|
16092
16730
|
gate: "pr:review",
|
|
16093
16731
|
resolveCycle: (options) => options.cycle,
|
|
16094
16732
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
@@ -16113,12 +16751,12 @@ function createPrReviewCommand(output, action) {
|
|
|
16113
16751
|
verifyProof: options.verifyProof
|
|
16114
16752
|
};
|
|
16115
16753
|
await (action ? action(args) : runPrReview(args, { publishCheckRun: createFactoryCheckPublisher(output) }));
|
|
16116
|
-
}));
|
|
16754
|
+
})));
|
|
16117
16755
|
}
|
|
16118
16756
|
//#endregion
|
|
16119
16757
|
//#region src/commands/pr-verify.ts
|
|
16120
16758
|
function createPrVerifyCommand(output, action = runPrVerify) {
|
|
16121
|
-
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",
|
|
16759
|
+
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({
|
|
16122
16760
|
gate: "pr:verify",
|
|
16123
16761
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
16124
16762
|
stderr: output.stderr
|
|
@@ -16140,7 +16778,7 @@ function createPrVerifyCommand(output, action = runPrVerify) {
|
|
|
16140
16778
|
requireKnownAuthoringSession: true
|
|
16141
16779
|
}, dependencies);
|
|
16142
16780
|
if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
|
|
16143
|
-
}));
|
|
16781
|
+
})));
|
|
16144
16782
|
}
|
|
16145
16783
|
function modeFor(options) {
|
|
16146
16784
|
if (options.docsOnly) return "docs-only";
|
|
@@ -16148,6 +16786,146 @@ function modeFor(options) {
|
|
|
16148
16786
|
if (options.full) return "full";
|
|
16149
16787
|
return "auto";
|
|
16150
16788
|
}
|
|
16789
|
+
const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
|
|
16790
|
+
const ZERO_SHA_PATTERN = /^0{40}$/u;
|
|
16791
|
+
const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
|
|
16792
|
+
basis: reason,
|
|
16793
|
+
name,
|
|
16794
|
+
state: "demanded"
|
|
16795
|
+
}));
|
|
16796
|
+
const refusal = ({ after, before, profile, reason }) => ({
|
|
16797
|
+
...after ? { afterSha: after } : {},
|
|
16798
|
+
basis: {
|
|
16799
|
+
changedFiles: [],
|
|
16800
|
+
classification: "refused",
|
|
16801
|
+
reasons: [reason]
|
|
16802
|
+
},
|
|
16803
|
+
...before ? { beforeSha: before } : {},
|
|
16804
|
+
decision: "refused",
|
|
16805
|
+
schemaVersion: 1,
|
|
16806
|
+
targets: demandedTargets(profile, reason),
|
|
16807
|
+
unsubscribedPaths: []
|
|
16808
|
+
});
|
|
16809
|
+
const assertCommitIdentity = (value, name) => {
|
|
16810
|
+
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`);
|
|
16811
|
+
return value.toLowerCase();
|
|
16812
|
+
};
|
|
16813
|
+
const assertReachableCommit = (cwd, sha, name) => {
|
|
16814
|
+
try {
|
|
16815
|
+
runCapture("git", [
|
|
16816
|
+
"cat-file",
|
|
16817
|
+
"-e",
|
|
16818
|
+
`${sha}^{commit}`
|
|
16819
|
+
], cwd);
|
|
16820
|
+
} catch {
|
|
16821
|
+
throw new Error(`${name} commit ${sha} is not reachable in this checkout`);
|
|
16822
|
+
}
|
|
16823
|
+
};
|
|
16824
|
+
const writeGithubOutputs = (outputPath, decision) => {
|
|
16825
|
+
appendFileSync(outputPath, `${[
|
|
16826
|
+
`decision=${decision.decision}`,
|
|
16827
|
+
`basis=${JSON.stringify(decision.basis)}`,
|
|
16828
|
+
`unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
|
|
16829
|
+
...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
|
|
16830
|
+
].join("\n")}\n`, "utf-8");
|
|
16831
|
+
};
|
|
16832
|
+
/**
|
|
16833
|
+
* Recompute impact from one merge push's exact before/after commits. The
|
|
16834
|
+
* result may withdraw consumer work only; every doubt path returns a refused
|
|
16835
|
+
* decision whose known targets remain demanded.
|
|
16836
|
+
*/
|
|
16837
|
+
const runProductionImpact = (args) => {
|
|
16838
|
+
const cwd = path.resolve(args.cwd ?? process.cwd());
|
|
16839
|
+
let profile;
|
|
16840
|
+
let profilePath;
|
|
16841
|
+
let decision;
|
|
16842
|
+
try {
|
|
16843
|
+
const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
|
|
16844
|
+
cwd,
|
|
16845
|
+
profilePath: args.profilePath
|
|
16846
|
+
});
|
|
16847
|
+
profile = loadedProfile;
|
|
16848
|
+
profilePath = loadedPath;
|
|
16849
|
+
const before = assertCommitIdentity(args.before, "before");
|
|
16850
|
+
const after = assertCommitIdentity(args.after, "after");
|
|
16851
|
+
assertReachableCommit(cwd, before, "before");
|
|
16852
|
+
assertReachableCommit(cwd, after, "after");
|
|
16853
|
+
const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
|
|
16854
|
+
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`);
|
|
16855
|
+
try {
|
|
16856
|
+
runCapture("git", [
|
|
16857
|
+
"merge-base",
|
|
16858
|
+
"--is-ancestor",
|
|
16859
|
+
before,
|
|
16860
|
+
after
|
|
16861
|
+
], cwd);
|
|
16862
|
+
} catch {
|
|
16863
|
+
throw new Error(`before commit ${before} is not an ancestor of after commit ${after}`);
|
|
16864
|
+
}
|
|
16865
|
+
const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
|
|
16866
|
+
"diff",
|
|
16867
|
+
"--find-renames",
|
|
16868
|
+
"--name-status",
|
|
16869
|
+
before,
|
|
16870
|
+
after
|
|
16871
|
+
], cwd).stdout))].toSorted();
|
|
16872
|
+
const stamp = computeImpactStamp({
|
|
16873
|
+
changedFiles,
|
|
16874
|
+
profile,
|
|
16875
|
+
profilePath: path.relative(cwd, profilePath),
|
|
16876
|
+
readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, path.join(cwd, LOCKFILE_PATH))
|
|
16877
|
+
});
|
|
16878
|
+
const usable = stamp.basis === "target-scoped";
|
|
16879
|
+
decision = {
|
|
16880
|
+
afterSha: after,
|
|
16881
|
+
basis: {
|
|
16882
|
+
changedFiles,
|
|
16883
|
+
classification: stamp.basis,
|
|
16884
|
+
reasons: stamp.reasons
|
|
16885
|
+
},
|
|
16886
|
+
beforeSha: before,
|
|
16887
|
+
decision: usable ? "usable" : "refused",
|
|
16888
|
+
schemaVersion: 1,
|
|
16889
|
+
targets: stamp.targets.map((target) => ({
|
|
16890
|
+
basis: target.basis,
|
|
16891
|
+
name: target.name,
|
|
16892
|
+
state: usable && target.impact === "not-affected" ? "withdrawn" : "demanded"
|
|
16893
|
+
})),
|
|
16894
|
+
unsubscribedPaths: stamp.unsubscribedPaths
|
|
16895
|
+
};
|
|
16896
|
+
} catch (error) {
|
|
16897
|
+
decision = refusal({
|
|
16898
|
+
after: args.after,
|
|
16899
|
+
before: args.before,
|
|
16900
|
+
profile,
|
|
16901
|
+
reason: `production impact classification refused: ${error instanceof Error ? error.message : String(error)}`
|
|
16902
|
+
});
|
|
16903
|
+
}
|
|
16904
|
+
if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
|
|
16905
|
+
if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
|
|
16906
|
+
return decision;
|
|
16907
|
+
};
|
|
16908
|
+
const productionImpactSummaryLines = (decision) => decision.basis.classification === "refused" ? [
|
|
16909
|
+
"Production impact: refused; all consumer work remains demanded.",
|
|
16910
|
+
...decision.basis.reasons,
|
|
16911
|
+
"Unsubscribed changed paths: unavailable because classification was refused."
|
|
16912
|
+
] : [`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(", ")}`];
|
|
16913
|
+
//#endregion
|
|
16914
|
+
//#region src/commands/production-impact.ts
|
|
16915
|
+
function createProductionImpactCommand(output) {
|
|
16916
|
+
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) => {
|
|
16917
|
+
const decision = runProductionImpact({
|
|
16918
|
+
after: options.after,
|
|
16919
|
+
before: options.before,
|
|
16920
|
+
cwd: resolveCwdOption(options.cwd),
|
|
16921
|
+
githubOutput: options.githubOutput,
|
|
16922
|
+
githubSummary: options.githubSummary,
|
|
16923
|
+
profilePath: options.profile
|
|
16924
|
+
});
|
|
16925
|
+
if (options.json) output.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
|
|
16926
|
+
else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
|
|
16927
|
+
}));
|
|
16928
|
+
}
|
|
16151
16929
|
//#endregion
|
|
16152
16930
|
//#region src/worktree-scratch-files.ts
|
|
16153
16931
|
var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -16435,9 +17213,11 @@ function createProgram(options = {}) {
|
|
|
16435
17213
|
program.addCommand(createDoctorCommand(output));
|
|
16436
17214
|
program.addCommand(createConfigCommand(output));
|
|
16437
17215
|
program.addCommand(createEvidenceEmitCommand(output));
|
|
17216
|
+
program.addCommand(createCiAnalyzeCommand(output));
|
|
16438
17217
|
program.addCommand(createHqFlushCommand(output));
|
|
16439
17218
|
program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
|
|
16440
17219
|
program.addCommand(createPrVerifyCommand(output));
|
|
17220
|
+
program.addCommand(createProductionImpactCommand(output));
|
|
16441
17221
|
program.addCommand(createCloseoutCommand(output));
|
|
16442
17222
|
program.addCommand(createEpicPublishStructureCommand(output));
|
|
16443
17223
|
program.addCommand(createPrReviewCommand(output, options.actions?.prReview));
|