@commonplaces/oxlint-plugin 0.4.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
@@ -1,19 +1,26 @@
1
1
  # @commonplaces/oxlint-plugin
2
2
 
3
- 106 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
4
- checks. This package supports **Oxlint 1.80.0, 1.85.0 or 1.86.0** and **Node 22.14 or newer**.
3
+ 107 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
4
+ checks. Version 0.6.0 supports **Oxlint 1.87.0, 1.88.0 or 1.89.0** and **Node 22.14 or newer**.
5
5
  Oxlint's JavaScript plugin API is experimental, so upgrade it only after running
6
- the rule tests. The development tools use 1.80.0; consuming projects may use any supported version.
6
+ the rule tests. The development tools use 1.89.0; consuming projects may use any supported version.
7
7
  All three exact versions are checked with independently installed archives. ESLint is not a supported host.
8
8
 
9
+ Version 0.6.0 adds native Effect correctness and immutable accumulation checks.
10
+ When upgrading from 0.5.0, use a supported Oxlint version and run the native
11
+ Effect setup below after dependency installation.
12
+
9
13
  ## Install
10
14
 
11
- Install the latest npm release and save its exact version:
15
+ Install version 0.6.0 and save its exact version:
12
16
 
13
17
  ```sh
14
- npm install --save-dev --save-exact @commonplaces/oxlint-plugin@latest oxlint@1.80.0
18
+ npm install --save-dev --save-exact @commonplaces/oxlint-plugin@0.6.0 oxlint@1.89.0
15
19
  ```
16
20
 
21
+ Version 0.6.0 supports exactly Oxlint 1.87.0, 1.88.0 or 1.89.0. Older releases
22
+ keep their earlier host lists. Consumer adoption is a separate step.
23
+
17
24
  The manifest and lockfile retain the resolved version. Historical GitHub release
18
25
  archives remain separate distribution artefacts; a GitHub release does not publish
19
26
  an npm version.
@@ -46,7 +53,7 @@ project facts.
46
53
  `defineConfig({ extends: [strict, projectPolicy] })`. It adds no project paths
47
54
  or source ignores. Keep exact ownership settings in the consuming project.
48
55
 
49
- The root exports these six groups and the `strict` composition. Individual plugins use
56
+ The root exports these six groups, the optional `style` preset and the `strict` composition. Individual plugins use
50
57
  the subpaths below. For example, a JSON configuration can enable one rule:
51
58
 
52
59
  ```json
@@ -56,6 +63,161 @@ the subpaths below. For example, a JSON configuration can enable one rule:
56
63
  }
57
64
  ```
58
65
 
