@jameswyse/oxc-config 0.1.0 → 0.2.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
@@ -23,6 +23,26 @@ peerDependencyRules:
23
23
 
24
24
  `autoInstallPeers` affects every dependency in the workspace. No ESLint configuration is needed either way.
25
25
 
26
+ ## Set up with a coding agent
27
+
28
+ To have a coding agent install and configure the package, give it this prompt:
29
+
30
+ ```text
31
+ Set up @jameswyse/oxc-config in this repository by following its README:
32
+ https://github.com/jameswyse/oxc-config#readme
33
+
34
+ 1. Install the package and its peers, then create oxlint.config.ts and oxfmt.config.ts. In a monorepo, check which directory each package's lint script runs from and which config Oxlint finds from there.
35
+ 2. Survey the code before choosing options. Pass each option the evidence supports:
36
+ - react, nextjs, node, vitest, jest, effect, electron and tests: the frameworks, runtimes and test runners each package uses, scoped to the files that use them.
37
+ - env: the modules that read process.env or import.meta.env. Each runtime should have one module that parses configuration and passes typed values on. If reads are scattered, list them as owners for now and record the consolidation as follow-up.
38
+ - boundaries: code that must not import from another part of the project, such as browser code importing server modules, Node built-ins or database clients, an Electron renderer importing main-process code, or a shared package importing an application.
39
+ - serverActionGuards: the functions that "use server" actions call to check authentication or permissions.
40
+ - clock and random: the modules that own the current time and ID or random value generation.
41
+ 3. Extend @jameswyse/oxc-config/tsconfig unless the project's existing compiler settings conflict with it.
42
+ 4. Run the lint and format scripts. Fix the violations. Suppress a rule only where it reports correct code, with the reason after --, and list each such case as a possible upstream issue.
43
+ 5. Report every option above, with the value you set or the evidence for leaving it out. Leaving out env or boundaries in an application needs a specific reason.
44
+ ```
45
+
26
46
  ## Configure
27
47
 
28
48
  Create `oxlint.config.ts` in the project root:
@@ -58,6 +78,8 @@ Add the scripts:
58
78
 
59
79
  Errors fail the run. Warnings mark patterns worth reviewing that have legitimate uses, so they report without blocking: dependency cycles, barrel and forwarding modules, `typeof` outside type guards, swallowed errors, boolean parameters on exported functions, pass-through functions, tests without a matching owner file and test timeout overrides. `lint:fix` formats after applying lint fixes, then lints again to confirm the result.
60
80
 
