@davidvornholt/standards 0.8.0 → 0.10.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/README.md CHANGED
@@ -12,7 +12,7 @@ standards github --check
12
12
  standards github --apply
13
13
  ```
14
14
 
15
- `github --check` compares the live GitHub repository (merge settings and rulesets) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges the live repository to exactly the declared state. `check` runs the same comparison whenever `.github/settings.json` is present.
15
+ `github --check` compares the live GitHub repository (merge settings and rulesets) against the merged declaration in `.github/settings.json` + `.github/settings.local.json`; `github --apply` converges the live repository to exactly the declared state. `check` runs the same comparison whenever `.github/settings.json` is present, and fails closed: declared state the token cannot see fails the gate with a message naming the token fix instead of passing unverified. `--apply` re-diffs the PATCH response, so a setting GitHub accepts with HTTP 200 but silently ignores (plan-unavailable features) is a reported failure, not a false success. A repository whose GitHub plan cannot enforce rulesets can declare that in the seam (see `rulesetEnforcement` below); both commands then skip rulesets and plan-gated repository settings, converge the rest, and print an unprotected-branch notice on every run.
16
16
 
17
17
  `structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
18
18
 
@@ -21,6 +21,7 @@ standards github --apply
21
21
  ## Configuration
22
22
 
23
23
  - **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
24
- - **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` and private-repo reads need an authenticated token, and repo merge settings are only visible (and thus verifiable) to admin tokens — non-admin runs report them as unverifiable.
24
+ - **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
25
+ - **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — and ruleset `bypass_actors` requires repository Administration read; a token that still cannot see declared state fails the check rather than passing unverified. In CI, where the workflow token can never hold that permission, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml` — a fine-grained PAT with read-only "Administration" access to the repository.
25
26
 
26
27
  See the standards repository README for the ownership model and adoption workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,11 @@
22
22
  "src/cli.ts",
23
23
  "src/github-api.ts",
24
24
  "src/github-apply.ts",
25
+ "src/github-command-shared.ts",
25
26
  "src/github-commands.ts",
26
27
  "src/github-diff.ts",
28
+ "src/github-live-drift.ts",
29
+ "src/github-settings-parse.ts",
27
30
  "src/github-settings.ts",
28
31
  "src/structure-check.ts",
29
32
  "src/structure-profile.ts",
package/src/cli.ts CHANGED
@@ -493,7 +493,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
493
493
  const problems = results.filter((p): p is string => p !== null);
494
494
  if (problems.length > 0) {
495
495
  console.error(
496
- `standards: ${problems.length} canonical file(s) drifted from upstream:`,
496
+ `standards: ${problems.length} canonical file(s) drifted from the last synced state:`,
497
497
  );
498
498
  console.error(problems.join('\n'));
499
499
  console.error(
@@ -502,7 +502,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
502
502
  return false;
503
503
  }
504
504
  console.log(
505
- `standards: ${Object.keys(lock.files).length} canonical file(s) match upstream`,
505
+ `standards: ${Object.keys(lock.files).length} canonical file(s) match the last synced state`,
506
506
  );
507
507
  return true;
508
508
  };
