@codyswann/lisa 3.62.0 → 3.64.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.
Files changed (80) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +223 -10
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +325 -32
  3. package/dist/cli/gate-report-templates.js +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  6. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +16 -9
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/dist/health/ruleset-inspection.d.ts.map +1 -1
  11. package/dist/health/ruleset-inspection.js +73 -6
  12. package/dist/health/ruleset-inspection.js.map +1 -1
  13. package/package.json +2 -1
  14. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +46 -7
  17. package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/SKILL.md +7 -3
  18. package/plugins/lisa/skills/lisa-doctor/SKILL.md +46 -7
  19. package/plugins/lisa/skills/lisa-setup-github-repo/SKILL.md +7 -3
  20. package/plugins/lisa-agy/plugin.json +1 -1
  21. package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +46 -7
  22. package/plugins/lisa-agy/skills/lisa-setup-github-repo/SKILL.md +7 -3
  23. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  26. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +46 -7
  30. package/plugins/lisa-copilot/skills/lisa-setup-github-repo/SKILL.md +7 -3
  31. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +46 -7
  33. package/plugins/lisa-cursor/skills/lisa-setup-github-repo/SKILL.md +7 -3
  34. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo-agy/plugin.json +1 -1
  37. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  57. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails-agy/plugin.json +1 -1
  62. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  67. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  72. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/src/base/skills/lisa-doctor/SKILL.md +46 -7
  75. package/plugins/src/base/skills/lisa-setup-github-repo/SKILL.md +7 -3
  76. package/scripts/check-required-check-promotions.mjs +69 -4
  77. package/scripts/lisa-github-repo-setup.sh +64 -5
  78. package/scripts/lisa-github-rulesets.sh +201 -21
  79. package/scripts/lisa-ruleset-payload.mjs +376 -0
  80. package/all/github-rulesets/base.json +0 -64
@@ -44,6 +44,25 @@ PROJECT_PATH=""
44
44
  UNCHANGED_COUNT=0
45
45
  APPLIED_PAYLOAD=""
46
46
 
47
+ # The temp file holding the config-derived `base` ruleset, cleaned up on exit.
48
+ GENERATED_RULESET=""
49
+
50
+ # Delete the generated ruleset, but ONLY when the top-level shell exits.
51
+ #
52
+ # A command substitution runs in a subshell that inherits this EXIT trap and
53
+ # fires it on the subshell's exit. Without the pid guard the very next
54
+ # `$(collect_templates ...)` deleted the file that had just been generated, and
55
+ # the applier then read an empty template, resolved its name to the empty
56
+ # string, and reported "Skipping ruleset '' — no applicable rules" while
57
+ # exiting 0. That is the whole failure mode this change exists to prevent, one
58
+ # layer down: a run that reports success having applied no branch protection.
59
+ cleanup_generated_ruleset() {
60
+ [[ "$BASHPID" == "$$" ]] || return 0
61
+ [[ -n "$GENERATED_RULESET" ]] && rm -f "$GENERATED_RULESET"
62
+ return 0
63
+ }
64
+ trap cleanup_generated_ruleset EXIT
65
+
47
66
  # Project type hierarchy (child -> parent)
48
67
  # Using a function to avoid associative array issues with set -u
