ai-deslop 0.4.2 → 0.4.4

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
@@ -50,19 +50,24 @@ The recommended config enables type-aware linting for `typescript/no-deprecated`
50
50
  | `no-widen-then-assert` | Recommended | Rejects widening known evidence before narrowing assertions. |
51
51
  | `require-safety-comment-for-type-assertion` | Recommended | Requires an invariant justification for non-const assertions. |
52
52
  | `no-call-only-assertions` | Recommended | Flags tests whose assertions only check mock calls. |
53
+ | `no-poor-substring-tests` | Recommended | Rejects testing that a constant string contains substrings. |
53
54
  | `no-pass-through-type-alias` | Recommended | Disallows aliases that only rename another type. |
55
+ | [`no-duplicate-consts`](docs/no-duplicate-consts.md) | Opt-in | Reports all-caps constants with the same static value in different files. |
54
56
  | [`no-duplicate-types`](docs/no-duplicate-types.md) | Opt-in | Reports exact duplicate object contracts. No fixer. |
55
57
  | [`canonical-class-names`](docs/tailwind.md) | Opt-in | Fixes non-canonical Tailwind CSS v4 class spellings. |
56
58
 
57
59
  | Standard rule | Status | Description |
58
60
  | --- | --- | --- |
59
61
  | `typescript/no-deprecated` | Recommended | Disallows using code marked `@deprecated`. Requires type-aware linting. |
62
+ | `typescript/no-require-imports` | Recommended | Disallows CommonJS `require()` imports. |
60
63
  | `typescript/no-floating-promises` | Recommended | Disallows floating promises. Requires type-aware linting. |
64
+ | `no-unreachable` | Recommended | Disallows unreachable code. |
61
65
 
62
66
  ## Guides
63
67
 
64
68
  | Guide | Contents |
65
69
  | --- | --- |
70
+ | [No duplicate consts](docs/no-duplicate-consts.md) | Configuration and exact static-value comparison behavior. |
66
71
  | [No duplicate types](docs/no-duplicate-types.md) | Exact comparison behavior, worker scope, complete TypeScript/JSON examples, and every supported option. |
67
72
  | [Tailwind canonical class names](docs/tailwind.md) | Tailwind v4 setup, complete TypeScript/JSON examples, and every supported option. |
68
73
 
package/dist/index.js CHANGED
@@ -17,8 +17,10 @@ import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.js
17
17
  import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.js";
18
18
  import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.js";
19
19
  import { noCallOnlyAssertionsRule } from "./rules/no-call-only-assertions.js";
20
+ import { noPoorSubstringTestsRule } from "./rules/no-poor-substring-tests.js";
20
21
  import { noPassThroughTypeAliasRule } from "./rules/no-pass-through-type-alias.js";
21
22
  import { noDuplicateTypesRule } from "./rules/no-duplicate-types.js";
23
+ import { noDuplicateConstsRule } from "./rules/no-duplicate-consts.js";
22
24
  import { canonicalClassNames } from "./tailwind/rule.js";
23
25
  /** Every `deslop/*` rule, plus selected standard rules, each set to `"error"`. Spread into the `rules` field of an oxlint config. */
