@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 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.6.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 { hasSafeCommand } from './structure-workspace';
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
- const runStructure = async (consumer: string): Promise<boolean> => {
891
- const problems = await collectStructureProblems(consumer);
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 source = resolveSource(from ?? DEFAULT_UPSTREAM, ref);
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 source = resolveSource(from ?? consumerManifest.upstream, ref);
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
  };
@@ -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
- hasSafeCommand,
7
- inspectWorkspace,
8
- isSafeFilteredTurboAlias,
9
- type Workspace,
10
- } from './structure-workspace';
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 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
- ]);
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: 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]) => {
137
+ const expectations = rootScriptExpectations(profile, requireA11y);
138
+ const gateProblems = expectations.flatMap(({ name, commands, exact }) => {
148
139
  const { [name]: script } = scripts;
149
- return typeof script === 'string' && hasSafeCommand(script, fragment)
140
+ const requirement = commands.join(' && ');
141
+ return typeof script === 'string' &&
142
+ hasSafeCommands(script, commands, exact)
150
143
  ? []
151
- : [`package.json: root script "${name}" must run ${fragment}`];
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
- ...inspectRootScripts(root, requireA11y),
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
- safeCommands(script)?.some(
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.manifest.version === '0.0.0'
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
  ];