49
68
  get_parent_type() {
@@ -141,6 +160,14 @@ check_prerequisites() {
141
160
  missing+=("jq")
142
161
  fi
143
162
 
163
+ # The `base` ruleset is no longer a shipped JSON file; it is generated from
164
+ # .lisa.config.json by scripts/lisa-ruleset-payload.mjs. Without node there
165
+ # is no branch protection to apply at all, so this is a hard prerequisite
166
+ # rather than a degraded mode.
167
+ if ! command -v node &> /dev/null; then
168
+ missing+=("node")
169
+ fi
170
+
144
171
  if [[ ${#missing[@]} -gt 0 ]]; then
145
172
  log_error "Missing required tools: ${missing[*]}"
146
173
  exit 1
@@ -362,14 +389,22 @@ strip_config_dropped_checks() {
362
389
  # report, and a required check that never reports blocks every pull request
363
390
  # forever (the #2476 "aspirational seed a guard then trusts" defect).
364
391
  #
365
- # Keyed by ruleset name, because more than one shipped template carries a
366
- # required_status_checks rule (`base` and `quality checks` both do) and an
367
- # unkeyed list could not say which one it meant:
368
- # { "github": { "rulesets": { "addRequiredChecks": {
392
+ # Keyed by ruleset name, because more than one ruleset carries a
393
+ # required_status_checks rule (the generated `base` and `quality checks` both
394
+ # do) and an unkeyed list could not say which one it meant:
395
+ # { "github": { "rulesets": { "requiredChecks": {
369
396
  # "quality checks": [
370
397
  # { "context": "🧩 Plugin artifacts match source", "integration_id": 15368 }
371
398
  # ] } } } }
372
399
  #
400
+ # `requiredChecks` is DECLARATIVE where the retired `addRequiredChecks` was
401
+ # additive: naming a ruleset here also stops the applier unioning that
402
+ # ruleset's LIVE required list back into the payload. Additive-only could add a
403
+ # context and never remove one, so a required check outlived the job that
404
+ # posted it and the only way to drop it was the admin console. `addRequiredChecks`
405
+ # is still read so installed projects keep applying, with a warning naming its
406
+ # replacement.
407
+ #
373
408
  # `integration_id` is optional and defaults to GitHub Actions. Contexts already
374
409
  # present are not duplicated, and a ruleset with no required_status_checks rule
375
410
  # gets one created so an addition is never silently dropped.
@@ -380,24 +415,62 @@ strip_config_dropped_checks() {
380
415
  # on a repository with no workflows can never report, and would block every pull
381
416
  # request), and naming the same context in both lists is operator error whose
382
417
  # safe resolution is to drop it rather than to require it.
383
- add_config_required_checks() {
384
- local json="$1"
385
- local project_path="$2"
386
- local ruleset_name="$3"
418
+
419
+ # The declared required-check list for one ruleset, or "[]" when none.
420
+ # Prefers the declarative key and falls back to the retired additive one.
421
+ config_required_checks() {
422
+ local project_path="$1"
423
+ local ruleset_name="$2"
387
424
  local config="$project_path/.lisa.config.json"
388
425
 
389
426
  if [[ ! -f "$config" ]]; then
390
- echo "$json"
427
+ echo "[]"
391
428
  return 0
392
429
  fi
393
430
 
394
- local added
395
- if ! added=$(jq -c --arg name "$ruleset_name" \
396
- '.github.rulesets.addRequiredChecks[$name] // []' "$config" 2>/dev/null); then
431
+ local declared
432
+ if ! declared=$(jq -c --arg name "$ruleset_name" \
433
+ '.github.rulesets.requiredChecks[$name] // null' "$config" 2>/dev/null); then
397
434
  log_warning ".lisa.config.json could not be parsed — ignoring github.rulesets overrides" >&2
398
- added="[]"
435
+ echo "[]"
436
+ return 0
437
+ fi
438
+
439
+ if [[ "$declared" != "null" ]]; then
440
+ echo "$declared"
441
+ return 0
399
442
  fi
400
443
 
444
+ local legacy
445
+ legacy=$(jq -c --arg name "$ruleset_name" \
446
+ '.github.rulesets.addRequiredChecks[$name] // []' "$config" 2>/dev/null) || legacy="[]"
447
+ if [[ "$legacy" != "[]" && "$legacy" != "null" ]]; then
448
+ log_warning "github.rulesets.addRequiredChecks is retired — rename it to requiredChecks, which can also STOP requiring a context" >&2
449
+ fi
450
+ echo "$legacy"
451
+ }
452
+
453
+ # True when config states this ruleset's required list, so the live list must
454
+ # NOT be unioned back in. That union is what made removal impossible.
455
+ ruleset_checks_are_declared() {
456
+ local project_path="$1"
457
+ local ruleset_name="$2"
458
+ local config="$project_path/.lisa.config.json"
459
+
460
+ [[ -f "$config" ]] || return 1
461
+ jq -e --arg name "$ruleset_name" \
462
+ '(.github.rulesets.requiredChecks[$name] // null) | type == "array"' \
463
+ "$config" &> /dev/null
464
+ }
465
+
466
+ add_config_required_checks() {
467
+ local json="$1"
468
+ local project_path="$2"
469
+ local ruleset_name="$3"
470
+
471
+ local added
472
+ added=$(config_required_checks "$project_path" "$ruleset_name")
473
+
401
474
  if [[ "$added" == "[]" || "$added" == "null" ]]; then
402
475
  echo "$json"
403
476
  return 0
@@ -526,6 +599,27 @@ ruleset_is_current() {
526
599
  covers($live; $want)' > /dev/null
527
600
  }
528
601
 
602
+ # The contexts the live ruleset requires that the outgoing payload does not.
603
+ # The mirror of ruleset_added_contexts, and it exists because a declarative
604
+ # required-check list can now REMOVE a requirement. Losing a protection is the
605
+ # more consequential of the two directions, so it is never merely "applied".
606
+ ruleset_removed_contexts() {
607
+ local live="$1"
608
+ local payload="$2"
609
+
610
+ jq -r -n --argjson live "${live:-null}" --argjson want "$payload" '
611
+ def contexts:
612
+ [ (.rules // [])[]
613
+ | select(.type == "required_status_checks")
614
+ | (.parameters.required_status_checks // [])[]
615
+ | .context ];
616
+
617
+ ($want | contexts) as $want_contexts
618
+ | (if $live == null then [] else ($live | contexts) end)
619
+ | map(select(. as $context | ($want_contexts | index($context)) | not))
620
+ | .[]'
621
+ }
622
+
529
623
  # Print one line per context this run makes blocking. Reads the payload that
530
624
  # GitHub actually ACCEPTED, so a context dropped by the integration fallback is
531
625
  # never reported as added.
@@ -539,6 +633,20 @@ report_added_contexts() {
539
633
  done < <(ruleset_added_contexts "$live" "$payload")
540
634
  }
541
635
 
636
+ # Print one line per context this run stops requiring, by name. A declarative
637
+ # list that quietly dropped a check would read in the audit log as a routine
638
+ # reconciliation, which is exactly how a guarantee disappears without anyone
639
+ # deciding to give it up.
640
+ report_removed_contexts() {
641
+ local live="$1"
642
+ local payload="$2"
643
+
644
+ local context
645
+ while IFS= read -r context; do
646
+ [[ -n "$context" ]] && log_warning " - no longer required: $context (nothing in .lisa.config.json declares it)"
647
+ done < <(ruleset_removed_contexts "$live" "$payload")
648
+ }
649
+
542
650
  apply_ruleset() {
543
651
  local repo="$1"
544
652
  local template_file="$2"
@@ -570,7 +678,29 @@ apply_ruleset() {
570
678
  log_warning "Could not read existing ruleset '$ruleset_name' details — refusing to silently replace required checks"
571
679
  return 1
572
680
  fi
573
- clean_template=$(preserve_live_required_checks "$live" "$clean_template")
681
+ # Union the live required list back in UNLESS config states it. Preserving
682
+ # it is the right default — the live list carries external app checks
683
+ # nothing declares, and replacing it silently would strip protection. But a
684
+ # project that names a ruleset in `requiredChecks` has said what it wants
685
+ # required there, and honouring the union in that case is precisely what
686
+ # made `addRequiredChecks` unable to remove anything.
687
+ #
688
+ # The generated `base` ruleset gets NO exemption from this. It was tempting:
689
+ # its payload is entirely config-derived, so unioning the live list back in
690
+ # means an await removed from config does not stop being required. But the
691
+ # exemption would make removal unconditional on every run of a script
692
+ # `lisa-github-repo-setup.sh` invokes with `--yes`, so any context required
693
+ # today that no gate awaits and no `requiredChecks.base` names would be
694
+ # deleted with no operator ever opting in. That is the mirror of the rule
695
+ # `lisa-reconcile-policy.mjs` states in its own header — an EXTRA context is
696
+ # reported, never removed without `--prune` — and a protection lost by
697
+ # default reads in the audit log as a routine reconciliation. So `base`
698
+ # becomes declarative the same way every other ruleset does: by being named.
699
+ if ruleset_checks_are_declared "$project_path" "$ruleset_name"; then
700
+ log_verbose "Required checks for '$ruleset_name' are declared in .lisa.config.json — the live list is not preserved"
701
+ else
702
+ clean_template=$(preserve_live_required_checks "$live" "$clean_template")
703
+ fi
574
704
  fi
575
705
 
576
706
  # Idempotence: a run that would change nothing must say so and send nothing,
@@ -588,6 +718,7 @@ apply_ruleset() {
588
718
  log_info "[DRY RUN] Would create ruleset '$ruleset_name'"
589
719
  fi
590
720
  report_added_contexts "$live" "$clean_template"
721
+ report_removed_contexts "$live" "$clean_template"
591
722
  log_verbose "Template: $template_file"
592
723
  return 0
593
724
  fi
@@ -601,6 +732,7 @@ apply_ruleset() {
601
732
  APPLIED_PAYLOAD=""
602
733
  if apply_with_integration_fallback "$repo" "$ruleset_name" "$clean_template" "$existing_id"; then
603
734
  report_added_contexts "$live" "${APPLIED_PAYLOAD:-$clean_template}"
735
+ report_removed_contexts "$live" "${APPLIED_PAYLOAD:-$clean_template}"
604
736
  return 0
605
737
  fi
606
738
  log_error "Failed to apply ruleset '$ruleset_name'"
@@ -690,6 +822,50 @@ apply_with_integration_fallback() {
690
822
  # Main Logic
691
823
  ##############################################################################
692
824
 
825
+ # Build the `base` ruleset from .lisa.config.json into a temp file.
826
+ #
827
+ # `all/github-rulesets/base.json` used to be a shipped template here. Seven of
828
+ # its fields were already declared in `.lisa.config.json`, so two writers set
829
+ # the same settings and the last one won; four more could not be declared at
830
+ # all; and it pinned two vendor status checks every repository inherited and
831
+ # none could drop. It is now generated, from one declaration, per project.
832
+ #
833
+ # A failure here is fatal rather than a skip. The alternative is applying every
834
+ # OTHER ruleset and reporting success while the repository has no branch
835
+ # protection, which is the shape of failure this whole change exists to stop.
836
+ generate_base_ruleset() {
837
+ local project_path="$1"
838
+ local generator="$LISA_ROOT/scripts/lisa-ruleset-payload.mjs"
839
+ local out
840
+
841
+ if [[ ! -f "$generator" ]]; then
842
+ log_error "Missing $generator — the base ruleset is generated from .lisa.config.json and cannot be applied without it"
843
+ return 1
844
+ fi
845
+
846
+ out="$(mktemp)"
847
+ if ! node "$generator" --project="$project_path" > "$out" 2>"$out.err"; then
848
+ log_error "Could not build the base ruleset from .lisa.config.json:"
849
+ log_error "$(cat "$out.err")"
850
+ rm -f "$out" "$out.err"
851
+ return 1
852
+ fi
853
+ rm -f "$out.err"
854
+
855
+ # A generator that exits 0 having printed nothing is the failure this guard
856
+ # exists for: the applier would read an empty template, resolve its name to
857
+ # the empty string, skip it as "no applicable rules", and exit 0 having
858
+ # applied no branch protection at all. Measured — a symlinked path made the
859
+ # generator's own entry-point guard false. Never trust the exit code alone.
860
+ if ! jq -e '(.name | type == "string" and length > 0) and ((.rules // []) | length) > 0' "$out" &> /dev/null; then
861
+ log_error "The generated base ruleset is empty or has no name — refusing to continue with no branch protection to apply"
862
+ rm -f "$out"
863
+ return 1
864
+ fi
865
+
866
+ echo "$out"
867
+ }
868
+
693
869
  collect_templates() {
694
870
  local -a types=("$@")
695
871
  local -a templates=()
@@ -789,18 +965,22 @@ main() {
789
965
  log_info "Detected types: ${expanded_types[*]}"
790
966
  fi
791
967
 
968
+ # Build the config-derived base ruleset first, so it is applied before any
969
+ # template and a failure to build it stops the run before anything is written.
970
+ if ! GENERATED_RULESET=$(generate_base_ruleset "$PROJECT_PATH"); then
971
+ exit 1
972
+ fi
973
+ log_verbose "Generated base ruleset from .lisa.config.json"
974
+
792
975
  # Collect templates
793
976
  local templates_str
794
977
  templates_str=$(collect_templates "${expanded_types[@]}")
795
978
 
796
- local -a templates=()
979
+ local -a templates=("$GENERATED_RULESET")
980
+ local -a extra_templates=()
797
981
  if [[ -n "$templates_str" ]]; then
798
- read -ra templates <<< "$templates_str"
799
- fi
800
-
801
- if [[ ${#templates[@]} -eq 0 ]]; then
802
- log_warning "No ruleset templates found"
803
- exit 0
982
+ read -ra extra_templates <<< "$templates_str"
983
+ templates+=("${extra_templates[@]}")
804
984
  fi
805
985
 
806
986
  log_info "Found ${#templates[@]} ruleset template(s)"
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file Build the `base` branch-ruleset payload from resolved Lisa config.
5
+ *
6
+ * This replaces `all/github-rulesets/base.json`, a shipped template that did
7
+ * two jobs badly.
8
+ *
9
+ * Seven of its fields were ALREADY declared in `.lisa.config.json`'s `policy`
10
+ * block — deletion, non-fast-forward, thread resolution, stale-review
11
+ * dismissal, last-push approval, allowed merge methods, strict status checks.
12
+ * Two writers therefore set the same settings from two files, whichever ran
13
+ * last won, and nothing compared them. Four more fields — `bypass_actors`, the
14
+ * `ref_name` conditions, `required_approving_review_count`, `enforcement` —
15
+ * could not be declared in config at all, so the template's values were a
16
+ * fleet-wide lock no project could override.
17
+ *
18
+ * And it pinned two vendor status checks by integration id, which every
19
+ * repository inherited and none could drop, because the only config hook was
20
+ * `github.rulesets.addRequiredChecks` — additive by construction. A project
21
+ * proving credential leakage with a different scanner had no way to say so,
22
+ * and a required context that nothing posts blocks every pull request in that
23
+ * repository forever.
24
+ *
25
+ * ## Where the required checks come from now
26
+ *
27
+ * From `await` gate declarations, a mode `lisa-gates.mjs` has always carried
28
+ * and nothing used. An awaited gate names a signal somebody else posts:
29
+ *
30
+ * ```json
31
+ * "credential-leakage": {
32
+ * "pull-request": {
33
+ * "level": "required",
34
+ * "await": "GitGuardian Security Checks",
35
+ * "posted_by": 46505
36
+ * }
37
+ * }
38
+ * ```
39
+ *
40
+ * `contextsFor` already emits an awaited gate's context verbatim rather than
41
+ * deriving `<workflow> / <label>`, so the same declaration feeds the branch
42
+ * ruleset, the CI façade, and `lisa-reconcile-policy.mjs`'s drift comparison.
43
+ * Declaring nothing requires nothing: that is the override the template could
44
+ * not express.
45
+ *
46
+ * ## Defaults are the old template, minus its vendors
47
+ *
48
+ * A project with no `policy` block gets exactly the ruleset `base.json`
49
+ * described, EXCEPT its two vendor checks. That is the deliberate difference:
50
+ * the shape was never the objectionable part, the un-droppable vendor lock was.
51
+ *
52
+ * Usage:
53
+ * lisa-ruleset-payload.mjs [--project=PATH] [--moment=pull-request]
54
+ * @module lisa-ruleset-payload
55
+ */
56
+
57
+ import { invokedAsScript } from "../all/copy-overwrite/scripts/lib/invoked-as-script.mjs";
58
+ import {
59
+ readGates,
60
+ resolveMoment,
61
+ } from "../all/copy-overwrite/scripts/lisa-gates.mjs";
62
+
63
+ /**
64
+ * The ruleset this module builds.
65
+ *
66
+ * Named rather than inferred because `lisa-reconcile-policy.mjs` compares the
67
+ * declared `policy.ruleset` block against ONE live ruleset's shape, and a
68
+ * comparison that guessed which one would report drift against whichever
69
+ * ruleset happened to sort first.
70
+ */
71
+ export const POLICY_RULESET_NAME = "base";
72
+
73
+ /**
74
+ * The ruleset shape a project inherits when it declares none.
75
+ *
76
+ * Field-for-field the retired `all/github-rulesets/base.json`, so deleting the
77
+ * template changes nothing for a project that declared nothing. The one
78
+ * omission is its `required_status_checks` list: those two contexts are now
79
+ * `await` declarations, and a default would put the lock straight back.
80
+ */
81
+ export const RULESET_DEFAULTS = Object.freeze({
82
+ enforcement: "active",
83
+ include_refs: Object.freeze([
84
+ "~DEFAULT_BRANCH",
85
+ "refs/heads/dev",
86
+ "refs/heads/staging",
87
+ "refs/heads/main",
88
+ ]),
89
+ exclude_refs: Object.freeze([]),
90
+ bypass_actors: Object.freeze([
91
+ Object.freeze({
92
+ actor_id: null,
93
+ actor_type: "DeployKey",
94
+ bypass_mode: "always",
95
+ }),
96
+ Object.freeze({
97
+ actor_id: 5,
98
+ actor_type: "RepositoryRole",
99
+ bypass_mode: "always",
100
+ }),
101
+ ]),
102
+ });
103
+
104
+ /** Pull-request policy defaults, matching the retired template. */
105
+ export const REVIEW_DEFAULTS = Object.freeze({
106
+ required_approving_review_count: 0,
107
+ require_code_owner_review: false,
108
+ });
109
+
110
+ /** Branch-protection defaults, matching the retired template. */
111
+ export const PROTECT_DEFAULTS = Object.freeze({
112
+ deletion: true,
113
+ force_push: true,
114
+ conversation_resolution: true,
115
+ dismiss_stale_reviews: false,
116
+ require_last_push_approval: false,
117
+ up_to_date_before_merge: false,
118
+ });
119
+
120
+ /** Merge-method defaults, matching the retired template. */
121
+ export const MERGE_DEFAULTS = Object.freeze({
122
+ merge_commit: true,
123
+ squash: false,
124
+ rebase: false,
125
+ });
126
+
127
+ /** GitHub's spelling of each allowed merge method. */
128
+ const MERGE_METHODS = Object.freeze({
129
+ merge_commit: "merge",
130
+ squash: "squash",
131
+ rebase: "rebase",
132
+ });
133
+
134
+ /**
135
+ * Read a declared value, falling back to the default.
136
+ * @param {object|undefined} section The declared section.
137
+ * @param {string} field The field name.
138
+ * @param {object} defaults The defaults for that section.
139
+ * @returns {*} The resolved value.
140
+ */
141
+ function resolved(section, field, defaults) {
142
+ const declared = section?.[field];
143
+ return declared === undefined ? defaults[field] : declared;
144
+ }
145
+
146
+ /**
147
+ * The awaited signals a project requires at one moment.
148
+ *
149
+ * Only `required` gates. An awaited gate at `optional` says the signal exists
150
+ * and is read, not that a pull request waits for it, and promoting it to a
151
+ * required context would turn advisory information into a merge block nobody
152
+ * declared.
153
+ * @param {object} gates The gates block.
154
+ * @param {string} moment The moment to resolve.
155
+ * @returns {Array<{context: string, integration_id?: number}>} Required checks.
156
+ */
157
+ export function awaitedChecks(gates, moment) {
158
+ return resolveMoment({ gates, moment })
159
+ .filter(gate => gate.level === "required" && gate.mode === "await")
160
+ .filter(gate => Boolean(gate.awaits))
161
+ .map(gate =>
162
+ gate.postedBy === null
163
+ ? { context: gate.awaits }
164
+ : { context: gate.awaits, integration_id: gate.postedBy }
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Collapse exact duplicate awaited checks, and refuse conflicting ones.
170
+ *
171
+ * A ruleset carries one entry per context. Two required gates awaiting the same
172
+ * signal is legitimate, so an exact duplicate — same context, same pin, both
173
+ * unpinned included — collapses to one entry. Two DIFFERENT pins for one
174
+ * context cannot both be honoured, and keeping whichever came first would
175
+ * discard a declaration silently: the repository would then require the context
176
+ * pinned to an app the project never named for it.
177
+ *
178
+ * `validateGates` refuses this too. Both, because this function is what
179
+ * actually writes the payload, and a writer that trusts its caller to have
180
+ * validated is a writer that ships the conflict the day some path skips
181
+ * validation.
182
+ * @param {Array<{context: string, integration_id?: number}>} checks Awaited checks.
183
+ * @returns {Array<{context: string, integration_id?: number}>} One per context.
184
+ * @throws {Error} When one context is declared with two different pins.
185
+ */
186
+ function collapseAwaited(checks) {
187
+ const byContext = new Map();
188
+ for (const check of checks) {
189
+ const previous = byContext.get(check.context);
190
+ if (previous === undefined) {
191
+ byContext.set(check.context, check);
192
+ continue;
193
+ }
194
+ if ((previous.integration_id ?? null) === (check.integration_id ?? null)) {
195
+ continue;
196
+ }
197
+ throw new Error(
198
+ `"${check.context}" is awaited by two required gates naming different ` +
199
+ `apps (${JSON.stringify(check.integration_id ?? null)} and ` +
200
+ `${JSON.stringify(previous.integration_id ?? null)}). A ruleset carries ` +
201
+ `one entry per context, so one pin would be dropped silently. An ` +
202
+ `omitted posted_by means unpinned, which is a different requirement ` +
203
+ `from a pinned one.`
204
+ );
205
+ }
206
+ return [...byContext.values()];
207
+ }
208
+
209
+ /**
210
+ * The `pull_request` rule, built from `policy.protect` and `policy.review`.
211
+ * @param {object} policy The policy block.
212
+ * @returns {object} The rule.
213
+ */
214
+ function pullRequestRule(policy) {
215
+ const merge = policy?.merge ?? {};
216
+ const allowed = Object.entries(MERGE_METHODS)
217
+ .filter(([field]) => resolved(merge, field, MERGE_DEFAULTS) === true)
218
+ .map(([, method]) => method);
219
+
220
+ return {
221
+ type: "pull_request",
222
+ parameters: {
223
+ required_approving_review_count: resolved(
224
+ policy?.review,
225
+ "required_approving_review_count",
226
+ REVIEW_DEFAULTS
227
+ ),
228
+ dismiss_stale_reviews_on_push: resolved(
229
+ policy?.protect,
230
+ "dismiss_stale_reviews",
231
+ PROTECT_DEFAULTS
232
+ ),
233
+ require_code_owner_review: resolved(
234
+ policy?.review,
235
+ "require_code_owner_review",
236
+ REVIEW_DEFAULTS
237
+ ),
238
+ require_last_push_approval: resolved(
239
+ policy?.protect,
240
+ "require_last_push_approval",
241
+ PROTECT_DEFAULTS
242
+ ),
243
+ required_review_thread_resolution: resolved(
244
+ policy?.protect,
245
+ "conversation_resolution",
246
+ PROTECT_DEFAULTS
247
+ ),
248
+ allowed_merge_methods: allowed,
249
+ },
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Build the ruleset payload GitHub is sent.
255
+ *
256
+ * Deliberately reads only `gates` and `policy`. The per-ruleset opt-ins —
257
+ * `github.rulesets.requiredChecks` and `dropRequiredChecks` — are applied by
258
+ * `scripts/lisa-github-rulesets.sh` to EVERY ruleset it sends, this one
259
+ * included, and applying them here as well would mean two writers with two
260
+ * different defaults for `integration_id` racing over the same list.
261
+ * @param {object} options Build inputs.
262
+ * @param {object} [options.gates] The gates block.
263
+ * @param {object} [options.policy] The policy block.
264
+ * @param {string} [options.moment] Moment to derive awaited checks for.
265
+ * @returns {object} A create/update payload for the `base` ruleset.
266
+ */
267
+ export function buildRulesetPayload({
268
+ gates = {},
269
+ policy = {},
270
+ moment = "pull-request",
271
+ } = {}) {
272
+ const protect = policy?.protect ?? {};
273
+ const history = policy?.history ?? {};
274
+ const ruleset = policy?.ruleset ?? {};
275
+
276
+ const rules = [];
277
+ if (resolved(protect, "deletion", PROTECT_DEFAULTS)) {
278
+ rules.push({ type: "deletion" });
279
+ }
280
+ if (resolved(protect, "force_push", PROTECT_DEFAULTS)) {
281
+ rules.push({ type: "non_fast_forward" });
282
+ }
283
+ if (history.linear === true) rules.push({ type: "required_linear_history" });
284
+ if (history.signed_commits === true) {
285
+ rules.push({ type: "required_signatures" });
286
+ }
287
+ rules.push(pullRequestRule(policy));
288
+
289
+ const checks = collapseAwaited(awaitedChecks(gates, moment));
290
+
291
+ // An empty required_status_checks rule is not a weaker rule, it is a rule
292
+ // GitHub rejects. Omitting it is also what makes "this project requires no
293
+ // external signal" representable at all, which the template could not say.
294
+ if (checks.length > 0) {
295
+ rules.push({
296
+ type: "required_status_checks",
297
+ parameters: {
298
+ strict_required_status_checks_policy: resolved(
299
+ protect,
300
+ "up_to_date_before_merge",
301
+ PROTECT_DEFAULTS
302
+ ),
303
+ do_not_enforce_on_create: false,
304
+ required_status_checks: checks,
305
+ },
306
+ });
307
+ }
308
+
309
+ return {
310
+ name: POLICY_RULESET_NAME,
311
+ target: "branch",
312
+ enforcement: resolved(ruleset, "enforcement", RULESET_DEFAULTS),
313
+ conditions: {
314
+ ref_name: {
315
+ exclude: [...resolved(ruleset, "exclude_refs", RULESET_DEFAULTS)],
316
+ include: [...resolved(ruleset, "include_refs", RULESET_DEFAULTS)],
317
+ },
318
+ },
319
+ bypass_actors: [...resolved(ruleset, "bypass_actors", RULESET_DEFAULTS)],
320
+ rules,
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Read a project's config without failing on its absence.
326
+ *
327
+ * A project with no `.lisa.config.json` still gets the default ruleset, which
328
+ * is what the deleted template gave it. Refusing here would turn "no config"
329
+ * into "no branch protection", a silent downgrade at exactly the moment
330
+ * governance is being applied.
331
+ * @param {string} projectPath The project directory.
332
+ * @returns {{gates: object, policy: object}} Resolved config.
333
+ */
334
+ export function readProjectConfig(projectPath) {
335
+ const { gates, policy } = readGates(projectPath);
336
+ return { gates, policy };
337
+ }
338
+
339
+ /**
340
+ * CLI entry point: print the payload as JSON.
341
+ */
342
+ function main() {
343
+ const argv = process.argv.slice(2);
344
+ const flag = name => {
345
+ const hit = argv.find(arg => arg.startsWith(`--${name}=`));
346
+ return hit ? hit.slice(name.length + 3) : null;
347
+ };
348
+ const projectPath = flag("project") ?? process.cwd();
349
+ const { gates, policy } = readProjectConfig(projectPath);
350
+ process.stdout.write(
351
+ `${JSON.stringify(
352
+ buildRulesetPayload({
353
+ gates,
354
+ policy,
355
+ moment: flag("moment") ?? "pull-request",
356
+ }),
357
+ null,
358
+ 2
359
+ )}\n`
360
+ );
361
+ }
362
+
363
+ // Not `import.meta.url === process.argv[1]`. Node resolves the module URL
364
+ // through realpath while argv[1] keeps whatever spelling the caller typed, so
365
+ // on a symlinked path — a macOS temp dir, a git worktree — they differ, main()
366
+ // never runs, and this generator prints NOTHING and exits 0. Measured: the
367
+ // applier then read an empty template and skipped the only ruleset carrying
368
+ // branch protection, reporting success.
369
+ if (invokedAsScript(import.meta.url)) {
370
+ try {
371
+ main();
372
+ } catch (err) {
373
+ console.error(err.message);
374
+ process.exitCode = 1;
375
+ }
376
+ }