@davidvornholt/standards 0.6.0 → 0.8.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 +3 -1
- package/src/cli.ts +116 -9
- package/src/structure-check.ts +22 -25
- package/src/structure-profile.ts +129 -0
- package/src/structure-script.ts +63 -0
- package/src/structure-workspace.ts +8 -54
package/README.md
CHANGED
|
@@ -16,8 +16,11 @@ standards github --apply
|
|
|
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
|
|
|
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.
|
|
21
24
|
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` and private-repo reads need an authenticated token, and repo merge settings are only visible (and thus verifiable) to admin tokens — non-admin runs report them as unverifiable.
|
|
22
25
|
|
|
23
26
|
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.8.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"src/github-diff.ts",
|
|
27
27
|
"src/github-settings.ts",
|
|
28
28
|
"src/structure-check.ts",
|
|
29
|
+
"src/structure-profile.ts",
|
|
30
|
+
"src/structure-script.ts",
|
|
29
31
|
"src/structure-workspace.ts"
|
|
30
32
|
],
|
|
31
33
|
"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 =>
|
|
@@ -755,6 +757,8 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
|
|
|
755
757
|
);
|
|
756
758
|
}
|
|
757
759
|
|
|
760
|
+
problems.push(...(await inspectPolicy(consumer)));
|
|
761
|
+
|
|
758
762
|
if (problems.length > 0) {
|
|
759
763
|
console.error(
|
|
760
764
|
`standards doctor: ${problems.length} integration problem(s):`,
|
|
@@ -779,6 +783,7 @@ Commands:
|
|
|
779
783
|
|
|
780
784
|
Options:
|
|
781
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
|
|
782
787
|
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
783
788
|
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
784
789
|
--dry-run Preview a sync without writing anything
|
|
@@ -809,6 +814,13 @@ const setCommand = (current: Command | undefined, next: Command): Command => {
|
|
|
809
814
|
return next;
|
|
810
815
|
};
|
|
811
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
|
+
|
|
812
824
|
const nextOptionValue = (
|
|
813
825
|
argv: ReadonlyArray<string>,
|
|
814
826
|
index: number,
|
|
@@ -828,6 +840,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
828
840
|
let ref: string | undefined;
|
|
829
841
|
let checkFlag = false;
|
|
830
842
|
let apply = false;
|
|
843
|
+
let profile: StructureProfile | undefined;
|
|
831
844
|
|
|
832
845
|
for (let index = 0; index < argv.length; index += 1) {
|
|
833
846
|
const arg = argv[index];
|
|
@@ -849,6 +862,10 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
849
862
|
from = nextOptionValue(argv, index);
|
|
850
863
|
index += 1;
|
|
851
864
|
break;
|
|
865
|
+
case '--profile':
|
|
866
|
+
profile = structureProfileOf(nextOptionValue(argv, index));
|
|
867
|
+
index += 1;
|
|
868
|
+
break;
|
|
852
869
|
case '--ref':
|
|
853
870
|
ref = nextOptionValue(argv, index);
|
|
854
871
|
index += 1;
|
|
@@ -874,6 +891,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
874
891
|
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
875
892
|
throw new Error('--ref is only valid with the init and sync commands');
|
|
876
893
|
}
|
|
894
|
+
if (profile !== undefined && command !== 'structure') {
|
|
895
|
+
throw new Error('--profile is only valid with the structure command');
|
|
896
|
+
}
|
|
877
897
|
|
|
878
898
|
return {
|
|
879
899
|
command,
|
|
@@ -882,13 +902,19 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
882
902
|
from,
|
|
883
903
|
ref,
|
|
884
904
|
apply,
|
|
905
|
+
profile: profile ?? 'consumer',
|
|
885
906
|
};
|
|
886
907
|
};
|
|
887
908
|
|
|
888
909
|
// Canonical monorepo structure gate: workspace and root script shapes,
|
|
889
910
|
// internal versioning, `exports`, tsconfig inheritance, and a11y wiring.
|
|
890
|
-
|
|
891
|
-
|
|
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);
|
|
892
918
|
if (problems.length > 0) {
|
|
893
919
|
console.error(
|
|
894
920
|
`standards structure: ${problems.length} monorepo structure problem(s):`,
|
|
@@ -903,7 +929,7 @@ const runStructure = async (consumer: string): Promise<boolean> => {
|
|
|
903
929
|
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
904
930
|
const driftIsClean = await runCheck(consumer);
|
|
905
931
|
const integrationIsValid = await runDoctor(consumer);
|
|
906
|
-
const structureIsValid = await runStructure(consumer);
|
|
932
|
+
const structureIsValid = await runStructure(consumer, 'consumer');
|
|
907
933
|
// The GitHub gate activates with the synced declaration and then fails
|
|
908
934
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
909
935
|
// unreadable origin is a failure, not a skip.
|
|
@@ -915,6 +941,82 @@ const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
|
915
941
|
);
|
|
916
942
|
};
|
|
917
943
|
|
|
944
|
+
// Consumer-owned sync policy, checked in next to the canonical (read-only)
|
|
945
|
+
// standards-sync workflow it configures — versioned and reviewable, unlike
|
|
946
|
+
// repository Actions variables. All fields are optional; a missing file means
|
|
947
|
+
// the defaults (track main, weekly auto-sync on).
|
|
948
|
+
// autoSync false skips the scheduled workflow run; manual dispatch and
|
|
949
|
+
// local CLI runs are deliberate acts and always proceed.
|
|
950
|
+
// ref tag, branch, or full commit sha to sync from instead of main.
|
|
951
|
+
const POLICY_FILE = 'sync-standards.local.json';
|
|
952
|
+
const LINE_BREAK = /[\r\n]/u;
|
|
953
|
+
|
|
954
|
+
type Policy = {
|
|
955
|
+
readonly autoSync?: boolean;
|
|
956
|
+
readonly ref?: string;
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
const parsePolicy = (parsed: unknown): Policy => {
|
|
960
|
+
if (!isRecord(parsed)) {
|
|
961
|
+
throw new Error(`${POLICY_FILE} must be a JSON object`);
|
|
962
|
+
}
|
|
963
|
+
const unsupportedFields = Object.keys(parsed).filter(
|
|
964
|
+
(field) => field !== 'autoSync' && field !== 'ref',
|
|
965
|
+
);
|
|
966
|
+
if (unsupportedFields.length > 0) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
`${POLICY_FILE} contains unsupported field(s): ${unsupportedFields.join(', ')}`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
if (parsed.autoSync !== undefined && typeof parsed.autoSync !== 'boolean') {
|
|
972
|
+
throw new Error(`${POLICY_FILE} "autoSync" must be a boolean`);
|
|
973
|
+
}
|
|
974
|
+
if (
|
|
975
|
+
parsed.ref !== undefined &&
|
|
976
|
+
(!isNonEmptyString(parsed.ref) || LINE_BREAK.test(parsed.ref))
|
|
977
|
+
) {
|
|
978
|
+
throw new Error(
|
|
979
|
+
`${POLICY_FILE} "ref" must be a non-empty single-line string`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
return { autoSync: parsed.autoSync, ref: parsed.ref };
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
const readPolicy = async (consumer: string): Promise<Policy> => {
|
|
986
|
+
const raw = await readTextIfPresent(join(consumer, POLICY_FILE));
|
|
987
|
+
if (raw === null) {
|
|
988
|
+
return {};
|
|
989
|
+
}
|
|
990
|
+
let parsed: unknown;
|
|
991
|
+
try {
|
|
992
|
+
parsed = JSON.parse(raw);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
throw new Error(`${POLICY_FILE} must contain valid JSON`, { cause: error });
|
|
995
|
+
}
|
|
996
|
+
return parsePolicy(parsed);
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
const inspectPolicy = async (
|
|
1000
|
+
consumer: string,
|
|
1001
|
+
): Promise<ReadonlyArray<string>> => {
|
|
1002
|
+
try {
|
|
1003
|
+
await readPolicy(consumer);
|
|
1004
|
+
return [];
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
return [error instanceof Error ? error.message : String(error)];
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// Policy validation is unconditional once the file exists. Selection happens
|
|
1011
|
+
// afterward: explicit refs win for remote sources, while local paths are used
|
|
1012
|
+
// as-is and ignore only the already-validated policy ref.
|
|
1013
|
+
const selectedRef = (
|
|
1014
|
+
src: string,
|
|
1015
|
+
explicitRef: string | undefined,
|
|
1016
|
+
policy: Policy,
|
|
1017
|
+
): string | undefined =>
|
|
1018
|
+
existsSync(src) ? explicitRef : (explicitRef ?? policy.ref);
|
|
1019
|
+
|
|
918
1020
|
const runInitCommand = async (
|
|
919
1021
|
consumer: string,
|
|
920
1022
|
from: string | undefined,
|
|
@@ -930,7 +1032,9 @@ const runInitCommand = async (
|
|
|
930
1032
|
process.exitCode = 1;
|
|
931
1033
|
return;
|
|
932
1034
|
}
|
|
933
|
-
const
|
|
1035
|
+
const src = from ?? DEFAULT_UPSTREAM;
|
|
1036
|
+
const policy = await readPolicy(consumer);
|
|
1037
|
+
const source = resolveSource(src, selectedRef(src, ref, policy));
|
|
934
1038
|
try {
|
|
935
1039
|
const manifest = await loadManifest(
|
|
936
1040
|
join(source.dir, 'sync-standards.json'),
|
|
@@ -950,7 +1054,9 @@ const runSyncCommand = async (
|
|
|
950
1054
|
const consumerManifest = await loadManifest(
|
|
951
1055
|
join(consumer, 'sync-standards.json'),
|
|
952
1056
|
);
|
|
953
|
-
const
|
|
1057
|
+
const src = from ?? consumerManifest.upstream;
|
|
1058
|
+
const policy = await readPolicy(consumer);
|
|
1059
|
+
const source = resolveSource(src, selectedRef(src, ref, policy));
|
|
954
1060
|
try {
|
|
955
1061
|
const manifest = await loadManifest(
|
|
956
1062
|
join(source.dir, 'sync-standards.json'),
|
|
@@ -966,6 +1072,7 @@ const runGateCommand = (
|
|
|
966
1072
|
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
967
1073
|
consumer: string,
|
|
968
1074
|
apply: boolean,
|
|
1075
|
+
profile: StructureProfile,
|
|
969
1076
|
): Promise<boolean> => {
|
|
970
1077
|
if (command === 'check') {
|
|
971
1078
|
return runCheckCommand(consumer);
|
|
@@ -974,13 +1081,13 @@ const runGateCommand = (
|
|
|
974
1081
|
return runDoctor(consumer);
|
|
975
1082
|
}
|
|
976
1083
|
if (command === 'structure') {
|
|
977
|
-
return runStructure(consumer);
|
|
1084
|
+
return runStructure(consumer, profile);
|
|
978
1085
|
}
|
|
979
1086
|
return apply ? runGithubApply(consumer) : runGithubCheck(consumer);
|
|
980
1087
|
};
|
|
981
1088
|
|
|
982
1089
|
const main = async (): Promise<void> => {
|
|
983
|
-
const { command, consumer, dryRun, from, ref, apply } = parseArgs(
|
|
1090
|
+
const { command, consumer, dryRun, from, ref, apply, profile } = parseArgs(
|
|
984
1091
|
process.argv.slice(2),
|
|
985
1092
|
);
|
|
986
1093
|
|
|
@@ -1006,7 +1113,7 @@ const main = async (): Promise<void> => {
|
|
|
1006
1113
|
return;
|
|
1007
1114
|
}
|
|
1008
1115
|
|
|
1009
|
-
if (!(await runGateCommand(command, consumer, apply))) {
|
|
1116
|
+
if (!(await runGateCommand(command, consumer, apply, profile))) {
|
|
1010
1117
|
process.exitCode = 1;
|
|
1011
1118
|
}
|
|
1012
1119
|
};
|
package/src/structure-check.ts
CHANGED
|
@@ -3,22 +3,16 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { isAbsolute, join } from 'node:path';
|
|
4
4
|
import { isRecord } from './github-settings';
|
|
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';
|
|
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
3
|
import { isRecord } from './github-settings';
|
|
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
|
];
|