81
+ The defaults enable no framework profiles or project boundaries, because both depend on the project. Select the profiles below, then [configure the boundaries](#enforce-project-boundaries) that fit.
82
+
61
83
  ## Select framework and runtime profiles
62
84
 
63
85
  Enable only the tools the project uses. Each profile accepts `true` for its default scope or an array of project-relative globs. `nextjs` takes application root directories instead of globs.
@@ -81,29 +103,30 @@ export default createLintConfig({
81
103
  });
82
104
  ```
83
105
 
84
- | Option | Adds |
85
- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
- | `react` | React and React Compiler rules, accessibility rules, effect-misuse rules, and e18e performance rules outside tests |
87
- | `nextjs` | The React profile plus Next.js rules, with default exports allowed for `app` and `pages` entrypoints under each root |
88
- | `node` | Node.js environment and rules |
89
- | `vitest` | Vitest rules; its globs join the shared test scope |
90
- | `jest` | Jest rules; keep its scope separate from Vitest when both exist |
91
- | `effect` | Effect error-tag and service-import rules, for modules that use [Effect](https://effect.website) |
92
- | `tests` | Extra test globs, such as Playwright or Node tests, for the custom test rules |
93
- | `electron` | Electron security rules everywhere, and an import boundary keeping `electron` and `node:*` out of the renderer (`src/renderer/**` by default, or `{ renderer: [...] }`) |
106
+ | Option | Adds |
107
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
+ | `react` | React and React Compiler rules, accessibility rules, effect-misuse rules, and e18e performance rules outside tests |
109
+ | `nextjs` | The React profile plus Next.js rules, with default exports allowed for `app` and `pages` entrypoints under each root |
110
+ | `node` | Node.js environment and rules |
111
+ | `vitest` | Vitest rules; its globs join the shared test scope |
112
+ | `jest` | Jest rules; keep its scope separate from Vitest when both exist |
113
+ | `effect` | Effect error-tag and service-import rules, for modules that use [Effect](https://effect.website) |
114
+ | `tests` | Extra test globs, such as Playwright or Node tests, for the custom test rules |
115
+ | `electron` | Electron security rules everywhere, and an import boundary keeping `electron` and `node:*` out of the renderer (`src/renderer/**` by default, or `{ renderer: [...] }`). Needs [`root`](#enforce-project-boundaries) |
94
116
 
95
117
  Default exports are also accepted where a tool loads them: config files, Storybook stories and `.storybook`, `sanity.cli`, GraphQL Codegen entrypoints and declaration files.
96
118
 
97
- In a monorepo, run Oxlint from each package with a `tsconfig.json`, or pass an existing lint tsconfig with `--tsconfig`.
119
+ In a monorepo, run Oxlint from each package with a `tsconfig.json`, or pass an existing lint tsconfig with `--tsconfig`. Oxlint uses the nearest config file in or above the directory it runs from, so one root config can serve every package. Write its globs from the root, such as `apps/web/src/**`.
98
120
 
99
121
  ## Enforce project boundaries
100
122
 
101
- These options need the project to name its owners, so each is off until configured. Globs are relative to the directory Oxlint runs from.
123
+ These options need the project to name its owners, so the defaults cannot enable them. Most applications have a configuration owner and at least one runtime boundary, so check each option during setup. Like the profiles, their globs are relative to the config file's directory, wherever Oxlint runs from.
102
124
 
103
125
  ```ts
104
126
  import { createLintConfig } from "@jameswyse/oxc-config/oxlint";
105
127
 
106
128
  export default createLintConfig({
129
+ root: import.meta.dirname,
107
130
  react: ["src/renderer/**"],
108
131
  env: ["src/main/config.ts", "src/renderer/env.ts"],
109
132
  boundaries: [
@@ -120,7 +143,7 @@ export default createLintConfig({
120
143
  ```
121
144
 
122
145
  - `env` lists the modules that may read `process.env` or `import.meta.env`. Other source files must receive parsed configuration. Config files and scripts are entrypoints and remain allowed.
123
- - `boundaries` rejects imports from `files` that match a `deny` glob. Bare specifiers match as written. Relative specifiers resolve to a project path first, so `../main/ipc` matches `src/main/**`. Type-only imports are allowed because they are erased. Use `ignore` to exclude files from a boundary's scope.
146
+ - `boundaries` rejects imports from `files` that match a `deny` glob. Bare specifiers match as written. Relative specifiers resolve to a path from the config file's directory first, so `../main/ipc` matches `src/main/**`. Type-only imports are allowed because they are erased. Use `ignore` to exclude files from a boundary's scope. The boundary rule needs `root: import.meta.dirname` to find that directory, and `createLintConfig` throws without it when `boundaries` or `electron` is set.
124
147
  - `serverActionGuards` requires every exported function in a `"use server"` module, and every inline `"use server"` function, to start by awaiting one of the named guards. Server-action modules must export each action where it is declared. A guard's name cannot prove it authorises the operation, so review the guard itself.
125
148
  - `clock` and `random` list the modules that may read the current time (`Date.now()`, argument-less `new Date()`) or generate random values and IDs (`Math.random()`, `crypto.randomUUID()`, `randomUUID`/`randomInt` from `node:crypto`). Other production code must receive them, so tests can control them without faking globals. Test files, parameter defaults such as `now = new Date()` and passing `Date.now` uncalled are allowed. Durations from `performance.now()` and secret generation are out of scope.
126
149
 
package/dist/format.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import type { OxfmtConfig, SortImportsConfig } from "oxfmt";
2
- export declare const formatConfig: OxfmtConfig & {
1
+ import type { OxfmtConfig } from "oxfmt";
2
+ type SortImportsOptions = Exclude<OxfmtConfig["sortImports"], boolean | undefined>;
3
+ export declare const formatConfig: Omit<OxfmtConfig, "ignorePatterns" | "sortImports"> & {
3
4
  ignorePatterns: string[];
4
- sortImports: SortImportsConfig;
5
+ sortImports: SortImportsOptions;
5
6
  };
7
+ export {};
package/dist/lint.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { DummyRuleMap, OxlintConfig, OxlintOverride } from "oxlint";
2
2
  export type FileScope = boolean | string[];
3
3
  export interface LintOptions {
4
+ root?: string;
4
5
  typeAware?: boolean;
5
6
  node?: FileScope;
6
7
  react?: FileScope;
@@ -30,4 +31,4 @@ export type SharedLintConfig = OxlintConfig & {
30
31
  rules: DummyRuleMap;
31
32
  };
32
33
  export declare const defaultIgnores: string[];
33
- export declare function createLintConfig({ typeAware, node, react, nextjs, vitest, jest, effect, tests, env, boundaries, serverActionGuards, clock, random, electron, }?: LintOptions): SharedLintConfig;
34
+ export declare function createLintConfig({ root, typeAware, node, react, nextjs, vitest, jest, effect, tests, env, boundaries, serverActionGuards, clock, random, electron, }?: LintOptions): SharedLintConfig;
package/dist/lint.js CHANGED
@@ -255,12 +255,12 @@ const testRuleNames = [
255
255
  "valid-expect",
256
256
  "valid-expect-in-promise",
257
257
  ];
258
- export function createLintConfig({ typeAware = true, node = false, react = false, nextjs = false, vitest = false, jest = false, effect = false, tests = [], env, boundaries = [], serverActionGuards, clock, random, electron = false, } = {}) {
259
- const nextRoots = filesFor(nextjs, ["."]).map((root) => {
260
- const normalized = root.replace(/^\.\//, "").replace(/\/+$/, "");
258
+ export function createLintConfig({ root, typeAware = true, node = false, react = false, nextjs = false, vitest = false, jest = false, effect = false, tests = [], env, boundaries = [], serverActionGuards, clock, random, electron = false, } = {}) {
259
+ const nextRoots = filesFor(nextjs, ["."]).map((appRoot) => {
260
+ const normalized = appRoot.replace(/^\.\//, "").replace(/\/+$/, "");
261
261
  return normalized === "." || normalized === "" ? "" : `${normalized}/`;
262
262
  });
263
- const nextFiles = nextRoots.flatMap((root) => sourceFiles.map((pattern) => `${root}${pattern}`));
263
+ const nextFiles = nextRoots.flatMap((appRoot) => sourceFiles.map((pattern) => `${appRoot}${pattern}`));
264
264
  const reactFiles = reactScope(react, nextFiles);
265
265
  const allTestFiles = [
266
266
  ...new Set([
@@ -325,9 +325,12 @@ export function createLintConfig({ typeAware = true, node = false, react = false
325
325
  ]
326
326
  : boundaries;
327
327
  if (importBoundaries.length > 0) {
328
+ if (root === undefined) {
329
+ throw new TypeError("Import boundaries resolve from the config file, so pass root: import.meta.dirname.");
330
+ }
328
331
  overrides.push({
329
332
  files: sourceFiles,
330
- rules: { "wyse/import-boundaries": ["error", { boundaries: importBoundaries }] },
333
+ rules: { "wyse/import-boundaries": ["error", { root, boundaries: importBoundaries }] },
331
334
  });
332
335
  }
333
336
  if (electron) {
@@ -377,6 +380,22 @@ export function createLintConfig({ typeAware = true, node = false, react = false
377
380
  "react/exhaustive-deps": "error",
378
381
  "react/no-array-index-key": "error",
379
382
  "react/react-in-jsx-scope": "off",
383
+ "jsx-a11y/control-has-associated-label": [
384
+ "error",
385
+ {
386
+ ignoreElements: [
387
+ "audio",
388
+ "canvas",
389
+ "embed",
390
+ "input",
391
+ "td",
392
+ "textarea",
393
+ "tr",
394
+ "video",
395
+ ],
396
+ },
397
+ ],
398
+ "jsx-a11y/label-has-associated-control": ["error", { depth: 25 }],
380
399
  "jsx-a11y/no-noninteractive-tabindex": ["error", { roles: ["region", "tabpanel"] }],
381
400
  "jsx-a11y/prefer-tag-over-role": "off",
382
401
  },
@@ -393,11 +412,11 @@ export function createLintConfig({ typeAware = true, node = false, react = false
393
412
  plugins: [...basePlugins, "react", "jsx-a11y", "nextjs"],
394
413
  rules: nextRules,
395
414
  }, {
396
- files: nextRoots.flatMap((root) => ["", "src/"].flatMap((source) => [
397
- `${root}${source}app/**/{page,layout,template,loading,error,global-error,not-found,global-not-found,forbidden,unauthorized,default}.{js,jsx,ts,tsx}`,
398
- `${root}${source}app/**/{sitemap,robots,manifest,icon,apple-icon,opengraph-image,twitter-image}.{js,jsx,ts,tsx}`,
399
- `${root}${source}pages/**/*.{js,jsx,ts,tsx}`,
400
- `${root}${source}{middleware,proxy}.{js,ts}`,
415
+ files: nextRoots.flatMap((appRoot) => ["", "src/"].flatMap((source) => [
416
+ `${appRoot}${source}app/**/{page,layout,template,loading,error,global-error,not-found,global-not-found,forbidden,unauthorized,default}.{js,jsx,ts,tsx}`,
417
+ `${appRoot}${source}app/**/{sitemap,robots,manifest,icon,apple-icon,opengraph-image,twitter-image}.{js,jsx,ts,tsx}`,
418
+ `${appRoot}${source}pages/**/*.{js,jsx,ts,tsx}`,
419
+ `${appRoot}${source}{middleware,proxy}.{js,ts}`,
401
420
  ])),
402
421
  excludeFiles: allTestFiles,
403
422
  rules: { "import/no-default-export": "off" },
@@ -17,15 +17,16 @@ function parseBoundary(value) {
17
17
  }
18
18
  return { files, ignore, deny, message };
19
19
  }
20
- function parseBoundaries(options) {
20
+ function parseSettings(options) {
21
21
  const [option] = options;
22
22
  if (typeof option !== "object" ||
23
23
  option === null ||
24
24
  Array.isArray(option) ||
25
+ typeof option.root !== "string" ||
25
26
  !Array.isArray(option.boundaries)) {
26
- throw new TypeError("Configure import boundaries with a boundaries array.");
27
+ throw new TypeError("Configure import boundaries with a root and a boundaries array.");
27
28
  }
28
- return option.boundaries.map(parseBoundary);
29
+ return { root: option.root, boundaries: option.boundaries.map(parseBoundary) };
29
30
  }
30
31
  export const importBoundaries = defineRule({
31
32
  meta: {
@@ -35,6 +36,7 @@ export const importBoundaries = defineRule({
35
36
  {
36
37
  type: "object",
37
38
  properties: {
39
+ root: { type: "string" },
38
40
  boundaries: {
39
41
  type: "array",
40
42
  items: {
@@ -50,15 +52,16 @@ export const importBoundaries = defineRule({
50
52
  },
51
53
  },
52
54
  },
53
- required: ["boundaries"],
55
+ required: ["root", "boundaries"],
54
56
  additionalProperties: false,
55
57
  },
56
58
  ],
57
59
  messages: { boundary: "Do not import '{{specifier}}' here. {{message}}" },
58
60
  },
59
61
  create(context) {
60
- const file = projectPath(context.cwd, context.filename);
61
- const active = parseBoundaries(context.options).filter((boundary) => matchesAny(file, boundary.files) && !matchesAny(file, boundary.ignore));
62
+ const { root, boundaries } = parseSettings(context.options);
63
+ const file = projectPath(root, context.filename);
64
+ const active = boundaries.filter((boundary) => matchesAny(file, boundary.files) && !matchesAny(file, boundary.ignore));
62
65
  if (active.length === 0) {
63
66
  return {};
64
67
  }
@@ -68,7 +71,7 @@ export const importBoundaries = defineRule({
68
71
  }
69
72
  const specifier = source.value;
70
73
  const target = specifier.startsWith(".")
71
- ? projectPath(context.cwd, resolvePath(dirname(context.filename), specifier))
74
+ ? projectPath(root, resolvePath(dirname(context.filename), specifier))
72
75
  : specifier;
73
76
  const boundary = active.find(({ deny }) => matchesAny(target, deny));
74
77
  if (boundary) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameswyse/oxc-config",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Opinionated Oxlint and Oxfmt configs with custom rules for TypeScript and JavaScript projects",
5
5
  "keywords": [
6
6
  "config",