@commonplaces/oxlint-plugin 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,25 @@
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.86.0 or 1.89.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
6
  the rule tests. The development tools use 1.89.0; consuming projects may use any supported version.
7
- Both exact versions are checked with independently installed archives. ESLint is not a supported host.
7
+ All three exact versions are checked with independently installed archives. ESLint is not a supported host.
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.
8
12
 
9
13
  ## Install
10
14
 
11
- Install version 0.5.0 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@0.5.0 oxlint@1.89.0
18
+ npm install --save-dev --save-exact @commonplaces/oxlint-plugin@0.6.0 oxlint@1.89.0
15
19
  ```
16
20
 
17
- Version 0.5.0 adds the optional style preset and the host requirements above.
18
- Published 0.4.0 keeps its earlier host list. Consumer adoption is a separate step.
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.
19
23
 
20
24
  The manifest and lockfile retain the resolved version. Historical GitHub release
21
25
  archives remain separate distribution artefacts; a GitHub release does not publish
@@ -59,6 +63,39 @@ the subpaths below. For example, a JSON configuration can enable one rule:
59
63
  }
60
64
  ```
61
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
+
62
99
  ## Optional style preset
63
100
 
64
101
  Add `style` before `strict`, then keep the project's own policy last:
@@ -74,8 +111,7 @@ export default defineConfig({ extends: [style, strict, project] });
74
111
  Run `oxlint --type-aware --deny-warnings .`. `style` also works alone and is
75
112
  available as the default export of `@commonplaces/oxlint-plugin/style`.
76
113
  It never imports or extends `strict`; `strict` still contains only its original
77
- six groups. Adding this release without selecting `style` leaves strict checks
78
- unchanged. This preset includes more than formatting preferences: Ultracite
114
+ six groups. Adding `style` leaves strict settings unchanged. This preset includes more than formatting preferences: Ultracite
79
115
  also supplies accessibility, test and correctness checks, browser globals,
80
116
  shared ignores and test-file overrides. Its ignores can reduce which files are
81
117
  checked; keep the project's own file policy explicit.
@@ -107,6 +143,37 @@ size limits remain in each consumer's project policy.
107
143
  | `no-inline-comments` | Allow short explanations beside the code they describe. |
108
144
  | `typescript/consistent-return` | Leave return types and Effect generator branches to TypeScript. |
109
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
+
110
177
  ### Supported-host adjustments
111
178
 
112
179
  The imported configurations need two small adjustments; these are separate
@@ -120,10 +187,14 @@ from the eleven shared overrides above:
120
187
  - Vitest's file override disabling `vitest/valid-title` is also omitted, so
121
188
  strict's correctness setting still applies inside test files.
122
189
 
123
- The tests install the packed archive on both supported hosts and compare
190
+ The tests install the packed archive on every supported host and compare
124
191
  every resolved strict rule, including its options, with `[style, strict]`.
125
- They also check inherited file overrides. These adjustments import no strict
126
- settings and leave every other upstream setting intact.
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.
127
198
 
128
199
  ### Ultracite 8.0.1 comparison
129
200
 
@@ -144,7 +215,8 @@ Both versions contain `no-unmodified-loop-condition` with
144
215
  `checkConditionalExpressions: true`; the supported hosts accept that option.
145
216
  Ultracite 8 requires Oxlint 1.86 or newer, so 1.80 and 1.85 are no longer
146
217
  supported. Its Oxfmt peer requires at least 0.71; repository tooling uses 0.74.0.
147
- The archive is checked on exactly 1.86.0 and the current 1.89.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.
148
220
 
149
221
  ## Project settings
150
222
 
@@ -224,11 +296,38 @@ Alchemy checks use `alchemy-secrets/`. Keep those names when moving from copied
224
296
  rule files. Remove the copied implementations and change `jsPlugins` to the
225
297
  package subpaths; keep your existing rule settings.
226
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
+
227
326
  ## Rule exports
228
327
 
229
328
  | Package subpath | Rule prefix | Rules |
230
329
  | ------------------- | --------------------- | ----: |
231
- | `/strict-effect` | `strict-effect/` | 29 |
330
+ | `/strict-effect` | `strict-effect/` | 30 |
232
331
  | `/effect` | `effect/` | 9 |
233
332
  | `/alchemy` | `alchemy-secrets/` | 3 |
234
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">;
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",
@@ -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";
@@ -1510,15 +1512,15 @@ const relativeModule = (filename, address) => {
1510
1512
  return address;
1511
1513
  }
1512
1514
  const directory = filename.replaceAll("\\", "/").replace(/\/[^/]+$/u, "");
1513
- return Function.pipe(`${directory}/${address}`.split("/"), Array.reduce([], (parts, part) => {
1515
+ return Function.pipe(`${directory}/${address}`.split("/"), Array.reduce(Chunk.empty(), (parts, part) => {
1514
1516
  if (part === "." || part === "") {
1515
1517
  return parts;
1516
1518
  }
1517
1519
  if (part === "..") {
1518
- return Array.dropRight(parts, 1);
1520
+ return Chunk.dropRight(parts, 1);
1519
1521
  }
1520
- return [...parts, part];
1521
- }), Array.join("/"));
1522
+ return Chunk.append(parts, part);
1523
+ }), Chunk.join("/"));
1522
1524
  };
1523
1525
  const CheckedPageOptions = Schema.Struct({
1524
1526
  checkedHelper: Schema.Struct({ module: Schema.String, export: Schema.String }),
@@ -2560,6 +2562,7 @@ export default eslintCompatPlugin({
2560
2562
  "no-native-work": noNativeWork,
2561
2563
  "no-runtime-type-assertions": noRuntimeTypeAssertions,
2562
2564
  "no-imperative-collections": noImperativeCollections,
2565
+ "no-copying-accumulator": noCopyingAccumulator,
2563
2566
  "public-host-literals": publicHostLiterals,
2564
2567
  "route-literal-ownership": routeLiteralOwnership,
2565
2568
  "http-client-ownership": httpClientOwnership,
package/dist/style.js CHANGED
@@ -22,6 +22,26 @@ const style = {
22
22
  },
23
23
  ],
24
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",
25
45
  "unicorn/no-array-for-each": "off",
26
46
  "unicorn/no-array-sort": "off",
27
47
  "unicorn/throw-new-error": "off",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonplaces/oxlint-plugin",
3
- "version": "0.5.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",
@@ -72,8 +72,10 @@
72
72
  "prepack": "bun run build"
73
73
  },
74
74
  "dependencies": {
75
+ "@effect/tsgo": "0.55.0",
75
76
  "@oxlint/plugins": "1.89.0",
76
77
  "effect": "4.0.5",
78
+ "oxlint-tsgolint": "7.0.2004",
77
79
  "ultracite": "8.0.1"
78
80
  },
79
81
  "devDependencies": {
@@ -84,7 +86,7 @@
84
86
  "vitest": "5.0.5"
85
87
  },
86
88
  "peerDependencies": {
87
- "oxlint": "1.86.0 || 1.89.0"
89
+ "oxlint": "1.87.0 || 1.88.0 || 1.89.0"
88
90
  },
89
91
  "engines": {
90
92
  "node": ">=22.14.0"