24
26
  export const recommendedRules = {
@@ -40,9 +42,12 @@ export const recommendedRules = {
40
42
  "deslop/no-widen-then-assert": "error",
41
43
  "deslop/require-safety-comment-for-type-assertion": "error",
42
44
  "deslop/no-call-only-assertions": "error",
45
+ "deslop/no-poor-substring-tests": "error",
43
46
  "deslop/no-pass-through-type-alias": "error",
44
47
  "typescript/no-deprecated": "error",
48
+ "typescript/no-require-imports": "error",
45
49
  "typescript/no-floating-promises": "error",
50
+ "no-unreachable": "error",
46
51
  };
47
52
  /** ESLint flat-config style configs. `recommended` enables every recommended `deslop/*` rule as `"error"` (excluding opt-in rules), plus standard `typescript/*` rules. The `typescript/*` rules require type-aware linting (`options.typeAware`), so `recommended` sets it. */
48
53
  export const configs = {
@@ -74,8 +79,11 @@ const deslopPlugin = eslintCompatPlugin({
74
79
  "no-widen-then-assert": noWidenThenAssertRule,
75
80
  "require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
76
81
  "no-call-only-assertions": noCallOnlyAssertionsRule,
82
+ "no-poor-substring-tests": noPoorSubstringTestsRule,
83
+ "no-constant-substring-tests": noPoorSubstringTestsRule,
77
84
  "no-pass-through-type-alias": noPassThroughTypeAliasRule,
78
85
  "no-duplicate-types": noDuplicateTypesRule,
86
+ "no-duplicate-consts": noDuplicateConstsRule,
79
87
  // Opt-in: NOT in `recommendedRules`. Requires a `cssPath` option, so users add it manually.
80
88
  "canonical-class-names": canonicalClassNames,
81
89
  },
@@ -0,0 +1,2 @@
1
+ /** Report static all-caps constants duplicated by name and value in another file. */
2
+ export declare const noDuplicateConstsRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,68 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ function staticValue(node) {
3
+ if (node.type === "Literal") {
4
+ return JSON.stringify(["literal", typeof node.value, String(node.value)]);
5
+ }
6
+ if (node.type === "TemplateLiteral") {
7
+ return node.expressions.length === 0 ? JSON.stringify(["literal", "string", node.quasis[0]?.value.cooked ?? node.quasis[0]?.value.raw ?? ""]) : null;
8
+ }
9
+ if (node.type === "UnaryExpression" && (node.operator === "+" || node.operator === "-") && node.argument.type === "Literal" && typeof node.argument.value === "number") {
10
+ return JSON.stringify(["literal", "number", `${node.operator}${node.argument.value}`]);
11
+ }
12
+ if (node.type === "ArrayExpression") {
13
+ const values = node.elements.map((element) => element === null || element.type === "SpreadElement" ? null : staticValue(element));
14
+ return values.some((value) => value === null) ? null : JSON.stringify(["array", values]);
15
+ }
16
+ if (node.type === "ObjectExpression") {
17
+ const properties = [];
18
+ for (const property of node.properties) {
19
+ if (property.type !== "Property" || property.computed || property.method || property.kind !== "init" || property.key.type !== "Identifier" && property.key.type !== "Literal")
20
+ return null;
21
+ const value = staticValue(property.value);
22
+ if (value === null)
23
+ return null;
24
+ const key = property.key.type === "Identifier" ? property.key.name : String(property.key.value);
25
+ properties.push([key, value]);
26
+ }
27
+ return JSON.stringify(["object", properties]);
28
+ }
29
+ return null;
30
+ }
31
+ /** Report static all-caps constants duplicated by name and value in another file. */
32
+ export const noDuplicateConstsRule = defineRule({
33
+ meta: {
34
+ type: "suggestion",
35
+ docs: { description: "Report all-caps constants duplicated by name and static value across files" },
36
+ messages: { duplicate: "{{name}} duplicates the constant in {{firstFile}}." },
37
+ schema: [],
38
+ },
39
+ createOnce(context) {
40
+ const seen = new Map();
41
+ return {
42
+ Program() {
43
+ // LSPs can re-lint a file in the same worker; discard stale entries first.
44
+ for (const [key, constant] of seen)
45
+ if (constant.filename === context.filename)
46
+ seen.delete(key);
47
+ },
48
+ VariableDeclaration(node) {
49
+ if (node.kind !== "const")
50
+ return;
51
+ for (const declaration of node.declarations) {
52
+ if (declaration.id.type !== "Identifier" || !/^[A-Z][A-Z0-9_]*$/.test(declaration.id.name) || declaration.init === null)
53
+ continue;
54
+ const value = staticValue(declaration.init);
55
+ if (value === null)
56
+ continue;
57
+ const key = `${declaration.id.name}:${value}`, first = seen.get(key);
58
+ if (first === undefined) {
59
+ seen.set(key, { filename: context.filename, name: declaration.id.name });
60
+ }
61
+ else if (first.filename !== context.filename) {
62
+ context.report({ node: declaration, messageId: "duplicate", data: { name: declaration.id.name, firstFile: first.filename } });
63
+ }
64
+ }
65
+ },
66
+ };
67
+ },
68
+ });
@@ -10,7 +10,9 @@ function canonicalIntegerType(node, builder) {
10
10
  return null;
11
11
  mode = property.value.value;
12
12
  }
13
- return mode === null ? builder.type : mode === "boolean" ? "TSBooleanKeyword" : null;
13
+ if (mode === null || mode === "number")
14
+ return builder.type;
15
+ return mode === "boolean" ? "TSBooleanKeyword" : null;
14
16
  }
15
17
  function canonicalColumn(node, builders) {
16
18
  let current = node, notNull = false, hasDefault = false, typeOverride = null;
@@ -105,7 +105,24 @@ export const noDuplicateTypesRule = defineRule({
105
105
  }
106
106
  };
107
107
  return {
108
- Program() { options = context.options[0] ?? {}; },
108
+ Program() {
109
+ // LSPs can re-lint a file in the same createOnce worker; discard its stale declarations first.
110
+ for (const [key, type] of seen)
111
+ if (type.filename === context.filename)
112
+ seen.delete(key);
113
+ options = context.options[0] ?? {};
114
+ zodNamespaces.clear();
115
+ valibotNamespaces.clear();
116
+ arkTypeFunctions.clear();
117
+ valibotFunctions.clear();
118
+ drizzleTables.clear();
119
+ drizzleSchemaFactories.clear();
120
+ drizzleSchemaObjects.clear();
121
+ drizzleBuilders.clear();
122
+ drizzleModels.clear();
123
+ drizzleAliases.length = 0;
124
+ hasArkTypeImport = false;
125
+ },
109
126
  ImportDeclaration: trackImport,
110
127
  TSTypeAliasDeclaration(node) { inspectType(node); drizzleAliases.push(node); },
111
128
  TSInterfaceDeclaration: inspectType,
@@ -0,0 +1,2 @@
1
+ /** Disallow testing that a constant string contains substrings. */
2
+ export declare const noPoorSubstringTestsRule: import("@oxlint/plugins").Rule;
@@ -0,0 +1,320 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { defineRule } from "@oxlint/plugins";
4
+ import { resolveVariable } from "../shared/scope.js";
5
+ const SUBSTRING_MATCHERS = new Set([
6
+ "toContain",
7
+ "toInclude",
8
+ "toMatch",
9
+ "include",
10
+ "contain",
11
+ "string",
12
+ "match",
13
+ ]);
14
+ const CHAIN_MODIFIERS = new Set([
15
+ "not",
16
+ "resolves",
17
+ "rejects",
18
+ "to",
19
+ "be",
20
+ "have",
21
+ "that",
22
+ "which",
23
+ "and",
24
+ "is",
25
+ "deep",
26
+ "nested",
27
+ "own",
28
+ ]);
29
+ const SUBSTRING_METHODS = new Set([
30
+ "includes",
31
+ "startsWith",
32
+ "endsWith",
33
+ "indexOf",
34
+ "search",
35
+ "match",
36
+ ]);
37
+ const ASSERT_MATCHERS = new Set([
38
+ "include",
39
+ "includes",
40
+ "notInclude",
41
+ "notIncludes",
42
+ "match",
43
+ "notMatch",
44
+ ]);
45
+ const fileContentCache = new Map();
46
+ function isTestFile(filename) {
47
+ if (!filename)
48
+ return false;
49
+ return /(?:^|[/\\])(?:__tests__|tests?)(?:[/\\]|$)|(?:[._](?:test|spec))\.[cm]?[jt]sx?$/i.test(filename);
50
+ }
51
+ function rootIdentifierName(node) {
52
+ let current = node;
53
+ while (true) {
54
+ if (current.type === "Identifier")
55
+ return current.name;
56
+ if (current.type === "MemberExpression") {
57
+ current = current.object;
58
+ }
59
+ else if (current.type === "CallExpression" || current.type === "TaggedTemplateExpression") {
60
+ current = current.type === "CallExpression" ? current.callee : current.tag;
61
+ }
62
+ else {
63
+ return undefined;
64
+ }
65
+ }
66
+ }
67
+ function unwrapExpression(expression) {
68
+ let current = expression;
69
+ while (true) {
70
+ if (current.type === "ParenthesizedExpression" ||
71
+ current.type === "TSAsExpression" ||
72
+ current.type === "TSTypeAssertion" ||
73
+ current.type === "TSNonNullExpression" ||
74
+ current.type === "TSSatisfiesExpression") {
75
+ current = current.expression;
76
+ }
77
+ else {
78
+ break;
79
+ }
80
+ }
81
+ return current;
82
+ }
83
+ function isStableConstVariable(variable, declarator) {
84
+ if (declarator.parent.type !== "VariableDeclaration")
85
+ return false;
86
+ if (declarator.parent.kind === "const")
87
+ return true;
88
+ return variable.references.every((ref) => ref.init || !ref.isWrite());
89
+ }
90
+ function isImportedConstantString(variable, currentFilename) {
91
+ if (!currentFilename || !path.isAbsolute(currentFilename))
92
+ return false;
93
+ const definition = variable.defs[0];
94
+ if (definition?.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") {
95
+ return false;
96
+ }
97
+ const source = definition.parent.source.value;
98
+ if (typeof source !== "string" || !source.startsWith(".")) {
99
+ return false;
100
+ }
101
+ const dir = path.dirname(currentFilename);
102
+ const candidates = [
103
+ path.resolve(dir, source),
104
+ path.resolve(dir, `${source}.ts`),
105
+ path.resolve(dir, `${source}.tsx`),
106
+ path.resolve(dir, `${source}.js`),
107
+ path.resolve(dir, `${source}.mjs`),
108
+ path.resolve(dir, source, "index.ts"),
109
+ path.resolve(dir, source, "index.js"),
110
+ ];
111
+ for (const candidate of candidates) {
112
+ try {
113
+ let content = fileContentCache.get(candidate);
114
+ if (content === undefined) {
115
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
116
+ continue;
117
+ }
118
+ content = fs.readFileSync(candidate, "utf8");
119
+ fileContentCache.set(candidate, content);
120
+ }
121
+ const importedName = definition.node.type === "ImportSpecifier"
122
+ ? definition.node.imported.type === "Identifier"
123
+ ? definition.node.imported.name
124
+ : definition.node.imported.value
125
+ : definition.node.type === "ImportDefaultSpecifier"
126
+ ? "default"
127
+ : null;
128
+ if (!importedName)
129
+ continue;
130
+ const pattern = new RegExp(`export\\s+const\\s+${importedName}\\s*=\\s*(["'\`])`);
131
+ if (pattern.test(content)) {
132
+ return true;
133
+ }
134
+ }
135
+ catch {
136
+ // ignore file read errors
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+ function isConstantString(expression, sourceCode, currentFilename, visited = new Set()) {
142
+ const current = unwrapExpression(expression);
143
+ if (current.type === "Literal" && typeof current.value === "string") {
144
+ return true;
145
+ }
146
+ if (current.type === "TemplateLiteral") {
147
+ if (current.expressions.length === 0) {
148
+ return true;
149
+ }
150
+ return current.expressions.every((expr) => isConstantString(expr, sourceCode, currentFilename, visited));
151
+ }
152
+ if (current.type === "BinaryExpression" && current.operator === "+") {
153
+ return (isConstantString(current.left, sourceCode, currentFilename, visited) &&
154
+ isConstantString(current.right, sourceCode, currentFilename, visited));
155
+ }
156
+ if (current.type === "CallExpression" &&
157
+ current.callee.type === "Identifier" &&
158
+ current.callee.name === "String" &&
159
+ current.arguments.length === 1 &&
160
+ current.arguments[0]?.type !== "SpreadElement") {
161
+ return isConstantString(current.arguments[0], sourceCode, currentFilename, visited);
162
+ }
163
+ if (current.type === "Identifier") {
164
+ const variable = resolveVariable(sourceCode, current);
165
+ if (variable === null || visited.has(variable)) {
166
+ return false;
167
+ }
168
+ if (variable.defs.length === 1) {
169
+ const [def] = variable.defs;
170
+ if (def?.type === "Variable" &&
171
+ def.node.type === "VariableDeclarator" &&
172
+ def.node.init !== null) {
173
+ if (isStableConstVariable(variable, def.node)) {
174
+ visited.add(variable);
175
+ return isConstantString(def.node.init, sourceCode, currentFilename, visited);
176
+ }
177
+ }
178
+ if (def?.type === "ImportBinding") {
179
+ return isImportedConstantString(variable, currentFilename);
180
+ }
181
+ }
182
+ }
183
+ return false;
184
+ }
185
+ function extractExpectSubject(callee) {
186
+ let object = callee.object;
187
+ while (object.type === "MemberExpression" &&
188
+ !object.computed &&
189
+ object.property.type === "Identifier" &&
190
+ CHAIN_MODIFIERS.has(object.property.name)) {
191
+ object = object.object;
192
+ }
193
+ if (object.type === "CallExpression" &&
194
+ rootIdentifierName(object.callee) === "expect" &&
195
+ object.arguments.length > 0 &&
196
+ object.arguments[0]?.type !== "SpreadElement") {
197
+ return object.arguments[0];
198
+ }
199
+ return null;
200
+ }
201
+ function extractSubstringCallSubject(node) {
202
+ const unwrapped = unwrapExpression(node);
203
+ if (unwrapped.type === "CallExpression" &&
204
+ unwrapped.callee.type === "MemberExpression" &&
205
+ !unwrapped.callee.computed &&
206
+ unwrapped.callee.property.type === "Identifier") {
207
+ const method = unwrapped.callee.property.name;
208
+ if (SUBSTRING_METHODS.has(method)) {
209
+ return unwrapped.callee.object;
210
+ }
211
+ if (method === "test" && unwrapped.arguments.length > 0 && unwrapped.arguments[0]?.type !== "SpreadElement") {
212
+ return unwrapped.arguments[0];
213
+ }
214
+ }
215
+ if (unwrapped.type === "BinaryExpression") {
216
+ if (unwrapped.left.type !== "PrivateIdentifier") {
217
+ const leftSubject = extractSubstringCallSubject(unwrapped.left);
218
+ if (leftSubject)
219
+ return leftSubject;
220
+ }
221
+ const rightSubject = extractSubstringCallSubject(unwrapped.right);
222
+ if (rightSubject)
223
+ return rightSubject;
224
+ }
225
+ return null;
226
+ }
227
+ function isExpectStringContaining(node) {
228
+ const unwrapped = unwrapExpression(node);
229
+ if (unwrapped.type === "CallExpression" &&
230
+ unwrapped.callee.type === "MemberExpression" &&
231
+ !unwrapped.callee.computed &&
232
+ unwrapped.callee.property.type === "Identifier" &&
233
+ unwrapped.callee.property.name === "stringContaining" &&
234
+ rootIdentifierName(unwrapped.callee.object) === "expect") {
235
+ return true;
236
+ }
237
+ return false;
238
+ }
239
+ /** Disallow testing that a constant string contains substrings. */
240
+ export const noPoorSubstringTestsRule = defineRule({
241
+ meta: {
242
+ type: "suggestion",
243
+ docs: {
244
+ description: "Disallow testing that a constant string contains substrings; tests must assert on dynamic behavior or verify full contracts.",
245
+ },
246
+ messages: {
247
+ poorSubstringTest: "Testing that a constant string contains a substring provides little signal. Assert against dynamic behavior, or verify the complete contract with an exact match or snapshot instead.",
248
+ },
249
+ schema: [],
250
+ },
251
+ createOnce(context) {
252
+ let shouldCheck = false;
253
+ return {
254
+ Program() {
255
+ shouldCheck = isTestFile(context.filename);
256
+ },
257
+ CallExpression(node) {
258
+ if (!shouldCheck)
259
+ return;
260
+ // Pattern 1: expect(subject).toContain(sub), expect(subject).toMatch(regex), etc.
261
+ if (node.callee.type === "MemberExpression" &&
262
+ !node.callee.computed &&
263
+ node.callee.property.type === "Identifier") {
264
+ const propertyName = node.callee.property.name;
265
+ if (SUBSTRING_MATCHERS.has(propertyName)) {
266
+ const subject = extractExpectSubject(node.callee);
267
+ if (subject && isConstantString(subject, context.sourceCode, context.filename)) {
268
+ context.report({ node, messageId: "poorSubstringTest" });
269
+ return;
270
+ }
271
+ }
272
+ // Pattern 1b: expect(subject).toEqual(expect.stringContaining(sub))
273
+ if (node.arguments.length > 0 &&
274
+ node.arguments[0] !== undefined &&
275
+ node.arguments[0].type !== "SpreadElement" &&
276
+ isExpectStringContaining(node.arguments[0])) {
277
+ const subject = extractExpectSubject(node.callee);
278
+ if (subject && isConstantString(subject, context.sourceCode, context.filename)) {
279
+ context.report({ node, messageId: "poorSubstringTest" });
280
+ return;
281
+ }
282
+ }
283
+ }
284
+ // Pattern 2: expect(subject.includes("sub")), expect(subject.startsWith("sub")), expect(/regex/.test(subject)), etc.
285
+ if (rootIdentifierName(node.callee) === "expect" && node.arguments.length > 0) {
286
+ const firstArg = node.arguments[0];
287
+ if (firstArg && firstArg.type !== "SpreadElement") {
288
+ const subject = extractSubstringCallSubject(firstArg);
289
+ if (subject && isConstantString(subject, context.sourceCode, context.filename)) {
290
+ context.report({ node, messageId: "poorSubstringTest" });
291
+ return;
292
+ }
293
+ }
294
+ }
295
+ // Pattern 3: assert(subject.includes("sub")), assert.ok(subject.includes("sub")), assert.isTrue(subject.includes("sub"))
296
+ if (rootIdentifierName(node.callee) === "assert" &&
297
+ node.arguments.length > 0 &&
298
+ node.arguments[0] !== undefined &&
299
+ node.arguments[0].type !== "SpreadElement") {
300
+ const firstArg = node.arguments[0];
301
+ // 3a: assert.include(subject, "sub"), assert.match(subject, /regex/)
302
+ if (node.callee.type === "MemberExpression" &&
303
+ !node.callee.computed &&
304
+ node.callee.property.type === "Identifier" &&
305
+ ASSERT_MATCHERS.has(node.callee.property.name)) {
306
+ if (isConstantString(firstArg, context.sourceCode, context.filename)) {
307
+ context.report({ node, messageId: "poorSubstringTest" });
308
+ return;
309
+ }
310
+ }
311
+ // 3b: assert(subject.includes("sub")), assert.ok(subject.includes("sub"))
312
+ const subject = extractSubstringCallSubject(firstArg);
313
+ if (subject && isConstantString(subject, context.sourceCode, context.filename)) {
314
+ context.report({ node, messageId: "poorSubstringTest" });
315
+ }
316
+ }
317
+ },
318
+ };
319
+ },
320
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-deslop",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Opinionated Oxlint rules that reject low-evidence, low-signal, AI-slop code patterns.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",