@patronage/software-factory 0.23.0 → 0.30.0-alpha.1

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
@@ -1,21 +1,230 @@
1
1
  import { z } from "zod";
2
+ //#region src/review-rungs.ts
3
+ const EVIDENCE_REVIEW_RUNGS$1 = [
4
+ "independent-model",
5
+ "oracle",
6
+ "human"
7
+ ];
8
+ Object.fromEntries(EVIDENCE_REVIEW_RUNGS$1.map((rung, index) => [rung, index]));
9
+ //#endregion
10
+ //#region src/demand-keys.ts
11
+ /** The demands that exist at most once per candidate. */
12
+ const DEMAND_KEYS = {
13
+ /**
14
+ * The boundary wave that authorizes this candidate's merge schedule (#477).
15
+ * In force exactly when GitHub already has auto-merge enabled: a live
16
+ * schedule with no wave demand resolved is an unauthorized merge waiting to
17
+ * happen.
18
+ */
19
+ boundaryWave: "boundary-wave",
20
+ /** The PR is still a draft. */
21
+ draft: "draft",
22
+ /** The candidate is not the intended final human review point. */
23
+ finalReviewPoint: "final-review-point",
24
+ /** GitHub's own check rollup for the candidate head. */
25
+ githubChecks: "github-checks",
26
+ /** Local HEAD and the GitHub PR head must be the same commit. */
27
+ headIdentity: "head-identity",
28
+ /** An unhandled post-readiness human comment or review submission. */
29
+ humanBlocker: "human-blocker",
30
+ /** The repository-wide merge freeze. */
31
+ mergeFreeze: "merge-freeze",
32
+ /** GitHub's mergeability / merge-state rollup. */
33
+ mergeState: "merge-state",
34
+ /**
35
+ * A native GitHub approval, demanded when the boundary wave in force does
36
+ * not authorize machine merge (#477). "Green = authorized to merge" is then
37
+ * a property of the branded required check itself.
38
+ */
39
+ nativeApproval: "native-approval",
40
+ /** The PR body's required rendered sections. */
41
+ prBodySections: "pr-body-sections",
42
+ /** A current, head-bound, passing typed `pr:verify` proof. */
43
+ prVerify: "pr-verify",
44
+ /** The profile-resolved review ladder policy. */
45
+ reviewLadder: "review-ladder",
46
+ /** Unresolved GitHub review threads. */
47
+ reviewThreads: "review-threads",
48
+ /** An explicit trivial waiver that the diff does not support. */
49
+ trivialWaiver: "trivial-waiver"
50
+ };
51
+ /** The families whose instances are named by a resolved value. */
52
+ const QUALIFIED_DEMAND_FAMILIES = [
53
+ "required-check",
54
+ "review-mode",
55
+ "review-rung"
56
+ ];
57
+ const QUALIFIER_PATTERN = /^[A-Za-z0-9._%/-]{1,64}$/u;
58
+ const percentEncode = (character) => {
59
+ const code = character.codePointAt(0) ?? 0;
60
+ if (code >= 55296 && code <= 57343) return `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
61
+ return [...new TextEncoder().encode(character)].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
62
+ };
63
+ /**
64
+ * Express a resolved value as a qualifier. Percent-encoding, not scrubbing:
65
+ * the mapping is injective, so two differently named required checks can never
66
+ * collapse into one key — which would let one operator waiver silently cover a
67
+ * demand nobody waived, and make HQ count two causes as one.
68
+ *
69
+ * Deterministic in both directions of use: the key a `pr:ready` proof records
70
+ * is the key a `pr:ready` waiver matches.
71
+ */
72
+ const demandQualifier = (value) => value.replaceAll(/[^A-Za-z0-9._/-]/gu, percentEncode);
73
+ const FIXED_DEMAND_KEYS = Object.values(DEMAND_KEYS);
74
+ /** The review modes a profile can resolve, and therefore demand. */
75
+ const REVIEW_MODE_DEMAND_VALUES = ["correctness", "security"];
76
+ const escapePattern = /%(?:u[0-9A-F]{4}|[0-9A-F]{2})/gu;
77
+ /**
78
+ * Reverse `demandQualifier`. A qualifier is canonical exactly when encoding
79
+ * its decoded form reproduces it — which rejects both malformed escapes and
80
+ * noncanonical aliases like `%41` for `A`. Returns undefined when the
81
+ * qualifier cannot have been minted here.
82
+ */
83
+ const decodeQualifier = (qualifier) => {
84
+ const bytes = [];
85
+ let decoded = "";
86
+ let index = 0;
87
+ const flush = () => {
88
+ if (bytes.length === 0) return true;
89
+ try {
90
+ decoded += new TextDecoder("utf-8", {
91
+ fatal: true,
92
+ ignoreBOM: true
93
+ }).decode(Uint8Array.from(bytes));
94
+ } catch {
95
+ return false;
96
+ }
97
+ bytes.length = 0;
98
+ return true;
99
+ };
100
+ while (index < qualifier.length) {
101
+ const character = qualifier[index];
102
+ if (character !== "%") {
103
+ if (!flush()) return;
104
+ decoded += character;
105
+ index += 1;
106
+ continue;
107
+ }
108
+ escapePattern.lastIndex = index;
109
+ const escape = escapePattern.exec(qualifier);
110
+ if (!escape || escape.index !== index) return;
111
+ if (escape[0][1] === "u") {
112
+ if (!flush()) return;
113
+ decoded += String.fromCodePoint(Number.parseInt(escape[0].slice(2), 16));
114
+ } else bytes.push(Number.parseInt(escape[0].slice(1), 16));
115
+ index += escape[0].length;
116
+ }
117
+ return flush() ? decoded : void 0;
118
+ };
119
+ const qualifierIsMintable = (family, qualifier) => {
120
+ if (!QUALIFIER_PATTERN.test(qualifier)) return false;
121
+ const decoded = decodeQualifier(qualifier);
122
+ if (decoded === void 0 || demandQualifier(decoded) !== qualifier) return false;
123
+ if (family === "review-rung") return EVIDENCE_REVIEW_RUNGS$1.includes(decoded);
124
+ if (family === "review-mode") return REVIEW_MODE_DEMAND_VALUES.includes(decoded);
125
+ return true;
126
+ };
127
+ /**
128
+ * A demand key from the closed vocabulary: a known unqualified family, or a
129
+ * known qualified family with a qualifier this module could actually have
130
+ * minted. Closed on purpose — an invented family, an unresolvable rung, or a
131
+ * noncanonical encoding would be indistinguishable from a typo to anyone
132
+ * counting causes, which is the whole point of recording codes.
133
+ */
134
+ const demandKeySchema = z.string().refine((value) => {
135
+ if (FIXED_DEMAND_KEYS.includes(value)) return true;
136
+ const separator = value.indexOf(":");
137
+ if (separator === -1) return false;
138
+ const family = value.slice(0, separator);
139
+ return QUALIFIED_DEMAND_FAMILIES.includes(family) && qualifierIsMintable(family, value.slice(separator + 1));
140
+ }, { message: `a demand key is one of ${FIXED_DEMAND_KEYS.join(", ")} or a resolvable ${QUALIFIED_DEMAND_FAMILIES.join(" / ")} key` });
141
+ /**
142
+ * The key shape an operator may *name* in a waiver: syntactic, not closed.
143
+ *
144
+ * Deliberately distinct from `demandKeySchema` above, which is what a proof's
145
+ * reason codes must come from. A waiver store is durable operator evidence
146
+ * written before today's vocabulary existed, so tightening its validation
147
+ * would make an existing record parse as no waiver and let the next write drop
148
+ * it. A waiver naming a key nobody resolves is already inert — `pr:ready`
149
+ * reports it as an unapplied waiver — so nothing is admitted by accepting it.
150
+ */
151
+ const waiverDemandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._%/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
152
+ //#endregion
153
+ //#region src/blocked-reasons.ts
154
+ /**
155
+ * The wire bound on one `detail`. A blocked proof carries one short sentence
156
+ * per demand, never a transcript: HQ's ingest cap is 256 KB and this field
157
+ * must stay far under it even when a candidate blocks on every gate at once.
158
+ */
159
+ const BLOCKED_REASON_DETAIL_MAX = 280;
160
+ /**
161
+ * The wire bound on how many demands one proof may name. Well past any real
162
+ * candidate (there are a dozen fixed demands plus the profile's checks and the
163
+ * live human blockers) and, with the detail bound, keeps the whole field two
164
+ * orders of magnitude under the ingest cap. Bounded here rather than trimmed
165
+ * downstream: HQ rejects an over-long list loudly instead of storing a quietly
166
+ * truncated one.
167
+ */
168
+ const BLOCKED_REASONS_MAX = 100;
169
+ /** One refusal sentence: single line, trimmed, bounded. */
170
+ const blockedReasonDetailSchema = z.string().min(1).max(280).refine((value) => value.trim().length > 0, { message: "must not be blank" }).refine((value) => !/[\r\n]/u.test(value), { message: "must be one line, not a multi-line payload" });
171
+ const blockedReasonSchema = z.object({
172
+ code: demandKeySchema,
173
+ detail: blockedReasonDetailSchema
174
+ });
175
+ const blockedReasonsSchema = z.array(blockedReasonSchema).max(100);
176
+ /** Fit one refusal sentence to the wire bound without losing its head. */
177
+ const blockedReasonDetail = (reason) => {
178
+ const line = reason.replaceAll(/\s+/gu, " ").trim();
179
+ return line.length <= 280 ? line : `${line.slice(0, 279)}…`;
180
+ };
181
+ const blockedReasonIssue = (message) => [{
182
+ code: "custom",
183
+ message,
184
+ path: ["blockedReasons"]
185
+ }];
186
+ /**
187
+ * The invariant every reader enforces, shared by the emitting schema and the
188
+ * wire schema so it is one rule rather than two that can drift. It is the
189
+ * producer's state machine, written down:
190
+ *
191
+ * - `ready` refused nothing, so it carries neither projection, and it is the
192
+ * final review point;
193
+ * - `slice-ready/not-final` was held back by exactly one demand — being a
194
+ * slice — and names it;
195
+ * - `blocked` refused something other than being a slice, and says so;
196
+ * - whichever it is, `blockedReasons` names every refusal listed in
197
+ * `blockingReasons`, in the same order, as the bounded form of that sentence.
198
+ *
199
+ * Naming the slice demand and the ledger's `finalReviewPoint` are the same
200
+ * fact, so a proof that says one and not the other is refused.
201
+ */
202
+ const blockedReasonIssues = (proof) => {
203
+ const named = proof.blockedReasons ?? [];
204
+ const reasons = proof.blockingReasons ?? [];
205
+ const slice = DEMAND_KEYS.finalReviewPoint;
206
+ const namesSlice = named.some((reason) => reason.code === slice);
207
+ if (proof.status === "ready") {
208
+ if (named.length > 0 || reasons.length > 0) return blockedReasonIssue("a ready pr:ready proof must carry no blocking reasons");
209
+ } else if (proof.status === "slice-ready/not-final") {
210
+ if (named.length !== 1 || !namesSlice) return blockedReasonIssue(`a slice-ready/not-final pr:ready proof is held back by exactly one demand, ${slice}`);
211
+ } else if (reasons.length === 0) return blockedReasonIssue("a blocked pr:ready proof must record what blocked it");
212
+ else if (named.length > 0 && !named.some((r) => r.code !== slice)) return blockedReasonIssue(`a blocked pr:ready proof must name a demand other than ${slice}`);
213
+ if (proof.finalReviewPoint !== void 0 && namesSlice === proof.finalReviewPoint) return blockedReasonIssue(`naming ${slice} and the ledger's finalReviewPoint are the same fact; this proof says both`);
214
+ if (named.length !== reasons.length) return blockedReasonIssue(`blockedReasons must name every blocking reason: ${reasons.length} reason(s), ${named.length} named`);
215
+ const drifted = named.findIndex((reason, index) => reason.detail !== blockedReasonDetail(reasons[index]));
216
+ return drifted === -1 ? [] : blockedReasonIssue(`blockedReasons[${drifted}] does not carry blocking reason ${drifted}; the two projections must tell one story`);
217
+ };
218
+ //#endregion
2
219
  //#region src/demand-waiver.ts
