@davidvornholt/standards 0.17.3 → 0.19.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.
@@ -10,13 +10,20 @@ import { isRecord } from './github-settings-parse';
10
10
 
11
11
  export const MANIFEST_STATE_BYTES = 32;
12
12
 
13
- // Root-credential ceiling: everything the broker may ever delegate. Actual
14
- // grants are narrowed per installation token at mint time.
15
- const DEFAULT_PERMISSIONS = Object.fromEntries(
16
- 'administration actions contents issues pull_requests secrets workflows'
17
- .split(' ')
18
- .map((permission) => [permission, 'write']),
19
- );
13
+ // Root-credential ceiling: everything the broker may ever delegate. The
14
+ // `permission-*` inputs a workflow passes when minting are a request, not a
15
+ // constraint — anything holding the durable private key can request the whole
16
+ // ceiling — so this, not those inputs, is the blast radius of the App identity
17
+ // sitting in every consumer's secrets/ci.yaml. It therefore lists only grants a
18
+ // workflow actually uses today; widening it is a deliberate edit here plus a
19
+ // settings-page change on the existing App, not a default to grow into.
20
+ const DEFAULT_PERMISSIONS = {
21
+ actions: 'read',
22
+ contents: 'write',
23
+ issues: 'write',
24
+ metadata: 'read',
25
+ pull_requests: 'write',
26
+ } as const;
20
27
 
