@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.
@@ -1,5 +1,6 @@
1
1
  // Drift comparison between declared GitHub settings (github-settings.ts) and
2
- // the live state returned by the GitHub API. Pure logic; no network.
2
+ // the live state returned by the GitHub API. Pure logic; no network. Ruleset
3
+ // comparison lives in github-ruleset-diff.ts.
3
4
 
4
5
  import { isRecord } from './github-settings-parse';
5
6
 
@@ -41,114 +42,6 @@ export const subsetMatches = (declared: unknown, live: unknown): boolean => {
41
42
  return declared === live;
42
43
  };
43
44
 
44
- const RULESET_COMPARED_KEYS = [
45
- 'target',
46
- 'enforcement',
47
- 'conditions',
48
- 'bypass_actors',
49
- ] as const;
50
-
51
- const diffRules = (
52
- name: string,
53
- declared: Readonly<Record<string, unknown>>,
54
- live: Readonly<Record<string, unknown>>,
55
- ): ReadonlyArray<string> => {
56
- const drifted: Array<string> = [];
57
- const declaredRules = Array.isArray(declared.rules)
58
- ? declared.rules.filter(isRecord)
59
- : [];
60
- const liveRules = Array.isArray(live.rules)
61
- ? live.rules.filter(isRecord)
62
- : [];
63
- const liveByType = new Map(
64
- liveRules.map((rule) => [String(rule.type), rule]),
65
- );
66
- const declaredTypes = new Set(declaredRules.map((rule) => String(rule.type)));
67
- for (const rule of declaredRules) {
68
- const type = String(rule.type);
69
- const liveRule = liveByType.get(type);
70
- const declaredWithoutType = Object.fromEntries(
71
- Object.entries(rule).filter(([key]) => key !== 'type'),
72
- );
73
- if (liveRule === undefined) {
74
- drifted.push(`ruleset "${name}": missing rule "${type}"`);
75
- } else if (!subsetMatches(declaredWithoutType, liveRule)) {
76
- drifted.push(
77
- `ruleset "${name}": rule "${type}" differs from the declared configuration`,
78
- );
79
- }
80
- }
81
- for (const type of liveByType.keys()) {
82
- if (!declaredTypes.has(type)) {
83
- drifted.push(`ruleset "${name}": has undeclared extra rule "${type}"`);
84
- }
85
- }
86
- return drifted;
87
- };
88
-
89
- // Some ruleset fields — bypass_actors in particular — are only included in
90
- // API responses for admin viewers. A declared key that is absent on the live
91
- // side is unverifiable for this token, not drift: the same policy as
92
- // repository merge settings, so callers can fail with a targeted
93
- // missing-visibility message instead of a bogus value mismatch.
94
- export const diffRuleset = (
95
- declared: Readonly<Record<string, unknown>>,
96
- live: Readonly<Record<string, unknown>>,
97
- ): SettingsDiff => {
98
- const name = String(declared.name);
99
- const drifted: Array<string> = [];
100
- const unverifiable: Array<string> = [];
101
- for (const key of RULESET_COMPARED_KEYS) {
102
- if (declared[key] !== undefined) {
103
- if (live[key] === undefined) {
104
- unverifiable.push(`ruleset "${name}": ${key}`);
105
- } else if (!subsetMatches(declared[key], live[key])) {
106
- drifted.push(
107
- `ruleset "${name}": ${key} differs from the declared configuration`,
108
- );
109
- }
110
- }
111
- }
112
- drifted.push(...diffRules(name, declared, live));
113
- return { drifted, unverifiable };
114
- };
115
-
116
- // Live rulesets must be exactly the declared set: additions, removals, and
117
- // in-place edits are all drift.
118
- export const diffRulesets = (
119
- declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
120
- live: ReadonlyArray<Readonly<Record<string, unknown>>>,
121
- ): SettingsDiff => {
122
- const drifted: Array<string> = [];
123
- const unverifiable: Array<string> = [];
124
- const liveByName = new Map(
125
- live.map((ruleset) => [String(ruleset.name), ruleset]),
126
- );
127
- const declaredNames = new Set(
128
- declared.map((ruleset) => String(ruleset.name)),
129
- );
130
- for (const ruleset of declared) {
131
- const liveRuleset = liveByName.get(String(ruleset.name));
132
- if (liveRuleset === undefined) {
133
- drifted.push(
134
- `ruleset "${ruleset.name}" is declared but missing on GitHub`,
135
- );
136
- } else {
137
- const diff = diffRuleset(ruleset, liveRuleset);
138
- drifted.push(...diff.drifted);
139
- unverifiable.push(...diff.unverifiable);
140
- }
141
- }
142
- for (const name of liveByName.keys()) {
143
- if (!declaredNames.has(name)) {
144
- drifted.push(
145
- `ruleset "${name}" exists on GitHub but is not declared; declare it in .github/settings.local.json or delete it`,
146
- );
147
- }
148
- }
149
- return { drifted, unverifiable };
150
- };
151
-
152
45
  // Repo merge settings are only visible to admin tokens; report invisible keys
