@patronage/software-factory 1.0.0-alpha.41 → 1.0.0-alpha.43
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 +13 -1
- package/dist/index.js +460 -110
- package/dist/schemas.js +1 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -228,7 +228,7 @@ interface GithubIssueComment {
|
|
|
228
228
|
}
|
|
229
229
|
declare const normalizeIssueComments: (value: GithubIssueCommentApi[] | GithubIssueCommentApi[][]) => GithubIssueComment[];
|
|
230
230
|
declare namespace boundary_review_proof_d_exports {
|
|
231
|
-
export { BOUNDARY_REVIEW_PROOF_KIND, BoundaryReviewProof, BoundaryReviewProofComment, boundaryReviewProofAuthorsSeen, boundaryReviewProofSchema, selectBoundaryReviewProof, undispositionedBlockingFindings };
|
|
231
|
+
export { BOUNDARY_REVIEW_PROOF_KIND, BoundaryReviewProof, BoundaryReviewProofComment, BoundaryReviewProofParseFailure, boundaryReviewProofAuthorsSeen, boundaryReviewProofParseFailures, boundaryReviewProofSchema, selectBoundaryReviewProof, undispositionedBlockingFindings };
|
|
232
232
|
}
|
|
233
233
|
declare const BOUNDARY_REVIEW_PROOF_KIND = "boundary-review-proof";
|
|
234
234
|
declare const boundaryReviewProofBaseSchema: z.ZodObject<{
|
|
@@ -325,8 +325,14 @@ interface BoundaryReviewProofComment {
|
|
|
325
325
|
commentId?: string | number;
|
|
326
326
|
commentUrl?: string;
|
|
327
327
|
}
|
|
328
|
+
interface BoundaryReviewProofParseFailure {
|
|
329
|
+
commentAuthor: string;
|
|
330
|
+
commentUrl?: string;
|
|
331
|
+
parseError: string;
|
|
332
|
+
}
|
|
328
333
|
declare const selectBoundaryReviewProof: (comments: GithubIssueComment[], boundary: string, declaredBy: string) => BoundaryReviewProofComment | undefined;
|
|
329
334
|
declare const boundaryReviewProofAuthorsSeen: (comments: GithubIssueComment[], boundary: string) => string[];
|
|
335
|
+
declare const boundaryReviewProofParseFailures: (comments: GithubIssueComment[], boundary: string) => BoundaryReviewProofParseFailure[];
|
|
330
336
|
declare const undispositionedBlockingFindings: (proof: BoundaryReviewProof) => string[];
|
|
331
337
|
//#endregion
|
|
332
338
|
//#region src/boundary-membership-graph.d.ts
|
|
@@ -2500,6 +2506,12 @@ interface CompletedFactoryCheckSnapshot {
|
|
|
2500
2506
|
readonly detailsUrl?: string;
|
|
2501
2507
|
readonly headSha: string;
|
|
2502
2508
|
readonly name: FactoryPrStatusCheckName;
|
|
2509
|
+
/**
|
|
2510
|
+
* Merge freeze only (#1501): the cancellation-only generations, newest
|
|
2511
|
+
* first, that the freeze selection read past to reach this snapshot's
|
|
2512
|
+
* generation (#1158). Absent when the newest generation stands.
|
|
2513
|
+
*/
|
|
2514
|
+
readonly readPastRunIds?: readonly number[];
|
|
2503
2515
|
}
|
|
2504
2516
|
interface FailedPreviewSnapshot {
|
|
2505
2517
|
readonly completedAt: string;
|
package/dist/index.js
CHANGED
|
@@ -22,7 +22,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
22
22
|
import { pathToFileURL } from "node:url";
|
|
23
23
|
//#region package.json
|
|
24
24
|
var name = "@patronage/software-factory";
|
|
25
|
-
var version = "1.0.0-alpha.
|
|
25
|
+
var version = "1.0.0-alpha.43";
|
|
26
26
|
//#endregion
|
|
27
27
|
//#region src/git-globals-parse.ts
|
|
28
28
|
const VALUE_TAKING_GIT_OPTIONS = new Set([
|
|
@@ -2507,6 +2507,7 @@ const coveredSetDivergences = (computed, proof) => {
|
|
|
2507
2507
|
var boundary_review_proof_exports = /* @__PURE__ */ __exportAll({
|
|
2508
2508
|
BOUNDARY_REVIEW_PROOF_KIND: () => BOUNDARY_REVIEW_PROOF_KIND$1,
|
|
2509
2509
|
boundaryReviewProofAuthorsSeen: () => boundaryReviewProofAuthorsSeen,
|
|
2510
|
+
boundaryReviewProofParseFailures: () => boundaryReviewProofParseFailures,
|
|
2510
2511
|
boundaryReviewProofSchema: () => boundaryReviewProofSchema$1,
|
|
2511
2512
|
selectBoundaryReviewProof: () => selectBoundaryReviewProof,
|
|
2512
2513
|
undispositionedBlockingFindings: () => undispositionedBlockingFindings
|
|
@@ -2561,8 +2562,11 @@ const boundaryReviewProofSchema$1 = z.object({
|
|
|
2561
2562
|
}).passthrough().optional()
|
|
2562
2563
|
}).passthrough();
|
|
2563
2564
|
const FENCED_JSON_PATTERN = /```json[ \t]*\r?\n(?<inner>[\s\S]*?)```/gu;
|
|
2564
|
-
const
|
|
2565
|
-
const
|
|
2565
|
+
const scanBoundaryReviewProofs = (comments, boundary) => {
|
|
2566
|
+
const scan = {
|
|
2567
|
+
failed: [],
|
|
2568
|
+
valid: []
|
|
2569
|
+
};
|
|
2566
2570
|
for (const comment of comments) {
|
|
2567
2571
|
const commentAuthor = comment.author.login;
|
|
2568
2572
|
if (!commentAuthor) continue;
|
|
@@ -2576,8 +2580,17 @@ const collectValidBoundaryReviewProofs = (comments, boundary) => {
|
|
|
2576
2580
|
}
|
|
2577
2581
|
if (typeof raw !== "object" || raw === null || raw.kind !== "boundary-review-proof") continue;
|
|
2578
2582
|
const parsed = boundaryReviewProofSchema$1.safeParse(raw);
|
|
2579
|
-
if (!parsed.success
|
|
2580
|
-
|
|
2583
|
+
if (!parsed.success) {
|
|
2584
|
+
const rawBoundary = raw.boundary;
|
|
2585
|
+
if (typeof rawBoundary !== "string" || rawBoundary === boundary) scan.failed.push({
|
|
2586
|
+
commentAuthor,
|
|
2587
|
+
...comment.url === void 0 ? {} : { commentUrl: comment.url },
|
|
2588
|
+
parseError: formatZodIssues(parsed.error)
|
|
2589
|
+
});
|
|
2590
|
+
continue;
|
|
2591
|
+
}
|
|
2592
|
+
if (parsed.data.boundary !== boundary) continue;
|
|
2593
|
+
scan.valid.push({
|
|
2581
2594
|
commentAuthor,
|
|
2582
2595
|
...comment.id === void 0 ? {} : { commentId: comment.id },
|
|
2583
2596
|
...comment.url === void 0 ? {} : { commentUrl: comment.url },
|
|
@@ -2585,12 +2598,12 @@ const collectValidBoundaryReviewProofs = (comments, boundary) => {
|
|
|
2585
2598
|
});
|
|
2586
2599
|
}
|
|
2587
2600
|
}
|
|
2588
|
-
return
|
|
2601
|
+
return scan;
|
|
2589
2602
|
};
|
|
2590
2603
|
const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
|
|
2591
2604
|
let selected;
|
|
2592
2605
|
const declaredByLower = declaredBy.toLowerCase();
|
|
2593
|
-
for (const entry of
|
|
2606
|
+
for (const entry of scanBoundaryReviewProofs(comments, boundary).valid) {
|
|
2594
2607
|
if (entry.commentAuthor.toLowerCase() !== declaredByLower) continue;
|
|
2595
2608
|
selected = {
|
|
2596
2609
|
...entry.commentId === void 0 ? {} : { commentId: entry.commentId },
|
|
@@ -2602,9 +2615,10 @@ const selectBoundaryReviewProof = (comments, boundary, declaredBy) => {
|
|
|
2602
2615
|
};
|
|
2603
2616
|
const boundaryReviewProofAuthorsSeen = (comments, boundary) => {
|
|
2604
2617
|
const authors = /* @__PURE__ */ new Set();
|
|
2605
|
-
for (const entry of
|
|
2618
|
+
for (const entry of scanBoundaryReviewProofs(comments, boundary).valid) authors.add(entry.commentAuthor);
|
|
2606
2619
|
return [...authors];
|
|
2607
2620
|
};
|
|
2621
|
+
const boundaryReviewProofParseFailures = (comments, boundary) => scanBoundaryReviewProofs(comments, boundary).failed;
|
|
2608
2622
|
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`);
|
|
2609
2623
|
//#endregion
|
|
2610
2624
|
//#region src/boundary-topology.ts
|
|
@@ -3030,6 +3044,42 @@ const waivedDemandSchema = z.object({
|
|
|
3030
3044
|
session: z.string().trim().min(1).optional(),
|
|
3031
3045
|
unmetReasons: z.array(z.string().min(1)).min(1)
|
|
3032
3046
|
});
|
|
3047
|
+
/**
|
|
3048
|
+
* The demands an operator waiver can reach (#354; moved out of `pr:ready` by
|
|
3049
|
+
* #1492 so `demand:waive` refuses by the same contract every reader folds by,
|
|
3050
|
+
* and can never record an authorization nothing applies).
|
|
3051
|
+
*
|
|
3052
|
+
* `pr:ready` folds the merge freeze, the profile's external required checks
|
|
3053
|
+
* and the boundary wave's review rung — the demands the resolver puts *in
|
|
3054
|
+
* force* for one candidate — plus the review-round limit (#1280), which exists
|
|
3055
|
+
* only to be waived and which `pr:review --assemble` folds too.
|
|
3056
|
+
* `boundary:check --final` folds the closeout pin `child-issue-open` (#901);
|
|
3057
|
+
* readiness never raises that demand, so naming it here widens nothing at
|
|
3058
|
+
* `pr:ready`.
|
|
3059
|
+
*
|
|
3060
|
+
* Everything else stays unwaivable. Structural refusals — candidate identity,
|
|
3061
|
+
* proof binding, an unreadable demand authority, wave membership — were never
|
|
3062
|
+
* waivable at merge time and are not waivable now; in `pr:ready` most of them
|
|
3063
|
+
* refuse by throwing before any blocker list exists. The evidence gates
|
|
3064
|
+
* readiness has always owned (verify, review modes, draft, human blockers) and
|
|
3065
|
+
* GitHub's own check and merge-state rollups are satisfied or not; a waiver is
|
|
3066
|
+
* not a second way to pass them.
|
|
3067
|
+
*/
|
|
3068
|
+
const WAIVABLE_DEMAND_KEYS = [
|
|
3069
|
+
DEMAND_KEYS.mergeFreeze,
|
|
3070
|
+
DEMAND_KEYS.reviewRoundLimit,
|
|
3071
|
+
DEMAND_KEYS.childIssueOpen
|
|
3072
|
+
];
|
|
3073
|
+
/** Every instance of these qualified families is waivable at `pr:ready`. */
|
|
3074
|
+
const WAIVABLE_QUALIFIED_DEMAND_FAMILIES = ["required-check", "review-rung"];
|
|
3075
|
+
const isWaivableDemand = (demand) => WAIVABLE_DEMAND_KEYS.includes(demand) || WAIVABLE_QUALIFIED_DEMAND_FAMILIES.some((family) => demand.startsWith(`${family}:`));
|
|
3076
|
+
/**
|
|
3077
|
+
* Why `demand:waive` refuses a well-formed key outside that contract. It names
|
|
3078
|
+
* the key and the waivable set, built from the same lists the predicate reads,
|
|
3079
|
+
* and says nothing about the demand itself: whether it is in force or met is
|
|
3080
|
+
* the reader's to decide, and a refused waiver changes neither.
|
|
3081
|
+
*/
|
|
3082
|
+
const ineligibleWaiverRefusal = (demand) => `demand:waive refuses demand key "${demand}": no factory command applies a waiver of it, so a recorded waiver would authorize nothing. A waiver can name ${WAIVABLE_DEMAND_KEYS.join(", ")}, or a ${WAIVABLE_QUALIFIED_DEMAND_FAMILIES.map((family) => `${family}:<value>`).join(" or ")} key.`;
|
|
3033
3083
|
/** The waivers that bind one candidate: same PR, same head. */
|
|
3034
3084
|
const selectWaiversForCandidate = ({ headSha, pr, waivers }) => headSha === void 0 ? [] : waivers.filter((waiver) => waiver.candidate.pr === pr && waiver.candidate.headSha === headSha);
|
|
3035
3085
|
/**
|
|
@@ -8835,7 +8885,7 @@ const readTransportedVerifyProof = (value, text) => {
|
|
|
8835
8885
|
*/
|
|
8836
8886
|
const parsePrVerifyCheckPayload = (text) => {
|
|
8837
8887
|
if (!text?.trim()) return;
|
|
8838
|
-
const candidate = (text.match(/```(?:json)?\s*(?<body>[\s\S]*?)```/u)?.groups?.body ?? text).trim();
|
|
8888
|
+
const candidate = (text.match(/```(?:json)?\s*(?<body>[\s\S]*?)\n```/u)?.groups?.body ?? text).trim();
|
|
8839
8889
|
try {
|
|
8840
8890
|
const parsed = JSON.parse(candidate);
|
|
8841
8891
|
if (parsed.schemaVersion !== PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION || parsed.kind !== "pr-verify-proof-binding" || typeof parsed.headSha !== "string" || parsed.headSha.length === 0 || !isResolvedPrVerifyMode(parsed.mode) || !isOutcome(parsed.outcome)) return;
|
|
@@ -9632,63 +9682,199 @@ const completeCheckRunPages = (pages) => {
|
|
|
9632
9682
|
const runs = pages.flatMap((page) => page.check_runs);
|
|
9633
9683
|
return pages.every((page) => page.total_count === runs.length) ? runs : void 0;
|
|
9634
9684
|
};
|
|
9685
|
+
/** The only instant shape either pr-verify reader takes from a typed proof. */
|
|
9686
|
+
const CANONICAL_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/u;
|
|
9687
|
+
/** An instant in epoch milliseconds, rounded down to its whole second. */
|
|
9688
|
+
const wholeSecondMs = (ms) => Math.floor(ms / 1e3) * 1e3;
|
|
9689
|
+
/**
|
|
9690
|
+
* When the verification a transported typed proof records started: its
|
|
9691
|
+
* `endedAt` minus its `durationMs`, rounded down to the whole second. Nothing
|
|
9692
|
+
* when `endedAt` is not canonical UTC or `durationMs` is not a finite
|
|
9693
|
+
* non-negative number; the run is then ordered like one without a typed proof.
|
|
9694
|
+
*/
|
|
9695
|
+
const proofStartMs = (proof) => {
|
|
9696
|
+
if (proof === void 0 || !CANONICAL_UTC.test(proof.endedAt)) return;
|
|
9697
|
+
const { durationMs } = proof;
|
|
9698
|
+
if (!(Number.isFinite(durationMs) && durationMs >= 0)) return;
|
|
9699
|
+
const startMs = Date.parse(proof.endedAt) - durationMs;
|
|
9700
|
+
return Number.isFinite(startMs) ? wholeSecondMs(startMs) : void 0;
|
|
9701
|
+
};
|
|
9702
|
+
/**
|
|
9703
|
+
* Whether a run is a pass: completed with conclusion `success`, and its bound
|
|
9704
|
+
* payload has outcome `passed`. The hosted gate's jq calls this `passing`.
|
|
9705
|
+
*/
|
|
9706
|
+
const isPassingRun = (run, payload) => payload !== void 0 && run.status === "completed" && run.conclusion === "success" && payload.outcome === "passed";
|
|
9707
|
+
/**
|
|
9708
|
+
* When a passing run's verification started, by its transported typed proof
|
|
9709
|
+
* (`proofStartMs`). Nothing for any other run, which is ordered at its
|
|
9710
|
+
* `started_at` whatever proof it carries (#1506).
|
|
9711
|
+
*/
|
|
9712
|
+
const passingProofStartMs = (run, payload) => isPassingRun(run, payload) ? proofStartMs(payload.verificationProof) : void 0;
|
|
9635
9713
|
/** The same bound payload with the same status and conclusion. */
|
|
9636
9714
|
const sameVerdict = (one, other) => one.payload !== void 0 && (one.run.status ?? "") === (other.run.status ?? "") && (one.run.conclusion ?? "") === (other.run.conclusion ?? "") && isDeepStrictEqual(one.payload, other.payload);
|
|
9637
9715
|
/**
|
|
9638
|
-
*
|
|
9639
|
-
*
|
|
9640
|
-
*
|
|
9641
|
-
* this reader:
|
|
9642
|
-
*
|
|
9643
|
-
* - A run whose bound payload transports a typed proof is ordered at the
|
|
9644
|
-
* `endedAt` that proof records, never by when it reached GitHub. Runs that
|
|
9645
|
-
* carry the same such payload with the same status and conclusion are one
|
|
9646
|
-
* generation posted more than once (`pr:verify` posts, `pr:publish`
|
|
9647
|
-
* re-posts), so an older pass re-posted after a newer failure does not
|
|
9648
|
-
* outrank it, and the normal post plus re-post is not a tie.
|
|
9649
|
-
* - Any other run is its own generation at its `started_at`: no bound payload,
|
|
9650
|
-
* or a proof over the text cap that travels without its typed proof. The
|
|
9651
|
-
* factory records a proof before it posts it, so that is the latest its
|
|
9652
|
-
* proof could have been recorded, and it still vetoes every pass recorded
|
|
9653
|
-
* before it was posted.
|
|
9654
|
-
* - Such a run carries no generation identity, so it is never grouped. Two
|
|
9655
|
-
* separate executions with the same command names and verdict post the same
|
|
9656
|
-
* compact payload, and so does a re-post. When the newest generation is one
|
|
9657
|
-
* of these and repeats another run's verdict (`sameVerdict`), it could be a
|
|
9658
|
-
* re-post of an older verdict or a new one, and nothing is trusted. Proofs
|
|
9659
|
-
* that size are rare, so refusing costs one fresh verification.
|
|
9660
|
-
* - Two different generations at the newest instant refuse, by the shared
|
|
9661
|
-
* sequencing owner's rule.
|
|
9662
|
-
*
|
|
9663
|
-
* Factory-ci's hosted proof-reuse gate applies the same rule in jq.
|
|
9716
|
+
* Every generation among the served runs, ordered and grouped by the rule
|
|
9717
|
+
* `newestVerifyProofGeneration` states, and every run's post with the
|
|
9718
|
+
* generation it belongs to.
|
|
9664
9719
|
*/
|
|
9665
|
-
const
|
|
9720
|
+
const verifyProofGenerations = (runs, bound) => {
|
|
9666
9721
|
const generations = [];
|
|
9722
|
+
const posts = [];
|
|
9667
9723
|
for (const run of runs) {
|
|
9668
9724
|
const parsed = parsePrVerifyCheckPayload(run.output?.text) ?? parsePrVerifyCheckPayload(run.output?.summary);
|
|
9669
9725
|
const payload = run.head_sha === bound.sha && parsed?.headSha === bound.sha && parsed.repository === bound.repository ? parsed : void 0;
|
|
9670
|
-
const
|
|
9671
|
-
|
|
9672
|
-
|
|
9673
|
-
|
|
9726
|
+
const postedMs = wholeSecondMs(generationOrderMs(run.started_at));
|
|
9727
|
+
const startMs = passingProofStartMs(run, payload);
|
|
9728
|
+
if (payload === void 0 || startMs === void 0) {
|
|
9729
|
+
const generation = {
|
|
9730
|
+
orderMs: postedMs,
|
|
9674
9731
|
...payload === void 0 ? {} : { payload },
|
|
9675
9732
|
recorded: false,
|
|
9676
9733
|
run
|
|
9734
|
+
};
|
|
9735
|
+
generations.push(generation);
|
|
9736
|
+
posts.push({
|
|
9737
|
+
generation,
|
|
9738
|
+
postedMs
|
|
9677
9739
|
});
|
|
9678
9740
|
continue;
|
|
9679
9741
|
}
|
|
9680
9742
|
const generation = {
|
|
9681
|
-
orderMs:
|
|
9743
|
+
orderMs: Math.min(startMs, postedMs),
|
|
9682
9744
|
payload,
|
|
9683
9745
|
recorded: true,
|
|
9684
9746
|
run
|
|
9685
9747
|
};
|
|
9686
|
-
|
|
9748
|
+
const earlier = generations.find((candidate) => candidate.recorded && sameVerdict(candidate, generation));
|
|
9749
|
+
if (earlier === void 0) generations.push(generation);
|
|
9750
|
+
else earlier.orderMs = Math.min(earlier.orderMs, generation.orderMs);
|
|
9751
|
+
posts.push({
|
|
9752
|
+
generation: earlier ?? generation,
|
|
9753
|
+
postedMs
|
|
9754
|
+
});
|
|
9687
9755
|
}
|
|
9756
|
+
return {
|
|
9757
|
+
generations,
|
|
9758
|
+
posts
|
|
9759
|
+
};
|
|
9760
|
+
};
|
|
9761
|
+
/**
|
|
9762
|
+
* The newest generation of the `pr:verify` proof at one head, or nothing when
|
|
9763
|
+
* no single generation can be trusted. A passing generation is ordered by when
|
|
9764
|
+
* its verification started, never later than when its run reached GitHub and
|
|
9765
|
+
* never by when its verification ended (#1409, #1490). Every other run is
|
|
9766
|
+
* ordered by when it reached GitHub (#1506):
|
|
9767
|
+
*
|
|
9768
|
+
* - A passing run is completed with conclusion `success`, and its bound
|
|
9769
|
+
* payload has outcome `passed`. When that payload transports a typed proof
|
|
9770
|
+
* whose start is computable (`proofStartMs`: its `endedAt` minus its
|
|
9771
|
+
* `durationMs`), the run is ordered at that start, or at its own
|
|
9772
|
+
* `started_at` when that is earlier. Runs that carry the same such payload
|
|
9773
|
+
* with the same status and conclusion are one generation posted more than
|
|
9774
|
+
* once (`pr:verify` posts, `pr:publish` re-posts), ordered at the earliest of
|
|
9775
|
+
* its members' instants, whatever order GitHub serves them in. A re-post
|
|
9776
|
+
* does not move the proof's start, so an older pass re-posted after a newer
|
|
9777
|
+
* failure does not outrank it, and the normal post plus re-post is not a
|
|
9778
|
+
* tie.
|
|
9779
|
+
* - Any other run is its own generation at its `started_at`: one still
|
|
9780
|
+
* running, a failure or cancellation whether or not it carries a proof, one
|
|
9781
|
+
* with no bound payload (including one whose transported proof fails the
|
|
9782
|
+
* proof schema, which drops the whole payload), or a pass with no usable
|
|
9783
|
+
* typed proof (over the text cap, or with no computable start). A run is
|
|
9784
|
+
* created no earlier than its verification began, so `started_at` is an
|
|
9785
|
+
* upper bound on that start. Ranking there can only make such a run newer
|
|
9786
|
+
* than it is, so it vetoes more, never less.
|
|
9787
|
+
* - Neither `endedAt` nor a passing run's `started_at` is its start. Every
|
|
9788
|
+
* writer posts its `pr-verify` run only once verification has finished
|
|
9789
|
+
* (`pr:verify` on success or failure, `pr:publish` on re-post), so both say
|
|
9790
|
+
* when it ended, and an older pass ordered there would hide a run that
|
|
9791
|
+
* started after the pass began: still running, or failed (#1490). A pass's
|
|
9792
|
+
* `started_at` only caps its proof's start. A failure's `started_at` is when
|
|
9793
|
+
* it was posted, not when it started, and that is deliberate: a veto's
|
|
9794
|
+
* instant only has to be no earlier than its start.
|
|
9795
|
+
* - Only a pass is ordered by its proof because the hosted gate cannot run the
|
|
9796
|
+
* proof schema this reader runs (#1506). Ordered by its proof's claimed
|
|
9797
|
+
* start there, a failure carrying a malformed proof ranked under an older
|
|
9798
|
+
* pass, and the gate reused the pass where this reader refused. For an
|
|
9799
|
+
* honest proof, `started_at` is no earlier than `endedAt`, which is no
|
|
9800
|
+
* earlier than the start, so the cap changes nothing for a valid pass, and
|
|
9801
|
+
* it also caps a schema-valid proof from a skewed clock.
|
|
9802
|
+
* - A pass is reused only when its verification started strictly after every
|
|
9803
|
+
* other run at this head was posted (#1506). Its start is its instant
|
|
9804
|
+
* (`orderMs`): its proof's start, capped as above, or its `started_at` when
|
|
9805
|
+
* it is ordered there. Every other run's post is its `started_at`, rounded
|
|
9806
|
+
* down to the whole second. Only the pass's own generation is left out: its
|
|
9807
|
+
* re-post group, or, for a pass with no typed proof, that run alone. At most
|
|
9808
|
+
* one generation can satisfy this, and that one is the newest, so this
|
|
9809
|
+
* reader selects the newest generation and, when it is a pass
|
|
9810
|
+
* (`isPassingRun`), trusts nothing unless every other run was posted before
|
|
9811
|
+
* its instant (`postedSinceItStarted`). When the newest generation is not a
|
|
9812
|
+
* pass, the caller refuses reuse anyway, so it is returned as before.
|
|
9813
|
+
* - The check exists because a run may be ordered earlier than its post by
|
|
9814
|
+
* its proof. This reader validates proofs, so for it the check only refuses
|
|
9815
|
+
* a valid pass that started before another pass was posted. The hosted gate
|
|
9816
|
+
* cannot validate them: without the check, a successful run whose malformed
|
|
9817
|
+
* proof claimed an early start ranked under an older pass there, whether
|
|
9818
|
+
* that pass was ordered by its proof or at its `started_at`, and the gate
|
|
9819
|
+
* reused the older pass where this reader, which drops that payload and
|
|
9820
|
+
* ranks the run at its `started_at`, refused. Both readers apply the check
|
|
9821
|
+
* so they agree. With it, whether a pass may be reused never consults
|
|
9822
|
+
* another run's proof fields, only when each other run was posted, which
|
|
9823
|
+
* GitHub records. In the hosted gate those fields can only cause a refusal;
|
|
9824
|
+
* they never hide a veto. The pass's own clock is capped at its post, so it
|
|
9825
|
+
* can only count against that pass.
|
|
9826
|
+
* - Two changes on valid input follow, and both only add refusals, each
|
|
9827
|
+
* costing one fresh verification in a rare case. A failure carrying a proof
|
|
9828
|
+
* is ordered when it was posted instead of when it started, so an older
|
|
9829
|
+
* failure that finished after a newer pass began now refuses reuse. Two pass
|
|
9830
|
+
* generations whose verifications overlapped now refuse, as does an older
|
|
9831
|
+
* pass re-posted after a newer pass started. Passes that ran one after the
|
|
9832
|
+
* other, and a pass with its own re-posts, are reused as before.
|
|
9833
|
+
* - The hosted gate's trust boundary is a class. The gate does not
|
|
9834
|
+
* reimplement this reader's payload parser. On payloads the factory's
|
|
9835
|
+
* writer emits, both readers select the same pass or both refuse: the
|
|
9836
|
+
* differential suite, `pr-verify-reuse-differential.test.ts`, asserts it
|
|
9837
|
+
* over 9,653 generated run sets. On App-signed text the writer never emits,
|
|
9838
|
+
* the two can differ in either direction. The known instances where the
|
|
9839
|
+
* hosted gate reuses and this reader refuses are: (a) a reused run whose
|
|
9840
|
+
* typed proof fails `validatePrVerifyProof`; (b) a newest pass that repeats
|
|
9841
|
+
* another run's verdict only once this reader normalizes that run's
|
|
9842
|
+
* payload, say by dropping a key; (c) a payload in `output.summary` beside
|
|
9843
|
+
* a text payload this reader cannot read, which it reads from the summary
|
|
9844
|
+
* and the hosted gate from the text; (d) text the gate's strip-and-`fromjson`
|
|
9845
|
+
* reads and this reader's fence match or `JSON.parse` refuses, such as a
|
|
9846
|
+
* closing fence with no opening one, a doubled opening fence, a fence line
|
|
9847
|
+
* inside the JSON, a payload split across two fenced blocks, or a number
|
|
9848
|
+
* literal such as `NaN` where the gate's jq accepts it (jq 1.8 does).
|
|
9849
|
+
* Mirroring any of it would turn the gate's jq into a parser. Only the
|
|
9850
|
+
* factory App can post the run, and that identity bounds the whole class
|
|
9851
|
+
* (#1511).
|
|
9852
|
+
* - A run not ordered by a typed proof carries no generation identity, so it
|
|
9853
|
+
* is never grouped. Two separate executions with the same command names and
|
|
9854
|
+
* verdict post the same compact payload, and so does a re-post. When the
|
|
9855
|
+
* newest generation is one of these and repeats another run's verdict
|
|
9856
|
+
* (`sameVerdict`), it could be a re-post of an older verdict or a new one,
|
|
9857
|
+
* and nothing is trusted. Proofs that size are rare, so refusing costs one
|
|
9858
|
+
* fresh verification.
|
|
9859
|
+
* - Two different generations at the newest instant refuse, by the shared
|
|
9860
|
+
* sequencing owner's rule.
|
|
9861
|
+
*
|
|
9862
|
+
* Every instant is rounded down to its whole second (`wholeSecondMs`), as the
|
|
9863
|
+
* hosted gate's jq must: it compares epoch seconds. Rounding down is monotonic,
|
|
9864
|
+
* so it can merge two instants into a tie, which refuses, but never invert
|
|
9865
|
+
* them. It also fails closed: a proof's start never moves later, so it never
|
|
9866
|
+
* outranks an unbound run in the same second.
|
|
9867
|
+
*
|
|
9868
|
+
* Factory-ci's hosted proof-reuse gate applies the same rule in jq.
|
|
9869
|
+
*/
|
|
9870
|
+
const newestVerifyProofGeneration = (runs, bound) => {
|
|
9871
|
+
const { generations, posts } = verifyProofGenerations(runs, bound);
|
|
9688
9872
|
const selected = selectNewestGeneration(generations);
|
|
9689
9873
|
if (selected.kind !== "newest") return;
|
|
9690
9874
|
const newest = selected.generation;
|
|
9691
|
-
|
|
9875
|
+
const repeatsAnotherRun = !newest.recorded && newest.payload !== void 0 && generations.some((generation) => generation !== newest && sameVerdict(generation, newest));
|
|
9876
|
+
const postedSinceItStarted = isPassingRun(newest.run, newest.payload) && posts.some((post) => post.generation !== newest && post.postedMs >= newest.orderMs);
|
|
9877
|
+
return repeatsAnotherRun || postedSinceItStarted ? void 0 : newest;
|
|
9692
9878
|
};
|
|
9693
9879
|
/**
|
|
9694
9880
|
* Read the machine-readable `pr:verify` binding for one commit (#247).
|
|
@@ -11003,8 +11189,21 @@ var DemandWaiveRefusalError = class extends Error {
|
|
|
11003
11189
|
this.refusals = refusals;
|
|
11004
11190
|
}
|
|
11005
11191
|
};
|
|
11006
|
-
/**
|
|
11007
|
-
|
|
11192
|
+
/**
|
|
11193
|
+
* Refusals about the named key: malformed, or well-formed but outside the one
|
|
11194
|
+
* eligibility contract the readers fold by (#1492). An ineligible key refuses
|
|
11195
|
+
* here, before anything is written, so the store never holds an apparent
|
|
11196
|
+
* operator authorization that no command applies.
|
|
11197
|
+
*/
|
|
11198
|
+
const demandKeyRefusals = ({ demand, parsedDemand }) => {
|
|
11199
|
+
if (!parsedDemand.success) return [`demand:waive refuses demand key "${demand}": a demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).`];
|
|
11200
|
+
return isWaivableDemand(parsedDemand.data) ? [] : [ineligibleWaiverRefusal(parsedDemand.data)];
|
|
11201
|
+
};
|
|
11202
|
+
/** Refusals about the request itself: the key, a missing rationale. */
|
|
11203
|
+
const requestRefusals = ({ demand, parsedDemand, rationale }) => [...demandKeyRefusals({
|
|
11204
|
+
demand,
|
|
11205
|
+
parsedDemand
|
|
11206
|
+
}), ...rationale.length === 0 ? ["demand:waive requires a non-empty --rationale."] : []];
|
|
11008
11207
|
/**
|
|
11009
11208
|
* Refusals about the candidate the waiver would bind to. A waiver names one
|
|
11010
11209
|
* pushed head. For ordinary demands that head must be the one verification
|
|
@@ -12320,10 +12519,14 @@ const defaultResolveRepo = (cwd) => runCapture("gh", [
|
|
|
12320
12519
|
"--jq",
|
|
12321
12520
|
".nameWithOwner"
|
|
12322
12521
|
], cwd).stdout.trim();
|
|
12323
|
-
const
|
|
12522
|
+
const parseFailureReason = ({ commentAuthor, commentUrl, parseError }) => `found a boundary-review-proof block from ${commentAuthor}${commentUrl === void 0 ? "" : ` (${commentUrl})`} that failed to parse: ${parseError}; edit that comment so the block matches the boundary-review proof schema — an unparseable proof satisfies nothing`;
|
|
12523
|
+
const evaluateProofAgainstDemands = ({ boundary, comments, computed, declaredBy, demandedRung, manifestHash, selected }) => {
|
|
12324
12524
|
if (!selected) {
|
|
12325
|
-
|
|
12326
|
-
|
|
12525
|
+
const authorsSeen = boundaryReviewProofAuthorsSeen(comments, boundary);
|
|
12526
|
+
const blockingReasons = boundaryReviewProofParseFailures(comments, boundary).map(parseFailureReason);
|
|
12527
|
+
if (authorsSeen.length > 0) blockingReasons.push(`boundary-review proof block(s) found on the epic issue, but none authored by declaredBy "${declaredBy}" (authors seen: ${authorsSeen.join(", ")}); run the boundary review at the ${demandedRung} rung from that account, or correct declaredBy if it names the wrong one`);
|
|
12528
|
+
if (blockingReasons.length === 0) blockingReasons.push(`no boundary-review proof found on the epic issue; run the boundary review at the ${demandedRung} rung and post its fenced JSON proof comment — absence fails closed`);
|
|
12529
|
+
return { blockingReasons };
|
|
12327
12530
|
}
|
|
12328
12531
|
const { proof } = selected;
|
|
12329
12532
|
const blockingReasons = [];
|
|
@@ -12350,7 +12553,8 @@ function runBoundaryCheck(args, dependencies = {}) {
|
|
|
12350
12553
|
const comments = github.fetchIssueComments(repo, args.epic);
|
|
12351
12554
|
const selected = selectBoundaryReviewProof(comments, parsed.manifest.boundary, parsed.manifest.declaredBy);
|
|
12352
12555
|
const { blockingReasons } = evaluateProofAgainstDemands({
|
|
12353
|
-
|
|
12556
|
+
boundary: parsed.manifest.boundary,
|
|
12557
|
+
comments,
|
|
12354
12558
|
computed,
|
|
12355
12559
|
declaredBy: parsed.manifest.declaredBy,
|
|
12356
12560
|
demandedRung,
|
|
@@ -17920,50 +18124,161 @@ const commitParentsSchema = z.object({
|
|
|
17920
18124
|
parents: z.array(z.object({ sha: shaSchema$2 })),
|
|
17921
18125
|
sha: shaSchema$2
|
|
17922
18126
|
});
|
|
17923
|
-
|
|
17924
|
-
|
|
17925
|
-
|
|
17926
|
-
|
|
17927
|
-
|
|
17928
|
-
|
|
17929
|
-
|
|
17930
|
-
|
|
17931
|
-
|
|
17932
|
-
|
|
18127
|
+
/**
|
|
18128
|
+
* The verdict a completed generation carries. A malformed payload throws, and
|
|
18129
|
+
* a payload that disagrees with its own check run is unreadable.
|
|
18130
|
+
*/
|
|
18131
|
+
function settleCompletedGeneration(run) {
|
|
18132
|
+
const completed = completedMergeFreezeCheckRunSchema.parse(run);
|
|
18133
|
+
const state = mergeFreezeStateSchema.parse(JSON.parse(completed.output.text));
|
|
18134
|
+
if (state.generationId !== completed.id || state.headSha !== completed.head_sha || state.outcome === "active" && completed.conclusion !== "failure" || state.outcome === "stale" && completed.conclusion !== "neutral" || !state.active && completed.conclusion !== "success") return {
|
|
18135
|
+
kind: "unreadable",
|
|
18136
|
+
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`
|
|
18137
|
+
};
|
|
18138
|
+
return {
|
|
18139
|
+
kind: "settled",
|
|
18140
|
+
state
|
|
18141
|
+
};
|
|
18142
|
+
}
|
|
18143
|
+
/**
|
|
18144
|
+
* Whether a generation's recorded verification was cancelled and nothing in it
|
|
18145
|
+
* failed (#1158): at least one `cancelled` job and no `failure`. It reads only
|
|
18146
|
+
* the recorded job facts and never infers why a run was cancelled. A legacy
|
|
18147
|
+
* schema-1 record without `verification` is never cancellation-only, because
|
|
18148
|
+
* the reader cannot tell why it froze.
|
|
18149
|
+
*/
|
|
18150
|
+
function isCancellationOnly(state) {
|
|
18151
|
+
const results = Object.values(state.verification?.jobs ?? {});
|
|
18152
|
+
return results.includes("cancelled") && !results.includes("failure");
|
|
18153
|
+
}
|
|
18154
|
+
/**
|
|
18155
|
+
* The verdict behind a newest generation whose verification was only
|
|
18156
|
+
* cancelled (#1158).
|
|
18157
|
+
*
|
|
18158
|
+
* The freeze reads the newest completed generation at the exact tip whose
|
|
18159
|
+
* verification was not cancellation-only. A cancellation-only generation means
|
|
18160
|
+
* verification did not finish, so it says nothing about the code. A duplicate
|
|
18161
|
+
* push or a concurrency group can cancel a later Verify run at a tip an earlier
|
|
18162
|
+
* run already judged, and that later run must not override the earlier verdict.
|
|
18163
|
+
*
|
|
18164
|
+
* So the reader steps back, newest first by the same `started_at` rule, over
|
|
18165
|
+
* cancellation-only generations to the first older one that decides. A failed
|
|
18166
|
+
* or mixed failed-and-cancelled generation decides, and so does a legacy
|
|
18167
|
+
* record without job facts. If no older generation decides, `newest` stands,
|
|
18168
|
+
* which keeps the freeze active and fail-closed. That covers three cases:
|
|
18169
|
+
* every completed generation at the tip is cancellation-only; the next older
|
|
18170
|
+
* generation is still running, since an older green never outranks a newer
|
|
18171
|
+
* running one; and the next older generation is for another tip, is malformed,
|
|
18172
|
+
* or shares its `started_at` with another.
|
|
18173
|
+
*
|
|
18174
|
+
* `steppedPast` carries the cancellation-only runs walked so far, newest first,
|
|
18175
|
+
* starting with the newest run itself. The selection reports them only when an
|
|
18176
|
+
* older verdict decides; when `newest` stands, it read past nothing.
|
|
18177
|
+
*/
|
|
18178
|
+
function verdictBehindCancellation(newest, olderRuns, expectedHeadSha, steppedPast) {
|
|
18179
|
+
const standing = {
|
|
18180
|
+
...newest,
|
|
18181
|
+
readPast: []
|
|
18182
|
+
};
|
|
18183
|
+
const selected = selectNewestGeneration(olderRuns.map((run) => ({
|
|
18184
|
+
orderMs: generationOrderMs(run.started_at),
|
|
18185
|
+
run
|
|
18186
|
+
})));
|
|
18187
|
+
if (selected.kind !== "newest") return standing;
|
|
18188
|
+
const { run } = selected.generation;
|
|
18189
|
+
if (run.head_sha !== expectedHeadSha || run.status !== "completed") return standing;
|
|
18190
|
+
let older;
|
|
18191
|
+
try {
|
|
18192
|
+
older = settleCompletedGeneration(run);
|
|
18193
|
+
} catch {
|
|
18194
|
+
return standing;
|
|
18195
|
+
}
|
|
18196
|
+
if (older.kind !== "settled") return standing;
|
|
18197
|
+
if (!isCancellationOnly(older.state)) return {
|
|
18198
|
+
generation: older,
|
|
18199
|
+
readPast: steppedPast,
|
|
18200
|
+
run
|
|
18201
|
+
};
|
|
18202
|
+
return verdictBehindCancellation(newest, olderRuns.filter((candidate) => candidate !== run), expectedHeadSha, [...steppedPast, run]);
|
|
18203
|
+
}
|
|
18204
|
+
/**
|
|
18205
|
+
* The generation the freeze selects among the runs listed at one tip: the one
|
|
18206
|
+
* rule behind `readGeneration`, `read`, and the PR status HUD's freeze row
|
|
18207
|
+
* (#1501). It is pure. Its caller has already decided which runs to trust; the
|
|
18208
|
+
* reader refuses the whole read on a run the pinned App did not write.
|
|
18209
|
+
*
|
|
18210
|
+
* GitHub documents check-run IDs only as unique, not ordered. The factory
|
|
18211
|
+
* deliberately relies on GitHub-stamped `started_at`, and equal newest instants
|
|
18212
|
+
* stay ambiguous and fail closed. `filter=all` in the API reader is required
|
|
18213
|
+
* because `latest` can hide a live generation (#522, #524).
|
|
18214
|
+
*
|
|
18215
|
+
* A malformed newest payload throws, as `settleCompletedGeneration` does.
|
|
18216
|
+
*/
|
|
18217
|
+
function selectMergeFreezeRun(runs, expectedHeadSha) {
|
|
18218
|
+
if (runs.length === 0) return {
|
|
18219
|
+
generation: { kind: "missing" },
|
|
18220
|
+
readPast: []
|
|
18221
|
+
};
|
|
17933
18222
|
const selected = selectNewestGeneration(runs.map((run) => ({
|
|
17934
18223
|
orderMs: generationOrderMs(run.started_at),
|
|
17935
18224
|
run
|
|
17936
18225
|
})));
|
|
17937
18226
|
if (selected.kind === "ambiguous") return {
|
|
17938
|
-
|
|
17939
|
-
|
|
18227
|
+
generation: {
|
|
18228
|
+
kind: "unreadable",
|
|
18229
|
+
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} generation is ambiguous: ${selected.tied.length} check runs share ${new Date(selected.orderMs).toISOString()}.`
|
|
18230
|
+
},
|
|
18231
|
+
readPast: []
|
|
18232
|
+
};
|
|
18233
|
+
if (selected.kind === "none") return {
|
|
18234
|
+
generation: { kind: "missing" },
|
|
18235
|
+
readPast: []
|
|
17940
18236
|
};
|
|
17941
|
-
if (selected.kind === "none") return { kind: "missing" };
|
|
17942
18237
|
const newest = selected.generation.run;
|
|
17943
18238
|
if (newest.head_sha !== expectedHeadSha) return {
|
|
17944
|
-
|
|
17945
|
-
|
|
18239
|
+
generation: {
|
|
18240
|
+
kind: "unreadable",
|
|
18241
|
+
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run targets ${newest.head_sha}, expected ${expectedHeadSha}.`
|
|
18242
|
+
},
|
|
18243
|
+
readPast: []
|
|
17946
18244
|
};
|
|
17947
18245
|
if (newest.status !== "completed") return {
|
|
17948
|
-
|
|
17949
|
-
|
|
17950
|
-
|
|
17951
|
-
|
|
17952
|
-
|
|
17953
|
-
|
|
17954
|
-
|
|
18246
|
+
generation: {
|
|
18247
|
+
kind: "settling",
|
|
18248
|
+
phase: "running-generation",
|
|
18249
|
+
reason: `Authoritative merge freeze generation ${newest.id} is in progress.`,
|
|
18250
|
+
witness: {
|
|
18251
|
+
headSha: newest.head_sha,
|
|
18252
|
+
runId: newest.id
|
|
18253
|
+
}
|
|
18254
|
+
},
|
|
18255
|
+
readPast: []
|
|
17955
18256
|
};
|
|
17956
|
-
const
|
|
17957
|
-
|
|
17958
|
-
|
|
17959
|
-
|
|
17960
|
-
reason: `Newest ${MERGE_FREEZE_CHECK_NAME} check run has inconsistent state.`
|
|
18257
|
+
const verdict = settleCompletedGeneration(newest);
|
|
18258
|
+
if (verdict.kind !== "settled") return {
|
|
18259
|
+
generation: verdict,
|
|
18260
|
+
readPast: []
|
|
17961
18261
|
};
|
|
18262
|
+
if (isCancellationOnly(verdict.state)) return verdictBehindCancellation({
|
|
18263
|
+
generation: verdict,
|
|
18264
|
+
run: newest
|
|
18265
|
+
}, runs.filter((run) => run !== newest), expectedHeadSha, [newest]);
|
|
17962
18266
|
return {
|
|
17963
|
-
|
|
17964
|
-
|
|
18267
|
+
generation: verdict,
|
|
18268
|
+
readPast: [],
|
|
18269
|
+
run: newest
|
|
17965
18270
|
};
|
|
17966
18271
|
}
|
|
18272
|
+
function selectListedMergeFreezeGeneration(listed, expectedHeadSha) {
|
|
18273
|
+
return selectMergeFreezeRun(z.object({ check_runs: z.array(z.unknown()) }).parse(listed).check_runs.map((run) => {
|
|
18274
|
+
const listedRun = mergeFreezeCheckRunListItemSchema.parse(run);
|
|
18275
|
+
if (!checkRunProducedByFactoryApp({
|
|
18276
|
+
app: listedRun.app,
|
|
18277
|
+
expectedAppId: FACTORY_PROOF_GATE_APP_ID
|
|
18278
|
+
})) throw new Error(`${MERGE_FREEZE_CHECK_NAME} check run ${listedRun.id} was not written by the pinned Factory App ${FACTORY_PROOF_GATE_APP_ID}.`);
|
|
18279
|
+
return listedRun;
|
|
18280
|
+
}), expectedHeadSha).generation;
|
|
18281
|
+
}
|
|
17967
18282
|
function unconfiguredMergeFreeze(input, detail) {
|
|
17968
18283
|
return {
|
|
17969
18284
|
kind: "unreadable",
|
|
@@ -18835,26 +19150,9 @@ const reviewRoundLimitBlockers = (input) => {
|
|
|
18835
19150
|
}];
|
|
18836
19151
|
};
|
|
18837
19152
|
/**
|
|
18838
|
-
* The demands an operator waiver can reach (#354, moved here from
|
|
18839
|
-
* `pr:merge-check` by #477). Exactly the set the merge-time site could waive:
|
|
18840
|
-
* the merge freeze, the profile's external required checks, and the boundary
|
|
18841
|
-
* wave's review rung — the demands the resolver puts *in force* for one
|
|
18842
|
-
* candidate — plus the review-round limit (#1280), which exists only to be
|
|
18843
|
-
* waived.
|
|
18844
|
-
*
|
|
18845
|
-
* Everything else stays unwaivable, and relocating the consumption site does
|
|
18846
|
-
* not widen the operator's override. Structural refusals — candidate identity,
|
|
18847
|
-
* proof binding, an unreadable demand authority, wave membership — were never
|
|
18848
|
-
* waivable at merge time and are not waivable here; in `pr:ready` most of them
|
|
18849
|
-
* refuse by throwing before any blocker list exists. The evidence gates
|
|
18850
|
-
* readiness has always owned (verify, review modes, draft, human blockers) are
|
|
18851
|
-
* satisfied or not; a waiver is not a second way to pass them.
|
|
18852
|
-
*/
|
|
18853
|
-
const WAIVABLE_QUALIFIED_DEMAND_FAMILIES = ["required-check", "review-rung"];
|
|
18854
|
-
const isWaivableDemand = (demand) => demand === DEMAND_KEYS.mergeFreeze || demand === DEMAND_KEYS.reviewRoundLimit || WAIVABLE_QUALIFIED_DEMAND_FAMILIES.some((family) => demand.startsWith(`${family}:`));
|
|
18855
|
-
/**
|
|
18856
19153
|
* Fold the operator's waivers through the blockers, one call per demand so
|
|
18857
|
-
* `demand-waiver.ts` stays the single authority on what a waiver means
|
|
19154
|
+
* `demand-waiver.ts` stays the single authority on what a waiver means and
|
|
19155
|
+
* which demands it can reach — the contract `demand:waive` refuses by. A
|
|
18858
19156
|
* waived demand keeps every refusal it made, verbatim, on the `WaivedDemand`
|
|
18859
19157
|
* record — it is never rewritten as satisfied.
|
|
18860
19158
|
*/
|
|
@@ -19293,6 +19591,8 @@ const renderTable = (rows) => [
|
|
|
19293
19591
|
"| --- | --- | --- | --- |",
|
|
19294
19592
|
...rows.map((row) => `| ${markdownCell(row.target)} | ${statusLabel(row.status)} | ${row.link} | ${row.timestamp} |`)
|
|
19295
19593
|
].join("\n");
|
|
19594
|
+
/** Name the cancellation-only runs a merge-freeze row read past (#1501). */
|
|
19595
|
+
const withReadPastNote = (target, runIds) => runIds && runIds.length > 0 ? `${target} (read past cancellation-only ${runIds.length === 1 ? "run" : "runs"} ${runIds.join(", ")})` : target;
|
|
19296
19596
|
const checkRow = (target, snapshot, renderedAt) => {
|
|
19297
19597
|
if (!snapshot) return {
|
|
19298
19598
|
detail: "No completed Factory App snapshot is available at this head.",
|
|
@@ -19305,7 +19605,7 @@ const checkRow = (target, snapshot, renderedAt) => {
|
|
|
19305
19605
|
detail: `Completed snapshot: ${snapshot.conclusion}.`,
|
|
19306
19606
|
link: safeMarkdownLink("Details", snapshot.detailsUrl),
|
|
19307
19607
|
status: snapshot.conclusion === "success" ? "Ready" : "Failed",
|
|
19308
|
-
target,
|
|
19608
|
+
target: withReadPastNote(target, snapshot.readPastRunIds),
|
|
19309
19609
|
timestamp: utcTimestamp(snapshot.completedAt)
|
|
19310
19610
|
};
|
|
19311
19611
|
};
|
|
@@ -19417,7 +19717,9 @@ const checkRunSchema = z.object({
|
|
|
19417
19717
|
conclusion: z.string().nullable().optional(),
|
|
19418
19718
|
details_url: z.string().nullable().optional(),
|
|
19419
19719
|
head_sha: z.string().regex(/^[0-9a-f]{40}$/u),
|
|
19720
|
+
id: z.number().int().positive(),
|
|
19420
19721
|
name: z.string(),
|
|
19722
|
+
output: z.object({ text: z.string().nullable().optional() }).nullable().optional(),
|
|
19421
19723
|
started_at: z.iso.datetime().nullable(),
|
|
19422
19724
|
status: z.enum([
|
|
19423
19725
|
"completed",
|
|
@@ -19488,16 +19790,13 @@ const listFactoryCheckRuns = async (input, name, sha, dependencies) => {
|
|
|
19488
19790
|
}
|
|
19489
19791
|
throw new Error(`${name} pagination limit was exhausted before GitHub returned a final page.`);
|
|
19490
19792
|
};
|
|
19491
|
-
|
|
19793
|
+
/** The Factory App's generations of one check at one SHA, each orderable. */
|
|
19794
|
+
const factoryGenerations = (name, sha, runs) => {
|
|
19492
19795
|
const candidates = runs.filter((run) => String(run.app?.id ?? "") === FACTORY_PROOF_GATE_APP_ID && run.head_sha === sha && run.name === name);
|
|
19493
19796
|
if (candidates.some((run) => !Number.isFinite(generationOrderMs(run.started_at)))) throw new Error(`${name} presentation snapshot has a generation with no usable started_at.`);
|
|
19494
|
-
|
|
19495
|
-
|
|
19496
|
-
|
|
19497
|
-
})));
|
|
19498
|
-
if (selected.kind === "ambiguous") throw new Error(`Newest ${name} presentation snapshot is ambiguous: ${selected.tied.length} check runs share the newest timestamp.`);
|
|
19499
|
-
if (selected.kind === "none") return;
|
|
19500
|
-
const { run } = selected.generation;
|
|
19797
|
+
return candidates;
|
|
19798
|
+
};
|
|
19799
|
+
const runSnapshot = (name, run) => {
|
|
19501
19800
|
if (run.status !== "completed" || !run.completed_at || !run.conclusion) return;
|
|
19502
19801
|
return {
|
|
19503
19802
|
completedAt: run.completed_at,
|
|
@@ -19507,6 +19806,45 @@ const completedSnapshot = (name, sha, runs) => {
|
|
|
19507
19806
|
name
|
|
19508
19807
|
};
|
|
19509
19808
|
};
|
|
19809
|
+
const newestSnapshot = (name, generations) => {
|
|
19810
|
+
const selected = selectNewestGeneration(generations.map((run) => ({
|
|
19811
|
+
orderMs: generationOrderMs(run.started_at),
|
|
19812
|
+
run
|
|
19813
|
+
})));
|
|
19814
|
+
if (selected.kind === "ambiguous") throw new Error(`Newest ${name} presentation snapshot is ambiguous: ${selected.tied.length} check runs share the newest timestamp.`);
|
|
19815
|
+
if (selected.kind === "none") return;
|
|
19816
|
+
return runSnapshot(name, selected.generation.run);
|
|
19817
|
+
};
|
|
19818
|
+
const completedSnapshot = (name, sha, runs) => newestSnapshot(name, factoryGenerations(name, sha, runs));
|
|
19819
|
+
const MERGE_FREEZE_CHECK = "patronage-factory/merge-freeze";
|
|
19820
|
+
/**
|
|
19821
|
+
* The merge-freeze row shows the generation readiness reads (#1501), chosen by
|
|
19822
|
+
* the freeze's own selection rather than a second copy of it. That selection
|
|
19823
|
+
* reads past a cancellation-only generation to an older verdict at the same
|
|
19824
|
+
* tip (#1158). The newest completed run alone would show that cancellation as
|
|
19825
|
+
* a freeze readiness does not apply, so the row names the runs it read past.
|
|
19826
|
+
*
|
|
19827
|
+
* Otherwise the row is the newest completed snapshot, as for every other check:
|
|
19828
|
+
* running, missing and ambiguous generations present as before, and so does a
|
|
19829
|
+
* newest generation the selection cannot settle. Every case where the freeze
|
|
19830
|
+
* stays fail-closed keeps the newest generation, so the row stays red. The HUD
|
|
19831
|
+
* is advisory and never gates.
|
|
19832
|
+
*/
|
|
19833
|
+
const mergeFreezeSnapshot = (sha, runs) => {
|
|
19834
|
+
const generations = factoryGenerations(MERGE_FREEZE_CHECK, sha, runs);
|
|
19835
|
+
const newest = newestSnapshot(MERGE_FREEZE_CHECK, generations);
|
|
19836
|
+
if (!newest) return newest;
|
|
19837
|
+
try {
|
|
19838
|
+
const { readPast, run } = selectMergeFreezeRun(generations, sha);
|
|
19839
|
+
const selected = run && readPast.length > 0 ? runSnapshot(MERGE_FREEZE_CHECK, run) : void 0;
|
|
19840
|
+
return selected ? {
|
|
19841
|
+
...selected,
|
|
19842
|
+
readPastRunIds: readPast.map((past) => past.id)
|
|
19843
|
+
} : newest;
|
|
19844
|
+
} catch {
|
|
19845
|
+
return newest;
|
|
19846
|
+
}
|
|
19847
|
+
};
|
|
19510
19848
|
const FAILED_PREVIEW_CONCLUSIONS = new Set([
|
|
19511
19849
|
"action_required",
|
|
19512
19850
|
"cancelled",
|
|
@@ -19555,7 +19893,8 @@ async function readFactoryPrStatusSources(input, dependencies = {}) {
|
|
|
19555
19893
|
];
|
|
19556
19894
|
const listedChecks = await Promise.all(checkTargets.map(([name, sha]) => listFactoryCheckRuns(input, name, sha, dependencies)));
|
|
19557
19895
|
const checks = Object.fromEntries(checkTargets.flatMap(([name, sha], index) => {
|
|
19558
|
-
const
|
|
19896
|
+
const runs = listedChecks[index] ?? [];
|
|
19897
|
+
const snapshot = name === MERGE_FREEZE_CHECK ? mergeFreezeSnapshot(sha, runs) : completedSnapshot(name, sha, runs);
|
|
19559
19898
|
return snapshot ? [[name, snapshot]] : [];
|
|
19560
19899
|
}));
|
|
19561
19900
|
const previewTargets = input.previewTargets ?? [];
|
|
@@ -21418,11 +21757,20 @@ const assertEpicPullRequestOpen = ({ args, base }) => {
|
|
|
21418
21757
|
* prompt for another patch, with no ladder cycle for that patch, is a review
|
|
21419
21758
|
* that nobody recorded, and no ladder control can see it. Assembling again at
|
|
21420
21759
|
* the same patch only rewrites that prompt.
|
|
21760
|
+
*
|
|
21761
|
+
* The prompt also counts as recorded when the checkout's own review proof
|
|
21762
|
+
* holds a cycle at exactly the patch and head the prompt names, whatever the
|
|
21763
|
+
* lineage (#1415). A squash merge of the previous pull request takes that head
|
|
21764
|
+
* off the next branch, so the ladder this candidate continues is empty, yet
|
|
21765
|
+
* the review of that prompt is on record. Only this question reads
|
|
21766
|
+
* `checkoutLadder`; the next review still continues `priorLadder`.
|
|
21421
21767
|
*/
|
|
21422
|
-
const assertPriorPromptRecorded = ({ existing, patchId, priorLadder }) => {
|
|
21768
|
+
const assertPriorPromptRecorded = ({ checkoutLadder, existing, patchId, priorLadder }) => {
|
|
21423
21769
|
if (existing.status !== "ok" || existing.artifact.patchId === patchId) return;
|
|
21424
21770
|
const prompted = existing.artifact;
|
|
21425
|
-
|
|
21771
|
+
const recordedOnContinuedLadder = (priorLadder?.cycles ?? []).some((cycle) => cycle.identity?.patchId === prompted.patchId);
|
|
21772
|
+
const recordedAtPromptedHead = (checkoutLadder?.cycles ?? []).some((cycle) => cycle.identity?.patchId === prompted.patchId && cycle.identity.headSha === prompted.headSha);
|
|
21773
|
+
if (recordedOnContinuedLadder || recordedAtPromptedHead) return;
|
|
21426
21774
|
throw new Error(`pr:review --assemble refuses: ${DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH} holds a prompt for patch ${prompted.patchId} at ${prompted.headSha}, and the review ladder has no cycle for that patch. The candidate is now patch ${patchId}. Record the review of that patch first: patronage-factory pr:review --historical --findings <findings file for patch ${prompted.patchId}>. If no reviewer read that prompt, delete ${DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH} and assemble again.`);
|
|
21427
21775
|
};
|
|
21428
21776
|
/**
|
|
@@ -21469,14 +21817,16 @@ const assertAssembleMayPrepareReview = ({ args, base, cwd, git, headSha, output,
|
|
|
21469
21817
|
args,
|
|
21470
21818
|
base
|
|
21471
21819
|
});
|
|
21820
|
+
const prior = readOptionalProofFor(PR_REVIEW_PROOF_DESCRIPTOR, cwd, output);
|
|
21472
21821
|
const priorLadder = priorLadderStateFor({
|
|
21473
21822
|
base: base.ref,
|
|
21474
21823
|
headSha,
|
|
21475
21824
|
isAncestor: (ancestor, descendant) => git.isAncestor(cwd, ancestor, descendant),
|
|
21476
|
-
prior
|
|
21825
|
+
prior
|
|
21477
21826
|
});
|
|
21478
21827
|
const existing = readAssembledReviewPrompt(path.resolve(cwd, DEFAULT_ASSEMBLED_REVIEW_PROMPT_PATH));
|
|
21479
21828
|
assertPriorPromptRecorded({
|
|
21829
|
+
checkoutLadder: prior?.ladder,
|
|
21480
21830
|
existing,
|
|
21481
21831
|
patchId,
|
|
21482
21832
|
priorLadder
|
package/dist/schemas.js
CHANGED
|
@@ -252,6 +252,7 @@ const waivedDemandSchema = z.object({
|
|
|
252
252
|
session: z.string().trim().min(1).optional(),
|
|
253
253
|
unmetReasons: z.array(z.string().min(1)).min(1)
|
|
254
254
|
});
|
|
255
|
+
DEMAND_KEYS.mergeFreeze, DEMAND_KEYS.reviewRoundLimit, DEMAND_KEYS.childIssueOpen;
|
|
255
256
|
//#endregion
|
|
256
257
|
//#region src/diff-classification.ts
|
|
257
258
|
const DIFF_CLASSIFICATIONS = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/software-factory",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.43",
|
|
4
4
|
"description": "Shared Patronage software factory CLI and project-profile validation tools",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"picomatch": "^4.0.5",
|
|
38
38
|
"yaml": "^2.9.0",
|
|
39
39
|
"zod": "4.4.3",
|
|
40
|
-
"@patronage/factory-ci": "1.0.0-alpha.
|
|
40
|
+
"@patronage/factory-ci": "1.0.0-alpha.43"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "24.13.3",
|