21
28
  export const buildAppManifest = (
22
29
  name: string,
@@ -0,0 +1,139 @@
1
+ export type CharacterMatcher =
2
+ | { readonly kind: 'literal'; readonly value: number }
3
+ | { readonly kind: 'non-slash' }
4
+ | {
5
+ readonly kind: 'class';
6
+ readonly ranges: ReadonlyArray<readonly [number, number]>;
7
+ readonly negated: boolean;
8
+ };
9
+
10
+ type CharacterRange = readonly [number, number];
11
+
12
+ type ClassValue = {
13
+ readonly escaped: boolean;
14
+ readonly value: number;
15
+ };
16
+
17
+ const BEFORE_SLASH = '/'.codePointAt(0) ?? 0;
18
+ const AFTER_SLASH = BEFORE_SLASH + 1;
19
+ const HYPHEN = '-'.codePointAt(0) ?? 0;
20
+ const MAXIMUM_CODE_POINT = 0x10_ff_ff;
21
+
22
+ const NON_SLASH_RANGES: ReadonlyArray<CharacterRange> = [
23
+ [0, BEFORE_SLASH - 1],
24
+ [AFTER_SLASH, MAXIMUM_CODE_POINT],
25
+ ];
26
+
27
+ const intersectRanges = (
28
+ left: ReadonlyArray<CharacterRange>,
29
+ right: ReadonlyArray<CharacterRange>,
30
+ ): ReadonlyArray<CharacterRange> => {
31
+ const intersection: Array<CharacterRange> = [];
32
+ for (const [leftStart, leftEnd] of left) {
33
+ for (const [rightStart, rightEnd] of right) {
34
+ const start = Math.max(leftStart, rightStart);
35
+ const end = Math.min(leftEnd, rightEnd);
36
+ if (start <= end) {
37
+ intersection.push([start, end]);
38
+ }
39
+ }
40
+ }
41
+ return intersection;
42
+ };
43
+
44
+ const subtractRanges = (
45
+ ranges: ReadonlyArray<CharacterRange>,
46
+ excluded: ReadonlyArray<CharacterRange>,
47
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Range subtraction keeps the finite character-class intersection exact.
48
+ ): ReadonlyArray<CharacterRange> => {
49
+ let remaining = [...ranges];
50
+ for (const [excludedStart, excludedEnd] of excluded) {
51
+ const next: Array<CharacterRange> = [];
52
+ for (const [start, end] of remaining) {
53
+ if (excludedEnd < start || excludedStart > end) {
54
+ next.push([start, end]);
55
+ } else {
56
+ if (start < excludedStart) {
57
+ next.push([start, excludedStart - 1]);
58
+ }
59
+ if (excludedEnd < end) {
60
+ next.push([excludedEnd + 1, end]);
61
+ }
62
+ }
63
+ }
64
+ remaining = next;
65
+ }
66
+ return remaining;
67
+ };
68
+
69
+ const matcherRanges = (
70
+ matcher: CharacterMatcher,
71
+ ): ReadonlyArray<CharacterRange> => {
72
+ if (matcher.kind === 'literal') {
73
+ return [[matcher.value, matcher.value]];
74
+ }
75
+ if (matcher.kind === 'non-slash') {
76
+ return NON_SLASH_RANGES;
77
+ }
78
+ return matcher.negated
79
+ ? subtractRanges(NON_SLASH_RANGES, matcher.ranges)
80
+ : intersectRanges(NON_SLASH_RANGES, matcher.ranges);
81
+ };
82
+
83
+ export const matchersIntersect = (
84
+ left: CharacterMatcher,
85
+ right: CharacterMatcher,
86
+ ): boolean =>
87
+ intersectRanges(matcherRanges(left), matcherRanges(right)).length > 0;
88
+
89
+ export const parseCharacterClass = (
90
+ characters: ReadonlyArray<string>,
91
+ start: number,
92
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby character classes require escape, negation, and range state in one parser.
93
+ ): { readonly end: number; readonly matcher: CharacterMatcher } | null => {
94
+ let cursor = start + 1;
95
+ const negated = characters[cursor] === '!' || characters[cursor] === '^';
96
+ if (negated) {
97
+ cursor += 1;
98
+ }
99
+ const values: Array<ClassValue> = [];
100
+ for (
101
+ ;
102
+ cursor < characters.length && characters[cursor] !== ']';
103
+ cursor += 1
104
+ ) {
105
+ let escaped = false;
106
+ if (characters[cursor] === '\\' && characters[cursor + 1] !== undefined) {
107
+ cursor += 1;
108
+ escaped = true;
109
+ }
110
+ values.push({
111
+ escaped,
112
+ value: characters[cursor]?.codePointAt(0) ?? 0,
113
+ });
114
+ }
115
+ if (characters[cursor] !== ']') {
116
+ return null;
117
+ }
118
+ const ranges: Array<CharacterRange> = [];
119
+ for (let index = 0; index < values.length; index += 1) {
120
+ const startValue = values[index]?.value ?? 0;
121
+ const separator = values[index + 1];
122
+ const endValue = values[index + 2]?.value ?? 0;
123
+ if (
124
+ index + 2 < values.length &&
125
+ separator?.value === HYPHEN &&
126
+ !separator.escaped
127
+ ) {
128
+ if (startValue <= endValue) {
129
+ ranges.push([startValue, endValue]);
130
+ } else {
131
+ ranges.push([startValue, startValue], [endValue, endValue]);
132
+ }
133
+ index += 2;
134
+ } else {
135
+ ranges.push([startValue, startValue]);
136
+ }
137
+ }
138
+ return { end: cursor, matcher: { kind: 'class', ranges, negated } };
139
+ };
@@ -0,0 +1,33 @@
1
+ const applySegment = (
2
+ segments: Array<string>,
3
+ segment: string,
4
+ absolute: boolean,
5
+ ): void => {
6
+ if (segment !== '' && segment !== '.') {
7
+ if (segment !== '..') {
8
+ segments.push(segment);
9
+ } else if (segments.at(-1) !== undefined && segments.at(-1) !== '..') {
10
+ segments.pop();
11
+ } else if (!absolute) {
12
+ segments.push(segment);
13
+ }
14
+ }
15
+ };
16
+
17
+ export const normalizeDependabotDirectory = (directory: string): string => {
18
+ const absolute = directory.startsWith('/');
19
+ const segments: Array<string> = [];
20
+ for (const segment of directory.split('/')) {
21
+ applySegment(segments, segment, absolute);
22
+ }
23
+ let normalized = segments.join('/');
24
+ if (normalized === '') {
25
+ normalized = absolute ? '/' : '.';
26
+ }
27
+ return normalized.startsWith('/') ? normalized : `/${normalized}`;
28
+ };
29
+
30
+ export const isDependabotGlob = (directory: string): boolean =>
31
+ directory.includes('*') ||
32
+ directory.includes('?') ||
33
+ (directory.includes('[') && directory.includes(']'));
@@ -0,0 +1,139 @@
1
+ import {
2
+ type CharacterMatcher,
3
+ matchersIntersect,
4
+ parseCharacterClass,
5
+ } from './dependabot-directory-character-class';
6
+
7
+ type Edge = {
8
+ readonly from: number;
9
+ readonly to: number;
10
+ readonly matcher: CharacterMatcher | null;
11
+ };
12
+
13
+ type Automaton = {
14
+ readonly accept: number;
15
+ readonly edges: ReadonlyArray<Edge>;
16
+ };
17
+
18
+ const compileDirectory = (
19
+ directory: string,
20
+ supportsGlobs: boolean,
21
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Ruby glob tokens have distinct NFA transitions that are clearest in one ordered parser.
22
+ ): Automaton => {
23
+ const characters = [...directory];
24
+ const edges: Array<Edge> = [];
25
+ let current = 0;
26
+ let states = 1;
27
+ const append = (matcher: CharacterMatcher): void => {
28
+ const next = states;
29
+ states += 1;
30
+ edges.push({ from: current, to: next, matcher });
31
+ current = next;
32
+ };
33
+
34
+ for (let index = 0; index < characters.length; index += 1) {
35
+ const character = characters[index];
36
+ if (!supportsGlobs) {
37
+ append({ kind: 'literal', value: character?.codePointAt(0) ?? 0 });
38
+ } else if (character === '\\' && characters[index + 1] !== undefined) {
39
+ index += 1;
40
+ append({
41
+ kind: 'literal',
42
+ value: characters[index]?.codePointAt(0) ?? 0,
43
+ });
44
+ } else if (
45
+ character === '*' &&
46
+ characters[index + 1] === '*' &&
47
+ characters[index + 2] === '/' &&
48
+ (index === 0 || characters[index - 1] === '/')
49
+ ) {
50
+ const next = states;
51
+ const insideDirectory = states + 1;
52
+ states += 2;
53
+ edges.push(
54
+ { from: current, to: next, matcher: null },
55
+ { from: current, to: insideDirectory, matcher: { kind: 'non-slash' } },
56
+ {
57
+ from: insideDirectory,
58
+ to: insideDirectory,
59
+ matcher: { kind: 'non-slash' },
60
+ },
61
+ {
62
+ from: insideDirectory,
63
+ to: current,
64
+ matcher: { kind: 'literal', value: '/'.codePointAt(0) ?? 0 },
65
+ },
66
+ );
67
+ current = next;
68
+ index += 2;
69
+ } else if (character === '*') {
70
+ while (characters[index + 1] === '*') {
71
+ index += 1;
72
+ }
73
+ const next = states;
74
+ states += 1;
75
+ edges.push(
76
+ { from: current, to: next, matcher: null },
77
+ { from: current, to: current, matcher: { kind: 'non-slash' } },
78
+ );
79
+ current = next;
80
+ } else if (character === '?') {
81
+ append({ kind: 'non-slash' });
82
+ } else if (character === '[') {
83
+ const parsed = parseCharacterClass(characters, index);
84
+ if (parsed === null) {
85
+ append({ kind: 'literal', value: '['.codePointAt(0) ?? 0 });
86
+ } else {
87
+ append(parsed.matcher);
88
+ index = parsed.end;
89
+ }
90
+ } else {
91
+ append({ kind: 'literal', value: character?.codePointAt(0) ?? 0 });
92
+ }
93
+ }
94
+ return { accept: current, edges };
95
+ };
96
+
97
+ export const directoriesOverlap = (
98
+ leftDirectory: string,
99
+ leftSupportsGlobs: boolean,
100
+ rightDirectory: string,
101
+ rightSupportsGlobs: boolean,
102
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The product walk must process both automata's epsilon and consuming transitions together.
103
+ ): boolean => {
104
+ const left = compileDirectory(leftDirectory, leftSupportsGlobs);
105
+ const right = compileDirectory(rightDirectory, rightSupportsGlobs);
106
+ const pending: Array<readonly [number, number]> = [[0, 0]];
107
+ const visited = new Set<string>();
108
+ for (const [leftState, rightState] of pending) {
109
+ const key = `${leftState}:${rightState}`;
110
+ if (!visited.has(key)) {
111
+ visited.add(key);
112
+ if (leftState === left.accept && rightState === right.accept) {
113
+ return true;
114
+ }
115
+ const leftEdges = left.edges.filter((edge) => edge.from === leftState);
116
+ const rightEdges = right.edges.filter((edge) => edge.from === rightState);
117
+ pending.push(
118
+ ...leftEdges
119
+ .filter((edge) => edge.matcher === null)
120
+ .map((edge) => [edge.to, rightState] as const),
121
+ ...rightEdges
122
+ .filter((edge) => edge.matcher === null)
123
+ .map((edge) => [leftState, edge.to] as const),
124
+ );
125
+ for (const leftEdge of leftEdges) {
126
+ for (const rightEdge of rightEdges) {
127
+ if (
128
+ leftEdge.matcher !== null &&
129
+ rightEdge.matcher !== null &&
130
+ matchersIntersect(leftEdge.matcher, rightEdge.matcher)
131
+ ) {
132
+ pending.push([leftEdge.to, rightEdge.to]);
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return false;
139
+ };
@@ -1,3 +1,8 @@
1
+ import {
2
+ isDependabotGlob,
3
+ normalizeDependabotDirectory,
4
+ } from './dependabot-directory-normalize';
5
+ import { directoriesOverlap } from './dependabot-directory-overlap';
1
6
  import { isNonEmptyString, isRecord } from './github-settings-parse';
2
7
 
3
8
  export type UpdateBlock = Record<string, unknown>;
@@ -6,6 +11,8 @@ export type UpdateTarget = {
6
11
  readonly ecosystem: string;
7
12
  readonly targetBranch: string | null;
8
13
  readonly directories: ReadonlyArray<string>;
14
+ readonly normalizedDirectories: ReadonlyArray<string>;
15
+ readonly globDirectories: ReadonlySet<string>;
9
16
  };
10
17
 
11
18
  const listOfNonEmptyStrings = (
@@ -72,6 +79,7 @@ export const updateTarget = (
72
79
  block: UpdateBlock,
73
80
  label: string,
74
81
  problems: Array<string>,
82
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Target validation deliberately gathers every independent configuration problem.
75
83
  ): UpdateTarget | null => {
76
84
  const ecosystem = block['package-ecosystem'];
77
85
  if (!isNonEmptyString(ecosystem)) {
@@ -103,6 +111,9 @@ export const updateTarget = (
103
111
  ? [directory as string]
104
112
  : (directories as ReadonlyArray<string>);
105
113
  const normalized = [...new Set(rawDirectories)].sort();
114
+ const normalizedDirectories = [
115
+ ...new Set(normalized.map(normalizeDependabotDirectory)),
116
+ ].sort();
106
117
  if (
107
118
  !hasDirectory &&
108
119
  Array.isArray(directories) &&
@@ -120,6 +131,10 @@ export const updateTarget = (
120
131
  ecosystem,
121
132
  targetBranch: targetBranch ?? null,
122
133
  directories: normalized,
134
+ normalizedDirectories,
135
+ globDirectories: new Set(
136
+ hasDirectories ? normalizedDirectories.filter(isDependabotGlob) : [],
137
+ ),
123
138
  };
124
139
  };
125
140
 
@@ -135,9 +150,13 @@ export const sameUpdateTarget = (
135
150
  right: UpdateTarget,
136
151
  ): boolean =>
137
152
  sameUpdateScope(left, right) &&
138
- left.directories.length === right.directories.length &&
139
- left.directories.every(
140
- (directory, index) => directory === right.directories[index],
153
+ left.normalizedDirectories.length === right.normalizedDirectories.length &&
154
+ left.normalizedDirectories.every(
155
+ (directory, index) => directory === right.normalizedDirectories[index],
156
+ ) &&
157
+ left.globDirectories.size === right.globDirectories.size &&
158
+ [...left.globDirectories].every((directory) =>
159
+ right.globDirectories.has(directory),
141
160
  );
142
161
 
143
162
  export const overlapsUpdateTarget = (
@@ -145,7 +164,16 @@ export const overlapsUpdateTarget = (
145
164
  right: UpdateTarget,
146
165
  ): boolean =>
147
166
  sameUpdateScope(left, right) &&
148
- left.directories.some((directory) => right.directories.includes(directory));
167
+ left.normalizedDirectories.some((leftDirectory) =>
168
+ right.normalizedDirectories.some((rightDirectory) =>
169
+ directoriesOverlap(
170
+ leftDirectory,
171
+ left.globDirectories.has(leftDirectory),
172
+ rightDirectory,
173
+ right.globDirectories.has(rightDirectory),
174
+ ),
175
+ ),
176
+ );
149
177
 
150
178
  export const updateTargetDescription = (target: UpdateTarget): string => {
151
179
  const branch =
package/src/github-api.ts CHANGED
@@ -112,83 +112,3 @@ export const loadDeclared = async (
112
112
  existsSync(localPath) ? await readFile(localPath, 'utf8') : null,
113
113
  );
114
114
  };
115
-
116
- const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
117
- ['allow_auto_merge', 'autoMergeAllowed'],
118
- ['allow_merge_commit', 'mergeCommitAllowed'],
119
- ['allow_rebase_merge', 'rebaseMergeAllowed'],
120
- ['allow_squash_merge', 'squashMergeAllowed'],
121
- ['delete_branch_on_merge', 'deleteBranchOnMerge'],
122
- ['merge_commit_message', 'mergeCommitMessage'],
123
- ['merge_commit_title', 'mergeCommitTitle'],
124
- ['squash_merge_commit_message', 'squashMergeCommitMessage'],
125
- ['squash_merge_commit_title', 'squashMergeCommitTitle'],
126
- ]);
127
-
128
- // REST omits repository merge settings for read-only viewers (they surface
129
- // only with write access — community discussion #153258), but GraphQL serves
130
- // the same values, with identical enum spellings, to any token that can read
131
- // the repository. This keeps a read-only PAT sufficient for the check.
132
- // Returns only the keys GraphQL answered; the rest stay unverifiable.
133
- export const fetchMergeSettingsViaGraphql = async (
134
- token: string | null,
135
- repo: string,
136
- keys: ReadonlyArray<string>,
137
- ): Promise<Readonly<Record<string, unknown>>> => {
138
- const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
139
- const [owner, name] = repo.split('/');
140
- if (mapped.length === 0 || owner === undefined || name === undefined) {
141
- return {};
142
- }
143
- const fields = mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' ');
144
- const response = await request(token, 'POST', '/graphql', {
145
- query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${fields} } }`,
146
- });
147
- if (response.status !== HTTP_OK || !isRecord(response.body)) {
148
- return {};
149
- }
150
- const data = isRecord(response.body.data) ? response.body.data : null;
151
- const repository =
152
- data !== null && isRecord(data.repository) ? data.repository : null;
153
- if (repository === null) {
154
- return {};
155
- }
156
- return Object.fromEntries(
157
- mapped
158
- .map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
159
- .filter(([, value]) => value !== undefined && value !== null),
160
- );
161
- };
162
-
163
- export type LiveRulesets = {
164
- readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
165
- readonly problem: string | null;
166
- };
167
-
168
- // Only repository-sourced rulesets are managed; org-level rulesets a consumer
169
- // inherits are outside this declaration's authority.
170
- export const fetchLiveRulesets = async (
171
- token: string | null,
172
- repo: string,
173
- ): Promise<LiveRulesets> => {
174
- const list = await request(token, 'GET', `/repos/${repo}/rulesets`);
175
- if (list.status !== HTTP_OK || !Array.isArray(list.body)) {
176
- return { rulesets: null, problem: apiError('listing rulesets', list) };
177
- }
178
- const repoOwned = list.body
179
- .filter(isRecord)
180
- .filter((ruleset) => ruleset.source_type === 'Repository');
181
- const detailed = await Promise.all(
182
- repoOwned.map((ruleset) =>
183
- request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
184
- ),
185
- );
186
- const failed = detailed.find((response) => response.status !== HTTP_OK);
187
- if (failed !== undefined) {
188
- return { rulesets: null, problem: apiError('reading a ruleset', failed) };
189
- }
190
- return {
191
- rulesets: detailed.map((response) => response.body).filter(isRecord),
192
- problem: null,
193
- };
194
- };
@@ -4,13 +4,14 @@
4
4
 
5
5
  import {
6
6
  apiError,
7
- fetchLiveRulesets,
8
7
  HTTP_CREATED,
9
8
  HTTP_NO_CONTENT,
10
9
  HTTP_OK,
11
10
  request,
12
11
  } from './github-api';
13
- import { diffRepositorySettings, diffRuleset } from './github-diff';
12
+ import { diffRepositorySettings } from './github-diff';
13
+ import { fetchLiveRulesets } from './github-ruleset-api';
14
+ import { diffRuleset } from './github-ruleset-diff';
14
15
  import { type GithubSettings, isRecord } from './github-settings-parse';
15
16
 
16
17
  // GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
@@ -0,0 +1,56 @@
1
+ // Reconciles ruleset bypass-actor lists REST hides, using the counts GraphQL
2
+ // answers for tokens that cannot read the actors themselves. Pure logic; the
3
+ // GraphQL call lives in github-graphql.ts.
4
+
5
+ import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
6
+
7
+ // Stand-ins for bypass actors GraphQL counted but would not name. They exist
8
+ // only to carry the count into the ordinary length comparison, and are attached
9
+ // exactly when the count and the declared length disagree or both are zero — so
10
+ // their content is never observed: subsetMatches short-circuits at the length
11
+ // check, two empty lists compare no element, and a declared ruleset with no
12
+ // bypass_actors array never reaches that comparison. Nothing else looks inside
13
+ // them; bypassActorCountDetail reads only `.length`, and github-live-drift.ts
14
+ // is the sole importer, so a stand-in cannot reach a PATCH body. The guarantee
15
+ // is not local: it also relies on declared ruleset names being unique, which
16
+ // github-settings-parse.ts and github-settings-merge.ts enforce.
17
+ const unreadableBypassActors = (count: number): ReadonlyArray<unknown> =>
18
+ Array.from({ length: count }, (_unused, index) => ({
19
+ unreadableBypassActor: index,
20
+ }));
21
+
22
+ // A count of zero pins the live list to the empty list exactly. A count that
23
+ // disagrees with the declared length is drift no identity could explain, so
24
+ // that many unreadable actors stand in and the ordinary comparison reports it.
25
+ // A matching non-zero count proves nothing about *which* actors bypass, so the
26
+ // field stays hidden and the gate fails closed on it. A list REST already
27
+ // answered is authoritative and never replaced.
28
+ //
29
+ // The counts are joined by ruleset id, the one identifier both APIs agree on:
30
+ // names are not unique on the live side, so joining on them could pin one
31
+ // ruleset's list from another ruleset's count. A live ruleset carrying no
32
+ // numeric id gets no count and stays hidden.
33
+ export const resolveHiddenBypassActors = (
34
+ declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
35
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
36
+ counts: ReadonlyMap<number, number>,
37
+ ): ReadonlyArray<Readonly<Record<string, unknown>>> => {
38
+ const declaredCounts = new Map(
39
+ declared
40
+ .filter((ruleset) => Array.isArray(ruleset[BYPASS_ACTORS_KEY]))
41
+ .map((ruleset) => [
42
+ String(ruleset.name),
43
+ (ruleset[BYPASS_ACTORS_KEY] as ReadonlyArray<unknown>).length,
44
+ ]),
45
+ );
46
+ return live.map((ruleset) => {
47
+ const count =
48
+ typeof ruleset.id === 'number' ? counts.get(ruleset.id) : undefined;
49
+ if (ruleset[BYPASS_ACTORS_KEY] !== undefined || count === undefined) {
50
+ return ruleset;
51
+ }
52
+ return count > 0 && count === declaredCounts.get(String(ruleset.name))
53
+ ? ruleset
54
+ : { ...ruleset, [BYPASS_ACTORS_KEY]: unreadableBypassActors(count) };
55
+ });
56
+ };
@@ -32,14 +32,21 @@ export const optOutEligibilityProblem = (
32
32
  ? `.github/settings.local.json "rulesetEnforcement" may only be declared for a private repository; ${repo} is public`
33
33
  : null;
34
34
 
35
+ export const ADMIN_VISIBILITY_ADVICE =
36
+ 'Use a token with read access to repository administration (in CI: ci.github_settings_read_token in secrets/ci.yaml), or verify locally with admin auth';
37
+
35
38
  // Declared state the token cannot see is a gate failure, not a pass with a
36
- // log line: a gate that cannot perform its comparison fails closed.
39
+ // log line: a gate that cannot perform its comparison fails closed. The advice
40
+ // is the caller's, because not every invisible field has the same remedy —
41
+ // ruleset bypass actors in particular are unreachable over REST for an
42
+ // installation token at any permission level.
37
43
  export const unverifiableProblem = (
38
44
  scope: string,
39
45
  items: ReadonlyArray<string>,
46
+ advice: string,
40
47
  ): ReadonlyArray<string> =>
41
48
  items.length === 0
42
49
  ? []
43
50
  : [
44
- `${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. Use a token with read access to repository administration (in CI: ci.github_settings_read_token in secrets/ci.yaml), or verify locally with admin auth`,
51
+ `${scope} not visible to this token, so the gate cannot verify: ${items.join('; ')}. ${advice}`,
45
52
  ];