3
220
  const DEMAND_WAIVER_SCHEMA_VERSION = 1;
4
221
  const shaSchema = z.string().regex(/^[0-9a-f]{40}$/u);
5
- /**
6
- * A resolved demand's key: a family, optionally qualified by the resolved
7
- * value that makes it this demand *instance* (`review-rung:human`,
8
- * `required-check:core`, `merge-freeze`). The consumer that resolved the
9
- * demand owns the key; this module only matches on it, which is why no
10
- * control name is enumerated here.
11
- */
12
- const demandKeySchema = z.string().regex(/^[a-z][a-z0-9-]*(?::[A-Za-z0-9._/-]+)?$/u, "A demand key is a lowercase family, optionally qualified by its resolved value (e.g. review-rung:human).");
13
222
  const demandWaiverSchema = z.object({
14
223
  candidate: z.object({
15
224
  headSha: shaSchema,
16
225
  pr: z.number().int().positive()
17
226
  }),
18
- demand: demandKeySchema,
227
+ demand: waiverDemandKeySchema,
19
228
  operator: z.string().trim().min(1),
20
229
  rationale: z.string().trim().min(1),
21
230
  recordedAt: z.iso.datetime(),
@@ -27,7 +236,7 @@ z.object({
27
236
  waivers: z.array(demandWaiverSchema)
28
237
  });
29
238
  const waivedDemandSchema = z.object({
30
- demand: demandKeySchema,
239
+ demand: waiverDemandKeySchema,
31
240
  operator: z.string().trim().min(1),
32
241
  rationale: z.string().trim().min(1),
33
242
  recordedAt: z.iso.datetime(),
@@ -783,67 +992,7 @@ const boundaryCheckProofSchema = z.object({
783
992
  }).optional(),
784
993
  status: z.enum(["ready", "blocked"])
785
994
  });
786
- const mergeGuardIdentitySchema = z.discriminatedUnion("kind", [
787
- z.object({
788
- headSha: z.string().regex(/^[0-9a-f]{40}$/u),
789
- kind: z.literal("match")
790
- }),
791
- z.object({
792
- kind: z.literal("diverged"),
793
- liveHeadSha: z.string().regex(/^[0-9a-f]{40}$/u),
794
- postProofCommits: z.array(z.object({
795
- sha: z.string().min(1),
796
- subject: z.string()
797
- })).optional(),
798
- proofHeadSha: z.string().regex(/^[0-9a-f]{40}$/u)
799
- }),
800
- z.object({
801
- kind: z.literal("live-head-invalid"),
802
- pr: z.number().int().positive(),
803
- received: z.string()
804
- }),
805
- z.object({
806
- errorDetail: z.string().optional(),
807
- kind: z.literal("ready-proof-missing"),
808
- readyProofPath: z.string().min(1)
809
- }),
810
- z.object({
811
- kind: z.literal("ready-proof-pr-mismatch"),
812
- proofPr: z.number().int().positive(),
813
- requestedPr: z.number().int().positive()
814
- }),
815
- z.object({
816
- blockingReasons: z.array(z.string()),
817
- kind: z.literal("ready-proof-not-ready"),
818
- status: z.string().min(1)
819
- })
820
- ]);
821
- const PR_MERGE_CHECK_SCHEMA_VERSION = 1;
822
- const prMergeCheckProofSchema = z.object({
823
- blockingReasons: z.array(z.string()),
824
- command: z.literal("patronage-factory pr:merge-check"),
825
- followUp: followUpActionSchema.optional(),
826
- identity: mergeGuardIdentitySchema,
827
- liveHeadSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
828
- notices: z.array(z.string()).optional(),
829
- pr: z.number().int().positive(),
830
- schemaVersion: z.literal(1),
831
- status: z.enum(["pass", "fail"]),
832
- waivedDemands: z.array(waivedDemandSchema).optional(),
833
- worktreeHeldBranch: z.object({
834
- branch: z.string().min(1),
835
- worktreePath: z.string().min(1)
836
- }).optional()
837
- });
838
- const requiredCheckScopeSchema = z.object({
839
- classifications: z.array(z.enum(DIFF_CLASSIFICATIONS)).min(1).optional(),
840
- labels: z.array(z.string().min(1)).min(1).optional()
841
- }).strict().superRefine((scope, context) => {
842
- if (scope.labels === void 0 && scope.classifications === void 0) context.addIssue({
843
- code: "custom",
844
- message: "requiredChecks scope must declare at least one condition (labels and/or classifications)."
845
- });
846
- });
995
+ const requiredCheckScopeSchema = z.object({ classifications: z.array(z.enum(DIFF_CLASSIFICATIONS)).min(1) }).strict();
847
996
  const readinessRepairSchema = z.object({
848
997
  action: z.string().min(1),
849
998
  code: z.enum([
@@ -1018,23 +1167,60 @@ const managedReadinessLedgerSchema = z.object({
1018
1167
  verifiedHeadSha: z.string().optional()
1019
1168
  })
1020
1169
  });
1021
- const PR_READY_SCHEMA_VERSION = 1;
1170
+ const PR_READY_SCHEMA_VERSION = 3;
1171
+ /**
1172
+ * The pr:ready proof versions a reader still accepts. `pr:ready` emits v3 only
1173
+ * (#477) — one current contract — but v1 and v2 events were spooled before the
1174
+ * bumps and HQ must ingest them without degrading, so the wire schema parses
1175
+ * all three.
1176
+ */
1177
+ const SUPPORTED_PR_READY_SCHEMA_VERSIONS = [
1178
+ 1,
1179
+ 2,
1180
+ 3
1181
+ ];
1182
+ const prReadySchemaVersionSchema = z.number().refine((value) => SUPPORTED_PR_READY_SCHEMA_VERSIONS.includes(value), { message: `schemaVersion must be one of: ${SUPPORTED_PR_READY_SCHEMA_VERSIONS.join(", ")}` });
1022
1183
  const prReadyProofSchema = z.object({
1184
+ arming: z.object({
1185
+ detail: z.string().min(1).optional(),
1186
+ headSha: z.string().regex(/^[0-9a-f]{40}$/u),
1187
+ outcome: z.enum([
1188
+ "armed",
1189
+ "merged",
1190
+ "not-armed"
1191
+ ])
1192
+ }).optional(),
1193
+ blockedReasons: blockedReasonsSchema.optional(),
1023
1194
  blockingReasons: z.array(z.string()),
1024
1195
  command: z.literal("patronage-factory pr:ready"),
1025
1196
  followUp: followUpActionSchema.optional(),
1026
1197
  humanBlockingReasons: z.array(z.string()).default([]),
1027
1198
  ledger: managedReadinessLedgerSchema,
1199
+ notices: z.array(z.string().min(1)).optional(),
1028
1200
  profileBlobSha: z.string().regex(/^[0-9a-f]{40}$/u).optional(),
1029
1201
  profilePath: z.string().min(1).optional(),
1030
1202
  repairs: z.array(readinessRepairSchema).default([]),
1031
1203
  repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u).optional(),
1032
- schemaVersion: z.literal(1),
1204
+ schemaVersion: prReadySchemaVersionSchema,
1033
1205
  status: z.enum([
1034
1206
  "ready",
1035
1207
  "blocked",
1036
1208
  "slice-ready/not-final"
1037
- ])
1209
+ ]),
1210
+ waivedDemands: z.array(waivedDemandSchema).optional()
1211
+ }).superRefine((proof, context) => {
1212
+ if (proof.schemaVersion < 2) {
1213
+ if (proof.blockedReasons !== void 0) context.addIssue({
1214
+ code: "custom",
1215
+ message: "blockedReasons is a schemaVersion 2 field; a v1 pr:ready proof must not carry it.",
1216
+ path: ["blockedReasons"]
1217
+ });
1218
+ return;
1219
+ }
1220
+ for (const issue of blockedReasonIssues({
1221
+ ...proof,
1222
+ finalReviewPoint: proof.ledger.finalReviewPoint
1223
+ })) context.addIssue(issue);
1038
1224
  });
1039
1225
  //#endregion
1040
- export { BOUNDARY_CHECK_SCHEMA_VERSION, BOUNDARY_REVIEW_PROOF_KIND, EVIDENCE_ENVELOPE_SCHEMA_VERSION, PR_MERGE_CHECK_SCHEMA_VERSION, PR_READY_SCHEMA_VERSION, PR_REVIEW_SCHEMA_VERSION, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, RETRO_ENVELOPE_WIRE_BOUNDS, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, boundaryCheckProofSchema, boundaryReviewProofSchema, closeoutArtifactSchema, evidenceEnvelopeSchema, prMergeCheckProofSchema, prReadyProofSchema, prReviewProofSchema, prVerifyProofSchema, retroEnvelopeV1Schema, retroEpicReference, waivedDemandSchema };
1226
+ export { BLOCKED_REASONS_MAX, BLOCKED_REASON_DETAIL_MAX, BOUNDARY_CHECK_SCHEMA_VERSION, BOUNDARY_REVIEW_PROOF_KIND, EVIDENCE_ENVELOPE_SCHEMA_VERSION, PR_READY_SCHEMA_VERSION, PR_REVIEW_SCHEMA_VERSION, RETRO_ENVELOPE_SCHEMA_VERSION, RETRO_ENVELOPE_VALIDATORS, RETRO_ENVELOPE_WIRE_BOUNDS, SUPPORTED_PR_READY_SCHEMA_VERSIONS, SUPPORTED_PR_VERIFY_SCHEMA_VERSIONS, SUPPORTED_RETRO_ENVELOPE_SCHEMA_VERSIONS, blockedReasonSchema, blockedReasonsSchema, boundaryCheckProofSchema, boundaryReviewProofSchema, closeoutArtifactSchema, evidenceEnvelopeSchema, prReadyProofSchema, prReviewProofSchema, prVerifyProofSchema, retroEnvelopeV1Schema, retroEpicReference, waivedDemandSchema };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "0.23.0",
3
+ "version": "0.30.0-alpha.1",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {