ai-deslop 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 +131 -0
- package/package.json +53 -0
- package/src/index.ts +87 -0
- package/src/rules/no-array-filter-map.ts +28 -0
- package/src/rules/no-call-only-assertions.ts +94 -0
- package/src/rules/no-chained-type-assertions.ts +77 -0
- package/src/rules/no-conditional-empty-object-spread.ts +49 -0
- package/src/rules/no-known-value-widening.ts +415 -0
- package/src/rules/no-module-mocking.ts +80 -0
- package/src/rules/no-object-parameters.ts +83 -0
- package/src/rules/no-pass-through-type-alias.ts +52 -0
- package/src/rules/no-reduce-accumulator-copy.ts +109 -0
- package/src/rules/no-reflect-apply.ts +28 -0
- package/src/rules/no-reflect-get.ts +28 -0
- package/src/rules/no-runtime-typeof.ts +77 -0
- package/src/rules/no-shape-in-symbol-names.ts +46 -0
- package/src/rules/no-unknown-parameters.ts +69 -0
- package/src/rules/no-unknown-returns.ts +82 -0
- package/src/rules/no-unknown-type-aliases.ts +54 -0
- package/src/rules/no-unsafe-dictionary-type.ts +154 -0
- package/src/rules/no-widen-then-assert.ts +366 -0
- package/src/rules/require-safety-comment-for-type-assertion.ts +131 -0
- package/src/shared/array-method.ts +94 -0
- package/src/shared/dictionary-types.ts +515 -0
- package/src/shared/function-parameters.ts +49 -0
- package/src/shared/lexical-type-parameters.ts +61 -0
- package/src/shared/reflect-method.ts +24 -0
- package/src/shared/scope.ts +15 -0
- package/src/shared/type-alias-resolution.ts +250 -0
- package/src/tailwind/candidates.ts +38 -0
- package/src/tailwind/design-system.ts +59 -0
- package/src/tailwind/rule.ts +126 -0
- package/src/tailwind/worker.ts +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# ai-deslop
|
|
2
|
+
|
|
3
|
+
Opinionated Oxlint rules that reject low-evidence, low-signal, AI-slop code patterns.
|
|
4
|
+
|
|
5
|
+
Rules are vendored from [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop) (generic rules) alongside additional deslop rules, all renamed under the `deslop/*` namespace. The vendored source is MIT licensed; see Credits below.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev ai-deslop
|
|
11
|
+
# @oxlint/plugins comes along as a runtime dependency — keep it in sync with your oxlint version
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires a runtime with native TypeScript plugin support: Bun, Deno, or Node.js >= 22.18 / 20.19.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
Register the plugin in `oxlint.config.ts` and enable every rule as an error via the exported `recommended` config:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { defineConfig } from "oxlint";
|
|
22
|
+
import deslopPlugin, { configs as deslopConfigs } from "ai-deslop";
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
jsPlugins: [deslopPlugin],
|
|
26
|
+
options: {
|
|
27
|
+
...deslopConfigs.recommended.options,
|
|
28
|
+
},
|
|
29
|
+
rules: {
|
|
30
|
+
...deslopConfigs.recommended.rules,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The `recommended` config also enables standard type-aware rules (`typescript/no-deprecated`, `typescript/no-floating-promises`), so it sets `options.typeAware`. Type-aware linting requires the `oxlint-tsgolint` package to be installed.
|
|
36
|
+
|
|
37
|
+
Or with `.oxlintrc.json` (list the rules explicitly):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"jsPlugins": ["ai-deslop"],
|
|
42
|
+
"options": {
|
|
43
|
+
"typeAware": true
|
|
44
|
+
},
|
|
45
|
+
"rules": {
|
|
46
|
+
"deslop/no-array-filter-map": "error",
|
|
47
|
+
"deslop/no-reduce-accumulator-copy": "error",
|
|
48
|
+
"deslop/no-chained-type-assertions": "error",
|
|
49
|
+
"deslop/no-conditional-empty-object-spread": "error",
|
|
50
|
+
"deslop/no-known-value-widening": "error",
|
|
51
|
+
"deslop/no-module-mocking": "error",
|
|
52
|
+
"deslop/no-object-parameters": "error",
|
|
53
|
+
"deslop/no-reflect-apply": "error",
|
|
54
|
+
"deslop/no-reflect-get": "error",
|
|
55
|
+
"deslop/no-runtime-typeof": "error",
|
|
56
|
+
"deslop/no-shape-in-symbol-names": "error",
|
|
57
|
+
"deslop/no-unknown-parameters": "error",
|
|
58
|
+
"deslop/no-unknown-returns": "error",
|
|
59
|
+
"deslop/no-unknown-type-aliases": "error",
|
|
60
|
+
"deslop/no-unsafe-dictionary-type": "error",
|
|
61
|
+
"deslop/no-widen-then-assert": "error",
|
|
62
|
+
"deslop/require-safety-comment-for-type-assertion": "error",
|
|
63
|
+
"deslop/no-call-only-assertions": "error",
|
|
64
|
+
"deslop/no-pass-through-type-alias": "error",
|
|
65
|
+
"typescript/no-deprecated": "error",
|
|
66
|
+
"typescript/no-floating-promises": "error"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Rules
|
|
72
|
+
|
|
73
|
+
### Generic (`deslop/*`)
|
|
74
|
+
|
|
75
|
+
| Rule | Description |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| `no-array-filter-map` | Rejects adjacent eager array filter/map passes; allows lazy iterator pipelines. |
|
|
78
|
+
| `no-reduce-accumulator-copy` | Rejects non-spread accumulator copies inside reducers. Enable native `oxc/no-accumulating-spread` alongside it. |
|
|
79
|
+
| `no-chained-type-assertions` | Rejects nested `as` / angle-bracket assertions that fabricate evidence (`as const` chains stay valid). |
|
|
80
|
+
| `no-conditional-empty-object-spread` | Reports object spreads using a conditional `{}` branch to omit fields. |
|
|
81
|
+
| `no-known-value-widening` | Rejects known expressions flowing into explicit `unknown` / `object` / open-dictionary targets. |
|
|
82
|
+
| `no-module-mocking` | Rejects Vitest and Jest `mock` / `doMock` / `unstable_mockModule` calls. |
|
|
83
|
+
| `no-object-parameters` | Rejects `object` (and aliases resolving to it) on function inputs. |
|
|
84
|
+
| `no-reflect-apply` | Rejects global `Reflect.apply` in favor of typed function calls. |
|
|
85
|
+
| `no-reflect-get` | Rejects global `Reflect.get` in favor of typed property access or boundary parsing. |
|
|
86
|
+
| `no-runtime-typeof` | Requires boundary parsing instead of ad hoc `typeof` narrowing (`typeof x === "undefined"` probes stay allowed). |
|
|
87
|
+
| `no-shape-in-symbol-names` | Rejects the case-insensitive substring `shape` in locally owned symbol names. |
|
|
88
|
+
| `no-unknown-parameters` | Rejects `unknown` on function inputs (except the `cause` convention and type-predicate subjects). |
|
|
89
|
+
| `no-unknown-returns` | Rejects function contracts resolving to `unknown` / `Promise<unknown>`. |
|
|
90
|
+
| `no-unknown-type-aliases` | Rejects aliases whose resolved type is `unknown`. |
|
|
91
|
+
| `no-unsafe-dictionary-type` | Rejects dictionary value contracts based on `unknown`, `any`, `object`, `{}`. |
|
|
92
|
+
| `no-widen-then-assert` | Rejects flows that widen known evidence then assert it back to a narrower type. |
|
|
93
|
+
| `require-safety-comment-for-type-assertion` | Requires a nearby invariant justification comment (default marker `SAFETY:`) for each non-const assertion. |
|
|
94
|
+
| `no-call-only-assertions` | Flags tests whose every assertion only checks a mock was called (`toHaveBeenCalledWith` stays allowed). |
|
|
95
|
+
| `no-pass-through-type-alias` | Disallows type aliases that only rename another type. |
|
|
96
|
+
|
|
97
|
+
The rules use Oxlint's ESTree and lexical-scope APIs rather than a TypeScript type checker. They resolve same-file aliases but do not infer imported type definitions or cross-file call signatures.
|
|
98
|
+
|
|
99
|
+
### Standard (`typescript/*`, in `recommended`)
|
|
100
|
+
|
|
101
|
+
| Rule | Description |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `typescript/no-deprecated` | Disallows using code marked `@deprecated`. Requires type-aware linting. |
|
|
104
|
+
| `typescript/no-floating-promises` | Disallows floating Promises without handling. Requires type-aware linting. |
|
|
105
|
+
|
|
106
|
+
### Opt-in (`deslop/canonical-class-names`, default off)
|
|
107
|
+
|
|
108
|
+
Enforces canonical Tailwind CSS class spellings with `--fix` support (`mt-[16px]` → `mt-4`). Same source of truth as the Tailwind language server. Excluded from `recommended` because it requires a `cssPath` option — add it manually:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
export default defineConfig({
|
|
112
|
+
jsPlugins: [deslopPlugin],
|
|
113
|
+
options: {
|
|
114
|
+
...deslopConfigs.recommended.options,
|
|
115
|
+
},
|
|
116
|
+
rules: {
|
|
117
|
+
...deslopConfigs.recommended.rules,
|
|
118
|
+
"deslop/canonical-class-names": ["warn", { cssPath: "./src/styles.css" }],
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Requires Tailwind CSS v4 (`tailwindcss` is an optional peer). Options: `cssPath` (required, absolute or relative to cwd), `rootFontSize` (default `16`), `attributes` (default `["class", "className"]`), `calleeFunctions` (default `["cn", "clsx", "cva", "twMerge", "tw", "classNames", "cx"]`). Only rewrites individual spellings — ordering/dedup belongs to the formatter. Strings with `${}` interpolations are skipped.
|
|
124
|
+
|
|
125
|
+
## Credits
|
|
126
|
+
|
|
127
|
+
- Generic rules vendored verbatim from [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop) (MIT, © Dillon Mulroy), including `src/shared/*`.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-deslop",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Opinionated Oxlint rules that reject low-evidence, low-signal, AI-slop code patterns.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": "./src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"check": "oxlint src && tsc --noEmit",
|
|
12
|
+
"lint": "oxlint src",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@oxlint/plugins": "1.82.0",
|
|
17
|
+
"@tailwindcss/node": "^4.3.3",
|
|
18
|
+
"synckit": "^0.11.13"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"tailwindcss": "^4.0.0"
|
|
22
|
+
},
|
|
23
|
+
"peerDependenciesMeta": {
|
|
24
|
+
"tailwindcss": {
|
|
25
|
+
"optional": true
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "26.2.0",
|
|
30
|
+
"oxlint": "1.82.0",
|
|
31
|
+
"typescript": "7.0.2"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/maharshi365/deslop.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/maharshi365/deslop#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/maharshi365/deslop/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"oxlint",
|
|
44
|
+
"oxlint-plugin",
|
|
45
|
+
"eslint",
|
|
46
|
+
"ai-slop",
|
|
47
|
+
"code-quality",
|
|
48
|
+
"typescript"
|
|
49
|
+
],
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { eslintCompatPlugin } from "@oxlint/plugins";
|
|
2
|
+
|
|
3
|
+
import { noArrayFilterMapRule } from "./rules/no-array-filter-map.ts";
|
|
4
|
+
import { noReduceAccumulatorCopyRule } from "./rules/no-reduce-accumulator-copy.ts";
|
|
5
|
+
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
|
|
6
|
+
import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts";
|
|
7
|
+
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts";
|
|
8
|
+
import { noModuleMockingRule } from "./rules/no-module-mocking.ts";
|
|
9
|
+
import { noObjectParametersRule } from "./rules/no-object-parameters.ts";
|
|
10
|
+
import { noReflectApplyRule } from "./rules/no-reflect-apply.ts";
|
|
11
|
+
import { noReflectGetRule } from "./rules/no-reflect-get.ts";
|
|
12
|
+
import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts";
|
|
13
|
+
import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts";
|
|
14
|
+
import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts";
|
|
15
|
+
import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts";
|
|
16
|
+
import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts";
|
|
17
|
+
import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts";
|
|
18
|
+
import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts";
|
|
19
|
+
import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts";
|
|
20
|
+
import { noCallOnlyAssertionsRule } from "./rules/no-call-only-assertions.ts";
|
|
21
|
+
import { noPassThroughTypeAliasRule } from "./rules/no-pass-through-type-alias.ts";
|
|
22
|
+
import { canonicalClassNames } from "./tailwind/rule.ts";
|
|
23
|
+
|
|
24
|
+
/** Every `deslop/*` rule, plus selected standard rules, each set to `"error"`. Spread into the `rules` field of an oxlint config. */
|
|
25
|
+
export const recommendedRules: Record<string, "error"> = {
|
|
26
|
+
"deslop/no-array-filter-map": "error",
|
|
27
|
+
"deslop/no-reduce-accumulator-copy": "error",
|
|
28
|
+
"deslop/no-chained-type-assertions": "error",
|
|
29
|
+
"deslop/no-conditional-empty-object-spread": "error",
|
|
30
|
+
"deslop/no-known-value-widening": "error",
|
|
31
|
+
"deslop/no-module-mocking": "error",
|
|
32
|
+
"deslop/no-object-parameters": "error",
|
|
33
|
+
"deslop/no-reflect-apply": "error",
|
|
34
|
+
"deslop/no-reflect-get": "error",
|
|
35
|
+
"deslop/no-runtime-typeof": "error",
|
|
36
|
+
"deslop/no-shape-in-symbol-names": "error",
|
|
37
|
+
"deslop/no-unknown-parameters": "error",
|
|
38
|
+
"deslop/no-unknown-returns": "error",
|
|
39
|
+
"deslop/no-unknown-type-aliases": "error",
|
|
40
|
+
"deslop/no-unsafe-dictionary-type": "error",
|
|
41
|
+
"deslop/no-widen-then-assert": "error",
|
|
42
|
+
"deslop/require-safety-comment-for-type-assertion": "error",
|
|
43
|
+
"deslop/no-call-only-assertions": "error",
|
|
44
|
+
"deslop/no-pass-through-type-alias": "error",
|
|
45
|
+
"typescript/no-deprecated": "error",
|
|
46
|
+
"typescript/no-floating-promises": "error",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** ESLint flat-config style configs. `recommended` enables every `deslop/*` rule as `"error"` (except opt-in rules like `canonical-class-names`), plus standard `typescript/*` rules. The `typescript/*` rules require type-aware linting (`options.typeAware`), so `recommended` sets it. */
|
|
50
|
+
export const configs = {
|
|
51
|
+
recommended: {
|
|
52
|
+
name: "deslop/recommended",
|
|
53
|
+
options: { typeAware: true },
|
|
54
|
+
rules: recommendedRules,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */
|
|
59
|
+
const deslopPlugin = eslintCompatPlugin({
|
|
60
|
+
meta: { name: "deslop" },
|
|
61
|
+
rules: {
|
|
62
|
+
"no-array-filter-map": noArrayFilterMapRule,
|
|
63
|
+
"no-reduce-accumulator-copy": noReduceAccumulatorCopyRule,
|
|
64
|
+
"no-chained-type-assertions": noChainedTypeAssertionsRule,
|
|
65
|
+
"no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule,
|
|
66
|
+
"no-known-value-widening": noKnownValueWideningRule,
|
|
67
|
+
"no-module-mocking": noModuleMockingRule,
|
|
68
|
+
"no-object-parameters": noObjectParametersRule,
|
|
69
|
+
"no-reflect-apply": noReflectApplyRule,
|
|
70
|
+
"no-reflect-get": noReflectGetRule,
|
|
71
|
+
"no-runtime-typeof": noRuntimeTypeofRule,
|
|
72
|
+
"no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule,
|
|
73
|
+
"no-unknown-parameters": noUnknownParametersRule,
|
|
74
|
+
"no-unknown-returns": noUnknownReturnsRule,
|
|
75
|
+
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
|
|
76
|
+
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
|
|
77
|
+
"no-widen-then-assert": noWidenThenAssertRule,
|
|
78
|
+
"require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
|
|
79
|
+
"no-call-only-assertions": noCallOnlyAssertionsRule,
|
|
80
|
+
"no-pass-through-type-alias": noPassThroughTypeAliasRule,
|
|
81
|
+
// Opt-in: NOT in `recommendedRules`. Requires a `cssPath` option, so users add it manually.
|
|
82
|
+
"canonical-class-names": canonicalClassNames,
|
|
83
|
+
},
|
|
84
|
+
}) as ReturnType<typeof eslintCompatPlugin> & { configs: typeof configs };
|
|
85
|
+
deslopPlugin.configs = configs;
|
|
86
|
+
|
|
87
|
+
export default deslopPlugin;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
|
|
3
|
+
import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "../shared/array-method.ts";
|
|
4
|
+
|
|
5
|
+
/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */
|
|
6
|
+
export const noArrayFilterMapRule = defineRule({
|
|
7
|
+
meta: {
|
|
8
|
+
type: "suggestion",
|
|
9
|
+
docs: { description: "Disallow adjacent array filter/map passes in favor of lazy iterator helpers or a single transformation." },
|
|
10
|
+
messages: {
|
|
11
|
+
arrayFilterMap: "Avoid consecutive array `{{first}}` and `{{second}}` passes. Prefer `.values().{{first}}(...).{{second}}(...).toArray()` where iterator helpers are supported, or a single `flatMap`/mutating reducer. Preserve callback ordering, indexes, and filtering semantics.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
createOnce(context) {
|
|
15
|
+
return {
|
|
16
|
+
CallExpression(node) {
|
|
17
|
+
const outer = arrayMethodTarget(node.callee);
|
|
18
|
+
if (outer === null || (outer.name !== "map" && outer.name !== "filter")) return;
|
|
19
|
+
const innerCall = unwrapArrayExpression(outer.object);
|
|
20
|
+
if (innerCall.type !== "CallExpression") return;
|
|
21
|
+
const inner = arrayMethodTarget(innerCall.callee);
|
|
22
|
+
if (inner === null || inner.name !== (outer.name === "map" ? "filter" : "map")) return;
|
|
23
|
+
if (!isKnownArrayExpression(context.sourceCode, inner.object)) return;
|
|
24
|
+
context.report({ node, messageId: "arrayFilterMap", data: { first: inner.name, second: outer.name } });
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
3
|
+
|
|
4
|
+
// Tests whose every assertion is a "was it called" mock check verify orchestration, not behavior.
|
|
5
|
+
// `toHaveBeenCalledWith` is allowed because it asserts on real data.
|
|
6
|
+
|
|
7
|
+
const CALL_ONLY_MATCHERS = new Set(["toHaveBeenCalled", "toHaveBeenCalledTimes", "toHaveBeenCalledOnce"]);
|
|
8
|
+
const TEST_NAMES = new Set(["it", "test"]);
|
|
9
|
+
const MODIFIERS = new Set(["not", "resolves", "rejects"]);
|
|
10
|
+
|
|
11
|
+
function rootIdentifierName(node: ESTree.Expression): string | undefined {
|
|
12
|
+
let current: ESTree.Expression = node;
|
|
13
|
+
while (true) {
|
|
14
|
+
if (current.type === "Identifier") return current.name;
|
|
15
|
+
if (current.type === "MemberExpression") {
|
|
16
|
+
current = current.object;
|
|
17
|
+
} else if (current.type === "CallExpression" || current.type === "TaggedTemplateExpression") {
|
|
18
|
+
current = current.type === "CallExpression" ? current.callee : current.tag;
|
|
19
|
+
} else {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isTestCall(node: ESTree.CallExpression): boolean {
|
|
26
|
+
const name = rootIdentifierName(node.callee);
|
|
27
|
+
if (name === undefined || !TEST_NAMES.has(name)) return false;
|
|
28
|
+
const callback = node.arguments.at(-1);
|
|
29
|
+
return callback?.type === "FunctionExpression" || callback?.type === "ArrowFunctionExpression";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function matcherName(node: ESTree.CallExpression): string | undefined {
|
|
33
|
+
if (node.callee.type !== "MemberExpression" || node.callee.computed) return undefined;
|
|
34
|
+
if (node.callee.property.type !== "Identifier") return undefined;
|
|
35
|
+
let object: ESTree.Expression = node.callee.object;
|
|
36
|
+
while (
|
|
37
|
+
object.type === "MemberExpression" &&
|
|
38
|
+
!object.computed &&
|
|
39
|
+
object.property.type === "Identifier" &&
|
|
40
|
+
MODIFIERS.has(object.property.name)
|
|
41
|
+
) {
|
|
42
|
+
object = object.object;
|
|
43
|
+
}
|
|
44
|
+
const isExpectCall = object.type === "CallExpression" && rootIdentifierName(object.callee) === "expect";
|
|
45
|
+
return isExpectCall ? node.callee.property.name : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TestEntry {
|
|
49
|
+
node: ESTree.CallExpression;
|
|
50
|
+
total: number;
|
|
51
|
+
callOnly: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Tests must assert on behavior, not only that a mock was called. */
|
|
55
|
+
export const noCallOnlyAssertionsRule = defineRule({
|
|
56
|
+
meta: {
|
|
57
|
+
type: "suggestion",
|
|
58
|
+
docs: { description: "Tests must assert on behavior, not only that a mock was called" },
|
|
59
|
+
messages: {
|
|
60
|
+
callOnlyAssertions:
|
|
61
|
+
"Every assertion in this test only checks that a mock was called. First check whether this is a valuable test; remove it if not. Otherwise, assert on returned values, state, or call arguments instead.",
|
|
62
|
+
},
|
|
63
|
+
schema: [],
|
|
64
|
+
},
|
|
65
|
+
createOnce(context) {
|
|
66
|
+
let tests: Array<TestEntry> = [];
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
before() {
|
|
70
|
+
tests = [];
|
|
71
|
+
},
|
|
72
|
+
CallExpression(node) {
|
|
73
|
+
if (isTestCall(node)) {
|
|
74
|
+
tests.push({ node, total: 0, callOnly: 0 });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const test = tests.at(-1);
|
|
78
|
+
if (!test) return;
|
|
79
|
+
const matcher = matcherName(node);
|
|
80
|
+
if (!matcher) return;
|
|
81
|
+
test.total += 1;
|
|
82
|
+
if (CALL_ONLY_MATCHERS.has(matcher)) test.callOnly += 1;
|
|
83
|
+
},
|
|
84
|
+
"CallExpression:exit"(node) {
|
|
85
|
+
const test = tests.at(-1);
|
|
86
|
+
if (test?.node !== node) return;
|
|
87
|
+
tests.pop();
|
|
88
|
+
if (test.total > 0 && test.total === test.callOnly) {
|
|
89
|
+
context.report({ messageId: "callOnlyAssertions", node: node.callee });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
3
|
+
|
|
4
|
+
type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
|
5
|
+
|
|
6
|
+
function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression {
|
|
7
|
+
return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression {
|
|
11
|
+
let current = expression;
|
|
12
|
+
while (current.type === "ParenthesizedExpression") {
|
|
13
|
+
current = current.expression;
|
|
14
|
+
}
|
|
15
|
+
return current;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isConstAssertion(node: TypeAssertionExpression): boolean {
|
|
19
|
+
const { typeAnnotation } = node;
|
|
20
|
+
return (
|
|
21
|
+
typeAnnotation.type === "TSTypeReference" &&
|
|
22
|
+
typeAnnotation.typeName.type === "Identifier" &&
|
|
23
|
+
typeAnnotation.typeName.name === "const"
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean {
|
|
28
|
+
let current: ESTree.Expression = node;
|
|
29
|
+
let parent = node.parent;
|
|
30
|
+
|
|
31
|
+
while (parent.type === "ParenthesizedExpression" && parent.expression === current) {
|
|
32
|
+
current = parent;
|
|
33
|
+
parent = parent.parent;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return !isTypeAssertionExpression(parent) || parent.expression !== current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean {
|
|
40
|
+
let assertionCount = 0;
|
|
41
|
+
let hasNonConstAssertion = false;
|
|
42
|
+
let current: ESTree.Expression = node;
|
|
43
|
+
|
|
44
|
+
while (isTypeAssertionExpression(current)) {
|
|
45
|
+
assertionCount += 1;
|
|
46
|
+
hasNonConstAssertion ||= !isConstAssertion(current);
|
|
47
|
+
current = unwrapParenthesizedExpression(current.expression);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return assertionCount > 1 && hasNonConstAssertion;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */
|
|
54
|
+
export const noChainedTypeAssertionsRule = defineRule({
|
|
55
|
+
meta: {
|
|
56
|
+
type: "problem",
|
|
57
|
+
docs: {
|
|
58
|
+
description:
|
|
59
|
+
"Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.",
|
|
60
|
+
},
|
|
61
|
+
messages: {
|
|
62
|
+
chained:
|
|
63
|
+
"This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
createOnce(context) {
|
|
67
|
+
const checkTypeAssertion = (node: TypeAssertionExpression) => {
|
|
68
|
+
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return;
|
|
69
|
+
context.report({ node, messageId: "chained" });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
TSAsExpression: checkTypeAssertion,
|
|
74
|
+
TSTypeAssertion: checkTypeAssertion,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
3
|
+
|
|
4
|
+
function unwrapParentheses(node: ESTree.Expression): ESTree.Expression {
|
|
5
|
+
let current = node;
|
|
6
|
+
while (current.type === "ParenthesizedExpression") {
|
|
7
|
+
current = current.expression;
|
|
8
|
+
}
|
|
9
|
+
return current;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isEmptyObjectExpression(node: ESTree.Expression): boolean {
|
|
13
|
+
return node.type === "ObjectExpression" && node.properties.length === 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean {
|
|
17
|
+
const conditional = unwrapParentheses(node);
|
|
18
|
+
return (
|
|
19
|
+
conditional.type === "ConditionalExpression" &&
|
|
20
|
+
(isEmptyObjectExpression(conditional.consequent) ||
|
|
21
|
+
isEmptyObjectExpression(conditional.alternate))
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Ban conditional empty-object spreads without changing their omission semantics. */
|
|
26
|
+
export const noConditionalEmptyObjectSpreadRule = defineRule({
|
|
27
|
+
meta: {
|
|
28
|
+
type: "suggestion",
|
|
29
|
+
docs: {
|
|
30
|
+
description:
|
|
31
|
+
"Disallow object spreads that conditionally spread an empty object to omit fields.",
|
|
32
|
+
},
|
|
33
|
+
messages: {
|
|
34
|
+
avoid:
|
|
35
|
+
"This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
createOnce(context) {
|
|
39
|
+
return {
|
|
40
|
+
SpreadElement(node) {
|
|
41
|
+
if (node.parent.type !== "ObjectExpression") return;
|
|
42
|
+
|
|
43
|
+
if (isConditionalEmptyObjectSpread(node.argument)) {
|
|
44
|
+
context.report({ node, messageId: "avoid" });
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|