@codyswann/lisa 4.13.2 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/lisa-owned-hash-ledger.d.ts +17 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +601 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/lisa-owned-provenance.d.ts +30 -0
- package/dist/core/lisa-owned-provenance.d.ts.map +1 -1
- package/dist/core/lisa-owned-provenance.js +28 -1
- package/dist/core/lisa-owned-provenance.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +9 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/health/deterministic.d.ts +3 -0
- package/dist/health/deterministic.d.ts.map +1 -1
- package/dist/health/deterministic.js +6 -0
- package/dist/health/deterministic.js.map +1 -1
- package/dist/health/ruleset-inspection.d.ts +14 -0
- package/dist/health/ruleset-inspection.d.ts.map +1 -1
- package/dist/health/ruleset-inspection.js +19 -8
- package/dist/health/ruleset-inspection.js.map +1 -1
- package/dist/health/ruleset-reach-inspection.d.ts +68 -0
- package/dist/health/ruleset-reach-inspection.d.ts.map +1 -0
- package/dist/health/ruleset-reach-inspection.js +209 -0
- package/dist/health/ruleset-reach-inspection.js.map +1 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +31 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +31 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +31 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +31 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +31 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +31 -0
- package/scripts/generate-lisa-owned-hash-ledger.mjs +455 -64
- package/scripts/lisa-github-rulesets.sh +149 -29
- 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
|
+
}
|