@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 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";
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
- if (refs.length === 0) unlinkedIssues.push({
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 refs) memberships.push({
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 && headRefName === integrationBranch) return [];
403
- return [`PR #${number} contradicts each-to-epic topology: live head/base ${headRefName ?? "(unknown)"} -> ${baseRefName ?? "(unknown)"}; expected an interior member targeting ${integrationBranch}, or the terminal ${integrationBranch} -> ${terminalBase} PR.`];
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
- * Validate the complete declared member set at boundary closeout. Callers
407
- * supply only issue-linked PRs and unlinked manifest-fallback PRs; branch
408
- * names classify those authoritative members' roles but never discover
409
- * membership.
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 boundaryTopologyReasons = ({ manifest, pullRequests, terminalBase = DEFAULT_TERMINAL_BASE }) => {
412
- const integrationBranch = eachToEpicBranch(manifest);
413
- if (!integrationBranch) return [];
414
- const reasons = [];
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.baseRefName !== pullRequest.baseRefName || previous.headRefName !== pullRequest.headRefName)) {
419
- reasons.push(`PR #${pullRequest.number} has contradictory live branch data across its declared memberships.`);
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 !== integrationBranch) {
441
- reasons.push(`PR #${pullRequest.number} targets ${terminalBase} from ${pullRequest.headRefName ?? "(unknown)"}; the each-to-epic terminal must originate from declared integrationBranch ${integrationBranch}.`);
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 computed = coveredSetProjection(buildBoundaryMembershipGraph(parsed.manifest, (issue) => github.fetchClosingPullRequests(repo, issue)));
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 reviewModes = input.reviewModes ?? ["correctness"];
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
- if (kinds.length === 0) {
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.20",
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.20"
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.74.0",
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.9.4",
54
+ "ultracite": "7.10.5",
55
55
  "vitest": "4.1.10",
56
56
  "vitest-evals": "0.14.0"
57
57
  },