@davidvornholt/standards 0.5.0 → 0.7.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 -0
- package/package.json +4 -2
- package/src/cli.ts +165 -42
- package/src/structure-check.ts +198 -0
- package/src/structure-workspace.ts +200 -0
package/README.md
CHANGED
|
@@ -7,14 +7,18 @@ bunx @davidvornholt/standards init
|
|
|
7
7
|
standards sync
|
|
8
8
|
standards check
|
|
9
9
|
standards doctor
|
|
10
|
+
standards structure
|
|
10
11
|
standards github --check
|
|
11
12
|
standards github --apply
|
|
12
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
16
|
|
|
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
|
+
|
|
16
19
|
## Configuration
|
|
17
20
|
|
|
21
|
+
- **`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.
|
|
18
22
|
- **`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
23
|
|
|
20
24
|
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.7.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
"src/github-apply.ts",
|
|
25
25
|
"src/github-commands.ts",
|
|
26
26
|
"src/github-diff.ts",
|
|
27
|
-
"src/github-settings.ts"
|
|
27
|
+
"src/github-settings.ts",
|
|
28
|
+
"src/structure-check.ts",
|
|
29
|
+
"src/structure-workspace.ts"
|
|
28
30
|
],
|
|
29
31
|
"scripts": {
|
|
30
32
|
"lint": "biome check --error-on-warnings .",
|
package/src/cli.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
dirname,
|
|
27
27
|
isAbsolute,
|
|
28
28
|
join,
|
|
29
|
-
|
|
29
|
+
posix,
|
|
30
30
|
relative,
|
|
31
31
|
resolve,
|
|
32
32
|
sep,
|
|
@@ -35,6 +35,8 @@ import process from 'node:process';
|
|
|
35
35
|
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
|
+
import { collectStructureProblems } from './structure-check';
|
|
39
|
+
import { hasSafeCommand } from './structure-workspace';
|
|
38
40
|
|
|
39
41
|
const { YAML: BunYaml } = await import('bun');
|
|
40
42
|
|
|
@@ -74,7 +76,14 @@ type Source = {
|
|
|
74
76
|
readonly cleanup: () => void;
|
|
75
77
|
};
|
|
76
78
|
|
|
77
|
-
type Command =
|
|
79
|
+
type Command =
|
|
80
|
+
| 'check'
|
|
81
|
+
| 'doctor'
|
|
82
|
+
| 'github'
|
|
83
|
+
| 'help'
|
|
84
|
+
| 'init'
|
|
85
|
+
| 'structure'
|
|
86
|
+
| 'sync';
|
|
78
87
|
|
|
79
88
|
type CliOptions = {
|
|
80
89
|
readonly command: Command | undefined;
|
|
@@ -91,7 +100,9 @@ const sha256 = (buf: Buffer): string =>
|
|
|
91
100
|
const toPosix = (p: string): string => p.split(sep).join('/');
|
|
92
101
|
|
|
93
102
|
const assertSafeRelativePath = (path: string, label: string): void => {
|
|
94
|
-
|
|
103
|
+
// POSIX semantics on every platform: managed paths are repository-relative
|
|
104
|
+
// POSIX paths, and win32 normalize would rewrite `/` to `\` and reject them.
|
|
105
|
+
const normalized = posix.normalize(path);
|
|
95
106
|
if (
|
|
96
107
|
path.length === 0 ||
|
|
97
108
|
path === '.' ||
|
|
@@ -672,11 +683,19 @@ const inspectDependabot = (raw: string): ReadonlyArray<string> => {
|
|
|
672
683
|
|
|
673
684
|
const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
674
685
|
const problems: Array<string> = [];
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
686
|
+
let packageJson: unknown;
|
|
687
|
+
try {
|
|
688
|
+
packageJson = JSON.parse(packageRaw);
|
|
689
|
+
} catch {
|
|
690
|
+
return ['package.json must contain valid JSON'];
|
|
691
|
+
}
|
|
692
|
+
if (!isRecord(packageJson)) {
|
|
693
|
+
return ['package.json must contain a JSON object'];
|
|
694
|
+
}
|
|
695
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : {};
|
|
696
|
+
const devDependencies = isRecord(packageJson.devDependencies)
|
|
697
|
+
? packageJson.devDependencies
|
|
698
|
+
: {};
|
|
680
699
|
if (typeof devDependencies?.['@davidvornholt/standards'] !== 'string') {
|
|
681
700
|
problems.push(
|
|
682
701
|
'package.json must declare @davidvornholt/standards directly',
|
|
@@ -684,7 +703,10 @@ const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
|
684
703
|
}
|
|
685
704
|
for (const name of ['check', 'check:fix']) {
|
|
686
705
|
const script = scripts?.[name];
|
|
687
|
-
if (
|
|
706
|
+
if (
|
|
707
|
+
typeof script !== 'string' ||
|
|
708
|
+
!hasSafeCommand(script, 'standards check')
|
|
709
|
+
) {
|
|
688
710
|
problems.push(`package.json script "${name}" must run standards check`);
|
|
689
711
|
}
|
|
690
712
|
}
|
|
@@ -733,6 +755,8 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
733
755
|
);
|
|
734
756
|
}
|
|
735
757
|
|
|
758
|
+
problems.push(...(await inspectPolicy(consumer)));
|
|
759
|
+
|
|
736
760
|
if (problems.length > 0) {
|
|
737
761
|
console.error(
|
|
738
762
|
`standards doctor: ${problems.length} integration problem(s):`,
|
|
@@ -747,12 +771,13 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
747
771
|
const USAGE = `Usage: standards <command> [options]
|
|
748
772
|
|
|
749
773
|
Commands:
|
|
750
|
-
init
|
|
751
|
-
sync
|
|
752
|
-
check
|
|
753
|
-
doctor
|
|
754
|
-
|
|
755
|
-
|
|
774
|
+
init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
|
|
775
|
+
sync Mirror canonical files from upstream and rewrite the lock
|
|
776
|
+
check Verify canonical files, extension seams, monorepo structure, and GitHub settings
|
|
777
|
+
doctor Validate extension seams only
|
|
778
|
+
structure Validate monorepo structure rules only
|
|
779
|
+
github Compare (--check) or converge (--apply) live GitHub settings
|
|
780
|
+
help Show this help
|
|
756
781
|
|
|
757
782
|
Options:
|
|
758
783
|
--dir <path> Consumer directory to operate on (default: current directory)
|
|
@@ -769,6 +794,7 @@ const commandFromArg = (arg: string): Command => {
|
|
|
769
794
|
arg === 'github' ||
|
|
770
795
|
arg === 'help' ||
|
|
771
796
|
arg === 'init' ||
|
|
797
|
+
arg === 'structure' ||
|
|
772
798
|
arg === 'sync'
|
|
773
799
|
) {
|
|
774
800
|
return arg;
|
|
@@ -861,18 +887,112 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
861
887
|
};
|
|
862
888
|
};
|
|
863
889
|
|
|
890
|
+
// Canonical monorepo structure gate: workspace and root script shapes,
|
|
891
|
+
// internal versioning, `exports`, tsconfig inheritance, and a11y wiring.
|
|
892
|
+
const runStructure = async (consumer: string): Promise<boolean> => {
|
|
893
|
+
const problems = await collectStructureProblems(consumer);
|
|
894
|
+
if (problems.length > 0) {
|
|
895
|
+
console.error(
|
|
896
|
+
`standards structure: ${problems.length} monorepo structure problem(s):`,
|
|
897
|
+
);
|
|
898
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
console.log('standards structure: workspace layout matches the standards');
|
|
902
|
+
return true;
|
|
903
|
+
};
|
|
904
|
+
|
|
864
905
|
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
865
906
|
const driftIsClean = await runCheck(consumer);
|
|
866
907
|
const integrationIsValid = await runDoctor(consumer);
|
|
908
|
+
const structureIsValid = await runStructure(consumer);
|
|
867
909
|
// The GitHub gate activates with the synced declaration and then fails
|
|
868
910
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
869
911
|
// unreadable origin is a failure, not a skip.
|
|
870
912
|
const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
|
|
871
913
|
? await runGithubCheck(consumer)
|
|
872
914
|
: true;
|
|
873
|
-
return
|
|
915
|
+
return (
|
|
916
|
+
driftIsClean && integrationIsValid && structureIsValid && githubIsConverged
|
|
917
|
+
);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
// Consumer-owned sync policy, checked in next to the canonical (read-only)
|
|
921
|
+
// standards-sync workflow it configures — versioned and reviewable, unlike
|
|
922
|
+
// repository Actions variables. All fields are optional; a missing file means
|
|
923
|
+
// the defaults (track main, weekly auto-sync on).
|
|
924
|
+
// autoSync false skips the scheduled workflow run; manual dispatch and
|
|
925
|
+
// local CLI runs are deliberate acts and always proceed.
|
|
926
|
+
// ref tag, branch, or full commit sha to sync from instead of main.
|
|
927
|
+
const POLICY_FILE = 'sync-standards.local.json';
|
|
928
|
+
const LINE_BREAK = /[\r\n]/u;
|
|
929
|
+
|
|
930
|
+
type Policy = {
|
|
931
|
+
readonly autoSync?: boolean;
|
|
932
|
+
readonly ref?: string;
|
|
874
933
|
};
|
|
875
934
|
|
|
935
|
+
const parsePolicy = (parsed: unknown): Policy => {
|
|
936
|
+
if (!isRecord(parsed)) {
|
|
937
|
+
throw new Error(`${POLICY_FILE} must be a JSON object`);
|
|
938
|
+
}
|
|
939
|
+
const unsupportedFields = Object.keys(parsed).filter(
|
|
940
|
+
(field) => field !== 'autoSync' && field !== 'ref',
|
|
941
|
+
);
|
|
942
|
+
if (unsupportedFields.length > 0) {
|
|
943
|
+
throw new Error(
|
|
944
|
+
`${POLICY_FILE} contains unsupported field(s): ${unsupportedFields.join(', ')}`,
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (parsed.autoSync !== undefined && typeof parsed.autoSync !== 'boolean') {
|
|
948
|
+
throw new Error(`${POLICY_FILE} "autoSync" must be a boolean`);
|
|
949
|
+
}
|
|
950
|
+
if (
|
|
951
|
+
parsed.ref !== undefined &&
|
|
952
|
+
(!isNonEmptyString(parsed.ref) || LINE_BREAK.test(parsed.ref))
|
|
953
|
+
) {
|
|
954
|
+
throw new Error(
|
|
955
|
+
`${POLICY_FILE} "ref" must be a non-empty single-line string`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
return { autoSync: parsed.autoSync, ref: parsed.ref };
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const readPolicy = async (consumer: string): Promise<Policy> => {
|
|
962
|
+
const raw = await readTextIfPresent(join(consumer, POLICY_FILE));
|
|
963
|
+
if (raw === null) {
|
|
964
|
+
return {};
|
|
965
|
+
}
|
|
966
|
+
let parsed: unknown;
|
|
967
|
+
try {
|
|
968
|
+
parsed = JSON.parse(raw);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
throw new Error(`${POLICY_FILE} must contain valid JSON`, { cause: error });
|
|
971
|
+
}
|
|
972
|
+
return parsePolicy(parsed);
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const inspectPolicy = async (
|
|
976
|
+
consumer: string,
|
|
977
|
+
): Promise<ReadonlyArray<string>> => {
|
|
978
|
+
try {
|
|
979
|
+
await readPolicy(consumer);
|
|
980
|
+
return [];
|
|
981
|
+
} catch (error) {
|
|
982
|
+
return [error instanceof Error ? error.message : String(error)];
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
// Policy validation is unconditional once the file exists. Selection happens
|
|
987
|
+
// afterward: explicit refs win for remote sources, while local paths are used
|
|
988
|
+
// as-is and ignore only the already-validated policy ref.
|
|
989
|
+
const selectedRef = (
|
|
990
|
+
src: string,
|
|
991
|
+
explicitRef: string | undefined,
|
|
992
|
+
policy: Policy,
|
|
993
|
+
): string | undefined =>
|
|
994
|
+
existsSync(src) ? explicitRef : (explicitRef ?? policy.ref);
|
|
995
|
+
|
|
876
996
|
const runInitCommand = async (
|
|
877
997
|
consumer: string,
|
|
878
998
|
from: string | undefined,
|
|
@@ -888,7 +1008,9 @@ const runInitCommand = async (
|
|
|
888
1008
|
process.exitCode = 1;
|
|
889
1009
|
return;
|
|
890
1010
|
}
|
|
891
|
-
const
|
|
1011
|
+
const src = from ?? DEFAULT_UPSTREAM;
|
|
1012
|
+
const policy = await readPolicy(consumer);
|
|
1013
|
+
const source = resolveSource(src, selectedRef(src, ref, policy));
|
|
892
1014
|
try {
|
|
893
1015
|
const manifest = await loadManifest(
|
|
894
1016
|
join(source.dir, 'sync-standards.json'),
|
|
@@ -908,7 +1030,9 @@ const runSyncCommand = async (
|
|
|
908
1030
|
const consumerManifest = await loadManifest(
|
|
909
1031
|
join(consumer, 'sync-standards.json'),
|
|
910
1032
|
);
|
|
911
|
-
const
|
|
1033
|
+
const src = from ?? consumerManifest.upstream;
|
|
1034
|
+
const policy = await readPolicy(consumer);
|
|
1035
|
+
const source = resolveSource(src, selectedRef(src, ref, policy));
|
|
912
1036
|
try {
|
|
913
1037
|
const manifest = await loadManifest(
|
|
914
1038
|
join(source.dir, 'sync-standards.json'),
|
|
@@ -919,6 +1043,24 @@ const runSyncCommand = async (
|
|
|
919
1043
|
}
|
|
920
1044
|
};
|
|
921
1045
|
|
|
1046
|
+
// Commands whose success is reported through the exit code.
|
|
1047
|
+
const runGateCommand = (
|
|
1048
|
+
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
1049
|
+
consumer: string,
|
|
1050
|
+
apply: boolean,
|
|
1051
|
+
): Promise<boolean> => {
|
|
1052
|
+
if (command === 'check') {
|
|
1053
|
+
return runCheckCommand(consumer);
|
|
1054
|
+
}
|
|
1055
|
+
if (command === 'doctor') {
|
|
1056
|
+
return runDoctor(consumer);
|
|
1057
|
+
}
|
|
1058
|
+
if (command === 'structure') {
|
|
1059
|
+
return runStructure(consumer);
|
|
1060
|
+
}
|
|
1061
|
+
return apply ? runGithubApply(consumer) : runGithubCheck(consumer);
|
|
1062
|
+
};
|
|
1063
|
+
|
|
922
1064
|
const main = async (): Promise<void> => {
|
|
923
1065
|
const { command, consumer, dryRun, from, ref, apply } = parseArgs(
|
|
924
1066
|
process.argv.slice(2),
|
|
@@ -936,30 +1078,6 @@ const main = async (): Promise<void> => {
|
|
|
936
1078
|
return;
|
|
937
1079
|
}
|
|
938
1080
|
|
|
939
|
-
if (command === 'check') {
|
|
940
|
-
if (!(await runCheckCommand(consumer))) {
|
|
941
|
-
process.exitCode = 1;
|
|
942
|
-
}
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
if (command === 'github') {
|
|
947
|
-
const converged = apply
|
|
948
|
-
? await runGithubApply(consumer)
|
|
949
|
-
: await runGithubCheck(consumer);
|
|
950
|
-
if (!converged) {
|
|
951
|
-
process.exitCode = 1;
|
|
952
|
-
}
|
|
953
|
-
return;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
if (command === 'doctor') {
|
|
957
|
-
if (!(await runDoctor(consumer))) {
|
|
958
|
-
process.exitCode = 1;
|
|
959
|
-
}
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
|
|
963
1081
|
if (command === 'init') {
|
|
964
1082
|
await runInitCommand(consumer, from, ref);
|
|
965
1083
|
return;
|
|
@@ -967,6 +1085,11 @@ const main = async (): Promise<void> => {
|
|
|
967
1085
|
|
|
968
1086
|
if (command === 'sync') {
|
|
969
1087
|
await runSyncCommand(consumer, from, ref, dryRun);
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (!(await runGateCommand(command, consumer, apply))) {
|
|
1092
|
+
process.exitCode = 1;
|
|
970
1093
|
}
|
|
971
1094
|
};
|
|
972
1095
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { isRecord } from './github-settings';
|
|
5
|
+
import {
|
|
6
|
+
hasSafeCommand,
|
|
7
|
+
inspectWorkspace,
|
|
8
|
+
isSafeFilteredTurboAlias,
|
|
9
|
+
type Workspace,
|
|
10
|
+
} from './structure-workspace';
|
|
11
|
+
|
|
12
|
+
const ROOT_CHECK = 'turbo run lint check-types test build test:a11y';
|
|
13
|
+
const ROOT_CHECK_FIX = 'turbo run lint:fix check-types test build test:a11y';
|
|
14
|
+
const ROOT_A11Y = 'turbo run test:a11y';
|
|
15
|
+
|
|
16
|
+
const ROOT_FIXED_SCRIPTS = new Set([
|
|
17
|
+
'standards',
|
|
18
|
+
'check',
|
|
19
|
+
'check:fix',
|
|
20
|
+
'test:a11y',
|
|
21
|
+
]);
|
|
22
|
+
const GLOB_SUFFIX = '/*';
|
|
23
|
+
const WORKSPACES_REQUIREMENT =
|
|
24
|
+
'package.json: "workspaces" must be a non-empty array of literal paths or one-level "<dir>/*" patterns';
|
|
25
|
+
|
|
26
|
+
const readJson = async (
|
|
27
|
+
path: string,
|
|
28
|
+
): Promise<Record<string, unknown> | null> => {
|
|
29
|
+
const raw = await readFile(path, 'utf8').catch(() => null);
|
|
30
|
+
if (raw === null) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
35
|
+
return isRecord(parsed) ? parsed : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
type ResolvedPattern = {
|
|
41
|
+
readonly dirs: ReadonlyArray<string>;
|
|
42
|
+
readonly problem: string | null;
|
|
43
|
+
};
|
|
44
|
+
type WorkspacePatterns = {
|
|
45
|
+
readonly patterns: ReadonlyArray<string>;
|
|
46
|
+
readonly problems: ReadonlyArray<string>;
|
|
47
|
+
};
|
|
48
|
+
const isSafeWorkspacePath = (pattern: string): boolean =>
|
|
49
|
+
pattern.trim() !== '' &&
|
|
50
|
+
pattern === pattern.trim() &&
|
|
51
|
+
!isAbsolute(pattern) &&
|
|
52
|
+
!pattern.includes('\\') &&
|
|
53
|
+
pattern
|
|
54
|
+
.split('/')
|
|
55
|
+
.every((part) => part !== '' && part !== '.' && part !== '..');
|
|
56
|
+
const workspacePatternsOf = (
|
|
57
|
+
root: Record<string, unknown>,
|
|
58
|
+
): WorkspacePatterns => {
|
|
59
|
+
const { workspaces } = root;
|
|
60
|
+
if (!Array.isArray(workspaces) || workspaces.length === 0) {
|
|
61
|
+
return { patterns: [], problems: [WORKSPACES_REQUIREMENT] };
|
|
62
|
+
}
|
|
63
|
+
const patterns: Array<string> = [];
|
|
64
|
+
const problems: Array<string> = [];
|
|
65
|
+
workspaces.forEach((pattern, index) => {
|
|
66
|
+
if (typeof pattern !== 'string') {
|
|
67
|
+
problems.push(`package.json: workspaces[${index}] must be a string`);
|
|
68
|
+
} else if (isSafeWorkspacePath(pattern)) {
|
|
69
|
+
patterns.push(pattern);
|
|
70
|
+
} else {
|
|
71
|
+
problems.push(
|
|
72
|
+
`package.json: unsafe workspaces pattern "${pattern}"; use a relative path without "." or ".." segments`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return { patterns, problems };
|
|
77
|
+
};
|
|
78
|
+
const resolvePattern = async (
|
|
79
|
+
consumer: string,
|
|
80
|
+
pattern: string,
|
|
81
|
+
): Promise<ResolvedPattern> => {
|
|
82
|
+
if (
|
|
83
|
+
pattern.endsWith(GLOB_SUFFIX) &&
|
|
84
|
+
!pattern.slice(0, -GLOB_SUFFIX.length).includes('*')
|
|
85
|
+
) {
|
|
86
|
+
const base = pattern.slice(0, -GLOB_SUFFIX.length);
|
|
87
|
+
const entries = await readdir(join(consumer, base), {
|
|
88
|
+
withFileTypes: true,
|
|
89
|
+
}).catch((error: unknown) =>
|
|
90
|
+
isRecord(error) && error.code === 'ENOENT' ? [] : null,
|
|
91
|
+
);
|
|
92
|
+
if (entries === null) {
|
|
93
|
+
return {
|
|
94
|
+
dirs: [],
|
|
95
|
+
problem: `package.json: cannot read workspace directory "${base}" declared by "${pattern}"`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
dirs: entries
|
|
100
|
+
.filter((entry) => entry.isDirectory())
|
|
101
|
+
.map((entry) => `${base}/${entry.name}`),
|
|
102
|
+
problem: null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (pattern.includes('*')) {
|
|
106
|
+
return {
|
|
107
|
+
dirs: [],
|
|
108
|
+
problem: `package.json: unsupported workspaces pattern "${pattern}"; use "<dir>/*" or a literal path`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { dirs: [pattern], problem: null };
|
|
112
|
+
};
|
|
113
|
+
type LoadedWorkspace = {
|
|
114
|
+
readonly workspace: Workspace | null;
|
|
115
|
+
readonly problem: string | null;
|
|
116
|
+
};
|
|
117
|
+
const loadWorkspace = async (
|
|
118
|
+
consumer: string,
|
|
119
|
+
rel: string,
|
|
120
|
+
): Promise<LoadedWorkspace> => {
|
|
121
|
+
const path = join(consumer, rel, 'package.json');
|
|
122
|
+
if (!existsSync(path)) {
|
|
123
|
+
return { workspace: null, problem: null };
|
|
124
|
+
}
|
|
125
|
+
const manifest = await readJson(path);
|
|
126
|
+
if (manifest === null) {
|
|
127
|
+
return {
|
|
128
|
+
workspace: null,
|
|
129
|
+
problem: `${rel}: package.json must contain a JSON object`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
workspace: { rel, dir: join(consumer, rel), manifest },
|
|
134
|
+
problem: null,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
const inspectRootScripts = (
|
|
138
|
+
root: Record<string, unknown>,
|
|
139
|
+
requireA11y: boolean,
|
|
140
|
+
): ReadonlyArray<string> => {
|
|
141
|
+
const scripts = isRecord(root.scripts) ? root.scripts : {};
|
|
142
|
+
const expectations: ReadonlyArray<readonly [string, string]> = [
|
|
143
|
+
['check', ROOT_CHECK],
|
|
144
|
+
['check:fix', ROOT_CHECK_FIX],
|
|
145
|
+
...(requireA11y ? [['test:a11y', ROOT_A11Y] as const] : []),
|
|
146
|
+
];
|
|
147
|
+
const gateProblems = expectations.flatMap(([name, fragment]) => {
|
|
148
|
+
const { [name]: script } = scripts;
|
|
149
|
+
return typeof script === 'string' && hasSafeCommand(script, fragment)
|
|
150
|
+
? []
|
|
151
|
+
: [`package.json: root script "${name}" must run ${fragment}`];
|
|
152
|
+
});
|
|
153
|
+
const aliasProblems = Object.entries(scripts).flatMap(([name, script]) =>
|
|
154
|
+
ROOT_FIXED_SCRIPTS.has(name) ||
|
|
155
|
+
typeof script !== 'string' ||
|
|
156
|
+
isSafeFilteredTurboAlias(script)
|
|
157
|
+
? []
|
|
158
|
+
: [
|
|
159
|
+
`package.json: root script "${name}" must delegate through Turbo with an explicit --filter`,
|
|
160
|
+
],
|
|
161
|
+
);
|
|
162
|
+
return [...gateProblems, ...aliasProblems];
|
|
163
|
+
};
|
|
164
|
+
export const collectStructureProblems = async (
|
|
165
|
+
consumer: string,
|
|
166
|
+
): Promise<ReadonlyArray<string>> => {
|
|
167
|
+
const root = await readJson(join(consumer, 'package.json'));
|
|
168
|
+
if (root === null) {
|
|
169
|
+
return ['package.json must exist and contain a JSON object'];
|
|
170
|
+
}
|
|
171
|
+
const declaration = workspacePatternsOf(root);
|
|
172
|
+
const resolved = await Promise.all(
|
|
173
|
+
declaration.patterns.map((pattern) => resolvePattern(consumer, pattern)),
|
|
174
|
+
);
|
|
175
|
+
const rels = [...new Set(resolved.flatMap((r) => r.dirs))].sort();
|
|
176
|
+
const loaded = await Promise.all(
|
|
177
|
+
rels.map((rel) => loadWorkspace(consumer, rel)),
|
|
178
|
+
);
|
|
179
|
+
const workspaces = loaded.flatMap((l) =>
|
|
180
|
+
l.workspace === null ? [] : [l.workspace],
|
|
181
|
+
);
|
|
182
|
+
const workspaceNames = new Set(
|
|
183
|
+
workspaces
|
|
184
|
+
.map((ws) => ws.manifest.name)
|
|
185
|
+
.filter((name): name is string => typeof name === 'string'),
|
|
186
|
+
);
|
|
187
|
+
const inspections = await Promise.all(
|
|
188
|
+
workspaces.map((ws) => inspectWorkspace(ws, workspaceNames)),
|
|
189
|
+
);
|
|
190
|
+
const requireA11y = inspections.some((i) => i.hasA11ySuite);
|
|
191
|
+
return [
|
|
192
|
+
...declaration.problems,
|
|
193
|
+
...resolved.flatMap((r) => (r.problem === null ? [] : [r.problem])),
|
|
194
|
+
...loaded.flatMap((l) => (l.problem === null ? [] : [l.problem])),
|
|
195
|
+
...inspectRootScripts(root, requireA11y),
|
|
196
|
+
...inspections.flatMap((i) => [...i.problems]),
|
|
197
|
+
];
|
|
198
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord } from './github-settings';
|
|
4
|
+
export type Workspace = {
|
|
5
|
+
readonly rel: string;
|
|
6
|
+
readonly dir: string;
|
|
7
|
+
readonly manifest: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
const WORKSPACE_SCRIPTS: ReadonlyArray<readonly [string, string]> = [
|
|
10
|
+
['check-types', 'tsc --noEmit'],
|
|
11
|
+
['lint', 'biome check --error-on-warnings .'],
|
|
12
|
+
['lint:fix', 'biome check --write --error-on-warnings .'],
|
|
13
|
+
['test', 'bun test'],
|
|
14
|
+
];
|
|
15
|
+
const A11Y_DEPENDENCIES = ['@axe-core/playwright', '@playwright/test'] as const;
|
|
16
|
+
const DEPENDENCY_FIELDS =
|
|
17
|
+
'dependencies devDependencies peerDependencies optionalDependencies'.split(
|
|
18
|
+
' ',
|
|
19
|
+
);
|
|
20
|
+
const SHARED_TSCONFIG_PACKAGE = '@davidvornholt/typescript-config';
|
|
21
|
+
const SHARED_TSCONFIG_PATH =
|
|
22
|
+
/^@davidvornholt\/typescript-config\/(?:base|next|react-library)$/u;
|
|
23
|
+
const UNSAFE_SCRIPT_SYNTAX = /[|;#"'`\r\n]/u;
|
|
24
|
+
const SCRIPT_WHITESPACE = /\s+/u;
|
|
25
|
+
const NON_EXECUTING_A11Y_OPTION =
|
|
26
|
+
/^(?:-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
|
+
const IGNORED_DIRS = new Set('.git,.next,.turbo,dist,node_modules'.split(','));
|
|
29
|
+
const scriptOf = (
|
|
30
|
+
manifest: Record<string, unknown>,
|
|
31
|
+
name: string,
|
|
32
|
+
): string | null => {
|
|
33
|
+
const scripts = isRecord(manifest.scripts) ? manifest.scripts : {};
|
|
34
|
+
const script = scripts[name];
|
|
35
|
+
return typeof script === 'string' ? script : null;
|
|
36
|
+
};
|
|
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
|
+
const hasSafeA11yCommand = (script: string | null): boolean =>
|
|
60
|
+
safeCommands(script)?.some(
|
|
61
|
+
(tokens) =>
|
|
62
|
+
tokens[0] === 'playwright' &&
|
|
63
|
+
tokens[1] === 'test' &&
|
|
64
|
+
tokens.slice(2).every((token) => !NON_EXECUTING_A11Y_OPTION.test(token)),
|
|
65
|
+
) ?? 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
|
+
const containsA11ySuite = async (dir: string): Promise<boolean> => {
|
|
89
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
90
|
+
if (
|
|
91
|
+
entries.some((entry) => entry.isFile() && entry.name.endsWith('.a11y.ts'))
|
|
92
|
+
) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
const nested = await Promise.all(
|
|
96
|
+
entries
|
|
97
|
+
.filter((entry) => entry.isDirectory() && !IGNORED_DIRS.has(entry.name))
|
|
98
|
+
.map((entry) => containsA11ySuite(join(dir, entry.name))),
|
|
99
|
+
);
|
|
100
|
+
return nested.includes(true);
|
|
101
|
+
};
|
|
102
|
+
const inspectScripts = (ws: Workspace): ReadonlyArray<string> =>
|
|
103
|
+
WORKSPACE_SCRIPTS.flatMap(([name, command]) =>
|
|
104
|
+
hasSafeCommand(scriptOf(ws.manifest, name), command)
|
|
105
|
+
? []
|
|
106
|
+
: [`${ws.rel}: script "${name}" must run ${command}`],
|
|
107
|
+
);
|
|
108
|
+
const declaredDependencies = (manifest: Record<string, unknown>) =>
|
|
109
|
+
DEPENDENCY_FIELDS.flatMap((field) => {
|
|
110
|
+
const deps = manifest[field];
|
|
111
|
+
return isRecord(deps) ? Object.entries(deps) : [];
|
|
112
|
+
});
|
|
113
|
+
const inspectInternalDeps = (
|
|
114
|
+
ws: Workspace,
|
|
115
|
+
workspaceNames: ReadonlySet<string>,
|
|
116
|
+
): ReadonlyArray<string> =>
|
|
117
|
+
declaredDependencies(ws.manifest).flatMap(([name, spec]) =>
|
|
118
|
+
workspaceNames.has(name) && spec !== 'workspace:*'
|
|
119
|
+
? [`${ws.rel}: internal dependency "${name}" must use "workspace:*"`]
|
|
120
|
+
: [],
|
|
121
|
+
);
|
|
122
|
+
const isSharedTsconfigPath = (value: unknown): value is string =>
|
|
123
|
+
typeof value === 'string' && SHARED_TSCONFIG_PATH.test(value);
|
|
124
|
+
const extendsSharedTsconfig = (raw: string): boolean => {
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try {
|
|
127
|
+
parsed = globalThis.Bun.JSONC.parse(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (!isRecord(parsed)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
const extensions = Array.isArray(parsed.extends)
|
|
135
|
+
? parsed.extends
|
|
136
|
+
: [parsed.extends];
|
|
137
|
+
return (
|
|
138
|
+
extensions.every((value) => typeof value === 'string') &&
|
|
139
|
+
extensions.some(isSharedTsconfigPath)
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
const inspectTsconfig = async (
|
|
143
|
+
ws: Workspace,
|
|
144
|
+
): Promise<ReadonlyArray<string>> => {
|
|
145
|
+
if (ws.manifest.name === SHARED_TSCONFIG_PACKAGE) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const raw = await readFile(join(ws.dir, 'tsconfig.json'), 'utf8').catch(
|
|
149
|
+
() => null,
|
|
150
|
+
);
|
|
151
|
+
if (raw === null || !extendsSharedTsconfig(raw)) {
|
|
152
|
+
return [`${ws.rel}: tsconfig.json must extend ${SHARED_TSCONFIG_PACKAGE}`];
|
|
153
|
+
}
|
|
154
|
+
return [];
|
|
155
|
+
};
|
|
156
|
+
const inspectA11y = async (
|
|
157
|
+
ws: Workspace,
|
|
158
|
+
): Promise<{ problems: ReadonlyArray<string>; hasSuite: boolean }> => {
|
|
159
|
+
const hasSuite = await containsA11ySuite(ws.dir);
|
|
160
|
+
if (!hasSuite) {
|
|
161
|
+
return { problems: [], hasSuite };
|
|
162
|
+
}
|
|
163
|
+
const declared = new Set(
|
|
164
|
+
declaredDependencies(ws.manifest).map(([name]) => name),
|
|
165
|
+
);
|
|
166
|
+
const problems = [
|
|
167
|
+
...(hasSafeA11yCommand(scriptOf(ws.manifest, 'test:a11y'))
|
|
168
|
+
? []
|
|
169
|
+
: [
|
|
170
|
+
`${ws.rel}: a *.a11y.ts suite requires a non-empty "test:a11y" script that runs playwright test`,
|
|
171
|
+
]),
|
|
172
|
+
...A11Y_DEPENDENCIES.filter((dep) => !declared.has(dep)).map(
|
|
173
|
+
(dep) =>
|
|
174
|
+
`${ws.rel}: a *.a11y.ts suite requires a direct dependency on ${dep}`,
|
|
175
|
+
),
|
|
176
|
+
];
|
|
177
|
+
return { problems, hasSuite };
|
|
178
|
+
};
|
|
179
|
+
export const inspectWorkspace = async (
|
|
180
|
+
ws: Workspace,
|
|
181
|
+
workspaceNames: ReadonlySet<string>,
|
|
182
|
+
) => {
|
|
183
|
+
const [tsconfigProblems, a11y] = await Promise.all([
|
|
184
|
+
inspectTsconfig(ws),
|
|
185
|
+
inspectA11y(ws),
|
|
186
|
+
]);
|
|
187
|
+
const problems = [
|
|
188
|
+
...inspectScripts(ws),
|
|
189
|
+
...(ws.manifest.version === '0.0.0'
|
|
190
|
+
? []
|
|
191
|
+
: [`${ws.rel}: internal workspace version must be "0.0.0"`]),
|
|
192
|
+
...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
|
+
...tsconfigProblems,
|
|
197
|
+
...a11y.problems,
|
|
198
|
+
];
|
|
199
|
+
return { problems, hasA11ySuite: a11y.hasSuite };
|
|
200
|
+
};
|