66
+ ## Native Effect correctness
67
+
68
+ Both `effect` and `strict` include every check in the pinned
69
+ `@effect/tsgo` **0.55.0** correctness preset, all at error level. This catches
70
+ forgotten Effect programs, incorrect generator yields, unhandled errors and
71
+ requirements, unchecked Effect type assertions, missing Schema class brands and
72
+ unstable APIs. Type-aware checking is enabled by the group itself; consumers
73
+ do not need to import another preset. Upstream's unrelated style and maintainer
74
+ preferences are not added to the public group.
75
+
76
+ The package installs `@effect/tsgo` and `oxlint-tsgolint` **7.0.2004** as exact
77
+ regular dependencies. After installing the package,
78
+ prepare the locally installed Oxlint binaries:
79
+
80
+ ```sh
81
+ npx --no-install effect-tsgo patch --no-typescript --oxlint
82
+ npx --no-install oxlint --deny-warnings .
83
+ ```
84
+
85
+ Repeat the patch after dependency installation, normally in the consuming
86
+ project's existing `prepare` command. `--no-typescript` keeps this setup focused
87
+ on Oxlint; a separate TypeScript compiler installation is not needed for the
88
+ patch. Missing native setup causes a configuration error. It never silently
89
+ falls back to the custom syntax checks. The checker supports exactly the three
90
+ Oxlint versions above; 1.86.0 is no longer supported in version 0.6.0.
91
+
92
+ Keep a normal `tsconfig.json` covering the checked files. Native diagnostic
93
+ options, such as exact unstable APIs approved for particular test files, live
94
+ under the `@effect/language-service` compiler plugin configuration. Leave its
95
+ `diagnostics` option false when Oxlint owns reporting. The archive tests prove
96
+ that forgotten work fails even without `--deny-warnings` and without a caller
97
+ adding `--type-aware`.
98
+
99
+ ## Optional style preset
100
+
101
+ Add `style` before `strict`, then keep the project's own policy last:
102
+
103
+ ```ts
104
+ import { style, strict } from "@commonplaces/oxlint-plugin";
105
+ import { defineConfig } from "oxlint";
106
+ import project from "./tools/oxlint/project.config.ts";
107
+
108
+ export default defineConfig({ extends: [style, strict, project] });
109
+ ```
110
+
111
+ Run `oxlint --type-aware --deny-warnings .`. `style` also works alone and is
112
+ available as the default export of `@commonplaces/oxlint-plugin/style`.
113
+ It never imports or extends `strict`; `strict` still contains only its original
114
+ six groups. Adding `style` leaves strict settings unchanged. This preset includes more than formatting preferences: Ultracite
115
+ also supplies accessibility, test and correctness checks, browser globals,
116
+ shared ignores and test-file overrides. Its ignores can reduce which files are
117
+ checked; keep the project's own file policy explicit.
118
+
119
+ Ultracite **8.0.1** is an exact regular dependency, selected at the owner's
120
+ request to use the latest releases. It requires no separate consumer install. A peer would
121
+ make consumers choose and install a second package, and could make imports of
122
+ the root package fail when it is missing. The dependency also installs
123
+ Ultracite's CLI dependencies; only the three Oxlint configuration modules run
124
+ when importing this preset. No Ultracite CLI, formatter or Effect runtime runs.
125
+
126
+ ### Shared overrides
127
+
128
+ These eleven rules are off in both historical root configurations: Common
129
+ Practice at `3b6beef^` and Site at `dda3820`. File-specific permissions and
130
+ size limits remain in each consumer's project policy.
131
+
132
+ | Rule set to `off` | Reason |
133
+ | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `unicorn/no-array-for-each` | Allow Effect's immutable `Array.forEach`; strict collection checks cover native mutation. |
135
+ | `unicorn/no-array-sort` | Allow Effect's immutable `Array.sort`; strict collection checks cover native mutation. |
136
+ | `unicorn/throw-new-error` | Allow the `Schema.TaggedError` factory; `strict-effect/error-constructor-new` checks error construction when strict is selected. |
137
+ | `no-redeclare` | Allow TypeScript overloads and merged declarations. |
138
+ | `react/function-component-definition` | Allow the project's existing component declaration forms. |
139
+ | `sort-keys` | Keep related object fields in meaningful order. |
140
+ | `func-names` | Allow unnamed library callbacks and Effect generators. |
141
+ | `func-style` | Allow function declarations and functions stored in constants. |
142
+ | `no-use-before-define` | Allow declarations to refer to later declarations. |
143
+ | `no-inline-comments` | Allow short explanations beside the code they describe. |
144
+ | `typescript/consistent-return` | Leave return types and Effect generator branches to TypeScript. |
145
+
146
+ ### Effect compatibility
147
+
148
+ Style must accept the code required by strict Effect. Putting `strict` last
149
+ only resolves two settings for the same rule; it cannot stop a different style
150
+ rule asking for forbidden code. For example, `typescript/promise-function-async`
151
+ asks for `async` on a host callback that returns `Effect.runPromise(...)`, while
152
+ both strict async checks reject the added keyword. Return the Promise directly
153
+ at the approved host boundary. Keep ordinary application work in Effect.
154
+
155
+ The preset turns off these additional style rules, including when used alone:
156
+
157
+ | Rules set to `off` | Reason |
158
+ | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
159
+ | `require-await`, `typescript/promise-function-async`, `typescript/return-await` | Do not ask for `async` or `await`. Strict rules own Promise boundaries. |
160
+ | `promise/prefer-await-to-callbacks`, `promise/prefer-await-to-then` | Do not replace callbacks or Promise chains with `await`; use Effect at the owning adapter. |
161
+ | `typescript/prefer-for-of`, `unicorn/no-array-reduce` | Do not suggest loops. Allow Effect's immutable `Array.reduce`, including renamed imports. |
162
+ | `oxc/no-accumulating-spread` | Its mutation advice is replaced by `strict-effect/no-copying-accumulator` in the Effect group. |
163
+ | `unicorn/prefer-at` | Use checked Effect collection access instead of native `.at`. |
164
+ | `unicorn/prefer-date-now` | Use Effect's clock instead of `Date.now`. |
165
+ | `unicorn/prefer-reflect-apply` | Do not suggest `Reflect.apply`, which strict rejects. |
166
+ | `unicorn/prefer-set-has` | Use Effect collections instead of suggesting a native mutable `Set`. |
167
+ | `typescript/non-nullable-type-assertion-style` | Do not replace a type assertion with `!`; strict rejects both unchecked forms. |
168
+ | `unicorn/prefer-ternary` | Do not request ternaries while strict's `no-ternary` is enabled. |
169
+ | `max-classes-per-file` | Keep related Schema error classes or service contracts in their owning module. |
170
+
171
+ These changes do not grant runtime, SDK or Promise permissions. Keep exact host
172
+ files in the project's policy. Strict still rejects async/await, raw Promises,
173
+ loops, mutation, unchecked access, thrown failures and unapproved runtime calls.
174
+ The collection convention accepts `Array.reduce(initial, callback)(values)`;
175
+ its three-argument form `Array.reduce(values, initial, callback)` still fails.
176
+
177
+ ### Supported-host adjustments
178
+
179
+ The imported configurations need two small adjustments; these are separate
180
+ from the eleven shared overrides above:
181
+
182
+ - React's explicit disables for `jsx-a11y/no-autofocus` and
183
+ `react/forward-ref-uses-ref` are omitted. This lets the host's correctness
184
+ settings apply: selecting `strict` keeps both at error level. Merely putting
185
+ `strict` last would not overcome an explicit upstream disable when strict
186
+ enables a rule through its correctness category.
187
+ - Vitest's file override disabling `vitest/valid-title` is also omitted, so
188
+ strict's correctness setting still applies inside test files.
189
+
190
+ The tests install the packed archive on every supported host and compare
191
+ every resolved strict rule, including its options, with `[style, strict]`.
192
+ They also check inherited file overrides. Type-aware archive tests compile and
193
+ lint approved host callbacks, SDK adapters, Effect programs, immutable
194
+ collections and related error classes. Restoring the async style rule makes the
195
+ host example fail, proving the type-aware check runs. Forbidden examples must
196
+ retain every strict diagnostic after style is added. The adjustments import no
197
+ strict settings; other upstream settings remain intact.
198
+
199
+ ### Ultracite 8.0.1 comparison
200
+
201
+ The installed npm archives were compared, including rule options and file
202
+ patterns, rather than relying on release notes. For the three selected presets:
203
+
204
+ | Preset | Rules in 7.12.0 | Rules in 8.0.1 | Change |
205
+ | ------ | --------------: | -------------: | ------------------------------------------------------------- |
206
+ | Core | 536 | 537 | Adds `typescript/no-generated-empty-object-type` as an error. |
207
+ | React | 124 | 124 | `react/jsx-no-target-blank` adds `{ forms: true }`. |
208
+ | Vitest | 74 | 75 | Adds `vitest/consistent-test-it` as an error. |
209
+
210
+ Counts include entries explicitly set to off and entries in file overrides.
211
+ No rule is removed and no other rule severity or option changes. Core's shared
212
+ test overrides and Vitest's test rules also start matching `.mts`, `.cts`,
213
+ `.mjs` and `.cjs` files. Shared ignores, globals and plugin lists are unchanged.
214
+ Both versions contain `no-unmodified-loop-condition` with
215
+ `checkConditionalExpressions: true`; the supported hosts accept that option.
216
+ Ultracite 8 requires Oxlint 1.86 or newer, so 1.80 and 1.85 are no longer
217
+ supported. Its Oxfmt peer requires at least 0.71; repository tooling uses 0.74.0.
218
+ The current archive is checked on exactly 1.87.0, 1.88.0 and 1.89.0, matching the
219
+ pinned native Effect checker. Published 0.5.0 was qualified on 1.86.0 and 1.89.0.
220
+
59
221
  ## Project settings
