@davidvornholt/standards 0.1.0 → 0.3.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
@@ -1,14 +1,20 @@
1
1
  # @davidvornholt/standards
2
2
 
3
- CLI for bootstrapping, synchronizing, and validating repositories that consume
4
- [davidvornholt/standards](https://github.com/davidvornholt/standards).
3
+ CLI for bootstrapping, synchronizing, and validating repositories that consume [davidvornholt/standards](https://github.com/davidvornholt/standards).
5
4
 
6
5
  ```sh
7
6
  bunx @davidvornholt/standards init
8
7
  standards sync
9
8
  standards check
10
9
  standards doctor
10
+ standards github --check
11
+ standards github --apply
11
12
  ```
12
13
 
13
- See the standards repository README for the ownership model and adoption
14
- workflow.
14
+ `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
+
16
+ ## Configuration
17
+
18
+ - **`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.
19
+
20
+ See the standards repository README for the ownership model and adoption workflow.
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/davidvornholt/standards.git",
9
+ "directory": "packages/standards-cli"
10
+ },
11
+ "homepage": "https://github.com/davidvornholt/standards#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/davidvornholt/standards/issues"
14
+ },
6
15
  "type": "module",
7
16
  "bin": {
8
17
  "standards": "src/cli.ts"
@@ -10,7 +19,12 @@
10
19
  "files": [
11
20
  "LICENSE",
12
21
  "README.md",
13
- "src/cli.ts"
22
+ "src/cli.ts",
23
+ "src/github-api.ts",
24
+ "src/github-apply.ts",
25
+ "src/github-commands.ts",
26
+ "src/github-diff.ts",
27
+ "src/github-settings.ts"
14
28
  ],
15
29
  "scripts": {
16
30
  "lint": "biome check --error-on-warnings .",
@@ -19,7 +33,7 @@
19
33
  "test": "bun test"
20
34
  },
21
35
  "devDependencies": {
22
- "@davidvornholt/typescript-config": "workspace:*",
36
+ "@davidvornholt/typescript-config": "0.0.0",
23
37
  "@types/bun": "^1.3.14",
24
38
  "typescript": "7.0.2"
25
39
  },
package/src/cli.ts CHANGED
@@ -32,6 +32,11 @@ import {
32
32
  sep,
33
33
  } from 'node:path';
34
34
  import process from 'node:process';
35
+ import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
36
+ import { runGithubApply, runGithubCheck } from './github-commands';
37
+ import { loadGithubSettings } from './github-settings';
38
+
39
+ const { YAML: BunYaml } = await import('bun');
35
40
 
36
41
  const DEFAULT_UPSTREAM = 'github:davidvornholt/standards';
37
42
 
@@ -69,13 +74,14 @@ type Source = {
69
74
  readonly cleanup: () => void;
70
75
  };
71
76
 
72
- type Command = 'check' | 'doctor' | 'init' | 'sync';
77
+ type Command = 'check' | 'doctor' | 'github' | 'init' | 'sync';
73
78
 
74
79
  type CliOptions = {
75
80
  readonly command: Command;
76
81
  readonly consumer: string;
77
82
  readonly dryRun: boolean;
78
83
  readonly from: string | undefined;
84
+ readonly apply: boolean;
79
85
  };
80
86
 
81
87
  const sha256 = (buf: Buffer): string =>
@@ -477,6 +483,179 @@ const hasStandardsImport = (justfile: string): boolean =>
477
483
  );
478
484
  });
479
485
 
