@patronage/software-factory 1.0.0-alpha.27 → 1.0.0-alpha.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +169 -44
- package/dist/index.js +406 -44
- package/dist/schemas.d.ts +52 -1
- package/dist/schemas.js +11 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { FACTORY_APP_BROKER_ORIGIN, FACTORY_APP_BROKER_PATH, FACTORY_CURSOR_OIDC
|
|
|
18
18
|
import http from "node:http";
|
|
19
19
|
import { pathToFileURL } from "node:url";
|
|
20
20
|
//#region package.json
|
|
21
|
-
var version = "1.0.0-alpha.
|
|
21
|
+
var version = "1.0.0-alpha.29";
|
|
22
22
|
//#endregion
|
|
23
23
|
//#region src/cli-entry.ts
|
|
24
24
|
/**
|
|
@@ -6576,10 +6576,48 @@ function verifyOutput(proof) {
|
|
|
6576
6576
|
title: `pr:verify ${value.outcome ?? "unknown"}`
|
|
6577
6577
|
};
|
|
6578
6578
|
}
|
|
6579
|
+
/**
|
|
6580
|
+
* The refusals to name, preferring the attributed projection (#391) so each
|
|
6581
|
+
* line carries its demand code. `blockingReasons` is the fallback because the
|
|
6582
|
+
* throwing path in `pr:ready` publishes a proof that carries only that field.
|
|
6583
|
+
* The two projections hold the same refusals in the same order, so reading
|
|
6584
|
+
* either one tells the same story.
|
|
6585
|
+
*/
|
|
6586
|
+
const readyReasonLines = (value) => {
|
|
6587
|
+
const named = value.blockedReasons ?? [];
|
|
6588
|
+
if (named.length > 0) return named.map((reason) => ({
|
|
6589
|
+
...reason.code === void 0 ? {} : { code: reason.code },
|
|
6590
|
+
detail: reason.detail ?? "unrecorded"
|
|
6591
|
+
}));
|
|
6592
|
+
return (value.blockingReasons ?? []).map((reason) => ({ detail: typeof reason === "string" ? reason : "unrecorded" }));
|
|
6593
|
+
};
|
|
6594
|
+
const readyReasonBlock = (reasons) => {
|
|
6595
|
+
return `\n\n${reasons.length === 1 ? "1 blocking reason:" : `${reasons.length} blocking reasons:`}\n\n${reasons.map((reason) => `- ${reason.code === void 0 ? "" : `**${reason.code}** — `}${reason.detail}`).join("\n")}`;
|
|
6596
|
+
};
|
|
6597
|
+
const readyIdentityField = (value) => value === void 0 || value.trim().length === 0 ? "unrecorded" : `\`${value}\``;
|
|
6598
|
+
/**
|
|
6599
|
+
* #1037: the review identity the proof already records, printed where the
|
|
6600
|
+
* merge decision is made. `pr:ready` recorded the review session, model, and
|
|
6601
|
+
* producer, and the authoring session it was checked against, but only the PR
|
|
6602
|
+
* body prose showed them — so a human merging from the checks page could not
|
|
6603
|
+
* see which model produced the review that admitted the candidate.
|
|
6604
|
+
*
|
|
6605
|
+
* This is recording made visible, not a control. The attended merge is the
|
|
6606
|
+
* control.
|
|
6607
|
+
*/
|
|
6608
|
+
const readyIdentityBlock = (value) => {
|
|
6609
|
+
const runs = value.ledger?.reviewRuns ?? [];
|
|
6610
|
+
if (runs.length === 0 && value.authoringSession === void 0) return "";
|
|
6611
|
+
return `\n\nRecorded review identity:\n\n${[...runs.map((run) => {
|
|
6612
|
+
const rung = run.rung === void 0 ? "" : ` at rung \`${run.rung}\``;
|
|
6613
|
+
return `- ${run.kind ?? "review"}${rung}: session ${readyIdentityField(run.sessionId)} · model ${readyIdentityField(run.model)} · producer ${readyIdentityField(run.producer)}`;
|
|
6614
|
+
}), `- authoring session: ${readyIdentityField(value.authoringSession)}`].join("\n")}`;
|
|
6615
|
+
};
|
|
6579
6616
|
function readyOutput(proof) {
|
|
6580
6617
|
const value = proof;
|
|
6618
|
+
const reasons = readyReasonLines(value);
|
|
6581
6619
|
return {
|
|
6582
|
-
summary: `Schema v${value.schemaVersion}; readiness verdict **${value.status ?? "unknown"}
|
|
6620
|
+
summary: `${`Schema v${value.schemaVersion}; readiness verdict **${value.status ?? "unknown"}**;${reasons.length === 0 ? " no blocking reasons;" : ""} ledger head \`${value.ledger?.headSha ?? "unknown"}\`.`}${reasons.length === 0 ? "" : readyReasonBlock(reasons)}${readyIdentityBlock(value)}`,
|
|
6583
6621
|
title: `pr:ready ${value.status ?? "unknown"}`
|
|
6584
6622
|
};
|
|
6585
6623
|
}
|
|
@@ -7508,6 +7546,15 @@ const assertPrVerifyProofRules = (proof, context) => {
|
|
|
7508
7546
|
* factory must adopt this release before HQ ingests its pr:verify proofs.
|
|
7509
7547
|
*/
|
|
7510
7548
|
const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS = [4];
|
|
7549
|
+
const prVerifyTestFailureSchema = z.object({
|
|
7550
|
+
file: z.string().min(1),
|
|
7551
|
+
kind: z.enum([
|
|
7552
|
+
"assertion",
|
|
7553
|
+
"error",
|
|
7554
|
+
"timeout"
|
|
7555
|
+
]),
|
|
7556
|
+
title: z.string().min(1)
|
|
7557
|
+
});
|
|
7511
7558
|
const executedCommandSchema = z.object({
|
|
7512
7559
|
command: z.string().min(1),
|
|
7513
7560
|
counts: z.object({
|
|
@@ -7516,6 +7563,7 @@ const executedCommandSchema = z.object({
|
|
|
7516
7563
|
}).optional(),
|
|
7517
7564
|
durationMs: z.number().nonnegative(),
|
|
7518
7565
|
exitCode: z.number(),
|
|
7566
|
+
failures: z.array(prVerifyTestFailureSchema).min(1).optional(),
|
|
7519
7567
|
name: z.string().min(1),
|
|
7520
7568
|
scope: z.enum([
|
|
7521
7569
|
"docs-only",
|
|
@@ -7797,6 +7845,13 @@ const verificationProofForReadiness = (state) => {
|
|
|
7797
7845
|
const failedExecutedCommand = (proof) => {
|
|
7798
7846
|
return proof.executedCommands?.find((command) => command.exitCode !== 0) ?? proof.executedCommands?.at(-1);
|
|
7799
7847
|
};
|
|
7848
|
+
const MAX_NAMED_TEST_FAILURES = 3;
|
|
7849
|
+
const formatFailedTests = (failures) => {
|
|
7850
|
+
if (!failures || failures.length === 0) return "";
|
|
7851
|
+
const named = failures.slice(0, MAX_NAMED_TEST_FAILURES).map((failure) => `${failure.file} > ${failure.title} (${failure.kind})`).join("; ");
|
|
7852
|
+
const remaining = failures.length - MAX_NAMED_TEST_FAILURES;
|
|
7853
|
+
return remaining > 0 ? ` on ${named}; and ${remaining} more failing test${remaining === 1 ? "" : "s"}` : ` on ${named}`;
|
|
7854
|
+
};
|
|
7800
7855
|
const verificationProofBlockingReason = (state) => {
|
|
7801
7856
|
if (state.kind === "typed-missing") {
|
|
7802
7857
|
if (state.reason === "enoent") return `Typed pr:verify proof at ${state.path} does not exist; pr:verify has not run for this PR head. Run patronage-factory pr:verify.`;
|
|
@@ -7808,7 +7863,7 @@ const verificationProofBlockingReason = (state) => {
|
|
|
7808
7863
|
}
|
|
7809
7864
|
if (state.kind === "typed-aborted") {
|
|
7810
7865
|
const failed = failedExecutedCommand(state.proof);
|
|
7811
|
-
if (failed) return `Typed pr:verify proof shows verification aborted at ${failed.name} (exit code ${failed.exitCode}); verification did not pass. Re-run patronage-factory pr:verify.`;
|
|
7866
|
+
if (failed) return `Typed pr:verify proof shows verification aborted at ${failed.name} (exit code ${failed.exitCode})${formatFailedTests(failed.failures)}; verification did not pass. Re-run patronage-factory pr:verify.`;
|
|
7812
7867
|
return "Typed pr:verify proof shows verification aborted; verification did not pass. Re-run patronage-factory pr:verify.";
|
|
7813
7868
|
}
|
|
7814
7869
|
};
|
|
@@ -8036,6 +8091,111 @@ function parseVitestCounts(stdout) {
|
|
|
8036
8091
|
if (testsMatch) counts.tests = Number(testsMatch.groups?.total ?? testsMatch.groups?.passed);
|
|
8037
8092
|
return counts;
|
|
8038
8093
|
}
|
|
8094
|
+
const MAX_RECORDED_TEST_FAILURES = 50;
|
|
8095
|
+
const ANSI_ESCAPE_PATTERN = /\u001B\[[0-9;]*m/gu;
|
|
8096
|
+
/**
|
|
8097
|
+
* Vitest's per-failure header: `FAIL <file> > <suite> > <test title>`. This
|
|
8098
|
+
* pattern claims only the `FAIL ` prefix; `splitVitestFailHeader` below reads
|
|
8099
|
+
* the rest, because a multi-project run puts a project label between `FAIL`
|
|
8100
|
+
* and the file and its shape depends on color support.
|
|
8101
|
+
*/
|
|
8102
|
+
const VITEST_FAIL_HEADER_PATTERN = /^\s*FAIL\s+(?<rest>\S.*)$/u;
|
|
8103
|
+
/** Vitest separates the file, each suite, and the test title with ` > `. */
|
|
8104
|
+
const VITEST_TITLE_SEPARATOR = " > ";
|
|
8105
|
+
/**
|
|
8106
|
+
* Splits a failure header into the failing file and the full test title.
|
|
8107
|
+
*
|
|
8108
|
+
* The file is the LAST whitespace-separated token before the first ` > `,
|
|
8109
|
+
* which is what makes this survive a multi-project run. Vitest's
|
|
8110
|
+
* `formatProjectName` writes the project label as `|name|` when color is
|
|
8111
|
+
* unsupported and as a color-boxed ` name ` when it is; after this module
|
|
8112
|
+
* strips the SGR codes both leave the label as its own token ahead of the
|
|
8113
|
+
* file, and both drop out here. The title keeps every suite name, so the
|
|
8114
|
+
* split is on the FIRST separator and never a later one.
|
|
8115
|
+
*
|
|
8116
|
+
* Answers `undefined` for a header that names no test.
|
|
8117
|
+
*/
|
|
8118
|
+
const splitVitestFailHeader = (rest) => {
|
|
8119
|
+
const separatorIndex = rest.indexOf(VITEST_TITLE_SEPARATOR);
|
|
8120
|
+
if (separatorIndex === -1) return;
|
|
8121
|
+
const file = rest.slice(0, separatorIndex).trim().split(/\s+/u).at(-1);
|
|
8122
|
+
const title = rest.slice(separatorIndex + 3).trim();
|
|
8123
|
+
if (!file || title.length === 0) return;
|
|
8124
|
+
return {
|
|
8125
|
+
file,
|
|
8126
|
+
title
|
|
8127
|
+
};
|
|
8128
|
+
};
|
|
8129
|
+
const VITEST_TIMEOUT_PATTERN = /^\s*\S*Error:\s+Test timed out in /u;
|
|
8130
|
+
const VITEST_ASSERTION_PATTERN = /^\s*\S*AssertionError\b/u;
|
|
8131
|
+
/**
|
|
8132
|
+
* Reads the failure class off the error line under a failure header. Anything
|
|
8133
|
+
* that is neither the test-timeout error nor an assertion error — including a
|
|
8134
|
+
* line that is not error-shaped at all — classifies as `error` rather than
|
|
8135
|
+
* guessing at a narrower class.
|
|
8136
|
+
*/
|
|
8137
|
+
const classifyVitestFailure = (errorLine) => {
|
|
8138
|
+
if (errorLine === void 0) return "error";
|
|
8139
|
+
if (VITEST_TIMEOUT_PATTERN.test(errorLine)) return "timeout";
|
|
8140
|
+
if (VITEST_ASSERTION_PATTERN.test(errorLine)) return "assertion";
|
|
8141
|
+
return "error";
|
|
8142
|
+
};
|
|
8143
|
+
/**
|
|
8144
|
+
* Scrapes the failing tests out of a vitest run's console output (#1035).
|
|
8145
|
+
*
|
|
8146
|
+
* Callers pass BOTH captured streams: vitest writes its `Failed Tests`
|
|
8147
|
+
* section to stderr, so stdout alone carries no failure header.
|
|
8148
|
+
*
|
|
8149
|
+
* Deliberately line-shaped, the same class of scan as `parseVitestCounts`
|
|
8150
|
+
* above: it reads the `FAIL <file> > <title>` header and the error line that
|
|
8151
|
+
* follows it, and nothing else. It must never grow toward a parser of vitest's
|
|
8152
|
+
* report. Output it does not recognize yields an empty list, never a throw —
|
|
8153
|
+
* an unreadable report is a proof that records no failures, not a verification
|
|
8154
|
+
* run that crashes while writing why it failed.
|
|
8155
|
+
*/
|
|
8156
|
+
function parseVitestFailures(stdout) {
|
|
8157
|
+
const lines = stdout.replace(ANSI_ESCAPE_PATTERN, "").split(/\r?\n/u);
|
|
8158
|
+
const failures = [];
|
|
8159
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8160
|
+
for (const [index, line] of lines.entries()) {
|
|
8161
|
+
const header = line.match(VITEST_FAIL_HEADER_PATTERN);
|
|
8162
|
+
if (!header?.groups?.rest) continue;
|
|
8163
|
+
const split = splitVitestFailHeader(header.groups.rest);
|
|
8164
|
+
if (!split) continue;
|
|
8165
|
+
const { file, title } = split;
|
|
8166
|
+
const key = `${file}\u0000${title}`;
|
|
8167
|
+
if (seen.has(key)) continue;
|
|
8168
|
+
seen.add(key);
|
|
8169
|
+
const errorLine = lines.slice(index + 1, index + 3).find((candidate) => candidate.trim().length > 0);
|
|
8170
|
+
failures.push({
|
|
8171
|
+
file,
|
|
8172
|
+
kind: classifyVitestFailure(errorLine),
|
|
8173
|
+
title
|
|
8174
|
+
});
|
|
8175
|
+
if (failures.length >= MAX_RECORDED_TEST_FAILURES) break;
|
|
8176
|
+
}
|
|
8177
|
+
return failures;
|
|
8178
|
+
}
|
|
8179
|
+
/**
|
|
8180
|
+
* Reduces one captured verification run to the outcome the proof records.
|
|
8181
|
+
*
|
|
8182
|
+
* Which stream each scan reads is the load-bearing decision here. Vitest's
|
|
8183
|
+
* default reporter prints the run tree and the `Test Files` summary that
|
|
8184
|
+
* `parseVitestCounts` reads to stdout, but prints the whole `Failed Tests`
|
|
8185
|
+
* section — every `FAIL` header and every error line — through
|
|
8186
|
+
* `logger.error`, which is stderr. Scanning stdout alone for failures finds
|
|
8187
|
+
* none at all, so the failure scan reads both streams.
|
|
8188
|
+
*/
|
|
8189
|
+
function verificationOutcomeOf(result) {
|
|
8190
|
+
const counts = parseVitestCounts(result.stdout);
|
|
8191
|
+
const failures = result.exitCode === 0 ? [] : parseVitestFailures(`${result.stdout}\n${result.stderr}`);
|
|
8192
|
+
return {
|
|
8193
|
+
...counts ? { counts } : {},
|
|
8194
|
+
durationMs: result.durationMs,
|
|
8195
|
+
exitCode: result.exitCode,
|
|
8196
|
+
...failures.length > 0 ? { failures } : {}
|
|
8197
|
+
};
|
|
8198
|
+
}
|
|
8039
8199
|
function defaultPrVerifyGit() {
|
|
8040
8200
|
return {
|
|
8041
8201
|
changedFiles,
|
|
@@ -8044,13 +8204,7 @@ function defaultPrVerifyGit() {
|
|
|
8044
8204
|
mergeBaseSha,
|
|
8045
8205
|
recognizeCatchUp: recognizeCatchUpCandidate,
|
|
8046
8206
|
runVerificationCommand(command, cwd, env) {
|
|
8047
|
-
|
|
8048
|
-
const counts = parseVitestCounts(result.stdout);
|
|
8049
|
-
return {
|
|
8050
|
-
...counts ? { counts } : {},
|
|
8051
|
-
durationMs: result.durationMs,
|
|
8052
|
-
exitCode: result.exitCode
|
|
8053
|
-
};
|
|
8207
|
+
return verificationOutcomeOf(runInheritedTee("bash", ["-lc", command], cwd, env));
|
|
8054
8208
|
},
|
|
8055
8209
|
showFileAtRef,
|
|
8056
8210
|
stablePatchId: runStablePatchId,
|
|
@@ -8136,6 +8290,19 @@ function formatVerifyTimingSummary(proof) {
|
|
|
8136
8290
|
}
|
|
8137
8291
|
return lines.join("\n");
|
|
8138
8292
|
}
|
|
8293
|
+
/**
|
|
8294
|
+
* The line `pr:verify` prints last when it aborts (#1035): where the record
|
|
8295
|
+
* is, which command failed, and — when the command reported them — which
|
|
8296
|
+
* tests failed and in what class. A timeout under machine load and a real
|
|
8297
|
+
* assertion look identical without this, so every red verify used to cost a
|
|
8298
|
+
* full visible rerun just to read a test title.
|
|
8299
|
+
*/
|
|
8300
|
+
function formatVerifyFailurePointer(proof, proofPath) {
|
|
8301
|
+
const failed = proof.executedCommands?.find((command) => command.exitCode !== 0);
|
|
8302
|
+
const lines = [failed ? `pr:verify aborted at ${failed.name} (exit code ${failed.exitCode}); record: ${proofPath}` : `pr:verify aborted; record: ${proofPath}`];
|
|
8303
|
+
for (const failure of failed?.failures ?? []) lines.push(` ${failure.kind}: ${failure.file} > ${failure.title}`);
|
|
8304
|
+
return lines.join("\n");
|
|
8305
|
+
}
|
|
8139
8306
|
function resolveMode({ classification, mode }) {
|
|
8140
8307
|
if (mode === "auto") {
|
|
8141
8308
|
if (classification === "docs/process-only") return "docs-only";
|
|
@@ -8299,6 +8466,7 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
8299
8466
|
writeProofCopies(proof);
|
|
8300
8467
|
failureProofWritten = true;
|
|
8301
8468
|
if (executedCommands.length > 0) console.log(`\n${formatVerifyTimingSummary(proof)}`);
|
|
8469
|
+
console.log(`\n${formatVerifyFailurePointer(proof, output)}`);
|
|
8302
8470
|
publishFactoryCheckSafely(publishCheckRun, {
|
|
8303
8471
|
conclusion: "failure",
|
|
8304
8472
|
cwd,
|
|
@@ -8321,7 +8489,8 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
8321
8489
|
exitCode: result.exitCode,
|
|
8322
8490
|
name: command.name,
|
|
8323
8491
|
scope: command.scope,
|
|
8324
|
-
...result.counts ? { counts: result.counts } : {}
|
|
8492
|
+
...result.counts ? { counts: result.counts } : {},
|
|
8493
|
+
...result.failures && result.failures.length > 0 ? { failures: result.failures } : {}
|
|
8325
8494
|
};
|
|
8326
8495
|
executedCommands.push(executedCommand);
|
|
8327
8496
|
if (result.exitCode !== 0) {
|
|
@@ -9706,6 +9875,7 @@ z.object({
|
|
|
9706
9875
|
"not-armed"
|
|
9707
9876
|
])
|
|
9708
9877
|
}).optional(),
|
|
9878
|
+
authoringSession: z.string().min(1).optional(),
|
|
9709
9879
|
blockedReasons: blockedReasonsSchema.optional(),
|
|
9710
9880
|
blockingReasons: z.array(z.string()),
|
|
9711
9881
|
command: z.literal("patronage-factory pr:ready"),
|
|
@@ -14638,9 +14808,13 @@ const renderPrBodySections = ({ reviewProof, verifyProof }) => `${renderPrBodySe
|
|
|
14638
14808
|
//#region src/pr-readiness/status-check-rollup.ts
|
|
14639
14809
|
var status_check_rollup_exports = /* @__PURE__ */ __exportAll({
|
|
14640
14810
|
HOSTED_VERIFY_CHECK_NAME: () => HOSTED_VERIFY_CHECK_NAME$1,
|
|
14811
|
+
HOSTED_VERIFY_DRAFT_CHECK_NAME: () => HOSTED_VERIFY_DRAFT_CHECK_NAME,
|
|
14641
14812
|
hostedVerifyCheckState: () => hostedVerifyCheckState,
|
|
14813
|
+
isDraftHostedVerifyCheck: () => isDraftHostedVerifyCheck,
|
|
14814
|
+
isExcludedFromAdmissionChecks: () => isExcludedFromAdmissionChecks,
|
|
14642
14815
|
isFactoryReadyCheck: () => isFactoryReadyCheck,
|
|
14643
14816
|
isHostedVerifyCheck: () => isHostedVerifyCheck,
|
|
14817
|
+
isSupersededHostedVerifyCheck: () => isSupersededHostedVerifyCheck,
|
|
14644
14818
|
pendingCheckNames: () => pendingCheckNames,
|
|
14645
14819
|
statusCheckState: () => statusCheckState
|
|
14646
14820
|
});
|
|
@@ -14683,6 +14857,39 @@ const HOSTED_VERIFY_CHECK_NAME$1 = "verify";
|
|
|
14683
14857
|
*/
|
|
14684
14858
|
const isHostedVerifyCheck = (check) => check.name === "verify" && Boolean(check.workflowName);
|
|
14685
14859
|
/**
|
|
14860
|
+
* The name the hosted summary job takes on a DRAFT head (#1045).
|
|
14861
|
+
*
|
|
14862
|
+
* The workflow renames that job so the pinned `verify` context does not exist
|
|
14863
|
+
* while the candidate is a draft. The generator that emits the workflow owns
|
|
14864
|
+
* the literal; this is the reader's copy of it, and
|
|
14865
|
+
* `software-factory-hq/alchemy/verify-workflow.test.ts` pins the two equal.
|
|
14866
|
+
*/
|
|
14867
|
+
const HOSTED_VERIFY_DRAFT_CHECK_NAME = `${HOSTED_VERIFY_CHECK_NAME$1} (draft)`;
|
|
14868
|
+
/**
|
|
14869
|
+
* Is this the draft-head summary run's residue?
|
|
14870
|
+
*
|
|
14871
|
+
* The draft run and the `ready_for_review` run land on the SAME head SHA, and
|
|
14872
|
+
* they post two different contexts — `verify (draft)` and `verify` — which
|
|
14873
|
+
* `effectiveChecks` keys separately and therefore never collapses. The Verify
|
|
14874
|
+
* workflow cancels an in-flight pull-request run when the next one starts, and
|
|
14875
|
+
* `ready_for_review` IS the next one, so undrafting typically cancels the
|
|
14876
|
+
* draft run mid-flight. That leaves a CANCELLED `verify (draft)` row sitting
|
|
14877
|
+
* next to a green `verify` on the admitted head.
|
|
14878
|
+
*
|
|
14879
|
+
* GitHub does not care: the branch ruleset pins `verify` alone. The factory's
|
|
14880
|
+
* own rollup read would: `CANCELLED` is a failed state, so the candidate's
|
|
14881
|
+
* `requiredChecks` would read `failed` forever and no push-free repair could
|
|
14882
|
+
* clear it. The draft run is a superseded generation of the summary job the
|
|
14883
|
+
* `verify` row already answers for, so admission does not judge it.
|
|
14884
|
+
*/
|
|
14885
|
+
const isDraftHostedVerifyCheck = (check) => check.name === HOSTED_VERIFY_DRAFT_CHECK_NAME && Boolean(check.workflowName);
|
|
14886
|
+
/**
|
|
14887
|
+
* The rollup rows an admission read must not judge: the factory's own branded
|
|
14888
|
+
* checks (it would be judging itself) and the draft-head summary residue
|
|
14889
|
+
* above.
|
|
14890
|
+
*/
|
|
14891
|
+
const isExcludedFromAdmissionChecks = (check) => isFactoryReadyCheck(check) || isDraftHostedVerifyCheck(check);
|
|
14892
|
+
/**
|
|
14686
14893
|
* The hosted `verify` gate's state on this head, read by presence (#477).
|
|
14687
14894
|
*
|
|
14688
14895
|
* This is the one rollup question the rollup can answer honestly. Wave 2
|
|
@@ -14769,6 +14976,31 @@ const statusCheckState = (rollup, exclude) => {
|
|
|
14769
14976
|
if (currentChecks.some((check) => PENDING_STATES.has(progressState(check)))) return "pending";
|
|
14770
14977
|
return "unknown";
|
|
14771
14978
|
};
|
|
14979
|
+
/**
|
|
14980
|
+
* Is this a hosted `verify` check run that GitHub started before `cutoffIso`?
|
|
14981
|
+
* (#1045)
|
|
14982
|
+
*
|
|
14983
|
+
* A caller that waits for `verify` on a head it just changed the state of —
|
|
14984
|
+
* `pr:publish` undrafting a candidate — must not accept the run that was
|
|
14985
|
+
* already there. #1034's history is the shape: a draft head carried a `verify`
|
|
14986
|
+
* that had run nothing, the wait saw it as terminal on its first attempt, and
|
|
14987
|
+
* readiness read `passed` about two minutes before the `ready_for_review` run
|
|
14988
|
+
* created its own check runs. The same shape is reachable from any superseded
|
|
14989
|
+
* `verify` generation still on the head.
|
|
14990
|
+
*
|
|
14991
|
+
* Fail CLOSED on an unreadable timestamp: a `verify` row that cannot be shown
|
|
14992
|
+
* to be newer than the cutoff is treated as superseded. The wait is bounded,
|
|
14993
|
+
* so the cost of that refusal is one timeout that falls through to the
|
|
14994
|
+
* caller's existing behaviour; the cost of failing open is the false green
|
|
14995
|
+
* this exists to remove.
|
|
14996
|
+
*/
|
|
14997
|
+
const isSupersededHostedVerifyCheck = (check, cutoffIso) => {
|
|
14998
|
+
if (!isHostedVerifyCheck(check)) return false;
|
|
14999
|
+
const startedMs = checkTimestampMs(check);
|
|
15000
|
+
const cutoffMs = Date.parse(cutoffIso);
|
|
15001
|
+
if (startedMs === void 0 || Number.isNaN(cutoffMs)) return true;
|
|
15002
|
+
return startedMs < cutoffMs;
|
|
15003
|
+
};
|
|
14772
15004
|
//#endregion
|
|
14773
15005
|
//#region src/arm-auto-merge.ts
|
|
14774
15006
|
/**
|
|
@@ -15605,6 +15837,43 @@ const requiredChecksEvaluation = (input) => {
|
|
|
15605
15837
|
outcomes
|
|
15606
15838
|
};
|
|
15607
15839
|
};
|
|
15840
|
+
/**
|
|
15841
|
+
* REPAIR SITES 2 and 3 of 3 (#1045). #1048 makes the permitted set a type:
|
|
15842
|
+
* only a `RepairableDemand` blocker may carry a repair, so a fourth site on a
|
|
15843
|
+
* proof refusal fails `tsc`. The type keys on the demand, not on the state, so
|
|
15844
|
+
* it still permits a repair on a FAILED required check — the evaluator test
|
|
15845
|
+
* that turns every non-route-owned refusal on at once pins that half.
|
|
15846
|
+
*
|
|
15847
|
+
* Both hosted-check demands in `collectBlockers` are route-owned in exactly
|
|
15848
|
+
* two states, and in both the repair is the same one — wait for the NEXT
|
|
15849
|
+
* hosted run:
|
|
15850
|
+
*
|
|
15851
|
+
* 1. `requiredChecks === "pending"`: a run is in flight on this head.
|
|
15852
|
+
* 2. `draft === true`: the candidate is a draft, so the hosted verification
|
|
15853
|
+
* workflow deliberately posts no `verify` context at all (#1045 — `core`
|
|
15854
|
+
* and `docs` skip on a draft, and the summary job is renamed so the pinned
|
|
15855
|
+
* context is ABSENT rather than a green or gray check that ran nothing).
|
|
15856
|
+
* Absence is what `hostedVerifyCheck: "none"` reports, so without this
|
|
15857
|
+
* repair a draft would raise a human blocker no human can clear, and
|
|
15858
|
+
* `pr:ready` would publish a red check on every draft — the notification
|
|
15859
|
+
* noise #1037 removed.
|
|
15860
|
+
*
|
|
15861
|
+
* Draft is route-owned by construction: `pr:publish` undrafts the candidate
|
|
15862
|
+
* itself, and the repair is literally named `await-post-undraft-checks`
|
|
15863
|
+
* because it is about the run the promotion triggers.
|
|
15864
|
+
*
|
|
15865
|
+
* A draft whose checks came back FAILED is deliberately excluded: a real red
|
|
15866
|
+
* is not a not-yet, and waiting for another run would not clear it. That stays
|
|
15867
|
+
* a human blocker, exactly as it is today.
|
|
15868
|
+
*/
|
|
15869
|
+
const awaitPostUndraftChecksRepair = (input) => {
|
|
15870
|
+
if (!(input.requiredChecks === "pending" || input.draft && input.requiredChecks !== "failed")) return {};
|
|
15871
|
+
return { repair: {
|
|
15872
|
+
action: "Poll and retry until post-undraft GitHub checks settle or fail; a draft head and UNSTABLE/pending are transient route-owned states, not human blockers.",
|
|
15873
|
+
code: "await-post-undraft-checks",
|
|
15874
|
+
command: `gh pr checks ${input.pr} --watch`
|
|
15875
|
+
} };
|
|
15876
|
+
};
|
|
15608
15877
|
const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUntyped, input, prVerifyStatus, verificationProof, securityRequired, securityStatus, securityUntyped }) => {
|
|
15609
15878
|
const blockers = [];
|
|
15610
15879
|
if (prVerifyStatus === "missing") blockers.push({
|
|
@@ -15652,22 +15921,21 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
15652
15921
|
demand: DEMAND_KEYS.mergeState,
|
|
15653
15922
|
reason: `GitHub merge state is ${input.mergeStateStatus}/${input.mergeable}; expected CLEAN or review-only BLOCKED with MERGEABLE.`
|
|
15654
15923
|
});
|
|
15924
|
+
const awaitPostUndraftChecks = awaitPostUndraftChecksRepair(input);
|
|
15655
15925
|
if ((input.hostedVerifyCheck ?? "none") === "none") blockers.push({
|
|
15656
15926
|
demand: DEMAND_KEYS.githubChecks,
|
|
15657
|
-
reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME$1} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall
|
|
15927
|
+
reason: `GitHub has no ${HOSTED_VERIFY_CHECK_NAME$1} check run from its workflow producer on this head; the branch ruleset pins it as a required check and the status rollup does not report the shortfall.`,
|
|
15928
|
+
...awaitPostUndraftChecks
|
|
15658
15929
|
});
|
|
15659
15930
|
if (input.requiredChecks === "none" && input.classification !== "docs/process-only") blockers.push({
|
|
15660
15931
|
demand: DEMAND_KEYS.githubChecks,
|
|
15661
|
-
reason: "GitHub has no current checks for a non-docs PR."
|
|
15932
|
+
reason: "GitHub has no current checks for a non-docs PR.",
|
|
15933
|
+
...awaitPostUndraftChecks
|
|
15662
15934
|
});
|
|
15663
15935
|
else if (input.requiredChecks !== "passed" && input.requiredChecks !== "none") blockers.push({
|
|
15664
15936
|
demand: DEMAND_KEYS.githubChecks,
|
|
15665
15937
|
reason: `${PENDING_CHECKS_BLOCKER_REASON_PREFIX} ${input.requiredChecks}; expected passed.`,
|
|
15666
|
-
...
|
|
15667
|
-
action: "Poll and retry until post-undraft GitHub checks settle or fail; UNSTABLE/pending is a transient route-owned state, not a human blocker.",
|
|
15668
|
-
code: "await-post-undraft-checks",
|
|
15669
|
-
command: `gh pr checks ${input.pr} --watch`
|
|
15670
|
-
} } : {}
|
|
15938
|
+
...awaitPostUndraftChecks
|
|
15671
15939
|
});
|
|
15672
15940
|
if (input.draft) {
|
|
15673
15941
|
const promotion = prUndraftFollowUp(input.pr);
|
|
@@ -15683,7 +15951,7 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
|
|
|
15683
15951
|
}
|
|
15684
15952
|
return blockers;
|
|
15685
15953
|
};
|
|
15686
|
-
const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
|
|
15954
|
+
const orderRepairs = (repairs) => [...new Map(repairs.map((repair) => [repair.code, repair])).values()].toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
|
|
15687
15955
|
const verificationProofForInput = ({ input }) => {
|
|
15688
15956
|
if (input.verificationProof) return {
|
|
15689
15957
|
fullVerifiedHeadShas: input.docsOnlyVerifyBaselineHeadShas,
|
|
@@ -16668,6 +16936,7 @@ const prReadyProofSchema = z.object({
|
|
|
16668
16936
|
"not-armed"
|
|
16669
16937
|
])
|
|
16670
16938
|
}).optional(),
|
|
16939
|
+
authoringSession: z.string().min(1).optional(),
|
|
16671
16940
|
blockedReasons: blockedReasonsSchema.optional(),
|
|
16672
16941
|
blockingReasons: z.array(z.string()),
|
|
16673
16942
|
command: z.literal("patronage-factory pr:ready"),
|
|
@@ -16994,7 +17263,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
16994
17263
|
requiredChecks
|
|
16995
17264
|
});
|
|
16996
17265
|
const output = path.resolve(cwd, args.output ?? ".factory-memory/pr-ready.json");
|
|
16997
|
-
const externalCheckState = statusCheckState(pr.statusCheckRollup,
|
|
17266
|
+
const externalCheckState = statusCheckState(pr.statusCheckRollup, isExcludedFromAdmissionChecks);
|
|
16998
17267
|
const hostedVerifyCheck = hostedVerifyCheckState(pr.statusCheckRollup);
|
|
16999
17268
|
const effectiveMergeStateStatus = pr.mergeStateStatus === "UNSTABLE" && (externalCheckState === "passed" || externalCheckState === "pending" || externalCheckState === "none") ? "CLEAN" : pr.mergeStateStatus;
|
|
17000
17269
|
const mergeFreeze = armingMergeFreezeOutcome({
|
|
@@ -17056,6 +17325,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
17056
17325
|
});
|
|
17057
17326
|
const undraftRepair = evaluation.repairs.find((repair) => repair.code === "undraft-pr");
|
|
17058
17327
|
const pendingExternalChecksOnly = externalCheckState === "pending" && evaluation.blockingReasons.length === 1;
|
|
17328
|
+
const routeOwnedNotYetOnly = evaluation.status === "blocked" && evaluation.humanBlockingReasons.length === 0;
|
|
17059
17329
|
const boundary = waveDemand?.kind === "demanded" ? waveDemand.demand : void 0;
|
|
17060
17330
|
const arming = evaluation.status === "ready" && boundary?.autoMerge === true ? (dependencies.armAutoMerge ?? armAutoMerge)({
|
|
17061
17331
|
cwd,
|
|
@@ -17088,10 +17358,12 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
17088
17358
|
...arming ? [armingNotice(arming)] : [],
|
|
17089
17359
|
...waveDemand?.notices ?? [],
|
|
17090
17360
|
...evaluation.status === "ready" && !boundary ? ["No boundary wave authorized this candidate, so nothing was armed: pr:ready admits it but schedules no merge. Re-run with --epic <issue> to resolve the wave demand and arm. That issue can be the work issue itself, carrying its own fence; do not file a second boundary issue for one candidate."] : [],
|
|
17091
|
-
...evaluation.status === "ready" && boundary?.autoMerge === false ? [`This candidate is admitted but unscheduled: boundary wave ${boundary.wave} does not authorize machine merge. GitHub auto-merge remains disabled; use the attended merge operation authorized for this wave.`] : []
|
|
17361
|
+
...evaluation.status === "ready" && boundary?.autoMerge === false ? [`This candidate is admitted but unscheduled: boundary wave ${boundary.wave} does not authorize machine merge. GitHub auto-merge remains disabled; use the attended merge operation authorized for this wave.`] : [],
|
|
17362
|
+
...routeOwnedNotYetOnly ? [`No ${FACTORY_CHECK_NAMES["pr-ready"]} check run was published for ${pr.headRefOid}: every refusal on this run is route-owned and not yet cleared (${evaluation.repairs.map((repair) => repair.code).join(", ")}). An absent required check blocks the merge, so the silence admits nothing; a proof failure still publishes a red check. Clear the repair and re-run pr:ready to green the head.`] : []
|
|
17092
17363
|
];
|
|
17093
17364
|
const proof = {
|
|
17094
17365
|
...arming ? { arming } : {},
|
|
17366
|
+
...authoringSession ? { authoringSession } : {},
|
|
17095
17367
|
...evaluation.blockedReasons.length > 0 ? { blockedReasons: evaluation.blockedReasons } : {},
|
|
17096
17368
|
blockingReasons: evaluation.blockingReasons,
|
|
17097
17369
|
command: "patronage-factory pr:ready",
|
|
@@ -17119,7 +17391,7 @@ async function runPrReady(args, dependencies = {}) {
|
|
|
17119
17391
|
sha: pr.headRefOid,
|
|
17120
17392
|
conclusion: CHECK_CONCLUSION_BY_READINESS_STATUS[proof.status]
|
|
17121
17393
|
};
|
|
17122
|
-
const publishedFinalCheck = await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(finalCheckInput) : ensureFactoryCheckRunPublished(finalCheckInput, { onDiagnostic: (message) => process.stderr.write(message) }));
|
|
17394
|
+
const publishedFinalCheck = routeOwnedNotYetOnly || await (dependencies.publishFinalCheckRun ? dependencies.publishFinalCheckRun(finalCheckInput) : ensureFactoryCheckRunPublished(finalCheckInput, { onDiagnostic: (message) => process.stderr.write(message) }));
|
|
17123
17395
|
finalCheckPublished = true;
|
|
17124
17396
|
if (!publishedFinalCheck) {
|
|
17125
17397
|
notices.push(`The ${FACTORY_CHECK_NAMES["pr-ready"]} check run was NOT confirmed for ${pr.headRefOid}, so GitHub is not serving this run's verdict as the settled state of the required check. Either the publication never landed there, or another run of that name is still unfinished on this head (#524) — an unfinished run blocks the merge whatever this run published, and publishing a newer one does not clear it. GitHub reports no rollup reason for either. Re-run pr:ready once the factory GitHub App can post; if it stays unconfirmed, look for a non-terminal ${FACTORY_CHECK_NAMES["pr-ready"]} run on ${pr.headRefOid}.`);
|
|
@@ -17504,10 +17776,20 @@ function emitLadderStageTrace({ cwd, evaluation, headSha, issue, patchId, profil
|
|
|
17504
17776
|
}
|
|
17505
17777
|
//#endregion
|
|
17506
17778
|
//#region src/review-contract.ts
|
|
17507
|
-
const
|
|
17779
|
+
const SESSION_ID_RULE = `"sessionId" is this review session's own id. It must differ from the authoring session id the candidate was verified under (FACTORY_SESSION_ID): pr:review --findings refuses a findings file whose sessionId equals the authoring session.`;
|
|
17780
|
+
const authorityRule = (waveRung) => waveRung === void 0 ? `Emit schemaVersion 1 and omit "producer" and "rung": this candidate resolves no boundary-wave review rung. If your dispatch instructions do name a rung, emit schemaVersion 2 with that rung and with "producer" instead.` : `This candidate belongs to a boundary wave that demands the ${waveRung} review rung, so schemaVersion 2 is required. Record "rung": "${waveRung}" exactly as shown, and set "producer" to the engine and model that ran this review (for example "codex-cli/gpt-5.6-sol"). Findings without both fields are recorded without authority, and pr:publish then refuses with "no passing review at that authority".`;
|
|
17781
|
+
/**
|
|
17782
|
+
* The findings-file instructions for one candidate.
|
|
17783
|
+
*
|
|
17784
|
+
* `waveRung` is the review rung the candidate's boundary wave demands, when it
|
|
17785
|
+
* resolves one. It selects the findings schema version the gate will accept.
|
|
17786
|
+
*/
|
|
17787
|
+
const reviewFooterInstructions = (waveRung) => {
|
|
17788
|
+
const schemaVersion = waveRung === void 0 ? 1 : 2;
|
|
17789
|
+
return `Do not print a machine-readable footer in your chat response. Write your findings to the typed findings file at the path given in your dispatch/task instructions (the file pr:review reads via --findings <path>); that file is the verdict. The file must be valid JSON matching review-findings-file.ts's schema (schemaVersion ${schemaVersion}):
|
|
17508
17790
|
{
|
|
17509
|
-
"schemaVersion":
|
|
17510
|
-
"model": "<your model identifier>",
|
|
17791
|
+
"schemaVersion": ${schemaVersion},
|
|
17792
|
+
${waveRung === void 0 ? "" : ` "producer": "<the engine and model that ran this review, for example codex-cli/gpt-5.6-sol>",\n "rung": "${waveRung}",\n`} "model": "<your model identifier>",
|
|
17511
17793
|
"sessionId": "<this session's id>",
|
|
17512
17794
|
"patchId": "<the stable patch-id from Review scope above>",
|
|
17513
17795
|
"reviews": [
|
|
@@ -17520,10 +17802,13 @@ const REVIEW_FOOTER_INSTRUCTIONS = `Do not print a machine-readable footer in yo
|
|
|
17520
17802
|
}
|
|
17521
17803
|
]
|
|
17522
17804
|
}
|
|
17805
|
+
${authorityRule(waveRung)}
|
|
17806
|
+
${SESSION_ID_RULE}
|
|
17523
17807
|
Use category "correctness", "safety", "coordination", or "maintainability"; set blockingAfterCap true for correctness, safety, or coordination findings. citedSpan must be the exact code text the finding is about, quoted verbatim from the diff (identity is derived from this, not from the title, so quote precisely). prescribedAction must be a concise, standalone statement of the required fix (identity is derived from this text too). If this finding is a restatement of a prior finding shown in the prior review ledger below, set supersedes to {"title": "<exact prior title>", "file": "<its file>"} instead of minting a new title for the same theme.`;
|
|
17808
|
+
};
|
|
17524
17809
|
//#endregion
|
|
17525
17810
|
//#region src/review-prompt.ts
|
|
17526
|
-
const reviewScope = ({ base, changedFiles: files, headSha, patchId, profile }) => `Review scope:
|
|
17811
|
+
const reviewScope = ({ base, changedFiles: files, headSha, patchId, profile, waveRung }) => `Review scope:
|
|
17527
17812
|
- Repository: ${profile.repository.owner}/${profile.repository.name}
|
|
17528
17813
|
- Base ref: ${base}
|
|
17529
17814
|
- Head SHA: ${headSha}
|
|
@@ -17539,7 +17824,7 @@ Required constraints:
|
|
|
17539
17824
|
- Do not request elevated or unsandboxed approvals.
|
|
17540
17825
|
- When you flag a finding, report every other instance of the same class in this diff in the same response, and include a one-line coverage note.
|
|
17541
17826
|
|
|
17542
|
-
${
|
|
17827
|
+
${reviewFooterInstructions(waveRung)}`;
|
|
17543
17828
|
const MODE_FLOOR_FILES = {
|
|
17544
17829
|
correctness: "correctness.md",
|
|
17545
17830
|
security: "security/SKILL.md"
|
|
@@ -17613,7 +17898,7 @@ ${entries.map(priorLedgerEntryLine).join("\n")}
|
|
|
17613
17898
|
|
|
17614
17899
|
Do not re-raise any of the findings above under a new title: they are already adjudicated (waived, filed as a follow-up, or fixed in this thread). If new evidence genuinely supersedes one of them, say so explicitly and name the exact prior finding (its title and file) that it supersedes, rather than minting an unrelated title for the same underlying theme.`
|
|
17615
17900
|
});
|
|
17616
|
-
const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile }, dependencies = {}) => {
|
|
17901
|
+
const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha, issueFocus, kind, patchId, priorLedgerEntries, profile, waveRung }, dependencies = {}) => {
|
|
17617
17902
|
const priorLedger = priorLedgerEntries ? nonOpenLadderFindings(priorLedgerEntries) : [];
|
|
17618
17903
|
const optionalSegments = {
|
|
17619
17904
|
"coding-standards": codingStandardsSegment({
|
|
@@ -17633,7 +17918,8 @@ const assembleReviewPrompt = ({ base, changedFiles, cwd = process.cwd(), headSha
|
|
|
17633
17918
|
changedFiles,
|
|
17634
17919
|
headSha,
|
|
17635
17920
|
patchId,
|
|
17636
|
-
profile
|
|
17921
|
+
profile,
|
|
17922
|
+
waveRung
|
|
17637
17923
|
}) },
|
|
17638
17924
|
{ text: modeFloorInstructions(kind, changedFiles) },
|
|
17639
17925
|
...REVIEW_PROMPT_SECTION_PROVENANCES.flatMap((provenance) => {
|
|
@@ -17658,7 +17944,8 @@ const ASSEMBLED_REVIEW_PROMPT_SCHEMA_VERSION = 1;
|
|
|
17658
17944
|
const assembledReviewPromptKindSchema = z.object({
|
|
17659
17945
|
kind: z.enum(["correctness", "security"]),
|
|
17660
17946
|
prompt: z.string().min(1),
|
|
17661
|
-
sections: z.array(reviewPromptSectionSchema)
|
|
17947
|
+
sections: z.array(reviewPromptSectionSchema),
|
|
17948
|
+
waveRung: z.enum(EVIDENCE_REVIEW_RUNGS).optional()
|
|
17662
17949
|
});
|
|
17663
17950
|
const assembledReviewPromptArtifactSchema = z.object({
|
|
17664
17951
|
headSha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
@@ -17687,6 +17974,51 @@ const defaultFetchIssueBody = ({ issue, owner, repo }) => runGhJson([
|
|
|
17687
17974
|
"--json",
|
|
17688
17975
|
"body"
|
|
17689
17976
|
]).body;
|
|
17977
|
+
const candidateWavePullRequestSchema = z.object({
|
|
17978
|
+
baseRefName: z.string().min(1),
|
|
17979
|
+
body: z.string(),
|
|
17980
|
+
headRefName: z.string().min(1),
|
|
17981
|
+
number: z.number().int().positive()
|
|
17982
|
+
});
|
|
17983
|
+
const defaultFetchWavePullRequest = (cwd) => runGhJsonAt([
|
|
17984
|
+
"pr",
|
|
17985
|
+
"view",
|
|
17986
|
+
"--json",
|
|
17987
|
+
"baseRefName,body,headRefName,number"
|
|
17988
|
+
], cwd);
|
|
17989
|
+
/**
|
|
17990
|
+
* The review rung this candidate's boundary wave demands (#1001).
|
|
17991
|
+
*
|
|
17992
|
+
* The assembled prompt tells the reviewer which findings schema the gate will
|
|
17993
|
+
* accept. A wave that demands a rung needs schemaVersion 2 with `producer` and
|
|
17994
|
+
* `rung`; the v1 shape is recorded without authority, and `pr:publish` then
|
|
17995
|
+
* refuses it with "no passing review at that authority".
|
|
17996
|
+
*
|
|
17997
|
+
* This is prompt enrichment and fails open, like the coding-standards read:
|
|
17998
|
+
* every unreadable step returns undefined and the prompt keeps the v1 shape.
|
|
17999
|
+
* It decides no authority — `pr:ready` resolves the same wave demand again and
|
|
18000
|
+
* is the stage that refuses on it.
|
|
18001
|
+
*/
|
|
18002
|
+
const assembleWaveRung = ({ args, cwd, dependencies, fetchIssueBody, fetchWavePullRequest, profile }) => {
|
|
18003
|
+
if (args.epic === void 0) return;
|
|
18004
|
+
try {
|
|
18005
|
+
const pullRequest = candidateWavePullRequestSchema.parse(fetchWavePullRequest(cwd));
|
|
18006
|
+
const demand = resolveWaveDemand({
|
|
18007
|
+
baseRefName: pullRequest.baseRefName,
|
|
18008
|
+
epic: args.epic,
|
|
18009
|
+
...dependencies.fetchClosingPullRequests === void 0 ? {} : { fetchClosingPullRequests: dependencies.fetchClosingPullRequests },
|
|
18010
|
+
fetchIssueBody,
|
|
18011
|
+
headRefName: pullRequest.headRefName,
|
|
18012
|
+
owner: profile.repository.owner,
|
|
18013
|
+
pr: pullRequest.number,
|
|
18014
|
+
prBody: pullRequest.body,
|
|
18015
|
+
repo: profile.repository.name
|
|
18016
|
+
});
|
|
18017
|
+
return demand.kind === "demanded" ? demand.demand.review : void 0;
|
|
18018
|
+
} catch {
|
|
18019
|
+
return;
|
|
18020
|
+
}
|
|
18021
|
+
};
|
|
17690
18022
|
const assertReviewCycleInWindow = (cycle, maxCycles) => {
|
|
17691
18023
|
if (cycle < 1 || cycle > maxCycles) throw new Error(`--cycle must be between 1 and ${maxCycles}.`);
|
|
17692
18024
|
};
|
|
@@ -17806,7 +18138,7 @@ const assembleGitDependencies = (git) => git.repositoryRoot !== void 0 && git.sh
|
|
|
17806
18138
|
repositoryRoot: git.repositoryRoot,
|
|
17807
18139
|
showFileAtRef: git.showFileAtRef
|
|
17808
18140
|
} : void 0;
|
|
17809
|
-
const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile }) => {
|
|
18141
|
+
const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, headSha, kinds, output, patchId, profile, waveRung }) => {
|
|
17810
18142
|
const priorLedger = priorLedgerFor(priorLadderStateFor({
|
|
17811
18143
|
base: args.base,
|
|
17812
18144
|
cwd,
|
|
@@ -17826,12 +18158,14 @@ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, hea
|
|
|
17826
18158
|
kind,
|
|
17827
18159
|
patchId,
|
|
17828
18160
|
priorLedgerEntries: priorLedger,
|
|
17829
|
-
profile
|
|
18161
|
+
profile,
|
|
18162
|
+
...waveRung === void 0 ? {} : { waveRung }
|
|
17830
18163
|
}, assembleGit === void 0 ? {} : { git: assembleGit });
|
|
17831
18164
|
return {
|
|
17832
18165
|
kind,
|
|
17833
18166
|
prompt: assembled.prompt,
|
|
17834
|
-
sections: assembled.sections
|
|
18167
|
+
sections: assembled.sections,
|
|
18168
|
+
...waveRung === void 0 ? {} : { waveRung }
|
|
17835
18169
|
};
|
|
17836
18170
|
});
|
|
17837
18171
|
const artifactPath = path.resolve(cwd, DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH);
|
|
@@ -17839,7 +18173,7 @@ const writeAssembledReviewPrompt = ({ args, cwd, fetchIssueBody, files, git, hea
|
|
|
17839
18173
|
writeProofJson({
|
|
17840
18174
|
headSha,
|
|
17841
18175
|
patchId,
|
|
17842
|
-
reviews: existing.status === "ok" && existing.artifact.patchId === patchId && existing.artifact.headSha === headSha ? [...new Map([...existing.artifact.reviews, ...assembledReviews].map((review) => [review.kind, review])).values()] : assembledReviews,
|
|
18176
|
+
reviews: existing.status === "ok" && existing.artifact.patchId === patchId && existing.artifact.headSha === headSha ? [...new Map([...existing.artifact.reviews.filter((review) => review.waveRung === waveRung), ...assembledReviews].map((review) => [review.kind, review])).values()] : assembledReviews,
|
|
17843
18177
|
schemaVersion: ASSEMBLED_REVIEW_PROMPT_SCHEMA_VERSION
|
|
17844
18178
|
}, artifactPath);
|
|
17845
18179
|
console.log(`pr:review assembled prompt written to ${artifactPath}`);
|
|
@@ -18068,17 +18402,26 @@ async function runPrReview(args, dependencies = {}) {
|
|
|
18068
18402
|
console.log(ASSEMBLE_FAITHFUL_MERGE_NOTICE);
|
|
18069
18403
|
return assembleProof();
|
|
18070
18404
|
}
|
|
18405
|
+
const fetchIssueBody = dependencies.fetchIssueBody ?? defaultFetchIssueBody;
|
|
18071
18406
|
writeAssembledReviewPrompt({
|
|
18072
18407
|
args,
|
|
18073
18408
|
cwd,
|
|
18074
|
-
fetchIssueBody
|
|
18409
|
+
fetchIssueBody,
|
|
18075
18410
|
files,
|
|
18076
18411
|
git,
|
|
18077
18412
|
headSha,
|
|
18078
18413
|
kinds,
|
|
18079
18414
|
output,
|
|
18080
18415
|
patchId,
|
|
18081
|
-
profile
|
|
18416
|
+
profile,
|
|
18417
|
+
waveRung: assembleWaveRung({
|
|
18418
|
+
args,
|
|
18419
|
+
cwd,
|
|
18420
|
+
dependencies,
|
|
18421
|
+
fetchIssueBody,
|
|
18422
|
+
fetchWavePullRequest: dependencies.fetchWavePullRequest ?? defaultFetchWavePullRequest,
|
|
18423
|
+
profile
|
|
18424
|
+
})
|
|
18082
18425
|
});
|
|
18083
18426
|
return assembleProof();
|
|
18084
18427
|
}
|
|
@@ -18212,6 +18555,7 @@ async function runPrReview(args, dependencies = {}) {
|
|
|
18212
18555
|
}
|
|
18213
18556
|
//#endregion
|
|
18214
18557
|
//#region src/pr-publish.ts
|
|
18558
|
+
const defaultNow = () => /* @__PURE__ */ new Date();
|
|
18215
18559
|
var PrPublishFollowUpError = class extends Error {
|
|
18216
18560
|
result;
|
|
18217
18561
|
constructor(result, cause) {
|
|
@@ -18543,7 +18887,8 @@ const PUBLISH_HOSTED_CHECKS_WAIT_MS = 15e3;
|
|
|
18543
18887
|
* PR #983 measured the second half: `verify` finished first, readiness ran and
|
|
18544
18888
|
* published red, and the head's status-HUD job was still in progress.
|
|
18545
18889
|
*
|
|
18546
|
-
* The `patronage-factory/*` family
|
|
18890
|
+
* The `patronage-factory/*` family and the draft-head summary residue are
|
|
18891
|
+
* excluded through `isExcludedFromAdmissionChecks` (#1045),
|
|
18547
18892
|
* the predicate readiness itself uses to exclude its own writes from the
|
|
18548
18893
|
* rollup gate. Waiting on it would deadlock: readiness is what completes those
|
|
18549
18894
|
* runs, and a stranded non-terminal generation of one (#524) never finishes.
|
|
@@ -18555,7 +18900,7 @@ const PUBLISH_HOSTED_CHECKS_WAIT_MS = 15e3;
|
|
|
18555
18900
|
* proceeds exactly as it does today. A moved head is reported, never absorbed:
|
|
18556
18901
|
* the caller aborts the transaction on it.
|
|
18557
18902
|
*/
|
|
18558
|
-
async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (message) => process.stderr.write(message), sleep = setTimeout$1 }) {
|
|
18903
|
+
async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (message) => process.stderr.write(message), sleep = setTimeout$1, verifyNotBefore }) {
|
|
18559
18904
|
for (let attempts = 0;; attempts += 1) {
|
|
18560
18905
|
const pr = fetchPr();
|
|
18561
18906
|
if (!sameHeadSha(pr.headRefOid, expectedHeadSha)) return {
|
|
@@ -18563,9 +18908,10 @@ async function awaitHostedChecksSettled({ expectedHeadSha, fetchPr, report = (me
|
|
|
18563
18908
|
attempts,
|
|
18564
18909
|
status: "head-moved"
|
|
18565
18910
|
};
|
|
18566
|
-
const
|
|
18911
|
+
const rollup = verifyNotBefore === void 0 ? pr.statusCheckRollup : pr.statusCheckRollup?.filter((check) => !isSupersededHostedVerifyCheck(check, verifyNotBefore));
|
|
18912
|
+
const verifyState = hostedVerifyCheckState(rollup);
|
|
18567
18913
|
const verifySettled = verifyState !== "none" && verifyState !== "pending";
|
|
18568
|
-
const pending = pendingCheckNames(
|
|
18914
|
+
const pending = pendingCheckNames(rollup, isExcludedFromAdmissionChecks);
|
|
18569
18915
|
if (verifySettled && pending.length === 0) return {
|
|
18570
18916
|
attempts,
|
|
18571
18917
|
status: "settled",
|
|
@@ -18600,15 +18946,28 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
|
|
|
18600
18946
|
* invocation's verify and review proof describe the head it waited for. That
|
|
18601
18947
|
* is the same reason the #348 await aborts on head movement.
|
|
18602
18948
|
*/
|
|
18603
|
-
const awaitHostedChecksBeforeReadiness = async ({ dependencies, expectedHeadSha, fetchPr }) => {
|
|
18949
|
+
const awaitHostedChecksBeforeReadiness = async ({ dependencies, expectedHeadSha, fetchPr, verifyNotBefore }) => {
|
|
18604
18950
|
const outcome = await awaitHostedChecksSettled({
|
|
18605
18951
|
expectedHeadSha,
|
|
18606
18952
|
fetchPr,
|
|
18607
|
-
...dependencies.sleep === void 0 ? {} : { sleep: dependencies.sleep }
|
|
18953
|
+
...dependencies.sleep === void 0 ? {} : { sleep: dependencies.sleep },
|
|
18954
|
+
...verifyNotBefore === void 0 ? {} : { verifyNotBefore }
|
|
18608
18955
|
});
|
|
18609
18956
|
if (outcome.status === "head-moved") throw new PrPublishTransactionAbortedError(`awaited the head's hosted check runs for ${expectedHeadSha}, but the head moved to ${outcome.actualHeadSha} while waiting. Re-run pr:publish against the current head.`);
|
|
18610
18957
|
return outcome;
|
|
18611
18958
|
};
|
|
18959
|
+
/**
|
|
18960
|
+
* The instant `pr:publish` measures `verify` staleness from (#1045). Read
|
|
18961
|
+
* before the promotion, so every check run that already existed on the head
|
|
18962
|
+
* sorts strictly before it.
|
|
18963
|
+
*/
|
|
18964
|
+
const promotionInstant = (dependencies) => (dependencies.now ?? defaultNow)().toISOString();
|
|
18965
|
+
/**
|
|
18966
|
+
* The staleness cutoff to hand the hosted-run await — present only when this
|
|
18967
|
+
* invocation actually promoted the candidate. Without a promotion there is no
|
|
18968
|
+
* draft-era `verify` to distrust, and the wait keeps today's behaviour.
|
|
18969
|
+
*/
|
|
18970
|
+
const promotionVerifyCutoff = (promotion, promotionStartedAt) => promotion.status === "succeeded" ? { verifyNotBefore: promotionStartedAt } : {};
|
|
18612
18971
|
const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
|
|
18613
18972
|
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.`);
|
|
18614
18973
|
};
|
|
@@ -18816,6 +19175,7 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
18816
19175
|
json: false,
|
|
18817
19176
|
report: false
|
|
18818
19177
|
};
|
|
19178
|
+
const promotionStartedAt = promotionInstant(dependencies);
|
|
18819
19179
|
const promotion = await promoteDraftBeforeDemandEvaluation({
|
|
18820
19180
|
cwd,
|
|
18821
19181
|
expectedHeadSha: verifyProof.headSha,
|
|
@@ -18828,7 +19188,8 @@ async function runPrPublish(args, dependencies = {}) {
|
|
|
18828
19188
|
await awaitHostedChecksBeforeReadiness({
|
|
18829
19189
|
dependencies,
|
|
18830
19190
|
expectedHeadSha: verifyProof.headSha,
|
|
18831
|
-
fetchPr
|
|
19191
|
+
fetchPr,
|
|
19192
|
+
...promotionVerifyCutoff(promotion, promotionStartedAt)
|
|
18832
19193
|
});
|
|
18833
19194
|
const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
|
|
18834
19195
|
requirePromotedProofHead({
|
|
@@ -18980,7 +19341,7 @@ function assertForegroundGate({ gate, env = process.env }) {
|
|
|
18980
19341
|
//#endregion
|
|
18981
19342
|
//#region src/commands/pr-review.ts
|
|
18982
19343
|
function createPrReviewCommand(output, action) {
|
|
18983
|
-
return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or 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$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. Use --dispositions for the findings its fixes answered").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; --assemble also loads Review focus from this issue", positiveInteger$1("--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({
|
|
19344
|
+
return markCwdOptionDefault(new Command("pr:review").description("Assemble a factory-owned review prompt, or 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$1("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--assemble", "write the factory-owned review prompt for a clean session; do not validate findings").option("--epic <number>", "issue carrying the factory-boundary manifest, which may be the work issue itself; --assemble then asks the reviewer for the findings schema the wave-demanded review rung accepts (#1001)", positiveInteger$1("--epic")).option("--findings <path>", "typed clean-session findings JSON file").option("--historical", "record findings that read an earlier commit on this branch: the cycle joins the ladder as prior history and renders no verdict of its own, so it never admits. It leaves the candidate's recorded verdict untouched. Use --dispositions for the findings its fixes answered").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; --assemble also loads Review focus from this issue", positiveInteger$1("--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({
|
|
18984
19345
|
gate: "pr:review",
|
|
18985
19346
|
resolveCycle: (options) => options.cycle,
|
|
18986
19347
|
resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
|
|
@@ -18998,6 +19359,7 @@ function createPrReviewCommand(output, action) {
|
|
|
18998
19359
|
cwd: resolveCwdOption(options.cwd),
|
|
18999
19360
|
cycle: options.cycle,
|
|
19000
19361
|
dispositions: options.dispositions,
|
|
19362
|
+
epic: options.epic,
|
|
19001
19363
|
findings: options.findings,
|
|
19002
19364
|
...options.historical === true ? { historical: true } : {},
|
|
19003
19365
|
issue: options.issue,
|