@davidvornholt/standards 0.17.3 → 0.18.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.
@@ -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
+ };
@@ -0,0 +1,100 @@
1
+ // Turns unverifiable ruleset fields into gate failures. This module owns both
2
+ // the wording of an invisible field and the test for which field it is, so the
3
+ // two cannot drift apart: an earlier version formatted the message in one
4
+ // module and recovered the signal by parsing the suffix in another, where a
5
+ // reformat would have silently stopped the bypass-actor fallback from firing.
6
+
7
+ import {
8
+ ADMIN_VISIBILITY_ADVICE,
9
+ unverifiableProblem,
10
+ } from './github-command-shared';
11
+ import {
12
+ BYPASS_ACTORS_KEY,
13
+ type UnverifiableRulesetField,
14
+ } from './github-ruleset-diff';
15
+
16
+ const SCOPE = 'ruleset field(s)';
17
+
18
+ export const isHiddenBypassActors = (
19
+ field: UnverifiableRulesetField,
20
+ ): boolean => field.key === BYPASS_ACTORS_KEY;
21
+
22
+ const fieldLabel = (field: UnverifiableRulesetField): string =>
23
+ `ruleset "${field.name}": ${field.key}`;
24
+
25
+ // What the GraphQL bypass-actor fallback established, so the advice can tell a
26
+ // failed request from a permission gap. `countedNames` holds the live rulesets
27
+ // GraphQL did answer a count for.
28
+ export type BypassCountOutcome = {
29
+ readonly countedNames: ReadonlySet<string>;
30
+ readonly failure: string | null;
31
+ };
32
+
33
+ // Rulesets can be unverifiable for reasons other than a hidden bypass list, in
34
+ // which case the fallback is never called.
35
+ export const GRAPHQL_NOT_CONSULTED: BypassCountOutcome = {
36
+ countedNames: new Set(),
37
+ failure: null,
38
+ };
39
+
40
+ // The generic "read access to repository administration" remedy is
41
+ // unactionable for brokered CI here: REST withholds this field from a GitHub
42
+ // App installation token at every permission level, so widening the App only
43
+ // broadens the blast radius while the gate stays red.
44
+ const INSTALLATION_DEAD_END =
45
+ "A GitHub App installation token cannot read bypass_actors over REST at any permission level, so widening the App's permissions will not help";
46
+
47
+ const COUNT_MATCHED_ADVICE = `GitHub reports the declared number of bypass actors but withholds their identities, so the repository is probably fine and only a local run with admin auth can confirm it. ${INSTALLATION_DEAD_END}`;
48
+
49
+ const NOTHING_ANSWERED_ADVICE = `Neither REST nor the GraphQL bypass-actor count answered for the ruleset(s) listed, so nothing at all is known about who may bypass them; verify locally with admin auth. ${INSTALLATION_DEAD_END}`;
50
+
51
+ // The two no-failure remedies differ per ruleset, so the fields are partitioned
52
+ // rather than reported under whichever advice one of them happened to match: a
53
+ // ruleset GraphQL never answered for must never be described as probably fine
54
+ // because a sibling ruleset was counted.
55
+ const hiddenBypassActorsProblems = (
56
+ fields: ReadonlyArray<UnverifiableRulesetField>,
57
+ outcome: BypassCountOutcome,
58
+ ): ReadonlyArray<string> => {
59
+ if (outcome.failure !== null) {
60
+ return unverifiableProblem(
61
+ SCOPE,
62
+ fields.map(fieldLabel),
63
+ `The GraphQL bypass-actor count did not answer (${outcome.failure}), so this is a failed request rather than a permission gap: re-run the check, and if it keeps failing verify locally with admin auth`,
64
+ );
65
+ }
66
+ const counted = fields.filter((field) =>
67
+ outcome.countedNames.has(field.name),
68
+ );
69
+ const unanswered = fields.filter(
70
+ (field) => !outcome.countedNames.has(field.name),
71
+ );
72
+ return [
73
+ ...unverifiableProblem(
74
+ SCOPE,
75
+ counted.map(fieldLabel),
76
+ COUNT_MATCHED_ADVICE,
77
+ ),
78
+ ...unverifiableProblem(
79
+ SCOPE,
80
+ unanswered.map(fieldLabel),
81
+ NOTHING_ANSWERED_ADVICE,
82
+ ),
83
+ ];
84
+ };
85
+
86
+ export const rulesetVisibilityProblems = (
87
+ unverifiable: ReadonlyArray<UnverifiableRulesetField>,
88
+ outcome: BypassCountOutcome,
89
+ ): ReadonlyArray<string> => {
90
+ const hidden = unverifiable.filter(isHiddenBypassActors);
91
+ const others = unverifiable.filter((field) => !isHiddenBypassActors(field));
92
+ return [
93
+ ...unverifiableProblem(
94
+ SCOPE,
95
+ others.map(fieldLabel),
96
+ ADMIN_VISIBILITY_ADVICE,
97
+ ),
98
+ ...hiddenBypassActorsProblems(hidden, outcome),
99
+ ];
100
+ };
@@ -1,8 +1,7 @@
1
- // Shape parsing for a single declarative GitHub settings file: the canonical
2
- // `.github/settings.json` or the repo-owned `.github/settings.local.json`
3
- // extension. Seam merging lives in github-settings.ts, drift comparison in
4
- // github-diff.ts, and the API interaction in github-api.ts. Like cli.ts, this
5
- // module is zero-dependency so `bunx` can execute the published package.
1
+ // Parse one GitHub settings file: canonical `.github/settings.json` or the
2
+ // repo-owned `.github/settings.local.json` extension. Seam merging, drift
3
+ // comparison, and API interaction live in dedicated modules. This module stays
4
+ // zero-dependency so `bunx` can execute the published package.
6
5
 
