@patronage/software-factory 1.0.0-alpha.31 → 1.0.0-alpha.32

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/schemas.js CHANGED
@@ -214,11 +214,6 @@ const blockedReasonIssues = (proof) => {
214
214
  const drifted = named.findIndex((reason, index) => reason.detail !== blockedReasonDetail(reasons[index]));
215
215
  return drifted === -1 ? [] : blockedReasonIssue(`blockedReasons[${drifted}] does not carry blocking reason ${drifted}; the two projections must tell one story`);
216
216
  };
217
- const sameHeadSha = (left, right) => {
218
- if (!left || !right) return false;
219
- if (Math.min(left.length, right.length) < 7) return false;
220
- return left.startsWith(right) || right.startsWith(left);
221
- };
222
217
  //#endregion
223
218
  //#region src/demand-waiver.ts
224
219
  const DEMAND_WAIVER_SCHEMA_VERSION = 1;
@@ -333,23 +328,121 @@ const REVIEW_CATEGORIES = [
333
328
  "unknown"
334
329
  ];
335
330
  //#endregion
336
- //#region src/evidence-freshness.ts
337
- const patchIdMatches = (recorded, candidate) => Boolean(recorded.patchId) && recorded.patchId === candidate.patchId;
338
- const headShaMatches = (recorded, candidate) => Boolean(recorded.headSha && candidate.headSha && sameHeadSha(candidate.headSha, recorded.headSha));
339
- /**
340
- * The one freshness decision. `true` means the recorded evidence still
341
- * describes the candidate.
342
- *
343
- * Time is not an input and never will be: adding one would contradict the
344
- * `freshness` definition in root `CONTEXT.md`.
345
- */
346
- function evidenceFresh({ candidate, recorded, rule }) {
347
- if (patchIdMatches(recorded, candidate)) return true;
348
- return rule === "patch-id-or-head-sha" && headShaMatches(recorded, candidate);
349
- }
350
- //#endregion
351
- //#region src/sha256-hex.ts
352
- const ROUND_CONSTANTS = new Uint32Array([
331
+ //#region src/review-ladder-ledger.ts
332
+ const OCCURRENCE_ADDRESS_PATTERN = /^c[1-9][0-9]*f[1-9][0-9]*$/u;
333
+ const occurrenceAddress = (cycle, finding) => `c${cycle}f${finding}`;
334
+ /** The 1-based recording position an address names. */
335
+ const cycleOfAddress = (address) => Number(/^c(?<cycle>\d+)f/u.exec(address)?.groups?.cycle);
336
+ const POLICY_DECLARATION_RULES = [
337
+ "cycle-2-severity-floor",
338
+ "identity-repeat",
339
+ "schema-2-import"
340
+ ];
341
+ const definedAttributes = (finding) => Object.fromEntries([
342
+ "blockingAfterCap",
343
+ "category",
344
+ "citedSpan",
345
+ "file",
346
+ "prescribedAction",
347
+ "priority"
348
+ ].flatMap((attribute) => finding[attribute] === void 0 ? [] : [[attribute, finding[attribute]]]));
349
+ const AUTHORITY_RANK = {
350
+ operator: 3,
351
+ policy: 1,
352
+ reviewer: 2
353
+ };
354
+ const outranked = (occurrence, declaration) => occurrence.authority !== void 0 && AUTHORITY_RANK[occurrence.authority] > AUTHORITY_RANK[declaration.authority];
355
+ const projectedGroupDisposition = (members) => members.some((member) => member.disposition === "open") ? "open" : members.at(-1)?.disposition ?? "open";
356
+ const invalid = (message) => {
357
+ throw new TypeError(`Invalid ladder history: ${message}`);
358
+ };
359
+ const entryFor = (members, reopenedCount) => {
360
+ const [root] = members;
361
+ const latest = members.at(-1);
362
+ if (!root || !latest) return invalid("a finding group has no members.");
363
+ const disposition = projectedGroupDisposition(members);
364
+ const settled = members.findLast((member) => member.disposition !== "stale-repeat" && member.disposition !== "open");
365
+ const referenced = disposition === "open" ? void 0 : settled ?? latest;
366
+ return {
367
+ ...definedAttributes(latest.finding),
368
+ address: root.address,
369
+ disposition,
370
+ firstFlagged: root.cycle,
371
+ flagCount: members.length,
372
+ lastFlagged: latest.cycle,
373
+ occurrences: [...members],
374
+ ...disposition === "stale-repeat" && settled ? { priorDisposition: settled.disposition } : {},
375
+ ...referenced === void 0 ? {} : { reference: referenced.reference ?? referenced.rule },
376
+ reopenedCount,
377
+ staleRepeatCount: members.filter((member) => member.disposition === "stale-repeat").length,
378
+ title: root.finding.title.trim()
379
+ };
380
+ };
381
+ const recordOccurrences = (cycle, cycleIndex, ref, occurrences, recordedIn, groups, reopened) => {
382
+ const { recording } = cycle;
383
+ if (!recording) return invalid(`cycle ${cycleIndex + 1} carries no recording; the ledger reads recorded occurrences and declarations only.`);
384
+ if (recording.occurrences.length !== cycle.findings.length) return invalid(`cycle ${cycleIndex + 1} records ${recording.occurrences.length} occurrence(s) for ${cycle.findings.length} finding(s).`);
385
+ for (const [findingIndex, record] of recording.occurrences.entries()) {
386
+ const address = occurrenceAddress(cycleIndex + 1, findingIndex + 1);
387
+ const finding = cycle.findings[findingIndex];
388
+ if (record.address !== address || finding === void 0) return invalid(`cycle ${cycleIndex + 1} finding ${findingIndex + 1} is recorded at ${record.address}, not ${address}.`);
389
+ let root = address;
390
+ if (record.repeatOf !== void 0) {
391
+ const target = occurrences.get(record.repeatOf);
392
+ if (!target || (recordedIn.get(record.repeatOf) ?? cycleIndex) >= cycleIndex) return invalid(`${address} repeats ${record.repeatOf}, which names no occurrence recorded in an earlier cycle.`);
393
+ ({root} = target);
394
+ }
395
+ const members = groups.get(root) ?? [];
396
+ if (members.length > 0 && projectedGroupDisposition(members) === "fixed-in-thread") reopened.set(root, (reopened.get(root) ?? 0) + 1);
397
+ const occurrence = {
398
+ address,
399
+ cycle: ref,
400
+ disposition: "open",
401
+ finding,
402
+ historical: cycle.historical === true,
403
+ root
404
+ };
405
+ occurrences.set(address, occurrence);
406
+ recordedIn.set(address, cycleIndex);
407
+ groups.set(root, [...members, occurrence]);
408
+ }
409
+ };
410
+ const applyDeclaration = (declaration, cycleIndex, occurrences) => {
411
+ const target = occurrences.get(declaration.target);
412
+ if (!target) return invalid(`cycle ${cycleIndex + 1} declares ${declaration.disposition} on ${declaration.target}, which names no occurrence recorded at or before that cycle.`);
413
+ if (outranked(target, declaration)) return;
414
+ target.disposition = declaration.disposition;
415
+ target.authority = declaration.authority;
416
+ if (declaration.authority === "policy") {
417
+ target.rule = declaration.rule;
418
+ delete target.reference;
419
+ } else {
420
+ delete target.rule;
421
+ if (declaration.reference === void 0) delete target.reference;
422
+ else target.reference = declaration.reference;
423
+ }
424
+ };
425
+ const foldLedger = (cycles, refs) => {
426
+ const occurrences = /* @__PURE__ */ new Map();
427
+ const recordedIn = /* @__PURE__ */ new Map();
428
+ const groups = /* @__PURE__ */ new Map();
429
+ const reopened = /* @__PURE__ */ new Map();
430
+ for (const [cycleIndex, cycle] of cycles.entries()) {
431
+ const ref = refs[cycleIndex];
432
+ if (!ref) return invalid(`cycle ${cycleIndex + 1} has no ladder position.`);
433
+ const declarations = cycle.recording?.declarations ?? [];
434
+ for (const declaration of declarations) if (cycleOfAddress(declaration.target) <= cycleIndex) applyDeclaration(declaration, cycleIndex, occurrences);
435
+ recordOccurrences(cycle, cycleIndex, ref, occurrences, recordedIn, groups, reopened);
436
+ for (const declaration of declarations) if (cycleOfAddress(declaration.target) > cycleIndex) applyDeclaration(declaration, cycleIndex, occurrences);
437
+ }
438
+ const ledger = /* @__PURE__ */ new Map();
439
+ for (const [root, members] of groups) ledger.set(root, entryFor(members, reopened.get(root) ?? 0));
440
+ return {
441
+ ledger,
442
+ occurrences: [...occurrences.values()]
443
+ };
444
+ };
445
+ new Uint32Array([
353
446
  1116352408,
354
447
  1899447441,
355
448
  3049323471,
@@ -415,7 +508,7 @@ const ROUND_CONSTANTS = new Uint32Array([
415
508
  3204031479,
416
509
  3329325298
417
510
  ]);
418
- const INITIAL_STATE = new Uint32Array([
511
+ new Uint32Array([
419
512
  1779033703,
420
513
  3144134277,
421
514
  1013904242,
@@ -425,273 +518,6 @@ const INITIAL_STATE = new Uint32Array([
425
518
  528734635,
426
519
  1541459225
427
520
  ]);
428
- const BLOCK_BYTES = 64;
429
- const LENGTH_FIELD_BYTES = 8;
430
- const WORD_BITS = 32;
431
- const rotateRight = (value, bits) => (value >>> bits | value << WORD_BITS - bits) >>> 0;
432
- /** Message bytes plus the FIPS 180-4 padding and 64-bit length suffix. */
433
- const padMessage = (message) => {
434
- const paddedLength = Math.ceil((message.length + 1 + LENGTH_FIELD_BYTES) / BLOCK_BYTES) * BLOCK_BYTES;
435
- const padded = new Uint8Array(paddedLength);
436
- padded.set(message);
437
- padded[message.length] = 128;
438
- const bitLength = BigInt(message.length) * 8n;
439
- new DataView(padded.buffer).setBigUint64(paddedLength - LENGTH_FIELD_BYTES, bitLength);
440
- return padded;
441
- };
442
- const ROUNDS = 64;
443
- const SCHEDULE_SEED_WORDS = 16;
444
- const STATE_WORDS = 8;
445
- const wordAt = (words, index) => words[index];
446
- /** One 64-byte block folded into the running state, in place. */
447
- const compress = (state, view, offset) => {
448
- const schedule = new Uint32Array(ROUNDS);
449
- for (let index = 0; index < SCHEDULE_SEED_WORDS; index += 1) schedule[index] = view.getUint32(offset + index * 4);
450
- for (let index = SCHEDULE_SEED_WORDS; index < ROUNDS; index += 1) {
451
- const previous = wordAt(schedule, index - 15);
452
- const recent = wordAt(schedule, index - 2);
453
- const sigma0 = rotateRight(previous, 7) ^ rotateRight(previous, 18) ^ previous >>> 3;
454
- const sigma1 = rotateRight(recent, 17) ^ rotateRight(recent, 19) ^ recent >>> 10;
455
- schedule[index] = wordAt(schedule, index - 16) + sigma0 + wordAt(schedule, index - 7) + sigma1 >>> 0;
456
- }
457
- const working = new Uint32Array(state);
458
- for (let index = 0; index < ROUNDS; index += 1) {
459
- const a = wordAt(working, 0);
460
- const b = wordAt(working, 1);
461
- const c = wordAt(working, 2);
462
- const d = wordAt(working, 3);
463
- const e = wordAt(working, 4);
464
- const f = wordAt(working, 5);
465
- const g = wordAt(working, 6);
466
- const h = wordAt(working, 7);
467
- const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
468
- const choose = e & f ^ ~e & g;
469
- const temp1 = h + sum1 + choose + wordAt(ROUND_CONSTANTS, index) + wordAt(schedule, index) >>> 0;
470
- const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
471
- const majority = a & b ^ a & c ^ b & c;
472
- working[7] = g;
473
- working[6] = f;
474
- working[5] = e;
475
- working[4] = d + temp1 >>> 0;
476
- working[3] = c;
477
- working[2] = b;
478
- working[1] = a;
479
- working[0] = temp1 + (sum0 + majority >>> 0) >>> 0;
480
- }
481
- for (let index = 0; index < STATE_WORDS; index += 1) state[index] = wordAt(state, index) + wordAt(working, index) >>> 0;
482
- };
483
- const toHex = (state) => {
484
- let hex = "";
485
- for (const word of state) hex += word.toString(16).padStart(8, "0");
486
- return hex;
487
- };
488
- /** The lowercase hex SHA-256 digest of a UTF-8 string. */
489
- const sha256Hex = (input) => {
490
- const padded = padMessage(new TextEncoder().encode(input));
491
- const view = new DataView(padded.buffer);
492
- const state = new Uint32Array(INITIAL_STATE);
493
- for (let offset = 0; offset < padded.length; offset += BLOCK_BYTES) compress(state, view, offset);
494
- return toHex(state);
495
- };
496
- //#endregion
497
- //#region src/review-ladder-ledger.ts
498
- const normalizeCitedSpan = (text) => text.split(/\r?\n/u).map((line) => line.trim().replaceAll(/\s+/gu, " ")).filter((line) => line.length > 0).join("\n");
499
- const PRESCRIBED_ACTION_STOPWORDS = new Set([
500
- "a",
501
- "an",
502
- "the",
503
- "to",
504
- "of",
505
- "in",
506
- "on",
507
- "and",
508
- "or",
509
- "is",
510
- "are",
511
- "be",
512
- "this",
513
- "that",
514
- "it",
515
- "for",
516
- "with",
517
- "as",
518
- "by",
519
- "from",
520
- "at",
521
- "into",
522
- "should",
523
- "must",
524
- "will",
525
- "would",
526
- "can",
527
- "could",
528
- "not",
529
- "but",
530
- "so",
531
- "if",
532
- "then",
533
- "than",
534
- "when",
535
- "which",
536
- "who",
537
- "whom",
538
- "its",
539
- "their",
540
- "there",
541
- "here",
542
- "also",
543
- "use",
544
- "using",
545
- "instead",
546
- "rather",
547
- "than",
548
- "over",
549
- "up",
550
- "out",
551
- "have",
552
- "has",
553
- "had",
554
- "been"
555
- ]);
556
- const tokenizePrescribedAction = (text) => {
557
- const words = text.toLowerCase().replaceAll(/[^a-z0-9\s]/gu, " ").split(/\s+/u).filter((word) => word.length > 0 && !PRESCRIBED_ACTION_STOPWORDS.has(word));
558
- return [...new Set(words)].toSorted();
559
- };
560
- const normalizeTitle = (title) => title.trim().toLowerCase().replaceAll(/\s+/gu, " ");
561
- const normalizeFile = (file) => file.trim();
562
- const findingKey = (finding) => {
563
- const path = finding.file ?? "";
564
- const normalizedSpan = normalizeCitedSpan(finding.citedSpan ?? "");
565
- const actionTokens = tokenizePrescribedAction(finding.prescribedAction ?? "");
566
- const legacyDiscriminator = normalizedSpan.length > 0 || actionTokens.length > 0 ? "" : normalizeTitle(finding.title ?? "");
567
- return sha256Hex(`${path}\0${sha256Hex(normalizedSpan)}\0${actionTokens.join(" ")}\0${legacyDiscriminator}`);
568
- };
569
- const declarationMatchesEntry = (entry, target) => normalizeTitle(entry.title) === normalizeTitle(target.title) && (target.file === void 0 || entry.file !== void 0 && normalizeFile(entry.file) === normalizeFile(target.file));
570
- const applyDisposition = (ledger, declaration) => {
571
- const entry = declaration.findingReport ? resolveEntryForFinding([...ledger.values()], declaration.findingReport) : [...ledger.values()].find((candidate) => declarationMatchesEntry(candidate, declaration.finding));
572
- if (!entry) return false;
573
- entry.disposition = declaration.disposition;
574
- entry.priorDisposition = void 0;
575
- if (declaration.reference !== void 0) entry.reference = declaration.reference;
576
- return true;
577
- };
578
- const definedAttributes = (finding) => Object.fromEntries([
579
- "blockingAfterCap",
580
- "category",
581
- "citedSpan",
582
- "file",
583
- "prescribedAction",
584
- "priority"
585
- ].flatMap((attribute) => finding[attribute] === void 0 ? [] : [[attribute, finding[attribute]]]));
586
- const MIN_MEANINGFUL_OVERLAP_LINE_LENGTH = 20;
587
- const spansOverlap = (a, b) => {
588
- if (a.length === 0 || b.length === 0) return false;
589
- if (a === b || a.includes(b) || b.includes(a)) return true;
590
- const linesA = new Set(a.split("\n").filter((line) => line.length >= MIN_MEANINGFUL_OVERLAP_LINE_LENGTH));
591
- return b.split("\n").some((line) => line.length >= MIN_MEANINGFUL_OVERLAP_LINE_LENGTH && linesA.has(line));
592
- };
593
- const resolveEntryForFinding = (entries, finding) => {
594
- const primaryKey = findingKey(finding);
595
- const exact = entries.find((entry) => entry.key === primaryKey);
596
- if (exact) return exact;
597
- const { supersedes } = finding;
598
- if (supersedes) {
599
- const superseded = entries.find((entry) => declarationMatchesEntry(entry, supersedes));
600
- if (superseded) return superseded;
601
- }
602
- if (finding.file !== void 0 && finding.citedSpan !== void 0) {
603
- const candidateSpan = normalizeCitedSpan(finding.citedSpan);
604
- if (candidateSpan.length > 0) {
605
- const overlapping = entries.find((entry) => entry.disposition === "open" && entry.file === finding.file && entry.citedSpan !== void 0 && spansOverlap(normalizeCitedSpan(entry.citedSpan), candidateSpan));
606
- if (overlapping) return overlapping;
607
- }
608
- }
609
- };
610
- const REFLAG_TRANSITIONS = {
611
- "fixed-in-thread": (entry) => {
612
- entry.disposition = "open";
613
- entry.reopenedCount += 1;
614
- },
615
- "follow-up-filed": (entry) => {
616
- entry.priorDisposition = entry.disposition;
617
- entry.disposition = "stale-repeat";
618
- entry.staleRepeatCount += 1;
619
- },
620
- open: () => {},
621
- "rerun-noise": (entry) => {
622
- entry.staleRepeatCount += 1;
623
- },
624
- "stale-repeat": (entry) => {
625
- entry.staleRepeatCount += 1;
626
- },
627
- waived: (entry) => {
628
- entry.priorDisposition = entry.disposition;
629
- entry.disposition = "stale-repeat";
630
- entry.staleRepeatCount += 1;
631
- }
632
- };
633
- const applyFlag = (ledger, finding, ref, demoteNew) => {
634
- const matched = resolveEntryForFinding([...ledger.values()], finding);
635
- if (!matched) {
636
- const key = findingKey(finding);
637
- ledger.set(key, {
638
- ...definedAttributes(finding),
639
- disposition: demoteNew ? "rerun-noise" : "open",
640
- firstFlagged: ref,
641
- flagCount: 1,
642
- key,
643
- lastFlagged: ref,
644
- reopenedCount: 0,
645
- staleRepeatCount: 0,
646
- title: finding.title.trim()
647
- });
648
- return;
649
- }
650
- matched.flagCount += 1;
651
- matched.lastFlagged = ref;
652
- Object.assign(matched, definedAttributes(finding));
653
- REFLAG_TRANSITIONS[matched.disposition](matched);
654
- };
655
- const findingKeySet = (findings) => new Set(findings.map((finding) => findingKey(finding)));
656
- const isSameHeadRerun = (previous, current) => previous?.identity !== void 0 && current.identity !== void 0 && evidenceFresh({
657
- candidate: current.identity,
658
- recorded: previous.identity,
659
- rule: "patch-id"
660
- });
661
- const isDisjointSameHeadRerun = (previous, current) => {
662
- if (current.correlatedRerun) return false;
663
- if (!isSameHeadRerun(previous, current) || previous?.findings.length === 0 || current.findings.length === 0) return false;
664
- const previousKeys = findingKeySet(previous?.findings ?? []);
665
- return current.findings.every((finding) => !previousKeys.has(findingKey(finding)));
666
- };
667
- const foldLedger = (cycles, refs) => {
668
- const ledger = /* @__PURE__ */ new Map();
669
- const unmatchedDispositions = [];
670
- for (const [index, entry] of cycles.entries()) {
671
- const dispositions = entry.dispositions ?? [];
672
- const severityFloorDispositions = dispositions.filter((declaration) => declaration.findingReport !== void 0);
673
- for (const declaration of dispositions.filter((candidate) => candidate.findingReport === void 0)) if (!applyDisposition(ledger, declaration)) unmatchedDispositions.push(declaration);
674
- const demoteNew = isDisjointSameHeadRerun(cycles[index - 1], entry);
675
- for (const finding of entry.findings) applyFlag(ledger, finding, refs[index], demoteNew);
676
- for (const declaration of severityFloorDispositions) if (!applyDisposition(ledger, declaration)) unmatchedDispositions.push(declaration);
677
- }
678
- return {
679
- ledger,
680
- unmatchedDispositions
681
- };
682
- };
683
- z.array(z.object({
684
- disposition: z.enum([
685
- "fixed-in-thread",
686
- "follow-up-filed",
687
- "waived"
688
- ]),
689
- finding: z.object({
690
- file: z.string().min(1).optional(),
691
- title: z.string().min(1)
692
- }).strict(),
693
- reference: z.string().min(1).optional()
694
- }).strict());
695
521
  //#endregion
696
522
  //#region src/review-ladder.ts
697
523
  const LADDER_NEXT_ACTION_KINDS = [
@@ -728,21 +554,138 @@ const ladderCycleRefsFor = (cycles) => {
728
554
  });
729
555
  };
730
556
  //#endregion
557
+ //#region src/review-recording-schema.ts
558
+ const ABSENT_PROOF_DIGEST = "absent";
559
+ const sha256Pattern = /^[0-9a-f]{64}$/u;
560
+ const occurrenceAddressSchema$1 = z.string().regex(OCCURRENCE_ADDRESS_PATTERN);
561
+ const declarationRecordSchema = z.union([z.object({
562
+ authority: z.enum(["operator", "reviewer"]),
563
+ disposition: z.enum([
564
+ "fixed-in-thread",
565
+ "follow-up-filed",
566
+ "waived"
567
+ ]),
568
+ reference: z.string().min(1).optional(),
569
+ target: occurrenceAddressSchema$1
570
+ }).strict(), z.object({
571
+ authority: z.literal("policy"),
572
+ disposition: z.enum(["policy-accepted", "stale-repeat"]),
573
+ rule: z.enum(POLICY_DECLARATION_RULES),
574
+ target: occurrenceAddressSchema$1
575
+ }).strict()]);
576
+ const recordsShape = {
577
+ declarations: z.array(declarationRecordSchema),
578
+ occurrences: z.array(z.object({
579
+ address: occurrenceAddressSchema$1,
580
+ declaredCycle: z.number().int().positive(),
581
+ repeatOf: occurrenceAddressSchema$1.optional()
582
+ }).strict())
583
+ };
584
+ const reviewCycleRecordingSchema = z.union([z.object({
585
+ ...recordsShape,
586
+ appliedTo: z.union([z.string().regex(sha256Pattern), z.literal(ABSENT_PROOF_DIGEST)]),
587
+ base: z.object({
588
+ ref: z.string().min(1),
589
+ sha: z.string().regex(/^[0-9a-f]{40}$/u)
590
+ }).strict(),
591
+ dispositionsDigest: z.string().regex(sha256Pattern).optional(),
592
+ findingsDigest: z.string().regex(sha256Pattern),
593
+ recordingId: z.string().regex(sha256Pattern)
594
+ }).strict(), z.object({
595
+ ...recordsShape,
596
+ imported: z.object({
597
+ proofDigest: z.string().regex(sha256Pattern),
598
+ schemaVersion: z.literal(2)
599
+ }).strict()
600
+ }).strict()]);
601
+ //#endregion
602
+ //#region src/review-recording.ts
603
+ const occurrenceAddressSchema = z.string().regex(OCCURRENCE_ADDRESS_PATTERN);
604
+ const declarableDisposition = z.enum([
605
+ "fixed-in-thread",
606
+ "follow-up-filed",
607
+ "waived"
608
+ ]);
609
+ z.array(z.union([z.object({
610
+ disposition: declarableDisposition,
611
+ reference: z.string().min(1).optional(),
612
+ target: occurrenceAddressSchema
613
+ }).strict(), z.object({
614
+ disposition: declarableDisposition,
615
+ finding: z.object({
616
+ file: z.string().min(1).optional(),
617
+ title: z.string().min(1)
618
+ }).strict(),
619
+ reference: z.string().min(1).optional()
620
+ }).strict()]));
621
+ //#endregion
622
+ //#region src/review-proof-import.ts
623
+ const legacyFindingSchema = z.object({
624
+ blockingAfterCap: z.boolean().optional(),
625
+ category: z.string().optional(),
626
+ citedSpan: z.string().optional(),
627
+ file: z.string().optional(),
628
+ prescribedAction: z.string().optional(),
629
+ priority: z.string().optional(),
630
+ supersedes: z.object({
631
+ file: z.string().optional(),
632
+ title: z.string()
633
+ }).passthrough().optional(),
634
+ title: z.string()
635
+ }).passthrough();
636
+ const legacyDeclarationSchema = z.object({
637
+ disposition: z.enum([
638
+ "fixed-in-thread",
639
+ "follow-up-filed",
640
+ "waived"
641
+ ]),
642
+ finding: z.object({
643
+ file: z.string().optional(),
644
+ title: z.string()
645
+ }).passthrough(),
646
+ findingReport: legacyFindingSchema.optional(),
647
+ reference: z.string().optional()
648
+ }).passthrough();
649
+ const legacyCycleSchema = z.object({
650
+ correlatedRerun: z.boolean().optional(),
651
+ dispositions: z.array(legacyDeclarationSchema).optional(),
652
+ engine: z.string().optional(),
653
+ findings: z.array(legacyFindingSchema),
654
+ historical: z.literal(true).optional(),
655
+ identity: z.object({
656
+ headSha: z.string(),
657
+ patchId: z.string()
658
+ }).passthrough().optional(),
659
+ model: z.string().optional(),
660
+ session: z.record(z.string(), z.unknown()).optional(),
661
+ stage: z.enum(["interior", "gate"]),
662
+ usage: z.record(z.string(), z.unknown()).optional()
663
+ }).passthrough();
664
+ z.object({ cycles: z.array(legacyCycleSchema) }).passthrough();
665
+ //#endregion
731
666
  //#region src/pr-readiness/ladder-proof-state.ts
732
- const prReviewLadderStateSchema = z.object({ cycles: z.array(z.custom()) }).superRefine((ladder, context) => {
667
+ const persistedCycleSchema = z.custom((cycle) => typeof cycle === "object" && cycle !== null, "ladder cycle must be an object").superRefine((cycle, context) => {
668
+ const parsed = reviewCycleRecordingSchema.safeParse(cycle.recording);
669
+ if (!parsed.success) context.addIssue({
670
+ code: "custom",
671
+ message: `ladder cycle carries no valid recording (schema 3): ${parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`,
672
+ path: ["recording"]
673
+ });
674
+ });
675
+ const prReviewLadderStateSchema = z.object({ cycles: z.array(persistedCycleSchema) }).superRefine((ladder, context) => {
733
676
  try {
734
677
  ladderLedgerFor(ladder);
735
- } catch {
678
+ } catch (error) {
736
679
  context.addIssue({
737
680
  code: "custom",
738
- message: "ladder cycles do not fold into a valid disposition ledger"
681
+ message: `ladder cycles do not fold into a valid disposition ledger: ${error instanceof Error ? error.message : String(error)}`
739
682
  });
740
683
  }
741
684
  });
742
685
  const ladderLedgerFor = (ladder) => [...foldLedger(ladder.cycles, ladderCycleRefsFor(ladder.cycles)).ledger.values()];
743
686
  //#endregion
744
687
  //#region src/pr-readiness/review-proof.ts
745
- const PR_REVIEW_SCHEMA_VERSION = 2;
688
+ const PR_REVIEW_SCHEMA_VERSION = 3;
746
689
  const nonBlankString = z.string().refine((value) => value.trim().length > 0, { message: "must not be blank" });
747
690
  const parseableDateString = z.string().refine((value) => Number.isFinite(Date.parse(value)), { message: "must be a parseable date string" });
748
691
  const findingSupersedesSchema = z.object({
@@ -819,18 +762,42 @@ const prReviewProofSchema = z.object({
819
762
  cleanedPaths: z.array(z.string()),
820
763
  findingProvenanceVersion: z.literal(1),
821
764
  headSha: z.string().regex(/^[0-9a-f]{40}$/u),
765
+ importedFrom: z.object({
766
+ proofDigest: z.string().regex(/^[0-9a-f]{64}$/u),
767
+ schemaVersion: z.literal(2)
768
+ }).strict().optional(),
822
769
  ladder: prReviewLadderStateSchema.optional(),
823
770
  maxReviewCycles: z.number().int().positive().optional(),
824
771
  patchId: z.string().regex(/^[0-9a-f]{40,64}$/u),
825
772
  policyResolution: policyResolutionSchema.optional(),
773
+ recoveryRequired: z.array(z.object({
774
+ candidates: z.array(z.string().regex(OCCURRENCE_ADDRESS_PATTERN)),
775
+ cycle: z.number().int().positive(),
776
+ declaration: z.object({
777
+ disposition: z.enum([
778
+ "fixed-in-thread",
779
+ "follow-up-filed",
780
+ "waived"
781
+ ]),
782
+ file: z.string().optional(),
783
+ reference: z.string().optional(),
784
+ title: z.string()
785
+ }).strict(),
786
+ reason: z.string().min(1)
787
+ }).strict()).optional(),
826
788
  reviewCycle: z.number().int().positive().optional(),
827
789
  reviewRequirement: z.object({
828
790
  reason: z.enum(["no-applicable-mode", "faithful-merge"]),
829
791
  status: z.literal("not-required")
830
792
  }).strict().optional(),
831
793
  reviews: z.array(prReviewResultSchema),
832
- schemaVersion: z.literal(2)
794
+ schemaVersion: z.literal(3)
833
795
  }).passthrough().superRefine((proof, context) => {
796
+ if (proof.importedFrom !== void 0 && proof.recoveryRequired === void 0) context.addIssue({
797
+ code: "custom",
798
+ message: "a proof converted from schema 2 (importedFrom) must carry recoveryRequired, an array that may be empty",
799
+ path: ["recoveryRequired"]
800
+ });
834
801
  if (proof.reviewRequirement && proof.reviews.length > 0) context.addIssue({
835
802
  code: "custom",
836
803
  message: "not-required review proofs must not contain review runs",
@@ -1209,6 +1176,7 @@ const prVerifySchemaVersionSchema = z.literal(4);
1209
1176
  const prVerifyProofSchema = z.object({
1210
1177
  authoringSession: z.string().trim().min(1),
1211
1178
  base: z.string().min(1),
1179
+ baseSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
1212
1180
  baselineFullProofs: z.array(z.object({
1213
1181
  base: z.string().min(1),
1214
1182
  changedFiles: z.array(z.string()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.31",
3
+ "version": "1.0.0-alpha.32",
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.31"
40
+ "@patronage/factory-ci": "1.0.0-alpha.32"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",