@patronage/software-factory 1.0.0-alpha.20 → 1.0.0-alpha.21
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 +60 -3
- package/dist/index.js +307 -37
- package/dist/schemas.d.ts +21 -1
- package/dist/schemas.js +17 -1
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -229,6 +229,8 @@ interface BoundaryPullRequestRef {
|
|
|
229
229
|
headRefOid?: string;
|
|
230
230
|
headRefName?: string;
|
|
231
231
|
mergeCommitOid?: string;
|
|
232
|
+
/** GitHub's merge timestamp; the merge-ordering fact topology reads (#821). */
|
|
233
|
+
mergedAt?: string;
|
|
232
234
|
number: number;
|
|
233
235
|
state: "OPEN" | "CLOSED" | "MERGED";
|
|
234
236
|
}
|
|
@@ -1283,7 +1285,7 @@ interface PrReviewProof {
|
|
|
1283
1285
|
cleanedPaths: string[];
|
|
1284
1286
|
ladder?: PrReviewLadderState;
|
|
1285
1287
|
reviewRequirement?: {
|
|
1286
|
-
reason: "no-applicable-mode";
|
|
1288
|
+
reason: "no-applicable-mode" | "faithful-merge";
|
|
1287
1289
|
status: "not-required";
|
|
1288
1290
|
};
|
|
1289
1291
|
reviews: PrReviewResult[];
|
|
@@ -1545,6 +1547,23 @@ interface UpsertFactoryPrStatusCommentResult {
|
|
|
1545
1547
|
}
|
|
1546
1548
|
declare function upsertFactoryPrStatusComment(input: UpsertFactoryPrStatusCommentInput, dependencies?: UpsertFactoryPrStatusCommentDependencies): Promise<UpsertFactoryPrStatusCommentResult>;
|
|
1547
1549
|
//#endregion
|
|
1550
|
+
//#region src/catch-up-recognition-record.d.ts
|
|
1551
|
+
interface CatchUpRecognition {
|
|
1552
|
+
/** The ref the candidate targets, as the recognizing command resolved it. */
|
|
1553
|
+
baseRef: string;
|
|
1554
|
+
/** The live target tip at recognition time; the head's first parent. */
|
|
1555
|
+
baseTipSha: string;
|
|
1556
|
+
/** The head's second parent: the admitted base-side content merged in. */
|
|
1557
|
+
mergedParentSha: string;
|
|
1558
|
+
/**
|
|
1559
|
+
* The tree `git merge-tree --write-tree` produced for the two parents. It
|
|
1560
|
+
* equals the head's own tree; that equality is the faithfulness proof.
|
|
1561
|
+
*/
|
|
1562
|
+
mergedTreeSha: string;
|
|
1563
|
+
/** The ref that proves the second parent is admitted content. */
|
|
1564
|
+
upstreamRef: string;
|
|
1565
|
+
}
|
|
1566
|
+
//#endregion
|
|
1548
1567
|
//#region src/pr-verify-proof-contract.d.ts
|
|
1549
1568
|
declare const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS: readonly [1, 2, 3, 4];
|
|
1550
1569
|
type PrVerifySchemaVersion = (typeof SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS)[number];
|
|
@@ -1611,6 +1630,7 @@ interface PrVerifyProof {
|
|
|
1611
1630
|
projectKey: string;
|
|
1612
1631
|
repository: string;
|
|
1613
1632
|
}[];
|
|
1633
|
+
catchUpRecognition?: CatchUpRecognition;
|
|
1614
1634
|
changedFiles: string[];
|
|
1615
1635
|
classification: DiffClassification;
|
|
1616
1636
|
classificationReasons: string[];
|
|
@@ -1635,7 +1655,7 @@ interface PrVerifyProof {
|
|
|
1635
1655
|
type PrVerifyBaselineFullProof = NonNullable<PrVerifyProof["baselineFullProofs"]>[number];
|
|
1636
1656
|
declare function validatePrVerifyProof(value: unknown): PrVerifyProof;
|
|
1637
1657
|
declare namespace verification_proof_d_exports {
|
|
1638
|
-
export { PrVerifyBaselineFullProof, PrVerifyCommandResult, PrVerifyExecutedCommand, PrVerifyNotDemandedRecord, PrVerifyNotRequiredCommand, PrVerifyProof, PrVerifySchemaVersion, ResolvedPrVerifyMode, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, VerificationHeadShas, VerificationProofState, VerifiedHeadShas, prVerifyFullHeadShasForDiff, prVerifyProofHeadShas, readPrVerifyProof, resolveVerifyProofApplicability, trustedImpactStamp, validatePrVerifyProof, verificationProofApplicabilityFor, verificationProofBlockingReason, verificationProofForReadiness, verificationProofStateFor, verifyProofIdentityMatches, verifyProofPassed };
|
|
1658
|
+
export { PrVerifyBaselineFullProof, PrVerifyCommandResult, PrVerifyExecutedCommand, PrVerifyNotDemandedRecord, PrVerifyNotRequiredCommand, PrVerifyProof, PrVerifySchemaVersion, ResolvedPrVerifyMode, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, VerificationHeadShas, VerificationProofState, VerifiedHeadShas, prVerifyFullHeadShasForDiff, prVerifyProofHeadShas, readPrVerifyProof, resolveVerifyProofApplicability, trustedCatchUpRecognition, trustedImpactStamp, validatePrVerifyProof, verificationProofApplicabilityFor, verificationProofBlockingReason, verificationProofForReadiness, verificationProofStateFor, verifyProofIdentityMatches, verifyProofPassed };
|
|
1639
1659
|
}
|
|
1640
1660
|
interface VerifiedHeadShas {
|
|
1641
1661
|
docsOnlyVerifiedHeadShas: string[];
|
|
@@ -1680,6 +1700,24 @@ declare const trustedImpactStamp: ({
|
|
|
1680
1700
|
};
|
|
1681
1701
|
proof: PrVerifyProof | undefined;
|
|
1682
1702
|
}) => ImpactStamp | undefined;
|
|
1703
|
+
/**
|
|
1704
|
+
* #833: the recorded catch-up recognition, consumed ONLY when it is
|
|
1705
|
+
* identity-bound to the current candidate under exactly the rules
|
|
1706
|
+
* `trustedImpactStamp` applies — the proof passed, is v4+, and its recorded
|
|
1707
|
+
* headSha AND patchId both name this candidate. The recognition withdraws
|
|
1708
|
+
* review and preview demands, so it earns the same strict double binding and
|
|
1709
|
+
* the same fail-closed answer (`undefined`) on every other state.
|
|
1710
|
+
*/
|
|
1711
|
+
declare const trustedCatchUpRecognition: ({
|
|
1712
|
+
candidate,
|
|
1713
|
+
proof
|
|
1714
|
+
}: {
|
|
1715
|
+
candidate: {
|
|
1716
|
+
headSha: string;
|
|
1717
|
+
patchId: string;
|
|
1718
|
+
};
|
|
1719
|
+
proof: PrVerifyProof | undefined;
|
|
1720
|
+
}) => CatchUpRecognition | undefined;
|
|
1683
1721
|
declare const prVerifyFullHeadShasForDiff: ({
|
|
1684
1722
|
docsOnlyDeltaAcceptedSince,
|
|
1685
1723
|
files,
|
|
@@ -2194,6 +2232,7 @@ declare const requiredCheckScopeSchema: z.ZodObject<{
|
|
|
2194
2232
|
}, z.core.$strict>;
|
|
2195
2233
|
type RequiredCheckScope = z.infer<typeof requiredCheckScopeSchema>;
|
|
2196
2234
|
interface RequiredCheckScopeContext {
|
|
2235
|
+
catchUpRecognition?: CatchUpRecognition;
|
|
2197
2236
|
classification: DiffClassification | undefined;
|
|
2198
2237
|
impactStamp?: ImpactStamp;
|
|
2199
2238
|
}
|
|
@@ -2626,6 +2665,19 @@ type HqFlushResult = (HqFlushOrphans & {
|
|
|
2626
2665
|
status: "flushed";
|
|
2627
2666
|
});
|
|
2628
2667
|
//#endregion
|
|
2668
|
+
//#region src/catch-up-recognition.d.ts
|
|
2669
|
+
type CatchUpRecognitionResult = {
|
|
2670
|
+
recognition: CatchUpRecognition;
|
|
2671
|
+
recognized: true;
|
|
2672
|
+
} | {
|
|
2673
|
+
reasons: string[];
|
|
2674
|
+
recognized: false;
|
|
2675
|
+
};
|
|
2676
|
+
type CatchUpRecognizer = (input: {
|
|
2677
|
+
base: string;
|
|
2678
|
+
cwd: string;
|
|
2679
|
+
}) => CatchUpRecognitionResult;
|
|
2680
|
+
//#endregion
|
|
2629
2681
|
//#region src/pr-review.d.ts
|
|
2630
2682
|
interface PrReviewArgs extends LoadProjectProfileInput {
|
|
2631
2683
|
base: string;
|
|
@@ -2641,6 +2693,7 @@ interface PrReviewGitDependencies {
|
|
|
2641
2693
|
changedFiles: (cwd: string, base: string) => string[];
|
|
2642
2694
|
currentHeadSha: (cwd: string) => string;
|
|
2643
2695
|
isAncestor: (cwd: string, ancestor: string, descendant: string) => boolean;
|
|
2696
|
+
recognizeCatchUp?: CatchUpRecognizer;
|
|
2644
2697
|
stablePatchId: (cwd: string, base: string) => string;
|
|
2645
2698
|
statusPorcelain: (cwd: string) => string;
|
|
2646
2699
|
}
|
|
@@ -2680,6 +2733,7 @@ interface PrVerifyGitDependencies {
|
|
|
2680
2733
|
currentHeadSha: (cwd: string) => string;
|
|
2681
2734
|
emptyTreeHash: (cwd: string) => string;
|
|
2682
2735
|
mergeBaseSha: (cwd: string, base: string) => string;
|
|
2736
|
+
recognizeCatchUp?: CatchUpRecognizer;
|
|
2683
2737
|
runVerificationCommand: (command: string, cwd: string, env: Record<string, string>) => VerificationCommandOutcome;
|
|
2684
2738
|
showFileAtRef?: (cwd: string, ref: string, absolutePath: string) => string | undefined;
|
|
2685
2739
|
statusPorcelain: (cwd: string) => string;
|
|
@@ -3034,6 +3088,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3034
3088
|
patchId: z.ZodString;
|
|
3035
3089
|
reason: z.ZodEnum<{
|
|
3036
3090
|
"no-applicable-mode": "no-applicable-mode";
|
|
3091
|
+
"faithful-merge": "faithful-merge";
|
|
3037
3092
|
}>;
|
|
3038
3093
|
reviewCycle: z.ZodNumber;
|
|
3039
3094
|
}, z.core.$strict>, readonly [z.ZodObject<{
|
|
@@ -3056,6 +3111,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3056
3111
|
patchId: z.ZodString;
|
|
3057
3112
|
reason: z.ZodEnum<{
|
|
3058
3113
|
"no-applicable-mode": "no-applicable-mode";
|
|
3114
|
+
"faithful-merge": "faithful-merge";
|
|
3059
3115
|
}>;
|
|
3060
3116
|
reviewCycle: z.ZodNumber;
|
|
3061
3117
|
}, z.core.$strict>], {
|
|
@@ -3069,6 +3125,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3069
3125
|
patchId: z.ZodString;
|
|
3070
3126
|
reason: z.ZodEnum<{
|
|
3071
3127
|
"no-applicable-mode": "no-applicable-mode";
|
|
3128
|
+
"faithful-merge": "faithful-merge";
|
|
3072
3129
|
}>;
|
|
3073
3130
|
reviewCycle: z.ZodNumber;
|
|
3074
3131
|
}, z.core.$strict>;
|
|
@@ -3086,7 +3143,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
|
|
|
3086
3143
|
observedAt: string;
|
|
3087
3144
|
outcome: "not-required";
|
|
3088
3145
|
patchId: string;
|
|
3089
|
-
reason: "no-applicable-mode";
|
|
3146
|
+
reason: "no-applicable-mode" | "faithful-merge";
|
|
3090
3147
|
reviewCycle: number;
|
|
3091
3148
|
issue?: number | undefined;
|
|
3092
3149
|
pr?: number | undefined;
|
package/dist/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { parse } from "yaml";
|
|
|
17
17
|
import { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, previewProofInventory, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
//#region package.json
|
|
20
|
-
var version = "1.0.0-alpha.
|
|
20
|
+
var version = "1.0.0-alpha.21";
|
|
21
21
|
//#endregion
|
|
22
22
|
//#region src/review-rungs.ts
|
|
23
23
|
const EVIDENCE_REVIEW_RUNGS$1 = [
|
|
@@ -159,23 +159,47 @@ const DEFAULT_TERMINAL_BASE = "main";
|
|
|
159
159
|
* Derive the membership graph from the manifest's declared issues and their
|
|
160
160
|
* live closing links. A PR observed under several issues keeps one
|
|
161
161
|
* membership per issue; nothing is collapsed here and nothing is refused.
|
|
162
|
+
*
|
|
163
|
+
* A PR that closed without merging contributes NO closing link (#821): it
|
|
164
|
+
* carried nothing into the boundary, so it is not a member and cannot fail
|
|
165
|
+
* one. The dropped link is recorded on the issue. Closeout blocks only on
|
|
166
|
+
* that flavor: an issue whose carriers all closed without merging. A declared
|
|
167
|
+
* issue that never had any closing link is reported as a notice, not a
|
|
168
|
+
* blocking reason (open question: #853).
|
|
162
169
|
*/
|
|
163
170
|
const buildBoundaryMembershipGraph = (manifest, fetchClosingPullRequests) => {
|
|
164
171
|
const memberships = [];
|
|
165
172
|
const unlinkedIssues = [];
|
|
173
|
+
const closedWithoutMergeLinks = [];
|
|
166
174
|
for (const wave of manifest.waves) for (const issue of wave.issues) {
|
|
167
175
|
const refs = fetchClosingPullRequests(issue);
|
|
168
|
-
|
|
176
|
+
const live = [];
|
|
177
|
+
const closedWithoutMerge = [];
|
|
178
|
+
for (const ref of refs) {
|
|
179
|
+
if (ref.state === "CLOSED") {
|
|
180
|
+
closedWithoutMerge.push(ref.number);
|
|
181
|
+
closedWithoutMergeLinks.push({
|
|
182
|
+
issue,
|
|
183
|
+
pr: ref.number,
|
|
184
|
+
wave: wave.name
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
live.push(ref);
|
|
189
|
+
}
|
|
190
|
+
if (live.length === 0) unlinkedIssues.push({
|
|
191
|
+
...closedWithoutMerge.length > 0 ? { closedWithoutMerge } : {},
|
|
169
192
|
issue,
|
|
170
193
|
wave: wave.name
|
|
171
194
|
});
|
|
172
|
-
for (const ref of
|
|
195
|
+
for (const ref of live) memberships.push({
|
|
173
196
|
issue,
|
|
174
197
|
ref,
|
|
175
198
|
wave
|
|
176
199
|
});
|
|
177
200
|
}
|
|
178
201
|
return {
|
|
202
|
+
closedWithoutMergeLinks,
|
|
179
203
|
memberships,
|
|
180
204
|
unlinkedIssues
|
|
181
205
|
};
|
|
@@ -208,7 +232,8 @@ const membershipWaveDemand = (graph, pr) => {
|
|
|
208
232
|
* validation; a repeated PR number is not evidence the memberships agree.
|
|
209
233
|
*/
|
|
210
234
|
const coveredSetProjection = (graph) => {
|
|
211
|
-
const notices = graph.unlinkedIssues.map(({ issue, wave }) => `issue #${issue} (wave ${wave}) has no linked closing PR yet`);
|
|
235
|
+
const notices = graph.unlinkedIssues.map(({ closedWithoutMerge, issue, wave }) => closedWithoutMerge === void 0 ? `issue #${issue} (wave ${wave}) has no linked closing PR yet` : `issue #${issue} (wave ${wave}) has no live closing PR; its only closing link(s) ${closedWithoutMerge.map((pr) => `PR #${pr}`).join(", ")} closed without merging`);
|
|
236
|
+
for (const pr of new Set(graph.closedWithoutMergeLinks.map((l) => l.pr))) notices.push(`PR #${pr} is closed without merge; it contributes nothing to the covered set`);
|
|
212
237
|
const entries = [];
|
|
213
238
|
const pullRequests = [];
|
|
214
239
|
for (const { issue, ref, wave } of graph.memberships) {
|
|
@@ -235,7 +260,6 @@ const coveredSetProjection = (graph) => {
|
|
|
235
260
|
else notices.push(`PR #${ref.number} is open but GitHub reports no head SHA; excluded from the covered set`);
|
|
236
261
|
continue;
|
|
237
262
|
}
|
|
238
|
-
notices.push(`PR #${ref.number} is closed without merge; it contributes nothing to the covered set`);
|
|
239
263
|
}
|
|
240
264
|
return {
|
|
241
265
|
entries: entries.toSorted((a, b) => a.pr - b.pr || a.issue - b.issue),
|
|
@@ -392,6 +416,14 @@ const eachToEpicBranch = (manifest) => manifest.topology === "each-to-epic" ? ma
|
|
|
392
416
|
* Validate only the current member during admission. This deliberately does
|
|
393
417
|
* not inspect sibling membership or require the terminal PR: an epic branch
|
|
394
418
|
* is assembled one admitted interior candidate at a time.
|
|
419
|
+
*
|
|
420
|
+
* A declared member that targets the terminal base is admitted from any head
|
|
421
|
+
* (#821). At admission the candidate has not merged, so merge ordering — the
|
|
422
|
+
* fact that separates the terminal from a post-terminal release member —
|
|
423
|
+
* does not exist yet. Closeout owns that judgment, where the whole graph is
|
|
424
|
+
* visible: it still requires exactly one terminal from the integration
|
|
425
|
+
* branch, and it still refuses a main-targeting member that did not merge
|
|
426
|
+
* after that terminal.
|
|
395
427
|
*/
|
|
396
428
|
const pullRequestTopologyReasons = ({ manifest, pullRequest, terminalBase = DEFAULT_TERMINAL_BASE }) => {
|
|
397
429
|
const integrationBranch = eachToEpicBranch(manifest);
|
|
@@ -399,29 +431,67 @@ const pullRequestTopologyReasons = ({ manifest, pullRequest, terminalBase = DEFA
|
|
|
399
431
|
const { baseRefName, headRefName, number } = pullRequest;
|
|
400
432
|
if (!headRefName) return [`PR #${number} has no live head branch name; each-to-epic admission fails closed until GitHub reports the member's headRefName.`];
|
|
401
433
|
if (baseRefName === integrationBranch && headRefName !== integrationBranch) return [];
|
|
402
|
-
if (baseRefName === terminalBase
|
|
403
|
-
return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or
|
|
434
|
+
if (baseRefName === terminalBase) return [];
|
|
435
|
+
return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or a member targeting ${terminalBase} (the terminal, or a post-terminal release member).`];
|
|
436
|
+
};
|
|
437
|
+
const mergedAtMs = (pullRequest) => {
|
|
438
|
+
if (pullRequest.mergedAt === void 0) return;
|
|
439
|
+
const parsed = Date.parse(pullRequest.mergedAt);
|
|
440
|
+
return Number.isNaN(parsed) ? void 0 : parsed;
|
|
404
441
|
};
|
|
405
442
|
/**
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
443
|
+
* Judge a declared member that targets the terminal base from a branch other
|
|
444
|
+
* than the integration branch. It is admitted as a post-terminal release
|
|
445
|
+
* member (#821) exactly when it merged strictly after the one terminal PR:
|
|
446
|
+
* an already-merged integration branch cannot receive later work, so a
|
|
447
|
+
* ratified release slice has nowhere else to target. Every fact this needs is
|
|
448
|
+
* membership plus merge ordering; nothing is declared in the manifest.
|
|
410
449
|
*/
|
|
411
|
-
const
|
|
412
|
-
const
|
|
413
|
-
if (!
|
|
414
|
-
|
|
450
|
+
const postTerminalReleaseReasons = ({ candidate, integrationBranch, terminal, terminalBase }) => {
|
|
451
|
+
const label = `PR #${candidate.number} targets ${terminalBase} from ${candidate.headRefName ?? "(unknown)"}`;
|
|
452
|
+
if (!terminal) return [`${label}; each-to-epic accepts that only as a post-terminal release member, and no terminal ${integrationBranch} -> ${terminalBase} PR was found.`];
|
|
453
|
+
if (candidate.state !== "MERGED") return [`${label} and is not merged (state ${candidate.state ?? "unknown"}); a post-terminal release member must be merged after terminal PR #${terminal.number}.`];
|
|
454
|
+
const candidateMergedAt = mergedAtMs(candidate);
|
|
455
|
+
const terminalMergedAt = mergedAtMs(terminal);
|
|
456
|
+
if (candidateMergedAt === void 0 || terminalMergedAt === void 0) return [`${label}; merge ordering against terminal PR #${terminal.number} cannot be established because GitHub reports no usable merge timestamp for ${candidateMergedAt === void 0 ? `PR #${candidate.number}` : `PR #${terminal.number}`}.`];
|
|
457
|
+
if (candidateMergedAt <= terminalMergedAt) return [`${label} but merged at ${candidate.mergedAt}, not after terminal PR #${terminal.number} (merged ${terminal.mergedAt}); each-to-epic admits a ${terminalBase}-targeting member from another head only as a post-terminal release member.`];
|
|
458
|
+
return [];
|
|
459
|
+
};
|
|
460
|
+
const contradicts = (previous, observation) => previous.baseRefName !== observation.baseRefName || previous.headRefName !== observation.headRefName || previous.state !== observation.state || previous.mergedAt !== observation.mergedAt;
|
|
461
|
+
/**
|
|
462
|
+
* Collapse the repeated observations of one PR. A PR declared under several
|
|
463
|
+
* issues is observed once per issue; identical observations collapse, and any
|
|
464
|
+
* disagreement about that PR's live facts fails closed instead of letting
|
|
465
|
+
* manifest order pick a winner.
|
|
466
|
+
*/
|
|
467
|
+
const collapseObservations = (pullRequests) => {
|
|
415
468
|
const byNumber = /* @__PURE__ */ new Map();
|
|
469
|
+
const reasons = [];
|
|
416
470
|
for (const pullRequest of pullRequests) {
|
|
417
471
|
const previous = byNumber.get(pullRequest.number);
|
|
418
|
-
if (previous && (previous
|
|
419
|
-
reasons.push(`PR #${pullRequest.number} has contradictory live
|
|
472
|
+
if (previous && contradicts(previous, pullRequest)) {
|
|
473
|
+
reasons.push(`PR #${pullRequest.number} has contradictory live data across its declared memberships.`);
|
|
420
474
|
continue;
|
|
421
475
|
}
|
|
422
476
|
byNumber.set(pullRequest.number, pullRequest);
|
|
423
477
|
}
|
|
478
|
+
return {
|
|
479
|
+
byNumber,
|
|
480
|
+
reasons
|
|
481
|
+
};
|
|
482
|
+
};
|
|
483
|
+
/**
|
|
484
|
+
* Validate the complete declared member set at boundary closeout. Callers
|
|
485
|
+
* supply only issue-linked PRs and unlinked manifest-fallback PRs; branch
|
|
486
|
+
* names classify those authoritative members' roles but never discover
|
|
487
|
+
* membership.
|
|
488
|
+
*/
|
|
489
|
+
const boundaryTopologyReasons = ({ manifest, pullRequests, terminalBase = DEFAULT_TERMINAL_BASE }) => {
|
|
490
|
+
const integrationBranch = eachToEpicBranch(manifest);
|
|
491
|
+
if (!integrationBranch) return [];
|
|
492
|
+
const { byNumber, reasons } = collapseObservations(pullRequests);
|
|
424
493
|
const terminalPullRequests = [];
|
|
494
|
+
const releaseCandidates = [];
|
|
425
495
|
for (const pullRequest of byNumber.values()) {
|
|
426
496
|
if (!pullRequest.headRefName) {
|
|
427
497
|
reasons.push(`PR #${pullRequest.number} has no live head branch name; each-to-epic closeout fails closed until GitHub reports headRefName.`);
|
|
@@ -437,16 +507,26 @@ const boundaryTopologyReasons = ({ manifest, pullRequests, terminalBase = DEFAUL
|
|
|
437
507
|
continue;
|
|
438
508
|
}
|
|
439
509
|
if (pullRequest.baseRefName === terminalBase) {
|
|
440
|
-
if (pullRequest.headRefName
|
|
441
|
-
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
terminalPullRequests.push(pullRequest.number);
|
|
510
|
+
if (pullRequest.headRefName === integrationBranch) terminalPullRequests.push(pullRequest);
|
|
511
|
+
else releaseCandidates.push(pullRequest);
|
|
445
512
|
continue;
|
|
446
513
|
}
|
|
447
514
|
reasons.push(`PR #${pullRequest.number} targets ${pullRequest.baseRefName ?? "(unknown)"}; every each-to-epic interior member must target declared integrationBranch ${integrationBranch}.`);
|
|
448
515
|
}
|
|
449
516
|
if (terminalPullRequests.length !== 1) reasons.push(`each-to-epic requires exactly one terminal member PR from ${integrationBranch} to ${terminalBase}; found ${terminalPullRequests.length}.`);
|
|
517
|
+
const [terminal] = terminalPullRequests;
|
|
518
|
+
const admittedReleaseMembers = [];
|
|
519
|
+
for (const candidate of releaseCandidates) {
|
|
520
|
+
const candidateReasons = postTerminalReleaseReasons({
|
|
521
|
+
candidate,
|
|
522
|
+
integrationBranch,
|
|
523
|
+
terminal: terminalPullRequests.length === 1 ? terminal : void 0,
|
|
524
|
+
terminalBase
|
|
525
|
+
});
|
|
526
|
+
if (candidateReasons.length === 0) admittedReleaseMembers.push(candidate);
|
|
527
|
+
reasons.push(...candidateReasons);
|
|
528
|
+
}
|
|
529
|
+
if (admittedReleaseMembers.length > 1) reasons.push(`each-to-epic admits at most one post-terminal release member targeting ${terminalBase}; found ${admittedReleaseMembers.length}: ${admittedReleaseMembers.map((member) => `PR #${member.number}`).join(", ")}.`);
|
|
450
530
|
return reasons;
|
|
451
531
|
};
|
|
452
532
|
//#endregion
|
|
@@ -1017,6 +1097,72 @@ function isAncestor(cwd, ancestor, descendant) {
|
|
|
1017
1097
|
if (result.status === 1) return false;
|
|
1018
1098
|
return false;
|
|
1019
1099
|
}
|
|
1100
|
+
/** The head commit's parent shas, in order. Empty on a root commit. */
|
|
1101
|
+
function commitParents(cwd, ref) {
|
|
1102
|
+
return runCapture("git", [
|
|
1103
|
+
"rev-list",
|
|
1104
|
+
"--parents",
|
|
1105
|
+
"-n",
|
|
1106
|
+
"1",
|
|
1107
|
+
ref
|
|
1108
|
+
], cwd).stdout.trim().split(/\s+/u).slice(1);
|
|
1109
|
+
}
|
|
1110
|
+
/** The tree object a commit-ish points at. */
|
|
1111
|
+
function treeShaOf(cwd, ref) {
|
|
1112
|
+
return runCapture("git", ["rev-parse", `${ref}^{tree}`], cwd).stdout.trim();
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Run the real merge machinery without touching the worktree
|
|
1116
|
+
* (`git merge-tree --write-tree`). Exit 0 is a clean merge, exit 1 is a
|
|
1117
|
+
* conflicted one; both write a tree. Any other outcome (old git, bad refs)
|
|
1118
|
+
* returns `undefined` so the caller fails closed.
|
|
1119
|
+
*/
|
|
1120
|
+
function mergeTreeWriteTree(cwd, firstParent, secondParent) {
|
|
1121
|
+
const result = spawnSync("git", [
|
|
1122
|
+
"merge-tree",
|
|
1123
|
+
"--write-tree",
|
|
1124
|
+
"--no-messages",
|
|
1125
|
+
firstParent,
|
|
1126
|
+
secondParent
|
|
1127
|
+
], {
|
|
1128
|
+
cwd,
|
|
1129
|
+
encoding: "utf-8",
|
|
1130
|
+
env: gitEnv(),
|
|
1131
|
+
stdio: [
|
|
1132
|
+
"ignore",
|
|
1133
|
+
"pipe",
|
|
1134
|
+
"pipe"
|
|
1135
|
+
]
|
|
1136
|
+
});
|
|
1137
|
+
if (result.error || result.status !== 0 && result.status !== 1) return;
|
|
1138
|
+
const [treeSha] = result.stdout.trim().split("\n");
|
|
1139
|
+
if (!treeSha || !/^[0-9a-f]{40,64}$/u.test(treeSha)) return;
|
|
1140
|
+
return {
|
|
1141
|
+
conflicted: result.status === 1,
|
|
1142
|
+
treeSha
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* The remote-tracking ref of the repository's default branch (e.g.
|
|
1147
|
+
* `refs/remotes/origin/main`), read from the local `origin/HEAD` symref.
|
|
1148
|
+
* `undefined` when the symref is unset; `git remote set-head origin --auto`
|
|
1149
|
+
* records it.
|
|
1150
|
+
*/
|
|
1151
|
+
function remoteDefaultBranchRef(cwd) {
|
|
1152
|
+
const result = spawnSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], {
|
|
1153
|
+
cwd,
|
|
1154
|
+
encoding: "utf-8",
|
|
1155
|
+
env: gitEnv(),
|
|
1156
|
+
stdio: [
|
|
1157
|
+
"ignore",
|
|
1158
|
+
"pipe",
|
|
1159
|
+
"pipe"
|
|
1160
|
+
]
|
|
1161
|
+
});
|
|
1162
|
+
if (result.error || result.status !== 0) return;
|
|
1163
|
+
const ref = result.stdout.trim();
|
|
1164
|
+
return ref.length > 0 ? ref : void 0;
|
|
1165
|
+
}
|
|
1020
1166
|
//#endregion
|
|
1021
1167
|
//#region src/github-issue-comments.ts
|
|
1022
1168
|
const isCommentPage = (value) => Array.isArray(value[0]);
|
|
@@ -3778,6 +3924,16 @@ const authorizeDemandWaiver = ({ authenticatedLogin, session }) => {
|
|
|
3778
3924
|
} };
|
|
3779
3925
|
};
|
|
3780
3926
|
//#endregion
|
|
3927
|
+
//#region src/catch-up-recognition-record.ts
|
|
3928
|
+
const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
3929
|
+
const catchUpRecognitionSchema = z.object({
|
|
3930
|
+
baseRef: z.string().min(1),
|
|
3931
|
+
baseTipSha: objectShaSchema,
|
|
3932
|
+
mergedParentSha: objectShaSchema,
|
|
3933
|
+
mergedTreeSha: objectShaSchema,
|
|
3934
|
+
upstreamRef: z.string().min(1)
|
|
3935
|
+
}).strict();
|
|
3936
|
+
//#endregion
|
|
3781
3937
|
//#region src/impact-stamp.ts
|
|
3782
3938
|
const LOCKFILE_PATH = "pnpm-lock.yaml";
|
|
3783
3939
|
const SUPPORTED_LOCKFILE_VERSION = "9.0";
|
|
@@ -4491,6 +4647,11 @@ const assertPrVerifyProofRules = (proof, context) => {
|
|
|
4491
4647
|
message: "notRequiredCommands is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
|
|
4492
4648
|
path: ["notRequiredCommands"]
|
|
4493
4649
|
});
|
|
4650
|
+
if (proof.schemaVersion < 4 && proof.catchUpRecognition !== void 0) context.addIssue({
|
|
4651
|
+
code: "custom",
|
|
4652
|
+
message: "catchUpRecognition is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
|
|
4653
|
+
path: ["catchUpRecognition"]
|
|
4654
|
+
});
|
|
4494
4655
|
if (proof.schemaVersion < 4 && proof.notDemandedRecords !== void 0) context.addIssue({
|
|
4495
4656
|
code: "custom",
|
|
4496
4657
|
message: "notDemandedRecords is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
|
|
@@ -4549,6 +4710,7 @@ const prVerifyProofSchema = z.object({
|
|
|
4549
4710
|
projectKey: z.string().min(1),
|
|
4550
4711
|
repository: z.string().min(1)
|
|
4551
4712
|
})).optional(),
|
|
4713
|
+
catchUpRecognition: catchUpRecognitionSchema.optional(),
|
|
4552
4714
|
changedFiles: z.array(z.string()),
|
|
4553
4715
|
classification: z.enum([
|
|
4554
4716
|
"docs/process-only",
|
|
@@ -4863,7 +5025,7 @@ z.object({
|
|
|
4863
5025
|
patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
|
|
4864
5026
|
reviewCycle: z.number().int().positive().optional(),
|
|
4865
5027
|
reviewRequirement: z.object({
|
|
4866
|
-
reason: z.enum(["no-applicable-mode"]),
|
|
5028
|
+
reason: z.enum(["no-applicable-mode", "faithful-merge"]),
|
|
4867
5029
|
status: z.literal("not-required")
|
|
4868
5030
|
}).strict().optional(),
|
|
4869
5031
|
reviews: z.array(prReviewResultSchema$1),
|
|
@@ -5183,7 +5345,7 @@ const CLOSING_PRS_QUERY = `query($owner: String!, $repo: String!, $issue: Int!)
|
|
|
5183
5345
|
repository(owner: $owner, name: $repo) {
|
|
5184
5346
|
issue(number: $issue) {
|
|
5185
5347
|
closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
|
|
5186
|
-
nodes { number state baseRefName headRefName headRefOid mergeCommit { oid } }
|
|
5348
|
+
nodes { number state baseRefName headRefName headRefOid mergedAt mergeCommit { oid } }
|
|
5187
5349
|
}
|
|
5188
5350
|
}
|
|
5189
5351
|
}
|
|
@@ -5215,6 +5377,7 @@ const defaultBoundaryCheckGithub = () => ({
|
|
|
5215
5377
|
...node.headRefOid ? { headRefOid: node.headRefOid } : {},
|
|
5216
5378
|
...node.headRefName ? { headRefName: node.headRefName } : {},
|
|
5217
5379
|
...node.mergeCommit?.oid ? { mergeCommitOid: node.mergeCommit.oid } : {},
|
|
5380
|
+
...node.mergedAt ? { mergedAt: node.mergedAt } : {},
|
|
5218
5381
|
number: node.number,
|
|
5219
5382
|
state: node.state
|
|
5220
5383
|
}));
|
|
@@ -5263,7 +5426,8 @@ function runBoundaryCheck(args, dependencies = {}) {
|
|
|
5263
5426
|
let record;
|
|
5264
5427
|
if (parsed.ok) {
|
|
5265
5428
|
const demandedRung = closeoutRungFor(parsed.manifest);
|
|
5266
|
-
const
|
|
5429
|
+
const graph = buildBoundaryMembershipGraph(parsed.manifest, (issue) => github.fetchClosingPullRequests(repo, issue));
|
|
5430
|
+
const computed = coveredSetProjection(graph);
|
|
5267
5431
|
const comments = github.fetchIssueComments(repo, args.epic);
|
|
5268
5432
|
const selected = selectBoundaryReviewProof(comments, parsed.manifest.boundary, parsed.manifest.declaredBy);
|
|
5269
5433
|
const { blockingReasons } = evaluateProofAgainstDemands({
|
|
@@ -5274,10 +5438,11 @@ function runBoundaryCheck(args, dependencies = {}) {
|
|
|
5274
5438
|
manifestHash: parsed.manifestHash,
|
|
5275
5439
|
selected
|
|
5276
5440
|
});
|
|
5441
|
+
const uncoveredIssueReasons = graph.unlinkedIssues.flatMap((entry) => entry.closedWithoutMerge === void 0 ? [] : [`issue #${entry.issue} (wave ${entry.wave}) is uncovered: its only closing link(s) ${entry.closedWithoutMerge.map((pr) => `PR #${pr}`).join(", ")} closed without merging`]);
|
|
5277
5442
|
blockingReasons.unshift(...boundaryTopologyReasons({
|
|
5278
5443
|
manifest: parsed.manifest,
|
|
5279
5444
|
pullRequests: computed.pullRequests
|
|
5280
|
-
}));
|
|
5445
|
+
}), ...uncoveredIssueReasons);
|
|
5281
5446
|
record = {
|
|
5282
5447
|
blockingReasons,
|
|
5283
5448
|
boundary: parsed.manifest.boundary,
|
|
@@ -7279,7 +7444,7 @@ const ReviewGateTracePayloadShape = z.object({
|
|
|
7279
7444
|
observedAt: isoTimestamp,
|
|
7280
7445
|
outcome: z.literal("not-required"),
|
|
7281
7446
|
patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
|
|
7282
|
-
reason: z.enum(["no-applicable-mode"]),
|
|
7447
|
+
reason: z.enum(["no-applicable-mode", "faithful-merge"]),
|
|
7283
7448
|
reviewCycle: positiveInteger
|
|
7284
7449
|
}).strict();
|
|
7285
7450
|
const ReviewGateTracePayloadSchema = ReviewGateTracePayloadShape;
|
|
@@ -8638,6 +8803,46 @@ function createCandidateIdentityResolver({ cwd, git }) {
|
|
|
8638
8803
|
};
|
|
8639
8804
|
}
|
|
8640
8805
|
//#endregion
|
|
8806
|
+
//#region src/catch-up-recognition.ts
|
|
8807
|
+
const refused = (reason) => ({
|
|
8808
|
+
reasons: [reason],
|
|
8809
|
+
recognized: false
|
|
8810
|
+
});
|
|
8811
|
+
const recognizeCatchUpCandidate = ({ base, cwd }) => {
|
|
8812
|
+
try {
|
|
8813
|
+
if (statusPorcelain(cwd) !== "") return refused("catch-up recognition refused: the worktree is dirty, so the committed head is not the candidate content.");
|
|
8814
|
+
const parents = commitParents(cwd, "HEAD");
|
|
8815
|
+
if (parents.length !== 2) return refused(`catch-up recognition refused: HEAD has ${parents.length} parent(s); a catch-up head is a merge commit with exactly two.`);
|
|
8816
|
+
const [firstParent, secondParent] = parents;
|
|
8817
|
+
const baseTipSha = runCapture("git", [
|
|
8818
|
+
"rev-parse",
|
|
8819
|
+
"--verify",
|
|
8820
|
+
`${base}^{commit}`
|
|
8821
|
+
], cwd).stdout.trim();
|
|
8822
|
+
if (firstParent !== baseTipSha) return refused(`catch-up recognition refused: the head's first parent ${firstParent} is not the live target tip ${baseTipSha} (${base}); re-merge onto the current tip and re-run.`);
|
|
8823
|
+
const upstreamRef = remoteDefaultBranchRef(cwd);
|
|
8824
|
+
if (upstreamRef === void 0) return refused("catch-up recognition refused: origin/HEAD is unset, so the second parent's provenance cannot be proved; run `git remote set-head origin --auto` and re-run.");
|
|
8825
|
+
if (!isAncestor(cwd, secondParent, upstreamRef)) return refused(`catch-up recognition refused: the head's second parent ${secondParent} is not an ancestor of ${upstreamRef}, so its content is not provably admitted.`);
|
|
8826
|
+
const mergeTree = mergeTreeWriteTree(cwd, firstParent, secondParent);
|
|
8827
|
+
if (mergeTree === void 0) return refused("catch-up recognition refused: `git merge-tree --write-tree` did not produce a tree (git older than 2.38, or unreadable parents).");
|
|
8828
|
+
if (mergeTree.conflicted) return refused("catch-up recognition refused: merging the two parents conflicts, so the head carries manual resolutions; a conflicted catch-up is a full candidate.");
|
|
8829
|
+
const headTreeSha = treeShaOf(cwd, "HEAD");
|
|
8830
|
+
if (headTreeSha !== mergeTree.treeSha) return refused(`catch-up recognition refused: the head tree ${headTreeSha} differs from the faithful merge tree ${mergeTree.treeSha}, so the merge carries content beyond its two parents.`);
|
|
8831
|
+
return {
|
|
8832
|
+
recognition: {
|
|
8833
|
+
baseRef: base,
|
|
8834
|
+
baseTipSha,
|
|
8835
|
+
mergedParentSha: secondParent,
|
|
8836
|
+
mergedTreeSha: mergeTree.treeSha,
|
|
8837
|
+
upstreamRef
|
|
8838
|
+
},
|
|
8839
|
+
recognized: true
|
|
8840
|
+
};
|
|
8841
|
+
} catch (error) {
|
|
8842
|
+
return refused(`catch-up recognition refused: ${error instanceof Error ? error.message : String(error)}`);
|
|
8843
|
+
}
|
|
8844
|
+
};
|
|
8845
|
+
//#endregion
|
|
8641
8846
|
//#region src/evidence-freshness.ts
|
|
8642
8847
|
const patchIdMatches = (recorded, candidate) => Boolean(recorded.patchId) && recorded.patchId === candidate.patchId;
|
|
8643
8848
|
const headShaMatches = (recorded, candidate) => Boolean(recorded.headSha && candidate.headSha && sameHeadSha(candidate.headSha, recorded.headSha));
|
|
@@ -8927,6 +9132,14 @@ const evaluateRequiredChecks = ({ authoringSessionIds, candidate, envelopes, req
|
|
|
8927
9132
|
scopeReason: release.reason,
|
|
8928
9133
|
status: "out-of-scope"
|
|
8929
9134
|
};
|
|
9135
|
+
if (scopeContext.catchUpRecognition && !boundFailureObserved) return {
|
|
9136
|
+
checkType: check.checkType,
|
|
9137
|
+
inScope: false,
|
|
9138
|
+
name: check.name,
|
|
9139
|
+
...check.scope ? { scope: check.scope } : {},
|
|
9140
|
+
scopeReason: `catch-up recognition released: the head is a faithful merge of ${scopeContext.catchUpRecognition.baseTipSha} and admitted base-side content (#833)`,
|
|
9141
|
+
status: "out-of-scope"
|
|
9142
|
+
};
|
|
8930
9143
|
return {
|
|
8931
9144
|
...evaluateInScopeCheck({
|
|
8932
9145
|
authoringSessionIds,
|
|
@@ -9904,6 +10117,7 @@ var verification_proof_exports = /* @__PURE__ */ __exportAll({
|
|
|
9904
10117
|
prVerifyProofHeadShas: () => prVerifyProofHeadShas,
|
|
9905
10118
|
readPrVerifyProof: () => readPrVerifyProof,
|
|
9906
10119
|
resolveVerifyProofApplicability: () => resolveVerifyProofApplicability,
|
|
10120
|
+
trustedCatchUpRecognition: () => trustedCatchUpRecognition,
|
|
9907
10121
|
trustedImpactStamp: () => trustedImpactStamp,
|
|
9908
10122
|
validatePrVerifyProof: () => validatePrVerifyProof,
|
|
9909
10123
|
verificationProofApplicabilityFor: () => verificationProofApplicabilityFor,
|
|
@@ -9973,6 +10187,24 @@ const trustedImpactStamp = ({ candidate, proof }) => {
|
|
|
9973
10187
|
});
|
|
9974
10188
|
return headBound && patchBound ? proof.impactStamp : void 0;
|
|
9975
10189
|
};
|
|
10190
|
+
/**
|
|
10191
|
+
* #833: the recorded catch-up recognition, consumed ONLY when it is
|
|
10192
|
+
* identity-bound to the current candidate under exactly the rules
|
|
10193
|
+
* `trustedImpactStamp` applies — the proof passed, is v4+, and its recorded
|
|
10194
|
+
* headSha AND patchId both name this candidate. The recognition withdraws
|
|
10195
|
+
* review and preview demands, so it earns the same strict double binding and
|
|
10196
|
+
* the same fail-closed answer (`undefined`) on every other state.
|
|
10197
|
+
*/
|
|
10198
|
+
const trustedCatchUpRecognition = ({ candidate, proof }) => {
|
|
10199
|
+
if (!proof || !verifyProofPassed(proof) || proof.schemaVersion < 4) return;
|
|
10200
|
+
const headBound = sameHeadSha(candidate.headSha, proof.headSha);
|
|
10201
|
+
const patchBound = evidenceFresh({
|
|
10202
|
+
candidate: { patchId: candidate.patchId },
|
|
10203
|
+
recorded: { patchId: proof.patchId },
|
|
10204
|
+
rule: "patch-id"
|
|
10205
|
+
});
|
|
10206
|
+
return headBound && patchBound ? proof.catchUpRecognition : void 0;
|
|
10207
|
+
};
|
|
9976
10208
|
const prVerifyFullHeadShasForDiff = ({ docsOnlyDeltaAcceptedSince, files, proof }) => {
|
|
9977
10209
|
if (!verifyProofPassed(proof)) return [];
|
|
9978
10210
|
if (proof.mode === "full") return sameFileList(proof.changedFiles, files) ? [proof.headSha] : [];
|
|
@@ -10254,6 +10486,7 @@ function defaultPrVerifyGit() {
|
|
|
10254
10486
|
currentHeadSha,
|
|
10255
10487
|
emptyTreeHash,
|
|
10256
10488
|
mergeBaseSha,
|
|
10489
|
+
recognizeCatchUp: recognizeCatchUpCandidate,
|
|
10257
10490
|
runVerificationCommand(command, cwd, env) {
|
|
10258
10491
|
const result = runInheritedTee("bash", ["-lc", command], cwd, env);
|
|
10259
10492
|
const counts = parseVitestCounts(result.stdout);
|
|
@@ -10293,11 +10526,12 @@ function baselineFullProofsForDocsOnly({ previousProof, profilePath, projectKey,
|
|
|
10293
10526
|
seedFilter: matchesProfile
|
|
10294
10527
|
});
|
|
10295
10528
|
}
|
|
10296
|
-
function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, profilePath, projectKey, repository, startedAt }) {
|
|
10529
|
+
function buildPrVerifyProof({ authoringSession, base, baselineFullProofs, catchUpRecognition, classification, commands, endedAt, executedCommands, files, headSha, impactStamp, mode, mergeBaseSha: proofMergeBaseSha, notRequiredCommands, outcome, patchId, profilePath, projectKey, repository, startedAt }) {
|
|
10297
10530
|
return {
|
|
10298
10531
|
authoringSession,
|
|
10299
10532
|
base,
|
|
10300
10533
|
...baselineFullProofs ? { baselineFullProofs } : {},
|
|
10534
|
+
...catchUpRecognition ? { catchUpRecognition } : {},
|
|
10301
10535
|
changedFiles: files,
|
|
10302
10536
|
classification: classification.classification,
|
|
10303
10537
|
classificationReasons: classification.reasons,
|
|
@@ -10380,6 +10614,11 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
10380
10614
|
cwd,
|
|
10381
10615
|
git
|
|
10382
10616
|
});
|
|
10617
|
+
const catchUpRecognitionResult = (git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
|
|
10618
|
+
base: args.base,
|
|
10619
|
+
cwd
|
|
10620
|
+
});
|
|
10621
|
+
const catchUpRecognition = catchUpRecognitionResult.recognized ? catchUpRecognitionResult.recognition : void 0;
|
|
10383
10622
|
const readLockfileAtRef = git.showFileAtRef ?? showFileAtRef;
|
|
10384
10623
|
let impactStamp;
|
|
10385
10624
|
try {
|
|
@@ -10425,6 +10664,7 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
10425
10664
|
console.log(`classification: ${classification.classification}`);
|
|
10426
10665
|
for (const line of context.runSummaryLines()) console.log(line);
|
|
10427
10666
|
for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
|
|
10667
|
+
if (catchUpRecognition) console.log(`catch-up recognition (#833): faithful merge of ${catchUpRecognition.baseTipSha} (${catchUpRecognition.baseRef}) and ${catchUpRecognition.mergedParentSha} (admitted via ${catchUpRecognition.upstreamRef}); verification mode and battery scoping are unchanged.`);
|
|
10428
10668
|
const vetoedTargets = boundFailingCheckNames({
|
|
10429
10669
|
candidate: {
|
|
10430
10670
|
headSha,
|
|
@@ -10461,6 +10701,7 @@ function runPrVerify(args, dependencies = {}) {
|
|
|
10461
10701
|
const proofContext = {
|
|
10462
10702
|
authoringSession,
|
|
10463
10703
|
base: args.base,
|
|
10704
|
+
...catchUpRecognition ? { catchUpRecognition } : {},
|
|
10464
10705
|
classification,
|
|
10465
10706
|
commands,
|
|
10466
10707
|
files,
|
|
@@ -10856,7 +11097,7 @@ const closingPullRequestsQuery = `query($owner: String!, $repo: String!, $issue:
|
|
|
10856
11097
|
repository(owner: $owner, name: $repo) {
|
|
10857
11098
|
issue(number: $issue) {
|
|
10858
11099
|
closedByPullRequestsReferences(first: 50, includeClosedPrs: true) {
|
|
10859
|
-
nodes { number }
|
|
11100
|
+
nodes { number state }
|
|
10860
11101
|
}
|
|
10861
11102
|
}
|
|
10862
11103
|
}
|
|
@@ -11818,7 +12059,7 @@ const prReviewProofSchema = z.object({
|
|
|
11818
12059
|
patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
|
|
11819
12060
|
reviewCycle: z.number().int().positive().optional(),
|
|
11820
12061
|
reviewRequirement: z.object({
|
|
11821
|
-
reason: z.enum(["no-applicable-mode"]),
|
|
12062
|
+
reason: z.enum(["no-applicable-mode", "faithful-merge"]),
|
|
11822
12063
|
status: z.literal("not-required")
|
|
11823
12064
|
}).strict().optional(),
|
|
11824
12065
|
reviews: z.array(prReviewResultSchema),
|
|
@@ -14006,6 +14247,21 @@ const addSecurityBlockingReasons = ({ blockers, securityRequired, securityStatus
|
|
|
14006
14247
|
reason: "Security review proof ran and explicitly failed (open blocking findings or errored run); resolve or disposition the findings and re-run pr:review."
|
|
14007
14248
|
});
|
|
14008
14249
|
};
|
|
14250
|
+
/**
|
|
14251
|
+
* #833: the trusted catch-up recognition for this evaluation's candidate —
|
|
14252
|
+
* `trustedCatchUpRecognition` over the same passed verify proof the impact
|
|
14253
|
+
* stamp is read from, fail closed to `undefined` on every other state.
|
|
14254
|
+
*/
|
|
14255
|
+
const recognitionForCandidate = (input) => {
|
|
14256
|
+
const { verificationProof } = input;
|
|
14257
|
+
return trustedCatchUpRecognition({
|
|
14258
|
+
candidate: {
|
|
14259
|
+
headSha: input.headSha,
|
|
14260
|
+
patchId: input.patchId
|
|
14261
|
+
},
|
|
14262
|
+
proof: verificationProof && "proof" in verificationProof ? verificationProof.proof : void 0
|
|
14263
|
+
});
|
|
14264
|
+
};
|
|
14009
14265
|
const requiredChecksEvaluation = (input) => {
|
|
14010
14266
|
const requiredChecks = input.externalRequiredChecks ?? [];
|
|
14011
14267
|
if (requiredChecks.length === 0) return {
|
|
@@ -14018,6 +14274,7 @@ const requiredChecksEvaluation = (input) => {
|
|
|
14018
14274
|
patchId: input.patchId
|
|
14019
14275
|
};
|
|
14020
14276
|
const { verificationProof } = input;
|
|
14277
|
+
const catchUpRecognition = recognitionForCandidate(input);
|
|
14021
14278
|
const impactStamp = trustedImpactStamp({
|
|
14022
14279
|
candidate: {
|
|
14023
14280
|
headSha: input.headSha,
|
|
@@ -14031,6 +14288,7 @@ const requiredChecksEvaluation = (input) => {
|
|
|
14031
14288
|
envelopes: input.evidenceEnvelopes ?? [],
|
|
14032
14289
|
requiredChecks,
|
|
14033
14290
|
scopeContext: {
|
|
14291
|
+
...catchUpRecognition ? { catchUpRecognition } : {},
|
|
14034
14292
|
classification: input.classification,
|
|
14035
14293
|
...impactStamp ? { impactStamp } : {}
|
|
14036
14294
|
}
|
|
@@ -14243,7 +14501,8 @@ const foldWaivers = ({ blockers, waivers }) => {
|
|
|
14243
14501
|
};
|
|
14244
14502
|
};
|
|
14245
14503
|
const evaluateReadiness = (input) => {
|
|
14246
|
-
const
|
|
14504
|
+
const catchUpRecognition = recognitionForCandidate(input);
|
|
14505
|
+
const reviewModes = catchUpRecognition ? [] : input.reviewModes ?? ["correctness"];
|
|
14247
14506
|
const epoch = reviewEpochModeFor(input.reviewProof ?? {}, input.ladderPolicy);
|
|
14248
14507
|
const verifyGate = verifyGateFor({ input });
|
|
14249
14508
|
const { correctnessProof, correctnessRequired, correctnessStatus, correctnessUntyped, gateCapMismatchBlockers, reviewCycleState, reviewTerminalState, reviewLadder, securityProof, securityRequired, securityStatus, securityUntyped } = resolveReviewRequiredness({
|
|
@@ -14281,6 +14540,7 @@ const evaluateReadiness = (input) => {
|
|
|
14281
14540
|
waivers: input.waivers ?? []
|
|
14282
14541
|
});
|
|
14283
14542
|
const notices = [
|
|
14543
|
+
...catchUpRecognition ? [`Catch-up recognition (#833): the head is a faithful merge of target tip ${catchUpRecognition.baseTipSha} and admitted base-side content (${catchUpRecognition.upstreamRef}); review modes and impact-demanded envelopes are not demanded for this candidate.`] : [],
|
|
14284
14544
|
...input.mergeFreeze?.notices ?? [],
|
|
14285
14545
|
...waivedDemands.map(waivedDemandNotice),
|
|
14286
14546
|
...unusedWaiverNotices({
|
|
@@ -15774,6 +16034,7 @@ const defaultPrReviewGit = () => ({
|
|
|
15774
16034
|
changedFiles,
|
|
15775
16035
|
currentHeadSha,
|
|
15776
16036
|
isAncestor,
|
|
16037
|
+
recognizeCatchUp: recognizeCatchUpCandidate,
|
|
15777
16038
|
stablePatchId: runStablePatchId,
|
|
15778
16039
|
statusPorcelain
|
|
15779
16040
|
});
|
|
@@ -15908,11 +16169,7 @@ async function runPrReview(args, dependencies = {}) {
|
|
|
15908
16169
|
const startedAt = /* @__PURE__ */ new Date();
|
|
15909
16170
|
const findingsFile = args.findings ? parseReviewFindingsFile(args.findings, cwd) : void 0;
|
|
15910
16171
|
const kinds = enabledKinds(profile, args.mode, files, findingsFile?.reviews.map((review) => review.kind) ?? []);
|
|
15911
|
-
|
|
15912
|
-
const reviewRequirement = {
|
|
15913
|
-
reason: "no-applicable-mode",
|
|
15914
|
-
status: "not-required"
|
|
15915
|
-
};
|
|
16172
|
+
const mintNotRequiredProof = (reviewRequirement) => {
|
|
15916
16173
|
const proof = emptyProof({
|
|
15917
16174
|
base: args.base,
|
|
15918
16175
|
cycle: args.cycle,
|
|
@@ -15940,8 +16197,21 @@ async function runPrReview(args, dependencies = {}) {
|
|
|
15940
16197
|
repoRoot: cwd
|
|
15941
16198
|
});
|
|
15942
16199
|
return proof;
|
|
16200
|
+
};
|
|
16201
|
+
if (kinds.length === 0) return mintNotRequiredProof({
|
|
16202
|
+
reason: "no-applicable-mode",
|
|
16203
|
+
status: "not-required"
|
|
16204
|
+
});
|
|
16205
|
+
if (!findingsFile) {
|
|
16206
|
+
if ((git.recognizeCatchUp ?? recognizeCatchUpCandidate)({
|
|
16207
|
+
base: args.base,
|
|
16208
|
+
cwd
|
|
16209
|
+
}).recognized) return mintNotRequiredProof({
|
|
16210
|
+
reason: "faithful-merge",
|
|
16211
|
+
status: "not-required"
|
|
16212
|
+
});
|
|
16213
|
+
throw new Error("--findings <path> is required when review kinds are enabled.");
|
|
15943
16214
|
}
|
|
15944
|
-
if (!findingsFile) throw new Error("--findings <path> is required when review kinds are enabled.");
|
|
15945
16215
|
if (!evidenceFresh({
|
|
15946
16216
|
candidate: {
|
|
15947
16217
|
headSha,
|
package/dist/schemas.d.ts
CHANGED
|
@@ -268,7 +268,7 @@ interface PrReviewProof {
|
|
|
268
268
|
cleanedPaths: string[];
|
|
269
269
|
ladder?: PrReviewLadderState;
|
|
270
270
|
reviewRequirement?: {
|
|
271
|
-
reason: "no-applicable-mode";
|
|
271
|
+
reason: "no-applicable-mode" | "faithful-merge";
|
|
272
272
|
status: "not-required";
|
|
273
273
|
};
|
|
274
274
|
reviews: PrReviewResult[];
|
|
@@ -443,6 +443,23 @@ type ReadinessRepair = z.infer<typeof readinessRepairSchema>;
|
|
|
443
443
|
type ReadinessStatus = "ready" | "blocked";
|
|
444
444
|
type ManagedReadinessLedger = z.infer<typeof managedReadinessLedgerSchema>;
|
|
445
445
|
//#endregion
|
|
446
|
+
//#region src/catch-up-recognition-record.d.ts
|
|
447
|
+
interface CatchUpRecognition {
|
|
448
|
+
/** The ref the candidate targets, as the recognizing command resolved it. */
|
|
449
|
+
baseRef: string;
|
|
450
|
+
/** The live target tip at recognition time; the head's first parent. */
|
|
451
|
+
baseTipSha: string;
|
|
452
|
+
/** The head's second parent: the admitted base-side content merged in. */
|
|
453
|
+
mergedParentSha: string;
|
|
454
|
+
/**
|
|
455
|
+
* The tree `git merge-tree --write-tree` produced for the two parents. It
|
|
456
|
+
* equals the head's own tree; that equality is the faithfulness proof.
|
|
457
|
+
*/
|
|
458
|
+
mergedTreeSha: string;
|
|
459
|
+
/** The ref that proves the second parent is admitted content. */
|
|
460
|
+
upstreamRef: string;
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
446
463
|
//#region src/pr-verify-proof-contract.d.ts
|
|
447
464
|
declare const SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS: readonly [1, 2, 3, 4];
|
|
448
465
|
type PrVerifySchemaVersion = (typeof SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS)[number];
|
|
@@ -509,6 +526,7 @@ interface PrVerifyProof {
|
|
|
509
526
|
projectKey: string;
|
|
510
527
|
repository: string;
|
|
511
528
|
}[];
|
|
529
|
+
catchUpRecognition?: CatchUpRecognition;
|
|
512
530
|
changedFiles: string[];
|
|
513
531
|
classification: DiffClassification;
|
|
514
532
|
classificationReasons: string[];
|
|
@@ -541,6 +559,7 @@ declare const prVerifyProofSchema: z.ZodObject<{
|
|
|
541
559
|
projectKey: z.ZodString;
|
|
542
560
|
repository: z.ZodString;
|
|
543
561
|
}, z.core.$strip>>>;
|
|
562
|
+
catchUpRecognition: z.ZodOptional<z.ZodType<CatchUpRecognition, unknown, z.core.$ZodTypeInternals<CatchUpRecognition, unknown>>>;
|
|
544
563
|
changedFiles: z.ZodArray<z.ZodString>;
|
|
545
564
|
classification: z.ZodEnum<{
|
|
546
565
|
"docs/process-only": "docs/process-only";
|
|
@@ -827,6 +846,7 @@ declare const prReviewProofSchema: z.ZodObject<{
|
|
|
827
846
|
reviewRequirement: z.ZodOptional<z.ZodObject<{
|
|
828
847
|
reason: z.ZodEnum<{
|
|
829
848
|
"no-applicable-mode": "no-applicable-mode";
|
|
849
|
+
"faithful-merge": "faithful-merge";
|
|
830
850
|
}>;
|
|
831
851
|
status: z.ZodLiteral<"not-required">;
|
|
832
852
|
}, z.core.$strict>>;
|
package/dist/schemas.js
CHANGED
|
@@ -228,6 +228,16 @@ const waivedDemandSchema = z.object({
|
|
|
228
228
|
session: z.string().trim().min(1).optional(),
|
|
229
229
|
unmetReasons: z.array(z.string().min(1)).min(1)
|
|
230
230
|
});
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/catch-up-recognition-record.ts
|
|
233
|
+
const objectShaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
|
|
234
|
+
const catchUpRecognitionSchema = z.object({
|
|
235
|
+
baseRef: z.string().min(1),
|
|
236
|
+
baseTipSha: objectShaSchema,
|
|
237
|
+
mergedParentSha: objectShaSchema,
|
|
238
|
+
mergedTreeSha: objectShaSchema,
|
|
239
|
+
upstreamRef: z.string().min(1)
|
|
240
|
+
}).strict();
|
|
231
241
|
const impactStampTargetSchema = z.object({
|
|
232
242
|
basis: z.string().min(1),
|
|
233
243
|
impact: z.enum(["affected", "not-affected"]),
|
|
@@ -515,6 +525,11 @@ const assertPrVerifyProofRules = (proof, context) => {
|
|
|
515
525
|
message: "notRequiredCommands is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
|
|
516
526
|
path: ["notRequiredCommands"]
|
|
517
527
|
});
|
|
528
|
+
if (proof.schemaVersion < 4 && proof.catchUpRecognition !== void 0) context.addIssue({
|
|
529
|
+
code: "custom",
|
|
530
|
+
message: "catchUpRecognition is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
|
|
531
|
+
path: ["catchUpRecognition"]
|
|
532
|
+
});
|
|
518
533
|
if (proof.schemaVersion < 4 && proof.notDemandedRecords !== void 0) context.addIssue({
|
|
519
534
|
code: "custom",
|
|
520
535
|
message: "notDemandedRecords is a schemaVersion>=4 field; a v1–v3 pr:verify proof must not carry it.",
|
|
@@ -573,6 +588,7 @@ const prVerifyProofSchema = z.object({
|
|
|
573
588
|
projectKey: z.string().min(1),
|
|
574
589
|
repository: z.string().min(1)
|
|
575
590
|
})).optional(),
|
|
591
|
+
catchUpRecognition: catchUpRecognitionSchema.optional(),
|
|
576
592
|
changedFiles: z.array(z.string()),
|
|
577
593
|
classification: z.enum([
|
|
578
594
|
"docs/process-only",
|
|
@@ -888,7 +904,7 @@ const prReviewProofSchema = z.object({
|
|
|
888
904
|
patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
|
|
889
905
|
reviewCycle: z.number().int().positive().optional(),
|
|
890
906
|
reviewRequirement: z.object({
|
|
891
|
-
reason: z.enum(["no-applicable-mode"]),
|
|
907
|
+
reason: z.enum(["no-applicable-mode", "faithful-merge"]),
|
|
892
908
|
status: z.literal("not-required")
|
|
893
909
|
}).strict().optional(),
|
|
894
910
|
reviews: z.array(prReviewResultSchema),
|
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.21",
|
|
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.21"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "24.13.3",
|
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"dependency-cruiser": "18.1.0",
|
|
47
47
|
"knip": "6.27.0",
|
|
48
48
|
"oxfmt": "0.59.0",
|
|
49
|
-
"oxlint": "1.
|
|
49
|
+
"oxlint": "1.76.0",
|
|
50
50
|
"oxlint-tsgolint": "7.0.2001",
|
|
51
51
|
"tsdown": "0.21.10",
|
|
52
52
|
"tsx": "4.23.1",
|
|
53
53
|
"typescript": "5.9.3",
|
|
54
|
-
"ultracite": "7.
|
|
54
|
+
"ultracite": "7.10.5",
|
|
55
55
|
"vitest": "4.1.10",
|
|
56
56
|
"vitest-evals": "0.14.0"
|
|
57
57
|
},
|