@rmartz/pr-policy 0.2.0

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.
@@ -0,0 +1,1151 @@
1
+ // src/contract.ts
2
+ var PR_POLICY_CHECK_NAME = "pr-policy";
3
+ var CI_APPROVAL_NEEDED_LABEL = "CI approval needed";
4
+ var CI_CHANGE_APPROVED_LABEL = "CI change approved";
5
+ var BREAKING_CHANGE_LABEL = "breaking change";
6
+ var HOTFIX_LABEL = "hotfix";
7
+ var RELEASE_PLEASE_PENDING_LABEL = "autorelease: pending";
8
+
9
+ // src/checks/ci-change/indicators.ts
10
+ var LOOSENING_INDICATORS = [
11
+ "workflow-removed",
12
+ "job-removed",
13
+ "step-removed",
14
+ "continue-on-error-added",
15
+ "if-added",
16
+ "trigger-narrowed",
17
+ "matrix-reduced",
18
+ "needs-reduced"
19
+ ];
20
+ var AMBIGUOUS_INDICATORS = [
21
+ "runs-on-changed",
22
+ "permissions-changed",
23
+ "env-changed",
24
+ "timeout-changed",
25
+ "unparseable",
26
+ "unclassified-change"
27
+ ];
28
+ function looseningIndicator(kind, file, location, detail) {
29
+ return { kind, class: "loosening", file, location, detail };
30
+ }
31
+ function ambiguousIndicator(kind, file, location, detail) {
32
+ return { kind, class: "ambiguous", file, location, detail };
33
+ }
34
+ var CI_CHANGE_VERDICTS = ["no-change", "tightening", "loosening"];
35
+
36
+ // src/checks/ci-change/classify.ts
37
+ import { parse } from "yaml";
38
+
39
+ // src/checks/ci-change/structure.ts
40
+ function asRecord(node) {
41
+ if (typeof node !== "object" || node === null || Array.isArray(node)) return void 0;
42
+ return node;
43
+ }
44
+ function asArray(node) {
45
+ return Array.isArray(node) ? node : void 0;
46
+ }
47
+ function asNameList(node) {
48
+ if (typeof node === "string") return [node];
49
+ const seq = asArray(node);
50
+ if (!seq) return [];
51
+ return seq.filter((entry) => typeof entry === "string");
52
+ }
53
+ function deepEqual(a, b) {
54
+ if (a === b) return true;
55
+ const aSeq = asArray(a);
56
+ const bSeq = asArray(b);
57
+ if (aSeq && bSeq) {
58
+ return aSeq.length === bSeq.length && aSeq.every((entry, i) => deepEqual(entry, bSeq[i]));
59
+ }
60
+ if (aSeq || bSeq) return false;
61
+ const aMap = asRecord(a);
62
+ const bMap = asRecord(b);
63
+ if (aMap && bMap) {
64
+ const aKeys = Object.keys(aMap);
65
+ const bKeys = Object.keys(bMap);
66
+ if (aKeys.length !== bKeys.length) return false;
67
+ return aKeys.every((key) => key in bMap && deepEqual(aMap[key], bMap[key]));
68
+ }
69
+ return false;
70
+ }
71
+ function missingEntries(base, head) {
72
+ const remaining = [...head];
73
+ const missing = [];
74
+ for (const entry of base) {
75
+ const at = remaining.findIndex((candidate) => deepEqual(candidate, entry));
76
+ if (at === -1) missing.push(entry);
77
+ else remaining.splice(at, 1);
78
+ }
79
+ return missing;
80
+ }
81
+ function containsAll(base, head) {
82
+ if (deepEqual(base, head)) return true;
83
+ const baseSeq = asArray(base);
84
+ const headSeq = asArray(head);
85
+ if (baseSeq && headSeq) {
86
+ const remaining = [...headSeq];
87
+ for (const entry of baseSeq) {
88
+ const at = remaining.findIndex((candidate) => containsAll(entry, candidate));
89
+ if (at === -1) return false;
90
+ remaining.splice(at, 1);
91
+ }
92
+ return true;
93
+ }
94
+ const baseMap = asRecord(base);
95
+ const headMap = asRecord(head);
96
+ if (baseMap && headMap) {
97
+ return Object.keys(baseMap).every(
98
+ (key) => key in headMap && containsAll(baseMap[key], headMap[key])
99
+ );
100
+ }
101
+ return false;
102
+ }
103
+ function stripActionRef(uses) {
104
+ const at = uses.lastIndexOf("@");
105
+ return at === -1 ? uses : uses.slice(0, at);
106
+ }
107
+ function normalizeUses(node) {
108
+ const seq = asArray(node);
109
+ if (seq) return seq.map(normalizeUses);
110
+ const map = asRecord(node);
111
+ if (!map) return node;
112
+ const out = {};
113
+ for (const [key, value] of Object.entries(map)) {
114
+ out[key] = key === "uses" && typeof value === "string" ? stripActionRef(value) : normalizeUses(value);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // src/checks/ci-change/common-rules.ts
120
+ function continuesOnError(node) {
121
+ const value = node["continue-on-error"];
122
+ return value !== void 0 && value !== false;
123
+ }
124
+ function guardIndicators(file, location, base, head) {
125
+ const before = asRecord(base);
126
+ const after = asRecord(head);
127
+ if (!before || !after) return [];
128
+ const found = [];
129
+ if (!continuesOnError(before) && continuesOnError(after)) {
130
+ found.push(
131
+ looseningIndicator(
132
+ "continue-on-error-added",
133
+ file,
134
+ `${location}.continue-on-error`,
135
+ `\`continue-on-error\` was enabled, so a failure here no longer fails the run.`
136
+ )
137
+ );
138
+ }
139
+ if (before.if === void 0 && after.if !== void 0) {
140
+ found.push(
141
+ looseningIndicator(
142
+ "if-added",
143
+ file,
144
+ `${location}.if`,
145
+ `An \`if:\` condition was added to something that previously always ran.`
146
+ )
147
+ );
148
+ }
149
+ if (!deepEqual(before.env, after.env)) {
150
+ found.push(ambiguousIndicator("env-changed", file, `${location}.env`, `\`env\` changed.`));
151
+ }
152
+ if (!deepEqual(before["timeout-minutes"], after["timeout-minutes"])) {
153
+ found.push(
154
+ ambiguousIndicator(
155
+ "timeout-changed",
156
+ file,
157
+ `${location}.timeout-minutes`,
158
+ `\`timeout-minutes\` changed from ${JSON.stringify(before["timeout-minutes"]) ?? "unset"} to ${JSON.stringify(after["timeout-minutes"]) ?? "unset"}.`
159
+ )
160
+ );
161
+ }
162
+ return found;
163
+ }
164
+
165
+ // src/checks/ci-change/step-rules.ts
166
+ function stepsOf(job) {
167
+ return asArray(asRecord(job)?.steps) ?? [];
168
+ }
169
+ function stepKey(step) {
170
+ const map = asRecord(step);
171
+ if (!map) return JSON.stringify(step ?? null);
172
+ const { id, name, uses, run } = map;
173
+ if (typeof id === "string") return `id:${id}`;
174
+ if (typeof name === "string") return `name:${name}`;
175
+ if (typeof uses === "string") return `uses:${stripActionRef(uses)}`;
176
+ if (typeof run === "string") return `run:${run}`;
177
+ return `step:${JSON.stringify(map)}`;
178
+ }
179
+ function byKey(steps) {
180
+ const index = /* @__PURE__ */ new Map();
181
+ for (const step of steps) {
182
+ const key = stepKey(step);
183
+ if (!index.has(key)) index.set(key, step);
184
+ }
185
+ return index;
186
+ }
187
+ function stepIndicators(file, jobId, base, head) {
188
+ const before = byKey(stepsOf(base));
189
+ const after = byKey(stepsOf(head));
190
+ const found = [];
191
+ for (const [key, step] of before) {
192
+ const location = `jobs.${jobId}.steps[${key}]`;
193
+ const counterpart = after.get(key);
194
+ if (counterpart === void 0) {
195
+ found.push(
196
+ looseningIndicator("step-removed", file, location, `Step \`${key}\` was removed.`)
197
+ );
198
+ continue;
199
+ }
200
+ found.push(...guardIndicators(file, location, step, counterpart));
201
+ }
202
+ return found;
203
+ }
204
+
205
+ // src/checks/ci-change/job-rules.ts
206
+ function jobsOf(doc) {
207
+ return asRecord(asRecord(doc)?.jobs) ?? {};
208
+ }
209
+ function needsIndicators(file, jobId, base, head) {
210
+ const before = asNameList(asRecord(base)?.needs);
211
+ const after = new Set(asNameList(asRecord(head)?.needs));
212
+ const dropped = before.filter((dependency) => !after.has(dependency));
213
+ if (dropped.length === 0) return [];
214
+ return [
215
+ looseningIndicator(
216
+ "needs-reduced",
217
+ file,
218
+ `jobs.${jobId}.needs`,
219
+ `${dropped.map((name) => `\`${name}\``).join(", ")} removed from \`needs\`, so the job no longer waits on it.`
220
+ )
221
+ ];
222
+ }
223
+ function matrixIndicators(file, jobId, base, head) {
224
+ const before = asRecord(asRecord(base)?.strategy)?.matrix;
225
+ if (before === void 0) return [];
226
+ const beforeMap = asRecord(before);
227
+ const afterMap = asRecord(asRecord(asRecord(head)?.strategy)?.matrix);
228
+ const at = `jobs.${jobId}.strategy.matrix`;
229
+ if (!beforeMap) return [];
230
+ if (!afterMap) {
231
+ return [
232
+ looseningIndicator("matrix-reduced", file, at, "The build matrix was removed entirely.")
233
+ ];
234
+ }
235
+ const found = [];
236
+ for (const [dimension, values] of Object.entries(beforeMap)) {
237
+ if (dimension === "exclude") continue;
238
+ if (!(dimension in afterMap)) {
239
+ found.push(
240
+ looseningIndicator(
241
+ "matrix-reduced",
242
+ file,
243
+ `${at}.${dimension}`,
244
+ `Matrix dimension \`${dimension}\` was removed.`
245
+ )
246
+ );
247
+ continue;
248
+ }
249
+ const beforeValues = asArray(values);
250
+ if (!beforeValues) continue;
251
+ const dropped = missingEntries(beforeValues, asArray(afterMap[dimension]) ?? []);
252
+ if (dropped.length > 0) {
253
+ found.push(
254
+ looseningIndicator(
255
+ "matrix-reduced",
256
+ file,
257
+ `${at}.${dimension}`,
258
+ `${dropped.length} value${dropped.length === 1 ? "" : "s"} removed from matrix dimension \`${dimension}\`.`
259
+ )
260
+ );
261
+ }
262
+ }
263
+ const addedExclusions = missingEntries(
264
+ asArray(afterMap.exclude) ?? [],
265
+ asArray(beforeMap.exclude) ?? []
266
+ );
267
+ if (addedExclusions.length > 0) {
268
+ found.push(
269
+ looseningIndicator(
270
+ "matrix-reduced",
271
+ file,
272
+ `${at}.exclude`,
273
+ `${addedExclusions.length} combination${addedExclusions.length === 1 ? "" : "s"} added to \`exclude\`.`
274
+ )
275
+ );
276
+ }
277
+ return found;
278
+ }
279
+ function jobSurfaceIndicators(file, jobId, base, head) {
280
+ const before = asRecord(base) ?? {};
281
+ const after = asRecord(head) ?? {};
282
+ const found = [];
283
+ if (!deepEqual(before["runs-on"], after["runs-on"])) {
284
+ found.push(
285
+ ambiguousIndicator(
286
+ "runs-on-changed",
287
+ file,
288
+ `jobs.${jobId}.runs-on`,
289
+ `\`runs-on\` changed, so the job runs on a different runner.`
290
+ )
291
+ );
292
+ }
293
+ if (!deepEqual(before.permissions, after.permissions)) {
294
+ found.push(
295
+ ambiguousIndicator(
296
+ "permissions-changed",
297
+ file,
298
+ `jobs.${jobId}.permissions`,
299
+ `Job-level \`permissions\` changed.`
300
+ )
301
+ );
302
+ }
303
+ return found;
304
+ }
305
+ function jobIndicators(file, base, head) {
306
+ const before = jobsOf(base);
307
+ const after = jobsOf(head);
308
+ const found = [];
309
+ for (const [jobId, job] of Object.entries(before)) {
310
+ const counterpart = after[jobId];
311
+ if (counterpart === void 0) {
312
+ found.push(
313
+ looseningIndicator("job-removed", file, `jobs.${jobId}`, `Job \`${jobId}\` was removed.`)
314
+ );
315
+ continue;
316
+ }
317
+ found.push(
318
+ ...guardIndicators(file, `jobs.${jobId}`, job, counterpart),
319
+ ...jobSurfaceIndicators(file, jobId, job, counterpart),
320
+ ...needsIndicators(file, jobId, job, counterpart),
321
+ ...matrixIndicators(file, jobId, job, counterpart),
322
+ ...stepIndicators(file, jobId, job, counterpart)
323
+ );
324
+ }
325
+ return found;
326
+ }
327
+
328
+ // src/checks/ci-change/workflow-rules.ts
329
+ var INCLUSIVE_FILTERS = ["branches", "tags", "paths", "types"];
330
+ var EXCLUSIVE_FILTERS = ["branches-ignore", "tags-ignore", "paths-ignore"];
331
+ function triggersOf(doc) {
332
+ const map = asRecord(doc);
333
+ if (!map) return void 0;
334
+ const on = "on" in map ? map.on : map.true;
335
+ if (on === void 0) return void 0;
336
+ if (typeof on === "string") return { [on]: {} };
337
+ const seq = asArray(on);
338
+ if (seq) {
339
+ const out2 = {};
340
+ for (const event of seq) if (typeof event === "string") out2[event] = {};
341
+ return out2;
342
+ }
343
+ const onMap = asRecord(on);
344
+ if (!onMap) return void 0;
345
+ const out = {};
346
+ for (const [event, filters] of Object.entries(onMap)) out[event] = filters ?? {};
347
+ return out;
348
+ }
349
+ function filterIndicators(file, event, base, head) {
350
+ const found = [];
351
+ const at = (key) => `on.${event}.${key}`;
352
+ const baseSeq = asArray(base);
353
+ const headSeq = asArray(head);
354
+ if (baseSeq) {
355
+ const dropped = missingEntries(baseSeq, headSeq ?? []);
356
+ if (dropped.length > 0) {
357
+ found.push(
358
+ looseningIndicator(
359
+ "trigger-narrowed",
360
+ file,
361
+ `on.${event}`,
362
+ `${dropped.length} \`${event}\` entr${dropped.length === 1 ? "y" : "ies"} removed.`
363
+ )
364
+ );
365
+ }
366
+ return found;
367
+ }
368
+ const baseFilters = asRecord(base) ?? {};
369
+ const headFilters = asRecord(head) ?? {};
370
+ for (const key of INCLUSIVE_FILTERS) {
371
+ const before = asArray(baseFilters[key]);
372
+ const after = asArray(headFilters[key]);
373
+ if (!before && after) {
374
+ found.push(
375
+ looseningIndicator(
376
+ "trigger-narrowed",
377
+ file,
378
+ at(key),
379
+ `\`${key}\` filter added to \`${event}\`, which previously matched everything.`
380
+ )
381
+ );
382
+ continue;
383
+ }
384
+ if (!before) continue;
385
+ const dropped = missingEntries(before, after ?? []);
386
+ if (dropped.length > 0) {
387
+ found.push(
388
+ looseningIndicator(
389
+ "trigger-narrowed",
390
+ file,
391
+ at(key),
392
+ `${dropped.map((entry) => `\`${String(entry)}\``).join(", ")} removed from \`${key}\`.`
393
+ )
394
+ );
395
+ }
396
+ }
397
+ for (const key of EXCLUSIVE_FILTERS) {
398
+ const before = asArray(baseFilters[key]) ?? [];
399
+ const after = asArray(headFilters[key]) ?? [];
400
+ const added = missingEntries(after, before);
401
+ if (added.length > 0) {
402
+ found.push(
403
+ looseningIndicator(
404
+ "trigger-narrowed",
405
+ file,
406
+ at(key),
407
+ `${added.map((entry) => `\`${String(entry)}\``).join(", ")} added to \`${key}\`, excluding more runs.`
408
+ )
409
+ );
410
+ }
411
+ }
412
+ return found;
413
+ }
414
+ function triggerIndicators(file, base, head) {
415
+ const before = triggersOf(base);
416
+ const after = triggersOf(head);
417
+ if (!before) return [];
418
+ const found = [];
419
+ for (const [event, filters] of Object.entries(before)) {
420
+ if (!after || !(event in after)) {
421
+ found.push(
422
+ looseningIndicator(
423
+ "trigger-narrowed",
424
+ file,
425
+ `on.${event}`,
426
+ `The \`${event}\` trigger was removed.`
427
+ )
428
+ );
429
+ continue;
430
+ }
431
+ found.push(...filterIndicators(file, event, filters, after[event]));
432
+ }
433
+ return found;
434
+ }
435
+ function topLevelIndicators(file, base, head) {
436
+ const baseMap = asRecord(base) ?? {};
437
+ const headMap = asRecord(head) ?? {};
438
+ const found = [];
439
+ for (const key of ["permissions", "env"]) {
440
+ const before = baseMap[key];
441
+ const after = headMap[key];
442
+ if (before === void 0 && after === void 0) continue;
443
+ if (deepEqual(before, after)) continue;
444
+ found.push(
445
+ ambiguousIndicator(
446
+ key === "permissions" ? "permissions-changed" : "env-changed",
447
+ file,
448
+ key,
449
+ `Workflow-level \`${key}\` changed.`
450
+ )
451
+ );
452
+ }
453
+ return found;
454
+ }
455
+
456
+ // src/checks/ci-change/classify.ts
457
+ function parseWorkflow(text) {
458
+ try {
459
+ return { doc: parse(text) };
460
+ } catch (err) {
461
+ return { error: err instanceof Error ? err.message : String(err) };
462
+ }
463
+ }
464
+ function classifyWorkflowFile(change) {
465
+ const { path, baseText, headText } = change;
466
+ if (headText === void 0) {
467
+ if (baseText === void 0) return [];
468
+ return [
469
+ looseningIndicator(
470
+ "workflow-removed",
471
+ path,
472
+ path,
473
+ "The workflow file was deleted, removing every check it defined."
474
+ )
475
+ ];
476
+ }
477
+ if (baseText === void 0) return [];
478
+ const before = parseWorkflow(baseText);
479
+ const after = parseWorkflow(headText);
480
+ if ("error" in after) {
481
+ return [
482
+ ambiguousIndicator(
483
+ "unparseable",
484
+ path,
485
+ path,
486
+ `The workflow could not be parsed at head, so its change cannot be classified: ${after.error}`
487
+ )
488
+ ];
489
+ }
490
+ if ("error" in before) {
491
+ return [
492
+ ambiguousIndicator(
493
+ "unparseable",
494
+ path,
495
+ path,
496
+ `The workflow could not be parsed at the merge base, so its change cannot be classified: ${before.error}`
497
+ )
498
+ ];
499
+ }
500
+ const found = [
501
+ ...triggerIndicators(path, before.doc, after.doc),
502
+ ...topLevelIndicators(path, before.doc, after.doc),
503
+ ...jobIndicators(path, before.doc, after.doc)
504
+ ];
505
+ if (found.length > 0) return found;
506
+ if (containsAll(normalizeUses(before.doc), normalizeUses(after.doc))) return [];
507
+ return [
508
+ ambiguousIndicator(
509
+ "unclassified-change",
510
+ path,
511
+ path,
512
+ "The workflow changed in a way that is not purely additive and matches no known tightening pattern."
513
+ )
514
+ ];
515
+ }
516
+ function classifyWorkflowChanges(changes) {
517
+ if (changes.length === 0) return { verdict: "no-change", indicators: [] };
518
+ const indicators = changes.flatMap(classifyWorkflowFile);
519
+ return { verdict: indicators.length > 0 ? "loosening" : "tightening", indicators };
520
+ }
521
+
522
+ // src/checks/ci-change/index.ts
523
+ var CI_CHANGE_CHECK = "ci-change";
524
+ function indicatorMessage(indicator) {
525
+ return `\`${indicator.file}\` \`${indicator.location}\` \u2014 ${indicator.kind} (${indicator.class}): ${indicator.detail}`;
526
+ }
527
+ function decideCiChange(classification, labels) {
528
+ const present = new Set(labels);
529
+ const signedOff = present.has(CI_CHANGE_APPROVED_LABEL);
530
+ const flagged = present.has(CI_APPROVAL_NEEDED_LABEL);
531
+ const loosening = classification.verdict === "loosening";
532
+ const labelsToAdd = loosening && !flagged ? [CI_APPROVAL_NEEDED_LABEL] : [];
533
+ const labelsToRemove = !loosening && flagged && !signedOff ? [CI_APPROVAL_NEEDED_LABEL] : [];
534
+ if (classification.verdict === "no-change") return { findings: [], labelsToAdd, labelsToRemove };
535
+ if (!loosening) {
536
+ const note = {
537
+ check: CI_CHANGE_CHECK,
538
+ message: "Workflow changes only add coverage or are maintenance. No sign-off needed.",
539
+ effect: "info"
540
+ };
541
+ return { findings: [note], labelsToAdd, labelsToRemove };
542
+ }
543
+ const headline = signedOff ? {
544
+ check: CI_CHANGE_CHECK,
545
+ message: `CI loosening signed off with \`${CI_CHANGE_APPROVED_LABEL}\``,
546
+ effect: "info",
547
+ headline: true
548
+ } : {
549
+ check: CI_CHANGE_CHECK,
550
+ message: `This PR loosens CI and is waiting for a human to review the change and apply \`${CI_CHANGE_APPROVED_LABEL}\`. No code change clears this. Ambiguous indicators are treated as loosening because a miss skips the sign-off entirely.`,
551
+ effect: "hold"
552
+ };
553
+ const evidence = classification.indicators.map((indicator) => ({
554
+ check: CI_CHANGE_CHECK,
555
+ message: indicatorMessage(indicator),
556
+ effect: "info"
557
+ }));
558
+ return { findings: [headline, ...evidence], labelsToAdd, labelsToRemove };
559
+ }
560
+ var ciChangeCheck = {
561
+ name: CI_CHANGE_CHECK,
562
+ evaluate: async (pr) => decideCiChange(classifyWorkflowChanges(pr.workflowChanges), pr.labels)
563
+ };
564
+
565
+ // src/checks/title/conventional.ts
566
+ var COMMIT_TYPES = [
567
+ "feat",
568
+ "fix",
569
+ "docs",
570
+ "chore",
571
+ "refactor",
572
+ "test",
573
+ "style",
574
+ "perf",
575
+ "ci",
576
+ "build",
577
+ "revert"
578
+ ];
579
+ var FUNCTIONAL_TYPES = [
580
+ "feat",
581
+ "fix",
582
+ "perf",
583
+ "revert"
584
+ ];
585
+ var TITLE = /^([a-z]+)(?:\(([^)]+)\))?(!)?: (\S.*)$/;
586
+ function isCommitType(value) {
587
+ return COMMIT_TYPES.includes(value);
588
+ }
589
+ function parseTitle(title) {
590
+ const match = TITLE.exec(title);
591
+ const [, type, scope, bang, subject] = match ?? [];
592
+ if (type === void 0 || subject === void 0 || !isCommitType(type)) return null;
593
+ return { type, scope, breaking: bang === "!", subject };
594
+ }
595
+ function isFunctionalType(type) {
596
+ return FUNCTIONAL_TYPES.includes(type);
597
+ }
598
+ var RELEASE_PLEASE_TITLE = /^chore(\([^)]*\))?!?:\s+release\b/;
599
+ function isReleasePleaseTitle(title) {
600
+ return RELEASE_PLEASE_TITLE.test(title);
601
+ }
602
+
603
+ // src/checks/title/sensitive-bump.ts
604
+ var CI_SENSITIVE_PACKAGES = ["eslint", "black", "pylint", "ruff", "prettier"];
605
+ var REQUIREMENTS_FILE = /^requirements[\w.-]*\.txt$/;
606
+ function basename(path) {
607
+ return path.slice(path.lastIndexOf("/") + 1);
608
+ }
609
+ function isManifestPath(path) {
610
+ if (path.split("/").includes("node_modules")) return false;
611
+ const name = basename(path);
612
+ return name === "package.json" || REQUIREMENTS_FILE.test(name);
613
+ }
614
+ function normalize(name) {
615
+ return name.trim().toLowerCase().replace(/[-_.]+/g, "-");
616
+ }
617
+ var DEPENDENCY_FIELDS = [
618
+ "dependencies",
619
+ "devDependencies",
620
+ "optionalDependencies",
621
+ "peerDependencies"
622
+ ];
623
+ function npmVersions(text) {
624
+ let parsed;
625
+ try {
626
+ parsed = JSON.parse(text);
627
+ } catch {
628
+ return null;
629
+ }
630
+ const versions = /* @__PURE__ */ new Map();
631
+ if (typeof parsed !== "object" || parsed === null) return versions;
632
+ for (const field of DEPENDENCY_FIELDS) {
633
+ const block2 = parsed[field];
634
+ if (typeof block2 !== "object" || block2 === null) continue;
635
+ for (const [name, spec] of Object.entries(block2)) {
636
+ if (typeof spec === "string") versions.set(name, spec);
637
+ }
638
+ }
639
+ return versions;
640
+ }
641
+ var REQUIREMENT_LINE = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*((?:==|~=|>=|<=|!=|<|>).*)$/;
642
+ function pipVersions(text) {
643
+ const versions = /* @__PURE__ */ new Map();
644
+ for (const line of text.split("\n")) {
645
+ const [, name, spec] = REQUIREMENT_LINE.exec(line.replace(/#.*/, "")) ?? [];
646
+ if (name !== void 0 && spec !== void 0) versions.set(normalize(name), spec.trim());
647
+ }
648
+ return versions;
649
+ }
650
+ function versionsOf(path, text) {
651
+ return basename(path) === "package.json" ? npmVersions(text) : pipVersions(text);
652
+ }
653
+ function sensitiveBumps(changes) {
654
+ const bumped = /* @__PURE__ */ new Set();
655
+ for (const change of changes) {
656
+ if (change.baseText === void 0 || change.headText === void 0) continue;
657
+ const before = versionsOf(change.path, change.baseText);
658
+ const after = versionsOf(change.path, change.headText);
659
+ if (before === null || after === null) continue;
660
+ for (const name of CI_SENSITIVE_PACKAGES) {
661
+ const was = before.get(name);
662
+ const now = after.get(name);
663
+ if (was !== void 0 && now !== void 0 && was !== now) bumped.add(name);
664
+ }
665
+ }
666
+ return [...bumped];
667
+ }
668
+
669
+ // src/checks/title/workflow-change.ts
670
+ import { parse as parse2 } from "yaml";
671
+ function parses(text) {
672
+ try {
673
+ return { doc: parse2(text) };
674
+ } catch {
675
+ return null;
676
+ }
677
+ }
678
+ function isPinBumpOnly(change) {
679
+ if (change.baseText === void 0 || change.headText === void 0) return false;
680
+ const before = parses(change.baseText);
681
+ const after = parses(change.headText);
682
+ if (before === null || after === null) return false;
683
+ return deepEqual(normalizeUses(before.doc), normalizeUses(after.doc));
684
+ }
685
+ function substantiveWorkflowChanges(changes) {
686
+ return changes.filter((change) => !isPinBumpOnly(change)).map((change) => change.path);
687
+ }
688
+
689
+ // src/checks/title/index.ts
690
+ var TITLE_CHECK = "title";
691
+ var FUNCTIONAL = FUNCTIONAL_TYPES.map((type) => `\`${type}\``).join("/");
692
+ function block(message) {
693
+ return { check: TITLE_CHECK, message, effect: "block" };
694
+ }
695
+ function decideTitle(pr) {
696
+ const labels = new Set(pr.labels.map((label) => label.toLowerCase()));
697
+ if (labels.has(RELEASE_PLEASE_PENDING_LABEL) || isReleasePleaseTitle(pr.title)) {
698
+ return { findings: [] };
699
+ }
700
+ const title = parseTitle(pr.title);
701
+ if (title === null) {
702
+ return {
703
+ findings: [
704
+ block(
705
+ `\`${pr.title}\` is not a Conventional Commit. Expected \`<type>[(<scope>)][!]: <subject>\` with a type of ${COMMIT_TYPES.join(", ")}. Edit the title; it becomes the squash commit on \`main\`.`
706
+ )
707
+ ]
708
+ };
709
+ }
710
+ const findings = [];
711
+ const functional = isFunctionalType(title.type);
712
+ const breakingLabel = labels.has(BREAKING_CHANGE_LABEL);
713
+ const breakingIntent = breakingLabel || labels.has(HOTFIX_LABEL);
714
+ if (!functional) {
715
+ if (title.breaking) {
716
+ findings.push(
717
+ block(
718
+ `\`!\` is only allowed on ${FUNCTIONAL}; on \`${title.type}\` it would cut a spurious major release. Drop the \`!\`.`
719
+ )
720
+ );
721
+ }
722
+ if (breakingLabel) {
723
+ findings.push(
724
+ block(
725
+ `\`${BREAKING_CHANGE_LABEL}\` is only meaningful on ${FUNCTIONAL}. Remove the label, or retitle to the functional type if this really is a breaking code change. (A \`ci\` change already forces sibling rebases through its type.)`
726
+ )
727
+ );
728
+ }
729
+ } else if (breakingIntent && !title.breaking) {
730
+ findings.push(
731
+ block(
732
+ `The PR is labelled breaking but the title has no \`!\`, so the release would miss the major. Retitle to \`${title.type}${title.scope ? `(${title.scope})` : ""}!: ${title.subject}\`.`
733
+ )
734
+ );
735
+ } else if (!breakingIntent && title.breaking) {
736
+ findings.push(
737
+ block(
738
+ `The title marks a breaking change but the PR lacks the \`${BREAKING_CHANGE_LABEL}\` label, which is the source of truth. Add the label, or drop the \`!\`.`
739
+ )
740
+ );
741
+ }
742
+ const workflows = substantiveWorkflowChanges(pr.workflowChanges);
743
+ const bundledCi = functional && breakingLabel;
744
+ if (workflows.length > 0 && title.type !== "ci" && !bundledCi) {
745
+ findings.push(
746
+ block(
747
+ `This PR changes CI (${workflows.map((path) => `\`${path}\``).join(", ")}), so it must be \`ci\`-typed: retitle to \`ci(<scope>): \u2026\`. If the CI change is bundled with a functional change, keep the ${FUNCTIONAL} type and apply \`${BREAKING_CHANGE_LABEL}\` instead.`
748
+ )
749
+ );
750
+ }
751
+ const bumps = sensitiveBumps(pr.manifestChanges);
752
+ if (bumps.length > 0 && title.type !== "ci") {
753
+ findings.push(
754
+ block(
755
+ `This PR changes the version of ${bumps.map((name) => `\`${name}\``).join(", ")}, which can change lint/format results on every in-flight PR. Retitle to \`ci(deps): \u2026\` so siblings re-test; do not use \`!\` or \`${BREAKING_CHANGE_LABEL}\` for it.`
756
+ )
757
+ );
758
+ }
759
+ return { findings };
760
+ }
761
+ var titleCheck = {
762
+ name: TITLE_CHECK,
763
+ evaluate: async (pr) => decideTitle(pr)
764
+ };
765
+
766
+ // src/checks/index.ts
767
+ var CHECKS = [titleCheck, ciChangeCheck];
768
+
769
+ // src/checks/ci-change/workflow-paths.ts
770
+ var WORKFLOW_FILE = /^\.github\/workflows\/.+\.ya?ml$/;
771
+ function isWorkflowPath(path) {
772
+ return WORKFLOW_FILE.test(path);
773
+ }
774
+
775
+ // src/report.ts
776
+ var POLICY_OUTCOMES = ["success", "pending", "failure"];
777
+ var ICONS = { block: "\u274C", hold: "\u23F3", info: "\u2139\uFE0F" };
778
+ function plural(count, noun) {
779
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
780
+ }
781
+ function buildReport(findings) {
782
+ const blocks = findings.filter((finding) => finding.effect === "block");
783
+ const holds = findings.filter((finding) => finding.effect === "hold");
784
+ const headline = findings.find((finding) => finding.headline);
785
+ let outcome = "success";
786
+ let title = headline?.message ?? "All PR policy checks pass";
787
+ if (blocks.length > 0) {
788
+ outcome = "failure";
789
+ title = plural(blocks.length, "blocking policy finding");
790
+ } else if (holds.length > 0) {
791
+ outcome = "pending";
792
+ title = `Waiting on ${plural(holds.length, "human sign-off")}`;
793
+ }
794
+ const lines = findings.map(
795
+ (finding) => `- ${ICONS[finding.effect]} **${finding.check}**: ${finding.message}`
796
+ );
797
+ const summary = lines.length > 0 ? lines.join("\n") : "No policy findings.";
798
+ return { outcome, title, summary, findings };
799
+ }
800
+
801
+ // src/evaluate.ts
802
+ async function evaluatePolicy(pr, checks = CHECKS) {
803
+ const results = await Promise.all(checks.map((check) => check.evaluate(pr)));
804
+ return {
805
+ ...buildReport(results.flatMap((result) => result.findings)),
806
+ labelsToAdd: results.flatMap((result) => result.labelsToAdd ?? []),
807
+ labelsToRemove: results.flatMap((result) => result.labelsToRemove ?? [])
808
+ };
809
+ }
810
+
811
+ // src/facts.ts
812
+ function isStringArray(value) {
813
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
814
+ }
815
+ function isOptionalString(value) {
816
+ return value === void 0 || typeof value === "string";
817
+ }
818
+ function parseFileChange(field, value, index) {
819
+ const where = `facts.${field}[${index}]`;
820
+ if (typeof value !== "object" || value === null) throw new Error(`${where} must be an object`);
821
+ const { path, baseText, headText } = value;
822
+ if (typeof path !== "string") throw new Error(`${where}.path must be a string`);
823
+ if (!isOptionalString(baseText) || !isOptionalString(headText)) {
824
+ throw new Error(`${where}.baseText / headText must be strings when present`);
825
+ }
826
+ return { path, baseText, headText };
827
+ }
828
+ function parseFileChanges(field, value) {
829
+ if (value === void 0) return [];
830
+ if (!Array.isArray(value)) throw new Error(`facts.${field} must be an array`);
831
+ return value.map((item, index) => parseFileChange(field, item, index));
832
+ }
833
+ function parseFacts(raw) {
834
+ const parsed = JSON.parse(raw);
835
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
836
+ throw new Error("facts must be a JSON object");
837
+ }
838
+ const { title, labels, changedFiles, workflowChanges, manifestChanges } = parsed;
839
+ if (typeof title !== "string") throw new Error("facts.title must be a string");
840
+ if (!isStringArray(labels)) throw new Error("facts.labels must be a string array");
841
+ if (!isStringArray(changedFiles)) throw new Error("facts.changedFiles must be a string array");
842
+ return {
843
+ title,
844
+ labels,
845
+ changedFiles,
846
+ workflowChanges: parseFileChanges("workflowChanges", workflowChanges),
847
+ manifestChanges: parseFileChanges("manifestChanges", manifestChanges)
848
+ };
849
+ }
850
+
851
+ // src/lib/bounded-subprocess.ts
852
+ import { spawn } from "child_process";
853
+ function boundedRun(command, args, options) {
854
+ return new Promise((resolve, reject) => {
855
+ const child = spawn(command, args, {
856
+ cwd: options.cwd,
857
+ env: options.env,
858
+ detached: true
859
+ // new process group, so we can kill the whole tree
860
+ });
861
+ let stdout = "";
862
+ let stderr = "";
863
+ let timedOut = false;
864
+ const timer = setTimeout(() => {
865
+ timedOut = true;
866
+ try {
867
+ process.kill(-child.pid, "SIGKILL");
868
+ } catch {
869
+ }
870
+ }, options.timeoutMs);
871
+ if (options.input !== void 0 && child.stdin) {
872
+ child.stdin.on("error", () => {
873
+ });
874
+ child.stdin.write(options.input);
875
+ child.stdin.end();
876
+ }
877
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
878
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
879
+ child.on("error", (err) => {
880
+ clearTimeout(timer);
881
+ reject(err);
882
+ });
883
+ child.on("close", (code) => {
884
+ clearTimeout(timer);
885
+ resolve({ stdout, stderr, code, timedOut });
886
+ });
887
+ });
888
+ }
889
+
890
+ // src/lib/github.ts
891
+ var GH_API_TIMEOUT_MS = 3e4;
892
+ var MAX_RETRIES = 2;
893
+ var BACKOFF_BASE_MS = 1e3;
894
+ var BACKOFF_CAP_MS = 8e3;
895
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
896
+ function isRateLimited(text) {
897
+ const t = (text ?? "").toLowerCase();
898
+ return t.includes("rate limit") || t.includes("rate-limit");
899
+ }
900
+ async function runTransport({ argv, stdin }, cwd) {
901
+ const [command, ...args] = argv;
902
+ if (command === void 0) return { stdout: null, stderr: "empty argv" };
903
+ try {
904
+ const r = await boundedRun(command, args, { timeoutMs: GH_API_TIMEOUT_MS, cwd, input: stdin });
905
+ if (r.code === 0) return { stdout: r.stdout, stderr: "" };
906
+ return { stdout: null, stderr: r.stderr || "" };
907
+ } catch (err) {
908
+ return { stdout: null, stderr: err instanceof Error ? err.message : String(err) };
909
+ }
910
+ }
911
+ async function ghCall(primary, fallback, opts = {}) {
912
+ const sleep = opts.sleep ?? realSleep;
913
+ for (const transport of [primary, fallback]) {
914
+ if (!transport) continue;
915
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
916
+ const { stdout, stderr } = await runTransport(transport, opts.cwd);
917
+ if (stdout !== null) return stdout;
918
+ if (isRateLimited(stderr)) break;
919
+ if (attempt < MAX_RETRIES) {
920
+ await sleep(Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
921
+ }
922
+ }
923
+ }
924
+ return null;
925
+ }
926
+ function parseSlugFromRemoteUrl(url) {
927
+ const trimmed = url.trim();
928
+ const match = trimmed.match(/[/:]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
929
+ if (match) return `${match[1]}/${match[2]}`;
930
+ const bare = trimmed.match(/^([^/:\s]+)\/([^/:\s]+?)(?:\.git)?$/);
931
+ return bare ? `${bare[1]}/${bare[2]}` : null;
932
+ }
933
+ async function currentRepo(opts = {}) {
934
+ const out = await ghCall(
935
+ { argv: ["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"] },
936
+ null,
937
+ opts
938
+ );
939
+ if (out && out.trim()) return out.trim();
940
+ const remote = await boundedRun("git", ["remote", "get-url", "origin"], {
941
+ timeoutMs: GH_API_TIMEOUT_MS,
942
+ cwd: opts.cwd
943
+ });
944
+ return remote.code === 0 ? parseSlugFromRemoteUrl(remote.stdout) : null;
945
+ }
946
+ async function resolveRepoTarget(opts = {}) {
947
+ const explicit = opts.repo?.trim();
948
+ if (explicit) return explicit;
949
+ const ghRepo = (opts.env ?? process.env).GH_REPO?.trim();
950
+ if (ghRepo) return ghRepo;
951
+ return currentRepo(opts);
952
+ }
953
+ function issueNumber(ref) {
954
+ const s = String(ref);
955
+ if (/^\d+$/.test(s)) return s;
956
+ const m = s.match(/\/(?:issues|pull)\/(\d+)/);
957
+ return m?.[1] ?? null;
958
+ }
959
+ async function addLabels(repo, issue, labels, call = {}) {
960
+ const number = issueNumber(issue);
961
+ if (number === null || !labels.length) return null;
962
+ const rest = {
963
+ argv: ["gh", "api", "-X", "POST", `repos/${repo}/issues/${number}/labels`, "--input", "-"],
964
+ stdin: JSON.stringify({ labels })
965
+ };
966
+ const fb = {
967
+ argv: ["gh", "issue", "edit", number, "--repo", repo, "--add-label", labels.join(",")]
968
+ };
969
+ const out = await ghCall(rest, fb, call);
970
+ return out !== null ? out.trim() : null;
971
+ }
972
+ async function removeLabel(repo, issue, label, call = {}) {
973
+ const number = issueNumber(issue);
974
+ if (number === null) return null;
975
+ const rest = {
976
+ argv: [
977
+ "gh",
978
+ "api",
979
+ "-X",
980
+ "DELETE",
981
+ `repos/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`
982
+ ]
983
+ };
984
+ const fb = {
985
+ argv: ["gh", "issue", "edit", number, "--repo", repo, "--remove-label", label]
986
+ };
987
+ const out = await ghCall(rest, fb, call);
988
+ return out !== null ? out.trim() : null;
989
+ }
990
+
991
+ // src/github/check-run.ts
992
+ function checkRunBody(evaluation, now) {
993
+ const output = { title: evaluation.title, summary: evaluation.summary };
994
+ if (evaluation.outcome === "pending") return { status: "in_progress", output };
995
+ return {
996
+ status: "completed",
997
+ conclusion: evaluation.outcome,
998
+ completed_at: now.toISOString(),
999
+ output
1000
+ };
1001
+ }
1002
+ async function openRun(target, headSha) {
1003
+ const out = await ghCall(
1004
+ {
1005
+ argv: [
1006
+ "gh",
1007
+ "api",
1008
+ `repos/${target.repo}/commits/${headSha}/check-runs?check_name=${PR_POLICY_CHECK_NAME}&filter=latest`,
1009
+ "--jq",
1010
+ ".check_runs"
1011
+ ]
1012
+ },
1013
+ null,
1014
+ { cwd: target.cwd }
1015
+ );
1016
+ if (out === null) throw new Error(`could not list ${PR_POLICY_CHECK_NAME} check-runs`);
1017
+ const runs = JSON.parse(out);
1018
+ return runs.find((run) => run.status !== "completed")?.id ?? null;
1019
+ }
1020
+ async function postCheckRun(target, headSha, evaluation) {
1021
+ const body = checkRunBody(evaluation, /* @__PURE__ */ new Date());
1022
+ const existing = await openRun(target, headSha);
1023
+ const argv = existing === null ? ["gh", "api", "-X", "POST", `repos/${target.repo}/check-runs`, "--input", "-"] : ["gh", "api", "-X", "PATCH", `repos/${target.repo}/check-runs/${existing}`, "--input", "-"];
1024
+ const payload = existing === null ? { name: PR_POLICY_CHECK_NAME, head_sha: headSha, ...body } : body;
1025
+ const out = await ghCall({ argv, stdin: JSON.stringify(payload) }, null, { cwd: target.cwd });
1026
+ if (out === null) throw new Error(`could not post the ${PR_POLICY_CHECK_NAME} check-run`);
1027
+ }
1028
+
1029
+ // src/github/pull-request.ts
1030
+ async function ghJson(target, argv) {
1031
+ const out = await ghCall({ argv }, null, { cwd: target.cwd });
1032
+ if (out === null) throw new Error(`gh call failed: ${argv.join(" ")}`);
1033
+ return JSON.parse(out);
1034
+ }
1035
+ async function blob(target, path, ref) {
1036
+ const out = await ghCall(
1037
+ {
1038
+ argv: [
1039
+ "gh",
1040
+ "api",
1041
+ "-H",
1042
+ "Accept: application/vnd.github.raw",
1043
+ `repos/${target.repo}/contents/${path}?ref=${encodeURIComponent(ref)}`
1044
+ ]
1045
+ },
1046
+ null,
1047
+ { cwd: target.cwd }
1048
+ );
1049
+ if (out === null) throw new Error(`could not read ${path} at ${ref}`);
1050
+ return out;
1051
+ }
1052
+ async function listFiles(target) {
1053
+ const out = await ghCall(
1054
+ {
1055
+ argv: [
1056
+ "gh",
1057
+ "api",
1058
+ "--paginate",
1059
+ `repos/${target.repo}/pulls/${target.pr}/files`,
1060
+ "--jq",
1061
+ ".[] | {filename, status, previous_filename}"
1062
+ ]
1063
+ },
1064
+ null,
1065
+ { cwd: target.cwd }
1066
+ );
1067
+ if (out === null) throw new Error(`could not list files of ${target.repo}#${target.pr}`);
1068
+ return out.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
1069
+ }
1070
+ async function gatherFacts(target) {
1071
+ const view = await ghJson(target, [
1072
+ "gh",
1073
+ "api",
1074
+ `repos/${target.repo}/pulls/${target.pr}`
1075
+ ]);
1076
+ const headSha = view.head.sha;
1077
+ const files = await listFiles(target);
1078
+ const touches = (predicate) => files.filter((file) => predicate(file.filename) || predicate(file.previous_filename ?? ""));
1079
+ const workflowFiles = touches(isWorkflowPath);
1080
+ const manifestFiles = touches(isManifestPath);
1081
+ let workflowChanges = [];
1082
+ let manifestChanges = [];
1083
+ if (workflowFiles.length > 0 || manifestFiles.length > 0) {
1084
+ const comparison = await ghJson(target, [
1085
+ "gh",
1086
+ "api",
1087
+ `repos/${target.repo}/compare/${view.base.ref}...${headSha}`
1088
+ ]);
1089
+ const mergeBase = comparison.merge_base_commit.sha;
1090
+ const read = async (file) => ({
1091
+ path: file.filename,
1092
+ baseText: file.status === "added" ? void 0 : await blob(target, file.previous_filename ?? file.filename, mergeBase),
1093
+ headText: file.status === "removed" ? void 0 : await blob(target, file.filename, headSha)
1094
+ });
1095
+ workflowChanges = await Promise.all(workflowFiles.map(read));
1096
+ manifestChanges = await Promise.all(manifestFiles.map(read));
1097
+ }
1098
+ const facts = {
1099
+ title: view.title,
1100
+ labels: view.labels.map((label) => label.name),
1101
+ changedFiles: files.map((file) => file.filename),
1102
+ workflowChanges,
1103
+ manifestChanges
1104
+ };
1105
+ return { facts, headSha };
1106
+ }
1107
+ async function applyLabelEdits(target, evaluation) {
1108
+ if (evaluation.labelsToAdd.length > 0) {
1109
+ await addLabels(target.repo, target.pr, evaluation.labelsToAdd, { cwd: target.cwd });
1110
+ }
1111
+ for (const label of evaluation.labelsToRemove) {
1112
+ await removeLabel(target.repo, target.pr, label, { cwd: target.cwd });
1113
+ }
1114
+ }
1115
+
1116
+ export {
1117
+ PR_POLICY_CHECK_NAME,
1118
+ CI_APPROVAL_NEEDED_LABEL,
1119
+ CI_CHANGE_APPROVED_LABEL,
1120
+ BREAKING_CHANGE_LABEL,
1121
+ HOTFIX_LABEL,
1122
+ RELEASE_PLEASE_PENDING_LABEL,
1123
+ LOOSENING_INDICATORS,
1124
+ AMBIGUOUS_INDICATORS,
1125
+ CI_CHANGE_VERDICTS,
1126
+ classifyWorkflowFile,
1127
+ classifyWorkflowChanges,
1128
+ CI_CHANGE_CHECK,
1129
+ decideCiChange,
1130
+ ciChangeCheck,
1131
+ COMMIT_TYPES,
1132
+ FUNCTIONAL_TYPES,
1133
+ parseTitle,
1134
+ CI_SENSITIVE_PACKAGES,
1135
+ isManifestPath,
1136
+ sensitiveBumps,
1137
+ TITLE_CHECK,
1138
+ decideTitle,
1139
+ titleCheck,
1140
+ CHECKS,
1141
+ isWorkflowPath,
1142
+ POLICY_OUTCOMES,
1143
+ buildReport,
1144
+ evaluatePolicy,
1145
+ parseFacts,
1146
+ resolveRepoTarget,
1147
+ checkRunBody,
1148
+ postCheckRun,
1149
+ gatherFacts,
1150
+ applyLabelEdits
1151
+ };