@codyswann/lisa 3.16.0 → 3.17.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 (119) hide show
  1. package/all/copy-overwrite/scripts/lisa-gates.mjs +1165 -0
  2. package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
  3. package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
  4. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  5. package/dist/core/lisa-owned-hash-ledger.js +24 -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 +72 -8
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +6 -2
  11. package/plugins/lisa/.claude-plugin/plugin.json +10 -1
  12. package/plugins/lisa/.codex-plugin/hooks.json +9 -0
  13. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
  15. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  16. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  17. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  18. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  19. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  20. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  21. package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  22. package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
  23. package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
  24. package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  25. package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  26. package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  27. package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  28. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  29. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  30. package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  31. package/plugins/lisa-agy/plugin.json +1 -1
  32. package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
  33. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  34. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  35. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  36. package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  37. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  38. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  39. package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  40. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  43. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
  46. package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
  47. package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
  48. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  49. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  50. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  51. package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  52. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  53. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  54. package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  55. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-cursor/hooks/hooks.json +3 -0
  57. package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
  58. package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
  59. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  60. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  61. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  62. package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  63. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  64. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  65. package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  66. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-expo-agy/plugin.json +1 -1
  69. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  74. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  77. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  78. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  79. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  82. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  83. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  84. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  88. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  89. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  93. package/plugins/lisa-rails-agy/plugin.json +1 -1
  94. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  98. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  99. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  103. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  104. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  106. package/plugins/src/base/.claude-plugin/plugin.json +9 -0
  107. package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
  108. package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
  109. package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
  110. package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
  111. package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
  112. package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
  113. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
  114. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
  115. package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
  116. package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
  117. package/scripts/lib/per-agent-hook-filter.mjs +18 -0
  118. package/typescript/copy-contents/.husky/pre-commit +130 -21
  119. package/typescript/copy-contents/.husky/pre-push +202 -22
