@codyswann/lisa 4.14.0 → 4.15.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.
Files changed (77) hide show
  1. package/dist/cli/doctor-two-channel-drift.d.ts.map +1 -1
  2. package/dist/cli/doctor-two-channel-drift.js +79 -19
  3. package/dist/cli/doctor-two-channel-drift.js.map +1 -1
  4. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  5. package/dist/core/upstream-evidence-manifest.js +7 -2
  6. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  7. package/dist/health/deterministic.d.ts +3 -0
  8. package/dist/health/deterministic.d.ts.map +1 -1
  9. package/dist/health/deterministic.js +6 -0
  10. package/dist/health/deterministic.js.map +1 -1
  11. package/dist/health/ruleset-inspection.d.ts +14 -0
  12. package/dist/health/ruleset-inspection.d.ts.map +1 -1
  13. package/dist/health/ruleset-inspection.js +19 -8
  14. package/dist/health/ruleset-inspection.js.map +1 -1
  15. package/dist/health/ruleset-reach-inspection.d.ts +68 -0
  16. package/dist/health/ruleset-reach-inspection.d.ts.map +1 -0
  17. package/dist/health/ruleset-reach-inspection.js +209 -0
  18. package/dist/health/ruleset-reach-inspection.js.map +1 -0
  19. package/package.json +1 -1
  20. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +31 -0
  23. package/plugins/lisa/skills/lisa-doctor/SKILL.md +31 -0
  24. package/plugins/lisa-agy/plugin.json +1 -1
  25. package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +31 -0
  26. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  29. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +31 -0
  33. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +31 -0
  35. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-expo-agy/plugin.json +1 -1
  38. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  43. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  48. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  53. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  58. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-rails-agy/plugin.json +1 -1
  63. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  68. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  73. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/src/base/skills/lisa-doctor/SKILL.md +31 -0
  76. package/scripts/lisa-github-rulesets.sh +149 -29
  77. package/scripts/lisa-ruleset-reach.mjs +512 -0
@@ -629,6 +629,37 @@ choice at all — the value is whatever GitHub's default happens to be. Declarin
629
629
  and makes it visible to the reconciler; declaring nothing keeps the payload byte-for-byte what it
630
630
  was, parameter absent.
631
631
 
632
+ ### A ruleset that governs no branch
633
+
634
+ Reconciliation asks whether the required LIST matches. It never asks whether the ruleset carrying
635
+ that list matches any ref the repository actually has, and GitHub does not ask either: a
636
+ `conditions.ref_name.include` entry is a pattern, not a reference, so `refs/heads/dev` is accepted
637
+ and stays live on a repository whose only branch is `main`. Everything that ruleset requires is then
638
+ required nowhere — the gate still runs and posts its context, and no pull request waits on it.
639
+
640
+ Every surface read that as healthy. `compareRulesets` in `lisa health` compares the include list
641
+ against Lisa's template by string equality, and both say `refs/heads/dev`, therefore no drift; the
642
+ repo-map row parses includes but matches the literal `~DEFAULT_BRANCH` and ORs across rows, so one
643
+ governing ruleset makes the whole answer green. Nothing enumerated the repository's branches.
644
+
645
+ The `github.ruleset-reach` check in `lisa health` now does, and so does
646
+ `scripts/lisa-github-rulesets.sh` at the end of every apply, dry run included. Both load one shipped
647
+ detector, `scripts/lisa-ruleset-reach.mjs`, so the two surfaces cannot disagree about which rulesets
648
+ govern nothing. Not in doctor: doctor makes no network call, and which branches exist is a live
649
+ read — the same split that puts the template half of the declaration comparison in doctor and the
650
+ live half in health.
651
+
652
+ Three answers, never two. A ruleset that matches at least one branch is silent. One that matches
653
+ none is named with the patterns that matched nothing. One this run could not classify — an
654
+ unreadable branch list, a `~DEFAULT_BRANCH` whose default branch could not be resolved, an fnmatch
655
+ bracket expression the detector does not model — is `undetermined` and says so, because an unread
656
+ branch list is not an empty repository and reporting it as one would name every ruleset present.
657
+
658
+ **Report only, and neither obvious repair is Lisa's to make.** Creating the missing branch
659
+ manufactures the very ref the ruleset was written to protect; disabling the ruleset gives up a
660
+ protection somebody chose. An automated actor may narrow a control, never loosen one. Point the
661
+ ruleset at a branch the repository has, or retire it deliberately.
662
+
632
663
  ## Secrets configuration
