@davidvornholt/standards 0.4.0 → 0.6.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 +3 -0
- package/package.json +4 -2
- package/src/cli.ts +130 -68
- package/src/structure-check.ts +198 -0
- package/src/structure-workspace.ts +200 -0
package/README.md
CHANGED
|
@@ -7,12 +7,15 @@ 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
|
|
|
18
21
|
- **`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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,13 +76,21 @@ 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;
|
|
81
90
|
readonly consumer: string;
|
|
82
91
|
readonly dryRun: boolean;
|
|
83
92
|
readonly from: string | undefined;
|
|
93
|
+
readonly ref: string | undefined;
|
|
84
94
|
readonly apply: boolean;
|
|
85
95
|
};
|
|
86
96
|
|
|
@@ -90,7 +100,9 @@ const sha256 = (buf: Buffer): string =>
|
|
|
90
100
|
const toPosix = (p: string): string => p.split(sep).join('/');
|
|
91
101
|
|
|
92
102
|
const assertSafeRelativePath = (path: string, label: string): void => {
|
|
93
|
-
|
|
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);
|
|
94
106
|
if (
|
|
95
107
|
path.length === 0 ||
|
|
96
108
|
path === '.' ||
|
|
@@ -163,9 +175,15 @@ const writeLock = async (dir: string, lock: Lock): Promise<void> => {
|
|
|
163
175
|
|
|
164
176
|
// Fetch the template into a working directory. Accepts a local path (used to
|
|
165
177
|
// prove the engine before the public repo exists and in tests), a github:
|
|
166
|
-
// shorthand, or any git URL.
|
|
167
|
-
|
|
178
|
+
// shorthand, or any git URL. Remote sources default to `main`; `ref` pins a
|
|
179
|
+
// tag, branch, or full commit sha instead (`git fetch` accepts all three).
|
|
180
|
+
const resolveSource = (src: string, ref: string | undefined): Source => {
|
|
168
181
|
if (existsSync(src)) {
|
|
182
|
+
if (ref !== undefined) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`--ref requires a git URL source; a local path is used as-is: ${src}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
169
187
|
let sha = 'local';
|
|
170
188
|
try {
|
|
171
189
|
sha = execFileSync('git', ['-C', src, 'rev-parse', 'HEAD'], {
|
|
@@ -180,18 +198,34 @@ const resolveSource = (src: string): Source => {
|
|
|
180
198
|
const url = src.startsWith(GITHUB_PREFIX)
|
|
181
199
|
? `https://github.com/${src.slice(GITHUB_PREFIX.length)}.git`
|
|
182
200
|
: src;
|
|
201
|
+
const target = ref ?? 'main';
|
|
183
202
|
const dir = mkdtempSync(join(tmpdir(), 'standards-'));
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
203
|
+
const cleanup = (): void => rmSync(dir, { recursive: true, force: true });
|
|
204
|
+
try {
|
|
205
|
+
// init + fetch instead of `clone --branch` so a full commit sha works as a
|
|
206
|
+
// ref, not only tags and branches (GitHub serves reachable sha fetches).
|
|
207
|
+
execFileSync('git', ['init', '--quiet', dir], { stdio: 'ignore' });
|
|
208
|
+
execFileSync(
|
|
209
|
+
'git',
|
|
210
|
+
['-C', dir, 'fetch', '--quiet', '--depth', '1', '--', url, target],
|
|
211
|
+
{ stdio: 'ignore' },
|
|
212
|
+
);
|
|
213
|
+
execFileSync(
|
|
214
|
+
'git',
|
|
215
|
+
['-C', dir, 'checkout', '--quiet', '--detach', 'FETCH_HEAD'],
|
|
216
|
+
{ stdio: 'ignore' },
|
|
217
|
+
);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
cleanup();
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Cannot fetch "${target}" from ${url}; expected a tag, branch, or full commit sha reachable on the remote`,
|
|
222
|
+
{ cause: error },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
187
225
|
const sha = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], {
|
|
188
226
|
encoding: 'utf8',
|
|
189
227
|
}).trim();
|
|
190
|
-
return {
|
|
191
|
-
dir,
|
|
192
|
-
sha,
|
|
193
|
-
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
194
|
-
};
|
|
228
|
+
return { dir, sha, cleanup };
|
|
195
229
|
};
|
|
196
230
|
|
|
197
231
|
// Recursively collect files under `abs`, keyed by their POSIX path relative to
|
|
@@ -474,15 +508,6 @@ const runCheck = async (consumer: string): Promise<boolean> => {
|
|
|
474
508
|
const readTextIfPresent = async (path: string): Promise<string | null> =>
|
|
475
509
|
existsSync(path) ? readFile(path, 'utf8') : null;
|
|
476
510
|
|
|
477
|
-
const hasStandardsImport = (justfile: string): boolean =>
|
|
478
|
-
justfile.split('\n').some((line) => {
|
|
479
|
-
const trimmed = line.trim();
|
|
480
|
-
return (
|
|
481
|
-
trimmed === "import 'standards.just'" ||
|
|
482
|
-
trimmed === 'import "standards.just"'
|
|
483
|
-
);
|
|
484
|
-
});
|
|
485
|
-
|
|
486
511
|
const DEPENDABOT_BASELINE_ECOSYSTEMS = ['bun', 'github-actions'] as const;
|
|
487
512
|
const DEPENDABOT_SCHEDULE_INTERVALS = new Set([
|
|
488
513
|
'daily',
|
|
@@ -658,11 +683,19 @@ const inspectDependabot = (raw: string): ReadonlyArray<string> => {
|
|
|
658
683
|
|
|
659
684
|
const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
660
685
|
const problems: Array<string> = [];
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
+
: {};
|
|
666
699
|
if (typeof devDependencies?.['@davidvornholt/standards'] !== 'string') {
|
|
667
700
|
problems.push(
|
|
668
701
|
'package.json must declare @davidvornholt/standards directly',
|
|
@@ -670,7 +703,10 @@ const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
|
670
703
|
}
|
|
671
704
|
for (const name of ['check', 'check:fix']) {
|
|
672
705
|
const script = scripts?.[name];
|
|
673
|
-
if (
|
|
706
|
+
if (
|
|
707
|
+
typeof script !== 'string' ||
|
|
708
|
+
!hasSafeCommand(script, 'standards check')
|
|
709
|
+
) {
|
|
674
710
|
problems.push(`package.json script "${name}" must run standards check`);
|
|
675
711
|
}
|
|
676
712
|
}
|
|
@@ -679,11 +715,6 @@ const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
|
|
|
679
715
|
|
|
680
716
|
const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
681
717
|
const problems: Array<string> = [];
|
|
682
|
-
const justfile = await readTextIfPresent(join(consumer, 'justfile'));
|
|
683
|
-
if (justfile === null || !hasStandardsImport(justfile)) {
|
|
684
|
-
problems.push("justfile must import 'standards.just'");
|
|
685
|
-
}
|
|
686
|
-
|
|
687
718
|
const biome = await readTextIfPresent(join(consumer, 'biome.jsonc'));
|
|
688
719
|
if (biome === null || !biome.includes('"./biome.base.jsonc"')) {
|
|
689
720
|
problems.push('biome.jsonc must extend "./biome.base.jsonc"');
|
|
@@ -738,16 +769,18 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
738
769
|
const USAGE = `Usage: standards <command> [options]
|
|
739
770
|
|
|
740
771
|
Commands:
|
|
741
|
-
init
|
|
742
|
-
sync
|
|
743
|
-
check
|
|
744
|
-
doctor
|
|
745
|
-
|
|
746
|
-
|
|
772
|
+
init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
|
|
773
|
+
sync Mirror canonical files from upstream and rewrite the lock
|
|
774
|
+
check Verify canonical files, extension seams, monorepo structure, and GitHub settings
|
|
775
|
+
doctor Validate extension seams only
|
|
776
|
+
structure Validate monorepo structure rules only
|
|
777
|
+
github Compare (--check) or converge (--apply) live GitHub settings
|
|
778
|
+
help Show this help
|
|
747
779
|
|
|
748
780
|
Options:
|
|
749
781
|
--dir <path> Consumer directory to operate on (default: current directory)
|
|
750
782
|
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
783
|
+
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
751
784
|
--dry-run Preview a sync without writing anything
|
|
752
785
|
--check With github: compare live settings to the declaration (default)
|
|
753
786
|
--apply With github: converge the live repository (needs admin auth)`;
|
|
@@ -759,6 +792,7 @@ const commandFromArg = (arg: string): Command => {
|
|
|
759
792
|
arg === 'github' ||
|
|
760
793
|
arg === 'help' ||
|
|
761
794
|
arg === 'init' ||
|
|
795
|
+
arg === 'structure' ||
|
|
762
796
|
arg === 'sync'
|
|
763
797
|
) {
|
|
764
798
|
return arg;
|
|
@@ -791,6 +825,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
791
825
|
let consumer = process.cwd();
|
|
792
826
|
let dryRun = false;
|
|
793
827
|
let from: string | undefined;
|
|
828
|
+
let ref: string | undefined;
|
|
794
829
|
let checkFlag = false;
|
|
795
830
|
let apply = false;
|
|
796
831
|
|
|
@@ -814,6 +849,10 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
814
849
|
from = nextOptionValue(argv, index);
|
|
815
850
|
index += 1;
|
|
816
851
|
break;
|
|
852
|
+
case '--ref':
|
|
853
|
+
ref = nextOptionValue(argv, index);
|
|
854
|
+
index += 1;
|
|
855
|
+
break;
|
|
817
856
|
case '--help':
|
|
818
857
|
case '-h':
|
|
819
858
|
command = setCommand(command, 'help');
|
|
@@ -832,31 +871,54 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
832
871
|
if (apply && checkFlag) {
|
|
833
872
|
throw new Error('github accepts exactly one of --check or --apply');
|
|
834
873
|
}
|
|
874
|
+
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
875
|
+
throw new Error('--ref is only valid with the init and sync commands');
|
|
876
|
+
}
|
|
835
877
|
|
|
836
878
|
return {
|
|
837
879
|
command,
|
|
838
880
|
consumer: resolve(consumer),
|
|
839
881
|
dryRun,
|
|
840
882
|
from,
|
|
883
|
+
ref,
|
|
841
884
|
apply,
|
|
842
885
|
};
|
|
843
886
|
};
|
|
844
887
|
|
|
888
|
+
// Canonical monorepo structure gate: workspace and root script shapes,
|
|
889
|
+
// internal versioning, `exports`, tsconfig inheritance, and a11y wiring.
|
|
890
|
+
const runStructure = async (consumer: string): Promise<boolean> => {
|
|
891
|
+
const problems = await collectStructureProblems(consumer);
|
|
892
|
+
if (problems.length > 0) {
|
|
893
|
+
console.error(
|
|
894
|
+
`standards structure: ${problems.length} monorepo structure problem(s):`,
|
|
895
|
+
);
|
|
896
|
+
console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
console.log('standards structure: workspace layout matches the standards');
|
|
900
|
+
return true;
|
|
901
|
+
};
|
|
902
|
+
|
|
845
903
|
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
846
904
|
const driftIsClean = await runCheck(consumer);
|
|
847
905
|
const integrationIsValid = await runDoctor(consumer);
|
|
906
|
+
const structureIsValid = await runStructure(consumer);
|
|
848
907
|
// The GitHub gate activates with the synced declaration and then fails
|
|
849
908
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
850
909
|
// unreadable origin is a failure, not a skip.
|
|
851
910
|
const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
|
|
852
911
|
? await runGithubCheck(consumer)
|
|
853
912
|
: true;
|
|
854
|
-
return
|
|
913
|
+
return (
|
|
914
|
+
driftIsClean && integrationIsValid && structureIsValid && githubIsConverged
|
|
915
|
+
);
|
|
855
916
|
};
|
|
856
917
|
|
|
857
918
|
const runInitCommand = async (
|
|
858
919
|
consumer: string,
|
|
859
920
|
from: string | undefined,
|
|
921
|
+
ref: string | undefined,
|
|
860
922
|
): Promise<void> => {
|
|
861
923
|
// Refuse before cloning upstream: re-initializing skips the lock, so it
|
|
862
924
|
// would silently overwrite local canonical edits and orphan files that
|
|
@@ -868,7 +930,7 @@ const runInitCommand = async (
|
|
|
868
930
|
process.exitCode = 1;
|
|
869
931
|
return;
|
|
870
932
|
}
|
|
871
|
-
const source = resolveSource(from ?? DEFAULT_UPSTREAM);
|
|
933
|
+
const source = resolveSource(from ?? DEFAULT_UPSTREAM, ref);
|
|
872
934
|
try {
|
|
873
935
|
const manifest = await loadManifest(
|
|
874
936
|
join(source.dir, 'sync-standards.json'),
|
|
@@ -882,12 +944,13 @@ const runInitCommand = async (
|
|
|
882
944
|
const runSyncCommand = async (
|
|
883
945
|
consumer: string,
|
|
884
946
|
from: string | undefined,
|
|
947
|
+
ref: string | undefined,
|
|
885
948
|
dryRun: boolean,
|
|
886
949
|
): Promise<void> => {
|
|
887
950
|
const consumerManifest = await loadManifest(
|
|
888
951
|
join(consumer, 'sync-standards.json'),
|
|
889
952
|
);
|
|
890
|
-
const source = resolveSource(from ?? consumerManifest.upstream);
|
|
953
|
+
const source = resolveSource(from ?? consumerManifest.upstream, ref);
|
|
891
954
|
try {
|
|
892
955
|
const manifest = await loadManifest(
|
|
893
956
|
join(source.dir, 'sync-standards.json'),
|
|
@@ -898,8 +961,26 @@ const runSyncCommand = async (
|
|
|
898
961
|
}
|
|
899
962
|
};
|
|
900
963
|
|
|
964
|
+
// Commands whose success is reported through the exit code.
|
|
965
|
+
const runGateCommand = (
|
|
966
|
+
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
967
|
+
consumer: string,
|
|
968
|
+
apply: boolean,
|
|
969
|
+
): Promise<boolean> => {
|
|
970
|
+
if (command === 'check') {
|
|
971
|
+
return runCheckCommand(consumer);
|
|
972
|
+
}
|
|
973
|
+
if (command === 'doctor') {
|
|
974
|
+
return runDoctor(consumer);
|
|
975
|
+
}
|
|
976
|
+
if (command === 'structure') {
|
|
977
|
+
return runStructure(consumer);
|
|
978
|
+
}
|
|
979
|
+
return apply ? runGithubApply(consumer) : runGithubCheck(consumer);
|
|
980
|
+
};
|
|
981
|
+
|
|
901
982
|
const main = async (): Promise<void> => {
|
|
902
|
-
const { command, consumer, dryRun, from, apply } = parseArgs(
|
|
983
|
+
const { command, consumer, dryRun, from, ref, apply } = parseArgs(
|
|
903
984
|
process.argv.slice(2),
|
|
904
985
|
);
|
|
905
986
|
|
|
@@ -915,37 +996,18 @@ const main = async (): Promise<void> => {
|
|
|
915
996
|
return;
|
|
916
997
|
}
|
|
917
998
|
|
|
918
|
-
if (command === '
|
|
919
|
-
|
|
920
|
-
process.exitCode = 1;
|
|
921
|
-
}
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
if (command === 'github') {
|
|
926
|
-
const converged = apply
|
|
927
|
-
? await runGithubApply(consumer)
|
|
928
|
-
: await runGithubCheck(consumer);
|
|
929
|
-
if (!converged) {
|
|
930
|
-
process.exitCode = 1;
|
|
931
|
-
}
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
if (command === 'doctor') {
|
|
936
|
-
if (!(await runDoctor(consumer))) {
|
|
937
|
-
process.exitCode = 1;
|
|
938
|
-
}
|
|
999
|
+
if (command === 'init') {
|
|
1000
|
+
await runInitCommand(consumer, from, ref);
|
|
939
1001
|
return;
|
|
940
1002
|
}
|
|
941
1003
|
|
|
942
|
-
if (command === '
|
|
943
|
-
await
|
|
1004
|
+
if (command === 'sync') {
|
|
1005
|
+
await runSyncCommand(consumer, from, ref, dryRun);
|
|
944
1006
|
return;
|
|
945
1007
|
}
|
|
946
1008
|
|
|
947
|
-
if (command
|
|
948
|
-
|
|
1009
|
+
if (!(await runGateCommand(command, consumer, apply))) {
|
|
1010
|
+
process.exitCode = 1;
|
|
949
1011
|
}
|
|
950
1012
|
};
|
|
951
1013
|
|
|
@@ -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
|
+
};
|