60
222
 
61
223
  Rules that refer to project files, schemas or routes are available but excluded
@@ -134,11 +296,38 @@ Alchemy checks use `alchemy-secrets/`. Keep those names when moving from copied
134
296
  rule files. Remove the copied implementations and change `jsPlugins` to the
135
297
  package subpaths; keep your existing rule settings.
136
298
 
299
+ ## Immutable accumulation
300
+
301
+ `effect` and `strict` enable `strict-effect/no-copying-accumulator` as an error.
302
+ It catches repeated array accumulator spreads, growing object spreads, copying
303
+ `Array.append`/`prepend`/`appendAll`/`prependAll`/`union`/`copy` calls and
304
+ `Record.set`/`union` calls in Effect reducers. Repeatedly copying an ever larger
305
+ result can make a modest input expensive to process.
306
+
307
+ Use `Array.map` or `Array.flatMap` to produce an array. Use `Record.fromEntries`
308
+ to build a record in one pass. When genuine accumulation is needed, use
309
+ persistent `Chunk.append`, `HashMap.set` or `HashSet.add`, which share existing
310
+ structure. The rule never suggests mutation and has no automatic fix.
311
+
312
+ It recognises imported Effect `reduce`, `reduceRight`, `scan` and `scanRight`
313
+ operations, including named, namespace, renamed and immutable aliased imports.
314
+ Both argument orders and locally declared callbacks are covered. It follows
315
+ accumulator aliases, fields and destructured fields, checks copied values by
316
+ their actual local binding, and recognises a copier as the first pipe operation.
317
+ Shadowed names, unrelated libraries, scalar folds, spreads of the current input
318
+ and fixed-shape object spreads remain valid. `Array.fromIterable` is allowed: the
319
+ installed Effect version reuses an input array rather than copying it.
320
+
321
+ This is a local written-pattern check. It does not follow imported callbacks,
322
+ opaque helper implementations, mutable aliases or an accumulator through later
323
+ pipe operations, and it does not measure input sizes or prove a runtime cost.
324
+ Native loops and mutation remain forbidden by the separate strict rules.
325
+
137
326
  ## Rule exports
138
327
 
139
328
  | Package subpath | Rule prefix | Rules |
140
329
  | ------------------- | --------------------- | ----: |
141
- | `/strict-effect` | `strict-effect/` | 29 |
330
+ | `/strict-effect` | `strict-effect/` | 30 |
142
331
  | `/effect` | `effect/` | 9 |
143
332
  | `/alchemy` | `alchemy-secrets/` | 3 |
144
333
  | `/bun` | `bun/` | 1 |