7
6
  import { labelIdentity } from './github-label-identity';
8
7
 
@@ -139,13 +138,15 @@ const parseDeclarationList = (
139
138
  options: ListField,
140
139
  problems: Array<string>,
141
140
  ): ReadonlyArray<unknown> | null => {
142
- const list = raw ?? [];
143
- if (!Array.isArray(list)) {
141
+ if (raw === undefined) {
142
+ return [];
143
+ }
144
+ if (!Array.isArray(raw)) {
144
145
  problems.push(`${options.label} "${options.field}" must be an array`);
145
146
  return null;
146
147
  }
147
- problems.push(...options.listProblems(list, options.label));
148
- return list;
148
+ problems.push(...options.listProblems(raw, options.label));
149
+ return raw;
149
150
  };
150
151
 
151
152
  export const parseSettings = (
@@ -162,7 +163,7 @@ export const parseSettings = (
162
163
  problems.push(`${label} has unknown key "${key}"`);
163
164
  }
164
165
  }
165
- const repository = raw.repository ?? {};
166
+ const repository = 'repository' in raw ? raw.repository : {};
166
167
  if (!isRecord(repository)) {
167
168
  problems.push(`${label} "repository" must be an object`);
168
169
  }
@@ -179,6 +180,7 @@ export const parseSettings = (
179
180
  // Only the opt-out value is accepted: enforcement is the default, and an
180
181
  // explicit "enforced" would be a second way to spell the same state.
181
182
  if (
183
+ allowedKeys.has('rulesetEnforcement') &&
182
184
  raw.rulesetEnforcement !== undefined &&
183
185
  raw.rulesetEnforcement !== ENFORCEMENT_OPT_OUT
184
186
  ) {
@@ -1,7 +1,8 @@
1
1
  // Load and merge canonical GitHub settings with the repo-owned extension.
2
2
  // Parsing lives in github-settings-parse.ts, merge policy in
3
- // github-settings-merge.ts, drift in github-diff.ts, and API work in
4
- // github-api.ts; this stays dependency-free.
3
+ // github-settings-merge.ts, drift in github-diff.ts and
4
+ // github-ruleset-diff.ts, and API work in github-api.ts; this stays
5
+ // dependency-free.
5
6
 
6
7
  import { mergeSettings } from './github-settings-merge';
7
8
  import {