@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
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @file Report a branch ruleset that governs no branch in the repository.
5
+ *
6
+ * GitHub accepts a `conditions.ref_name.include` entry naming a branch that
7
+ * does not exist. The entries are patterns, not references, so neither the API
8
+ * nor any Lisa surface validates them against the repository's actual heads. A
9
+ * ruleset can therefore be live, active, and matching nothing — and every
10
+ * surface reads it as healthy:
11
+ *
12
+ * - `health/ruleset-inspection`'s `compareRulesets` holds the template against
13
+ * the live ruleset by string equality. Template says `refs/heads/dev`, live
14
+ * says `refs/heads/dev`, so it is not drifted. It is a template-conformance
15
+ * check and cannot be a governance-reach check.
16
+ * - `cli/ui-github-repo-map`'s `mapRulesetRow` parses includes, but as a
17
+ * literal match on `~DEFAULT_BRANCH`, and its consumer ORs across rows. One
18
+ * governing row makes the whole answer green and masks every other row.
19
+ *
20
+ * Measured on this repository: a live ruleset included `refs/heads/dev` and
21
+ * `refs/heads/staging` while `git ls-remote --heads` listed 82 heads and not
22
+ * one of those two names. Harmless there only because `~DEFAULT_BRANCH` sat
23
+ * beside them. Where nothing sits beside them — a shipped ruleset whose whole
24
+ * include list is `["refs/heads/dev"]` — the gate governs nothing, and the
25
+ * operator finds out at the moment they are blocked. Moving that discovery
26
+ * earlier is the entire job of this module.
27
+ *
28
+ * ## Report only, and deliberately so
29
+ *
30
+ * Nothing here creates a branch, edits an include list, or disables a ruleset.
31
+ * Every one of those is an automated actor LOOSENING a control it was not
32
+ * asked to loosen: creating the branch manufactures the very ref the ruleset
33
+ * was written to protect, and disabling the ruleset gives up a protection
34
+ * somebody chose. What this owes the operator is the ruleset's name and the
35
+ * patterns that matched nothing — which is precisely what nothing gave them.
36
+ *
37
+ * ## Three answers, never two
38
+ *
39
+ * `governs` / `zero-reach` / `undetermined`. The third is not a soft second.
40
+ * An unreadable branch list, an unmodelled pattern, and a `~DEFAULT_BRANCH`
41
+ * whose default branch could not be resolved all mean the same thing: this run
42
+ * does not know. Collapsing that into `governs` reports a repository clean on
43
+ * the strength of a read that never happened; collapsing it into `zero-reach`
44
+ * sends an operator to investigate a ruleset that is probably fine, and a
45
+ * report an operator learns to ignore protects nothing.
46
+ *
47
+ * Usage:
48
+ * lisa-ruleset-reach.mjs --rulesets=FILE [--branches=FILE]
49
+ * [--default-branch=NAME] [--format=text|json]
50
+ * @module lisa-ruleset-reach
51
+ */
52
+
53
+ import { readFileSync } from "node:fs";
54
+
55
+ import { invokedAsScript } from "../all/copy-overwrite/scripts/lib/invoked-as-script.mjs";
56
+
57
+ /** GitHub's token for "every ref this target covers". */
58
+ const ALL_REFS = "~ALL";
59
+
60
+ /** GitHub's token for the repository's default branch. */
61
+ const DEFAULT_BRANCH_REF = "~DEFAULT_BRANCH";
62
+
63
+ /** The prefix a fully qualified branch pattern carries. */
64
+ const HEADS_PREFIX = "refs/heads/";
65
+
66
+ /** The only enforcement level that governs anything at merge time. */
67
+ const ACTIVE = "active";
68
+
69
+ /** The only ruleset target whose ref patterns name branches. */
70
+ const BRANCH_TARGET = "branch";
71
+
72
+ /**
73
+ * Glob metacharacters this module does not model.
74
+ *
75
+ * Bracket expressions and brace alternation are legal fnmatch, and a wrong
76
+ * expansion of either produces a false `zero-reach` — the one outcome that
77
+ * costs an operator a trip to a ruleset that is fine. A pattern carrying one
78
+ * is answered `undetermined` instead, which is what the third answer is for.
79
+ */
80
+ const UNMODELLED_GLOB = /[[\]{}]/u;
81
+
82
+ /**
83
+ * Every glob construct and regex metacharacter, in one alternation.
84
+ *
85
+ * One pass, deliberately. A chain of `.split().join()` calls needs a sentinel
86
+ * to hold a half-translated token, and a sentinel is a string that can appear
87
+ * in a branch name — so the translation would depend on what the repository
88
+ * happens to have named a branch. Matching the longest construct first makes
89
+ * the pass unambiguous without one.
90
+ */
91
+ const GLOB_TOKEN = /\/\*\*\/|\*\*|\*|\?|[.+^$()|\\]/gu;
92
+
93
+ /** What each construct becomes in the anchored matcher. */
94
+ const GLOB_TRANSLATION = Object.freeze({
95
+ "/**/": "/(?:.*/)?",
96
+ "**": ".*",
97
+ "*": "[^/]*",
98
+ "?": "[^/]",
99
+ });
100
+
101
+ /**
102
+ * Translate one fnmatch branch pattern into an anchored regular expression.
103
+ *
104
+ * `*` stops at a path separator and `**` crosses it, matching how GitHub
105
+ * documents ruleset ref patterns. A `/` `**` `/` run additionally collapses to
106
+ * nothing, so `releases/` `**` `/*` covers `releases/v1` as well as
107
+ * `releases/v1/hotfix` — the reading an author who wrote that pattern intends,
108
+ * and the one whose absence would report a governing ruleset as reaching
109
+ * nothing.
110
+ * @param {string} glob The pattern, without its `refs/heads/` prefix.
111
+ * @returns {RegExp} An anchored matcher.
112
+ */
113
+ export function globToRegExp(glob) {
114
+ const pattern = glob.replace(
115
+ GLOB_TOKEN,
116
+ token => GLOB_TRANSLATION[token] ?? `\\${token}`
117
+ );
118
+ return new RegExp(`^${pattern}$`, "u");
119
+ }
120
+
121
+ /**
122
+ * Whether one include/exclude entry matches one branch.
123
+ * @param {string} pattern One `conditions.ref_name` entry.
124
+ * @param {string} branch One branch name, unqualified.
125
+ * @param {string|undefined} defaultBranch The repository's default branch.
126
+ * @returns {boolean|null} The verdict, or null when it cannot be determined.
127
+ */
128
+ export function refPatternMatches(pattern, branch, defaultBranch) {
129
+ if (typeof pattern !== "string" || pattern.length === 0) return null;
130
+ if (pattern === ALL_REFS) return true;
131
+ if (pattern === DEFAULT_BRANCH_REF) {
132
+ return defaultBranch === undefined ? null : branch === defaultBranch;
133
+ }
134
+ // Any other `~` token is one GitHub added and this module has never seen.
135
+ // Guessing at it in either direction is a claim this run cannot support.
136
+ if (pattern.startsWith("~")) return null;
137
+ if (!pattern.startsWith(HEADS_PREFIX)) return null;
138
+ const glob = pattern.slice(HEADS_PREFIX.length);
139
+ return UNMODELLED_GLOB.test(glob) ? null : globToRegExp(glob).test(branch);
140
+ }
141
+
142
+ /**
143
+ * Whether any entry matches, preserving "cannot tell" over "no".
144
+ * @param {readonly string[]} patterns The entries to test.
145
+ * @param {string} branch One branch name.
146
+ * @param {string|undefined} defaultBranch The repository's default branch.
147
+ * @returns {boolean|null} True on a match, null when any entry is unmodelled.
148
+ */
149
+ function anyMatches(patterns, branch, defaultBranch) {
150
+ const verdicts = patterns.map(pattern =>
151
+ refPatternMatches(pattern, branch, defaultBranch)
152
+ );
153
+ if (verdicts.includes(true)) return true;
154
+ return verdicts.includes(null) ? null : false;
155
+ }
156
+
157
+ /**
158
+ * Read one ruleset's include and exclude entries.
159
+ * @param {object} ruleset One detailed ruleset payload.
160
+ * @returns {{include: unknown[], exclude: unknown[]}|null} The entries, or null
161
+ * when the conditions could not be read as a ref-name condition.
162
+ */
163
+ function refConditions(ruleset) {
164
+ const conditions = ruleset.conditions;
165
+ if (conditions === null || typeof conditions !== "object") return null;
166
+ const refName = conditions.ref_name;
167
+ if (refName === null || typeof refName !== "object") return null;
168
+ const include = refName.include;
169
+ const exclude = refName.exclude;
170
+ if (!Array.isArray(include)) return null;
171
+ if (exclude !== undefined && !Array.isArray(exclude)) return null;
172
+ return { include, exclude: Array.isArray(exclude) ? exclude : [] };
173
+ }
174
+
175
+ /**
176
+ * Build one classification answer.
177
+ * @param {string} name The ruleset name.
178
+ * @param {string} verdict One of the reach verdicts.
179
+ * @param {readonly string[]} patterns The include patterns, when read.
180
+ * @param {readonly string[]} matched The branches governed, when known.
181
+ * @param {string} reason Why, for every verdict but `governs`.
182
+ * @returns {{name: string, verdict: string, patterns: readonly string[],
183
+ * matched: readonly string[], reason: string}} The answer.
184
+ */
185
+ function answer(name, verdict, patterns, matched, reason) {
186
+ return { name, verdict, patterns, matched, reason };
187
+ }
188
+
189
+ /**
190
+ * Classify a ruleset on its own shape alone, before any branch is consulted.
191
+ *
192
+ * A disabled ruleset governing nothing is a decision somebody made, not a
193
+ * defect, and a tag-target ruleset's ref patterns name tags — comparing either
194
+ * against a branch list would manufacture a finding.
195
+ * @param {object} ruleset One detailed ruleset payload.
196
+ * @param {string} name The ruleset's validated name.
197
+ * @returns {object|null} A terminal answer, or null to keep going.
198
+ */
199
+ function shapeVerdict(ruleset, name) {
200
+ const target = ruleset.target ?? BRANCH_TARGET;
201
+ if (typeof target !== "string") {
202
+ return answer(name, "undetermined", [], [], "its target could not be read");
203
+ }
204
+ if (target !== BRANCH_TARGET) {
205
+ return answer(name, "not-branch-target", [], [], `its target is ${target}`);
206
+ }
207
+ if (typeof ruleset.enforcement !== "string") {
208
+ return answer(
209
+ name,
210
+ "undetermined",
211
+ [],
212
+ [],
213
+ "its enforcement could not be read"
214
+ );
215
+ }
216
+ return ruleset.enforcement === ACTIVE
217
+ ? null
218
+ : answer(
219
+ name,
220
+ "inactive",
221
+ [],
222
+ [],
223
+ `its enforcement is ${ruleset.enforcement}`
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Classify the repository state the patterns are about to be tested against.
229
+ * @param {string} name The ruleset's validated name.
230
+ * @param {readonly string[]} patterns The include patterns.
231
+ * @param {readonly string[]|undefined} branches Every branch, or undefined.
232
+ * @param {string|undefined} defaultBranch The default branch name.
233
+ * @returns {object|null} A terminal answer, or null to keep going.
234
+ */
235
+ function inputVerdict(name, patterns, branches, defaultBranch) {
236
+ if (!Array.isArray(branches)) {
237
+ return answer(
238
+ name,
239
+ "undetermined",
240
+ patterns,
241
+ [],
242
+ "the repository's branches were not read"
243
+ );
244
+ }
245
+ if (branches.length === 0) {
246
+ return answer(
247
+ name,
248
+ "undetermined",
249
+ patterns,
250
+ [],
251
+ "no branch was readable, which is not the same as a repository with none"
252
+ );
253
+ }
254
+ // A default branch absent from the branch list means the list this run holds
255
+ // is not the repository's. Testing `~DEFAULT_BRANCH` against it would answer
256
+ // "matches nothing" from an input already known to be wrong.
257
+ return defaultBranch === undefined || branches.includes(defaultBranch)
258
+ ? null
259
+ : answer(
260
+ name,
261
+ "undetermined",
262
+ patterns,
263
+ [],
264
+ "the default branch is missing from the branch list this run read"
265
+ );
266
+ }
267
+
268
+ /**
269
+ * The branches one include/exclude pair actually governs.
270
+ * @param {object} input The patterns and repository state.
271
+ * @param {readonly string[]} input.patterns Include entries.
272
+ * @param {readonly unknown[]} input.exclude Exclude entries.
273
+ * @param {readonly string[]} input.branches Every branch the repository has.
274
+ * @param {string|undefined} input.defaultBranch The default branch name.
275
+ * @returns {{matched: readonly string[]}|{reason: string}} The governed
276
+ * branches, or why this run cannot tell.
277
+ */
278
+ function governedBranches({ patterns, exclude, branches, defaultBranch }) {
279
+ const included = branches.map(branch => ({
280
+ branch,
281
+ verdict: anyMatches(patterns, branch, defaultBranch),
282
+ }));
283
+ if (included.some(entry => entry.verdict === null)) {
284
+ return {
285
+ reason: "an include entry uses a pattern this check does not model",
286
+ };
287
+ }
288
+ const candidates = included
289
+ .filter(entry => entry.verdict === true)
290
+ .map(entry => ({
291
+ branch: entry.branch,
292
+ verdict: anyMatches(exclude, entry.branch, defaultBranch),
293
+ }));
294
+ if (candidates.some(entry => entry.verdict === null)) {
295
+ return {
296
+ reason: "an exclude entry uses a pattern this check does not model",
297
+ };
298
+ }
299
+ return {
300
+ matched: candidates
301
+ .filter(entry => entry.verdict === false)
302
+ .map(entry => entry.branch),
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Classify what one ruleset actually governs.
308
+ * @param {object} input The ruleset and the repository state to test it
309
+ * against.
310
+ * @param {object} input.ruleset One detailed ruleset payload.
311
+ * @param {readonly string[]|undefined} input.branches Every branch the
312
+ * repository has. `undefined` means unread, which is never "none".
313
+ * @param {string|undefined} input.defaultBranch The default branch name.
314
+ * @returns {{name: string, verdict: string, patterns: readonly string[],
315
+ * matched: readonly string[], reason: string}} The classification.
316
+ */
317
+ export function rulesetReach({ ruleset, branches, defaultBranch }) {
318
+ if (ruleset === null || typeof ruleset !== "object") {
319
+ return answer("", "undetermined", [], [], "the ruleset was not an object");
320
+ }
321
+ const name = typeof ruleset.name === "string" ? ruleset.name : "";
322
+ if (name.length === 0) {
323
+ return answer(
324
+ "",
325
+ "undetermined",
326
+ [],
327
+ [],
328
+ "the ruleset payload has no name"
329
+ );
330
+ }
331
+ const shape = shapeVerdict(ruleset, name);
332
+ if (shape !== null) return shape;
333
+ const conditions = refConditions(ruleset);
334
+ if (conditions === null) {
335
+ return answer(
336
+ name,
337
+ "undetermined",
338
+ [],
339
+ [],
340
+ "its ref-name conditions could not be read"
341
+ );
342
+ }
343
+ const patterns = conditions.include.filter(one => typeof one === "string");
344
+ if (patterns.length !== conditions.include.length) {
345
+ return answer(
346
+ name,
347
+ "undetermined",
348
+ patterns,
349
+ [],
350
+ "an include entry was not a string"
351
+ );
352
+ }
353
+ const inputs = inputVerdict(name, patterns, branches, defaultBranch);
354
+ if (inputs !== null) return inputs;
355
+ const governed = governedBranches({
356
+ patterns,
357
+ exclude: conditions.exclude,
358
+ branches,
359
+ defaultBranch,
360
+ });
361
+ if (governed.matched === undefined) {
362
+ return answer(name, "undetermined", patterns, [], governed.reason);
363
+ }
364
+ return governed.matched.length === 0
365
+ ? answer(
366
+ name,
367
+ "zero-reach",
368
+ patterns,
369
+ [],
370
+ "no branch in this repository matches its include patterns"
371
+ )
372
+ : answer(name, "governs", patterns, governed.matched, "");
373
+ }
374
+
375
+ /**
376
+ * Classify every ruleset a repository holds.
377
+ * @param {object} input The repository state.
378
+ * @param {readonly object[]} input.rulesets Detailed ruleset payloads.
379
+ * @param {readonly string[]|undefined} input.branches Every branch, or
380
+ * undefined when they were not read.
381
+ * @param {string|undefined} input.defaultBranch The default branch name.
382
+ * @returns {{zeroReach: object[], undetermined: object[], governing: object[]}}
383
+ * The classification, grouped by what an operator has to act on.
384
+ */
385
+ export function sweepRulesetReach({ rulesets, branches, defaultBranch }) {
386
+ const classified = (Array.isArray(rulesets) ? rulesets : []).map(ruleset =>
387
+ rulesetReach({ ruleset, branches, defaultBranch })
388
+ );
389
+ return {
390
+ zeroReach: classified.filter(item => item.verdict === "zero-reach"),
391
+ undetermined: classified.filter(item => item.verdict === "undetermined"),
392
+ governing: classified.filter(item => item.verdict === "governs"),
393
+ };
394
+ }
395
+
396
+ /** The heading a zero-reach report opens with, matched on by its tests. */
397
+ export const ZERO_REACH_HEADING =
398
+ "RULESETS THAT GOVERN NO BRANCH — these are active and match nothing, so what they require is not required anywhere:";
399
+
400
+ /**
401
+ * Render the operator-facing report for one sweep.
402
+ *
403
+ * Empty when there is nothing to say. A sweep that found every ruleset
404
+ * governing something prints NOTHING, which is what keeps the report worth
405
+ * reading on the run where it does print.
406
+ * @param {{zeroReach: readonly object[], undetermined: readonly object[]}} sweep
407
+ * One sweep.
408
+ * @returns {string} The report, or the empty string.
409
+ */
410
+ export function renderReachReport(sweep) {
411
+ const zero =
412
+ sweep.zeroReach.length === 0
413
+ ? []
414
+ : [
415
+ ZERO_REACH_HEADING,
416
+ ...sweep.zeroReach.map(
417
+ item =>
418
+ ` ruleset '${item.name}' matches no branch; its include patterns are: ${item.patterns.join(", ")}`
419
+ ),
420
+ "",
421
+ " Lisa changed nothing above: creating the branch and disabling the ruleset would both LOOSEN a control nobody asked to loosen.",
422
+ " Either point the ruleset at a branch this repository has, or retire it deliberately.",
423
+ ];
424
+ const unknown = sweep.undetermined.map(
425
+ item =>
426
+ ` ruleset '${item.name === "" ? "(unnamed)" : item.name}' was NOT checked for reach — ${item.reason}. This is not a clean result.`
427
+ );
428
+ const lines = [...zero, ...unknown];
429
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
430
+ }
431
+
432
+ /**
433
+ * Read one file, answering undefined rather than throwing.
434
+ * @param {string} file The path to read.
435
+ * @returns {string|undefined} The contents, or undefined when unreadable.
436
+ */
437
+ function readTextOrUndefined(file) {
438
+ try {
439
+ return readFileSync(file, "utf8");
440
+ } catch {
441
+ return undefined;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Parse a JSON array of strings, answering undefined rather than throwing.
447
+ * @param {string} text The candidate JSON.
448
+ * @returns {string[]|undefined} The names, or undefined when unparseable.
449
+ */
450
+ function parseJsonNames(text) {
451
+ try {
452
+ const parsed = JSON.parse(text);
453
+ return Array.isArray(parsed)
454
+ ? parsed.filter(entry => typeof entry === "string")
455
+ : undefined;
456
+ } catch {
457
+ return undefined;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Read newline- or JSON-delimited branch names from one file.
463
+ * @param {string|undefined} file The file path, when one was given.
464
+ * @returns {string[]|undefined} The names, or undefined when unread.
465
+ */
466
+ function readBranchFile(file) {
467
+ if (file === undefined) return undefined;
468
+ const text = readTextOrUndefined(file);
469
+ if (text === undefined) return undefined;
470
+ const trimmed = text.trim();
471
+ if (trimmed.startsWith("[")) return parseJsonNames(trimmed);
472
+ return trimmed.length === 0 ? [] : trimmed.split("\n").map(one => one.trim());
473
+ }
474
+
475
+ /** Read the flags, sweep, and print the report. */
476
+ function main() {
477
+ const flag = name => {
478
+ const found = process.argv.find(one => one.startsWith(`--${name}=`));
479
+ return found === undefined ? undefined : found.slice(name.length + 3);
480
+ };
481
+ const rulesetFile = flag("rulesets");
482
+ if (rulesetFile === undefined) {
483
+ throw new Error("--rulesets=FILE is required");
484
+ }
485
+ const defaultBranch = flag("default-branch");
486
+ const sweep = sweepRulesetReach({
487
+ rulesets: JSON.parse(readFileSync(rulesetFile, "utf8")),
488
+ branches: readBranchFile(flag("branches")),
489
+ defaultBranch:
490
+ defaultBranch === undefined || defaultBranch === ""
491
+ ? undefined
492
+ : defaultBranch,
493
+ });
494
+ process.stdout.write(
495
+ flag("format") === "json"
496
+ ? `${JSON.stringify(sweep, null, 2)}\n`
497
+ : renderReachReport(sweep)
498
+ );
499
+ }
500
+
501
+ // Not `import.meta.url === process.argv[1]`: Node resolves the module URL
502
+ // through realpath while argv[1] keeps whatever spelling the caller typed, so
503
+ // on a symlinked path — a macOS temp dir, a git worktree — they differ and
504
+ // main() never runs, printing nothing and exiting 0.
505
+ if (invokedAsScript(import.meta.url)) {
506
+ try {
507
+ main();
508
+ } catch (err) {
509
+ console.error(err.message);
510
+ process.exitCode = 1;
511
+ }
512
+ }