@commonplaces/oxlint-plugin 0.6.2 → 0.8.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,7 +1,7 @@
1
1
  # @commonplaces/oxlint-plugin
2
2
 
3
- 107 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
4
- checks. Version 0.6.1 supports **Oxlint 1.87.0, 1.88.0 or 1.89.0** and **Node 22.14 or newer**.
3
+ 108 custom Oxlint rules for strict Effect TypeScript, Alchemy and general code
4
+ checks. Version 0.8.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
7
  All three exact versions are checked with independently installed archives. ESLint is not a supported host.
@@ -12,14 +12,14 @@ Effect setup below after dependency installation.
12
12
 
13
13
  ## Install
14
14
 
15
- Install version 0.6.1 and save its exact version:
15
+ Install version 0.8.0 and save its exact version:
16
16
 
17
17
  ```sh
18
- npm install --save-dev --save-exact @commonplaces/oxlint-plugin@0.6.1 \
18
+ npm install --save-dev --save-exact @commonplaces/oxlint-plugin@0.8.0 \
19
19
  @effect/tsgo@0.55.0 oxlint@1.89.0 oxlint-tsgolint@7.0.2004 typescript@7.0.2
20
20
  ```
21
21
 
22
- Version 0.6.1 supports exactly Oxlint 1.87.0, 1.88.0 or 1.89.0. Older releases
22
+ Version 0.8.0 supports exactly Oxlint 1.87.0, 1.88.0 or 1.89.0. Older releases
23
23
  keep their earlier host lists. Consumer adoption is a separate step.
24
24
 
25
25
  The manifest and lockfile retain the resolved version. Historical GitHub release
@@ -200,6 +200,15 @@ loops, mutation, unchecked access, thrown failures and unapproved runtime calls.
200
200
  The collection convention accepts `Array.reduce(initial, callback)(values)`;
201
201
  its three-argument form `Array.reduce(values, initial, callback)` still fails.
202
202
 
203
+ `strict-effect/no-promise-workflow` also rejects every runtime `import()` expression,
204
+ including imports wrapped in `Effect.tryPromise` and imports in exact
205
+ `allowedFiles` or `promiseOnlyFiles` owners. Those permissions adapt host Promise
206
+ contracts; they do not permit dynamic module loading. Use static imports and
207
+ defer service initialisation through native Effect or Atom lifetimes where needed.
208
+ Static imports, re-exports and TypeScript-only `import("module").Type` references
209
+ remain valid. Framework-generated output stays subject to the consumer's existing
210
+ generated-file exclusions; there is no adapter exception to this prohibition.
211
+
203
212
  ### Supported-host adjustments
204
213
 
205
214
  The imported configurations need two small adjustments; these are separate
@@ -433,6 +442,23 @@ are bounded directory prefixes; this is not a TypeScript, bundler, conditional
433
442
  export or symlink resolver. Same-owner asset queries are allowed; `file:` imports
434
443
  are unsupported and rejected. Invalid or contradictory options fail closed.
435
444
 
445
+ ### HTTP response-body error classification
446
+
447
+ The `effect` and `strict` groups enable `strict-effect/http-client-error-classification`.
448
+ Effect's HTTP `DecodeError` includes connection failures while reading a response
449
+ body. Do not map every `DecodeError` to a payload/integrity failure while mapping
450
+ `TransportError` to unavailability. Use the transport recovery path by default;
451
+ distinguish deliberate size limits or other payload failures with a tagged cause.
452
+ Keep malformed JSON/schema errors separate and test failure after response headers.
453
+
454
+ The rule checks native `Match.tags`/`tagsExhaustive` tables containing
455
+ `DecodeError`, `TransportError` and `StatusCodeError`. It rejects unconditional
456
+ results using different error constructors, following immutable local aliases,
457
+ simple return helpers and `Effect.fail`/`succeed`. Imported aliases and shadowing
458
+ are respected. It does not prove conditional branches, opaque helpers, computed
459
+ tables or other dispatch styles: behavioural tests still own their semantics.
460
+ There are no project-specific names, configuration options or automatic fixes.
461
+
436
462
  ### Subprocess pipe capture
