@rmartz/merge-safety 0.11.0 → 0.13.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.
package/README.md CHANGED
@@ -15,11 +15,8 @@ is:
15
15
  check named exactly `merge-safety`; see
16
16
  [docs/check-run-contract.md](docs/check-run-contract.md).
17
17
 
18
- > **Status: released.** `v0.1.0` ships the full `evaluate` / `invalidate`
19
- > implementation and the `ai-merge-safety` CLI — the package is functional and
20
- > ready to adopt. It was split out of `@rmartz/pr-review` per
21
- > [ai-tools#247](https://github.com/rmartz/ai-tools/issues/247); see
22
- > [docs/migration.md](docs/migration.md) for the record of how it was extracted.
18
+ > **Status: released.** The package ships the full `evaluate` / `invalidate`
19
+ > implementation as the `merge-safety` CLI and is ready to adopt.
23
20
 
24
21
  ## Using it in a consuming repo
25
22
 
@@ -51,8 +51,8 @@ interface Args {
51
51
  baseRef: string;
52
52
  /**
53
53
  * The consumer's caller workflow filename that `invalidate` re-dispatches per PR
54
- * (issue #247's dispatch-target wrinkle: a reusable workflow's fan-out targets
55
- * the *caller* file, not this reusable file). Defaults to the conventional name.
54
+ * (a reusable workflow's fan-out must target the *caller* file, not this
55
+ * reusable file). Defaults to the conventional name.
56
56
  */
57
57
  workflow: string;
58
58
  cwd?: string;
@@ -230,8 +230,6 @@ var BREAKING_SUBJECT_RE = /^[a-z]+(?:!(?:\([^)]*\))?|(?:\([^)]*\))?!):/;
230
230
  var BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/m;
231
231
  var CI_SUBJECT_RE = /^ci(\([^)]*\))?!?:/;
232
232
  var DOCS_SUBJECT_RE = /^docs(\([^)]*\))?!?:/;
233
- var FUNCTIONAL_TYPES = /* @__PURE__ */ new Set(["feat", "fix", "perf", "revert"]);
234
- var CONVENTIONAL_TYPE_RE = /^([a-z]+)(?:!|\([^)]*\))*:/;
235
233
  function firstLine(message) {
236
234
  return message.split("\n", 1)[0] ?? "";
237
235
  }