@@ -0,0 +1 @@
1
+ export declare const noCopyingAccumulator: import("@oxlint/plugins").Rule;
@@ -0,0 +1,140 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { Array, Option } from "effect";
3
+ import { constant, operation, referenceOf, valueOf } from "./native-reference.js";
4
+ import { childNodes, propertyName, variableInScope } from "./syntax.js";
5
+ const reducers = ["Array", "Chunk", "HashMap", "HashSet", "Record", "Effect"];
6
+ const isFunction = (node) => node.type === "ArrowFunctionExpression" ||
7
+ node.type === "FunctionExpression" ||
8
+ node.type === "FunctionDeclaration";
9
+ const callbackOf = (context, node, visited = []) => {
10
+ if (Array.some(visited, (entry) => entry === node))
11
+ return Option.none();
12
+ if (isFunction(node))
13
+ return Option.some(node);
14
+ if (node.type !== "Identifier")
15
+ return Option.none();
16
+ const next = [...visited, node];
17
+ return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap((variable) => constant(variable).pipe(Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.flatMap((value) => callbackOf(context, value, next)), Option.orElse(() => Array.head(variable.defs).pipe(Option.filter(() => Array.every(variable.references, (entry) => !entry.isWrite())), Option.map((definition) => definition.node), Option.filter(isFunction))))));
18
+ };
19
+ const bindings = (node) => {
20
+ if (node.type === "Identifier")
21
+ return [node];
22
+ if (node.type === "AssignmentPattern")
23
+ return bindings(node.left);
24
+ if (node.type === "RestElement")
25
+ return bindings(node.argument);
26
+ if (node.type === "ObjectPattern")
27
+ return Array.flatMap(node.properties, (property) => property.type === "Property"
28
+ ? bindings(property.value)
29
+ : property.type === "RestElement"
30
+ ? bindings(property.argument)
31
+ : []);
32
+ if (node.type === "ArrayPattern")
33
+ return Array.flatMap(node.elements, (element) => element === null ? [] : bindings(element));
34
+ return [];
35
+ };
36
+ // Compare bindings, not names: a nested parameter called `acc` is another value.
37
+ // Only immutable local aliases are followed; imported helpers remain opaque.
38
+ const accumulator = (context, node, variables, visited = []) => {
39
+ if (Array.some(visited, (entry) => entry === node))
40
+ return false;
41
+ const next = [...visited, node];
42
+ if (node.type === "TSAsExpression" ||
43
+ node.type === "TSSatisfiesExpression" ||
44
+ node.type === "TSNonNullExpression" ||
45
+ node.type === "ChainExpression")
46
+ return accumulator(context, node.expression, variables, next);
47
+ if (node.type === "MemberExpression")
48
+ return accumulator(context, node.object, variables, next);
49
+ if (node.type !== "Identifier")
50
+ return false;
51
+ return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.exists((variable) => Array.some(variables, (entry) => entry === variable) ||
52
+ constant(variable).pipe(Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.exists((value) => accumulator(context, value, variables, next)))));
53
+ };
54
+ // The argument count is the installed operation's data-first arity. Persistent
55
+ // Chunk/HashMap/HashSet updates share structure and deliberately do not appear.
56
+ const copierArity = (context, node) => referenceOf(context, node).pipe(Option.flatMap((reference) => Option.flatMap(reference.name, (name) => {
57
+ if (reference.module === "effect/Array") {
58
+ if (name === "copy")
59
+ return Option.some(1);
60
+ if (Array.contains(["append", "appendAll", "prepend", "prependAll", "union"], name))
61
+ return Option.some(2);
62
+ }
63
+ if (reference.module === "effect/Record") {
64
+ if (name === "set")
65
+ return Option.some(3);
66
+ if (name === "union")
67
+ return Option.some(2);
68
+ }
69
+ return Option.none();
70
+ })));
71
+ const curriedCopier = (context, node) => {
72
+ const resolved = Option.getOrElse(valueOf(context, node), () => node);
73
+ if (resolved.type !== "CallExpression")
74
+ return Option.contains(copierArity(context, resolved), 1);
75
+ return copierArity(context, resolved.callee).pipe(Option.exists((arity) => arity > 1 && resolved.arguments.length === arity - 1));
76
+ };
77
+ export const noCopyingAccumulator = defineRule({
78
+ meta: {
79
+ type: "problem",
80
+ schema: [],
81
+ docs: { description: "Reject repeated accumulator copies without recommending mutation." },
82
+ messages: {
83
+ copying: "Copying the growing result on every step can make a small input very slow. Use Effect Array.map/flatMap, Record.fromEntries, Chunk.append, HashMap.set or HashSet.add. Keep old values unchanged; do not use push, Object.assign or mutable builders.",
84
+ },
85
+ },
86
+ create(context) {
87
+ return {
88
+ CallExpression(node) {
89
+ const reducer = referenceOf(context, node.callee).pipe(Option.exists((reference) => Array.some(reducers, (module) => reference.module === `effect/${module}`) &&
90
+ Option.exists(reference.name, (name) => Array.contains(["reduce", "reduceRight", "scan", "scanRight"], name))));
91
+ if (!reducer || node.arguments.length < 2)
92
+ return;
93
+ const argument = Array.get(node.arguments, node.arguments.length >= 3 ? 2 : 1);
94
+ const callback = Option.flatMap(argument, (value) => callbackOf(context, value));
95
+ if (Option.isNone(callback) || !isFunction(callback.value))
96
+ return;
97
+ const first = Array.head(callback.value.params);
98
+ const variables = first.pipe(Option.map((parameter) => Array.flatMap(bindings(parameter), (binding) => Option.toArray(variableInScope(context.sourceCode.getScope(binding), binding.name)))), Option.getOrElse(() => []));
99
+ const report = (copy) => context.report({ node: copy, messageId: "copying" });
100
+ const isAccumulator = (value) => accumulator(context, value, variables);
101
+ const inspect = (value) => {
102
+ // A fixed-shape object update is bounded. Dynamic keys or another
103
+ // spread can grow the copied record with each incoming element.
104
+ if (value.type === "SpreadElement" &&
105
+ (value.parent.type === "ArrayExpression" ||
106
+ (value.parent.type === "ObjectExpression" &&
107
+ Array.some(value.parent.properties, (property) => (property.type === "Property" &&
108
+ property.computed &&
109
+ property.key.type !== "Literal") ||
110
+ (property.type === "SpreadElement" && property !== value)))) &&
111
+ isAccumulator(value.argument))
112
+ report(value);
113
+ if (value.type === "CallExpression") {
114
+ if (copierArity(context, value.callee).pipe(Option.exists((arity) => value.arguments.length >= arity)) &&
115
+ Array.head(value.arguments).pipe(Option.exists(isAccumulator)))
116
+ report(value);
117
+ if (value.callee.type === "CallExpression" &&
118
+ curriedCopier(context, value.callee) &&
119
+ Array.head(value.arguments).pipe(Option.exists(isAccumulator)))
120
+ report(value);
121
+ const pipe = operation(context, value.callee, "effect/Function", "pipe") ||
122
+ operation(context, value.callee, "effect", "pipe");
123
+ const method = value.callee.type === "MemberExpression" &&
124
+ Option.contains(propertyName(value.callee.property), "pipe") &&
125
+ isAccumulator(value.callee.object);
126
+ const transforms = method ? value.arguments : Array.drop(value.arguments, 1);
127
+ if (method || (pipe && Array.head(value.arguments).pipe(Option.exists(isAccumulator))))
128
+ Array.head(transforms).pipe(Option.map((transform) => {
129
+ if (curriedCopier(context, transform))
130
+ report(transform);
131
+ }));
132
+ }
133
+ Array.forEach(childNodes(context, value), inspect);
134
+ };
135
+ if (callback.value.body)
136
+ inspect(callback.value.body);
137
+ },
138
+ };
139
+ },
140
+ });
package/dist/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
- import { Array, Option, Predicate, Record, Schema } from "effect";
2
+ import { Array, HashMap, Option, Predicate, Record, Schema } from "effect";
3
3
  import { childNodes, propertyName, variableInScope } from "./syntax.js";