package/src/github-api.ts CHANGED
@@ -8,10 +8,10 @@ import { readFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import process from 'node:process';
10
10
  import {
11
- isRecord,
12
11
  type LoadedGithubSettings,
13
12
  loadGithubSettings,
14
13
  } from './github-settings';
14
+ import { isRecord } from './github-settings-parse';
15
15
 
16
16
  const API_ROOT = 'https://api.github.com';
17
17
 
@@ -32,6 +32,7 @@ const quietExec = (
32
32
  try {
33
33
  const out = execFileSync(file, [...args], {
34
34
  encoding: 'utf8',
35
+ env: process.env,
35
36
  stdio: ['ignore', 'pipe', 'ignore'],
36
37
  }).trim();
37
38
  return out.length > 0 ? out : null;
@@ -110,6 +111,53 @@ export const loadDeclared = async (
110
111
  );
111
112
  };
112
113
 
114
+ const GRAPHQL_MERGE_FIELDS: ReadonlyMap<string, string> = new Map([
115
+ ['allow_auto_merge', 'autoMergeAllowed'],
116
+ ['allow_merge_commit', 'mergeCommitAllowed'],
117
+ ['allow_rebase_merge', 'rebaseMergeAllowed'],
118
+ ['allow_squash_merge', 'squashMergeAllowed'],
119
+ ['delete_branch_on_merge', 'deleteBranchOnMerge'],
120
+ ['merge_commit_message', 'mergeCommitMessage'],
121
+ ['merge_commit_title', 'mergeCommitTitle'],
122
+ ['squash_merge_commit_message', 'squashMergeCommitMessage'],
123
+ ['squash_merge_commit_title', 'squashMergeCommitTitle'],
124
+ ]);
125
+
126
+ // REST omits repository merge settings for read-only viewers (they surface
127
+ // only with write access — community discussion #153258), but GraphQL serves
128
+ // the same values, with identical enum spellings, to any token that can read
129
+ // the repository. This keeps a read-only PAT sufficient for the check.
130
+ // Returns only the keys GraphQL answered; the rest stay unverifiable.
131
+ export const fetchMergeSettingsViaGraphql = async (
132
+ token: string | null,
133
+ repo: string,
134
+ keys: ReadonlyArray<string>,
135
+ ): Promise<Readonly<Record<string, unknown>>> => {
136
+ const mapped = keys.filter((key) => GRAPHQL_MERGE_FIELDS.has(key));
137
+ const [owner, name] = repo.split('/');
138
+ if (mapped.length === 0 || owner === undefined || name === undefined) {
139
+ return {};
140
+ }
141
+ const fields = mapped.map((key) => GRAPHQL_MERGE_FIELDS.get(key)).join(' ');
142
+ const response = await request(token, 'POST', '/graphql', {
143
+ query: `query { repository(owner: ${JSON.stringify(owner)}, name: ${JSON.stringify(name)}) { ${fields} } }`,
144
+ });
145
+ if (response.status !== HTTP_OK || !isRecord(response.body)) {
146
+ return {};
147
+ }
148
+ const data = isRecord(response.body.data) ? response.body.data : null;
149
+ const repository =
150
+ data !== null && isRecord(data.repository) ? data.repository : null;
151
+ if (repository === null) {
152
+ return {};
153
+ }
154
+ return Object.fromEntries(
155
+ mapped
156
+ .map((key) => [key, repository[GRAPHQL_MERGE_FIELDS.get(key) ?? '']])
157
+ .filter(([, value]) => value !== undefined && value !== null),
158
+ );
159
+ };
160
+
113
161
  export type LiveRulesets = {
114
162
  readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
115
163
  readonly problem: string | null;
@@ -10,8 +10,56 @@ import {
10
10
  HTTP_OK,
11
11
  request,
12
12
  } from './github-api';
13
- import { diffRuleset } from './github-diff';
14
- import type { GithubSettings } from './github-settings';
13
+ import { diffRepositorySettings, diffRuleset } from './github-diff';
14
+ import { type GithubSettings, isRecord } from './github-settings-parse';
15
+
16
+ // GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
17
+ // and the old value, so the response body — the updated repository — is
18
+ // diffed again instead of trusting the status code.
19
+ export const applyRepositorySettings = async (
20
+ token: string,
21
+ repo: string,
22
+ repository: Readonly<Record<string, unknown>>,
23
+ liveRepository: Readonly<Record<string, unknown>>,
24
+ ): Promise<ReadonlyArray<string>> => {
25
+ const diff = diffRepositorySettings(repository, liveRepository);
26
+ if (diff.drifted.length === 0 && diff.unverifiable.length === 0) {
27
+ return [];
28
+ }
29
+ const patched = await request(token, 'PATCH', `/repos/${repo}`, repository);
30
+ if (patched.status !== HTTP_OK || !isRecord(patched.body)) {
31
+ throw new Error(apiError('updating repository settings', patched));
32
+ }
33
+ const persisted = diffRepositorySettings(repository, patched.body);
34
+ const ignored = [
35
+ ...persisted.drifted,
36
+ ...persisted.unverifiable.map(
37
+ (key) =>
38
+ `repository setting "${key}" is missing from the update response`,
39
+ ),
40
+ ];
41
+ if (ignored.length > 0) {
42
+ throw new Error(
43
+ `GitHub returned HTTP 200 but ignored part of the update: ${ignored.join('; ')}. A silently ignored setting is usually unavailable on this GitHub plan; if this plan cannot protect branches, declare the ruleset-enforcement opt-out instead`,
44
+ );
45
+ }
46
+ return ['updated repository merge settings'];
47
+ };
48
+
49
+ export const applySummary = (
50
+ repo: string,
51
+ actions: ReadonlyArray<string>,
52
+ rulesetsSkipped: boolean,
53
+ ): string => {
54
+ if (rulesetsSkipped) {
55
+ return actions.length === 0
56
+ ? `standards github: enforceable settings already converged for ${repo}; plan-gated settings skipped`
57
+ : `standards github: enforceable settings apply complete for ${repo}; plan-gated settings skipped`;
58
+ }
59
+ return actions.length === 0
60
+ ? 'standards github: already converged; no changes'
61
+ : `standards github: apply complete for ${repo}`;
62
+ };
15
63
 
16
64
  const reconcileRuleset = async (
17
65
  token: string,
@@ -0,0 +1,45 @@
1
+ import {
2
+ ENFORCEMENT_OPT_OUT,
3
+ type GithubSettings,
4
+ } from './github-settings-parse';
5
+
6
+ // Repository settings that only function alongside branch protection, which
7
+ // the ruleset-enforcement opt-out declares unavailable on the plan. GitHub
8
+ // answers a PATCH for them with HTTP 200 and silently keeps the old value, so
9
+ // they must be skipped, never applied-and-trusted.
10
+ export const PLAN_GATED_REPOSITORY_KEYS: ReadonlySet<string> = new Set([
11
+ 'allow_auto_merge',
12
+ ]);
13
+
14
+ export const enforceableRepositorySettings = (
15
+ declared: GithubSettings,
16
+ ): Readonly<Record<string, unknown>> =>
17
+ declared.rulesetEnforcement === ENFORCEMENT_OPT_OUT
18
+ ? Object.fromEntries(
19
+ Object.entries(declared.repository).filter(
20
+ ([key]) => !PLAN_GATED_REPOSITORY_KEYS.has(key),
21
+ ),
22
+ )
23
+ : declared.repository;
24
+
25
+ export const optOutEligibilityProblem = (
26
+ repo: string,
27
+ declared: GithubSettings,
28
+ liveRepository: Readonly<Record<string, unknown>>,
29
+ ): string | null =>
30
+ declared.rulesetEnforcement === ENFORCEMENT_OPT_OUT &&
31
+ liveRepository.private !== true
32
+ ? `.github/settings.local.json "rulesetEnforcement" may only be declared for a private repository; ${repo} is public`
33
+ : null;
34
+
35
+ // 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.
37
+ export const unverifiableProblem = (
38
+ scope: string,
39
+ items: ReadonlyArray<string>,
40
+ ): ReadonlyArray<string> =>
41
+ items.length === 0
42
+ ? []
43
+ : [
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`,
45
+ ];
@@ -1,20 +1,32 @@
1
1
  // `standards github --check` verifies the live GitHub repository against the
2
- // declared settings and fails closed on any drift or API error. `standards
3
- // github --apply` converges the live repository; it needs an admin token, so
4
- // it runs locally rather than in CI.
2
+ // declared settings and fails closed on any drift, API error, or declared
3
+ // state the token cannot see. `standards github --apply` converges the live
4
+ // repository; it needs an admin token, so it runs locally rather than in CI.
5
5
 
6
6
  import {
7
7
  apiError,
8
- fetchLiveRulesets,
9
8
  HTTP_OK,
10
9
  loadDeclared,
11
10
  request,
12
11
  resolveGithubRepo,
13
12
  resolveToken,
14
13
  } from './github-api';
15
- import { applyRulesets } from './github-apply';
16
- import { diffRepositorySettings, diffRulesets } from './github-diff';
17
- import { type GithubSettings, isRecord } from './github-settings';
14
+ import {
15
+ applyRepositorySettings,
16
+ applyRulesets,
17
+ applySummary,
18
+ } from './github-apply';
19
+ import {
20
+ enforceableRepositorySettings,
21
+ optOutEligibilityProblem,
22
+ } from './github-command-shared';
23
+ import { collectLiveDrift } from './github-live-drift';
24
+ import { isRecord } from './github-settings-parse';
25
+
26
+ // Printed on every check and apply while the opt-out is declared: the skip
27
+ // must stay louder than the comfort of a green gate.
28
+ const UNENFORCEABLE_NOTICE =
29
+ 'standards github: rulesets are declared unenforceable on this GitHub plan (.github/settings.local.json "rulesetEnforcement"); the default branch is NOT protected, and plan-gated repository settings ("allow_auto_merge") are skipped. After upgrading the plan, remove the declaration, then run `bun standards github --apply`.';
18
30
 
19
31
  const reportProblems = (problems: ReadonlyArray<string>): void => {
20
32
  console.error(
@@ -23,54 +35,11 @@ const reportProblems = (problems: ReadonlyArray<string>): void => {
23
35
  console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
24
36
  };
25
37
 
26
- const collectLiveDrift = async (
27
- consumer: string,
28
- declared: GithubSettings,
29
- ): Promise<ReadonlyArray<string>> => {
30
- const repo = resolveGithubRepo(consumer);
31
- if (repo === null) {
32
- return ['cannot determine the GitHub repository from the origin remote'];
33
- }
34
- const token = resolveToken();
35
- const problems: Array<string> = [];
36
- try {
37
- const repoResponse = await request(token, 'GET', `/repos/${repo}`);
38
- if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
39
- problems.push(apiError(`reading repository ${repo}`, repoResponse));
40
- } else {
41
- const diff = diffRepositorySettings(
42
- declared.repository,
43
- repoResponse.body,
44
- );
45
- problems.push(...diff.drifted);
46
- if (diff.unverifiable.length > 0) {
47
- console.log(
48
- `standards github: repository setting(s) not visible to this token, verify with admin auth: ${diff.unverifiable.join(', ')}`,
49
- );
50
- }
51
- }
52
- const live = await fetchLiveRulesets(token, repo);
53
- if (live.rulesets === null) {
54
- problems.push(live.problem ?? 'unable to read rulesets');
55
- } else {
56
- const rulesetDiff = diffRulesets(declared.rulesets, live.rulesets);
57
- problems.push(...rulesetDiff.drifted);
58
- if (rulesetDiff.unverifiable.length > 0) {
59
- console.log(
60
- `standards github: ruleset field(s) not visible to this token, verify with admin auth: ${rulesetDiff.unverifiable.join('; ')}`,
61
- );
62
- }
63
- }
64
- } catch (error) {
65
- problems.push(
66
- `GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
67
- );
68
- }
69
- return problems;
70
- };
71
-
72
38
  export const runGithubCheck = async (consumer: string): Promise<boolean> => {
73
39
  const declared = await loadDeclared(consumer);
40
+ if (declared.merged?.rulesetEnforcement === 'unavailable-on-plan') {
41
+ console.log(UNENFORCEABLE_NOTICE);
42
+ }
74
43
  const problems = [...declared.problems];
75
44
  if (declared.merged !== null) {
76
45
  problems.push(...(await collectLiveDrift(consumer, declared.merged)));
@@ -83,13 +52,18 @@ export const runGithubCheck = async (consumer: string): Promise<boolean> => {
83
52
  return false;
84
53
  }
85
54
  console.log(
86
- 'standards github: live GitHub settings match the declared configuration',
55
+ declared.merged?.rulesetEnforcement === 'unavailable-on-plan'
56
+ ? 'standards github: live repository settings match the declared configuration (plan-gated settings skipped)'
57
+ : 'standards github: live GitHub settings match the declared configuration',
87
58
  );
88
59
  return true;
89
60
  };
90
61
 
91
62
  export const runGithubApply = async (consumer: string): Promise<boolean> => {
92
63
  const declared = await loadDeclared(consumer);
64
+ if (declared.merged?.rulesetEnforcement === 'unavailable-on-plan') {
65
+ console.log(UNENFORCEABLE_NOTICE);
66
+ }
93
67
  if (declared.merged === null || declared.problems.length > 0) {
94
68
  reportProblems(declared.problems);
95
69
  return false;
@@ -109,36 +83,35 @@ export const runGithubApply = async (consumer: string): Promise<boolean> => {
109
83
  return false;
110
84
  }
111
85
  try {
112
- const actions: Array<string> = [];
113
86
  const repoResponse = await request(token, 'GET', `/repos/${repo}`);
114
87
  if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
115
88
  throw new Error(apiError(`reading repository ${repo}`, repoResponse));
116
89
  }
117
- const diff = diffRepositorySettings(
118
- declared.merged.repository,
90
+ const eligibilityProblem = optOutEligibilityProblem(
91
+ repo,
92
+ declared.merged,
119
93
  repoResponse.body,
120
94
  );
121
- if (diff.drifted.length > 0 || diff.unverifiable.length > 0) {
122
- const patched = await request(
95
+ if (eligibilityProblem !== null) {
96
+ throw new Error(eligibilityProblem);
97
+ }
98
+ const actions = [
99
+ ...(await applyRepositorySettings(
123
100
  token,
124
- 'PATCH',
125
- `/repos/${repo}`,
126
- declared.merged.repository,
127
- );
128
- if (patched.status !== HTTP_OK) {
129
- throw new Error(apiError('updating repository settings', patched));
130
- }
131
- actions.push('updated repository merge settings');
101
+ repo,
102
+ enforceableRepositorySettings(declared.merged),
103
+ repoResponse.body,
104
+ )),
105
+ ];
106
+ const rulesetsSkipped =
107
+ declared.merged.rulesetEnforcement === 'unavailable-on-plan';
108
+ if (!rulesetsSkipped) {
109
+ actions.push(...(await applyRulesets(token, repo, declared.merged)));
132
110
  }
133
- actions.push(...(await applyRulesets(token, repo, declared.merged)));
134
111
  for (const action of actions) {
135
112
  console.log(` ${action}`);
136
113
  }
137
- console.log(
138
- actions.length === 0
139
- ? 'standards github: already converged; no changes'
140
- : `standards github: apply complete for ${repo}`,
141
- );
114
+ console.log(applySummary(repo, actions, rulesetsSkipped));
142
115
  return true;
143
116
  } catch (error) {
144
117
  console.error(
@@ -1,7 +1,7 @@
1
1
  // Drift comparison between declared GitHub settings (github-settings.ts) and
2
2
  // the live state returned by the GitHub API. Pure logic; no network.
3
3
 
4
- import { isRecord } from './github-settings';
4
+ import { isRecord } from './github-settings-parse';
5
5
 
6
6
  export type SettingsDiff = {
7
7
  readonly drifted: ReadonlyArray<string>;
@@ -89,8 +89,8 @@ const diffRules = (
89
89
  // Some ruleset fields — bypass_actors in particular — are only included in
90
90
  // API responses for admin viewers. A declared key that is absent on the live
91
91
  // side is unverifiable for this token, not drift: the same policy as
92
- // repository merge settings, so a non-admin CI token does not fail the gate
93
- // on state it cannot see.
92
+ // repository merge settings, so callers can fail with a targeted
93
+ // missing-visibility message instead of a bogus value mismatch.
94
94
  export const diffRuleset = (
95
95
  declared: Readonly<Record<string, unknown>>,
96
96
  live: Readonly<Record<string, unknown>>,
@@ -150,9 +150,9 @@ export const diffRulesets = (
150
150
  };
151
151
 
152
152
  // Repo merge settings are only visible to admin tokens; report invisible keys
153
- // as unverifiable instead of drifted so a non-admin CI token does not fail the
154
- // gate, while still surfacing that only a local admin check gives full
155
- // coverage.
153
+ // as unverifiable instead of drifted so callers fail the gate with a
154
+ // missing-visibility message that names the token fix, not a bogus value
155
+ // mismatch.
156
156
  export const diffRepositorySettings = (
157
157
  declared: Readonly<Record<string, unknown>>,
158
158
  live: Readonly<Record<string, unknown>>,
@@ -0,0 +1,99 @@
1
+ // Drift collection for `standards github --check`: compares the live GitHub
2
+ // repository against the merged declaration and fails closed on state the
3
+ // token cannot see. Command wiring lives in github-commands.ts.
4
+
5
+ import {
6
+ apiError,
7
+ fetchLiveRulesets,
8
+ fetchMergeSettingsViaGraphql,
9
+ HTTP_OK,
10
+ request,
11
+ resolveGithubRepo,
12
+ resolveToken,
13
+ } from './github-api';
14
+ import {
15
+ enforceableRepositorySettings,
16
+ optOutEligibilityProblem,
17
+ unverifiableProblem,
18
+ } from './github-command-shared';
19
+ import { diffRepositorySettings, diffRulesets } from './github-diff';
20
+ import { type GithubSettings, isRecord } from './github-settings-parse';
21
+
22
+ const repositoryDrift = async (
23
+ token: string | null,
24
+ repo: string,
25
+ declared: GithubSettings,
26
+ ): Promise<ReadonlyArray<string>> => {
27
+ const repoResponse = await request(token, 'GET', `/repos/${repo}`);
28
+ if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
29
+ return [apiError(`reading repository ${repo}`, repoResponse)];
30
+ }
31
+ const eligibilityProblem = optOutEligibilityProblem(
32
+ repo,
33
+ declared,
34
+ repoResponse.body,
35
+ );
36
+ if (eligibilityProblem !== null) {
37
+ return [eligibilityProblem];
38
+ }
39
+ const declaredRepository = enforceableRepositorySettings(declared);
40
+ const diff = diffRepositorySettings(declaredRepository, repoResponse.body);
41
+ if (diff.unverifiable.length === 0) {
42
+ return diff.drifted;
43
+ }
44
+ // REST hides merge settings from read-only tokens; retry the invisible
45
+ // keys over GraphQL before failing the gate.
46
+ const fallback = await fetchMergeSettingsViaGraphql(
47
+ token,
48
+ repo,
49
+ diff.unverifiable,
50
+ );
51
+ const rediff = diffRepositorySettings(declaredRepository, {
52
+ ...repoResponse.body,
53
+ ...fallback,
54
+ });
55
+ return [
56
+ ...rediff.drifted,
57
+ ...unverifiableProblem('repository setting(s)', rediff.unverifiable),
58
+ ];
59
+ };
60
+
61
+ const rulesetDrift = async (
62
+ token: string | null,
63
+ repo: string,
64
+ declared: GithubSettings,
65
+ ): Promise<ReadonlyArray<string>> => {
66
+ if (declared.rulesetEnforcement === 'unavailable-on-plan') {
67
+ return [];
68
+ }
69
+ const live = await fetchLiveRulesets(token, repo);
70
+ if (live.rulesets === null) {
71
+ return [live.problem ?? 'unable to read rulesets'];
72
+ }
73
+ const diff = diffRulesets(declared.rulesets, live.rulesets);
74
+ return [
75
+ ...diff.drifted,
76
+ ...unverifiableProblem('ruleset field(s)', diff.unverifiable),
77
+ ];
78
+ };
79
+
80
+ export const collectLiveDrift = async (
81
+ consumer: string,
82
+ declared: GithubSettings,
83
+ ): Promise<ReadonlyArray<string>> => {
84
+ const repo = resolveGithubRepo(consumer);
85
+ if (repo === null) {
86
+ return ['cannot determine the GitHub repository from the origin remote'];
87
+ }
88
+ const token = resolveToken();
89
+ try {
90
+ return [
91
+ ...(await repositoryDrift(token, repo, declared)),
92
+ ...(await rulesetDrift(token, repo, declared)),
93
+ ];
94
+ } catch (error) {
95
+ return [
96
+ `GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
97
+ ];
98
+ }
99
+ };
@@ -0,0 +1,120 @@
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.
6
+
7
+ // GitHub only enforces rulesets on private repositories on paid plans. The
8
+ // seam can declare that fact so the gate skips rulesets loudly instead of
9
+ // failing closed forever (personal accounts) or trusting rulesets the API
10
+ // reports as active but a free-plan organization silently does not enforce.
11
+ export type RulesetEnforcement = 'enforced' | 'unavailable-on-plan';
12
+
13
+ export type GithubSettings = {
14
+ readonly repository: Readonly<Record<string, unknown>>;
15
+ readonly rulesets: ReadonlyArray<Readonly<Record<string, unknown>>>;
16
+ readonly rulesetEnforcement: RulesetEnforcement;
17
+ };
18
+
19
+ export type ParsedGithubSettings = {
20
+ readonly repository: Readonly<Record<string, unknown>> | null;
21
+ readonly rulesets: ReadonlyArray<unknown> | null;
22
+ readonly rulesetEnforcement: RulesetEnforcement | null;
23
+ };
24
+
25
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
26
+ typeof value === 'object' && value !== null && !Array.isArray(value);
27
+
28
+ export const isNamedRuleset = (
29
+ value: unknown,
30
+ ): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
31
+ isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
32
+
33
+ export const SETTINGS_KEYS: ReadonlySet<string> = new Set([
34
+ 'repository',
35
+ 'rulesets',
36
+ ]);
37
+ export const LOCAL_SETTINGS_KEYS: ReadonlySet<string> = new Set([
38
+ ...SETTINGS_KEYS,
39
+ 'rulesetEnforcement',
40
+ ]);
41
+
42
+ export const ENFORCEMENT_OPT_OUT = 'unavailable-on-plan';
43
+
44
+ export type ParseResult = {
45
+ readonly settings: ParsedGithubSettings | null;
46
+ readonly problems: ReadonlyArray<string>;
47
+ };
48
+
49
+ const parseRulesetEnforcement = (value: unknown): RulesetEnforcement | null => {
50
+ if (value === undefined) {
51
+ return 'enforced';
52
+ }
53
+ return value === ENFORCEMENT_OPT_OUT ? ENFORCEMENT_OPT_OUT : null;
54
+ };
55
+
56
+ const rulesetListProblems = (
57
+ rulesets: ReadonlyArray<unknown>,
58
+ label: string,
59
+ ): ReadonlyArray<string> => {
60
+ const problems: Array<string> = [];
61
+ const names = new Set<string>();
62
+ for (const [index, ruleset] of rulesets.entries()) {
63
+ if (!isNamedRuleset(ruleset)) {
64
+ problems.push(
65
+ `${label} rulesets[${index}] must be an object with a non-empty "name"`,
66
+ );
67
+ } else if (names.has(ruleset.name)) {
68
+ problems.push(
69
+ `${label} declares ruleset "${ruleset.name}" more than once`,
70
+ );
71
+ } else {
72
+ names.add(ruleset.name);
73
+ }
74
+ }
75
+ return problems;
76
+ };
77
+
78
+ export const parseSettings = (
79
+ raw: unknown,
80
+ label: string,
81
+ allowedKeys: ReadonlySet<string>,
82
+ ): ParseResult => {
83
+ if (!isRecord(raw)) {
84
+ return { settings: null, problems: [`${label} must be a JSON object`] };
85
+ }
86
+ const problems: Array<string> = [];
87
+ for (const key of Object.keys(raw)) {
88
+ if (!allowedKeys.has(key)) {
89
+ problems.push(`${label} has unknown key "${key}"`);
90
+ }
91
+ }
92
+ const repository = raw.repository ?? {};
93
+ if (!isRecord(repository)) {
94
+ problems.push(`${label} "repository" must be an object`);
95
+ }
96
+ const rulesets = raw.rulesets ?? [];
97
+ if (Array.isArray(rulesets)) {
98
+ problems.push(...rulesetListProblems(rulesets, label));
99
+ } else {
100
+ problems.push(`${label} "rulesets" must be an array`);
101
+ }
102
+ // Only the opt-out value is accepted: enforcement is the default, and an
103
+ // explicit "enforced" would be a second way to spell the same state.
104
+ if (
105
+ raw.rulesetEnforcement !== undefined &&
106
+ raw.rulesetEnforcement !== ENFORCEMENT_OPT_OUT
107
+ ) {
108
+ problems.push(
109
+ `${label} "rulesetEnforcement" must be "${ENFORCEMENT_OPT_OUT}" when present; omit it on plans where GitHub enforces rulesets`,
110
+ );
111
+ }
112
+ return {
113
+ settings: {
114
+ repository: isRecord(repository) ? repository : null,
115
+ rulesets: Array.isArray(rulesets) ? rulesets : null,
116
+ rulesetEnforcement: parseRulesetEnforcement(raw.rulesetEnforcement),
117
+ },
118
+ problems,
119
+ };
120
+ };
@@ -1,125 +1,134 @@
1
- // Declarative GitHub repository settings: parsing and seam merging for the
2
- // canonical `.github/settings.json` and the repo-owned
3
- // `.github/settings.local.json` extension. Pure logic only; drift comparison
4
- // lives in github-diff.ts and the API interaction in github-api.ts. Like
5
- // cli.ts, this module is zero-dependency so `bunx` can execute the published
6
- // package.
1
+ // Merge canonical GitHub settings with the repo-owned extension. Parsing,
2
+ // drift, and API work live in named modules; this stays dependency-free.
7
3
 
8
- export type GithubSettings = {
9
- readonly repository: Readonly<Record<string, unknown>>;
10
- readonly rulesets: ReadonlyArray<Readonly<Record<string, unknown>>>;
11
- };
4
+ import {
5
+ ENFORCEMENT_OPT_OUT,
6
+ type GithubSettings,
7
+ isNamedRuleset,
8
+ LOCAL_SETTINGS_KEYS,
9
+ type ParsedGithubSettings,
10
+ parseSettings,
11
+ SETTINGS_KEYS,
12
+ } from './github-settings-parse';
12
13
 
13
14
  export type LoadedGithubSettings = {
14
15
  readonly merged: GithubSettings | null;
15
16
  readonly problems: ReadonlyArray<string>;
16
17
  };
17
18
 
18
- export const isRecord = (value: unknown): value is Record<string, unknown> =>
19
- typeof value === 'object' && value !== null && !Array.isArray(value);
20
-
21
- const SETTINGS_KEYS = new Set(['repository', 'rulesets']);
22
-
23
- type ParseResult = {
24
- readonly settings: GithubSettings | null;
25
- readonly problems: ReadonlyArray<string>;
19
+ const completeSettings = (
20
+ parsed: ParsedGithubSettings | null,
21
+ ): GithubSettings | null => {
22
+ if (
23
+ parsed === null ||
24
+ parsed.repository === null ||
25
+ parsed.rulesets === null ||
26
+ parsed.rulesetEnforcement === null
27
+ ) {
28
+ return null;
29
+ }
30
+ const rulesets = parsed.rulesets.filter(isNamedRuleset);
31
+ if (rulesets.length !== parsed.rulesets.length) {
32
+ return null;
33
+ }
34
+ return {
35
+ repository: parsed.repository,
36
+ rulesets,
37
+ rulesetEnforcement: parsed.rulesetEnforcement,
38
+ };
26
39
  };
27
40
 
28
- const rulesetListProblems = (
29
- rulesets: ReadonlyArray<unknown>,
30
- label: string,
41
+ const enforcementMergeProblems = (
42
+ local: ParsedGithubSettings | null,
31
43
  ): ReadonlyArray<string> => {
32
- const problems: Array<string> = [];
33
- const names = new Set<string>();
34
- for (const [index, ruleset] of rulesets.entries()) {
35
- if (
36
- !(
37
- isRecord(ruleset) &&
38
- typeof ruleset.name === 'string' &&
39
- ruleset.name.length > 0
40
- )
41
- ) {
42
- problems.push(
43
- `${label} rulesets[${index}] must be an object with a non-empty "name"`,
44
- );
45
- } else if (names.has(ruleset.name)) {
46
- problems.push(
47
- `${label} declares ruleset "${ruleset.name}" more than once`,
48
- );
49
- } else {
50
- names.add(ruleset.name);
51
- }
44
+ if (
45
+ local?.rulesetEnforcement !== ENFORCEMENT_OPT_OUT ||
46
+ local.rulesets === null ||
47
+ local.rulesets.length === 0
48
+ ) {
49
+ return [];
52
50
  }
53
- return problems;
51
+ return [
52
+ `.github/settings.local.json declares additional rulesets while "rulesetEnforcement" is "${ENFORCEMENT_OPT_OUT}"; remove the rulesets or the opt-out`,
53
+ ];
54
54
  };
55
55
 
56
- const parseSettings = (raw: unknown, label: string): ParseResult => {
57
- if (!isRecord(raw)) {
58
- return { settings: null, problems: [`${label} must be a JSON object`] };
59
- }
60
- const problems: Array<string> = [];
61
- for (const key of Object.keys(raw)) {
62
- if (!SETTINGS_KEYS.has(key)) {
63
- problems.push(`${label} has unknown key "${key}"`);
64
- }
65
- }
66
- const repository = raw.repository ?? {};
67
- if (!isRecord(repository)) {
68
- problems.push(`${label} "repository" must be an object`);
69
- }
70
- const rulesets = raw.rulesets ?? [];
71
- if (Array.isArray(rulesets)) {
72
- problems.push(...rulesetListProblems(rulesets, label));
73
- } else {
74
- problems.push(`${label} "rulesets" must be an array`);
75
- }
76
- if (problems.length > 0) {
77
- return { settings: null, problems };
56
+ const repositoryMergeProblems = (
57
+ canonical: ParsedGithubSettings | null,
58
+ local: ParsedGithubSettings | null,
59
+ ): ReadonlyArray<string> => {
60
+ if (
61
+ canonical === null ||
62
+ canonical.repository === null ||
63
+ local === null ||
64
+ local.repository === null
65
+ ) {
66
+ return [];
78
67
  }
79
- return {
80
- settings: {
81
- repository: repository as Record<string, unknown>,
82
- rulesets: rulesets as ReadonlyArray<Record<string, unknown>>,
83
- },
84
- problems: [],
85
- };
68
+ const canonicalRepository = canonical.repository;
69
+ return Object.keys(local.repository)
70
+ .filter((key) => key in canonicalRepository)
71
+ .map(
72
+ (key) =>
73
+ `.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
74
+ );
86
75
  };
87
76
 
88
- type MergeResult = {
89
- readonly merged: GithubSettings | null;
90
- readonly problems: ReadonlyArray<string>;
77
+ const rulesetMergeProblems = (
78
+ canonical: ParsedGithubSettings | null,
79
+ local: ParsedGithubSettings | null,
80
+ ): ReadonlyArray<string> => {
81
+ if (
82
+ canonical === null ||
83
+ canonical.rulesets === null ||
84
+ local === null ||
85
+ local.rulesets === null
86
+ ) {
87
+ return [];
88
+ }
89
+ const canonicalNames = new Set(
90
+ canonical.rulesets.filter(isNamedRuleset).map((ruleset) => ruleset.name),
91
+ );
92
+ return local.rulesets
93
+ .filter(isNamedRuleset)
94
+ .filter((ruleset) => canonicalNames.has(ruleset.name))
95
+ .map(
96
+ (ruleset) =>
97
+ `.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
98
+ );
91
99
  };
92
100
 
93
101
  // The seam may only add. Overriding a canonical repository key or redefining a
94
102
  // canonical ruleset could weaken the canonical floor; GitHub layers multiple
95
- // rulesets strictest-wins, so adding a ruleset is always safe.
103
+ // rulesets strictest-wins, so adding a ruleset is always safe. The one
104
+ // subtractive declaration is the ruleset-enforcement opt-out, which skips the
105
+ // ruleset gate entirely rather than weakening any single rule.
96
106
  const mergeSettings = (
97
- canonical: GithubSettings,
98
- local: GithubSettings,
99
- ): MergeResult => {
100
- const problems: Array<string> = [];
101
- for (const key of Object.keys(local.repository)) {
102
- if (key in canonical.repository) {
103
- problems.push(
104
- `.github/settings.local.json repository."${key}" would override a canonical value; canonical settings are read-only`,
105
- );
106
- }
107
- }
108
- const canonicalNames = new Set(canonical.rulesets.map((r) => r.name));
109
- for (const ruleset of local.rulesets) {
110
- if (canonicalNames.has(ruleset.name)) {
111
- problems.push(
112
- `.github/settings.local.json ruleset "${ruleset.name}" collides with a canonical ruleset; add a separately named ruleset to tighten further`,
113
- );
114
- }
115
- }
116
- if (problems.length > 0) {
107
+ canonical: ParsedGithubSettings | null,
108
+ local: ParsedGithubSettings | null,
109
+ ) => {
110
+ const problems = [
111
+ ...enforcementMergeProblems(local),
112
+ ...repositoryMergeProblems(canonical, local),
113
+ ...rulesetMergeProblems(canonical, local),
114
+ ];
115
+ const completeCanonical = completeSettings(canonical);
116
+ const completeLocal = completeSettings(local);
117
+ if (
118
+ problems.length > 0 ||
119
+ completeCanonical === null ||
120
+ completeLocal === null
121
+ ) {
117
122
  return { merged: null, problems };
118
123
  }
119
124
  return {
120
125
  merged: {
121
- repository: { ...canonical.repository, ...local.repository },
122
- rulesets: [...canonical.rulesets, ...local.rulesets],
126
+ repository: {
127
+ ...completeCanonical.repository,
128
+ ...completeLocal.repository,
129
+ },
130
+ rulesets: [...completeCanonical.rulesets, ...completeLocal.rulesets],
131
+ rulesetEnforcement: completeLocal.rulesetEnforcement,
123
132
  },
124
133
  problems: [],
125
134
  };
@@ -160,15 +169,32 @@ export const loadGithubSettings = (
160
169
  if (localJson !== null && localJson.problem !== null) {
161
170
  problems.push(localJson.problem);
162
171
  }
163
- if (problems.length > 0) {
164
- return { merged: null, problems };
172
+ const canonical =
173
+ canonicalJson.problem === null
174
+ ? parseSettings(
175
+ canonicalJson.value,
176
+ '.github/settings.json',
177
+ SETTINGS_KEYS,
178
+ )
179
+ : null;
180
+ const local =
181
+ localJson?.problem === null
182
+ ? parseSettings(
183
+ localJson.value,
184
+ '.github/settings.local.json',
185
+ LOCAL_SETTINGS_KEYS,
186
+ )
187
+ : null;
188
+ if (canonical !== null) {
189
+ problems.push(...canonical.problems);
165
190
  }
166
- const canonical = parseSettings(canonicalJson.value, '.github/settings.json');
167
- const local = parseSettings(localJson?.value, '.github/settings.local.json');
168
- problems.push(...canonical.problems, ...local.problems);
169
- if (canonical.settings === null || local.settings === null) {
170
- return { merged: null, problems };
191
+ if (local !== null) {
192
+ problems.push(...local.problems);
171
193
  }
172
- const merged = mergeSettings(canonical.settings, local.settings);
173
- return { merged: merged.merged, problems: [...problems, ...merged.problems] };
194
+ const merged = mergeSettings(
195
+ canonical?.settings ?? null,
196
+ local?.settings ?? null,
197
+ );
198
+ problems.push(...merged.problems);
199
+ return { merged: problems.length === 0 ? merged.merged : null, problems };
174
200
  };
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { isAbsolute, join } from 'node:path';
4
- import { isRecord } from './github-settings';
4
+ import { isRecord } from './github-settings-parse';
5
5
  import {
6
6
  missingPublishedCliProblems,
7
7
  rootScriptExpectations,
@@ -1,4 +1,4 @@
1
- import { isRecord } from './github-settings';
1
+ import { isRecord } from './github-settings-parse';
2
2
 
3
3
  // Structure validation profiles. `consumer` is the contract every downstream
4
4
  // repo satisfies. `source` is the standards template repository itself, which
@@ -1,6 +1,6 @@
1
1
  import { readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { isRecord } from './github-settings';
3
+ import { isRecord } from './github-settings-parse';
4
4
  import {
5
5
  inspectVersionAndExports,
6
6
  type StructureProfile,