@davidvornholt/standards 0.7.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 +2 -0
- package/package.json +3 -1
- package/src/cli.ts +32 -7
- 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,6 +16,8 @@ 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
|
|
|
21
23
|
- **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
|
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 =>
|
|
@@ -781,6 +783,7 @@ Commands:
|
|
|
781
783
|
|
|
782
784
|
Options:
|
|
783
785
|
--dir <path> Consumer directory to operate on (default: current directory)
|
|
786
|
+
--profile <p> With structure: validate as a "consumer" (default) or as the standards "source" repository itself
|
|
784
787
|
--from <src> Upstream override for init/sync (GitHub repo or local path)
|
|
785
788
|
--ref <ref> Upstream tag, branch, or full commit sha for init/sync (remote Git/GitHub sources only; default: main)
|
|
786
789
|
--dry-run Preview a sync without writing anything
|
|
@@ -811,6 +814,13 @@ const setCommand = (current: Command | undefined, next: Command): Command => {
|
|
|
811
814
|
return next;
|
|
812
815
|
};
|
|
813
816
|
|
|
817
|
+
const structureProfileOf = (value: string): StructureProfile => {
|
|
818
|
+
if (value === 'consumer' || value === 'source') {
|
|
819
|
+
return value;
|
|
820
|
+
}
|
|
821
|
+
throw new Error(`--profile must be "consumer" or "source": ${value}`);
|
|
822
|
+
};
|
|
823
|
+
|
|
814
824
|
const nextOptionValue = (
|
|
815
825
|
argv: ReadonlyArray<string>,
|
|
816
826
|
index: number,
|
|
@@ -830,6 +840,7 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
830
840
|
let ref: string | undefined;
|
|
831
841
|
let checkFlag = false;
|
|
832
842
|
let apply = false;
|
|
843
|
+
let profile: StructureProfile | undefined;
|
|
833
844
|
|
|
834
845
|
for (let index = 0; index < argv.length; index += 1) {
|
|
835
846
|
const arg = argv[index];
|
|
@@ -851,6 +862,10 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
851
862
|
from = nextOptionValue(argv, index);
|
|
852
863
|
index += 1;
|
|
853
864
|
break;
|
|
865
|
+
case '--profile':
|
|
866
|
+
profile = structureProfileOf(nextOptionValue(argv, index));
|
|
867
|
+
index += 1;
|
|
868
|
+
break;
|
|
854
869
|
case '--ref':
|
|
855
870
|
ref = nextOptionValue(argv, index);
|
|
856
871
|
index += 1;
|
|
@@ -876,6 +891,9 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
876
891
|
if (ref !== undefined && command !== 'init' && command !== 'sync') {
|
|
877
892
|
throw new Error('--ref is only valid with the init and sync commands');
|
|
878
893
|
}
|
|
894
|
+
if (profile !== undefined && command !== 'structure') {
|
|
895
|
+
throw new Error('--profile is only valid with the structure command');
|
|
896
|
+
}
|
|
879
897
|
|
|
880
898
|
return {
|
|
881
899
|
command,
|
|
@@ -884,13 +902,19 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
|
|
|
884
902
|
from,
|
|
885
903
|
ref,
|
|
886
904
|
apply,
|
|
905
|
+
profile: profile ?? 'consumer',
|
|
887
906
|
};
|
|
888
907
|
};
|
|
889
908
|
|
|
890
909
|
// Canonical monorepo structure gate: workspace and root script shapes,
|
|
891
910
|
// internal versioning, `exports`, tsconfig inheritance, and a11y wiring.
|
|
892
|
-
|
|
893
|
-
|
|
911
|
+
// The `source` profile instead pins the standards template repository's own
|
|
912
|
+
// deliberate exceptions to the consumer contract.
|
|
913
|
+
const runStructure = async (
|
|
914
|
+
consumer: string,
|
|
915
|
+
profile: StructureProfile,
|
|
916
|
+
): Promise<boolean> => {
|
|
917
|
+
const problems = await collectStructureProblems(consumer, profile);
|
|
894
918
|
if (problems.length > 0) {
|
|
895
919
|
console.error(
|
|
896
920
|
`standards structure: ${problems.length} monorepo structure problem(s):`,
|
|
@@ -905,7 +929,7 @@ const runStructure = async (consumer: string): Promise<boolean> => {
|
|
|
905
929
|
const runCheckCommand = async (consumer: string): Promise<boolean> => {
|
|
906
930
|
const driftIsClean = await runCheck(consumer);
|
|
907
931
|
const integrationIsValid = await runDoctor(consumer);
|
|
908
|
-
const structureIsValid = await runStructure(consumer);
|
|
932
|
+
const structureIsValid = await runStructure(consumer, 'consumer');
|
|
909
933
|
// The GitHub gate activates with the synced declaration and then fails
|
|
910
934
|
// closed: once .github/settings.json exists, an unreachable API or an
|
|
911
935
|
// unreadable origin is a failure, not a skip.
|
|
@@ -1048,6 +1072,7 @@ const runGateCommand = (
|
|
|
1048
1072
|
command: 'check' | 'doctor' | 'github' | 'structure',
|
|
1049
1073
|
consumer: string,
|
|
1050
1074
|
apply: boolean,
|
|
1075
|
+
profile: StructureProfile,
|
|
1051
1076
|
): Promise<boolean> => {
|
|
1052
1077
|
if (command === 'check') {
|
|
1053
1078
|
return runCheckCommand(consumer);
|
|
@@ -1056,13 +1081,13 @@ const runGateCommand = (
|
|
|
1056
1081
|
return runDoctor(consumer);
|
|
1057
1082
|
}
|
|
1058
1083
|
if (command === 'structure') {
|
|
1059
|
-
return runStructure(consumer);
|
|
1084
|
+
return runStructure(consumer, profile);
|
|
1060
1085
|
}
|
|
1061
1086
|
return apply ? runGithubApply(consumer) : runGithubCheck(consumer);
|
|
1062
1087
|
};
|
|
1063
1088
|
|
|
1064
1089
|
const main = async (): Promise<void> => {
|
|
1065
|
-
const { command, consumer, dryRun, from, ref, apply } = parseArgs(
|
|
1090
|
+
const { command, consumer, dryRun, from, ref, apply, profile } = parseArgs(
|
|
1066
1091
|
process.argv.slice(2),
|
|
1067
1092
|
);
|
|
1068
1093
|
|
|
@@ -1088,7 +1113,7 @@ const main = async (): Promise<void> => {
|
|
|
1088
1113
|
return;
|
|
1089
1114
|
}
|
|
1090
1115
|
|
|
1091
|
-
if (!(await runGateCommand(command, consumer, apply))) {
|
|
1116
|
+
if (!(await runGateCommand(command, consumer, apply, profile))) {
|
|
1092
1117
|
process.exitCode = 1;
|
|
1093
1118
|
}
|
|
1094
1119
|
};
|
package/src/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
|
];
|