@davidvornholt/standards 0.7.0 → 0.9.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 +4 -1
- package/package.json +5 -1
- package/src/cli.ts +34 -9
- package/src/github-api.ts +2 -1
- package/src/github-apply.ts +34 -2
- package/src/github-command-shared.ts +11 -0
- package/src/github-commands.ts +91 -52
- package/src/github-diff.ts +1 -1
- package/src/github-settings-parse.ts +120 -0
- package/src/github-settings.ts +132 -106
- package/src/structure-check.ts +23 -26
- package/src/structure-profile.ts +129 -0
- package/src/structure-script.ts +63 -0
- package/src/structure-workspace.ts +9 -55
package/README.md
CHANGED
|
@@ -12,13 +12,16 @@ 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. A repository whose GitHub plan cannot enforce rulesets can declare that in the seam (see `rulesetEnforcement` below); both commands then skip rulesets, still converge repository merge settings, 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
|
|
|
19
|
+
`structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
|
|
20
|
+
|
|
19
21
|
## Configuration
|
|
20
22
|
|
|
21
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
|
+
- **`.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. 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.
|
|
22
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` 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.
|
|
23
26
|
|
|
24
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.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,10 +22,14 @@
|
|
|
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-settings-parse.ts",
|
|
27
29
|
"src/github-settings.ts",
|
|
28
30
|
"src/structure-check.ts",
|
|
31
|
+
"src/structure-profile.ts",
|
|
32
|
+
"src/structure-script.ts",
|
|
29
33
|
"src/structure-workspace.ts"
|
|
30
34
|
],
|
|
31
35
|
"scripts": {
|
package/src/cli.ts
CHANGED
|
@@ -36,7 +36,8 @@ import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
|
|
|
36
36
|
import { runGithubApply, runGithubCheck } from './github-commands';
|
|
37
37
|
import { loadGithubSettings } from './github-settings';
|
|
38
38
|
import { collectStructureProblems } from './structure-check';
|
|
39
|
-
import {
|
|
39
|
+
import type { StructureProfile } from './structure-profile';
|
|
40
|
+
import { hasSafeCommand } from './structure-script';
|
|
40
41
|
|
|
41
42
|
const { YAML: BunYaml } = await import('bun');
|
|
42
43
|
|
|
@@ -92,6 +93,7 @@ type CliOptions = {
|
|
|
92
93
|
readonly from: string | undefined;
|
|
93
94
|
readonly ref: string | undefined;
|
|
94
95
|
readonly apply: boolean;
|
|
96
|
+
readonly profile: StructureProfile;
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
const sha256 = (buf: Buffer): string =>
|
|
@@ -491,7 +493,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
491
493
|
const problems = results.filter((p): p is string => p !== null);
|
|
492
494
|
if (problems.length > 0) {
|
|
493
495
|
console.error(
|
|
494
|
-
`standards: ${problems.length} canonical file(s) drifted from
|
|
496
|
+
`standards: ${problems.length} canonical file(s) drifted from the last synced state:`,
|
|
495
497
|
);
|
|
496
498
|
console.error(problems.join('\n'));
|
|
497
499
|
console.error(
|
|
@@ -500,7 +502,7 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
500
502
|
return false;
|
|
501
503
|
}
|
|
502
504
|
console.log(
|
|
503
|
-
`standards: ${Object.keys(lock.files).length} canonical file(s) match
|
|
505
|
+
`standards: ${Object.keys(lock.files).length} canonical file(s) match the last synced state`,
|
|
504
506
|
);
|
|
505
507
|
return true;
|
|
506
508
|
};
|
|
@@ -781,6 +783,7 @@ Commands:
|
|
|
781
783
|
|
|
782
784
|
Options:
|
|
783
785
|
--dir <path> Consumer directory to operate on (default: current directory)
|
|
786
|
+
--profile <p> With structure: validate as a "consumer" (default) or as the standards "source" repository itself
|
|
784
787
|
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
785
788
|
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
786
789
|
--dry-run Preview a sync without writing anything
|
|
@@ -811,6 +814,13 @@ const setCommand = (current: Command | undefined, next: Command): Command => {
|
|
|
811
814
|
return next;
|
|
812
815
|
};
|
|
813
816
|
|
|
817
|
+
const structureProfileOf = (value: string): StructureProfile => {
|
|
818
|
+
if (value === 'consumer' || value === 'source') {
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
821
|
+
throw new Error(`--profile must be "consumer" or "source": ${value}`);
|
|
822
|
+
};
|
|
823
|
+
|
|
814
824
|
const nextOptionValue = (
|
|
815
825
|
argv: ReadonlyArray<string>,
|
|
816
826
|
index: number,
|
|
@@ -830,6 +840,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
830
840
|
let ref: string | undefined;
|
|
831
841
|
let checkFlag = false;
|
|
832
842
|
let apply = false;
|
|
843
|
+
let profile: StructureProfile | undefined;
|
|
833
844
|
|
|
834
845
|
for (let index = 0; index < argv.length; index += 1) {
|
|
835
846
|
const arg = argv[index];
|
|
@@ -851,6 +862,10 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
851
862
|
from = nextOptionValue(argv, index);
|
|
852
863
|
index += 1;
|
|
853
864
|
break;
|
|
865
|
+
case '--profile':
|
|
866
|
+
profile = structureProfileOf(nextOptionValue(argv, index));
|
|
867
|
+
index += 1;
|
|
868
|
+
break;
|
|
854
869
|
case '--ref':
|
|
855
870
|
ref = nextOptionValue(argv, index);
|
|
856
871
|
index += 1;
|
|
@@ -876,6 +891,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
876
891
|
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
877
892
|
throw new Error('--ref is only valid with the init and sync commands');
|
|
878
893
|
}
|
|
894
|
+
if (profile !== undefined && command !== 'structure') {
|
|
895
|
+
throw new Error('--profile is only valid with the structure command');
|
|
896
|
+
}
|
|
879
897
|
|
|
880
898
|
return {
|
|
881
899
|
command,
|
|
@@ -884,13 +902,19 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
884
902
|
from,
|
|
885
903
|
ref,
|
|
886
904
|
apply,
|
|
905
|
+
profile: profile ?? 'consumer',
|
|
887
906
|
};
|
|
888
907
|
};
|
|
889
908
|
|
|
890
909
|
// Canonical monorepo structure gate: workspace and root script shapes,
|
|
891
910
|
// internal versioning, `exports`, tsconfig inheritance, and a11y wiring.
|
|
892
|
-
|
|
893
|
-
|
|
911
|
+
// The `source` profile instead pins the standards template repository's own
|
|
912
|
+
// deliberate exceptions to the consumer contract.
|
|
913
|
+
const runStructure = async (
|
|
914
|
+
consumer: string,
|
|
915
|
+
profile: StructureProfile,
|
|
916
|
+
): Promise<boolean> => {
|
|
917
|
+
const problems = await collectStructureProblems(consumer, profile);
|
|
894
918
|
if (problems.length > 0) {
|
|
895
919
|
console.error(
|
|
896
920
|
`standards structure: ${problems.length} monorepo structure problem(s):`,
|
|
@@ -905,7 +929,7 @@ const runStructure = async (consumer: string): Promise<boolean> => {
|
|
|
905
929
|
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
906
930
|
const driftIsClean = await runCheck(consumer);
|
|
907
931
|
const integrationIsValid = await runDoctor(consumer);
|
|
908
|
-
const structureIsValid = await runStructure(consumer);
|
|
932
|
+
const structureIsValid = await runStructure(consumer, 'consumer');
|
|
909
933
|
// The GitHub gate activates with the synced declaration and then fails
|
|
910
934
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
911
935
|
// unreadable origin is a failure, not a skip.
|
|
@@ -1048,6 +1072,7 @@ const runGateCommand = (
|
|
|
1048
1072
|
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
1049
1073
|
consumer: string,
|
|
1050
1074
|
apply: boolean,
|
|
1075
|
+
profile: StructureProfile,
|
|
1051
1076
|
): Promise<boolean> => {
|
|
1052
1077
|
if (command === 'check') {
|
|
1053
1078
|
return runCheckCommand(consumer);
|
|
@@ -1056,13 +1081,13 @@ const runGateCommand = (
|
|
|
1056
1081
|
return runDoctor(consumer);
|
|
1057
1082
|
}
|
|
1058
1083
|
if (command === 'structure') {
|
|
1059
|
-
return runStructure(consumer);
|
|
1084
|
+
return runStructure(consumer, profile);
|
|
1060
1085
|
}
|
|
1061
1086
|
return apply ? runGithubApply(consumer) : runGithubCheck(consumer);
|
|
1062
1087
|
};
|
|
1063
1088
|
|
|
1064
1089
|
const main = async (): Promise<void> => {
|
|
1065
|
-
const { command, consumer, dryRun, from, ref, apply } = parseArgs(
|
|
1090
|
+
const { command, consumer, dryRun, from, ref, apply, profile } = parseArgs(
|
|
1066
1091
|
process.argv.slice(2),
|
|
1067
1092
|
);
|
|
1068
1093
|
|
|
@@ -1088,7 +1113,7 @@ const main = async (): Promise<void> => {
|
|
|
1088
1113
|
return;
|
|
1089
1114
|
}
|
|
1090
1115
|
|
|
1091
|
-
if (!(await runGateCommand(command, consumer, apply))) {
|
|
1116
|
+
if (!(await runGateCommand(command, consumer, apply, profile))) {
|
|
1092
1117
|
process.exitCode = 1;
|
|
1093
1118
|
}
|
|
1094
1119
|
};
|
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;
|
package/src/github-apply.ts
CHANGED
|
@@ -10,8 +10,40 @@ 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 } from './github-settings-parse';
|
|
15
|
+
|
|
16
|
+
export const applyRepositorySettings = async (
|
|
17
|
+
token: string,
|
|
18
|
+
repo: string,
|
|
19
|
+
repository: Readonly<Record<string, unknown>>,
|
|
20
|
+
liveRepository: Readonly<Record<string, unknown>>,
|
|
21
|
+
): Promise<ReadonlyArray<string>> => {
|
|
22
|
+
const diff = diffRepositorySettings(repository, liveRepository);
|
|
23
|
+
if (diff.drifted.length === 0 && diff.unverifiable.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const patched = await request(token, 'PATCH', `/repos/${repo}`, repository);
|
|
27
|
+
if (patched.status !== HTTP_OK) {
|
|
28
|
+
throw new Error(apiError('updating repository settings', patched));
|
|
29
|
+
}
|
|
30
|
+
return ['updated repository merge settings'];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const applySummary = (
|
|
34
|
+
repo: string,
|
|
35
|
+
actions: ReadonlyArray<string>,
|
|
36
|
+
rulesetsSkipped: boolean,
|
|
37
|
+
): string => {
|
|
38
|
+
if (rulesetsSkipped) {
|
|
39
|
+
return actions.length === 0
|
|
40
|
+
? `standards github: repository settings already converged for ${repo}; rulesets skipped`
|
|
41
|
+
: `standards github: repository settings apply complete for ${repo}; rulesets skipped`;
|
|
42
|
+
}
|
|
43
|
+
return actions.length === 0
|
|
44
|
+
? 'standards github: already converged; no changes'
|
|
45
|
+
: `standards github: apply complete for ${repo}`;
|
|
46
|
+
};
|
|
15
47
|
|
|
16
48
|
const reconcileRuleset = async (
|
|
17
49
|
token: string,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { GithubSettings } from './github-settings-parse';
|
|
2
|
+
|
|
3
|
+
export const optOutEligibilityProblem = (
|
|
4
|
+
repo: string,
|
|
5
|
+
declared: GithubSettings,
|
|
6
|
+
liveRepository: Readonly<Record<string, unknown>>,
|
|
7
|
+
): string | null =>
|
|
8
|
+
declared.rulesetEnforcement === 'unavailable-on-plan' &&
|
|
9
|
+
liveRepository.private !== true
|
|
10
|
+
? `.github/settings.local.json "rulesetEnforcement" may only be declared for a private repository; ${repo} is public`
|
|
11
|
+
: null;
|
package/src/github-commands.ts
CHANGED
|
@@ -12,9 +12,19 @@ import {
|
|
|
12
12
|
resolveGithubRepo,
|
|
13
13
|
resolveToken,
|
|
14
14
|
} from './github-api';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
applyRepositorySettings,
|
|
17
|
+
applyRulesets,
|
|
18
|
+
applySummary,
|
|
19
|
+
} from './github-apply';
|
|
20
|
+
import { optOutEligibilityProblem } from './github-command-shared';
|
|
16
21
|
import { diffRepositorySettings, diffRulesets } from './github-diff';
|
|
17
|
-
import { type GithubSettings, isRecord } from './github-settings';
|
|
22
|
+
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
23
|
+
|
|
24
|
+
// Printed on every check and apply while the opt-out is declared: the skip
|
|
25
|
+
// must stay louder than the comfort of a green gate.
|
|
26
|
+
const UNENFORCEABLE_NOTICE =
|
|
27
|
+
'standards github: rulesets are declared unenforceable on this GitHub plan (.github/settings.local.json "rulesetEnforcement"); the default branch is NOT protected. After upgrading the plan, remove the declaration, then run `bun standards github --apply`.';
|
|
18
28
|
|
|
19
29
|
const reportProblems = (problems: ReadonlyArray<string>): void => {
|
|
20
30
|
console.error(
|
|
@@ -23,6 +33,53 @@ const reportProblems = (problems: ReadonlyArray<string>): void => {
|
|
|
23
33
|
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
24
34
|
};
|
|
25
35
|
|
|
36
|
+
const repositoryDrift = async (
|
|
37
|
+
token: string | null,
|
|
38
|
+
repo: string,
|
|
39
|
+
declared: GithubSettings,
|
|
40
|
+
): Promise<ReadonlyArray<string>> => {
|
|
41
|
+
const repoResponse = await request(token, 'GET', `/repos/${repo}`);
|
|
42
|
+
if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
|
|
43
|
+
return [apiError(`reading repository ${repo}`, repoResponse)];
|
|
44
|
+
}
|
|
45
|
+
const eligibilityProblem = optOutEligibilityProblem(
|
|
46
|
+
repo,
|
|
47
|
+
declared,
|
|
48
|
+
repoResponse.body,
|
|
49
|
+
);
|
|
50
|
+
if (eligibilityProblem !== null) {
|
|
51
|
+
return [eligibilityProblem];
|
|
52
|
+
}
|
|
53
|
+
const diff = diffRepositorySettings(declared.repository, repoResponse.body);
|
|
54
|
+
if (diff.unverifiable.length > 0) {
|
|
55
|
+
console.log(
|
|
56
|
+
`standards github: repository setting(s) not visible to this token, verify with admin auth: ${diff.unverifiable.join(', ')}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return diff.drifted;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const rulesetDrift = async (
|
|
63
|
+
token: string | null,
|
|
64
|
+
repo: string,
|
|
65
|
+
declared: GithubSettings,
|
|
66
|
+
): Promise<ReadonlyArray<string>> => {
|
|
67
|
+
if (declared.rulesetEnforcement === 'unavailable-on-plan') {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const live = await fetchLiveRulesets(token, repo);
|
|
71
|
+
if (live.rulesets === null) {
|
|
72
|
+
return [live.problem ?? 'unable to read rulesets'];
|
|
73
|
+
}
|
|
74
|
+
const diff = diffRulesets(declared.rulesets, live.rulesets);
|
|
75
|
+
if (diff.unverifiable.length > 0) {
|
|
76
|
+
console.log(
|
|
77
|
+
`standards github: ruleset field(s) not visible to this token, verify with admin auth: ${diff.unverifiable.join('; ')}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return diff.drifted;
|
|
81
|
+
};
|
|
82
|
+
|
|
26
83
|
const collectLiveDrift = async (
|
|
27
84
|
consumer: string,
|
|
28
85
|
declared: GithubSettings,
|
|
@@ -32,45 +89,23 @@ const collectLiveDrift = async (
|
|
|
32
89
|
return ['cannot determine the GitHub repository from the origin remote'];
|
|
33
90
|
}
|
|
34
91
|
const token = resolveToken();
|
|
35
|
-
const problems: Array<string> = [];
|
|
36
92
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
}
|
|
93
|
+
return [
|
|
94
|
+
...(await repositoryDrift(token, repo, declared)),
|
|
95
|
+
...(await rulesetDrift(token, repo, declared)),
|
|
96
|
+
];
|
|
64
97
|
} catch (error) {
|
|
65
|
-
|
|
98
|
+
return [
|
|
66
99
|
`GitHub API unreachable: ${error instanceof Error ? error.message : String(error)}`,
|
|
67
|
-
|
|
100
|
+
];
|
|
68
101
|
}
|
|
69
|
-
return problems;
|
|
70
102
|
};
|
|
71
103
|
|
|
72
104
|
export const runGithubCheck = async (consumer: string): Promise<boolean> => {
|
|
73
105
|
const declared = await loadDeclared(consumer);
|
|
106
|
+
if (declared.merged?.rulesetEnforcement === 'unavailable-on-plan') {
|
|
107
|
+
console.log(UNENFORCEABLE_NOTICE);
|
|
108
|
+
}
|
|
74
109
|
const problems = [...declared.problems];
|
|
75
110
|
if (declared.merged !== null) {
|
|
76
111
|
problems.push(...(await collectLiveDrift(consumer, declared.merged)));
|
|
@@ -83,13 +118,18 @@ export const runGithubCheck = async (consumer: string): Promise<boolean> => {
|
|
|
83
118
|
return false;
|
|
84
119
|
}
|
|
85
120
|
console.log(
|
|
86
|
-
|
|
121
|
+
declared.merged?.rulesetEnforcement === 'unavailable-on-plan'
|
|
122
|
+
? 'standards github: live repository settings match the declared configuration (rulesets skipped)'
|
|
123
|
+
: 'standards github: live GitHub settings match the declared configuration',
|
|
87
124
|
);
|
|
88
125
|
return true;
|
|
89
126
|
};
|
|
90
127
|
|
|
91
128
|
export const runGithubApply = async (consumer: string): Promise<boolean> => {
|
|
92
129
|
const declared = await loadDeclared(consumer);
|
|
130
|
+
if (declared.merged?.rulesetEnforcement === 'unavailable-on-plan') {
|
|
131
|
+
console.log(UNENFORCEABLE_NOTICE);
|
|
132
|
+
}
|
|
93
133
|
if (declared.merged === null || declared.problems.length > 0) {
|
|
94
134
|
reportProblems(declared.problems);
|
|
95
135
|
return false;
|
|
@@ -109,36 +149,35 @@ export const runGithubApply = async (consumer: string): Promise<boolean> => {
|
|
|
109
149
|
return false;
|
|
110
150
|
}
|
|
111
151
|
try {
|
|
112
|
-
const actions: Array<string> = [];
|
|
113
152
|
const repoResponse = await request(token, 'GET', `/repos/${repo}`);
|
|
114
153
|
if (repoResponse.status !== HTTP_OK || !isRecord(repoResponse.body)) {
|
|
115
154
|
throw new Error(apiError(`reading repository ${repo}`, repoResponse));
|
|
116
155
|
}
|
|
117
|
-
const
|
|
118
|
-
|
|
156
|
+
const eligibilityProblem = optOutEligibilityProblem(
|
|
157
|
+
repo,
|
|
158
|
+
declared.merged,
|
|
119
159
|
repoResponse.body,
|
|
120
160
|
);
|
|
121
|
-
if (
|
|
122
|
-
|
|
161
|
+
if (eligibilityProblem !== null) {
|
|
162
|
+
throw new Error(eligibilityProblem);
|
|
163
|
+
}
|
|
164
|
+
const actions = [
|
|
165
|
+
...(await applyRepositorySettings(
|
|
123
166
|
token,
|
|
124
|
-
|
|
125
|
-
`/repos/${repo}`,
|
|
167
|
+
repo,
|
|
126
168
|
declared.merged.repository,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
169
|
+
repoResponse.body,
|
|
170
|
+
)),
|
|
171
|
+
];
|
|
172
|
+
const rulesetsSkipped =
|
|
173
|
+
declared.merged.rulesetEnforcement === 'unavailable-on-plan';
|
|
174
|
+
if (!rulesetsSkipped) {
|
|
175
|
+
actions.push(...(await applyRulesets(token, repo, declared.merged)));
|
|
132
176
|
}
|
|
133
|
-
actions.push(...(await applyRulesets(token, repo, declared.merged)));
|
|
134
177
|
for (const action of actions) {
|
|
135
178
|
console.log(` ${action}`);
|
|
136
179
|
}
|
|
137
|
-
console.log(
|
|
138
|
-
actions.length === 0
|
|
139
|
-
? 'standards github: already converged; no changes'
|
|
140
|
-
: `standards github: apply complete for ${repo}`,
|
|
141
|
-
);
|
|
180
|
+
console.log(applySummary(repo, actions, rulesetsSkipped));
|
|
142
181
|
return true;
|
|
143
182
|
} catch (error) {
|
|
144
183
|
console.error(
|
package/src/github-diff.ts
CHANGED
|
@@ -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>;
|
|
@@ -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
|
+
};
|
package/src/github-settings.ts
CHANGED
|
@@ -1,125 +1,134 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
label: string,
|
|
41
|
+
const enforcementMergeProblems = (
|
|
42
|
+
local: ParsedGithubSettings | null,
|
|
31
43
|
): ReadonlyArray<string> => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
98
|
-
local:
|
|
99
|
-
)
|
|
100
|
-
const problems
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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: {
|
|
122
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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(
|
|
173
|
-
|
|
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
|
};
|
package/src/structure-check.ts
CHANGED
|
@@ -1,24 +1,18 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
} from './structure-
|
|
6
|
+
missingPublishedCliProblems,
|
|
7
|
+
rootScriptExpectations,
|
|
8
|
+
type StructureProfile,
|
|
9
|
+
} from './structure-profile';
|
|
10
|
+
import { hasSafeCommands, isSafeFilteredTurboAlias } from './structure-script';
|
|
11
|
+
import { inspectWorkspace, type Workspace } from './structure-workspace';
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const ROOT_FIXED_SCRIPTS = new Set([
|
|
17
|
-
'standards',
|
|
18
|
-
'check',
|
|
19
|
-
'check:fix',
|
|
20
|
-
'test:a11y',
|
|
21
|
-
]);
|
|
13
|
+
const ROOT_FIXED_SCRIPTS = new Set(
|
|
14
|
+
'standards,check,check:fix,test:a11y'.split(','),
|
|
15
|
+
);
|
|
22
16
|
const GLOB_SUFFIX = '/*';
|
|
23
17
|
const WORKSPACES_REQUIREMENT =
|
|
24
18
|
'package.json: "workspaces" must be a non-empty array of literal paths or one-level "<dir>/*" patterns';
|
|
@@ -136,19 +130,20 @@ const loadWorkspace = async (
|
|
|
136
130
|
};
|
|
137
131
|
const inspectRootScripts = (
|
|
138
132
|
root: Record<string, unknown>,
|
|
133
|
+
profile: StructureProfile,
|
|
139
134
|
requireA11y: boolean,
|
|
140
135
|
): ReadonlyArray<string> => {
|
|
141
136
|
const scripts = isRecord(root.scripts) ? root.scripts : {};
|
|
142
|
-
const expectations
|
|
143
|
-
|
|
144
|
-
['check:fix', ROOT_CHECK_FIX],
|
|
145
|
-
...(requireA11y ? [['test:a11y', ROOT_A11Y] as const] : []),
|
|
146
|
-
];
|
|
147
|
-
const gateProblems = expectations.flatMap(([name, fragment]) => {
|
|
137
|
+
const expectations = rootScriptExpectations(profile, requireA11y);
|
|
138
|
+
const gateProblems = expectations.flatMap(({ name, commands, exact }) => {
|
|
148
139
|
const { [name]: script } = scripts;
|
|
149
|
-
|
|
140
|
+
const requirement = commands.join(' && ');
|
|
141
|
+
return typeof script === 'string' &&
|
|
142
|
+
hasSafeCommands(script, commands, exact)
|
|
150
143
|
? []
|
|
151
|
-
: [
|
|
144
|
+
: [
|
|
145
|
+
`package.json: root script "${name}" must run ${exact ? 'exactly ' : ''}${requirement}`,
|
|
146
|
+
];
|
|
152
147
|
});
|
|
153
148
|
const aliasProblems = Object.entries(scripts).flatMap(([name, script]) =>
|
|
154
149
|
ROOT_FIXED_SCRIPTS.has(name) ||
|
|
@@ -163,6 +158,7 @@ const inspectRootScripts = (
|
|
|
163
158
|
};
|
|
164
159
|
export const collectStructureProblems = async (
|
|
165
160
|
consumer: string,
|
|
161
|
+
profile: StructureProfile,
|
|
166
162
|
): Promise<ReadonlyArray<string>> => {
|
|
167
163
|
const root = await readJson(join(consumer, 'package.json'));
|
|
168
164
|
if (root === null) {
|
|
@@ -185,14 +181,15 @@ export const collectStructureProblems = async (
|
|
|
185
181
|
.filter((name): name is string => typeof name === 'string'),
|
|
186
182
|
);
|
|
187
183
|
const inspections = await Promise.all(
|
|
188
|
-
workspaces.map((ws) => inspectWorkspace(ws, workspaceNames)),
|
|
184
|
+
workspaces.map((ws) => inspectWorkspace(ws, workspaceNames, profile)),
|
|
189
185
|
);
|
|
190
186
|
const requireA11y = inspections.some((i) => i.hasA11ySuite);
|
|
191
187
|
return [
|
|
192
188
|
...declaration.problems,
|
|
193
189
|
...resolved.flatMap((r) => (r.problem === null ? [] : [r.problem])),
|
|
194
190
|
...loaded.flatMap((l) => (l.problem === null ? [] : [l.problem])),
|
|
195
|
-
...
|
|
191
|
+
...missingPublishedCliProblems(profile, workspaces),
|
|
192
|
+
...inspectRootScripts(root, profile, requireA11y),
|
|
196
193
|
...inspections.flatMap((i) => [...i.problems]),
|
|
197
194
|
];
|
|
198
195
|
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { isRecord } from './github-settings-parse';
|
|
2
|
+
|
|
3
|
+
// Structure validation profiles. `consumer` is the contract every downstream
|
|
4
|
+
// repo satisfies. `source` is the standards template repository itself, which
|
|
5
|
+
// is deliberately not a consumer: its root gate runs the local CLI instead of
|
|
6
|
+
// a recursive `standards check`, and it publishes the CLI workspace as a
|
|
7
|
+
// released bin-only package. Pinning those exceptions here keeps them from
|
|
8
|
+
// drifting silently while the normal quality gate stays green.
|
|
9
|
+
export type StructureProfile = 'consumer' | 'source';
|
|
10
|
+
|
|
11
|
+
const CONSUMER_CHECK = 'turbo run lint check-types test build test:a11y';
|
|
12
|
+
const CONSUMER_CHECK_FIX =
|
|
13
|
+
'turbo run lint:fix check-types test build test:a11y';
|
|
14
|
+
const ROOT_A11Y = 'turbo run test:a11y';
|
|
15
|
+
|
|
16
|
+
export const PUBLISHED_CLI_WORKSPACE = 'packages/standards-cli';
|
|
17
|
+
const PUBLISHED_CLI_PACKAGE = '@davidvornholt/standards';
|
|
18
|
+
const SOURCE_CLI = `bun ${PUBLISHED_CLI_WORKSPACE}/src/cli.ts`;
|
|
19
|
+
const RELEASE_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u;
|
|
20
|
+
|
|
21
|
+
export type RootScriptExpectation = {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly commands: ReadonlyArray<string>;
|
|
24
|
+
readonly exact: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
|
|
28
|
+
{ name: 'standards', commands: [SOURCE_CLI], exact: true },
|
|
29
|
+
{
|
|
30
|
+
name: 'check',
|
|
31
|
+
commands: [
|
|
32
|
+
`${SOURCE_CLI} structure --profile source`,
|
|
33
|
+
`${SOURCE_CLI} github --check`,
|
|
34
|
+
'turbo run lint check-types test',
|
|
35
|
+
],
|
|
36
|
+
exact: true,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'check:fix',
|
|
40
|
+
commands: [
|
|
41
|
+
`${SOURCE_CLI} structure --profile source`,
|
|
42
|
+
`${SOURCE_CLI} github --check`,
|
|
43
|
+
'turbo run lint:fix check-types test',
|
|
44
|
+
],
|
|
45
|
+
exact: true,
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const rootScriptExpectations = (
|
|
50
|
+
profile: StructureProfile,
|
|
51
|
+
requireA11y: boolean,
|
|
52
|
+
): ReadonlyArray<RootScriptExpectation> => [
|
|
53
|
+
...(profile === 'source'
|
|
54
|
+
? SOURCE_ROOT_EXPECTATIONS
|
|
55
|
+
: [
|
|
56
|
+
{ name: 'check', commands: [CONSUMER_CHECK], exact: false },
|
|
57
|
+
{ name: 'check:fix', commands: [CONSUMER_CHECK_FIX], exact: false },
|
|
58
|
+
]),
|
|
59
|
+
...(requireA11y
|
|
60
|
+
? [{ name: 'test:a11y', commands: [ROOT_A11Y], exact: false }]
|
|
61
|
+
: []),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// The published CLI ships as a released bin-only package: it carries a release
|
|
65
|
+
// SemVer instead of the internal "0.0.0" and exposes its public API through
|
|
66
|
+
// "bin" instead of "exports". Everything else follows the normal rules.
|
|
67
|
+
const inspectPublishedCli = (
|
|
68
|
+
manifest: Record<string, unknown>,
|
|
69
|
+
): ReadonlyArray<string> => [
|
|
70
|
+
...(manifest.name === PUBLISHED_CLI_PACKAGE
|
|
71
|
+
? []
|
|
72
|
+
: [
|
|
73
|
+
`${PUBLISHED_CLI_WORKSPACE}: published CLI package name must be "${PUBLISHED_CLI_PACKAGE}"`,
|
|
74
|
+
]),
|
|
75
|
+
...(typeof manifest.version === 'string' &&
|
|
76
|
+
manifest.version !== '0.0.0' &&
|
|
77
|
+
RELEASE_SEMVER.test(manifest.version)
|
|
78
|
+
? []
|
|
79
|
+
: [
|
|
80
|
+
`${PUBLISHED_CLI_WORKSPACE}: published CLI version must be a stable release SemVer, not "0.0.0"`,
|
|
81
|
+
]),
|
|
82
|
+
...(isRecord(manifest.bin) &&
|
|
83
|
+
Object.keys(manifest.bin).length === 1 &&
|
|
84
|
+
manifest.bin.standards === 'src/cli.ts'
|
|
85
|
+
? []
|
|
86
|
+
: [
|
|
87
|
+
`${PUBLISHED_CLI_WORKSPACE}: published CLI bin must be exactly { "standards": "src/cli.ts" }`,
|
|
88
|
+
]),
|
|
89
|
+
...(manifest.private === true
|
|
90
|
+
? [`${PUBLISHED_CLI_WORKSPACE}: published CLI must not be private`]
|
|
91
|
+
: []),
|
|
92
|
+
...(manifest.exports === undefined
|
|
93
|
+
? []
|
|
94
|
+
: [
|
|
95
|
+
`${PUBLISHED_CLI_WORKSPACE}: published CLI must be bin-only and must not define "exports"`,
|
|
96
|
+
]),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
export const inspectVersionAndExports = (
|
|
100
|
+
profile: StructureProfile,
|
|
101
|
+
rel: string,
|
|
102
|
+
manifest: Record<string, unknown>,
|
|
103
|
+
): ReadonlyArray<string> => {
|
|
104
|
+
if (profile === 'source' && rel === PUBLISHED_CLI_WORKSPACE) {
|
|
105
|
+
return inspectPublishedCli(manifest);
|
|
106
|
+
}
|
|
107
|
+
return [
|
|
108
|
+
...(manifest.version === '0.0.0'
|
|
109
|
+
? []
|
|
110
|
+
: [`${rel}: internal workspace version must be "0.0.0"`]),
|
|
111
|
+
...(rel.startsWith('packages/') && manifest.exports === undefined
|
|
112
|
+
? [`${rel}: package must define its public API with "exports"`]
|
|
113
|
+
: []),
|
|
114
|
+
];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// The source profile only means anything in the repository that owns and
|
|
118
|
+
// publishes the CLI; failing loudly when that workspace is absent keeps the
|
|
119
|
+
// profile from being applied to the wrong repository shape.
|
|
120
|
+
export const missingPublishedCliProblems = (
|
|
121
|
+
profile: StructureProfile,
|
|
122
|
+
workspaces: ReadonlyArray<{ readonly rel: string }>,
|
|
123
|
+
): ReadonlyArray<string> =>
|
|
124
|
+
profile === 'source' &&
|
|
125
|
+
!workspaces.some((ws) => ws.rel === PUBLISHED_CLI_WORKSPACE)
|
|
126
|
+
? [
|
|
127
|
+
`${PUBLISHED_CLI_WORKSPACE}: the source profile requires the published CLI workspace`,
|
|
128
|
+
]
|
|
129
|
+
: [];
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const UNSAFE_SCRIPT_SYNTAX = /[|;#"'`\r\n]/u;
|
|
2
|
+
const SCRIPT_WHITESPACE = /\s+/u;
|
|
3
|
+
const NON_EXECUTING_TURBO_OPTION = /^(?:-h|-v|--(?:dry|help|version))(?:=|$)/u;
|
|
4
|
+
|
|
5
|
+
export const parseSafeCommands = (
|
|
6
|
+
script: string | null,
|
|
7
|
+
): ReadonlyArray<ReadonlyArray<string>> | null => {
|
|
8
|
+
if (
|
|
9
|
+
script === null ||
|
|
10
|
+
script.trim() === '' ||
|
|
11
|
+
UNSAFE_SCRIPT_SYNTAX.test(script) ||
|
|
12
|
+
script.includes('$(')
|
|
13
|
+
) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const commands = script.split('&&').map((command) => command.trim());
|
|
17
|
+
return commands.some((command) => command === '' || command.includes('&'))
|
|
18
|
+
? null
|
|
19
|
+
: commands.map((command) => command.split(SCRIPT_WHITESPACE));
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const hasSafeCommands = (
|
|
23
|
+
script: string | null,
|
|
24
|
+
expected: ReadonlyArray<string>,
|
|
25
|
+
exact: boolean,
|
|
26
|
+
): boolean => {
|
|
27
|
+
const commands = parseSafeCommands(script)?.map((tokens) => tokens.join(' '));
|
|
28
|
+
if (commands === undefined) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return exact
|
|
32
|
+
? commands.length === expected.length &&
|
|
33
|
+
commands.every((command, index) => command === expected[index])
|
|
34
|
+
: expected.every((command) => commands.includes(command));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const hasSafeCommand = (
|
|
38
|
+
script: string | null,
|
|
39
|
+
expected: string,
|
|
40
|
+
): boolean => hasSafeCommands(script, [expected], false);
|
|
41
|
+
|
|
42
|
+
export const isSafeFilteredTurboAlias = (script: string): boolean => {
|
|
43
|
+
const commands = parseSafeCommands(script);
|
|
44
|
+
if (commands?.length !== 1) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const [tokens] = commands;
|
|
48
|
+
const filterAt = tokens.findIndex(
|
|
49
|
+
(token) => token === '--filter' || token.startsWith('--filter='),
|
|
50
|
+
);
|
|
51
|
+
const filter = tokens[filterAt];
|
|
52
|
+
const filterValue =
|
|
53
|
+
filter === '--filter'
|
|
54
|
+
? tokens[filterAt + 1]
|
|
55
|
+
: filter?.slice('--filter='.length);
|
|
56
|
+
return tokens[0] !== 'turbo' || tokens[1] !== 'run'
|
|
57
|
+
? false
|
|
58
|
+
: tokens[2]?.startsWith('-') === false &&
|
|
59
|
+
!tokens.some((token) => NON_EXECUTING_TURBO_OPTION.test(token)) &&
|
|
60
|
+
filterValue !== undefined &&
|
|
61
|
+
filterValue !== '' &&
|
|
62
|
+
!filterValue.startsWith('-');
|
|
63
|
+
};
|
|
@@ -1,6 +1,11 @@
|
|
|
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
|
+
import {
|
|
5
|
+
inspectVersionAndExports,
|
|
6
|
+
type StructureProfile,
|
|
7
|
+
} from './structure-profile';
|
|
8
|
+
import { hasSafeCommand, parseSafeCommands } from './structure-script';
|
|
4
9
|
export type Workspace = {
|
|
5
10
|
readonly rel: string;
|
|
6
11
|
readonly dir: string;
|
|
@@ -20,11 +25,8 @@ const DEPENDENCY_FIELDS =
|
|
|
20
25
|
const SHARED_TSCONFIG_PACKAGE = '@davidvornholt/typescript-config';
|
|
21
26
|
const SHARED_TSCONFIG_PATH =
|
|
22
27
|
/^@davidvornholt\/typescript-config\/(?:base|next|react-library)$/u;
|
|
23
|
-
const UNSAFE_SCRIPT_SYNTAX = /[|;#"'`\r\n]/u;
|
|
24
|
-
const SCRIPT_WHITESPACE = /\s+/u;
|
|
25
28
|
const NON_EXECUTING_A11Y_OPTION =
|
|
26
29
|
/^(?:-h|-V|--(?:debug|help|last-failed|list|only-changed|ui|version))(?:=|$)/u;
|
|
27
|
-
const NON_EXECUTING_TURBO_OPTION = /^(?:-h|-v|--(?:dry|help|version))(?:=|$)/u;
|
|
28
30
|
const IGNORED_DIRS = new Set('.git,.next,.turbo,dist,node_modules'.split(','));
|
|
29
31
|
const scriptOf = (
|
|
30
32
|
manifest: Record<string, unknown>,
|
|
@@ -34,57 +36,13 @@ const scriptOf = (
|
|
|
34
36
|
const script = scripts[name];
|
|
35
37
|
return typeof script === 'string' ? script : null;
|
|
36
38
|
};
|
|
37
|
-
const safeCommands = (
|
|
38
|
-
script: string | null,
|
|
39
|
-
): ReadonlyArray<ReadonlyArray<string>> | null => {
|
|
40
|
-
if (
|
|
41
|
-
script === null ||
|
|
42
|
-
script.trim() === '' ||
|
|
43
|
-
UNSAFE_SCRIPT_SYNTAX.test(script) ||
|
|
44
|
-
script.includes('$(')
|
|
45
|
-
) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
const commands = script.split('&&').map((command) => command.trim());
|
|
49
|
-
return commands.some((command) => command === '' || command.includes('&'))
|
|
50
|
-
? null
|
|
51
|
-
: commands.map((command) => command.split(SCRIPT_WHITESPACE));
|
|
52
|
-
};
|
|
53
|
-
export const hasSafeCommand = (
|
|
54
|
-
script: string | null,
|
|
55
|
-
expected: string,
|
|
56
|
-
): boolean =>
|
|
57
|
-
safeCommands(script)?.some((tokens) => tokens.join(' ') === expected) ??
|
|
58
|
-
false;
|
|
59
39
|
const hasSafeA11yCommand = (script: string | null): boolean =>
|
|
60
|
-
|
|
40
|
+
parseSafeCommands(script)?.some(
|
|
61
41
|
(tokens) =>
|
|
62
42
|
tokens[0] === 'playwright' &&
|
|
63
43
|
tokens[1] === 'test' &&
|
|
64
44
|
tokens.slice(2).every((token) => !NON_EXECUTING_A11Y_OPTION.test(token)),
|
|
65
45
|
) ?? false;
|
|
66
|
-
export const isSafeFilteredTurboAlias = (script: string): boolean => {
|
|
67
|
-
const commands = safeCommands(script);
|
|
68
|
-
if (commands?.length !== 1) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
const [tokens] = commands;
|
|
72
|
-
const filterAt = tokens.findIndex(
|
|
73
|
-
(token) => token === '--filter' || token.startsWith('--filter='),
|
|
74
|
-
);
|
|
75
|
-
const filter = tokens[filterAt];
|
|
76
|
-
const filterValue =
|
|
77
|
-
filter === '--filter'
|
|
78
|
-
? tokens[filterAt + 1]
|
|
79
|
-
: filter?.slice('--filter='.length);
|
|
80
|
-
return tokens[0] !== 'turbo' || tokens[1] !== 'run'
|
|
81
|
-
? false
|
|
82
|
-
: tokens[2]?.startsWith('-') === false &&
|
|
83
|
-
!tokens.some((token) => NON_EXECUTING_TURBO_OPTION.test(token)) &&
|
|
84
|
-
filterValue !== undefined &&
|
|
85
|
-
filterValue !== '' &&
|
|
86
|
-
!filterValue.startsWith('-');
|
|
87
|
-
};
|
|
88
46
|
const containsA11ySuite = async (dir: string): Promise<boolean> => {
|
|
89
47
|
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
90
48
|
if (
|
|
@@ -179,6 +137,7 @@ const inspectA11y = async (
|
|
|
179
137
|
export const inspectWorkspace = async (
|
|
180
138
|
ws: Workspace,
|
|
181
139
|
workspaceNames: ReadonlySet<string>,
|
|
140
|
+
profile: StructureProfile,
|
|
182
141
|
) => {
|
|
183
142
|
const [tsconfigProblems, a11y] = await Promise.all([
|
|
184
143
|
inspectTsconfig(ws),
|
|
@@ -186,13 +145,8 @@ export const inspectWorkspace = async (
|
|
|
186
145
|
]);
|
|
187
146
|
const problems = [
|
|
188
147
|
...inspectScripts(ws),
|
|
189
|
-
...(ws.
|
|
190
|
-
? []
|
|
191
|
-
: [`${ws.rel}: internal workspace version must be "0.0.0"`]),
|
|
148
|
+
...inspectVersionAndExports(profile, ws.rel, ws.manifest),
|
|
192
149
|
...inspectInternalDeps(ws, workspaceNames),
|
|
193
|
-
...(ws.rel.startsWith('packages/') && ws.manifest.exports === undefined
|
|
194
|
-
? [`${ws.rel}: package must define its public API with "exports"`]
|
|
195
|
-
: []),
|
|
196
150
|
...tsconfigProblems,
|
|
197
151
|
...a11y.problems,
|
|
198
152
|
];
|