@zgeoff/chex 0.0.9 → 0.0.11

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/lefthook.yml CHANGED
@@ -13,6 +13,16 @@
13
13
  # repo's own install of chex, diet and rig, and fails when one is missing
14
14
  # rather than download a package of that name.
15
15
 
16
+ # A pre-commit or a pre-push in a linked worktree exports GIT_DIR, which git
17
+ # reads before a -C path, so a job command that runs git in a folder of its
18
+ # own, such as a temp clone, would write to this repository instead. Each job
19
+ # starts with clear-git-env, which unsets every variable git lists as local to
20
+ # a repository; git then finds the repository from the job's folder. lefthook
21
+ # expands a template in run only, so each files command that runs a program
22
+ # unsets the same variables itself.
23
+ templates:
24
+ clear-git-env: unset $(git rev-parse --local-env-vars);
25
+
16
26
  pre-commit:
17
27
  # Sequential, so typecheck reads the files the fixers before it rewrote.
18
28
  piped: true
@@ -20,7 +30,7 @@ pre-commit:
20
30
  # lefthook skips the job when no staged file matches the glob.
21
31
  - name: typecheck
22
32
  glob: '*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,json,jsonc}'
23
- run: bun x --no-install diet -- bun run typecheck
33
+ run: '{clear-git-env} bun x --no-install diet -- bun run typecheck'
24
34
 
25
35
  pre-push:
26
36
  # Sequential, to bound memory on a small host or with parallel agents.
@@ -30,24 +40,30 @@ pre-push:
30
40
  # push when a file the branch changes or deletes holds a change that is
31
41
  # not committed, and names each such file.
32
42
  - name: uncommitted
33
- run: bun x --no-install diet -- bun x --no-install chex uncommitted
43
+ run: '{clear-git-env} bun x --no-install diet -- bun x --no-install chex uncommitted'
34
44
  - name: format:check
35
- run: bun x --no-install diet -- bun run format:check
45
+ run: '{clear-git-env} bun x --no-install diet -- bun run format:check'
36
46
  # Plain oxlint, without the unused-directive check: a directive for a
37
47
  # type-aware rule reads as unused in plain mode.
38
48
  - name: lint
39
- files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
49
+ files:
50
+ unset $(git rev-parse --local-env-vars); bun
51
+ node_modules/@zgeoff/chex/src/read-branch-files.ts
40
52
  glob: '*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'
41
53
  run:
42
- bun x --no-install diet -- bun x --no-install oxlint --no-error-on-unmatched-pattern {files}
54
+ '{clear-git-env} bun x --no-install diet -- bun x --no-install oxlint
55
+ --no-error-on-unmatched-pattern {files}'
43
56
  # Each file check reads only the files the branch changes that match its
44
57
  # glob, so a file the branch leaves unchanged never fails a push. A check
45
58
  # whose program the machine lacks prints a notice and passes: CI installs
46
59
  # the program and runs the check on every pull request.
47
60
  - name: shell
48
- files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
61
+ files:
62
+ unset $(git rev-parse --local-env-vars); bun
63
+ node_modules/@zgeoff/chex/src/read-branch-files.ts
49
64
  glob: '*.sh'
50
65
  run: |
66
+ {clear-git-env}
51
67
  for program in shellcheck shfmt; do
52
68
  if ! command -v "$program" > /dev/null; then
53
69
  echo "shell: skipped, because $program is not on the PATH; CI runs the check"
@@ -62,9 +78,11 @@ pre-push:
62
78
  # decides whether the job runs. The repo's check:docs script names the
63
79
  # checks it takes, such as chex prose length links.
64
80
  - name: docs
65
- files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
81
+ files:
82
+ unset $(git rev-parse --local-env-vars); bun
83
+ node_modules/@zgeoff/chex/src/read-branch-files.ts
66
84
  glob: '*.md'
67
- run: bun x --no-install diet -- bun run --silent check:docs
85
+ run: '{clear-git-env} bun x --no-install diet -- bun run --silent check:docs'
68
86
  # GitHub runs no workflow from a subfolder of .github/workflows, and
69
87
  # sync/workflow-callers holds the workflows that tools delivers. actionlint
70
88
  # 1.7.12 rejects the concurrency queue key that GitHub documents, until
@@ -72,11 +90,14 @@ pre-push:
72
90
  # The workflow lint runs after actionlint, even when actionlint is missing
73
91
  # or fails.
74
92
  - name: workflows
75
- files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
93
+ files:
94
+ unset $(git rev-parse --local-env-vars); bun
95
+ node_modules/@zgeoff/chex/src/read-branch-files.ts
76
96
  glob:
77
97
  - '.github/workflows/*.{yml,yaml}'
78
98
  - 'sync/workflow-callers/*.{yml,yaml}'
79
99
  run: |
100
+ {clear-git-env}
80
101
  status=0
81
102
  if ! command -v actionlint > /dev/null; then
82
103
  echo "workflows: actionlint skipped, because it is not on the PATH; CI runs it"
@@ -98,26 +119,27 @@ pre-push:
98
119
  - name: commits
99
120
  files: echo package.json
100
121
  use_stdin: true
101
- run: 'bun x --no-install diet -- bun x --no-install chex commits --pushed-refs # {files}'
122
+ run:
123
+ '{clear-git-env} bun x --no-install diet -- bun x --no-install chex commits --pushed-refs #
124
+ {files}'
102
125
  # package.json keeps this job from the same lefthook skip.
103
126
  - name: local-paths
104
127
  files: echo package.json
105
128
  use_stdin: true
106
- run: 'bun x --no-install diet -- bun x --no-install chex local-paths --pushed-refs # {files}'
129
+ run:
130
+ '{clear-git-env} bun x --no-install diet -- bun x --no-install chex local-paths
131
+ --pushed-refs # {files}'
107
132
  - name: test-timeouts
108
- run: bun x --no-install diet -- bun x --no-install chex test-timeouts
133
+ run: '{clear-git-env} bun x --no-install diet -- bun x --no-install chex test-timeouts'
109
134
  - name: dependency-gates
110
- run: bun x --no-install diet -- bun x --no-install chex dependency-gates
135
+ run: '{clear-git-env} bun x --no-install diet -- bun x --no-install chex dependency-gates'
111
136
  - name: typecheck
112
- run: bun x --no-install diet -- bun run typecheck
113
- # A pre-push in a linked worktree exports GIT_DIR, and a test that runs git
114
- # in a temp folder would inherit it and write to the real repository: a
115
- # git init there sets core.bare=true. The tests run without git's
116
- # repository variables. A bunfig.toml reaches the job too, because a change
117
- # to it skips the related tests.
137
+ run: '{clear-git-env} bun x --no-install diet -- bun run typecheck'
138
+ # A bunfig.toml reaches the job too, because a change to it skips the
139
+ # related tests.
118
140
  - name: test
119
- files: bun node_modules/@zgeoff/chex/src/read-branch-files.ts
141
+ files:
142
+ unset $(git rev-parse --local-env-vars); bun
143
+ node_modules/@zgeoff/chex/src/read-branch-files.ts
120
144
  glob: '*.{ts,tsx,toml}'
121
- run:
122
- env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR bun x --no-install diet
123
- -- bun x --no-install rig related {files}
145
+ run: '{clear-git-env} bun x --no-install diet -- bun x --no-install rig related {files}'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/chex",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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
+ }