486
+ const DEPENDABOT_BASELINE_ECOSYSTEMS = ['bun', 'github-actions'] as const;
487
+ const DEPENDABOT_SCHEDULE_INTERVALS = new Set([
488
+ 'daily',
489
+ 'weekly',
490
+ 'monthly',
491
+ 'quarterly',
492
+ 'semiannually',
493
+ 'yearly',
494
+ 'cron',
495
+ ]);
496
+
497
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
498
+ typeof value === 'object' && value !== null && !Array.isArray(value);
499
+
500
+ const isNonEmptyString = (value: unknown): value is string =>
501
+ typeof value === 'string' && value.length > 0;
502
+
503
+ type DependabotUpdateInspection = {
504
+ readonly problems: ReadonlyArray<string>;
505
+ readonly rootEcosystem: string | null;
506
+ };
507
+
508
+ const inspectDependabotSchedule = (
509
+ schedule: unknown,
510
+ label: string,
511
+ ): ReadonlyArray<string> => {
512
+ if (!(isRecord(schedule) && isNonEmptyString(schedule.interval))) {
513
+ return [`${label} must define schedule.interval`];
514
+ }
515
+ if (!DEPENDABOT_SCHEDULE_INTERVALS.has(schedule.interval)) {
516
+ return [`${label} has an unsupported schedule.interval`];
517
+ }
518
+ if (schedule.interval === 'cron' && !isNonEmptyString(schedule.cronjob)) {
519
+ return [`${label} must define schedule.cronjob for a cron interval`];
520
+ }
521
+ return [];
522
+ };
523
+
524
+ type DependabotGroupInspection = {
525
+ readonly problems: ReadonlyArray<string>;
526
+ readonly scheduledGroups: ReadonlySet<string>;
527
+ };
528
+
529
+ const inspectDependabotGroups = (
530
+ groups: unknown,
531
+ ): DependabotGroupInspection => {
532
+ if (groups === undefined) {
533
+ return { problems: [], scheduledGroups: new Set() };
534
+ }
535
+ if (!isRecord(groups)) {
536
+ return {
537
+ problems: [
538
+ '.github/dependabot.yml multi-ecosystem-groups must be a mapping',
539
+ ],
540
+ scheduledGroups: new Set(),
541
+ };
542
+ }
543
+
544
+ const problems: Array<string> = [];
545
+ const scheduledGroups = new Set<string>();
546
+ for (const [name, group] of Object.entries(groups)) {
547
+ const label = `.github/dependabot.yml multi-ecosystem-groups.${name}`;
548
+ const groupProblems = isRecord(group)
549
+ ? inspectDependabotSchedule(group.schedule, label)
550
+ : [`${label} must be a mapping`];
551
+ problems.push(...groupProblems);
552
+ if (groupProblems.length === 0) {
553
+ scheduledGroups.add(name);
554
+ }
555
+ }
556
+ return { problems, scheduledGroups };
557
+ };
558
+
559
+ const inspectDependabotUpdate = (
560
+ update: unknown,
561
+ index: number,
562
+ scheduledGroups: ReadonlySet<string>,
563
+ ): DependabotUpdateInspection => {
564
+ const label = `.github/dependabot.yml updates[${index}]`;
565
+ if (!isRecord(update)) {
566
+ return { problems: [`${label} must be a mapping`], rootEcosystem: null };
567
+ }
568
+
569
+ const {
570
+ directory,
571
+ directories,
572
+ schedule,
573
+ 'multi-ecosystem-group': multiEcosystemGroup,
574
+ 'package-ecosystem': ecosystem,
575
+ } = update;
576
+ const problems: Array<string> = [];
577
+ if (!isNonEmptyString(ecosystem)) {
578
+ problems.push(`${label} must define package-ecosystem`);
579
+ }
580
+
581
+ const hasDirectory = isNonEmptyString(directory);
582
+ const hasDirectories =
583
+ Array.isArray(directories) &&
584
+ directories.length > 0 &&
585
+ directories.every(isNonEmptyString);
586
+ if (hasDirectory === hasDirectories) {
587
+ problems.push(
588
+ `${label} must define exactly one of directory or directories`,
589
+ );
590
+ }
591
+
592
+ if (schedule === undefined && isNonEmptyString(multiEcosystemGroup)) {
593
+ if (!scheduledGroups.has(multiEcosystemGroup)) {
594
+ problems.push(
595
+ `${label} must reference a scheduled multi-ecosystem group`,
596
+ );
597
+ }
598
+ } else {
599
+ problems.push(...inspectDependabotSchedule(schedule, label));
600
+ }
601
+
602
+ const targetsRoot =
603
+ directory === '/' ||
604
+ (Array.isArray(directories) && directories.includes('/'));
605
+ return {
606
+ problems,
607
+ rootEcosystem:
608
+ isNonEmptyString(ecosystem) && targetsRoot ? ecosystem : null,
609
+ };
610
+ };
611
+
612
+ const inspectDependabot = (raw: string): ReadonlyArray<string> => {
613
+ const problems: Array<string> = [];
614
+ let config: unknown;
615
+ try {
616
+ config = BunYaml.parse(raw);
617
+ } catch {
618
+ return ['.github/dependabot.yml must contain valid YAML'];
619
+ }
620
+
621
+ if (!isRecord(config)) {
622
+ return ['.github/dependabot.yml must contain a YAML mapping'];
623
+ }
624
+ if (config.version !== 2) {
625
+ problems.push('.github/dependabot.yml must use version: 2');
626
+ }
627
+
628
+ const { updates, 'multi-ecosystem-groups': multiEcosystemGroups } = config;
629
+ if (!Array.isArray(updates)) {
630
+ problems.push('.github/dependabot.yml must define an updates list');
631
+ return problems;
632
+ }
633
+
634
+ const groupInspection = inspectDependabotGroups(multiEcosystemGroups);
635
+ problems.push(...groupInspection.problems);
636
+ const rootEcosystems = new Set<string>();
637
+ for (const [index, update] of updates.entries()) {
638
+ const inspection = inspectDependabotUpdate(
639
+ update,
640
+ index,
641
+ groupInspection.scheduledGroups,
642
+ );
643
+ problems.push(...inspection.problems);
644
+ if (inspection.rootEcosystem !== null) {
645
+ rootEcosystems.add(inspection.rootEcosystem);
646
+ }
647
+ }
648
+
649
+ for (const ecosystem of DEPENDABOT_BASELINE_ECOSYSTEMS) {
650
+ if (!rootEcosystems.has(ecosystem)) {
651
+ problems.push(
652
+ `.github/dependabot.yml must include a root-directory ${ecosystem} ecosystem`,
653
+ );
654
+ }
655
+ }
656
+ return problems;
657
+ };
658
+
480
659
  const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