633
664
 
634
665
  Run the secrets health checks through the skill that owns the contract, rather than reimplementing
@@ -933,26 +933,32 @@ collect_templates() {
933
933
  # a repository may legitimately require a status posted by a third-party app or
934
934
  # by a job its own CI defines, and a sweep that flagged every externally
935
935
  # produced context would be noise an operator learns to ignore.
936
- report_retired_contexts() {
936
+ # Fetch every ruleset's DETAIL payload once, for every report-only sweep.
937
+ #
938
+ # The LIST endpoint answers with summaries: id, name, enforcement — and no
939
+ # `rules` and no `conditions`. A sweep run over the list would therefore find
940
+ # nothing on every repository, always, and report it as clean. Each ruleset's
941
+ # required checks and ref conditions only exist on its detail payload, so each
942
+ # one is fetched, and a detail this run could not read is stated rather than
943
+ # skipped.
944
+ #
945
+ # Fetched once and shared. Two sweeps now read the same details — one for
946
+ # retired required contexts, one for branch reach — and fetching twice would
947
+ # double a bounded N+1 of API calls for an answer that cannot differ between
948
+ # them, then report the same unreadable ruleset to the operator twice.
949
+ #
950
+ # Writes the JSON array to $3 rather than stdout, because the warnings it emits
951
+ # for an unreadable ruleset are themselves stdout and would otherwise land in
952
+ # the middle of the payload.
953
+ fetch_ruleset_details() {
937
954
  local repo="$1"
938
955
  local rulesets="$2"
939
- local registry="$LISA_ROOT/all/copy-overwrite/scripts/lisa-gates.mjs"
940
- local retired
956
+ local outfile="$3"
941
957
 
942
- # An unread retirement list is NOT "no retired contexts". Saying nothing here
943
- # would report a repository clean on the strength of a read that never
944
- # happened, which is the same defect one layer up.
945
- if [[ ! -f "$registry" ]]; then
946
- log_warning "Could not read $registry — retired required contexts were NOT checked. This is not a clean result."
947
- return 0
948
- fi
949
- if ! retired=$(node "$registry" retired-contexts 2>/dev/null); then
950
- log_warning "Could not derive the retired-context list — retired required contexts were NOT checked. This is not a clean result."
951
- return 0
952
- fi
953
-
954
- # Likewise for the other side of the comparison: zero rulesets read and a
955
- # repository with nothing required look identical from here.
958
+ # Zero rulesets read and a repository with nothing required look identical
959
+ # from here, and the empty one is the more likely: a token without the
960
+ # ruleset scope, an organization-level ruleset this reader cannot see, and a
961
+ # repository with no protection all arrive as zero rows.
956
962
  local -a ids=()
957
963
  if [[ -n "$rulesets" ]]; then
958
964
  while IFS= read -r id; do
@@ -960,24 +966,43 @@ report_retired_contexts() {
960
966
  done < <(echo "$rulesets" | jq -r '.[].id // empty')
961
967
  fi
962
968
  if [[ ${#ids[@]} -eq 0 ]]; then
963
- log_warning "No rulesets were readable — retired required contexts were NOT checked. This is not a clean result."
964
- return 0
969
+ log_warning "No rulesets were readable — retired required contexts and ruleset branch reach were NOT checked. This is not a clean result."
970
+ return 1
965
971
  fi
966
972
 
967
- # The LIST endpoint answers with summaries: id, name, enforcement — and no
968
- # `rules`. A sweep run over the list would therefore find nothing on every
969
- # repository, always, and report it as clean. Each ruleset's required checks
970
- # only exist on its detail payload, so each one is fetched, and a detail this
971
- # run could not read is stated rather than skipped.
972
973
  local details="[]"
973
974
  local id detail
974
975
  for id in "${ids[@]}"; do
975
976
  if ! detail=$(gh api -X GET "repos/$repo/rulesets/$id" 2>/dev/null); then
976
- log_warning "Ruleset $id could not be read — retired required contexts were NOT checked for it. This is not a clean result."
977
+ log_warning "Ruleset $id could not be read — retired required contexts and branch reach were NOT checked for it. This is not a clean result."
977
978
  continue
978
979
  fi
979
980
  details=$(jq -c -n --argjson all "$details" --argjson one "$detail" '$all + [$one]')
980
981
  done
982
+ printf '%s' "$details" > "$outfile"
983
+ return 0
984
+ }
985
+
986
+ report_retired_contexts() {
987
+ local details_file="$1"
988
+ local registry="$LISA_ROOT/all/copy-overwrite/scripts/lisa-gates.mjs"
989
+ local retired
990
+
991
+ # An unread retirement list is NOT "no retired contexts". Saying nothing here
992
+ # would report a repository clean on the strength of a read that never
993
+ # happened, which is the same defect one layer up.
994
+ if [[ ! -f "$registry" ]]; then
995
+ log_warning "Could not read $registry — retired required contexts were NOT checked. This is not a clean result."
996
+ return 0
997
+ fi
998
+ if ! retired=$(node "$registry" retired-contexts 2>/dev/null); then
999
+ log_warning "Could not derive the retired-context list — retired required contexts were NOT checked. This is not a clean result."
1000
+ return 0
1001
+ fi
1002
+
1003
+ [[ -s "$details_file" ]] || return 0
1004
+ local details
1005
+ details=$(cat "$details_file")
981
1006
 
982
1007
  # Matched on the retired LABEL as the final context segment, never on the
983
1008
  # whole context string. A check run's reported name is the `/`-joined chain
@@ -1034,6 +1059,94 @@ report_retired_contexts() {
1034
1059
  echo ""
1035
1060
  }
1036
1061
 
1062
+ ##############################################################################
1063
+ # Ruleset Branch Reach
1064
+ ##############################################################################
1065
+
1066
+ # Report every ruleset whose include patterns match NO branch this repository
1067
+ # has. CodySwannGT/lisa#2781.
1068
+ #
1069
+ # GitHub accepts an include entry naming a branch that does not exist — the
1070
+ # entries are patterns, not references, and nothing on either side validates
1071
+ # them. So a ruleset can be live, active, and governing nothing, and every
1072
+ # surface reads it as healthy: `compareRulesets` in `src/health` holds the
1073
+ # template against the live ruleset by string equality, and both say
1074
+ # `refs/heads/dev`, therefore not drifted, therefore fine.
1075
+ #
1076
+ # The cost is paid by whoever merges next. A required context is only required
1077
+ # on the refs the ruleset matches, so a gate whose ruleset matches nothing is a
1078
+ # gate that reports and blocks no one — and the operator learns which of those
1079
+ # two it is at the moment they are blocked, or never. This moves it earlier.
1080
+ #
1081
+ # REPORT ONLY, like the sweep above and for a stronger reason. The two obvious
1082
+ # repairs — create the missing branch, or disable the ruleset — are both an
1083
+ # automated actor LOOSENING a control nobody asked it to loosen: the first
1084
+ # manufactures the very ref the ruleset exists to protect, the second gives up
1085
+ # a protection somebody chose. Neither is this script's decision.
1086
+ report_zero_reach_rulesets() {
1087
+ local repo="$1"
1088
+ local details_file="$2"
1089
+ local detector="$LISA_ROOT/scripts/lisa-ruleset-reach.mjs"
1090
+
1091
+ if [[ ! -f "$detector" ]]; then
1092
+ log_warning "Could not read $detector — ruleset branch reach was NOT checked. This is not a clean result."
1093
+ return 0
1094
+ fi
1095
+ [[ -s "$details_file" ]] || return 0
1096
+
1097
+ # An unread branch list is NOT an empty repository, and the difference is the
1098
+ # whole point: comparing include patterns against zero branches would report
1099
+ # EVERY ruleset as governing nothing, which is noise an operator learns to
1100
+ # ignore and is how a real one stops being read.
1101
+ local branch_pages branch_names
1102
+ if ! branch_pages=$(gh api --paginate "repos/$repo/branches?per_page=100" 2>/dev/null); then
1103
+ log_warning "Could not list this repository's branches — ruleset branch reach was NOT checked. This is not a clean result."
1104
+ return 0
1105
+ fi
1106
+ # `--paginate` emits one JSON array PER PAGE rather than one merged array, so
1107
+ # this filter is applied to a stream of arrays; jq reads each top-level value
1108
+ # in turn, which is exactly the shape needed. A filter written for a single
1109
+ # array would silently read page one only.
1110
+ if ! branch_names=$(jq -r '.[] | .name? // empty' <<< "$branch_pages" 2>/dev/null); then
1111
+ log_warning "Could not read this repository's branch list — ruleset branch reach was NOT checked. This is not a clean result."
1112
+ return 0
1113
+ fi
1114
+ if [[ -z "$branch_names" ]]; then
1115
+ log_warning "No branch was readable — ruleset branch reach was NOT checked. This is not a clean result."
1116
+ return 0
1117
+ fi
1118
+
1119
+ # Read separately, and NOT defaulted when unreadable. `~DEFAULT_BRANCH` is
1120
+ # the include entry most rulesets rely on, and guessing which branch it names
1121
+ # would decide the whole verdict from a guess. The detector answers
1122
+ # `undetermined` for a ruleset it cannot resolve it for.
1123
+ local default_branch=""
1124
+ default_branch=$(gh api "repos/$repo" --jq '.default_branch' 2>/dev/null) || default_branch=""
1125
+
1126
+ local branches_file report
1127
+ branches_file=$(mktemp)
1128
+ printf '%s\n' "$branch_names" > "$branches_file"
1129
+ # A detector that could not run is NOT a repository with nothing to report.
1130
+ # Reading its failure as silence is the same defect one layer up, sited on
1131
+ # the detector built to catch it.
1132
+ if ! report=$(node "$detector" \
1133
+ --rulesets="$details_file" \
1134
+ --branches="$branches_file" \
1135
+ --default-branch="$default_branch" 2>/dev/null); then
1136
+ rm -f "$branches_file"
1137
+ log_warning "The ruleset reach detector could not run — ruleset branch reach was NOT checked. This is not a clean result."
1138
+ return 0
1139
+ fi
1140
+ rm -f "$branches_file"
1141
+
1142
+ if [[ -z "$report" ]]; then
1143
+ log_verbose "Every ruleset in this repository governs at least one branch."
1144
+ return 0
1145
+ fi
1146
+ echo ""
1147
+ echo "$report"
1148
+ }
1149
+
1037
1150
  main() {
1038
1151
  # Parse arguments
1039
1152
  while [[ $# -gt 0 ]]; do
@@ -1171,10 +1284,17 @@ main() {
1171
1284
  fi
1172
1285
  done
1173
1286
 
1174
- # Runs whatever the apply did, dry-run included. The retired name lives in a
1175
- # ruleset the apply above never touched, so an apply that "succeeded" is
1176
- # exactly the run that most needs to say this.
1177
- report_retired_contexts "$repo" "$existing_rulesets"
1287
+ # Both sweeps run whatever the apply did, dry-run included, and both read the
1288
+ # SAME detail payloads. What they report lives in rulesets the apply above
1289
+ # never touched, so an apply that "succeeded" is exactly the run that most
1290
+ # needs to say this.
1291
+ local ruleset_details
1292
+ ruleset_details=$(mktemp)
1293
+ if fetch_ruleset_details "$repo" "$existing_rulesets" "$ruleset_details"; then
1294
+ report_retired_contexts "$ruleset_details"
1295
+ report_zero_reach_rulesets "$repo" "$ruleset_details"
1296
+ fi
1297
+ rm -f "$ruleset_details"
1178
1298
 
1179
1299
  echo ""
1180
1300
  if [[ "$DRY_RUN" == "true" ]]; then