437
463
 
438
464
  The `effect` group enables `strict-effect/subprocess-pipe-capture`. It follows
@@ -0,0 +1 @@
1
+ export declare const httpClientErrorClassification: import("@oxlint/plugins").Rule;
@@ -0,0 +1,93 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { Array, Option } from "effect";
3
+ import { constant, operation, referenceOf, valueOf } from "./native-reference.js";
4
+ import { propertyName, variableInScope } from "./syntax.js";
5
+ // Only prove unconditional constructor results. Conditional cause checks and
6
+ // opaque helpers need behavioural tests; this rule does not infer their meaning.
7
+ const constructedError = (context, input, visited = []) => {
8
+ if (Array.some(visited, (previous) => previous === input))
9
+ return Option.none();
10
+ const next = [...visited, input];
11
+ return valueOf(context, input).pipe(Option.flatMap((node) => {
12
+ if (node.type === "NewExpression")
13
+ return Option.some(node.callee);
14
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
15
+ const body = node.body;
16
+ if (!body)
17
+ return Option.none();
18
+ if (body.type !== "BlockStatement")
19
+ return constructedError(context, body, next);
20
+ return Array.head(body.body).pipe(Option.filter(() => body.body.length === 1), Option.filter((statement) => statement.type === "ReturnStatement"), Option.flatMap((statement) => Option.fromNullishOr(statement.argument)), Option.flatMap((result) => constructedError(context, result, next)));
21
+ }
22
+ if (node.type !== "CallExpression")
23
+ return Option.none();
24
+ if (operation(context, node.callee, "effect/Effect", "fail") ||
25
+ operation(context, node.callee, "effect/Effect", "succeed"))
26
+ return Array.head(node.arguments).pipe(Option.flatMap((argument) => constructedError(context, argument, next)));
27
+ return constructedError(context, node.callee, next);
28
+ }));
29
+ };
30
+ const localClass = (context, node, visited = []) => {
31
+ if (Array.some(visited, (previous) => previous === node))
32
+ return Option.none();
33
+ if (node.type === "ClassExpression")
34
+ return Option.some(node);
35
+ if (node.type !== "Identifier")
36
+ return Option.none();
37
+ return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.filter((variable) => Array.every(variable.references, (reference) => !reference.isWrite() || reference.init)), Option.flatMap((variable) => constant(variable).pipe(Option.flatMap((declaration) => Option.fromNullishOr(declaration.init)), Option.flatMap((initialiser) => localClass(context, initialiser, [...visited, node])), Option.orElse(() => Array.head(variable.defs).pipe(Option.filter(() => variable.defs.length === 1), Option.map((definition) => definition.node), Option.filter((definition) => definition.type === "ClassDeclaration"))))));
38
+ };
39
+ const differentConstructors = (context, left, right) => {
40
+ const imported = Option.all([referenceOf(context, left), referenceOf(context, right)]);
41
+ if (Option.isSome(imported)) {
42
+ const [a, b] = imported.value;
43
+ return (a.module !== b.module || !Option.makeEquivalence((x, y) => x === y)(a.name, b.name));
44
+ }
45
+ return Option.exists(Option.all([localClass(context, left), localClass(context, right)]), ([a, b]) => a !== b);
46
+ };
47
+ const handler = (table, name) => Array.findLast(table.properties, (property) => property.type === "Property" && Option.contains(propertyName(property.key), name)).pipe(Option.flatMap((property) => property.type === "Property" ? Option.some(property.value) : Option.none()));
48
+ export const httpClientErrorClassification = defineRule({
49
+ meta: {
50
+ type: "problem",
51
+ schema: [],
52
+ messages: {
53
+ classification: "HTTP DecodeError also covers failed response-body reads. Use the TransportError recovery path unless you inspect a separately tagged payload failure; do not classify every DecodeError as a different domain error.",
54
+ },
55
+ },
56
+ create(context) {
57
+ return {
58
+ CallExpression(node) {
59
+ if (!operation(context, node.callee, "effect/Match", "tags") &&
60
+ !operation(context, node.callee, "effect/Match", "tagsExhaustive"))
61
+ return;
62
+ const table = Array.head(node.arguments).pipe(Option.flatMap((argument) => valueOf(context, argument)), Option.filter((value) => value.type === "ObjectExpression"),
63
+ // Do not guess which handler an unresolved spread/computed key replaces.
64
+ Option.filter((value) => Array.every(value.properties, (property) => property.type === "Property" &&
65
+ (!property.computed || property.key.type === "Literal"))));
66
+ Option.match(table, {
67
+ onNone: () => { },
68
+ onSome: (table) => {
69
+ // This distinctive native reason table avoids domain DecodeError
70
+ // unions and does not depend on project-specific error names.
71
+ if (Option.isNone(handler(table, "StatusCodeError")))
72
+ return;
73
+ const handlers = Option.all([
74
+ handler(table, "DecodeError"),
75
+ handler(table, "TransportError"),
76
+ ]);
77
+ Option.match(handlers, {
78
+ onNone: () => { },
79
+ onSome: ([decode, transport]) => {
80
+ const errors = Option.all([
81
+ constructedError(context, decode),
82
+ constructedError(context, transport),
83
+ ]);
84
+ if (Option.exists(errors, ([left, right]) => differentConstructors(context, left, right)))
85
+ context.report({ node: decode, messageId: "classification" });
86
+ },
87
+ });
88
+ },
89
+ });
90
+ },
91
+ };
92
+ },
93
+ });
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ export const effect = {
44
44
  "effect/require-service-operation-instrumentation": "error",
45
45
  "strict-effect/static-layers": "error",
46
46
  "strict-effect/subprocess-pipe-capture": "error",
47
+ "strict-effect/http-client-error-classification": "error",
47
48
  "strict-effect/no-runtime-type-assertions": "error",
48
49
  "strict-effect/no-unchecked-index": "error",
49
50
  "strict-effect/no-native-at": "error",
@@ -1,4 +1,5 @@
1
1
  import { subprocessPipeCapture } from "./subprocess-pipe-capture.js";
2
+ import { httpClientErrorClassification } from "./http-client-error-classification.js";
2
3
  import { noCopyingAccumulator } from "./accumulation.js";
3
4
  import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
4
5
  import * as Array from "effect/Array";
@@ -960,6 +961,7 @@ const noPromiseWorkflow = defineRule({
960
961
  messages: {
961
962
  forbidden: "Use Effect for asynchronous work. Only an explicitly named host adapter may mention Promise; async, await and Promise chains remain forbidden there.",
962
963
  bridge: "Use a native Effect or Alchemy operation. Promise conversion belongs only in an explicitly named host or SDK adapter.",
964
+ dynamicImport: "Dynamic imports are forbidden, including in host and SDK adapters. Use a static import; Effect and Atom may defer initialisation, not module loading.",
963
965
  },
964
966
  },
965
967
  create(context) {
@@ -982,6 +984,9 @@ const noPromiseWorkflow = defineRule({
982
984
  FunctionExpression: checkFunction,
983
985
  FunctionDeclaration: checkFunction,
984
986
  AwaitExpression: report,
987
+ ImportExpression(node) {
988
+ context.report({ node, messageId: "dynamicImport" });
989
+ },
985
990
  NewExpression(node) {
986
991
  if (isGlobalPromise(node.callee))
987
992
  report(node);
@@ -2549,6 +2554,7 @@ export default eslintCompatPlugin({
2549
2554
  "website-rpc-boundary": websiteRpcBoundary,
2550
2555
  "static-layers": staticLayers,
2551
2556
  "subprocess-pipe-capture": subprocessPipeCapture,
2557
+ "http-client-error-classification": httpClientErrorClassification,
2552
2558
  "no-unchecked-index": noUncheckedIndex,
2553
2559
  "no-native-at": noNativeAt,
2554
2560
  "runtime-file-convention": runtimeFileConvention,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonplaces/oxlint-plugin",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Shared Oxlint rules for strict Effect TypeScript, Alchemy and repository code.",
5
5
  "license": "MIT",
6
6
  "author": "Cooper Corbett",