@davidvornholt/standards 0.5.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 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.5.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
- normalize,
29
+ posix,
30
30
  relative,
31
31
  resolve,
32
32
  sep,
@@ -35,6 +35,8 @@ import process from 'node:process';
35
35
  import { CANONICAL_SETTINGS_FILE, LOCAL_SETTINGS_FILE } from './github-api';
36
36
  import { runGithubApply, runGithubCheck } from './github-commands';
37
37
  import { loadGithubSettings } from './github-settings';
38
+ import { collectStructureProblems } from './structure-check';
39
+ import { hasSafeCommand } from './structure-workspace';
38
40
 
39
41
  const { YAML: BunYaml } = await import('bun');
40
42
 
@@ -74,7 +76,14 @@ type Source = {
74
76
  readonly cleanup: () => void;
75
77
  };
76
78
 
77
- type Command = 'check' | 'doctor' | 'github' | 'help' | 'init' | 'sync';
79
+ type Command =
80
+ | 'check'
81
+ | 'doctor'
82
+ | 'github'
83
+ | 'help'
84
+ | 'init'
85
+ | 'structure'
86
+ | 'sync';
78
87
 
79
88
  type CliOptions = {
80
89
  readonly command: Command | undefined;
@@ -91,7 +100,9 @@ const sha256 = (buf: Buffer): string =>
91
100
  const toPosix = (p: string): string => p.split(sep).join('/');
92
101
 
93
102
  const assertSafeRelativePath = (path: string, label: string): void => {
94
- const normalized = normalize(path);
103
+ // POSIX semantics on every platform: managed paths are repository-relative
104
+ // POSIX paths, and win32 normalize would rewrite `/` to `\` and reject them.
105
+ const normalized = posix.normalize(path);
95
106
  if (
96
107
  path.length === 0 ||
97
108
  path === '.' ||
@@ -672,11 +683,19 @@ const inspectDependabot = (raw: string): ReadonlyArray<string> => {
672
683
 
673
684
  const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
674
685
  const problems: Array<string> = [];
675
- const packageJson = JSON.parse(packageRaw) as Record<string, unknown>;
676
- const scripts = packageJson.scripts as Record<string, unknown> | undefined;
677
- const devDependencies = packageJson.devDependencies as
678
- | Record<string, unknown>
679
- | undefined;
686
+ let packageJson: unknown;
687
+ try {
688
+ packageJson = JSON.parse(packageRaw);
689
+ } catch {
690
+ return ['package.json must contain valid JSON'];
691
+ }
692
+ if (!isRecord(packageJson)) {
693
+ return ['package.json must contain a JSON object'];
694
+ }
695
+ const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : {};
696
+ const devDependencies = isRecord(packageJson.devDependencies)
697
+ ? packageJson.devDependencies
698
+ : {};
680
699
  if (typeof devDependencies?.['@davidvornholt/standards'] !== 'string') {
681
700
  problems.push(
682
701
  'package.json must declare @davidvornholt/standards directly',
@@ -684,7 +703,10 @@ const inspectPackageJson = (packageRaw: string): ReadonlyArray<string> => {
684
703
  }
685
704
  for (const name of ['check', 'check:fix']) {
686
705
  const script = scripts?.[name];
687
- if (typeof script !== 'string' || !script.includes('standards check')) {
706
+ if (
707
+ typeof script !== 'string' ||
708
+ !hasSafeCommand(script, 'standards check')
709
+ ) {
688
710
  problems.push(`package.json script "${name}" must run standards check`);
689
711
  }
690
712
  }
@@ -747,12 +769,13 @@ const runDoctor = async (consumer: string): Promise<boolean> => {
747
769
  const USAGE = `Usage: standards <command> [options]
748
770
 
749
771
  Commands:
750
- init Bootstrap a consumer repo: seed repo-owned files, mirror canonical files, write the lock
751
- sync Mirror canonical files from upstream and rewrite the lock
752
- check Verify canonical files, extension seams, and GitHub settings
753
- doctor Validate extension seams only
754
- github Compare (--check) or converge (--apply) live GitHub settings
755
- help Show this help
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
756
779
 
757
780
  Options:
758
781
  --dir <path> Consumer directory to operate on (default: current directory)
@@ -769,6 +792,7 @@ const commandFromArg = (arg: string): Command => {
769
792
  arg === 'github' ||
770
793
  arg === 'help' ||
771
794
  arg === 'init' ||
795
+ arg === 'structure' ||
772
796
  arg === 'sync'
773
797
  ) {
774
798
  return arg;
@@ -861,16 +885,34 @@ const parseArgs = (argv: ReadonlyArray<string>): CliOptions => {
861
885
  };
862
886
  };
863
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
+
864
903
  const runCheckCommand = async (consumer: string): Promise<boolean> => {
865
904
  const driftIsClean = await runCheck(consumer);
866
905
  const integrationIsValid = await runDoctor(consumer);
906
+ const structureIsValid = await runStructure(consumer);
867
907
  // The GitHub gate activates with the synced declaration and then fails
868
908
  // closed: once .github/settings.json exists, an unreachable API or an
869
909
  // unreadable origin is a failure, not a skip.
870
910
  const githubIsConverged = existsSync(join(consumer, CANONICAL_SETTINGS_FILE))
871
911
  ? await runGithubCheck(consumer)
872
912
  : true;
873
- return driftIsClean && integrationIsValid && githubIsConverged;
913
+ return (
914
+ driftIsClean && integrationIsValid && structureIsValid && githubIsConverged
915
+ );
874
916
  };
875
917
 
876
918
  const runInitCommand = async (
@@ -919,6 +961,24 @@ const runSyncCommand = async (
919
961
  }
920
962
  };
921
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
+
922
982
  const main = async (): Promise<void> => {
923
983
  const { command, consumer, dryRun, from, ref, apply } = parseArgs(
924
984
  process.argv.slice(2),
@@ -936,30 +996,6 @@ const main = async (): Promise<void> => {
936
996
  return;
937
997
  }
938
998
 
939
- if (command === 'check') {
940
- if (!(await runCheckCommand(consumer))) {
941
- process.exitCode = 1;
942
- }
943
- return;
944
- }
945
-
946
- if (command === 'github') {
947
- const converged = apply
948
- ? await runGithubApply(consumer)
949
- : await runGithubCheck(consumer);
950
- if (!converged) {
951
- process.exitCode = 1;
952
- }
953
- return;
954
- }
955
-
956
- if (command === 'doctor') {
957
- if (!(await runDoctor(consumer))) {
958
- process.exitCode = 1;
959
- }
960
- return;
961
- }
962
-
963
999
  if (command === 'init') {
964
1000
  await runInitCommand(consumer, from, ref);
965
1001
  return;
@@ -967,6 +1003,11 @@ const main = async (): Promise<void> => {
967
1003
 
968
1004
  if (command === 'sync') {
969
1005
  await runSyncCommand(consumer, from, ref, dryRun);
1006
+ return;
1007
+ }
1008
+
1009
+ if (!(await runGateCommand(command, consumer, apply))) {
1010
+ process.exitCode = 1;
970
1011
  }
971
1012
  };
972
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
+ };