153
46
  // as unverifiable instead of drifted so callers fail the gate with a
154
47
  // missing-visibility message that names the token fix, not a bogus value
@@ -0,0 +1,198 @@
1
+ // GraphQL fallbacks for repository state REST hides from tokens that can
2
+ // legitimately read it. Each returns only what GraphQL actually answered, so a
3
+ // remaining gap stays unverifiable instead of becoming a false pass.
4
+
5
+ import { apiError, HTTP_OK, request } from './github-api';
6
+ import { isRecord } from './github-settings-parse';
7
+
8
+ // The `repository` node GraphQL answered with, or why there is no answer.
9
+ type GraphqlAnswer = {
10
+ readonly repository: Readonly<Record<string, unknown>> | null;
11
+ readonly failure: string | null;
12
+ };
13
+
14
+ const errorSummary = (errors: ReadonlyArray<unknown>): string =>
15
+ errors
16
+ .map((error) =>
17
+ isRecord(error) && typeof error.message === 'string'
18
+ ? error.message
19
+ : JSON.stringify(error),
20
+ )
21
+ .join('; ');
22
+
23
+ // GraphQL reports a permission failure as HTTP 200 with a partial `data` and a
24
+ // non-empty `errors` array, so a body carrying errors is no answer at all: a
25
+ // suppressed field that survives as a plausible value — `0` for a count — must
26
+ // never be read as a verified one.
27
+ const queryRepository = async (
28
+ token: string | null,
29
+ repo: string,
30
+ selection: string,
31
+ ): Promise<GraphqlAnswer> => {
32
+ const [owner, name] = repo.split('/');
33
+ if (owner === undefined || name === undefined) {
34
+ return {
35
+ repository: null,
36
+ failure: `"${repo}" is not an owner/name repository`,
37
+ };
38
+ }
39
+ const response = await request(token, 'POST', '/graphql', {
40
+ query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${selection} } }`,
41
+ });
42
+ if (response.status !== HTTP_OK || !isRecord(response.body)) {
43
+ return {
44
+ repository: null,
45
+ failure: apiError('querying the GraphQL API', response),
46
+ };
47
+ }
48
+ const { errors } = response.body;
49
+ if (Array.isArray(errors) && errors.length > 0) {
50
+ return {
51
+ repository: null,
52
+ failure: `HTTP ${response.status} ${errorSummary(errors)}`,
53
+ };
54
+ }
55
+ const data = isRecord(response.body.data) ? response.body.data : null;
56
+ if (data === null || !isRecord(data.repository)) {
57
+ return {
58
+ repository: null,
59
+ failure: `HTTP ${response.status} the response carried no repository`,
60
+ };
61
+ }
62
+ return { repository: data.repository, failure: null };
63
+ };
64
+
65
+ const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
66
+ ['allow_auto_merge', 'autoMergeAllowed'],
67
+ ['allow_merge_commit', 'mergeCommitAllowed'],
68
+ ['allow_rebase_merge', 'rebaseMergeAllowed'],
69
+ ['allow_squash_merge', 'squashMergeAllowed'],
70
+ ['delete_branch_on_merge', 'deleteBranchOnMerge'],
71
+ ['merge_commit_message', 'mergeCommitMessage'],
72
+ ['merge_commit_title', 'mergeCommitTitle'],
73
+ ['squash_merge_commit_message', 'squashMergeCommitMessage'],
74
+ ['squash_merge_commit_title', 'squashMergeCommitTitle'],
75
+ ]);
76
+
77
+ // REST omits repository merge settings for read-only viewers (they surface
78
+ // only with write access — community discussion #153258), but GraphQL serves
79
+ // the same values, with identical enum spellings, to any token that can read
80
+ // the repository. This keeps a read-only token sufficient for the check.
81
+ // Returns only the keys GraphQL answered; the rest stay unverifiable.
82
+ export const fetchMergeSettingsViaGraphql = async (
83
+ token: string | null,
84
+ repo: string,
85
+ keys: ReadonlyArray<string>,
86
+ ): Promise<Readonly<Record<string, unknown>>> => {
87
+ const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
88
+ if (mapped.length === 0) {
89
+ return {};
90
+ }
91
+ const { repository } = await queryRepository(
92
+ token,
93
+ repo,
94
+ mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' '),
95
+ );
96
+ if (repository === null) {
97
+ return {};
98
+ }
99
+ return Object.fromEntries(
100
+ mapped
101
+ .map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
102
+ .filter(([, value]) => value !== undefined && value !== null),
103
+ );
104
+ };
105
+
106
+ // One page is every ruleset any consumer declares. Nothing rests on that:
107
+ // counts are only ever consulted for rulesets the REST loader listed, and a
108
+ // ruleset GraphQL did not answer for stays unverifiable.
109
+ const GRAPHQL_RULESET_PAGE = 100;
110
+
111
+ // Asking for one actor makes the count self-corroborating at no extra cost:
112
+ // `totalCount === 0` must yield an empty node list and `totalCount >= 1`
113
+ // exactly one node.
114
+ const BYPASS_ACTOR_PROBE = 1;
115
+
116
+ // A repository-owned ruleset node paired with its bypass-actor count, keyed by
117
+ // the id REST reports for the same ruleset, or null for anything else.
118
+ // Org-inherited rulesets are outside this declaration's authority, the same
119
+ // filter the REST loader applies. A node list that disagrees with its own
120
+ // `totalCount` means the connection is being filtered rather than merely
121
+ // redacted, so it supplies no count; a null *element* is the documented
122
+ // identity-withholding shape, so only the length is checked.
123
+ const countedRuleset = (node: unknown): readonly [number, number] | null => {
124
+ if (!isRecord(node) || typeof node.databaseId !== 'number') {
125
+ return null;
126
+ }
127
+ if (!isRecord(node.source) || node.source.__typename !== 'Repository') {
128
+ return null;
129
+ }
130
+ const actors = isRecord(node.bypassActors) ? node.bypassActors : null;
131
+ if (
132
+ actors === null ||
133
+ typeof actors.totalCount !== 'number' ||
134
+ !Array.isArray(actors.nodes)
135
+ ) {
136
+ return null;
137
+ }
138
+ const total = actors.totalCount;
139
+ return total >= 0 &&
140
+ actors.nodes.length === Math.min(total, BYPASS_ACTOR_PROBE)
141
+ ? [node.databaseId, total]
142
+ : null;
143
+ };
144
+
145
+ // Nothing promises an id appears once in the node list, and a repeated key
146
+ // would silently take the last node's count. Dropping the id entirely degrades
147
+ // it to unverifiable instead.
148
+ const withoutRepeatedIds = (
149
+ entries: ReadonlyArray<readonly [number, number]>,
150
+ ): ReadonlyMap<number, number> => {
151
+ const seen = new Set<number>();
152
+ const repeated = new Set<number>();
153
+ for (const [id] of entries) {
154
+ if (seen.has(id)) {
155
+ repeated.add(id);
156
+ }
157
+ seen.add(id);
158
+ }
159
+ return new Map(entries.filter(([id]) => !repeated.has(id)));
160
+ };
161
+
162
+ export type BypassActorCounts = {
163
+ // Keyed by ruleset id, never by name: two live rulesets may share a name, and
164
+ // joining on it would let a harmless ruleset's count verify a bypassed
165
+ // ruleset's list.
166
+ readonly counts: ReadonlyMap<number, number>;
167
+ readonly failure: string | null;
168
+ };
169
+
170
+ // REST omits ruleset `bypass_actors` for viewers without repository
171
+ // Administration access, and omits it from GitHub App installation tokens
172
+ // whatever the App's permissions — so a brokered CI token can never read the
173
+ // list there. GraphQL answers `bypassActors` on the same repository-owned
174
+ // rulesets and serves an exact `totalCount` to those tokens, withholding only
175
+ // the actor identities. Returns the count per ruleset id; an id GraphQL did
176
+ // not answer for stays absent, so the caller keeps it unverifiable.
177
+ export const fetchBypassActorCountsViaGraphql = async (
178
+ token: string | null,
179
+ repo: string,
180
+ ): Promise<BypassActorCounts> => {
181
+ const answer = await queryRepository(
182
+ token,
183
+ repo,
184
+ `rulesets(first: ${GRAPHQL_RULESET_PAGE}, includeParents: false) { nodes { databaseId source { __typename } bypassActors(first: ${BYPASS_ACTOR_PROBE}) { totalCount nodes { __typename } } } }`,
185
+ );
186
+ const rulesets =
187
+ answer.repository !== null && isRecord(answer.repository.rulesets)
188
+ ? answer.repository.rulesets
189
+ : null;
190
+ const nodes =
191
+ rulesets !== null && Array.isArray(rulesets.nodes) ? rulesets.nodes : [];
192
+ return {
193
+ counts: withoutRepeatedIds(
194
+ nodes.map(countedRuleset).filter((entry) => entry !== null),
195
+ ),
196
+ failure: answer.failure,
197
+ };
198
+ };
@@ -4,8 +4,6 @@
4
4
 
5
5
  import {
6
6
  apiError,
7
- fetchLiveRulesets,
8
- fetchMergeSettingsViaGraphql,
9
7
  HTTP_FORBIDDEN,
10
8
  HTTP_OK,
11
9
  HTTP_UNAUTHORIZED,
@@ -13,14 +11,27 @@ import {
13
11
  resolveGithubRepo,
14
12
  resolveToken,
15
13
  } from './github-api';
14
+ import { resolveHiddenBypassActors } from './github-bypass-actors';
16
15
  import {
16
+ ADMIN_VISIBILITY_ADVICE,
17
17
  enforceableRepositorySettings,
18
18
  optOutEligibilityProblem,
19
19
  unverifiableProblem,
20
20
  } from './github-command-shared';
21
- import { diffRepositorySettings, diffRulesets } from './github-diff';
21
+ import { diffRepositorySettings } from './github-diff';
22
+ import {
23
+ fetchBypassActorCountsViaGraphql,
24
+ fetchMergeSettingsViaGraphql,
25
+ } from './github-graphql';
22
26
  import { diffLabels, fetchLiveLabels } from './github-labels';
23
27
  import { GithubListResponseError } from './github-paginate';
28
+ import { fetchLiveRulesets } from './github-ruleset-api';
29
+ import { diffRulesets } from './github-ruleset-diff';
30
+ import {
31
+ GRAPHQL_NOT_CONSULTED,
32
+ isHiddenBypassActors,
33
+ rulesetVisibilityProblems,
34
+ } from './github-ruleset-visibility';
24
35
  import { type GithubSettings, isRecord } from './github-settings-parse';
25
36
 
26
37
  const LABEL_VISIBILITY_PROBLEM =
@@ -73,10 +84,29 @@ const repositoryDrift = async (
73
84
  });
74
85
  return [
75
86
  ...rediff.drifted,
76
- ...unverifiableProblem('repository setting(s)', rediff.unverifiable),
87
+ ...unverifiableProblem(
88
+ 'repository setting(s)',
89
+ rediff.unverifiable,
90
+ ADMIN_VISIBILITY_ADVICE,
91
+ ),
77
92
  ];
78
93
  };
79
94
 
95
+ // The live rulesets GraphQL answered a bypass-actor count for, named as the
96
+ // REST response names them so the advice can say whether a hidden list was
97
+ // counted-and-matched or never answered for at all.
98
+ const countedRulesetNames = (
99
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
100
+ counts: ReadonlyMap<number, number>,
101
+ ): ReadonlySet<string> =>
102
+ new Set(
103
+ live
104
+ .filter(
105
+ (ruleset) => typeof ruleset.id === 'number' && counts.has(ruleset.id),
106
+ )
107
+ .map((ruleset) => String(ruleset.name)),
108
+ );
109
+
80
110
  const rulesetDrift = async (
81
111
  token: string | null,
82
112
  repo: string,
@@ -90,9 +120,30 @@ const rulesetDrift = async (
90
120
  return [live.problem ?? 'unable to read rulesets'];
91
121
  }
92
122
  const diff = diffRulesets(declared.rulesets, live.rulesets);
123
+ if (!diff.unverifiable.some(isHiddenBypassActors)) {
124
+ return [
125
+ ...diff.drifted,
126
+ ...rulesetVisibilityProblems(diff.unverifiable, GRAPHQL_NOT_CONSULTED),
127
+ ];
128
+ }
129
+ // REST hides bypass actors from every non-admin token and from installation
130
+ // tokens outright; retry the hidden lists as GraphQL counts before failing
131
+ // the gate.
132
+ const answered = await fetchBypassActorCountsViaGraphql(token, repo);
133
+ const rediff = diffRulesets(
134
+ declared.rulesets,
135
+ resolveHiddenBypassActors(
136
+ declared.rulesets,
137
+ live.rulesets,
138
+ answered.counts,
139
+ ),
140
+ );
93
141
  return [
94
- ...diff.drifted,
95
- ...unverifiableProblem('ruleset field(s)', diff.unverifiable),
142
+ ...rediff.drifted,
143
+ ...rulesetVisibilityProblems(rediff.unverifiable, {
144
+ countedNames: countedRulesetNames(live.rulesets, answered.counts),
145
+ failure: answered.failure,
146
+ }),
96
147
  ];
97
148
  };
98
149
 
@@ -0,0 +1,57 @@
1
+ // Loading the live repository rulesets. This lives outside github-api.ts
2
+ // because the exhaustive pagination it needs (github-paginate.ts) is itself
3
+ // built on github-api.ts, and calling it from there would be a mutual import.
4
+
5
+ import { apiError, HTTP_OK, request } from './github-api';
6
+ import { listAllPages } from './github-paginate';
7
+ import { isRecord } from './github-settings-parse';
8
+
9
+ export type LiveRulesets = {
10
+ readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
11
+ readonly problem: string | null;
12
+ };
13
+
14
+ // Only repository-sourced rulesets are managed; org-level rulesets a consumer
15
+ // inherits are outside this declaration's authority. The list really
16
+ // paginates — GitHub serves 30 per page by default and `includes_parents`
17
+ // defaults to true — so a single-page read would hide an undeclared ruleset
18
+ // that happens to land on page two, and the "not declared" branch would never
19
+ // fire for it.
20
+ //
21
+ // listAllPages throws, but the drift collector tells a ruleset read failure
22
+ // apart from a label read failure by the returned problem string, not by the
23
+ // exception, so the throw is converted here rather than propagated.
24
+ export const fetchLiveRulesets = async (
25
+ token: string | null,
26
+ repo: string,
27
+ ): Promise<LiveRulesets> => {
28
+ let summaries: ReadonlyArray<unknown>;
29
+ try {
30
+ summaries = await listAllPages(
31
+ token,
32
+ `/repos/${repo}/rulesets`,
33
+ 'listing rulesets',
34
+ );
35
+ } catch (error) {
36
+ return {
37
+ rulesets: null,
38
+ problem: error instanceof Error ? error.message : String(error),
39
+ };
40
+ }
41
+ const repoOwned = summaries
42
+ .filter(isRecord)
43
+ .filter((ruleset) => ruleset.source_type === 'Repository');
44
+ const detailed = await Promise.all(
45
+ repoOwned.map((ruleset) =>
46
+ request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
47
+ ),
48
+ );
49
+ const failed = detailed.find((response) => response.status !== HTTP_OK);
50
+ if (failed !== undefined) {
51
+ return { rulesets: null, problem: apiError('reading a ruleset', failed) };
52
+ }
53
+ return {
54
+ rulesets: detailed.map((response) => response.body).filter(isRecord),
55
+ problem: null,
56
+ };
57
+ };
@@ -0,0 +1,181 @@
1
+ // Drift comparison for repository rulesets: the declared set
2
+ // (github-settings.ts) against live API state. Pure logic; no network. The
3
+ // general value comparison lives in github-diff.ts.
4
+
5
+ import { subsetMatches } from './github-diff';
6
+ import { isRecord } from './github-settings-parse';
7
+
8
+ export const BYPASS_ACTORS_KEY = 'bypass_actors';
9
+
10
+ // A declared ruleset field the live side did not carry. It stays structured so
11
+ // the caller owns both the wording and the test for *which* field it is;
12
+ // formatting it here and recovering the signal by parsing the message suffix
13
+ // would let a reformat silently disable the bypass-actor fallback.
14
+ export type UnverifiableRulesetField = {
15
+ readonly key: string;
16
+ readonly name: string;
17
+ };
18
+
19
+ export type RulesetDiff = {
20
+ readonly drifted: ReadonlyArray<string>;
21
+ readonly unverifiable: ReadonlyArray<UnverifiableRulesetField>;
22
+ };
23
+
24
+ const RULESET_COMPARED_KEYS = [
25
+ 'target',
26
+ 'enforcement',
27
+ 'conditions',
28
+ BYPASS_ACTORS_KEY,
29
+ ] as const;
30
+
31
+ const diffRules = (
32
+ name: string,
33
+ declared: Readonly<Record<string, unknown>>,
34
+ live: Readonly<Record<string, unknown>>,
35
+ ): ReadonlyArray<string> => {
36
+ const drifted: Array<string> = [];
37
+ const declaredRules = Array.isArray(declared.rules)
38
+ ? declared.rules.filter(isRecord)
39
+ : [];
40
+ const liveRules = Array.isArray(live.rules)
41
+ ? live.rules.filter(isRecord)
42
+ : [];
43
+ const liveByType = new Map(
44
+ liveRules.map((rule) => [String(rule.type), rule]),
45
+ );
46
+ const declaredTypes = new Set(declaredRules.map((rule) => String(rule.type)));
47
+ for (const rule of declaredRules) {
48
+ const type = String(rule.type);
49
+ const liveRule = liveByType.get(type);
50
+ const declaredWithoutType = Object.fromEntries(
51
+ Object.entries(rule).filter(([key]) => key !== 'type'),
52
+ );
53
+ if (liveRule === undefined) {
54
+ drifted.push(`ruleset "${name}": missing rule "${type}"`);
55
+ } else if (!subsetMatches(declaredWithoutType, liveRule)) {
56
+ drifted.push(
57
+ `ruleset "${name}": rule "${type}" differs from the declared configuration`,
58
+ );
59
+ }
60
+ }
61
+ for (const type of liveByType.keys()) {
62
+ if (!declaredTypes.has(type)) {
63
+ drifted.push(`ruleset "${name}": has undeclared extra rule "${type}"`);
64
+ }
65
+ }
66
+ return drifted;
67
+ };
68
+
69
+ // Whoever hits bypass-actor drift holds, by construction, a token that cannot
70
+ // name the actors, so the two lengths are the only facts worth printing — and
71
+ // when the live list is stand-ins for a GraphQL count, they are the only facts
72
+ // that exist. Equal lengths mean the identities differ, where a count would
73
+ // say nothing.
74
+ const bypassActorCountDetail = (
75
+ key: string,
76
+ declared: unknown,
77
+ live: unknown,
78
+ ): string =>
79
+ key === BYPASS_ACTORS_KEY &&
80
+ Array.isArray(declared) &&
81
+ Array.isArray(live) &&
82
+ declared.length !== live.length
83
+ ? ` (GitHub reports ${live.length} bypass actor(s); ${declared.length} declared)`
84
+ : '';
85
+
86
+ // Some ruleset fields — bypass_actors in particular — are only included in
87
+ // API responses for admin viewers. A declared key that is absent on the live
88
+ // side is unverifiable for this token, not drift: the same policy as
89
+ // repository merge settings, so callers can fail with a targeted
90
+ // missing-visibility message instead of a bogus value mismatch. The live side
91
+ // is not always an API response — github-bypass-actors.ts synthesises
92
+ // unmatchable stand-in actors from a GraphQL count — so a `bypass_actors` list
93
+ // arriving here may encode a count GitHub answered rather than actors it
94
+ // named.
95
+ export const diffRuleset = (
96
+ declared: Readonly<Record<string, unknown>>,
97
+ live: Readonly<Record<string, unknown>>,
98
+ ): RulesetDiff => {
99
+ const name = String(declared.name);
100
+ const drifted: Array<string> = [];
101
+ const unverifiable: Array<UnverifiableRulesetField> = [];
102
+ for (const key of RULESET_COMPARED_KEYS) {
103
+ if (declared[key] !== undefined) {
104
+ if (live[key] === undefined) {
105
+ unverifiable.push({ key, name });
106
+ } else if (!subsetMatches(declared[key], live[key])) {
107
+ drifted.push(
108
+ `ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
109
+ );
110
+ }
111
+ }
112
+ }
113
+ drifted.push(...diffRules(name, declared, live));
114
+ return { drifted, unverifiable };
115
+ };
116
+
117
+ const groupLiveByName = (
118
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
119
+ ): ReadonlyMap<string, ReadonlyArray<Readonly<Record<string, unknown>>>> => {
120
+ const groups = new Map<string, Array<Readonly<Record<string, unknown>>>>();
121
+ for (const ruleset of live) {
122
+ const group = groups.get(String(ruleset.name));
123
+ if (group === undefined) {
124
+ groups.set(String(ruleset.name), [ruleset]);
125
+ } else {
126
+ group.push(ruleset);
127
+ }
128
+ }
129
+ return groups;
130
+ };
131
+
132
+ // GitHub does not require repository ruleset names to be unique, and the
133
+ // declaration addresses rulesets by name alone. Picking one of a colliding
134
+ // pair would compare the harmless one, pass, and never mention the other — a
135
+ // bypassed default branch reported as converged — so a collision fails the
136
+ // gate outright and names the ids that make it ambiguous.
137
+ const nameCollisionDrift = (
138
+ name: string,
139
+ group: ReadonlyArray<Readonly<Record<string, unknown>>>,
140
+ ): string =>
141
+ `ruleset name "${name}" is used by ${group.length} rulesets on GitHub (ids ${group.map((ruleset) => String(ruleset.id)).join(', ')}); the declaration addresses rulesets by name, so none of them can be verified until all but one is renamed or deleted`;
142
+
143
+ // Live rulesets must be exactly the declared set: additions, removals, and
144
+ // in-place edits are all drift.
145
+ export const diffRulesets = (
146
+ declared: ReadonlyArray<Readonly<Record<string, unknown>>>,
147
+ live: ReadonlyArray<Readonly<Record<string, unknown>>>,
148
+ ): RulesetDiff => {
149
+ const drifted: Array<string> = [];
150
+ const unverifiable: Array<UnverifiableRulesetField> = [];
151
+ const liveByName = groupLiveByName(live);
152
+ const declaredNames = new Set(
153
+ declared.map((ruleset) => String(ruleset.name)),
154
+ );
155
+ for (const ruleset of declared) {
156
+ const group = liveByName.get(String(ruleset.name)) ?? [];
157
+ const [only] = group;
158
+ // A colliding name is reported below and compared against nothing: no live
159
+ // ruleset in the group can be the declared one without picking a winner.
160
+ if (only === undefined) {
161
+ drifted.push(
162
+ `ruleset "${ruleset.name}" is declared but missing on GitHub`,
163
+ );
164
+ } else if (group.length === 1) {
165
+ const diff = diffRuleset(ruleset, only);
166
+ drifted.push(...diff.drifted);
167
+ unverifiable.push(...diff.unverifiable);
168
+ }
169
+ }
170
+ for (const [name, group] of liveByName) {
171
+ if (group.length > 1) {
172
+ drifted.push(nameCollisionDrift(name, group));
173
+ }
174
+ if (!declaredNames.has(name)) {
175
+ drifted.push(
176
+ `ruleset "${name}" exists on GitHub but is not declared; declare it in .github/settings.local.json or delete it`,
177
+ );
178
+ }
179
+ }
180
+ return { drifted, unverifiable };
181
+ };