@zgeoff/chex 0.0.10 → 0.0.12

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
@@ -57,6 +57,7 @@ The repo checks read the branch, the pushed commits and the repo's config:
57
57
  | `commits` | a pushed commit whose message fails `@zgeoff/commitlint-config` | `--pushed-refs` |
58
58
  | `local-paths` | a line a pushed commit adds that holds the home folder or the host name | `--pushed-refs` |
59
59
  | `test-timeouts` | a `bun test` timeout above its lane's limit, in a script or a `bunfig.toml` | none |
60
+ | `test-layout` | a test folder, file name or import outside [the test layout](../../docs/guides/test-layout.md) | none |
60
61
  | `workflows` | nothing: it warns on each fault of the shared workflow lint, and passes | the paths |
61
62
  | `dependency-gates` | a stale age-gate exclude, an override or an audit ignore that nothing needs, or a first-party package with no age-gate exclude | none |
62
63
  | `uncommitted` | a file the branch changes that holds a change the push does not send | none |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/chex",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Run each shared docs and repo check as one subcommand, with the lefthook pre-push baseline that calls them",
5
5
  "keywords": [
6
6
  "checks",
@@ -20,9 +20,14 @@
20
20
  "files": [
21
21
  "src",
22
22
  "!src/**/*.test.ts",
23
- "lefthook.yml"
23
+ "lefthook.yml",
24
+ "!src/test-utils"
24
25
  ],
25
26
  "type": "module",
27
+ "exports": {
28
+ "./test-utils/*": "./src/test-utils/*.ts",
29
+ "./*": "./*"
30
+ },
26
31
  "publishConfig": {
27
32
  "access": "public"
28
33
  },
@@ -31,10 +36,14 @@
31
36
  },
32
37
  "dependencies": {
33
38
  "@zgeoff/std": "0.3.2",
39
+ "oxc-parser": "0.156.0",
34
40
  "ts-pattern": "5.9.2"
35
41
  },