@@ -253,10 +251,6 @@ function isCiTitle(title) {
253
251
  function isDocsTitle(title) {
254
252
  return DOCS_SUBJECT_RE.test(title.trim());
255
253
  }
256
- function mayCarryBreakingMarker(title) {
257
- const type = CONVENTIONAL_TYPE_RE.exec(title.trim())?.[1];
258
- return type !== void 0 && FUNCTIONAL_TYPES.has(type);
259
- }
260
254
 
261
255
  // src/reasons.ts
262
256
  var BREAKING_DIFF_REASONS = {
@@ -326,6 +320,89 @@ function evaluateStaleness(facts) {
326
320
  return { needsUpdate, reasons };
327
321
  }
328
322
 
323
+ // src/base-health.ts
324
+ var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
325
+ var GITHUB_ACTIONS_APP_SLUG = "github-actions";
326
+ var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
327
+ function isGitHubActionsCheck(check) {
328
+ return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
329
+ }
330
+ function isNonBuildPlatformCheck(name) {
331
+ return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
332
+ }
333
+ function isFailingCiConclusion(conclusion) {
334
+ return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
335
+ }
336
+ function failingRequiredBaseChecks(checks, requiredContexts) {
337
+ if (!requiredContexts || requiredContexts.length === 0) return [];
338
+ const required = new Set(requiredContexts);
339
+ return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
340
+ }
341
+ function failingFallbackBaseChecks(checks) {
342
+ return checks.filter(
343
+ (c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
344
+ ).map((c) => c.name);
345
+ }
346
+
347
+ // src/merge-safety.ts
348
+ var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
349
+ function isEvaluablePrState(state) {
350
+ return state === "OPEN";
351
+ }
352
+ var HOTFIX_LABEL = "hotfix";
353
+ function overlappingFiles(prFiles, baseFiles) {
354
+ if (!prFiles.length || !baseFiles.length) return [];
355
+ const base = new Set(baseFiles);
356
+ return prFiles.filter((f) => base.has(f));
357
+ }
358
+ function evaluateMergeSafety(facts) {
359
+ const reasons = [];
360
+ if (facts.hasConflict) {
361
+ reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
362
+ }
363
+ const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
364
+ if (baseUnhealthy) {
365
+ reasons.push(
366
+ withDetail(
367
+ "The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
368
+ facts.failingBaseChecks
369
+ )
370
+ );
371
+ }
372
+ const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
373
+ reasons.push(...stalenessReasons);
374
+ const needsAction = facts.hasConflict || baseUnhealthy;
375
+ const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
376
+ const summary = conclusion === "success" ? "No update required: current, or no breaking/overlapping changes on the base, no conflict, and the base CI is green." : firstLine(reasons[0] ?? "Not safe to merge as-is.");
377
+ const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : "No update required";
378
+ const add = [];
379
+ if (needsUpdate) add.push("update required");
380
+ if (facts.hasConflict) add.push("merge conflict");
381
+ const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
382
+ return {
383
+ conclusion,
384
+ needsUpdate,
385
+ hasConflict: facts.hasConflict,
386
+ baseUnhealthy,
387
+ title,
388
+ reasons,
389
+ summary,
390
+ labels: { add, remove }
391
+ };
392
+ }
393
+ function errorMergeSafetyDecision(message) {
394
+ return {
395
+ conclusion: "failure",
396
+ needsUpdate: false,
397
+ hasConflict: false,
398
+ baseUnhealthy: false,
399
+ title: "Could not evaluate",
400
+ reasons: [message],
401
+ summary: `Could not evaluate merge safety: ${message}`,
402
+ labels: { add: [], remove: [] }
403
+ };
404
+ }
405
+
329
406
  // src/breaking-diff.ts
330
407
  var CI_SENSITIVE_PACKAGES = /* @__PURE__ */ new Set(["eslint", "black", "pylint", "ruff", "prettier"]);
331
408
  var NON_DEPENDENCY_NPM_KEYS = /* @__PURE__ */ new Set(["version"]);
@@ -432,110 +509,6 @@ function breakingDiffSignals(diffText) {
432
509
  if (tests.length) signals.push({ kind: "material-test-changes", detail: tests });
433
510
  return signals;
434
511
  }
435
- function hasSignal(signals, kind) {
436
- return signals.some((s) => s.kind === kind);
437
- }
438
- function signalDetail(signals, kind) {
439
- return signals.find((s) => s.kind === kind)?.detail ?? [];
440
- }
441
-
442
- // src/base-health.ts
443
- var FAILING_CI_CONCLUSIONS = ["failure", "timed_out", "startup_failure"];
444
- var GITHUB_ACTIONS_APP_SLUG = "github-actions";
445
- var NON_BUILD_PLATFORM_CHECKS = /* @__PURE__ */ new Set(["dependabot", "dependabot updates"]);
446
- function isGitHubActionsCheck(check) {
447
- return check.appSlug === GITHUB_ACTIONS_APP_SLUG;
448
- }
449
- function isNonBuildPlatformCheck(name) {
450
- return NON_BUILD_PLATFORM_CHECKS.has(name.trim().toLowerCase());
451
- }
452
- function isFailingCiConclusion(conclusion) {
453
- return conclusion !== null && FAILING_CI_CONCLUSIONS.includes(conclusion);
454
- }
455
- function failingRequiredBaseChecks(checks, requiredContexts) {
456
- if (!requiredContexts || requiredContexts.length === 0) return [];
457
- const required = new Set(requiredContexts);
458
- return checks.filter((c) => required.has(c.name) && isFailingCiConclusion(c.conclusion)).map((c) => c.name);
459
- }
460
- function failingFallbackBaseChecks(checks) {
461
- return checks.filter(
462
- (c) => isGitHubActionsCheck(c) && isFailingCiConclusion(c.conclusion) && !isNonBuildPlatformCheck(c.name)
463
- ).map((c) => c.name);
464
- }
465
-
466
- // src/merge-safety.ts
467
- var MERGE_SAFETY_LABELS = ["update required", "merge conflict"];
468
- function isEvaluablePrState(state) {
469
- return state === "OPEN";
470
- }
471
- var HOTFIX_LABEL = "hotfix";
472
- var BREAKING_LABEL = "breaking change";
473
- function overlappingFiles(prFiles, baseFiles) {
474
- if (!prFiles.length || !baseFiles.length) return [];
475
- const base = new Set(baseFiles);
476
- return prFiles.filter((f) => base.has(f));
477
- }
478
- function evaluateMergeSafety(facts) {
479
- const reasons = [];
480
- if (facts.hasConflict) {
481
- reasons.push("Git reports a merge conflict against the base \u2014 resolve it before merging.");
482
- }
483
- const baseUnhealthy = facts.baseCiFailing && !facts.prIsHotfix;
484
- if (baseUnhealthy) {
485
- reasons.push(
486
- withDetail(
487
- "The base branch's CI is failing \u2014 only hotfix PRs may merge until it is green (label this PR `hotfix` to override):",
488
- facts.failingBaseChecks
489
- )
490
- );
491
- }
492
- const { needsUpdate, reasons: stalenessReasons } = evaluateStaleness(facts);
493
- reasons.push(...stalenessReasons);
494
- const needsCiRetitle = hasSignal(facts.prBreakingDiffSignals, "sensitive-package-bump") ? !facts.prIsCi : false;
495
- if (needsCiRetitle) {
496
- reasons.push(
497
- withDetail(
498
- "This PR changes a CI-sensitive package version, which can redden the format/lint gate on PRs that never touched the bumped file. Retitle it with the `ci` type (e.g. `ci(deps): \u2026`) so merged siblings are forced to re-test under it \u2014 a `breaking change` label cannot carry this signal on a non-functional type:",
499
- signalDetail(facts.prBreakingDiffSignals, "sensitive-package-bump")
500
- )
501
- );
502
- }
503
- const needsAction = facts.hasConflict || baseUnhealthy || needsCiRetitle;
504
- const conclusion = needsAction ? "failure" : needsUpdate ? "pending" : "success";
505
- const summary = conclusion === "success" ? "No update required: current, or no breaking/overlapping changes on the base, no conflict, and the base CI is green." : firstLine(reasons[0] ?? "Not safe to merge as-is.");
506
- const title = facts.hasConflict ? "Merge conflict" : baseUnhealthy ? "Base CI failing" : needsUpdate ? "Update required" : needsCiRetitle ? "Retitle as a CI change" : "No update required";
507
- const add = [];
508
- if (needsUpdate) add.push("update required");
509
- if (facts.hasConflict) add.push("merge conflict");
510
- const remove = MERGE_SAFETY_LABELS.filter((l) => !add.includes(l));
511
- const addOnly = [];
512
- if (hasSignal(facts.prBreakingDiffSignals, "major-version-bump") && facts.prMayCarryBreakingMarker)
513
- addOnly.push(BREAKING_LABEL);
514
- return {
515
- conclusion,
516
- needsUpdate,
517
- hasConflict: facts.hasConflict,
518
- baseUnhealthy,
519
- needsCiRetitle,
520
- title,
521
- reasons,
522
- summary,
523
- labels: { add, remove, addOnly }
524
- };
525
- }
526
- function errorMergeSafetyDecision(message) {
527
- return {
528
- conclusion: "failure",
529
- needsUpdate: false,
530
- hasConflict: false,
531
- baseUnhealthy: false,
532
- needsCiRetitle: false,
533
- title: "Could not evaluate",
534
- reasons: [message],
535
- summary: `Could not evaluate merge safety: ${message}`,
536
- labels: { add: [], remove: [], addOnly: [] }
537
- };
538
- }
539
512
 
540
513
  // src/merge-safety-facts.ts
541
514
  var GIT_TIMEOUT_MS = 3e4;
@@ -617,9 +590,10 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
617
590
  // Each boolean is derived from its detail list — one computation, two views.
618
591
  baseBreakingSinceMergeBase: baseBreakingCommits.length > 0,
619
592
  baseCiSinceMergeBase: baseCiCommits.length > 0,
620
- // Additive by construction: the title marker and the label remain inputs, and
621
- // the diff can only ever add a reason to treat the PR as breaking (#53).
622
- prIsBreaking: isBreakingTitle(meta.title) || labels.includes(BREAKING_LABEL) || diffSignals.length > 0,
593
+ // The title's `!` is the only declared input (#82) — pr-policy's title check
594
+ // keeps it in step with the `breaking change` label. The diff can only add a
595
+ // reason to re-test the PR against a moved base (#53).
596
+ prIsBreaking: isBreakingTitle(meta.title) || diffSignals.length > 0,
623
597
  prIsDocs: isDocsTitle(meta.title),
624
598
  baseOnlyDocsSinceMergeBase: commits.length > 0 && commits.every((c) => isDocsCommitMessage(c.message)),
625
599
  // Title or path (#67): a release-typed PR that changes CI is still re-tested.
@@ -632,8 +606,7 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
632
606
  baseCiCommits,
633
607
  overlappingFiles: overlaps,
634
608
  failingBaseChecks,
635
- prBreakingDiffSignals: diffSignals,
636
- prMayCarryBreakingMarker: mayCarryBreakingMarker(meta.title)
609
+ prBreakingDiffSignals: diffSignals
637
610
  };
638
611
  }
639
612
 
@@ -641,7 +614,7 @@ async function gatherMergeSafetyFacts(meta, { baseRef = "origin/main", git, base
641
614
  var DEFAULT_CALLER_WORKFLOW = "merge-safety.yml";
642
615
  function usage() {
643
616
  console.error(
644
- "usage: ai-merge-safety evaluate --pr <n> [--json] [--repo <o/r>] [--base <ref>] [--cwd <path>]\n ai-merge-safety invalidate [--base-branch <name>] [--workflow <file>] [--exclude <n>]\n [--repo <o/r>] [--cwd <path>]"
617
+ "usage: merge-safety evaluate --pr <n> [--json] [--repo <o/r>] [--base <ref>] [--cwd <path>]\n merge-safety invalidate [--base-branch <name>] [--workflow <file>] [--exclude <n>]\n [--repo <o/r>] [--cwd <path>]"
645
618
  );
646
619
  process.exit(2);
647
620
  }
@@ -807,13 +780,7 @@ ${detail}` },
807
780
  decision.conclusion,
808
781
  args.cwd
809
782
  );
810
- await reconcileLabels(
811
- repo,
812
- pr,
813
- [...decision.labels.add, ...decision.labels.addOnly],
814
- decision.labels.remove,
815
- args.cwd
816
- );
783
+ await reconcileLabels(repo, pr, decision.labels.add, decision.labels.remove, args.cwd);
817
784
  console.log(`#${pr}: ${decision.conclusion} \u2014 ${decision.summary}`);
818
785
  }
819
786
  function emitDecisionJson(decision, isError) {
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Encodes the package's stable public contract: the `merge-safety` check-run
5
5
  * name and the CLI command surface. The evaluate/invalidate implementation
6
- * (`merge-safety.ts` + `merge-safety-facts.ts` and the `ai-merge-safety` bin)
6
+ * (`merge-safety.ts` + `merge-safety-facts.ts` and the `merge-safety` bin)
7
7
  * lives alongside it; this module is intentionally the narrow, frozen surface
8
8
  * consumers and the reusable workflow depend on.
9
9
  */
@@ -19,7 +19,7 @@
19
19
  */
20
20
  declare const MERGE_SAFETY_CHECK_NAME = "merge-safety";
21
21
  /**
22
- * The two operations the `ai-merge-safety` CLI dispatches:
22
+ * The two operations the `merge-safety` CLI dispatches:
23
23
  * - `evaluate` — gather base-currency + breaking-change + conflict facts for one
24
24
  * PR, post the `merge-safety` check-run, and reconcile the update-required /
25
25
  * merge-conflict labels.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmartz/merge-safety",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "The pre-auto-merge safety verdict for a PR (base-currency, breaking-change, and conflict facts), posted as the `merge-safety` check-run and distributed via a reusable workflow kept current by Dependabot.",
5
5
  "keywords": [
6
6
  "ci",
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "bin": {
29
- "ai-merge-safety": "./dist/bin/merge-safety.js"
29
+ "merge-safety": "./dist/bin/merge-safety.js"
30
30
  },
31
31
  "files": [
32
32
  "dist"