4
4
  const RuleId = Schema.String.check(Schema.isPattern(/^\S+$/u));
5
5
  const Options = Schema.Struct({
@@ -37,7 +37,7 @@ const staticValue = (context, node, visited = []) => {
37
37
  const staticProperties = (context, node) => staticValue(context, node).pipe(Option.filter((value) => value.type === "ObjectExpression"), Option.flatMap((value) => {
38
38
  if (value.type !== "ObjectExpression")
39
39
  return Option.none();
40
- const empty = {};
40
+ const empty = HashMap.empty();
41
41
  return Option.some(Array.reduce(value.properties, empty, (known, entry) => {
42
42
  if (entry.type !== "Property")
43
43
  return empty;
@@ -48,9 +48,9 @@ const staticProperties = (context, node) => staticValue(context, node).pipe(Opti
48
48
  : propertyName(entry.key);
49
49
  return Option.match(key, {
50
50
  onNone: () => empty,
51
- onSome: (name) => Record.set(known, name, entry),
51
+ onSome: (name) => HashMap.set(known, name, entry),
52
52
  });
53
- }));
53
+ }).pipe(HashMap.toEntries, Record.fromEntries));
54
54
  }));
55
55
  const importedConfig = (context, node, namespace) => {
56
56
  if (node.type !== "Identifier")
@@ -51,7 +51,8 @@ export const syntaxRules = {
51
51
  "reduce",
52
52
  "some",
53
53
  "sort",
54
- ], name)))))
54
+ ], name) &&
55
+ (name !== "reduce" || node.arguments.length >= 3)))))
55
56
  report(context, node);
56
57
  },
57
58
  })),
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { OxlintConfig } from "oxlint";
1
2
  /** General code checks, with no project-specific file names or settings. */
2
3
  export declare const general: {
3
4
  jsPlugins: string[];
@@ -21,33 +22,7 @@ export declare const general: {
21
22
  };
22
23
  };
23
24
  /** Effect code checks. Add the general group separately if wanted. */
24
- export declare const effect: {
25
- jsPlugins: string[];
26
- rules: {
27
- "effect/no-ambient-process": "error";
28
- "effect/no-async-await-promise": "error";
29
- "effect/no-console": "error";
30
- "effect/no-instanceof": "error";
31
- "effect/no-manual-tag": "error";
32
- "effect/no-raw-fetch": "error";
33
- "effect/require-service-operation-instrumentation": "error";
34
- "strict-effect/static-layers": "error";
35
- "strict-effect/subprocess-pipe-capture": "error";
36
- "strict-effect/no-runtime-type-assertions": "error";
37
- "strict-effect/no-unchecked-index": "error";
38
- "strict-effect/no-native-at": "error";
39
- "strict-effect/tagged-error-name": "error";
40
- "strict-effect/error-constructor-new": "error";
41
- "strict-effect/no-promise-workflow": "error";
42
- "strict-effect/no-unsafe-option-unwrap": "error";
43
- "strict-effect/no-unchecked-json": "error";
44
- "strict-effect/no-native-work": "error";
45
- "strict-effect/no-imperative-collections": "error";
46
- "strict-effect/no-native-client-replica": "error";
47
- "bun/no-ambient-host-api": "error";
48
- "anti-slop-effect/no-service-constructor-imports": "error";
49
- };
50
- };
25
+ export declare const effect: OxlintConfig;
51
26
  /** Optional strict coding conventions; boundary permissions remain project facts. */