@@ -0,0 +1,1188 @@
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
5
+ /**
6
+ * @file Reconcile DECLARED gate/policy config against the LIVE GitHub ruleset.
7
+ *
8
+ * `lisa-gates.mjs` can already *derive* the branch-protection contexts a
9
+ * repository ought to require: `contextsFor(gates, {moment: "pull-request"})`.
10
+ * What it cannot do is say whether GitHub agrees. That answer lives in a
11
+ * ruleset a human edits in an admin console, and until now the only way to hold
12
+ * the two together was to transcribe the live list into
13
+ * `.github/required-checks.json` by hand — a snapshot that ships empty, expires
14
+ * after 90 days, and was measured wrong in BOTH directions (#2476: it claimed a
15
+ * context was required that nothing required, and omitted six that were).
16
+ *
17
+ * This script replaces the transcription with a comparison. It reads the live
18
+ * ruleset through `gh`, derives the declared list from `.lisa.config.json`, and
19
+ * reports three sets: MISSING (declared, not live), EXTRA (live, not declared),
20
+ * MATCHED. It reads repository settings the same way and compares them against
21
+ * the `policy` block.
22
+ *
23
+ * ## Could-not-check is not clean
24
+ *
25
+ * There is a third verdict beside matched and drifted, and it is the one this
26
+ * script exists to get right. If `gh` is absent, unauthenticated, refused (a
27
+ * private repo on a plan without rulesets answers 403), or returns something
28
+ * unparseable, the verdict is **UNPROVEN** — its own state, carrying its own
29
+ * exit code (2), with the drift sets set to `null` rather than to empty arrays.
30
+ * Empty arrays are what a clean repository looks like, so a failed read that
31
+ * produced them would render as "policy matches" and be indistinguishable from
32
+ * proof. The structure makes that unrepresentable: an unproven result has no
33
+ * sets to read, and `on_drift` does not govern it, because `on_drift` answers
34
+ * what to do about a drift you have MEASURED and here nothing was measured.
35
+ *
36
+ * ## An EXTRA context is never deleted without being named
37
+ *
38
+ * `repair` ADDS what is missing. It does not remove what is extra unless
39
+ * `--prune` is passed, and even then it names every context it removes first.
40
+ *
41
+ * The reason is that Lisa does not own the whole required list. A repository
42
+ * routinely requires contexts posted by external apps — `SonarCloud Code
43
+ * Analysis`, `GitGuardian Security Checks`, `CodeRabbit` — which no gates block
44
+ * declares and which `contextsFor` therefore cannot derive. Every one of those
45
+ * is EXTRA by construction. A repair that treated EXTRA as "delete to converge"
46
+ * would silently strip live protection on its first run, and the deletion would
47
+ * read in the audit log as a routine reconciliation. So the default is: add,
48
+ * report, and require a human to ask for the removal.
49
+ *
50
+ * That is also why an EXTRA context does not FAIL `on_drift: block`. It is
51
+ * reported by name in every mode, and it still makes the verdict DRIFT — there
52
+ * genuinely is a difference between the declaration and the repository. But
53
+ * blocking on it would make `block` a mode no repository with a SonarCloud or
54
+ * CodeRabbit check can ever satisfy, since by construction nothing declares
55
+ * those and this script refuses to remove them. A check that cannot pass while
56
+ * the repository is correct gets one fix from whoever is blocked by it —
57
+ * `--prune`, or deleting the check — and both of those lose protection. So
58
+ * `block` fails on the drift a repair could converge: MISSING contexts, and
59
+ * settings drift. Under `--prune` an EXTRA becomes removable, and therefore
60
+ * blocking, because the operator has said it should be.
61
+ *
62
+ * ## Only a workflow context is pinned to the Actions app
63
+ *
64
+ * A required check can name the app allowed to post it. Every context Lisa
65
+ * derives from a `run` gate is posted by GitHub Actions, so it is pinned. An
66
+ * `await` gate's context is posted by somebody else entirely — that is what
67
+ * awaiting means — and pinning `CodeRabbit` to the Actions integration would
68
+ * require a status that the only app able to post it can never satisfy. Those
69
+ * are added unpinned, which is GitHub's "any source".
70
+ *
71
+ * ## The alias window
72
+ *
73
+ * `--previous=a,b` is passed through to `contextsFor`'s `previousLabels`, which
74
+ * emits BOTH the old and the new context for one release. During a job rename
75
+ * the old context stops reporting the moment the workflow changes, and a
76
+ * required context that never reports blocks every in-flight pull request
77
+ * indefinitely. The fastest way out of that for whoever is blocked is deleting
78
+ * the requirement — which is how a rename quietly removes a guarantee. Requiring
79
+ * both names for one release is what avoids it. Downstream repositories call
80
+ * the shared workflow unpinned, so the rename reaches them before any of them
81
+ * has reconciled; the window is not optional.
82
+ *
83
+ * ## One writer per surface
84
+ *
85
+ * Repair writes exactly two things: required-status-check contexts on a ruleset
86
+ * (PUT), and repository settings (PATCH). Policy that lives in the SHAPE of a
87
+ * ruleset's rules — linear history, signed commits, force-push and deletion
88
+ * protection, conversation resolution — is compared and reported here but
89
+ * repaired by `scripts/lisa-github-rulesets.sh`, which owns rule construction.
90
+ * Reshaping rules from two places is how a repair strips a rule it did not
91
+ * understand.
92
+ *
93
+ * Usage:
94
+ * lisa-reconcile-policy.mjs [--repo=OWNER/NAME] [--moment=pull-request]
95
+ * [--workflow="🔍 Quality Checks"] [--previous=a,b]
96
+ * [--on-drift=repair|report|block] [--ruleset=NAME]
97
+ * [--dry-run] [--prune] [--json]
98
+ *
99
+ * Exit codes:
100
+ * 0 matched, or drift under `repair`/`report`, or EXTRA-only drift under
101
+ * `block`
102
+ * 1 convergeable drift under `block`, or a repair write that failed
103
+ * 2 UNPROVEN — nothing was measured
104
+ * @module lisa-reconcile-policy
105
+ */
106
+
107
+ import { spawnSync } from "node:child_process";
108
+
109
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
110
+
111
+ import {
112
+ contextsFor,
113
+ POLICY_SCHEMA,
114
+ readGates,
115
+ resolveMoment,
116
+ } from "./lisa-gates.mjs";
117
+
118
+ /**
119
+ * A ruleset as GitHub returns it. Permissive on purpose: the API adds fields,
120
+ * and this module only ever reads `name`, `id`, `enforcement`, and `rules`.
121
+ * @typedef {Record<string, *>} Ruleset
122
+ */
123
+
124
+ /**
125
+ * One required status check, with the ruleset that requires it.
126
+ *
127
+ * The owner travels with the context because an EXTRA one has to be NAMED with
128
+ * somewhere for the reader to go and look, and because pruning needs to know
129
+ * which ruleset to rewrite.
130
+ * @typedef {object} LiveContext
131
+ * @property {string} context The exact context string GitHub matches on.
132
+ * @property {number|null} integration_id The app that posts it, when declared.
133
+ * @property {string} ruleset Name of the ruleset requiring it.
134
+ * @property {*} rulesetId Id of that ruleset.
135
+ */
136
+
137
+ /**
138
+ * Why nothing could be measured.
139
+ * @typedef {object} Unproven
140
+ * @property {string} reason One of `UNPROVEN`.
141
+ * @property {string} detail Verbatim failure text, never summarised away.
142
+ * @property {string|null} command The `gh` invocation that failed.
143
+ */
144
+
145
+ /**
146
+ * The three context sets. Reached only when the read succeeded.
147
+ * @typedef {object} ContextDrift
148
+ * @property {string[]} missing Declared, not required on GitHub.
149
+ * @property {LiveContext[]} extra Required on GitHub, not declared.
150
+ * @property {string[]} matched Present on both sides.
151
+ */
152
+
153
+ /**
154
+ * One declared policy field, compared against what GitHub reports.
155
+ * @typedef {object} PolicyFinding
156
+ * @property {string} path Dotted path into the `policy` block.
157
+ * @property {*} declared What the project declared.
158
+ * @property {*} observed What GitHub reports.
159
+ * @property {string} surface `repository` or `ruleset`.
160
+ * @property {string} field The name it is observed under.
161
+ */
162
+
163
+ /**
164
+ * Policy comparison, split by whether it agreed.
165
+ * @typedef {object} SettingsDrift
166
+ * @property {PolicyFinding[]} drift Fields that disagree.
167
+ * @property {PolicyFinding[]} matched Fields that agree.
168
+ * @property {string[]} unknown Declared paths this module cannot observe.
169
+ */
170
+
171
+ /**
172
+ * One step of a repair. `manual` steps are printed and never executed.
173
+ * @typedef {object} RepairAction
174
+ * @property {string} kind `contexts`, `settings`, or `manual`.
175
+ * @property {string} [message] What a human must do, for a `manual` step.
176
+ * @property {string} [ruleset] Name of the ruleset a `contexts` step writes.
177
+ * @property {*} [rulesetId] Its id.
178
+ * @property {string[]} [add] Contexts to start requiring.
179
+ * @property {string[]} [remove] Contexts to stop requiring.
180
+ * @property {Ruleset} [payload] The body a `contexts` step PUTs.
181
+ * @property {Record<string, *>} [fields] The body a `settings` step PATCHes.
182
+ * @property {string[]} [paths] Policy paths a `settings` step repairs.
183
+ */
184
+
185
+ /**
186
+ * What became of one planned action.
187
+ * @typedef {object} RepairOutcome
188
+ * @property {RepairAction} action The action attempted.
189
+ * @property {boolean} applied Whether GitHub accepted the write.
190
+ * @property {string|null} note The failure text, or a manual step's message.
191
+ */
192
+
193
+ /**
194
+ * A full reconciliation.
195
+ *
196
+ * `contexts` and `settings` are `null` — never empty — when the verdict is
197
+ * UNPROVEN, because empty sets are what a CLEAN repository looks like and a
198
+ * reader that cannot tell them apart reports a match it never measured.
199
+ * @typedef {object} Reconciliation
200
+ * @property {string|null} repo `OWNER/NAME`, or null when none resolved.
201
+ * @property {string} moment The moment contexts were derived for.
202
+ * @property {string[]} declared Contexts `contextsFor` derived.
203
+ * @property {string} onDrift The response in force.
204
+ * @property {boolean} dryRun Whether writing was suppressed.
205
+ * @property {boolean} prune Whether EXTRA contexts may be removed.
206
+ * @property {string} verdict One of `VERDICT`.
207
+ * @property {boolean|null} blocking Whether the measured drift is the kind a
208
+ * repair could converge, and therefore the kind `block` fails on. Null when
209
+ * nothing was measured, for the same reason the sets are.
210
+ * @property {Unproven|null} unproven Why nothing was measured, when nothing was.
211
+ * @property {ContextDrift|null} contexts Context comparison, or null.
212
+ * @property {SettingsDrift|null} settings Policy comparison, or null.
213
+ * @property {RepairAction[]} plan What would bring the repository back.
214
+ * @property {RepairOutcome[]} outcomes What was actually attempted.
215
+ */
216
+
217
+ /**
218
+ * Everything read from the repository in one pass.
219
+ * @typedef {object} LivePolicy
220
+ * @property {true} ok Discriminator: the read succeeded.
221
+ * @property {Ruleset[]} rulesets Branch-target rulesets, in full.
222
+ * @property {LiveContext[]} contexts Every required context they carry.
223
+ * @property {Record<string, *>} settings Repository settings.
224
+ * @property {Record<string, boolean>} signals Ruleset-surface policy.
225
+ */
226
+
227
+ /**
228
+ * A failed read. The discriminator is what keeps it from being mistaken for a
229
+ * clean `LivePolicy`.
230
+ * @typedef {object} FailedRead
231
+ * @property {false} ok Discriminator: nothing was measured.
232
+ * @property {Unproven} unproven Why.
233
+ */
234
+
235
+ /** The three states a reconciliation can end in. */
236
+ export const VERDICT = Object.freeze({
237
+ MATCHED: "matched",
238
+ DRIFT: "drift",
239
+ UNPROVEN: "unproven",
240
+ });
241
+
242
+ /**
243
+ * Why a reconciliation could not be performed.
244
+ *
245
+ * Separated by operator action, not by error class: a missing CLI, a missing
246
+ * login, and a repository that refused the read send someone to three different
247
+ * places.
248
+ */
249
+ export const UNPROVEN = Object.freeze({
250
+ NO_CLI: "gh-not-installed",
251
+ UNAUTHENTICATED: "gh-unauthenticated",
252
+ NO_REPO: "repository-unresolved",
253
+ API_ERROR: "api-error",
254
+ MALFORMED: "unreadable-response",
255
+ });
256
+
257
+ /** How to respond to measured drift. Mirrors `DRIFT_RESPONSES` in lisa-gates. */
258
+ export const ON_DRIFT = Object.freeze(["repair", "report", "block"]);
259
+
260
+ /** Fields GitHub rejects on a ruleset write. Same list the apply script strips. */
261
+ const READ_ONLY_RULESET_FIELDS = [
262
+ "id",
263
+ "source_type",
264
+ "source",
265
+ "node_id",
266
+ "created_at",
267
+ "updated_at",
268
+ "_links",
269
+ "current_user_can_bypass",
270
+ ];
271
+
272
+ /** GitHub Actions integration id, the default owner of a Lisa-derived context. */
273
+ const ACTIONS_INTEGRATION_ID = 15368;
274
+
275
+ /**
276
+ * Where each `policy` field is observed, and under what name.
277
+ *
278
+ * `surface` is load-bearing: `repository` fields are readable and writable
279
+ * through `PATCH /repos/{owner}/{repo}`, while `ruleset` fields are properties
280
+ * of a ruleset's rule shape, which this script reads but does not write.
281
+ *
282
+ * The keys are asserted to cover `POLICY_SCHEMA` exactly, so a policy field
283
+ * added upstream cannot quietly become one this script never compares — which
284
+ * would present as a clean reconciliation of a setting nobody looked at.
285
+ */
286
+ export const POLICY_SOURCES = Object.freeze({
287
+ "merge.squash": { surface: "repository", field: "allow_squash_merge" },
288
+ "merge.merge_commit": { surface: "repository", field: "allow_merge_commit" },
289
+ "merge.rebase": { surface: "repository", field: "allow_rebase_merge" },
290
+ "merge.auto_merge": { surface: "repository", field: "allow_auto_merge" },
291
+ "merge.delete_branch_on_merge": {
292
+ surface: "repository",
293
+ field: "delete_branch_on_merge",
294
+ },
295
+ "merge.allow_update_branch": {
296
+ surface: "repository",
297
+ field: "allow_update_branch",
298
+ },
299
+ "history.linear": { surface: "ruleset", field: "linear" },
300
+ "history.signed_commits": { surface: "ruleset", field: "signed_commits" },
301
+ "history.commit_signoff": {
302
+ surface: "repository",
303
+ field: "web_commit_signoff_required",
304
+ },
305
+ "protect.force_push": { surface: "ruleset", field: "force_push" },
306
+ "protect.deletion": { surface: "ruleset", field: "deletion" },
307
+ "protect.up_to_date_before_merge": {
308
+ surface: "ruleset",
309
+ field: "up_to_date_before_merge",
310
+ },
311
+ "protect.conversation_resolution": {
312
+ surface: "ruleset",
313
+ field: "conversation_resolution",
314
+ },
315
+ "protect.dismiss_stale_reviews": {
316
+ surface: "ruleset",
317
+ field: "dismiss_stale_reviews",
318
+ },
319
+ "protect.require_last_push_approval": {
320
+ surface: "ruleset",
321
+ field: "require_last_push_approval",
322
+ },
323
+ "repository.has_issues": { surface: "repository", field: "has_issues" },
324
+ "repository.has_wiki": { surface: "repository", field: "has_wiki" },
325
+ "repository.default_branch": {
326
+ surface: "repository",
327
+ field: "default_branch",
328
+ },
329
+ });
330
+
331
+ /**
332
+ * Run `gh` and report the outcome without throwing.
333
+ *
334
+ * A missing executable is reported as `missing` rather than as a failed call,
335
+ * because "gh is not installed here" and "gh said no" are different findings
336
+ * with different fixes, and collapsing them is how an unauthenticated machine
337
+ * gets told to install a CLI it already has.
338
+ * @param {string[]} args Arguments to `gh`.
339
+ * @param {object} [options] Runner options.
340
+ * @param {string} [options.input] Body to pipe to stdin.
341
+ * @returns {{ok: boolean, stdout: string, stderr: string, missing?: boolean}} Outcome.
342
+ */
343
+ export function ghRunner(args, options = {}) {
344
+ const result = spawnSync("gh", args, {
345
+ encoding: "utf8",
346
+ input: options.input,
347
+ });
348
+ if (result.error) {
349
+ const missing = result.error.code === "ENOENT";
350
+ return { ok: false, stdout: "", stderr: result.error.message, missing };
351
+ }
352
+ return {
353
+ ok: result.status === 0,
354
+ stdout: result.stdout ?? "",
355
+ stderr: result.stderr ?? "",
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Build an unproven result.
361
+ * @param {string} reason One of `UNPROVEN`.
362
+ * @param {string} detail Verbatim failure text, never summarised away.
363
+ * @param {string[]} [command] The `gh` arguments that produced it.
364
+ * @returns {FailedRead} Unproven.
365
+ */
366
+ function unproven(reason, detail, command = null) {
367
+ return {
368
+ ok: false,
369
+ unproven: {
370
+ reason,
371
+ detail: String(detail ?? "").trim(),
372
+ command: command ? `gh ${command.join(" ")}` : null,
373
+ },
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Call `gh` and parse its stdout as JSON, mapping every failure to UNPROVEN.
379
+ * @param {Function} gh The injected runner.
380
+ * @param {string[]} args Arguments to `gh`.
381
+ * @returns {{ok: true, value: *}|FailedRead} Parsed value, or unproven.
382
+ */
383
+ function ghJson(gh, args) {
384
+ let result;
385
+ try {
386
+ result = gh(args);
387
+ } catch (err) {
388
+ return unproven(UNPROVEN.API_ERROR, `gh threw: ${err.message}`, args);
389
+ }
390
+ if (result?.missing) {
391
+ return unproven(
392
+ UNPROVEN.NO_CLI,
393
+ result.stderr || "the gh executable was not found on PATH",
394
+ args
395
+ );
396
+ }
397
+ if (!result?.ok) {
398
+ const text = `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`.trim();
399
+ const authFailed =
400
+ /gh auth login|bad credentials|not logged|HTTP 401|requires authentication/iu.test(
401
+ text
402
+ );
403
+ return unproven(
404
+ authFailed ? UNPROVEN.UNAUTHENTICATED : UNPROVEN.API_ERROR,
405
+ text || "gh exited non-zero with no output",
406
+ args
407
+ );
408
+ }
409
+ try {
410
+ return { ok: true, value: JSON.parse(result.stdout) };
411
+ } catch (err) {
412
+ return unproven(
413
+ UNPROVEN.MALFORMED,
414
+ `could not parse the response as JSON: ${err.message}`,
415
+ args
416
+ );
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Reduce a repository's rulesets to the policy properties they assert.
422
+ *
423
+ * `force_push` reads `non_fast_forward` because that is the rule GitHub uses to
424
+ * mean "force pushes are refused"; the policy field states the protection, not
425
+ * the permission, so `true` means force pushing is blocked.
426
+ * @param {Ruleset[]} rulesets Full ruleset objects.
427
+ * @returns {Record<string, boolean>} Observed ruleset-surface policy.
428
+ */
429
+ export function rulesetSignals(rulesets) {
430
+ const signals = {
431
+ linear: false,
432
+ signed_commits: false,
433
+ force_push: false,
434
+ deletion: false,
435
+ up_to_date_before_merge: false,
436
+ conversation_resolution: false,
437
+ dismiss_stale_reviews: false,
438
+ require_last_push_approval: false,
439
+ };
440
+ for (const ruleset of rulesets ?? []) {
441
+ // An `evaluate`/`disabled` ruleset asserts nothing; counting it would make
442
+ // a dry-run ruleset read as live protection.
443
+ if ((ruleset?.enforcement ?? "active") !== "active") continue;
444
+ for (const rule of ruleset?.rules ?? []) {
445
+ const parameters = rule?.parameters ?? {};
446
+ if (rule?.type === "required_linear_history") signals.linear = true;
447
+ if (rule?.type === "required_signatures") signals.signed_commits = true;
448
+ if (rule?.type === "non_fast_forward") signals.force_push = true;
449
+ if (rule?.type === "deletion") signals.deletion = true;
450
+ if (
451
+ rule?.type === "required_status_checks" &&
452
+ parameters.strict_required_status_checks_policy
453
+ ) {
454
+ signals.up_to_date_before_merge = true;
455
+ }
456
+ if (rule?.type !== "pull_request") continue;
457
+ if (parameters.required_review_thread_resolution) {
458
+ signals.conversation_resolution = true;
459
+ }
460
+ if (parameters.dismiss_stale_reviews_on_push) {
461
+ signals.dismiss_stale_reviews = true;
462
+ }
463
+ if (parameters.require_last_push_approval) {
464
+ signals.require_last_push_approval = true;
465
+ }
466
+ }
467
+ }
468
+ return signals;
469
+ }
470
+
471
+ /**
472
+ * Every required status check the live rulesets carry, with its owner.
473
+ * @param {Ruleset[]} rulesets Full ruleset objects.
474
+ * @returns {LiveContext[]} Live contexts.
475
+ */
476
+ export function liveContexts(rulesets) {
477
+ const found = [];
478
+ for (const ruleset of rulesets ?? []) {
479
+ if ((ruleset?.enforcement ?? "active") !== "active") continue;
480
+ for (const rule of ruleset?.rules ?? []) {
481
+ if (rule?.type !== "required_status_checks") continue;
482
+ for (const check of rule?.parameters?.required_status_checks ?? []) {
483
+ if (typeof check?.context !== "string") continue;
484
+ found.push({
485
+ context: check.context,
486
+ integration_id: check.integration_id ?? null,
487
+ ruleset: ruleset.name,
488
+ rulesetId: ruleset.id,
489
+ });
490
+ }
491
+ }
492
+ }
493
+ return found;
494
+ }
495
+
496
+ /**
497
+ * Read the live ruleset and repository settings.
498
+ *
499
+ * Every failure path returns UNPROVEN rather than a partial reading. A ruleset
500
+ * index that was readable while a detail fetch failed still leaves the required
501
+ * list unknown, and half a list compared against a whole declaration reports
502
+ * drift that may not exist.
503
+ * @param {object} options Read inputs.
504
+ * @param {string} options.repo `OWNER/NAME`.
505
+ * @param {Function} options.gh Injected `gh` runner.
506
+ * @returns {LivePolicy|FailedRead} Live state, or why it could not be read.
507
+ */
508
+ export function readLivePolicy({ repo, gh }) {
509
+ const index = ghJson(gh, ["api", `repos/${repo}/rulesets`]);
510
+ if (!index.ok) return index;
511
+ if (!Array.isArray(index.value)) {
512
+ return unproven(
513
+ UNPROVEN.MALFORMED,
514
+ `expected an array of rulesets, got ${typeof index.value}`,
515
+ ["api", `repos/${repo}/rulesets`]
516
+ );
517
+ }
518
+
519
+ const rulesets = [];
520
+ for (const entry of index.value) {
521
+ if (entry?.target && entry.target !== "branch") continue;
522
+ const detail = ghJson(gh, ["api", `repos/${repo}/rulesets/${entry?.id}`]);
523
+ if (!detail.ok) return detail;
524
+ rulesets.push(detail.value);
525
+ }
526
+
527
+ const settings = ghJson(gh, ["api", `repos/${repo}`]);
528
+ if (!settings.ok) return settings;
529
+
530
+ return {
531
+ ok: true,
532
+ rulesets,
533
+ contexts: liveContexts(rulesets),
534
+ settings: settings.value ?? {},
535
+ signals: rulesetSignals(rulesets),
536
+ };
537
+ }
538
+
539
+ /**
540
+ * Compare declared contexts against live ones.
541
+ *
542
+ * Comparison is exact string equality, deliberately. A repository routinely
543
+ * carries confusable pairs — an app's required `SonarCloud Code Analysis`
544
+ * beside a not-required `SonarCloud SAST`, `🧹 Lint` beside `🐢 Slow Lint
545
+ * Rules` — and a fuzzy match raises a false alarm whose obvious fix is deleting
546
+ * the guard.
547
+ * @param {object} options Comparison inputs.
548
+ * @param {string[]} options.declared Contexts `contextsFor` derived.
549
+ * @param {LiveContext[]} options.live Contexts read from the rulesets.
550
+ * @returns {ContextDrift} Three sets.
551
+ */
552
+ export function reconcileContexts({ declared, live }) {
553
+ const liveNames = new Set((live ?? []).map(entry => entry.context));
554
+ const declaredNames = new Set(declared ?? []);
555
+ const byName = (a, b) => a.localeCompare(b);
556
+
557
+ return {
558
+ missing: [...declaredNames]
559
+ .filter(name => !liveNames.has(name))
560
+ .sort(byName),
561
+ extra: (live ?? [])
562
+ .filter(entry => !declaredNames.has(entry.context))
563
+ .sort((a, b) => a.context.localeCompare(b.context)),
564
+ matched: [...declaredNames]
565
+ .filter(name => liveNames.has(name))
566
+ .sort(byName),
567
+ };
568
+ }
569
+
570
+ /**
571
+ * Compare the declared `policy` block against observed repository state.
572
+ *
573
+ * Only declared fields are compared. A field the project did not declare has no
574
+ * expected value, and inventing one would report drift against Lisa's taste
575
+ * rather than against the project's decision.
576
+ * @param {object} options Comparison inputs.
577
+ * @param {object} options.policy The `policy` block.
578
+ * @param {{settings?: Record<string, *>, signals?: Record<string, boolean>}} options.live The two
579
+ * surfaces a policy field can be observed on. Narrower than `LivePolicy` on
580
+ * purpose: this is everything the comparison reads.
581
+ * @returns {SettingsDrift} Findings.
582
+ */
583
+ export function reconcileSettings({ policy, live }) {
584
+ const drift = [];
585
+ const matched = [];
586
+ const unknown = [];
587
+
588
+ for (const [section, fields] of Object.entries(policy ?? {})) {
589
+ if (section === "on_drift") continue;
590
+ if (!fields || typeof fields !== "object") continue;
591
+ for (const [field, declared] of Object.entries(fields)) {
592
+ const path = `${section}.${field}`;
593
+ const source = POLICY_SOURCES[path];
594
+ if (!source) {
595
+ unknown.push(path);
596
+ continue;
597
+ }
598
+ const observed =
599
+ source.surface === "repository"
600
+ ? live.settings?.[source.field]
601
+ : live.signals?.[source.field];
602
+ const finding = { path, declared, observed, ...source };
603
+ if (observed === declared) matched.push(finding);
604
+ else drift.push(finding);
605
+ }
606
+ }
607
+ return { drift, matched, unknown };
608
+ }
609
+
610
+ /**
611
+ * Choose the ruleset a context repair should write to.
612
+ *
613
+ * Ambiguity refuses rather than guesses. More than one shipped template carries
614
+ * a `required_status_checks` rule (`base` and `quality checks` both do), and
615
+ * writing to the wrong one puts a context where a different ref-name condition
616
+ * governs it — enforced somewhere other than where it was meant to be.
617
+ * Both keys are always present, one of them null. A discriminated union would
618
+ * read better in isolation and worse at every call site, which has to answer
619
+ * "did this refuse?" before it can do anything either way.
620
+ * @param {Ruleset[]} rulesets Live rulesets.
621
+ * @param {string|null} [named] An explicit `--ruleset` name.
622
+ * @returns {{ruleset: Ruleset|null, problem: string|null}} Target, or why not.
623
+ */
624
+ export function repairTarget(rulesets, named = null) {
625
+ if (named) {
626
+ const hit = (rulesets ?? []).find(entry => entry?.name === named) ?? null;
627
+ return {
628
+ ruleset: hit,
629
+ problem: hit ? null : `no ruleset named "${named}" on this repository`,
630
+ };
631
+ }
632
+ const carriers = (rulesets ?? []).filter(entry =>
633
+ (entry?.rules ?? []).some(rule => rule?.type === "required_status_checks")
634
+ );
635
+ if (carriers.length === 1) return { ruleset: carriers[0], problem: null };
636
+ if (carriers.length === 0) {
637
+ return {
638
+ ruleset: null,
639
+ problem:
640
+ "no ruleset requires any status check, so there is nothing to add to. " +
641
+ "Seed one with scripts/lisa-github-rulesets.sh first.",
642
+ };
643
+ }
644
+ return {
645
+ ruleset: null,
646
+ problem:
647
+ `${carriers.length} rulesets require status checks ` +
648
+ `(${carriers.map(entry => entry.name).join(", ")}); ` +
649
+ `pass --ruleset=<name> to say which one owns the derived contexts.`,
650
+ };
651
+ }
652
+
653
+ /**
654
+ * The contexts at this moment that some other app posts.
655
+ *
656
+ * An `await` gate names a signal Lisa does not produce — `CodeRabbit`,
657
+ * `SonarCloud Code Analysis`. The name travels separately from the derived list
658
+ * because by the time `contextsFor` has flattened both kinds to strings, the
659
+ * one fact a writer needs about them — who is allowed to post this — is gone.
660
+ * @param {object} gates The gates block.
661
+ * @param {string} moment The moment contexts were derived for.
662
+ * @returns {string[]} Awaited context names, required ones only.
663
+ */
664
+ export function awaitedContexts(gates, moment) {
665
+ return resolveMoment({ gates, moment })
666
+ .filter(gate => gate.level === "required" && gate.mode === "await")
667
+ .map(gate => gate.awaits)
668
+ .filter(Boolean);
669
+ }
670
+
671
+ /**
672
+ * Rewrite a ruleset's required contexts, returning a writable payload.
673
+ * @param {Ruleset} ruleset The live ruleset.
674
+ * @param {object} [options] Edit inputs.
675
+ * @param {string[]} [options.add] Contexts to require.
676
+ * @param {string[]} [options.remove] Contexts to stop requiring.
677
+ * @param {string[]} [options.awaited] Of `add`, the ones an external app posts.
678
+ * @returns {Ruleset} A payload with read-only fields stripped.
679
+ */
680
+ export function rulesetPayload(
681
+ ruleset,
682
+ { add = [], remove = [], awaited = [] } = {}
683
+ ) {
684
+ const payload = structuredClone(ruleset);
685
+ for (const field of READ_ONLY_RULESET_FIELDS) delete payload[field];
686
+
687
+ const rules = payload.rules ?? [];
688
+ const rule = rules.find(entry => entry?.type === "required_status_checks");
689
+ // Pinning the integration is what stops another writer satisfying a check
690
+ // Actions is supposed to post. Applied to an awaited context it does the
691
+ // opposite: it names the one app that will never post it, and the required
692
+ // check then blocks every pull request forever.
693
+ const additions = add.map(context =>
694
+ awaited.includes(context)
695
+ ? { context }
696
+ : { context, integration_id: ACTIONS_INTEGRATION_ID }
697
+ );
698
+
699
+ if (!rule) {
700
+ payload.rules = [
701
+ ...rules,
702
+ {
703
+ type: "required_status_checks",
704
+ parameters: {
705
+ strict_required_status_checks_policy: false,
706
+ do_not_enforce_on_create: true,
707
+ required_status_checks: additions,
708
+ },
709
+ },
710
+ ];
711
+ return payload;
712
+ }
713
+
714
+ const kept = (rule.parameters?.required_status_checks ?? []).filter(
715
+ check => !remove.includes(check?.context)
716
+ );
717
+ const present = new Set(kept.map(check => check?.context));
718
+ rule.parameters = {
719
+ ...rule.parameters,
720
+ required_status_checks: [
721
+ ...kept,
722
+ ...additions.filter(check => !present.has(check.context)),
723
+ ],
724
+ };
725
+ payload.rules = rules;
726
+ return payload;
727
+ }
728
+
729
+ /**
730
+ * The actions that would bring the repository back to its declaration.
731
+ *
732
+ * Built whether or not they will be executed, so `--dry-run` and `report` print
733
+ * exactly what `repair` would do rather than a description of it.
734
+ * @param {object} options Planning inputs.
735
+ * @param {ContextDrift} options.contexts Result of `reconcileContexts`.
736
+ * @param {SettingsDrift} options.settings Result of `reconcileSettings`.
737
+ * @param {LivePolicy} options.live Result of `readLivePolicy`.
738
+ * @param {boolean} options.prune Whether EXTRA contexts may be removed.
739
+ * @param {string|null} options.rulesetName Explicit target ruleset.
740
+ * @param {string[]} [options.awaited] Contexts an external app posts.
741
+ * @returns {RepairAction[]} Planned actions.
742
+ */
743
+ export function planRepairs({
744
+ contexts,
745
+ settings,
746
+ live,
747
+ prune,
748
+ rulesetName,
749
+ awaited = [],
750
+ }) {
751
+ const plan = [];
752
+ const removable = prune ? contexts.extra.map(entry => entry.context) : [];
753
+
754
+ if (contexts.missing.length || removable.length) {
755
+ const { ruleset, problem } = repairTarget(live.rulesets, rulesetName);
756
+ if (!ruleset) {
757
+ plan.push({ kind: "manual", message: problem });
758
+ } else {
759
+ plan.push({
760
+ kind: "contexts",
761
+ ruleset: ruleset.name,
762
+ rulesetId: ruleset.id,
763
+ add: contexts.missing,
764
+ remove: removable,
765
+ payload: rulesetPayload(ruleset, {
766
+ add: contexts.missing,
767
+ remove: removable,
768
+ awaited,
769
+ }),
770
+ });
771
+ }
772
+ }
773
+
774
+ // Rule (b): an EXTRA context is reported by name and left alone. Most of them
775
+ // are external apps Lisa never declares, and removing one silently strips a
776
+ // protection nobody asked to lose.
777
+ if (contexts.extra.length && !prune) {
778
+ plan.push({
779
+ kind: "manual",
780
+ message:
781
+ `${contexts.extra.length} required context(s) are live but not ` +
782
+ `declared: ${contexts.extra.map(entry => `"${entry.context}"`).join(", ")}. ` +
783
+ `They are NOT removed. Each is either an external app Lisa does not ` +
784
+ `manage (SonarCloud, GitGuardian, CodeRabbit) — in which case leave it ` +
785
+ `— or a gate that should be declared in .lisa.config.json. Re-run with ` +
786
+ `--prune only once you have decided, one by one, that each is neither.`,
787
+ });
788
+ }
789
+
790
+ const repositoryDrift = settings.drift.filter(
791
+ finding => finding.surface === "repository"
792
+ );
793
+ if (repositoryDrift.length) {
794
+ plan.push({
795
+ kind: "settings",
796
+ fields: Object.fromEntries(
797
+ repositoryDrift.map(finding => [finding.field, finding.declared])
798
+ ),
799
+ paths: repositoryDrift.map(finding => finding.path),
800
+ });
801
+ }
802
+
803
+ const rulesetDrift = settings.drift.filter(
804
+ finding => finding.surface === "ruleset"
805
+ );
806
+ if (rulesetDrift.length) {
807
+ plan.push({
808
+ kind: "manual",
809
+ message:
810
+ `${rulesetDrift.map(finding => finding.path).join(", ")} live in the ` +
811
+ `SHAPE of a ruleset's rules, which scripts/lisa-github-rulesets.sh ` +
812
+ `owns. Re-run that script to repair them; this one will not reshape ` +
813
+ `rules it did not build.`,
814
+ });
815
+ }
816
+ return plan;
817
+ }
818
+
819
+ /**
820
+ * Execute a plan. Never called under `--dry-run` or outside `repair`.
821
+ * @param {object} options Write inputs.
822
+ * @param {string} options.repo `OWNER/NAME`.
823
+ * @param {Function} options.gh Injected runner.
824
+ * @param {RepairAction[]} options.plan Planned actions.
825
+ * @returns {RepairOutcome[]} One outcome per action.
826
+ */
827
+ export function applyRepairs({ repo, gh, plan }) {
828
+ const outcomes = [];
829
+ for (const action of plan) {
830
+ if (action.kind === "manual") {
831
+ outcomes.push({ action, applied: false, note: action.message });
832
+ continue;
833
+ }
834
+ const [args, input] =
835
+ action.kind === "contexts"
836
+ ? [
837
+ [
838
+ "api",
839
+ "-X",
840
+ "PUT",
841
+ `repos/${repo}/rulesets/${action.rulesetId}`,
842
+ "--input",
843
+ "-",
844
+ ],
845
+ JSON.stringify(action.payload),
846
+ ]
847
+ : [
848
+ ["api", "-X", "PATCH", `repos/${repo}`, "--input", "-"],
849
+ JSON.stringify(action.fields),
850
+ ];
851
+ let result;
852
+ try {
853
+ result = gh(args, { input });
854
+ } catch (err) {
855
+ result = { ok: false, stdout: "", stderr: err.message };
856
+ }
857
+ outcomes.push({
858
+ action,
859
+ applied: Boolean(result?.ok),
860
+ note: result?.ok
861
+ ? null
862
+ : `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`.trim(),
863
+ });
864
+ }
865
+ return outcomes;
866
+ }
867
+
868
+ /**
869
+ * Reconcile declared configuration against the live repository.
870
+ * @param {object} options Reconciliation inputs.
871
+ * @param {string|null} options.repo `OWNER/NAME`, or null when none resolved —
872
+ * which is itself an UNPROVEN verdict, not an error.
873
+ * @param {object} [options.gates] The gates block.
874
+ * @param {object} [options.policy] The policy block.
875
+ * @param {Function} [options.gh] Injected `gh` runner.
876
+ * @param {string} [options.moment] Moment to derive contexts for.
877
+ * @param {string} [options.workflowName] Calling workflow name.
878
+ * @param {string[]} [options.previousLabels] Labels retired this release.
879
+ * @param {boolean} [options.dryRun] Never write, whatever `on_drift` says.
880
+ * @param {boolean} [options.prune] Allow removal of EXTRA contexts.
881
+ * @param {string} [options.onDrift] Override `policy.on_drift`.
882
+ * @param {string|null} [options.rulesetName] Explicit repair target.
883
+ * @param {boolean} [options.ghMissing] Whether resolving `repo` failed because
884
+ * `gh` is not installed, rather than because nothing named the repository.
885
+ * @returns {Reconciliation} The reconciliation result.
886
+ */
887
+ export function reconcile({
888
+ repo,
889
+ gates = {},
890
+ policy = {},
891
+ gh = ghRunner,
892
+ moment = "pull-request",
893
+ workflowName = "🔍 Quality Checks",
894
+ previousLabels = [],
895
+ dryRun = false,
896
+ prune = false,
897
+ onDrift = policy?.on_drift ?? "repair",
898
+ rulesetName = null,
899
+ ghMissing = false,
900
+ }) {
901
+ const declared = contextsFor(gates, { moment, workflowName, previousLabels });
902
+ const base = { repo, moment, declared, onDrift, dryRun, prune };
903
+
904
+ if (!repo) {
905
+ return {
906
+ ...base,
907
+ verdict: VERDICT.UNPROVEN,
908
+ blocking: null,
909
+ unproven: unproven(
910
+ ghMissing ? UNPROVEN.NO_CLI : UNPROVEN.NO_REPO,
911
+ ghMissing
912
+ ? "no OWNER/NAME was configured and `gh repo view` could not be " +
913
+ "asked, because the gh executable was not found on PATH"
914
+ : "no OWNER/NAME could be resolved from --repo, .lisa.config.json " +
915
+ "(github.org + github.repo), or `gh repo view`"
916
+ ).unproven,
917
+ contexts: null,
918
+ settings: null,
919
+ plan: [],
920
+ outcomes: [],
921
+ };
922
+ }
923
+
924
+ const live = readLivePolicy({ repo, gh });
925
+ if (!live.ok) {
926
+ // Rule (a): the sets are null, not empty. Empty is what clean looks like.
927
+ return {
928
+ ...base,
929
+ verdict: VERDICT.UNPROVEN,
930
+ blocking: null,
931
+ unproven: live.unproven,
932
+ contexts: null,
933
+ settings: null,
934
+ plan: [],
935
+ outcomes: [],
936
+ };
937
+ }
938
+
939
+ const contexts = reconcileContexts({ declared, live: live.contexts });
940
+ const settings = reconcileSettings({ policy, live });
941
+ const drifted =
942
+ contexts.missing.length > 0 ||
943
+ contexts.extra.length > 0 ||
944
+ settings.drift.length > 0;
945
+ // Every difference is DRIFT and every difference is reported. Only the part a
946
+ // repair could converge decides `block`, so the mode stays passable on a
947
+ // repository whose only EXTRA is a check this script refuses to remove.
948
+ const blocking =
949
+ contexts.missing.length > 0 ||
950
+ settings.drift.length > 0 ||
951
+ (prune && contexts.extra.length > 0);
952
+ const plan = drifted
953
+ ? planRepairs({
954
+ contexts,
955
+ settings,
956
+ live,
957
+ prune,
958
+ rulesetName,
959
+ awaited: awaitedContexts(gates, moment),
960
+ })
961
+ : [];
962
+ const outcomes =
963
+ drifted && onDrift === "repair" && !dryRun
964
+ ? applyRepairs({ repo, gh, plan })
965
+ : [];
966
+
967
+ return {
968
+ ...base,
969
+ verdict: drifted ? VERDICT.DRIFT : VERDICT.MATCHED,
970
+ blocking,
971
+ unproven: null,
972
+ contexts,
973
+ settings,
974
+ plan,
975
+ outcomes,
976
+ };
977
+ }
978
+
979
+ /**
980
+ * The process exit code for a result.
981
+ *
982
+ * UNPROVEN is 2 in every mode, including `report`. `on_drift` decides what to do
983
+ * about a drift that was measured, and an unproven run measured nothing — so
984
+ * letting `report` map it to 0 would be a mode that turns "I could not look"
985
+ * into "I looked and it was fine".
986
+ * @param {Reconciliation} result A reconciliation result.
987
+ * @returns {number} 0 matched or reported, 1 blocked or failed write, 2 unproven.
988
+ */
989
+ export function exitCodeFor(result) {
990
+ if (result.verdict === VERDICT.UNPROVEN) return 2;
991
+ if (result.verdict === VERDICT.MATCHED) return 0;
992
+ // A `manual` outcome is an instruction printed for a human, and `applyRepairs`
993
+ // records it as `applied: false` because nothing was written — not because a
994
+ // write was attempted and refused. Counting it as a failed write reports the
995
+ // routine EXTRA-context notice as a broken repair, which `render` already
996
+ // knows better than to do.
997
+ const writeFailed = result.outcomes.some(
998
+ outcome => outcome.action.kind !== "manual" && !outcome.applied
999
+ );
1000
+ if (writeFailed) return 1;
1001
+ return result.onDrift === "block" && result.blocking ? 1 : 0;
1002
+ }
1003
+
1004
+ /**
1005
+ * Render a result for a human.
1006
+ * @param {Reconciliation} result A reconciliation result.
1007
+ * @returns {string} The report.
1008
+ */
1009
+ export function render(result) {
1010
+ if (result.verdict === VERDICT.UNPROVEN) {
1011
+ return [
1012
+ `UNPROVEN — the policy was NOT checked. This is not "matches".`,
1013
+ ` reason: ${result.unproven.reason}`,
1014
+ result.unproven.command ? ` command: ${result.unproven.command}` : null,
1015
+ ` detail: ${result.unproven.detail}`,
1016
+ ]
1017
+ .filter(Boolean)
1018
+ .join("\n");
1019
+ }
1020
+
1021
+ const lines = [
1022
+ `${result.verdict.toUpperCase()} — ${result.repo} at ${result.moment}`,
1023
+ ` matched: ${result.contexts.matched.length} context(s)`,
1024
+ ];
1025
+ for (const name of result.contexts.missing) {
1026
+ lines.push(` MISSING ${name} (declared, not required on GitHub)`);
1027
+ }
1028
+ for (const entry of result.contexts.extra) {
1029
+ lines.push(
1030
+ ` EXTRA ${entry.context} (required by "${entry.ruleset}", not declared)`
1031
+ );
1032
+ }
1033
+ for (const finding of result.settings.drift) {
1034
+ lines.push(
1035
+ ` DRIFT ${finding.path}: declared ${JSON.stringify(finding.declared)}, ` +
1036
+ `live ${JSON.stringify(finding.observed)} [${finding.surface}]`
1037
+ );
1038
+ }
1039
+ for (const path of result.settings.unknown) {
1040
+ lines.push(` SKIPPED ${path}: Lisa does not know where to observe it`);
1041
+ }
1042
+ for (const action of result.plan) {
1043
+ lines.push(
1044
+ action.kind === "manual"
1045
+ ? ` ACTION ${action.message}`
1046
+ : ` PLAN ${describePlan(action)}`
1047
+ );
1048
+ }
1049
+ for (const outcome of result.outcomes) {
1050
+ if (outcome.action.kind === "manual") continue;
1051
+ lines.push(
1052
+ outcome.applied
1053
+ ? ` WROTE ${describePlan(outcome.action)}`
1054
+ : ` FAILED ${describePlan(outcome.action)}: ${outcome.note}`
1055
+ );
1056
+ }
1057
+ if (result.onDrift === "block" && result.verdict === VERDICT.DRIFT) {
1058
+ lines.push(
1059
+ result.blocking
1060
+ ? ` (on_drift=block: this run FAILS.)`
1061
+ : ` (on_drift=block: this run passes. The only difference is EXTRA ` +
1062
+ `context(s), which this script will not remove and which nothing ` +
1063
+ `declares — blocking on them would leave --prune as the only way ` +
1064
+ `out, and that deletes live protection.)`
1065
+ );
1066
+ }
1067
+ if (result.dryRun) lines.push(` (--dry-run: nothing was written)`);
1068
+ return lines.join("\n");
1069
+ }
1070
+
1071
+ /**
1072
+ * One line describing a planned write.
1073
+ * @param {RepairAction} action A non-manual plan action.
1074
+ * @returns {string} Description.
1075
+ */
1076
+ function describePlan(action) {
1077
+ if (action.kind === "contexts") {
1078
+ const parts = [];
1079
+ if (action.add.length) parts.push(`require ${action.add.join(", ")}`);
1080
+ if (action.remove.length)
1081
+ parts.push(`stop requiring ${action.remove.join(", ")}`);
1082
+ return `ruleset "${action.ruleset}": ${parts.join("; ")}`;
1083
+ }
1084
+ return `repository settings: ${action.paths.join(", ")}`;
1085
+ }
1086
+
1087
+ /**
1088
+ * Resolve `OWNER/NAME` from a flag, the config, or `gh`.
1089
+ *
1090
+ * Reports WHY it failed, not just that it did. A machine with no `gh` and no
1091
+ * `github` block in its config fails here first, and collapsing that into "no
1092
+ * repository could be resolved" sends the reader to edit a config file when the
1093
+ * fix is installing a CLI — the same conflation `ghRunner` exists to avoid one
1094
+ * layer down.
1095
+ * @param {string|null} flagged An explicit `--repo`.
1096
+ * @param {object} config Parsed `.lisa.config.json`.
1097
+ * @param {Function} gh Injected runner.
1098
+ * @returns {{repo: string|null, ghMissing: boolean}} The repository, or null
1099
+ * with the reason it stayed null.
1100
+ */
1101
+ export function resolveRepo(flagged, config, gh) {
1102
+ if (flagged) return { repo: flagged, ghMissing: false };
1103
+ const { org, repo } = config?.github ?? {};
1104
+ if (org && repo) return { repo: `${org}/${repo}`, ghMissing: false };
1105
+ let result;
1106
+ try {
1107
+ result = gh([
1108
+ "repo",
1109
+ "view",
1110
+ "--json",
1111
+ "nameWithOwner",
1112
+ "-q",
1113
+ ".nameWithOwner",
1114
+ ]);
1115
+ } catch {
1116
+ return { repo: null, ghMissing: false };
1117
+ }
1118
+ const name = result?.ok ? result.stdout.trim() : "";
1119
+ return { repo: name || null, ghMissing: Boolean(result?.missing) };
1120
+ }
1121
+
1122
+ /**
1123
+ * CLI entry point.
1124
+ */
1125
+ function main() {
1126
+ const argv = process.argv.slice(2);
1127
+ const flag = name => {
1128
+ const hit = argv.find(arg => arg.startsWith(`--${name}=`));
1129
+ return hit ? hit.slice(name.length + 3) : null;
1130
+ };
1131
+ const { gates, policy, config } = readGates();
1132
+ const onDrift = flag("on-drift") ?? policy.on_drift ?? "repair";
1133
+ if (!ON_DRIFT.includes(onDrift)) {
1134
+ throw new Error(`--on-drift must be one of ${ON_DRIFT.join(", ")}`);
1135
+ }
1136
+
1137
+ const { repo, ghMissing } = resolveRepo(flag("repo"), config, ghRunner);
1138
+ const result = reconcile({
1139
+ repo,
1140
+ ghMissing,
1141
+ gates,
1142
+ policy,
1143
+ gh: ghRunner,
1144
+ moment: flag("moment") ?? "pull-request",
1145
+ workflowName: flag("workflow") ?? "🔍 Quality Checks",
1146
+ previousLabels: (flag("previous") ?? "")
1147
+ .split(",")
1148
+ .map(entry => entry.trim())
1149
+ .filter(Boolean),
1150
+ dryRun: argv.includes("--dry-run"),
1151
+ prune: argv.includes("--prune"),
1152
+ onDrift,
1153
+ rulesetName: flag("ruleset"),
1154
+ });
1155
+
1156
+ console.log(
1157
+ argv.includes("--json") ? JSON.stringify(result, null, 2) : render(result)
1158
+ );
1159
+ // `process.exit` truncates a pending stdout write when stdout is a pipe, and
1160
+ // this report is long enough to still be buffered. Setting the code instead
1161
+ // lets Node flush and exit on its own — measured: exiting here cut the report
1162
+ // off mid-sentence, so a piped run lost the very EXTRA-context warning the
1163
+ // script exists to print.
1164
+ process.exitCode = exitCodeFor(result);
1165
+ }
1166
+
1167
+ if (invokedAsScript(import.meta.url)) {
1168
+ try {
1169
+ main();
1170
+ } catch (err) {
1171
+ console.error(err.message);
1172
+ process.exitCode = 1;
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Assert `POLICY_SOURCES` still covers `POLICY_SCHEMA`.
1178
+ *
1179
+ * Exported for the test suite rather than run at import time: a module that
1180
+ * throws on load takes the whole doctor down over a config-shape mismatch.
1181
+ * @returns {string[]} Policy paths Lisa declares but this script cannot observe.
1182
+ */
1183
+ export function unobservablePolicyFields() {
1184
+ const declared = Object.entries(POLICY_SCHEMA).flatMap(([section, fields]) =>
1185
+ Object.keys(fields).map(field => `${section}.${field}`)
1186
+ );
1187
+ return declared.filter(path => !Object.hasOwn(POLICY_SOURCES, path));
1188
+ }