481
660
  const problems: Array<string> = [];
482
661
  const packageJson = JSON.parse(packageRaw) as Record<string, unknown>;
@@ -514,6 +693,15 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
514
693
  problems.push('AGENTS.local.md must exist for project-specific guidance');
515
694
  }
516
695
 
696
+ const dependabot = await readTextIfPresent(
697
+ join(consumer, '.github/dependabot.yml'),
698
+ );
699
+ if (dependabot === null) {
700
+ problems.push('.github/dependabot.yml must exist');
701
+ } else {
702
+ problems.push(...inspectDependabot(dependabot));
703
+ }
704
+
517
705
  const packagePath = join(consumer, 'package.json');
518
706
  const packageRaw = await readTextIfPresent(packagePath);
519
707
  if (packageRaw === null) {
@@ -522,6 +710,20 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
522
710
  problems.push(...inspectPackageJson(packageRaw));
523
711
  }
524
712
 
713
+ // The GitHub settings seam only exists once the canonical declaration has
714
+ // been synced in; before that there is nothing to extend.
715
+ const canonicalSettings = await readTextIfPresent(
716
+ join(consumer, CANONICAL_SETTINGS_FILE),
717
+ );
718
+ if (canonicalSettings !== null) {
719
+ const localSettings = await readTextIfPresent(
720
+ join(consumer, LOCAL_SETTINGS_FILE),
721
+ );
722
+ problems.push(
723
+ ...loadGithubSettings(canonicalSettings, localSettings).problems,
724
+ );
725
+ }
726
+
525
727
  if (problems.length > 0) {
526
728
  console.error(
527
729
  `standards doctor: ${problems.length} integration problem(s):`,
@@ -534,7 +736,13 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
534
736
  };
535
737
 
536
738
  const commandFromArg = (arg: string): Command => {
537
- if (arg === 'check' || arg === 'doctor' || arg === 'init' || arg === 'sync') {
739
+ if (
740
+ arg === 'check' ||
741
+ arg === 'doctor' ||
742
+ arg === 'github' ||
743
+ arg === 'init' ||
744
+ arg === 'sync'
745
+ ) {
538
746
  return arg;
539
747
  }
540
748
  throw new Error(
@@ -565,12 +773,17 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
565
773
  let consumer = process.cwd();
566
774
  let dryRun = false;
567
775
  let from: string | undefined;
776
+ let checkFlag = false;
777
+ let apply = false;
568
778
 
569
779
  for (let index = 0; index < argv.length; index += 1) {
570
780
  const arg = argv[index];
571
781
  switch (arg) {
782
+ case '--apply':
783
+ apply = true;
784
+ break;
572
785
  case '--check':
573
- command = setCommand(command, 'check');
786
+ checkFlag = true;
574
787
  break;
575
788
  case '--dir':
576
789
  consumer = nextOptionValue(argv, index);
@@ -588,21 +801,100 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
588
801
  }
589
802
  }
590
803
 
804
+ // `--check` doubles as the legacy spelling of the check command and as the
805
+ // explicit (default) mode of `github`.
806
+ if (checkFlag && command !== 'github') {
807
+ command = setCommand(command, 'check');
808
+ }
809
+ if (apply && command !== 'github') {
810
+ throw new Error('--apply is only valid with the github command');
811
+ }
812
+ if (apply && checkFlag) {
813
+ throw new Error('github accepts exactly one of --check or --apply');
814
+ }
815
+
591
816
  return {
592
817
  command: command ?? 'sync',
593
818
  consumer: resolve(consumer),
594
819
  dryRun,
595
820
  from,
821
+ apply,
596
822
  };
597
823
  };
598
824
 
825
+ const runCheckCommand = async (consumer: string): Promise<boolean> => {
826
+ const driftIsClean = await runCheck(consumer);
827
+ const integrationIsValid = await runDoctor(consumer);
828
+ // The GitHub gate activates with the synced declaration and then fails
829
+ // closed: once .github/settings.json exists, an unreachable API or an
830
+ // unreadable origin is a failure, not a skip.
831
+ const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
832
+ ? await runGithubCheck(consumer)
833
+ : true;
834
+ return driftIsClean && integrationIsValid && githubIsConverged;
835
+ };
836
+
837
+ const runInitCommand = async (
838
+ consumer: string,
839
+ from: string | undefined,
840
+ ): Promise<void> => {
841
+ // Refuse before cloning upstream: re-initializing skips the lock, so it
842
+ // would silently overwrite local canonical edits and orphan files that
843
+ // upstream deleted (they leave the lock and no future sync removes them).
844
+ if (existsSync(join(consumer, 'sync-standards.lock'))) {
845
+ console.error(
846
+ 'standards: already initialized (sync-standards.lock exists). Use `just sync-standards` to update.',
847
+ );
848
+ process.exitCode = 1;
849
+ return;
850
+ }
851
+ const source = resolveSource(from ?? DEFAULT_UPSTREAM);
852
+ try {
853
+ const manifest = await loadManifest(
854
+ join(source.dir, 'sync-standards.json'),
855
+ );
856
+ await runInit(manifest, source, consumer);
857
+ } finally {
858
+ source.cleanup();
859
+ }
860
+ };
861
+
862
+ const runSyncCommand = async (
863
+ consumer: string,
864
+ from: string | undefined,
865
+ dryRun: boolean,
866
+ ): Promise<void> => {
867
+ const consumerManifest = await loadManifest(
868
+ join(consumer, 'sync-standards.json'),
869
+ );
870
+ const source = resolveSource(from ?? consumerManifest.upstream);
871
+ try {
872
+ const manifest = await loadManifest(
873
+ join(source.dir, 'sync-standards.json'),
874
+ );
875
+ await runSync(manifest, source, consumer, dryRun);
876
+ } finally {
877
+ source.cleanup();
878
+ }
879
+ };
880
+
599
881
  const main = async (): Promise<void> => {
600
- const { command, consumer, dryRun, from } = parseArgs(process.argv.slice(2));
882
+ const { command, consumer, dryRun, from, apply } = parseArgs(
883
+ process.argv.slice(2),
884
+ );
601
885
 
602
886
  if (command === 'check') {
603
- const driftIsClean = await runCheck(consumer);
604
- const integrationIsValid = await runDoctor(consumer);
605
- if (!(driftIsClean && integrationIsValid)) {
887
+ if (!(await runCheckCommand(consumer))) {
888
+ process.exitCode = 1;
889
+ }
890
+ return;
891
+ }
892
+
893
+ if (command === 'github') {
894
+ const converged = apply
895
+ ? await runGithubApply(consumer)
896
+ : await runGithubCheck(consumer);
897
+ if (!converged) {
606
898
  process.exitCode = 1;
607
899
  }
608
900
  return;
@@ -616,41 +908,12 @@ const main = async (): Promise<void> => {
616
908
  }
617
909
 
618
910
  if (command === 'init') {
619
- // Refuse before cloning upstream: re-initializing skips the lock, so it
620
- // would silently overwrite local canonical edits and orphan files that
621
- // upstream deleted (they leave the lock and no future sync removes them).
622
- if (existsSync(join(consumer, 'sync-standards.lock'))) {
623
- console.error(
624
- 'standards: already initialized (sync-standards.lock exists). Use `just sync-standards` to update.',
625
- );
626
- process.exitCode = 1;
627
- return;
628
- }
629
- const source = resolveSource(from ?? DEFAULT_UPSTREAM);
630
- try {
631
- const manifest = await loadManifest(
632
- join(source.dir, 'sync-standards.json'),
633
- );
634
- await runInit(manifest, source, consumer);
635
- } finally {
636
- source.cleanup();
637
- }
911
+ await runInitCommand(consumer, from);
638
912
  return;
639
913
  }
640
914
 
641
915
  if (command === 'sync') {
642
- const consumerManifest = await loadManifest(
643
- join(consumer, 'sync-standards.json'),
644
- );
645
- const source = resolveSource(from ?? consumerManifest.upstream);
646
- try {
647
- const manifest = await loadManifest(
648
- join(source.dir, 'sync-standards.json'),
649
- );
650
- await runSync(manifest, source, consumer, dryRun);
651
- } finally {
652
- source.cleanup();
653
- }
916
+ await runSyncCommand(consumer, from, dryRun);
654
917
  }
655
918
  };
656
919
 
@@ -0,0 +1,144 @@
1
+ // GitHub API plumbing for declarative repository settings: token and repo
2
+ // resolution, a minimal fetch wrapper, and shared loaders. The check/apply
3
+ // commands live in github-commands.ts.
4
+
5
+ import { execFileSync } from 'node:child_process';
6
+ import { existsSync } from 'node:fs';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join } from 'node:path';
9
+ import process from 'node:process';
10
+ import {
11
+ isRecord,
12
+ type LoadedGithubSettings,
13
+ loadGithubSettings,
14
+ } from './github-settings';
15
+
16
+ const API_ROOT = 'https://api.github.com';
17
+
18
+ const GITHUB_REMOTE_PATTERN =
19
+ /github\.com[/:](?<repo>[^/]+\/[^/]+?)(?:\.git)?$/u;
20
+
21
+ export const HTTP_OK = 200;
22
+ export const HTTP_CREATED = 201;
23
+ export const HTTP_NO_CONTENT = 204;
24
+
25
+ export const CANONICAL_SETTINGS_FILE = '.github/settings.json';
26
+ export const LOCAL_SETTINGS_FILE = '.github/settings.local.json';
27
+
28
+ const quietExec = (
29
+ file: string,
30
+ args: ReadonlyArray<string>,
31
+ ): string | null => {
32
+ try {
33
+ const out = execFileSync(file, [...args], {
34
+ encoding: 'utf8',
35
+ stdio: ['ignore', 'pipe', 'ignore'],
36
+ }).trim();
37
+ return out.length > 0 ? out : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ };
42
+
43
+ // GH_TOKEN or GITHUB_TOKEN, else the local gh CLI. Null is still usable for
44
+ // reads on public repositories.
45
+ export const resolveToken = (): string | null => {
46
+ const fromEnv = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
47
+ if (fromEnv !== undefined && fromEnv.length > 0) {
48
+ return fromEnv;
49
+ }
50
+ return quietExec('gh', ['auth', 'token']);
51
+ };
52
+
53
+ export const resolveGithubRepo = (consumer: string): string | null => {
54
+ const url = quietExec('git', ['-C', consumer, 'remote', 'get-url', 'origin']);
55
+ return url?.match(GITHUB_REMOTE_PATTERN)?.groups?.repo ?? null;
56
+ };
57
+
58
+ export type ApiResponse = { readonly status: number; readonly body: unknown };
59
+
60
+ export const request = async (
61
+ token: string | null,
62
+ method: string,
63
+ path: string,
64
+ body?: unknown,
65
+ ): Promise<ApiResponse> => {
66
+ const response = await fetch(`${API_ROOT}${path}`, {
67
+ method,
68
+ headers: {
69
+ accept: 'application/vnd.github+json',
70
+ 'x-github-api-version': '2022-11-28',
71
+ ...(token === null ? {} : { authorization: `Bearer ${token}` }),
72
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
73
+ },
74
+ body: body === undefined ? undefined : JSON.stringify(body),
75
+ });
76
+ const text = await response.text();
77
+ let parsed: unknown = null;
78
+ try {
79
+ parsed = text.length === 0 ? null : (JSON.parse(text) as unknown);
80
+ } catch {
81
+ parsed = text;
82
+ }
83
+ return { status: response.status, body: parsed };
84
+ };
85
+
86
+ export const apiError = (context: string, response: ApiResponse): string => {
87
+ const message =
88
+ isRecord(response.body) && typeof response.body.message === 'string'
89
+ ? response.body.message
90
+ : 'unexpected response';
91
+ return `${context}: HTTP ${response.status} ${message}`;
92
+ };
93
+
94
+ export const loadDeclared = async (
95
+ consumer: string,
96
+ ): Promise<LoadedGithubSettings> => {
97
+ const canonicalPath = join(consumer, CANONICAL_SETTINGS_FILE);
98
+ if (!existsSync(canonicalPath)) {
99
+ return {
100
+ merged: null,
101
+ problems: [
102
+ `${CANONICAL_SETTINGS_FILE} not found; run \`just sync-standards\` first`,
103
+ ],
104
+ };
105
+ }
106
+ const localPath = join(consumer, LOCAL_SETTINGS_FILE);
107
+ return loadGithubSettings(
108
+ await readFile(canonicalPath, 'utf8'),
109
+ existsSync(localPath) ? await readFile(localPath, 'utf8') : null,
110
+ );
111
+ };
112
+
113
+ export type LiveRulesets = {
114
+ readonly rulesets: ReadonlyArray<Record<string, unknown>> | null;
115
+ readonly problem: string | null;
116
+ };
117
+
118
+ // Only repository-sourced rulesets are managed; org-level rulesets a consumer
119
+ // inherits are outside this declaration's authority.
120
+ export const fetchLiveRulesets = async (
121
+ token: string | null,
122
+ repo: string,
123
+ ): Promise<LiveRulesets> => {
124
+ const list = await request(token, 'GET', `/repos/${repo}/rulesets`);
125
+ if (list.status !== HTTP_OK || !Array.isArray(list.body)) {
126
+ return { rulesets: null, problem: apiError('listing rulesets', list) };
127
+ }
128
+ const repoOwned = list.body
129
+ .filter(isRecord)
130
+ .filter((ruleset) => ruleset.source_type === 'Repository');
131
+ const detailed = await Promise.all(
132
+ repoOwned.map((ruleset) =>
133
+ request(token, 'GET', `/repos/${repo}/rulesets/${ruleset.id}`),
134
+ ),
135
+ );
136
+ const failed = detailed.find((response) => response.status !== HTTP_OK);
137
+ if (failed !== undefined) {
138
+ return { rulesets: null, problem: apiError('reading a ruleset', failed) };
139
+ }
140
+ return {
141
+ rulesets: detailed.map((response) => response.body).filter(isRecord),
142
+ problem: null,
143
+ };
144
+ };
@@ -0,0 +1,99 @@
1
+ // Ruleset reconciliation for `standards github --apply`: create, update, and
2
+ // delete live rulesets so they converge on exactly the declared set. Mutations
3
+ // throw on the first API failure so a partial apply is reported, not hidden.
4
+
5
+ import {
6
+ apiError,
7
+ fetchLiveRulesets,
8
+ HTTP_CREATED,
9
+ HTTP_NO_CONTENT,
10
+ HTTP_OK,
11
+ request,
12
+ } from './github-api';
13
+ import { diffRuleset } from './github-diff';
14
+ import type { GithubSettings } from './github-settings';
15
+
16
+ const reconcileRuleset = async (
17
+ token: string,
18
+ repo: string,
19
+ ruleset: Readonly<Record<string, unknown>>,
20
+ liveRuleset: Readonly<Record<string, unknown>> | undefined,
21
+ ): Promise<string | null> => {
22
+ const name = String(ruleset.name);
23
+ if (liveRuleset === undefined) {
24
+ const created = await request(
25
+ token,
26
+ 'POST',
27
+ `/repos/${repo}/rulesets`,
28
+ ruleset,
29
+ );
30
+ if (created.status !== HTTP_CREATED) {
31
+ throw new Error(apiError(`creating ruleset "${name}"`, created));
32
+ }
33
+ return `created ruleset "${name}"`;
34
+ }
35
+ const diff = diffRuleset(ruleset, liveRuleset);
36
+ if (diff.drifted.length === 0 && diff.unverifiable.length === 0) {
37
+ return null;
38
+ }
39
+ const updated = await request(
40
+ token,
41
+ 'PUT',
42
+ `/repos/${repo}/rulesets/${liveRuleset.id}`,
43
+ ruleset,
44
+ );
45
+ if (updated.status !== HTTP_OK) {
46
+ throw new Error(apiError(`updating ruleset "${name}"`, updated));
47
+ }
48
+ return `updated ruleset "${name}"`;
49
+ };
50
+
51
+ const deleteRuleset = async (
52
+ token: string,
53
+ repo: string,
54
+ name: string,
55
+ liveRuleset: Readonly<Record<string, unknown>>,
56
+ ): Promise<string> => {
57
+ const deleted = await request(
58
+ token,
59
+ 'DELETE',
60
+ `/repos/${repo}/rulesets/${liveRuleset.id}`,
61
+ );
62
+ if (deleted.status !== HTTP_NO_CONTENT) {
63
+ throw new Error(apiError(`deleting ruleset "${name}"`, deleted));
64
+ }
65
+ return `deleted undeclared ruleset "${name}"`;
66
+ };
67
+
68
+ export const applyRulesets = async (
69
+ token: string,
70
+ repo: string,
71
+ declared: GithubSettings,
72
+ ): Promise<ReadonlyArray<string>> => {
73
+ const live = await fetchLiveRulesets(token, repo);
74
+ if (live.rulesets === null) {
75
+ throw new Error(live.problem ?? 'unable to read rulesets');
76
+ }
77
+ const liveByName = new Map(live.rulesets.map((r) => [String(r.name), r]));
78
+ const declaredNames = new Set(declared.rulesets.map((r) => String(r.name)));
79
+ const actions: Array<string> = [];
80
+ for (const ruleset of declared.rulesets) {
81
+ // biome-ignore lint/performance/noAwaitInLoops: GitHub advises against concurrent write requests (secondary rate limits); mutations run sequentially on purpose.
82
+ const action = await reconcileRuleset(
83
+ token,
84
+ repo,
85
+ ruleset,
86
+ liveByName.get(String(ruleset.name)),
87
+ );
88
+ if (action !== null) {
89
+ actions.push(action);
90
+ }
91
+ }
92
+ for (const [name, liveRuleset] of liveByName) {
93
+ if (!declaredNames.has(name)) {
94
+ // biome-ignore lint/performance/noAwaitInLoops: GitHub advises against concurrent write requests (secondary rate limits); mutations run sequentially on purpose.
95
+ actions.push(await deleteRuleset(token, repo, name, liveRuleset));
96
+ }
97
+ }
98
+ return actions;
99
+ };
@@ -0,0 +1,149 @@
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.
5
+
6
+ import {
7
+ apiError,
8
+ fetchLiveRulesets,
9
+ HTTP_OK,
10
+ loadDeclared,
11
+ request,
12
+ resolveGithubRepo,
13
+ resolveToken,
14
+ } from './github-api';
15
+ import { applyRulesets } from './github-apply';
16
+ import { diffRepositorySettings, diffRulesets } from './github-diff';
17
+ import { type GithubSettings, isRecord } from './github-settings';
18
+
19
+ const reportProblems = (problems: ReadonlyArray<string>): void => {
20
+ console.error(
21
+ `standards github: ${problems.length} problem(s) with declared GitHub settings:`,
22
+ );
23
+ console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
24
+ };
25
+
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
+ export const runGithubCheck = async (consumer: string): Promise<boolean> => {
73
+ const declared = await loadDeclared(consumer);
74
+ const problems = [...declared.problems];
75
+ if (declared.merged !== null) {
76
+ problems.push(...(await collectLiveDrift(consumer, declared.merged)));
77
+ }
78
+ if (problems.length > 0) {
79
+ reportProblems(problems);
80
+ console.error(
81
+ 'Converge with `just sync-standards github --apply` (admin auth), or fix the declaration.',
82
+ );
83
+ return false;
84
+ }
85
+ console.log(
86
+ 'standards github: live GitHub settings match the declared configuration',
87
+ );
88
+ return true;
89
+ };
90
+
91
+ export const runGithubApply = async (consumer: string): Promise<boolean> => {
92
+ const declared = await loadDeclared(consumer);
93
+ if (declared.merged === null || declared.problems.length > 0) {
94
+ reportProblems(declared.problems);
95
+ return false;
96
+ }
97
+ const repo = resolveGithubRepo(consumer);
98
+ if (repo === null) {
99
+ console.error(
100
+ 'standards github: cannot determine the GitHub repository from the origin remote',
101
+ );
102
+ return false;
103
+ }
104
+ const token = resolveToken();
105
+ if (token === null) {
106
+ console.error(
107
+ 'standards github: apply needs an admin token; authenticate the gh CLI or set GH_TOKEN',
108
+ );
109
+ return false;
110
+ }
111
+ try {
112
+ const actions: Array<string> = [];
113
+ const repoResponse = await request(token, 'GET', `/repos/${repo}`);
114
+ if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
115
+ throw new Error(apiError(`reading repository ${repo}`, repoResponse));
116
+ }
117
+ const diff = diffRepositorySettings(
118
+ declared.merged.repository,
119
+ repoResponse.body,
120
+ );
121
+ if (diff.drifted.length > 0 || diff.unverifiable.length > 0) {
122
+ const patched = await request(
123
+ 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');
132
+ }
133
+ actions.push(...(await applyRulesets(token, repo, declared.merged)));
134
+ for (const action of actions) {
135
+ console.log(` ${action}`);
136
+ }
137
+ console.log(
138
+ actions.length === 0
139
+ ? 'standards github: already converged; no changes'
140
+ : `standards github: apply complete for ${repo}`,
141
+ );
142
+ return true;
143
+ } catch (error) {
144
+ console.error(
145
+ `standards github: ${error instanceof Error ? error.message : String(error)}`,
146
+ );
147
+ return false;
148
+ }
149
+ };
@@ -0,0 +1,172 @@
1
+ // Drift comparison between declared GitHub settings (github-settings.ts) and
2
+ // the live state returned by the GitHub API. Pure logic; no network.
3
+
4
+ import { isRecord } from './github-settings';
5
+
6
+ export type SettingsDiff = {
7
+ readonly drifted: ReadonlyArray<string>;
8
+ readonly unverifiable: ReadonlyArray<string>;
9
+ };
10
+
11
+ // Declared values must match live ones; keys GitHub adds to live objects are
12
+ // ignored so new API defaults do not read as drift. Arrays must have the same
13
+ // length, with each declared element matching a distinct live element — so an
14
+ // added bypass actor or required check is drift even when the declared list is
15
+ // a subset of the live one.
16
+ export const subsetMatches = (declared: unknown, live: unknown): boolean => {
17
+ if (Array.isArray(declared)) {
18
+ if (!Array.isArray(live) || declared.length !== live.length) {
19
+ return false;
20
+ }
21
+ const remaining = [...(live as ReadonlyArray<unknown>)];
22
+ return declared.every((value) => {
23
+ const index = remaining.findIndex((candidate) =>
24
+ subsetMatches(value, candidate),
25
+ );
26
+ if (index === -1) {
27
+ return false;
28
+ }
29
+ remaining.splice(index, 1);
30
+ return true;
31
+ });
32
+ }
33
+ if (isRecord(declared)) {
34
+ return (
35
+ isRecord(live) &&
36
+ Object.entries(declared).every(([key, value]) =>
37
+ subsetMatches(value, live[key]),
38
+ )
39
+ );
40
+ }
41
+ return declared === live;
42
+ };
43
+
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 a non-admin CI token does not fail the gate
93
+ // on state it cannot see.
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
+ // 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.
156
+ export const diffRepositorySettings = (
157
+ declared: Readonly<Record<string, unknown>>,
158
+ live: Readonly<Record<string, unknown>>,
159
+ ): SettingsDiff => {
160
+ const drifted: Array<string> = [];
161
+ const unverifiable: Array<string> = [];
162
+ for (const [key, value] of Object.entries(declared)) {
163
+ if (live[key] === undefined) {
164
+ unverifiable.push(key);
165
+ } else if (!subsetMatches(value, live[key])) {
166
+ drifted.push(
167
+ `repository setting "${key}" is ${JSON.stringify(live[key])} on GitHub, declared ${JSON.stringify(value)}`,
168
+ );
169
+ }
170
+ }
171
+ return { drifted, unverifiable };
172
+ };
@@ -0,0 +1,174 @@
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.
7
+
8
+ export type GithubSettings = {
9
+ readonly repository: Readonly<Record<string, unknown>>;
10
+ readonly rulesets: ReadonlyArray<Readonly<Record<string, unknown>>>;
11
+ };
12
+
13
+ export type LoadedGithubSettings = {
14
+ readonly merged: GithubSettings | null;
15
+ readonly problems: ReadonlyArray<string>;
16
+ };
17
+
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>;
26
+ };
27
+
28
+ const rulesetListProblems = (
29
+ rulesets: ReadonlyArray<unknown>,
30
+ label: string,
31
+ ): 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
+ }
52
+ }
53
+ return problems;
54
+ };
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 };
78
+ }
79
+ return {
80
+ settings: {
81
+ repository: repository as Record<string, unknown>,
82
+ rulesets: rulesets as ReadonlyArray<Record<string, unknown>>,
83
+ },
84
+ problems: [],
85
+ };
86
+ };
87
+
88
+ type MergeResult = {
89
+ readonly merged: GithubSettings | null;
90
+ readonly problems: ReadonlyArray<string>;
91
+ };
92
+
93
+ // The seam may only add. Overriding a canonical repository key or redefining a
94
+ // canonical ruleset could weaken the canonical floor; GitHub layers multiple
95
+ // rulesets strictest-wins, so adding a ruleset is always safe.
96
+ 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) {
117
+ return { merged: null, problems };
118
+ }
119
+ return {
120
+ merged: {
121
+ repository: { ...canonical.repository, ...local.repository },
122
+ rulesets: [...canonical.rulesets, ...local.rulesets],
123
+ },
124
+ problems: [],
125
+ };
126
+ };
127
+
128
+ type JsonParse = {
129
+ readonly value: unknown;
130
+ readonly problem: string | null;
131
+ };
132
+
133
+ const parseJson = (raw: string, label: string): JsonParse => {
134
+ try {
135
+ return { value: JSON.parse(raw) as unknown, problem: null };
136
+ } catch {
137
+ return { value: null, problem: `${label} must contain valid JSON` };
138
+ }
139
+ };
140
+
141
+ // Parse both files and merge them, gathering every problem before failing.
142
+ export const loadGithubSettings = (
143
+ canonicalRaw: string,
144
+ localRaw: string | null,
145
+ ): LoadedGithubSettings => {
146
+ const problems: Array<string> = [];
147
+ const canonicalJson = parseJson(canonicalRaw, '.github/settings.json');
148
+ if (canonicalJson.problem !== null) {
149
+ problems.push(canonicalJson.problem);
150
+ }
151
+ if (localRaw === null) {
152
+ problems.push(
153
+ '.github/settings.local.json must exist; seed it with {"repository":{},"rulesets":[]}',
154
+ );
155
+ }
156
+ const localJson =
157
+ localRaw === null
158
+ ? null
159
+ : parseJson(localRaw, '.github/settings.local.json');
160
+ if (localJson !== null && localJson.problem !== null) {
161
+ problems.push(localJson.problem);
162
+ }
163
+ if (problems.length > 0) {
164
+ return { merged: null, problems };
165
+ }
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 };
171
+ }
172
+ const merged = mergeSettings(canonical.settings, local.settings);
173
+ return { merged: merged.merged, problems: [...problems, ...merged.problems] };
174
+ };