52
27
  export declare const conventions: {
53
28
  jsPlugins: string[];
@@ -95,7 +70,7 @@ export declare const alchemy: {
95
70
  };
96
71
  /** Complete strict Effect application profile; exact project owners stay local. */
97
72
  export declare const strict: {
98
- extends: ({
73
+ extends: (OxlintConfig | {
99
74
  jsPlugins: string[];
100
75
  rules: {
101
76
  "anti-slop/no-chained-type-assertions": "error";
@@ -115,32 +90,6 @@ export declare const strict: {
115
90
  "anti-slop/require-safety-comment-for-type-assertion": "error";
116
91
  "package/no-cross-package-source-imports": "error";
117
92
  };
118
- } | {
119
- jsPlugins: string[];
120
- rules: {
121
- "effect/no-ambient-process": "error";
122
- "effect/no-async-await-promise": "error";
123
- "effect/no-console": "error";
124
- "effect/no-instanceof": "error";
125
- "effect/no-manual-tag": "error";
126
- "effect/no-raw-fetch": "error";
127
- "effect/require-service-operation-instrumentation": "error";
128
- "strict-effect/static-layers": "error";
129
- "strict-effect/subprocess-pipe-capture": "error";
130
- "strict-effect/no-runtime-type-assertions": "error";
131
- "strict-effect/no-unchecked-index": "error";
132
- "strict-effect/no-native-at": "error";
133
- "strict-effect/tagged-error-name": "error";
134
- "strict-effect/error-constructor-new": "error";
135
- "strict-effect/no-promise-workflow": "error";
136
- "strict-effect/no-unsafe-option-unwrap": "error";
137
- "strict-effect/no-unchecked-json": "error";
138
- "strict-effect/no-native-work": "error";
139
- "strict-effect/no-imperative-collections": "error";
140
- "strict-effect/no-native-client-replica": "error";
141
- "bun/no-ambient-host-api": "error";
142
- "anti-slop-effect/no-service-constructor-imports": "error";
143
- };
144
93
  } | {
145
94
  jsPlugins: string[];
146
95
  rules: Record<`effect-conventions/${string}`, "error">;
@@ -180,3 +129,4 @@ export declare const strict: {
180
129
  };
181
130
  })[];
182
131
  };
132
+ export { default as style } from "./style.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { correctness } from "@effect/tsgo/oxlint-presets";
1
2
  import { Record } from "effect";
2
3
  import conventionPlugin from "./conventions.js";
3
4
  import webPlugin from "./web.js";
@@ -25,6 +26,7 @@ export const general = {
25
26
  };
26
27
  /** Effect code checks. Add the general group separately if wanted. */
27
28
  export const effect = {
29
+ ...correctness,
28
30
  jsPlugins: [
29
31
  "@commonplaces/oxlint-plugin/effect",
30
32
  "@commonplaces/oxlint-plugin/strict-effect",
@@ -32,6 +34,7 @@ export const effect = {
32
34
  "@commonplaces/oxlint-plugin/anti-slop-effect",
33
35
  ],
34
36
  rules: {
37
+ ...Record.map(correctness.rules ?? {}, () => "error"),
35
38
  "effect/no-ambient-process": "error",
36
39
  "effect/no-async-await-promise": "error",
37
40
  "effect/no-console": "error",
@@ -51,6 +54,7 @@ export const effect = {
51
54
  "strict-effect/no-unchecked-json": "error",
52
55
  "strict-effect/no-native-work": "error",
53
56
  "strict-effect/no-imperative-collections": "error",
57
+ "strict-effect/no-copying-accumulator": "error",
54
58
  "strict-effect/no-native-client-replica": "error",
55
59
  "bun/no-ambient-host-api": "error",
56
60
  "anti-slop-effect/no-service-constructor-imports": "error",
@@ -106,3 +110,4 @@ export const alchemy = {
106
110
  export const strict = {
107
111
  extends: [general, effect, alchemy, conventions, web, typescript],
108
112
  };
113
+ export { default as style } from "./style.js";
@@ -1,5 +1,5 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
- import { Array, Number, Option, Predicate, Record, Schema } from "effect";
2
+ import { Array, Chunk, Number, Option, Predicate, Record, Schema } from "effect";
3
3
  import { variableInScope } from "../syntax.js";
4
4
  // A checked manifest consumer selects export conditions before configuring this
5
5
  // pure rule. A false entry preserves a manifest's explicit null export fence.
@@ -104,13 +104,13 @@ const moduleAddress = (context, node, visited = []) => {
104
104
  ? Option.fromNullishOr(definition.node.init)
105
105
  : Option.none()), Option.flatMap((initialiser) => moduleAddress(context, initialiser, next)));
106
106
  };
107
- const resolvePath = (base, address) => Array.join(Array.reduce(address.split("/"), base === "." || base === "" ? [] : base.split("/"), (parts, part) => part === ".."
108
- ? parts.length === 0 || Option.contains(Array.last(parts), "..")
109
- ? [...parts, ".."]
110
- : Array.dropRight(parts, 1)
107
+ const resolvePath = (base, address) => Chunk.join(Array.reduce(address.split("/"), Chunk.fromIterable(base === "." || base === "" ? [] : base.split("/")), (parts, part) => part === ".."
108
+ ? parts.length === 0 || Option.contains(Chunk.last(parts), "..")
109
+ ? Chunk.append(parts, "..")
110
+ : Chunk.dropRight(parts, 1)
111
111
  : part === "." || part === ""
112
112
  ? parts
113
- : [...parts, part]), "/");
113
+ : Chunk.append(parts, part)), "/");
114
114
  const relativeFile = (filename, cwd) => {
115
115
  const file = normalise(filename);
116
116
  const root = normalise(cwd).replace(/\/$/u, "");
package/dist/package.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
- import { Array, Option, Predicate } from "effect";
2
+ import { Array, Chunk, Option, Predicate } from "effect";
3
3
  import { workspaceImportBoundariesRule } from "./package/workspace-import-boundaries.js";
4
4
  import { noRedundantRuleRegistrations } from "./config.js";
5
- const resolveRelative = (fileName, specifier) => Array.join(Array.reduce(specifier.split("/"), Array.dropRight(fileName.split("/"), 1), (segments, segment) => segment === ".."
6
- ? Array.dropRight(segments, 1)
5
+ const resolveRelative = (fileName, specifier) => Chunk.join(Array.reduce(specifier.split("/"), Chunk.fromIterable(Array.dropRight(fileName.split("/"), 1)), (segments, segment) => segment === ".."
6
+ ? Chunk.dropRight(segments, 1)
7
7
  : segment === "." || segment.length === 0
8
8
  ? segments
9
- : [...segments, segment]), "/");
9
+ : Chunk.append(segments, segment)), "/");
10
10
  const workspaceSourceRoot = (fileName) => Option.fromNullishOr(fileName.match(/\/(?:apps|packages|content)\/[^/]+(?:\/|$)/u)).pipe(Option.flatMap(Array.head), Option.map((root) => root.replace(/\/$/u, "")));
11
11
  const isPrivatePackageAlias = (specifier) => /^@[^/]+\/[^/]+\/src(?:\/|$)/u.test(specifier);
12
12
  const isCrossWorkspaceSourcePath = (fileName, specifier) => {
@@ -1,5 +1,5 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
- import { Array, Option, Schema } from "effect";
2
+ import { Array, Chunk, Option, Schema } from "effect";
3
3
  import { constant, operation, referenceOf, valueOf } from "./native-reference.js";
4
4
  import { childNodes, propertyName, variableInScope } from "./syntax.js";
5
5
  const Options = Schema.Struct({
@@ -17,11 +17,11 @@ const adapted = (context, node, name, adapters) => {
17
17
  return false;
18
18
  return referenceOf(context, value.value.callee).pipe(Option.exists((reference) => Array.some(adapters, (adapter) => {
19
19
  const relative = reference.module.startsWith(".")
20
- ? Array.join(Array.reduce(reference.module.split("/"), Array.dropRight(context.filename.replaceAll("\\", "/").split("/"), 1), (parts, segment) => segment === ".."
21
- ? Array.dropRight(parts, 1)
20
+ ? Chunk.join(Array.reduce(reference.module.split("/"), Chunk.fromIterable(Array.dropRight(context.filename.replaceAll("\\", "/").split("/"), 1)), (parts, segment) => segment === ".."
21
+ ? Chunk.dropRight(parts, 1)
22
22
  : segment === "."
23
23
  ? parts
24
- : [...parts, segment]), "/")
24
+ : Chunk.append(parts, segment)), "/")
25
25
  : "";
26
26
  const source = (path) => path.replace(/\.[cm]?[jt]sx?$/u, "");
27
27
  return (adapter.operation === name &&
@@ -1,6 +1,8 @@
1
1
  import { subprocessPipeCapture } from "./subprocess-pipe-capture.js";
2
+ import { noCopyingAccumulator } from "./accumulation.js";
2
3
  import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
3
4
  import * as Array from "effect/Array";
5
+ import * as Chunk from "effect/Chunk";
4
6
  import * as Function from "effect/Function";
5
7
  import * as Option from "effect/Option";
6
8
  import * as Predicate from "effect/Predicate";
@@ -596,6 +598,23 @@ const layerExpression = (context, expression, visited = []) => {
596
598
  if (Array.isReadonlyArrayEmpty(expression.arguments)) {
597
599
  return layerExpression(context, expression.callee.object, next);
598
600
  }
601
+ // Completed Match.value pipelines return their branch values, including
602
+ // Layers. Inline native callbacks remain allowed; an owned factory does not.
603
+ const completesMatch = Array.last(expression.arguments).pipe(Option.exists((argument) => scopedEffectReference(context, argument.type === "CallExpression" ? argument.callee : argument).pipe(Option.exists((reference) => isExport(reference, "effect/Match", "exhaustive") ||
604
+ isExport(reference, "effect/Match", "orElse")))));
605
+ const valueMatch = expression.callee.object.type === "CallExpression" &&
606
+ scopedEffectReference(context, expression.callee.object.callee).pipe(Option.exists((reference) => isExport(reference, "effect/Match", "value")));
607
+ const branchReturnsLayer = (argument) => argument.type === "CallExpression" &&
608
+ scopedEffectReference(context, argument.callee).pipe(Option.exists((reference) => reference.module === "effect/Match")) &&
609
+ Array.some((callback) => (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") &&
610
+ Option.fromNullishOr(callback.body).pipe(Option.exists((body) => body.type === "BlockStatement"
611
+ ? returnsLayer(context, body)
612
+ : layerExpression(context, body, next))))(argument.arguments);
613
+ if (valueMatch &&
614
+ completesMatch &&
615
+ Array.some(branchReturnsLayer)(expression.arguments)) {
616
+ return true;
617
+ }
599
618
  return transforms(expression.arguments);
600
619
  }
601
620
  return false;
@@ -1493,15 +1512,15 @@ const relativeModule = (filename, address) => {
1493
1512
  return address;
1494
1513
  }
1495
1514
  const directory = filename.replaceAll("\\", "/").replace(/\/[^/]+$/u, "");
1496
- return Function.pipe(`${directory}/${address}`.split("/"), Array.reduce([], (parts, part) => {
1515
+ return Function.pipe(`${directory}/${address}`.split("/"), Array.reduce(Chunk.empty(), (parts, part) => {
1497
1516
  if (part === "." || part === "") {
1498
1517
  return parts;
1499
1518
  }
1500
1519
  if (part === "..") {
1501
- return Array.dropRight(parts, 1);
1520
+ return Chunk.dropRight(parts, 1);
1502
1521
  }
1503
- return [...parts, part];
1504
- }), Array.join("/"));
1522
+ return Chunk.append(parts, part);
1523
+ }), Chunk.join("/"));
1505
1524
  };
1506
1525
  const CheckedPageOptions = Schema.Struct({
1507
1526
  checkedHelper: Schema.Struct({ module: Schema.String, export: Schema.String }),
@@ -2543,6 +2562,7 @@ export default eslintCompatPlugin({
2543
2562
  "no-native-work": noNativeWork,
2544
2563
  "no-runtime-type-assertions": noRuntimeTypeAssertions,
2545
2564
  "no-imperative-collections": noImperativeCollections,
2565
+ "no-copying-accumulator": noCopyingAccumulator,
2546
2566
  "public-host-literals": publicHostLiterals,
2547
2567
  "route-literal-ownership": routeLiteralOwnership,
2548
2568
  "http-client-ownership": httpClientOwnership,
@@ -0,0 +1,4 @@
1
+ import type { OxlintConfig } from "oxlint";
2
+ /** Optional shared style checks. Extend before strict and the project policy. */
3
+ declare const style: OxlintConfig;
4
+ export default style;
package/dist/style.js ADDED
@@ -0,0 +1,58 @@
1
+ import { Array, Record } from "effect";
2
+ import core from "ultracite/oxlint/core";
3
+ import react from "ultracite/oxlint/react";
4
+ import vitest from "ultracite/oxlint/vitest";
5
+ /** Optional shared style checks. Extend before strict and the project policy. */
6
+ const style = {
7
+ extends: [
8
+ core,
9
+ {
10
+ ...react,
11
+ // Leave these correctness rules to the host category settings. Explicit
12
+ // upstream "off" values would beat strict's correctness category.
13
+ rules: Record.filter(react.rules ?? {}, (_, name) => name !== "jsx-a11y/no-autofocus" && name !== "react/forward-ref-uses-ref"),
14
+ },
15
+ {
16
+ ...vitest,
17
+ // Keep the host's correctness check for test titles in test files too.
18
+ overrides: Array.map(vitest.overrides ?? [], (override) => ({
19
+ ...override,
20
+ rules: Record.filter(override.rules ?? {}, (_, name) => name !== "vitest/valid-title"),
21
+ })),
22
+ },
23
+ ],
24
+ rules: {
25
+ // Effect owns asynchronous work. Host callbacks may return a Promise
26
+ // directly without adding async, await or a Promise chain.
27
+ "require-await": "off",
28
+ "typescript/promise-function-async": "off",
29
+ "typescript/return-await": "off",
30
+ "promise/prefer-await-to-callbacks": "off",
31
+ "promise/prefer-await-to-then": "off",
32
+ // These rewrites suggest loops, mutation or native APIs that strict Effect
33
+ // rejects. Immutable Effect operations remain checked by the strict rules.
34
+ "typescript/prefer-for-of": "off",
35
+ "typescript/non-nullable-type-assertion-style": "off",
36
+ "oxc/no-accumulating-spread": "off",
37
+ "unicorn/no-array-reduce": "off",
38
+ "unicorn/prefer-at": "off",
39
+ "unicorn/prefer-date-now": "off",
40
+ "unicorn/prefer-reflect-apply": "off",
41
+ "unicorn/prefer-set-has": "off",
42
+ "unicorn/prefer-ternary": "off",
43
+ // An errors or service module may own several related Schema classes.
44
+ "max-classes-per-file": "off",
45
+ "unicorn/no-array-for-each": "off",
46
+ "unicorn/no-array-sort": "off",
47
+ "unicorn/throw-new-error": "off",
48
+ "no-redeclare": "off",
49
+ "react/function-component-definition": "off",
50
+ "sort-keys": "off",
51
+ "func-names": "off",
52
+ "func-style": "off",
53
+ "no-use-before-define": "off",
54
+ "no-inline-comments": "off",
55
+ "typescript/consistent-return": "off",
56
+ },
57
+ };
58
+ export default style;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonplaces/oxlint-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Shared Oxlint rules for strict Effect TypeScript, Alchemy and repository code.",
5
5
  "license": "MIT",
6
6
  "author": "Cooper Corbett",
@@ -56,6 +56,10 @@
56
56
  "./anti-slop-effect": {
57
57
  "types": "./dist/anti-slop/effect/index.d.ts",
58
58
  "default": "./dist/anti-slop/effect/index.js"
59
+ },
60
+ "./style": {
61
+ "types": "./dist/style.d.ts",
62
+ "default": "./dist/style.js"
59
63
  }
60
64
  },
61
65
  "publishConfig": {
@@ -68,18 +72,21 @@
68
72
  "prepack": "bun run build"
69
73
  },
70
74
  "dependencies": {
71
- "@oxlint/plugins": "1.80.0",
72
- "effect": "4.0.2"
75
+ "@effect/tsgo": "0.55.0",
76
+ "@oxlint/plugins": "1.89.0",
77
+ "effect": "4.0.5",
78
+ "oxlint-tsgolint": "7.0.2004",
79
+ "ultracite": "8.0.1"
73
80
  },
74
81
  "devDependencies": {
75
- "@effect/platform-node": "4.0.2",
76
- "@effect/vitest": "4.0.2",
77
- "@types/bun": "1.4.0",
82
+ "@effect/platform-node": "4.0.5",
83
+ "@effect/vitest": "4.0.5",
84
+ "@types/bun": "1.4.3",
78
85
  "typescript": "7.0.2",
79
- "vitest": "5.0.1"
86
+ "vitest": "5.0.5"
80
87
  },
81
88
  "peerDependencies": {
82
- "oxlint": "1.80.0 || 1.85.0 || 1.86.0"
89
+ "oxlint": "1.87.0 || 1.88.0 || 1.89.0"
83
90
  },
84
91
  "engines": {
85
92
  "node": ">=22.14.0"