36
42
  "devDependencies": {
37
43
  "@types/bun": "1.4.4",
44
+ "@zgeoff/rig": "0.0.6",
45
+ "@zgeoff/std": "0.3.2",
46
+ "@zgeoff/testing": "0.1.0",
38
47
  "tiny-invariant": "1.3.3",
39
48
  "tsrs": "0.18.0-ts7.1.0-dev.20260929",
40
49
  "typescript": "7.0.2"
@@ -6,6 +6,7 @@ export const checkNames = [
6
6
  'commits',
7
7
  'local-paths',
8
8
  'test-timeouts',
9
+ 'test-layout',
9
10
  'workflows',
10
11
  'dependency-gates',
11
12
  'uncommitted',
package/src/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { runChex } from './run-chex.ts';
8
8
  import { runCommitsCommand } from './run-commits-check.ts';
9
9
  import { runInit } from './run-init.ts';
10
10
  import { runLocalPathsCommand } from './run-local-paths-check.ts';
11
+ import { runTestLayoutCommand } from './run-test-layout-check.ts';
11
12
  import { runTestTimeoutsCommand } from './run-test-timeouts-check.ts';
12
13
  import { runUncommittedCommand } from './run-uncommitted-check.ts';
13
14
  import { runWorkflowsCommand } from './run-workflows-check.ts';
@@ -21,6 +22,7 @@ process.exitCode = await runChex(process.argv.slice(2), {
21
22
  commits: runCommitsCommand,
22
23
  'local-paths': runLocalPathsCommand,
23
24
  'test-timeouts': runTestTimeoutsCommand,
25
+ 'test-layout': runTestLayoutCommand,
24
26
  workflows: runWorkflowsCommand,
25
27
  'dependency-gates': runDependencyGatesCommand,
26
28
  uncommitted: runUncommittedCommand,
@@ -0,0 +1,119 @@
1
+ import { join } from 'node:path';
2
+ import { runProgram } from './run-program.ts';
3
+ import { collectLaneSuffixes } from './test-layout/collect-lane-suffixes.ts';
4
+ import { collectTestLayoutFaults } from './test-layout/collect-test-layout-faults.ts';
5
+ import { collectWorkspaceRoots } from './test-layout/collect-workspace-roots.ts';
6
+ import type { OxcParserModule } from './test-layout/oxc-parser-module.ts';
7
+ import { parseWorkspacePatterns } from './test-layout/parse-workspace-patterns.ts';
8
+ import { readOxcParser } from './test-layout/read-oxc-parser.ts';
9
+
10
+ export interface TestLayoutCheckOptions {
11
+ readonly readTrackedPaths: () => Promise<readonly string[]>;
12
+
13
+ // Reads a tracked file by its path relative to the repo root.
14
+ readonly readText: (path: string) => Promise<string>;
15
+
16
+ // The default export of the rig.config.ts at the repo root.
17
+ readonly readRigConfig: () => Promise<unknown>;
18
+
19
+ readonly readParser: () => Promise<OxcParserModule>;
20
+
21
+ readonly writeOutput: (text: string) => void;
22
+
23
+ readonly writeError: (text: string) => void;
24
+ }
25
+
26
+ const sourcePattern = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
27
+
28
+ // Returns the exit code: 0 for a tree with no layout fault, 1 with a fault, and 2 when the check
29
+ // cannot run.
30
+ export async function runTestLayoutCheck(options: TestLayoutCheckOptions): Promise<number> {
31
+ let faults: string[];
32
+
33
+ try {
34
+ const trackedPaths = await options.readTrackedPaths();
35
+
36
+ const tracked = new Set(trackedPaths);
37
+
38
+ const manifestText = tracked.has('package.json')
39
+ ? await options.readText('package.json')
40
+ : '{}';
41
+
42
+ const manifest: unknown = JSON.parse(manifestText);
43
+ const workspaces = parseWorkspacePatterns(manifest);
44
+
45
+ const oxlintConfigText = tracked.has('.oxlintrc.json')
46
+ ? await options.readText('.oxlintrc.json')
47
+ : null;
48
+
49
+ const rigConfig = tracked.has('rig.config.ts') ? await options.readRigConfig() : null;
50
+
51
+ const sources = await Promise.all(
52
+ trackedPaths
53
+ .filter((path) => sourcePattern.test(path))
54
+ .map(async (path) => ({ path, text: await options.readText(path) })),
55
+ );
56
+
57
+ const parser = await options.readParser();
58
+
59
+ faults = collectTestLayoutFaults(
60
+ parser,
61
+ {
62
+ trackedPaths,
63
+ workspaces:
64
+ workspaces.kind === 'single'
65
+ ? []
66
+ : collectWorkspaceRoots(trackedPaths, workspaces.patterns),
67
+ isWorkspaceRepo: workspaces.kind === 'workspaces',
68
+ laneSuffixes: collectLaneSuffixes(oxlintConfigText, rigConfig),
69
+ },
70
+ sources,
71
+ );
72
+ } catch (error) {
73
+ options.writeError(`test-layout: ${error instanceof Error ? error.message : String(error)}\n`);
74
+
75
+ return 2;
76
+ }
77
+
78
+ for (const fault of faults) {
79
+ options.writeOutput(`${fault}\n`);
80
+ }
81
+
82
+ return faults.length === 0 ? 0 : 1;
83
+ }
84
+
85
+ export function runTestLayoutCommand(): Promise<number> {
86
+ const cwd = process.cwd();
87
+
88
+ return runTestLayoutCheck({
89
+ readTrackedPaths: () => {
90
+ const listing = runProgram(['git', 'ls-files', '-z'], { cwd, env: process.env });
91
+
92
+ if (listing.exitCode !== 0) {
93
+ return Promise.reject(new Error(`git ls-files failed: ${listing.stderr.trim()}`));
94
+ }
95
+
96
+ return Promise.resolve(listing.stdout.split('\0').filter((path) => path !== ''));
97
+ },
98
+ readText: (path) => Bun.file(join(cwd, path)).text(),
99
+ readRigConfig: async () => {
100
+ const loaded: unknown = await import(join(cwd, 'rig.config.ts'));
101
+
102
+ const defaultExport: unknown =
103
+ typeof loaded === 'object' && loaded !== null ? Reflect.get(loaded, 'default') : null;
104
+
105
+ return defaultExport;
106
+ },
107
+ readParser: readOxcParser,
108
+ writeOutput: (text) => {
109
+ process.stdout.write(text);
110
+ },
111
+ writeError: (text) => {
112
+ process.stderr.write(text);
113
+ },
114
+ });
115
+ }
116
+
117
+ if (import.meta.main) {
118
+ process.exitCode = await runTestLayoutCommand();
119
+ }
@@ -0,0 +1,141 @@
1
+ import { posix } from 'node:path';
2
+ import type { TestLayout } from './types.ts';
3
+
4
+ // The faults of where folders sit: test folders, root folders of a workspace repo, helpers outside
5
+ // src/, integration folders, and the subfolders of e2e/, bench/ and evals/.
6
+ export function collectFolderFaults(layout: TestLayout): string[] {
7
+ return [
8
+ ...collectRootFolderFaults(layout),
9
+ ...collectIntegrationFaults(layout.trackedPaths),
10
+ ...collectTestFolderFaults(layout.trackedPaths),
11
+ ...collectHelperFolderFaults(layout),
12
+ ...collectSubfolderFaults(layout),
13
+ ];
14
+ }
15
+
16
+ const rootFolders = ['e2e', 'evals', 'bench'] as const;
17
+
18
+ function collectRootFolderFaults(layout: TestLayout): string[] {
19
+ const example = pickExampleWorkspace(layout.workspaces);
20
+
21
+ const faults = rootFolders
22
+ .filter((folder) => layout.isWorkspaceRepo && hasFolder(layout.trackedPaths, folder))
23
+ .map(
24
+ (folder) =>
25
+ `${folder}/ sits at the repo root; move each file into the workspace it tests, such as ${example}/${folder}/`,
26
+ );
27
+
28
+ if (hasFolder(layout.trackedPaths, 'test-utils')) {
29
+ const target = layout.isWorkspaceRepo
30
+ ? `the workspace it tests, such as ${example}/src/test-utils/`
31
+ : 'src/test-utils/';
32
+
33
+ faults.push(`test-utils/ sits at the repo root; move each file into ${target}`);
34
+ }
35
+
36
+ return faults;
37
+ }
38
+
39
+ function pickExampleWorkspace(workspaces: readonly string[]): string {
40
+ const [first] = workspaces;
41
+ const parent = first === undefined ? '.' : posix.dirname(first);
42
+
43
+ return parent === '.' ? 'packages/<name>' : `${parent}/<name>`;
44
+ }
45
+
46
+ function hasFolder(trackedPaths: readonly string[], folder: string): boolean {
47
+ return trackedPaths.some((path) => path.startsWith(`${folder}/`));
48
+ }
49
+
50
+ function collectIntegrationFaults(trackedPaths: readonly string[]): string[] {
51
+ return collectFolderPaths(trackedPaths)
52
+ .filter((folder) => posix.basename(folder) === 'integration')
53
+ .map(
54
+ (folder) =>
55
+ `${folder}/ holds tests; move each one beside the module it tests, in the package that wires together the packages it imports`,
56
+ );
57
+ }
58
+
59
+ // Every folder that holds a tracked path, each once, in order.
60
+ function collectFolderPaths(trackedPaths: readonly string[]): string[] {
61
+ const folders = new Set<string>();
62
+
63
+ for (const path of trackedPaths) {
64
+ const segments = path.split('/').slice(0, -1);
65
+
66
+ for (let length = 1; length <= segments.length; length += 1) {
67
+ folders.add(segments.slice(0, length).join('/'));
68
+ }
69
+ }
70
+
71
+ return [...folders].toSorted();
72
+ }
73
+
74
+ const testFolderNames = new Set(['test', 'tests', '__tests__']);
75
+
76
+ function collectTestFolderFaults(trackedPaths: readonly string[]): string[] {
77
+ return collectFolderPaths(trackedPaths)
78
+ .filter((folder) => testFolderNames.has(posix.basename(folder)))
79
+ .map(
80
+ (folder) =>
81
+ `${folder}/ is a test folder; move its tests beside the modules they test, its end-to-end tests into the package's e2e/, and its helpers into src/test-utils/`,
82
+ );
83
+ }
84
+
85
+ function collectHelperFolderFaults(layout: TestLayout): string[] {
86
+ return layout.workspaces
87
+ .filter((root) => layout.trackedPaths.some((path) => path.startsWith(`${root}/test-utils/`)))
88
+ .map(
89
+ (root) =>
90
+ `${root}/test-utils/ holds test helpers outside src/; move each file into ${root}/src/test-utils/`,
91
+ );
92
+ }
93
+
94
+ // An e2e/, bench/ or evals/ folder counts in a folder that holds a package.json, and at the root of
95
+ // a single-package repo.
96
+ function collectSubfolderFaults(layout: TestLayout): string[] {
97
+ const tracked = new Set(layout.trackedPaths);
98
+
99
+ const isPackageFolder = (parent: string): boolean =>
100
+ parent === '' ? !layout.isWorkspaceRepo : tracked.has(posix.join(parent, 'package.json'));
101
+
102
+ const faults = new Set<string>();
103
+
104
+ for (const path of layout.trackedPaths) {
105
+ const segments = path.split('/');
106
+
107
+ for (const [index, name] of segments.entries()) {
108
+ const parent = segments.slice(0, index).join('/');
109
+ const rest = segments.slice(index + 1);
110
+ const folder = posix.join(parent, name);
111
+
112
+ if (!isPackageFolder(parent)) {
113
+ continue;
114
+ }
115
+
116
+ const [first, second] = rest;
117
+
118
+ if (name === 'e2e' && second !== undefined && first !== 'fixtures') {
119
+ faults.add(
120
+ `${folder}/${first}/ is a subfolder of ${folder}/; keep the tests flat in ${folder}/, with fixtures in ${folder}/fixtures/ and helpers in src/test-utils/`,
121
+ );
122
+ }
123
+
124
+ if (name === 'bench' && second !== undefined) {
125
+ faults.add(
126
+ `${folder}/${first}/ is a subfolder of ${folder}/; keep the bench files flat in ${folder}/, with helpers in src/test-utils/`,
127
+ );
128
+ }
129
+
130
+ const [suite, subfolder, inner] = rest;
131
+
132
+ if (name === 'evals' && suite !== undefined && inner !== undefined) {
133
+ faults.add(
134
+ `${folder}/${suite}/${subfolder}/ is a subfolder of ${folder}/${suite}/; keep that suite's files flat in ${folder}/${suite}/, with helpers in src/test-utils/`,
135
+ );
136
+ }
137
+ }
138
+ }
139
+
140
+ return [...faults].toSorted();
141
+ }
@@ -0,0 +1,122 @@
1
+ import { dirname, join, normalize } from 'node:path';
2
+ import { collectImportSpecifiers } from './collect-import-specifiers.ts';
3
+ import type { OxcParserModule } from './oxc-parser-module.ts';
4
+ import type { SourceFile, TestLayout } from './types.ts';
5
+
6
+ const sourcePattern = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
7
+
8
+ // The faults of imports: a relative import of another workspace's helper, and production code that
9
+ // imports a helper.
10
+ export function collectImportFaults(
11
+ oxc: OxcParserModule,
12
+ layout: TestLayout,
13
+ sources: readonly SourceFile[],
14
+ ): string[] {
15
+ return sources
16
+ .filter((source) => sourcePattern.test(source.path))
17
+ .flatMap((source) => {
18
+ const parsed = oxc.parseSync(source.path, source.text);
19
+ const [parseError] = parsed.errors;
20
+
21
+ if (parseError !== undefined) {
22
+ return [
23
+ `${source.path} does not parse, so the layout check cannot read its imports: ${parseError.message}`,
24
+ ];
25
+ }
26
+
27
+ return collectImportSpecifiers(oxc, parsed.program).flatMap((specifier) =>
28
+ collectSpecifierFaults(layout, source.path, specifier),
29
+ );
30
+ });
31
+ }
32
+
33
+ const packageHelperPattern = /^@[^/]+\/[^/]+\/test-utils(?:\/|$)/u;
34
+
35
+ function collectSpecifierFaults(layout: TestLayout, importer: string, specifier: string): string[] {
36
+ const isRelative = specifier.startsWith('./') || specifier.startsWith('../');
37
+ const target = isRelative ? normalize(join(dirname(importer), specifier)) : specifier;
38
+ const isHelper = isRelative ? isHelperPath(layout, target) : packageHelperPattern.test(specifier);
39
+
40
+ if (isHelper && isProductionCode(layout, importer)) {
41
+ return [
42
+ `${importer} imports ${target}, a test helper, from production code; import test helpers only from tests and other test helpers`,
43
+ ];
44
+ }
45
+
46
+ if (!isRelative || !isHelper) {
47
+ return [];
48
+ }
49
+
50
+ const targetRoot = getWorkspaceRoot(layout, target);
51
+
52
+ if (targetRoot === getWorkspaceRoot(layout, importer)) {
53
+ return [];
54
+ }
55
+
56
+ const fix = layout.workspaces.includes(targetRoot)
57
+ ? "import it by the package's name, with the package as a workspace:* dev dependency"
58
+ : "copy the helper into the importer's own test-utils/";
59
+
60
+ return [`${importer} imports ${target}, a test helper of ${targetRoot}; ${fix}`];
61
+ }
62
+
63
+ function isHelperPath(layout: TestLayout, path: string): boolean {
64
+ const folder = getHelperFolder(layout, getWorkspaceRoot(layout, path));
65
+
66
+ return path === folder || path.startsWith(`${folder}/`);
67
+ }
68
+
69
+ function getHelperFolder(layout: TestLayout, root: string): string {
70
+ if (layout.workspaces.includes(root)) {
71
+ return `${root}/src/test-utils`;
72
+ }
73
+
74
+ if (root === '') {
75
+ return layout.isWorkspaceRepo ? 'test-utils' : 'src/test-utils';
76
+ }
77
+
78
+ return `${root}/test-utils`;
79
+ }
80
+
81
+ // A path belongs to its workspace, to the single package of a repo with no workspaces, or else to
82
+ // the top-level folder it sits in, such as scripts/.
83
+ function getWorkspaceRoot(layout: TestLayout, path: string): string {
84
+ const workspace = layout.workspaces.find((root) => path.startsWith(`${root}/`));
85
+
86
+ if (workspace !== undefined) {
87
+ return workspace;
88
+ }
89
+
90
+ const segments = path.split('/');
91
+
92
+ if (!layout.isWorkspaceRepo || segments.length === 1) {
93
+ return '';
94
+ }
95
+
96
+ return segments[0] ?? '';
97
+ }
98
+
99
+ const packageTestFolders = new Set(['e2e', 'evals', 'bench', 'test-utils']);
100
+
101
+ function isProductionCode(layout: TestLayout, path: string): boolean {
102
+ const root = getWorkspaceRoot(layout, path);
103
+
104
+ if ((root === '' && layout.isWorkspaceRepo) || isHelperPath(layout, path)) {
105
+ return false;
106
+ }
107
+
108
+ if (/\.(?:test|bench)\.[^/]+$/u.test(path)) {
109
+ return false;
110
+ }
111
+
112
+ const isPackage = layout.workspaces.includes(root) || (root === '' && !layout.isWorkspaceRepo);
113
+
114
+ if (!isPackage) {
115
+ return true;
116
+ }
117
+
118
+ const relativePath = root === '' ? path : path.slice(root.length + 1);
119
+ const [folder] = relativePath.split('/');
120
+
121
+ return !packageTestFolders.has(folder ?? '');
122
+ }
@@ -0,0 +1,55 @@
1
+ import type { Expression, Program, StringLiteral } from 'oxc-parser';
2
+ import type { OxcParserModule } from './oxc-parser-module.ts';
3
+
4
+ export function collectImportSpecifiers(oxc: OxcParserModule, program: Program): string[] {
5
+ const specifiers: string[] = [];
6
+
7
+ const addLiteral = (node: Expression | StringLiteral | null | undefined): void => {
8
+ if (node?.type === 'Literal' && typeof node.value === 'string') {
9
+ specifiers.push(node.value);
10
+ }
11
+
12
+ const [onlyPart] =
13
+ node?.type === 'TemplateLiteral' && node.expressions.length === 0 ? node.quasis : [];
14
+
15
+ if (typeof onlyPart?.value.cooked === 'string') {
16
+ specifiers.push(onlyPart.value.cooked);
17
+ }
18
+ };
19
+
20
+ const visitor = new oxc.Visitor({
21
+ ImportDeclaration: (node) => {
22
+ addLiteral(node.source);
23
+ },
24
+ ExportNamedDeclaration: (node) => {
25
+ addLiteral(node.source);
26
+ },
27
+ ExportAllDeclaration: (node) => {
28
+ addLiteral(node.source);
29
+ },
30
+ ImportExpression: (node) => {
31
+ addLiteral(node.source);
32
+ },
33
+ TSImportType: (node) => {
34
+ addLiteral(node.source);
35
+ },
36
+ TSExternalModuleReference: (node) => {
37
+ addLiteral(node.expression);
38
+ },
39
+ CallExpression: (node) => {
40
+ const [first] = node.arguments;
41
+
42
+ if (
43
+ node.callee.type === 'Identifier' &&
44
+ node.callee.name === 'require' &&
45
+ first?.type !== 'SpreadElement'
46
+ ) {
47
+ addLiteral(first);
48
+ }
49
+ },
50
+ });
51
+
52
+ visitor.visit(program);
53
+
54
+ return specifiers;
55
+ }
@@ -0,0 +1,54 @@
1
+ import { parseLaneLimits } from '../parse-lane-limits.ts';
2
+
3
+ // The suffixes that a test file name may carry before `.test.ts`. A lane of the resolved
4
+ // rig.config.ts has exactly the suffix it gives, and a lane only the oxlint config names has
5
+ // `.<lane>`. The e2e lane selects by folder, so it has none.
6
+ export function collectLaneSuffixes(
7
+ oxlintConfigText: string | null,
8
+ rigConfig: unknown,
9
+ ): readonly string[] {
10
+ const resolved = collectResolvedSuffixes(rigConfig);
11
+ const names = [...parseLaneLimits(oxlintConfigText).keys()].filter((name) => name !== 'e2e');
12
+
13
+ const fromNames = names.flatMap((name): string[] => {
14
+ const suffix = resolved.has(name) ? resolved.get(name) : `.${name}`;
15
+
16
+ return typeof suffix === 'string' ? [suffix] : [];
17
+ });
18
+
19
+ const fromConfig = [...resolved.values()].filter((suffix): suffix is string => suffix !== null);
20
+
21
+ return [...new Set([...fromNames, ...fromConfig])].toSorted();
22
+ }
23
+
24
+ // The suffix of each lane of the resolved rig.config.ts, by lane name; null for a lane without one.
25
+ function collectResolvedSuffixes(rigConfig: unknown): Map<string, string | null> {
26
+ const lanes: unknown =
27
+ typeof rigConfig === 'object' && rigConfig !== null
28
+ ? Reflect.get(rigConfig, 'lanes')
29
+ : undefined;
30
+
31
+ const suffixes = new Map<string, string | null>();
32
+
33
+ if (!Array.isArray(lanes)) {
34
+ return suffixes;
35
+ }
36
+
37
+ for (const entry of lanes) {
38
+ const lane: unknown = entry;
39
+
40
+ if (typeof lane !== 'object' || lane === null) {
41
+ continue;
42
+ }
43
+
44
+ const name: unknown = Reflect.get(lane, 'name');
45
+ const suffix: unknown = Reflect.get(lane, 'suffix');
46
+ const laneSuffix = typeof suffix === 'string' ? suffix : null;
47
+
48
+ if (typeof name === 'string') {
49
+ suffixes.set(name, laneSuffix);
50
+ }
51
+ }
52
+
53
+ return suffixes;
54
+ }
@@ -0,0 +1,96 @@
1
+ import { posix } from 'node:path';
2
+ import type { TestLayout } from './types.ts';
3
+
4
+ const testSuffix = '.test.ts';
5
+
6
+ // The faults of how test files are named: an e2e file's prefix, a module test with no module, and
7
+ // a lane suffix that no lane declares.
8
+ export function collectNameFaults(layout: TestLayout): string[] {
9
+ const namesByFolder = Map.groupBy(layout.trackedPaths, (path) => posix.dirname(path));
10
+ const faults: string[] = [];
11
+
12
+ for (const path of layout.trackedPaths) {
13
+ if (path.endsWith(testSuffix)) {
14
+ const siblings = namesByFolder.get(posix.dirname(path)) ?? [];
15
+
16
+ faults.push(
17
+ ...collectE2ENameFaults(path, layout),
18
+ ...collectModuleTestFaults(path, layout, siblings),
19
+ );
20
+ }
21
+ }
22
+
23
+ return faults;
24
+ }
25
+
26
+ const e2ePrefixes = [
27
+ 'start-',
28
+ 'stop-',
29
+ 'recover-',
30
+ 'cli-',
31
+ 'build-',
32
+ 'runtime-',
33
+ 'enforce-',
34
+ 'compat-',
35
+ 'journey-',
36
+ ] as const;
37
+
38
+ function collectE2ENameFaults(path: string, layout: TestLayout): string[] {
39
+ const folder = posix.dirname(path);
40
+ const parent = posix.dirname(folder);
41
+
42
+ const isE2EFolder =
43
+ posix.basename(folder) === 'e2e' &&
44
+ (parent === '.'
45
+ ? !layout.isWorkspaceRepo
46
+ : layout.trackedPaths.includes(posix.join(parent, 'package.json')));
47
+
48
+ const name = posix.basename(path);
49
+
50
+ if (!isE2EFolder || e2ePrefixes.some((prefix) => name.startsWith(prefix))) {
51
+ return [];
52
+ }
53
+
54
+ return [
55
+ `${path} doesn't start with an e2e prefix; rename it to start with ${e2ePrefixes.slice(0, -1).join(', ')} or ${e2ePrefixes.at(-1)}`,
56
+ ];
57
+ }
58
+
59
+ // A test in a src/ folder sits beside the module it tests: `<base>.test.ts` beside `<base>.<ext>`,
60
+ // and `<base>.<word>.test.ts` beside `<base>.<ext>` when the word is a lane suffix, or beside
61
+ // `<base>.<word>.<ext>` when it is part of a module's name.
62
+ function collectModuleTestFaults(
63
+ path: string,
64
+ layout: TestLayout,
65
+ siblings: readonly string[],
66
+ ): string[] {
67
+ const stem = posix.basename(path).slice(0, -testSuffix.length);
68
+ const dotIndex = stem.lastIndexOf('.');
69
+ const base = dotIndex === -1 ? stem : stem.slice(0, dotIndex);
70
+ const word = dotIndex === -1 ? null : stem.slice(dotIndex + 1);
71
+ const names = siblings.map((sibling) => posix.basename(sibling));
72
+
73
+ const hasModule = (prefix: string): boolean =>
74
+ names.some(
75
+ (name) => name.startsWith(`${prefix}.`) && !name.slice(prefix.length + 1).includes('.'),
76
+ );
77
+
78
+ const isInSrc = posix.dirname(path).split('/').includes('src');
79
+ const isLaneTest = word !== null && layout.laneSuffixes.includes(`.${word}`);
80
+
81
+ if (word !== null && !isLaneTest) {
82
+ return hasModule(`${base}.${word}`)
83
+ ? []
84
+ : [
85
+ `${path} uses the suffix .${word}, which no lane in rig.config.ts declares, and ${base}.${word}.<ext> isn't beside it; declare the lane in rig.config.ts lanes, or rename the test after the module it tests`,
86
+ ];
87
+ }
88
+
89
+ if (isInSrc && !hasModule(base)) {
90
+ return [
91
+ `${path} has no ${base}.<ext> beside it; move the test next to the module it tests, or rename it after that module`,
92
+ ];
93
+ }
94
+
95
+ return [];
96
+ }
@@ -0,0 +1,17 @@
1
+ import { collectFolderFaults } from './collect-folder-faults.ts';
2
+ import { collectImportFaults } from './collect-import-faults.ts';
3
+ import { collectNameFaults } from './collect-name-faults.ts';
4
+ import type { OxcParserModule } from './oxc-parser-module.ts';
5
+ import type { SourceFile, TestLayout } from './types.ts';
6
+
7
+ export function collectTestLayoutFaults(
8
+ oxc: OxcParserModule,
9
+ layout: TestLayout,
10
+ sources: readonly SourceFile[],
11
+ ): string[] {
12
+ return [
13
+ ...collectFolderFaults(layout),
14
+ ...collectNameFaults(layout),
15
+ ...collectImportFaults(oxc, layout, sources),
16
+ ];
17
+ }
@@ -0,0 +1,24 @@
1
+ import { posix } from 'node:path';
2
+
3
+ // Resolves the patterns of the root package.json `workspaces` field against the folders that hold
4
+ // a tracked package.json. A pattern that starts with `!` leaves its folders out.
5
+ export function collectWorkspaceRoots(
6
+ trackedPaths: readonly string[],
7
+ patterns: readonly string[],
8
+ ): string[] {
9
+ const included = patterns.filter((pattern) => !pattern.startsWith('!'));
10
+
11
+ const excluded = patterns
12
+ .filter((pattern) => pattern.startsWith('!'))
13
+ .map((pattern) => pattern.slice(1));
14
+
15
+ return trackedPaths
16
+ .filter((path) => posix.basename(path) === 'package.json' && path !== 'package.json')
17
+ .map((path) => posix.dirname(path))
18
+ .filter(
19
+ (folder) =>
20
+ included.some((pattern) => new Bun.Glob(pattern).match(folder)) &&
21
+ !excluded.some((pattern) => new Bun.Glob(pattern).match(folder)),
22
+ )
23
+ .toSorted();
24
+ }
@@ -0,0 +1,7 @@
1
+ import type { Visitor, parseSync } from 'oxc-parser';
2
+
3
+ export interface OxcParserModule {
4
+ readonly Visitor: typeof Visitor;
5
+
6
+ readonly parseSync: typeof parseSync;
7
+ }
@@ -0,0 +1,27 @@
1
+ export type ParsedWorkspaces =
2
+ | { readonly kind: 'single' }
3
+ | { readonly kind: 'workspaces'; readonly patterns: readonly string[] };
4
+
5
+ // A root package.json declares workspaces as a list, or as an object with a `packages` list.
6
+ export function parseWorkspacePatterns(manifest: unknown): ParsedWorkspaces {
7
+ const workspaces: unknown =
8
+ typeof manifest === 'object' && manifest !== null
9
+ ? Reflect.get(manifest, 'workspaces')
10
+ : undefined;
11
+
12
+ if (workspaces === undefined) {
13
+ return { kind: 'single' };
14
+ }
15
+
16
+ const list: unknown =
17
+ typeof workspaces === 'object' && workspaces !== null && !Array.isArray(workspaces)
18
+ ? Reflect.get(workspaces, 'packages')
19
+ : workspaces;
20
+
21
+ return {
22
+ kind: 'workspaces',
23
+ patterns: Array.isArray(list)
24
+ ? list.filter((pattern): pattern is string => typeof pattern === 'string')
25
+ : [],
26
+ };
27
+ }
@@ -0,0 +1,23 @@
1
+ import type { OxcParserModule } from './oxc-parser-module.ts';
2
+
3
+ // The module name is built at run time, so the step bundle that CI downloads leaves oxc-parser out:
4
+ // its native binding fails at import time on a runner with no node_modules, which would break every
5
+ // check in the bundle. Only this check needs the parser, and it runs from an install.
6
+ export async function readOxcParser(): Promise<OxcParserModule> {
7
+ const loaded: unknown = await import(['oxc', 'parser'].join('-'));
8
+
9
+ if (!isOxcParserModule(loaded)) {
10
+ throw new Error('oxc-parser exports no parseSync and no Visitor');
11
+ }
12
+
13
+ return loaded;
14
+ }
15
+
16
+ function isOxcParserModule(value: unknown): value is OxcParserModule {
17
+ return (
18
+ typeof value === 'object' &&
19
+ value !== null &&
20
+ typeof Reflect.get(value, 'parseSync') === 'function' &&
21
+ typeof Reflect.get(value, 'Visitor') === 'function'
22
+ );
23
+ }
@@ -0,0 +1,19 @@
1
+ export interface SourceFile {
2
+ readonly path: string;
3
+
4
+ readonly text: string;
5
+ }
6
+
7
+ export interface TestLayout {
8
+ // Every path git tracks.
9
+ readonly trackedPaths: readonly string[];
10
+
11
+ // The workspace folders of the root package.json, relative to the repo root.
12
+ readonly workspaces: readonly string[];
13
+
14
+ // Whether the root package.json declares `workspaces`. A repo without the field is one package.
15
+ readonly isWorkspaceRepo: boolean;
16
+
17
+ // The suffixes that lanes use, each with its dot, such as `.browser`.
18
+ readonly laneSuffixes: readonly string[];
19
+ }