ai-deslop 0.5.2 → 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.
Files changed (27) hide show
  1. package/dist/index.js +1 -0
  2. package/dist/rules/max-boolean-conditions.js +18 -1
  3. package/dist/rules/no-call-only-assertions.js +22 -1
  4. package/dist/rules/no-duplicate-consts.js +9 -3
  5. package/dist/rules/no-duplicate-types.js +25 -12
  6. package/dist/rules/no-pass-through-schema.js +18 -6
  7. package/dist/rules/no-pass-through-type-alias.js +14 -2
  8. package/dist/rules/no-poor-substring-tests.js +57 -22
  9. package/dist/rules/no-reexports-outside-entrypoints.js +18 -7
  10. package/dist/rules/no-renamed-imports.js +81 -4
  11. package/dist/vendor/anti-slop/rules/no-known-value-widening.js +8 -1
  12. package/dist/vendor/anti-slop/rules/no-module-mocking.js +4 -3
  13. package/dist/vendor/anti-slop/rules/no-object-parameters.js +1 -1
  14. package/dist/vendor/anti-slop/rules/no-reduce-accumulator-copy.js +6 -1
  15. package/dist/vendor/anti-slop/rules/no-reflect-apply.js +2 -0
  16. package/dist/vendor/anti-slop/rules/no-reflect-get.js +2 -0
  17. package/dist/vendor/anti-slop/rules/no-runtime-typeof.js +2 -0
  18. package/dist/vendor/anti-slop/rules/no-shape-in-symbol-names.js +3 -0
  19. package/dist/vendor/anti-slop/rules/no-unknown-returns.js +1 -1
  20. package/dist/vendor/anti-slop/rules/no-unknown-type-aliases.js +1 -1
  21. package/dist/vendor/anti-slop/rules/no-unsafe-dictionary-type.js +1 -1
  22. package/dist/vendor/anti-slop/rules/no-widen-then-assert.js +4 -2
  23. package/dist/vendor/anti-slop/shared/dictionary-types.d.ts +1 -1
  24. package/dist/vendor/anti-slop/shared/dictionary-types.js +2 -2
  25. package/dist/vendor/anti-slop/shared/type-alias-resolution.d.ts +2 -2
  26. package/dist/vendor/anti-slop/shared/type-alias-resolution.js +56 -13
  27. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -58,6 +58,7 @@ export const recommendedRules = {
58
58
  "typescript/await-thenable": "error",
59
59
  "typescript/no-unnecessary-condition": "error",
60
60
  "typescript/switch-exhaustiveness-check": "error",
61
+ "typescript/no-inferrable-types": "error",
61
62
  "no-unreachable": "error",
62
63
  "deslop/no-restricted-syntax": [
63
64
  "error",
@@ -1,4 +1,18 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
+ function countRawTerms(node) {
3
+ const pending = [node];
4
+ let count = 0;
5
+ while (pending.length > 0) {
6
+ const term = pending.pop();
7
+ if (term.type === "LogicalExpression" && term.operator !== "??") {
8
+ pending.push(term.left, term.right);
9
+ }
10
+ else {
11
+ count++;
12
+ }
13
+ }
14
+ return count;
15
+ }
2
16
  /** Limit top-level Boolean terms in control-flow conditions. */
3
17
  export const maxBooleanConditionsRule = defineRule({
4
18
  meta: {
@@ -21,6 +35,10 @@ export const maxBooleanConditionsRule = defineRule({
21
35
  function checkCondition(node) {
22
36
  if (node.test === null)
23
37
  return;
38
+ // Grouping can only merge terms, so skip token reads when the ungrouped count fits.
39
+ const max = options.maxConditions ?? 3;
40
+ if (countRawTerms(node.test) <= max)
41
+ return;
24
42
  const pending = [node.test];
25
43
  let count = 0;
26
44
  while (pending.length > 0) {
@@ -35,7 +53,6 @@ export const maxBooleanConditionsRule = defineRule({
35
53
  count++;
36
54
  }
37
55
  }
38
- const max = options.maxConditions ?? 3;
39
56
  if (count <= max)
40
57
  return;
41
58
  if (options.message !== undefined) {
@@ -1,7 +1,13 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
2
  // Tests whose every assertion is a "was it called" mock check verify orchestration, not behavior.
3
3
  // `toHaveBeenCalledWith` is allowed because it asserts on real data.
4
- const CALL_ONLY_MATCHERS = new Set(["toHaveBeenCalled", "toHaveBeenCalledTimes", "toHaveBeenCalledOnce"]);
4
+ const CALL_ONLY_MATCHERS = new Set([
5
+ "toHaveBeenCalled",
6
+ "toHaveBeenCalledTimes",
7
+ "toHaveBeenCalledOnce",
8
+ "toBeCalled",
9
+ "toBeCalledTimes",
10
+ ]);
5
11
  const TEST_NAMES = new Set(["it", "test"]);
6
12
  const MODIFIERS = new Set(["not", "resolves", "rejects"]);
7
13
  function rootIdentifierName(node) {
@@ -42,6 +48,14 @@ function matcherName(node) {
42
48
  const isExpectCall = object.type === "CallExpression" && rootIdentifierName(object.callee) === "expect";
43
49
  return isExpectCall ? node.callee.property.name : undefined;
44
50
  }
51
+ // `assert(...)`, `assert.equal(...)`, `assert.strict.equal(...)`: real assertions.
52
+ function isAssertCall(node) {
53
+ let callee = node.callee;
54
+ while (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") {
55
+ callee = callee.object;
56
+ }
57
+ return callee.type === "Identifier" && callee.name === "assert";
58
+ }
45
59
  /** Tests must assert on behavior, not only that a mock was called. */
46
60
  export const noCallOnlyAssertionsRule = defineRule({
47
61
  meta: {
@@ -57,6 +71,9 @@ export const noCallOnlyAssertionsRule = defineRule({
57
71
  return {
58
72
  before() {
59
73
  tests = [];
74
+ // Every call-only matcher name contains "Called". `\u` covers escaped identifiers.
75
+ const text = context.sourceCode.text;
76
+ return text.includes("Called") || text.includes("\\u");
60
77
  },
61
78
  CallExpression(node) {
62
79
  if (isTestCall(node)) {
@@ -66,6 +83,10 @@ export const noCallOnlyAssertionsRule = defineRule({
66
83
  const test = tests.at(-1);
67
84
  if (!test)
68
85
  return;
86
+ if (isAssertCall(node)) {
87
+ test.total += 1;
88
+ return;
89
+ }
69
90
  const matcher = matcherName(node);
70
91
  if (!matcher)
71
92
  return;
@@ -38,12 +38,13 @@ export const noDuplicateConstsRule = defineRule({
38
38
  },
39
39
  createOnce(context) {
40
40
  const seen = new Map();
41
+ const seenKeysByFile = new Map();
41
42
  return {
42
43
  Program() {
43
44
  // 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);
45
+ for (const key of seenKeysByFile.get(context.filename) ?? [])
46
+ seen.delete(key);
47
+ seenKeysByFile.delete(context.filename);
47
48
  },
48
49
  VariableDeclaration(node) {
49
50
  if (node.kind !== "const")
@@ -57,6 +58,11 @@ export const noDuplicateConstsRule = defineRule({
57
58
  const key = `${declaration.id.name}:${value}`, first = seen.get(key);
58
59
  if (first === undefined) {
59
60
  seen.set(key, { filename: context.filename, name: declaration.id.name });
61
+ const keys = seenKeysByFile.get(context.filename);
62
+ if (keys === undefined)
63
+ seenKeysByFile.set(context.filename, [key]);
64
+ else
65
+ keys.push(key);
60
66
  }
61
67
  else if (first.filename !== context.filename) {
62
68
  context.report({ node: declaration, messageId: "duplicate", data: { name: declaration.id.name, firstFile: first.filename } });
@@ -16,7 +16,7 @@ export const noDuplicateTypesRule = defineRule({
16
16
  schema: [{ type: "object", properties: { message: { type: "string" }, minProperties: { type: "integer", minimum: 0 }, schemas: { type: "object", properties: { libraries: { type: "array", items: { enum: ["zod", "valibot", "arktype"] } }, matchTypes: { type: "boolean" } }, additionalProperties: false }, drizzle: { type: "object", properties: { models: { type: "array", items: { enum: ["select", "insert"] } }, matchTypes: { type: "boolean" } }, additionalProperties: false } }, additionalProperties: false }],
17
17
  },
18
18
  createOnce(context) {
19
- const seen = new Map();
19
+ const seen = new Map(), seenKeysByFile = new Map();
20
20
  const zodNamespaces = new Set(), valibotNamespaces = new Set(), arkTypeFunctions = new Set();
21
21
  const valibotFunctions = new Map(), drizzleTables = new Set(), drizzleSchemaFactories = new Set(), drizzleSchemaObjects = new Set();
22
22
  const drizzleBuilders = new Map(), drizzleModels = new Map();
@@ -29,10 +29,16 @@ export const noDuplicateTypesRule = defineRule({
29
29
  const isolates = kind === "schema" ? options.schemas?.matchTypes === false : kind.startsWith("drizzle-") && options.drizzle?.matchTypes === false;
30
30
  const key = isolates ? `${kind}:${fingerprint}` : fingerprint, first = seen.get(key);
31
31
  if (first === undefined) {
32
- seen.set(key, { node, name, filename: context.filename });
32
+ seen.set(key, { start: node.start, name, filename: context.filename });
33
+ const keys = seenKeysByFile.get(context.filename);
34
+ if (keys === undefined)
35
+ seenKeysByFile.set(context.filename, [key]);
36
+ else
37
+ keys.push(key);
33
38
  return;
34
39
  }
35
- if (first.node !== node)
40
+ // The same declarator can produce one fingerprint for several Drizzle models.
41
+ if (first.filename !== context.filename || first.start !== node.start)
36
42
  context.report({ node, messageId: "duplicate", data: { message: options.message ?? "This type is structurally identical to an existing declaration.", firstName: first.name, firstFile: first.filename } });
37
43
  };
38
44
  const inspectType = (node) => {
@@ -42,7 +48,10 @@ export const noDuplicateTypesRule = defineRule({
42
48
  report(node, node.id.name, fingerprint, "type");
43
49
  };
44
50
  const inspectSchema = (node) => {
45
- if (node.id.type !== "Identifier" || node.init === null)
51
+ // Every schema form is a call, and needs a library binding imported earlier in the file.
52
+ if (node.id.type !== "Identifier" || node.init?.type !== "CallExpression")
53
+ return;
54
+ if (zodNamespaces.size === 0 && valibotNamespaces.size === 0 && valibotFunctions.size === 0 && !hasArkTypeImport)
46
55
  return;
47
56
  const zod = memberCall(node.init);
48
57
  const valibot = factoryCall(node.init, valibotNamespaces, valibotFunctions);
@@ -55,7 +64,7 @@ export const noDuplicateTypesRule = defineRule({
55
64
  report(node, node.id.name, schema.fingerprint, "schema");
56
65
  };
57
66
  const inspectDrizzle = (node) => {
58
- if (options.drizzle === undefined || node.id.type !== "Identifier" || node.init === null)
67
+ if (options.drizzle === undefined || node.id.type !== "Identifier" || node.init?.type !== "CallExpression" || drizzleTables.size === 0 && drizzleSchemaObjects.size === 0)
59
68
  return;
60
69
  const models = new Map();
61
70
  for (const model of options.drizzle.models ?? ["select", "insert"]) {
@@ -69,18 +78,19 @@ export const noDuplicateTypesRule = defineRule({
69
78
  drizzleModels.set(node.id.name, models);
70
79
  };
71
80
  const trackImport = (node) => {
72
- if (node.source.value === "zod")
81
+ // Only track bindings for enabled detectors; the inspectors read nothing else.
82
+ if (node.source.value === "zod" && enabled("zod"))
73
83
  for (const specifier of node.specifiers)
74
84
  if (specifier.type === "ImportNamespaceSpecifier" || specifier.type === "ImportDefaultSpecifier" || specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "z")
75
85
  zodNamespaces.add(specifier.local.name);
76
- if (node.source.value === "valibot")
86
+ if (node.source.value === "valibot" && enabled("valibot"))
77
87
  for (const specifier of node.specifiers) {
78
88
  if (specifier.type === "ImportNamespaceSpecifier")
79
89
  valibotNamespaces.add(specifier.local.name);
80
90
  if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier")
81
91
  valibotFunctions.set(specifier.local.name, specifier.imported.name);
82
92
  }
83
- if (node.source.value === "arktype") {
93
+ if (node.source.value === "arktype" && enabled("arktype")) {
84
94
  hasArkTypeImport = true;
85
95
  for (const specifier of node.specifiers) {
86
96
  const imported = specifier.type === "ImportSpecifier" ? specifier.imported : null;
@@ -88,6 +98,8 @@ export const noDuplicateTypesRule = defineRule({
88
98
  arkTypeFunctions.add(specifier.local.name);
89
99
  }
90
100
  }
101
+ if (options.drizzle === undefined)
102
+ return;
91
103
  const source = /^drizzle-orm\/(pg|mysql|sqlite)-core$/.exec(String(node.source.value));
92
104
  if (source === null)
93
105
  return;
@@ -107,9 +119,9 @@ export const noDuplicateTypesRule = defineRule({
107
119
  return {
108
120
  Program() {
109
121
  // 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);
122
+ for (const key of seenKeysByFile.get(context.filename) ?? [])
123
+ seen.delete(key);
124
+ seenKeysByFile.delete(context.filename);
113
125
  options = context.options[0] ?? {};
114
126
  zodNamespaces.clear();
115
127
  valibotNamespaces.clear();
@@ -124,7 +136,8 @@ export const noDuplicateTypesRule = defineRule({
124
136
  hasArkTypeImport = false;
125
137
  },
126
138
  ImportDeclaration: trackImport,
127
- TSTypeAliasDeclaration(node) { inspectType(node); drizzleAliases.push(node); },
139
+ TSTypeAliasDeclaration(node) { inspectType(node); if (options.drizzle !== undefined)
140
+ drizzleAliases.push(node); },
128
141
  TSInterfaceDeclaration: inspectType,
129
142
  VariableDeclarator(node) {
130
143
  inspectSchema(node);
@@ -67,7 +67,9 @@ export const noPassThroughSchemaRule = defineRule({
67
67
  createOnce(context) {
68
68
  let options;
69
69
  const candidates = [];
70
- const exported = new Set();
70
+ // Export identifiers are resolved to variables only when an alias needs the check.
71
+ const exportedNames = [];
72
+ let exported = null;
71
73
  function variableFor(node) {
72
74
  let scope = context.sourceCode.getScope(node);
73
75
  while (true) {
@@ -150,15 +152,25 @@ export const noPassThroughSchemaRule = defineRule({
150
152
  return null;
151
153
  }
152
154
  function markExport(node) {
153
- const variable = variableFor(node);
154
- if (variable !== null)
155
- exported.add(variable);
155
+ exportedNames.push(node);
156
+ }
157
+ function exportedVariables() {
158
+ if (exported === null) {
159
+ exported = new Set();
160
+ for (const node of exportedNames) {
161
+ const variable = variableFor(node);
162
+ if (variable !== null)
163
+ exported.add(variable);
164
+ }
165
+ }
166
+ return exported;
156
167
  }
157
168
  return {
158
169
  before() {
159
170
  options = context.options[0] ?? {};
160
171
  candidates.length = 0;
161
- exported.clear();
172
+ exportedNames.length = 0;
173
+ exported = null;
162
174
  },
163
175
  VariableDeclarator(node) {
164
176
  if (node.parent.type === "VariableDeclaration" && node.parent.kind === "const" && node.id.type === "Identifier" && node.init?.type === "Identifier")
@@ -190,7 +202,7 @@ export const noPassThroughSchemaRule = defineRule({
190
202
  if (target !== "schema" && target !== "zod-schema" && target !== "ark-schema")
191
203
  continue;
192
204
  const variable = variableFor(node.id);
193
- if (options.allowExportedAliases && variable !== null && exported.has(variable))
205
+ if (options.allowExportedAliases && variable !== null && exportedVariables().has(variable))
194
206
  continue;
195
207
  context.report({ node, messageId: "passThroughSchema", data: { schemaName: node.init.name } });
196
208
  }
@@ -5,16 +5,28 @@ function isForwardedTypeParameter(typeArgument, typeParameter) {
5
5
  typeArgument.typeName.name === typeParameter.name.name &&
6
6
  !typeArgument.typeArguments);
7
7
  }
8
+ function typeNameText(node) {
9
+ if (node.type === "Identifier")
10
+ return node.name;
11
+ if (node.type !== "TSQualifiedName")
12
+ return null;
13
+ const left = typeNameText(node.left);
14
+ return left === null ? null : `${left}.${node.right.name}`;
15
+ }
8
16
  function passThroughTargetName(node) {
9
17
  const annotation = node.typeAnnotation;
10
- if (annotation.type !== "TSTypeReference" || annotation.typeName.type !== "Identifier") {
18
+ if (annotation.type !== "TSTypeReference") {
19
+ return null;
20
+ }
21
+ const typeName = typeNameText(annotation.typeName);
22
+ if (typeName === null) {
11
23
  return null;
12
24
  }
13
25
  const typeParameters = node.typeParameters?.params ?? [];
14
26
  const typeArguments = annotation.typeArguments?.params ?? [];
15
27
  const isPassThrough = typeParameters.length === typeArguments.length &&
16
28
  typeParameters.every((typeParameter, index) => isForwardedTypeParameter(typeArguments[index], typeParameter));
17
- return isPassThrough ? annotation.typeName.name : null;
29
+ return isPassThrough ? typeName : null;
18
30
  }
19
31
  /** Disallow type aliases that only rename another type. */
20
32
  export const noPassThroughTypeAliasRule = defineRule({
@@ -42,7 +42,40 @@ const ASSERT_MATCHERS = new Set([
42
42
  "match",
43
43
  "notMatch",
44
44
  ]);
45
+ // Bounded, insertion-ordered cache. Entries are validated by mtime and size so that
46
+ // long-lived processes (editor/LSP) see on-disk changes.
47
+ const FILE_CONTENT_CACHE_LIMIT = 256;
45
48
  const fileContentCache = new Map();
49
+ // TS ESM style: a `.js` specifier may refer to a `.ts` source file, etc.
50
+ const SOURCE_EXTENSIONS_FOR_SPECIFIER = {
51
+ ".js": [".ts", ".tsx"],
52
+ ".jsx": [".tsx"],
53
+ ".mjs": [".mts"],
54
+ ".cjs": [".cts"],
55
+ };
56
+ function readCachedFile(filePath) {
57
+ const stats = fs.statSync(filePath, { throwIfNoEntry: false });
58
+ if (!stats?.isFile()) {
59
+ fileContentCache.delete(filePath);
60
+ return undefined;
61
+ }
62
+ const cached = fileContentCache.get(filePath);
63
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
64
+ return cached.content;
65
+ }
66
+ const content = fs.readFileSync(filePath, "utf8");
67
+ fileContentCache.delete(filePath);
68
+ if (fileContentCache.size >= FILE_CONTENT_CACHE_LIMIT) {
69
+ const oldest = fileContentCache.keys().next().value;
70
+ if (oldest !== undefined)
71
+ fileContentCache.delete(oldest);
72
+ }
73
+ fileContentCache.set(filePath, { mtimeMs: stats.mtimeMs, size: stats.size, content });
74
+ return content;
75
+ }
76
+ function escapeRegExp(text) {
77
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
78
+ }
46
79
  function isTestFile(filename) {
47
80
  if (!filename)
48
81
  return false;
@@ -98,36 +131,37 @@ function isImportedConstantString(variable, currentFilename) {
98
131
  if (typeof source !== "string" || !source.startsWith(".")) {
99
132
  return false;
100
133
  }
134
+ const importedName = definition.node.type === "ImportSpecifier"
135
+ ? definition.node.imported.type === "Identifier"
136
+ ? definition.node.imported.name
137
+ : definition.node.imported.value
138
+ : definition.node.type === "ImportDefaultSpecifier"
139
+ ? "default"
140
+ : null;
141
+ if (!importedName)
142
+ return false;
101
143
  const dir = path.dirname(currentFilename);
144
+ const specifierExtension = path.extname(source);
145
+ const stem = source.slice(0, source.length - specifierExtension.length);
102
146
  const candidates = [
103
147
  path.resolve(dir, source),
148
+ ...(SOURCE_EXTENSIONS_FOR_SPECIFIER[specifierExtension] ?? []).map((extension) => path.resolve(dir, `${stem}${extension}`)),
104
149
  path.resolve(dir, `${source}.ts`),
105
150
  path.resolve(dir, `${source}.tsx`),
151
+ path.resolve(dir, `${source}.mts`),
152
+ path.resolve(dir, `${source}.cts`),
106
153
  path.resolve(dir, `${source}.js`),
154
+ path.resolve(dir, `${source}.jsx`),
107
155
  path.resolve(dir, `${source}.mjs`),
108
156
  path.resolve(dir, source, "index.ts"),
109
157
  path.resolve(dir, source, "index.js"),
110
158
  ];
159
+ const pattern = new RegExp(`export\\s+const\\s+${escapeRegExp(importedName)}\\s*=\\s*(["'\`])`);
111
160
  for (const candidate of candidates) {
112
161
  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)
162
+ const content = readCachedFile(candidate);
163
+ if (content === undefined)
129
164
  continue;
130
- const pattern = new RegExp(`export\\s+const\\s+${importedName}\\s*=\\s*(["'\`])`);
131
165
  if (pattern.test(content)) {
132
166
  return true;
133
167
  }
@@ -249,14 +283,15 @@ export const noPoorSubstringTestsRule = defineRule({
249
283
  schema: [],
250
284
  },
251
285
  createOnce(context) {
252
- let shouldCheck = false;
253
286
  return {
254
- Program() {
255
- shouldCheck = isTestFile(context.filename);
287
+ before() {
288
+ if (!isTestFile(context.filename))
289
+ return false;
290
+ // Every pattern needs an `expect` or `assert` callee. `\u` covers escaped identifiers.
291
+ const text = context.sourceCode.text;
292
+ return text.includes("expect") || text.includes("assert") || text.includes("\\u");
256
293
  },
257
294
  CallExpression(node) {
258
- if (!shouldCheck)
259
- return;
260
295
  // Pattern 1: expect(subject).toContain(sub), expect(subject).toMatch(regex), etc.
261
296
  if (node.callee.type === "MemberExpression" &&
262
297
  !node.callee.computed &&
@@ -1,6 +1,7 @@
1
1
  import { matchesGlob, relative } from "node:path";
2
2
  import { defineRule } from "@oxlint/plugins";
3
3
  import { resolveVariable } from "../vendor/anti-slop/shared/scope.js";
4
+ const defaultEntrypoints = ["**/index.ts", "**/index.tsx", "**/index.mts", "**/index.cts", "**/index.js", "**/index.jsx", "**/index.mjs"];
4
5
  /** Keep re-exports in explicitly allowed entrypoint files. */
5
6
  export const noReexportsOutsideEntrypointsRule = defineRule({
6
7
  meta: {
@@ -21,28 +22,38 @@ export const noReexportsOutsideEntrypointsRule = defineRule({
21
22
  function isImported(node) {
22
23
  return resolveVariable(context.sourceCode, node)?.defs.some((definition) => definition.type === "ImportBinding") ?? false;
23
24
  }
24
- return {
25
- before() {
25
+ // Most files have no re-exports, so match the entrypoint globs only on the first one.
26
+ let entrypoint;
27
+ function isEntrypoint() {
28
+ if (entrypoint === undefined) {
26
29
  const options = context.options[0];
27
30
  const filename = relative(context.cwd, context.filename).replaceAll("\\", "/");
28
- return !(options?.entrypoints ?? ["**/index.ts"]).some((pattern) => matchesGlob(filename, pattern));
31
+ entrypoint = (options?.entrypoints ?? defaultEntrypoints).some((pattern) => matchesGlob(filename, pattern));
32
+ }
33
+ return entrypoint;
34
+ }
35
+ return {
36
+ before() {
37
+ entrypoint = undefined;
29
38
  },
30
39
  ExportNamedDeclaration(node) {
31
40
  if (node.source !== null) {
32
- context.report({ node, messageId: "reexport" });
41
+ if (!isEntrypoint())
42
+ context.report({ node, messageId: "reexport" });
33
43
  return;
34
44
  }
35
45
  for (const specifier of node.specifiers) {
36
- if (specifier.local.type === "Identifier" && isImported(specifier.local)) {
46
+ if (specifier.local.type === "Identifier" && isImported(specifier.local) && !isEntrypoint()) {
37
47
  context.report({ node: specifier, messageId: "reexport" });
38
48
  }
39
49
  }
40
50
  },
41
51
  ExportAllDeclaration(node) {
42
- context.report({ node, messageId: "reexport" });
52
+ if (!isEntrypoint())
53
+ context.report({ node, messageId: "reexport" });
43
54
  },
44
55
  ExportDefaultDeclaration(node) {
45
- if (node.declaration.type === "Identifier" && isImported(node.declaration)) {
56
+ if (node.declaration.type === "Identifier" && isImported(node.declaration) && !isEntrypoint()) {
46
57
  context.report({ node, messageId: "reexport" });
47
58
  }
48
59
  },
@@ -11,6 +11,78 @@ function moduleScopeFor(scope) {
11
11
  function importedName(node) {
12
12
  return node.imported.type === "Identifier" ? node.imported.name : null;
13
13
  }
14
+ const reservedBindingNames = new Set([
15
+ "arguments",
16
+ "await",
17
+ "break",
18
+ "case",
19
+ "catch",
20
+ "class",
21
+ "const",
22
+ "continue",
23
+ "debugger",
24
+ "default",
25
+ "delete",
26
+ "do",
27
+ "else",
28
+ "enum",
29
+ "eval",
30
+ "export",
31
+ "extends",
32
+ "false",
33
+ "finally",
34
+ "for",
35
+ "function",
36
+ "if",
37
+ "implements",
38
+ "import",
39
+ "in",
40
+ "instanceof",
41
+ "interface",
42
+ "let",
43
+ "new",
44
+ "null",
45
+ "package",
46
+ "private",
47
+ "protected",
48
+ "public",
49
+ "return",
50
+ "static",
51
+ "super",
52
+ "switch",
53
+ "this",
54
+ "throw",
55
+ "true",
56
+ "try",
57
+ "typeof",
58
+ "var",
59
+ "void",
60
+ "while",
61
+ "with",
62
+ "yield",
63
+ ]);
64
+ function isShadowedAt(reference, name, moduleScope) {
65
+ let current = reference.from;
66
+ while (current !== null && current !== moduleScope) {
67
+ if (current.set.has(name))
68
+ return true;
69
+ current = current.upper;
70
+ }
71
+ return false;
72
+ }
73
+ function isIntrinsicJsxTag(identifier, name) {
74
+ const parent = identifier.parent;
75
+ return (parent.type === "JSXOpeningElement" || parent.type === "JSXClosingElement") && /^[a-z]/.test(name);
76
+ }
77
+ function referenceReplacement(identifier, localName, name) {
78
+ const parent = identifier.parent;
79
+ if (parent.type === "Property" && parent.shorthand && parent.value === identifier)
80
+ return `${localName}: ${name}`;
81
+ if (parent.type === "ExportSpecifier" && parent.local === identifier && parent.exported.range[0] === identifier.range[0]) {
82
+ return `${name} as ${localName}`;
83
+ }
84
+ return name;
85
+ }
14
86
  /** Disallow named import aliases when the imported name is available in module scope. */
15
87
  export const noRenamedImportsRule = defineRule({
16
88
  meta: {
@@ -39,11 +111,16 @@ export const noRenamedImportsRule = defineRule({
39
111
  messageId: "unnecessaryAlias",
40
112
  data: { importedName: name, localName: node.local.name },
41
113
  fix(fixer) {
114
+ const localName = node.local.name;
115
+ const references = variable.references.filter((reference) => reference.resolved === variable);
116
+ if (reservedBindingNames.has(name) ||
117
+ moduleScope.through.some((reference) => reference.identifier.name === name) ||
118
+ references.some((reference) => isShadowedAt(reference, name, moduleScope) || isIntrinsicJsxTag(reference.identifier, name))) {
119
+ return null;
120
+ }
42
121
  return [
43
- fixer.replaceText(node, name),
44
- ...variable.references
45
- .filter((reference) => reference.resolved === variable)
46
- .map((reference) => fixer.replaceText(reference.identifier, name)),
122
+ fixer.replaceTextRange([node.imported.range[0], node.range[1]], name),
123
+ ...references.map((reference) => fixer.replaceText(reference.identifier, referenceReplacement(reference.identifier, localName, name))),
47
124
  ];
48
125
  },
49
126
  });
@@ -2,6 +2,7 @@ import { defineRule } from "@oxlint/plugins";
2
2
  import { classifyUnsafeDictionaryValue, classifyWideningTarget, createTypeEnvironment, isKnownEvidenceExpression, } from "../shared/dictionary-types.js";
3
3
  import { containsUnknownType, functionParameterBindingName, functionParameterTypeAnnotation, } from "../shared/function-parameters.js";
4
4
  import { resolveVariable } from "../shared/scope.js";
5
+ const TYPE_PREDICATE_KEYWORD = /\b(?:asserts|is)\b/;
5
6
  function unwrapExpression(expression) {
6
7
  let current = expression;
7
8
  while (current.type === "ParenthesizedExpression" ||
@@ -196,6 +197,7 @@ export const noKnownValueWideningRule = defineRule({
196
197
  },
197
198
  createOnce(context) {
198
199
  let environment = null;
200
+ let hasTypePredicateKeyword = null;
199
201
  const reportFlow = (expression, destination, subject) => {
200
202
  if (destination === null)
201
203
  return;
@@ -214,7 +216,8 @@ export const noKnownValueWideningRule = defineRule({
214
216
  const targetFromAnnotation = (annotation) => environment === null ? null : annotationTarget(annotation, environment);
215
217
  return {
216
218
  Program(node) {
217
- environment = createTypeEnvironment(node, context.sourceCode.visitorKeys);
219
+ hasTypePredicateKeyword = null;
220
+ environment = createTypeEnvironment(node, context.sourceCode.visitorKeys, context.sourceCode.text);
218
221
  },
219
222
  VariableDeclarator(node) {
220
223
  if (node.init === null || node.id.type !== "Identifier")
@@ -245,6 +248,10 @@ export const noKnownValueWideningRule = defineRule({
245
248
  CallExpression(node) {
246
249
  if (environment === null)
247
250
  return;
251
+ // A report needs a local type predicate (`x is T` or `asserts x`) in this file.
252
+ hasTypePredicateKeyword ??= TYPE_PREDICATE_KEYWORD.test(context.sourceCode.text);
253
+ if (!hasTypePredicateKeyword)
254
+ return;
248
255
  const owner = localFunctionForCall(context.sourceCode, node.callee);
249
256
  if (owner === null)
250
257
  return;
@@ -29,8 +29,6 @@ function isTestFrameworkObject(sourceCode, expression) {
29
29
  function moduleMockCall(sourceCode, callee) {
30
30
  if (!("property" in callee) || !("object" in callee) || !("computed" in callee))
31
31
  return false;
32
- if (!isTestFrameworkObject(sourceCode, callee.object))
33
- return false;
34
32
  const property = callee.property;
35
33
  const method = callee.computed
36
34
  ? property.type === "Literal" &&
@@ -42,7 +40,10 @@ function moduleMockCall(sourceCode, callee) {
42
40
  : property.type === "Identifier"
43
41
  ? property.name
44
42
  : null;
45
- return method !== null && moduleMockMethods.has(method);
43
+ // Compare the cheap method name before resolving the object through scope analysis.
44
+ return (method !== null &&
45
+ moduleMockMethods.has(method) &&
46
+ isTestFrameworkObject(sourceCode, callee.object));
46
47
  }
47
48
  /** Ban test framework module mocking in favor of real dependency seams. */
48
49
  export const noModuleMockingRule = defineRule({
@@ -39,7 +39,7 @@ export const noObjectParametersRule = defineRule({
39
39
  };
40
40
  return {
41
41
  Program(node) {
42
- environment = createTypeAliasEnvironment(node, context.sourceCode.visitorKeys);
42
+ environment = createTypeAliasEnvironment(node, context.sourceCode.visitorKeys, context.sourceCode.text);
43
43
  },
44
44
  ArrowFunctionExpression: checkParameters,
45
45
  FunctionDeclaration: checkParameters,
@@ -1,5 +1,6 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
2
  import { arrayMethodTarget, isKnownArrayExpression, resolveArrayBinding, unwrapArrayExpression, } from "../shared/array-method.js";
3
+ const ARRAY_COPY_METHODS = ["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"];
3
4
  function enclosingReducer(node) {
4
5
  let parent = node.parent;
5
6
  while (parent !== null) {
@@ -68,6 +69,10 @@ export const noReduceAccumulatorCopyRule = defineRule({
68
69
  const method = arrayMethodTarget(node.callee);
69
70
  if (method === null)
70
71
  return;
72
+ // Only these methods can copy an accumulator; skip the parent walk for every other call.
73
+ if (method.name !== "assign" && method.name !== "from" &&
74
+ !ARRAY_COPY_METHODS.includes(method.name))
75
+ return;
71
76
  const reducer = enclosingReducer(node);
72
77
  if (reducer === null)
73
78
  return;
@@ -85,7 +90,7 @@ export const noReduceAccumulatorCopyRule = defineRule({
85
90
  const source = node.arguments[0];
86
91
  copiesAccumulator = source !== undefined && isAccumulator(source);
87
92
  }
88
- else if (["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"].includes(method.name)) {
93
+ else if (ARRAY_COPY_METHODS.includes(method.name)) {
89
94
  const initialValue = reducer.initialValue;
90
95
  const arrayAccumulator = initialValue !== undefined &&
91
96
  isKnownArrayExpression(context.sourceCode, initialValue);
@@ -13,6 +13,8 @@ export const noReflectApplyRule = defineRule({
13
13
  },
14
14
  createOnce(context) {
15
15
  return {
16
+ // Every report calls through the `Reflect` identifier.
17
+ before: () => context.sourceCode.text.includes("Reflect"),
16
18
  CallExpression(node) {
17
19
  if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression")
18
20
  return;
@@ -13,6 +13,8 @@ export const noReflectGetRule = defineRule({
13
13
  },
14
14
  createOnce(context) {
15
15
  return {
16
+ // Every report calls through the `Reflect` identifier.
17
+ before: () => context.sourceCode.text.includes("Reflect"),
16
18
  CallExpression(node) {
17
19
  if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression")
18
20
  return;
@@ -47,6 +47,8 @@ export const noRuntimeTypeofRule = defineRule({
47
47
  },
48
48
  createOnce(context) {
49
49
  return {
50
+ // Every report is a `typeof` operator, so a file without the keyword cannot report.
51
+ before: () => context.sourceCode.text.includes("typeof"),
50
52
  UnaryExpression(node) {
51
53
  const option = context.options?.[0];
52
54
  const allowInTypeGuards = typeof option === "object" &&
@@ -1,5 +1,6 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
2
  const FORBIDDEN_SYMBOL_NAME = "shape";
3
+ const FORBIDDEN_SYMBOL_TEXT = /shape/i;
3
4
  function containsForbiddenSymbolName(name) {
4
5
  return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME);
5
6
  }
@@ -32,6 +33,8 @@ export const noForbiddenTermInSymbolNamesRule = defineRule({
32
33
  });
33
34
  };
34
35
  return {
36
+ // Every symbol name is source text, so a file without the term cannot report.
37
+ before: () => FORBIDDEN_SYMBOL_TEXT.test(context.sourceCode.text),
35
38
  Identifier: reportForbiddenSymbolName,
36
39
  PrivateIdentifier: reportForbiddenSymbolName,
37
40
  JSXIdentifier: reportForbiddenSymbolName,
@@ -41,7 +41,7 @@ export const noUnknownReturnsRule = defineRule({
41
41
  };
42
42
  return {
43
43
  Program(node) {
44
- environment = createTypeAliasEnvironment(node, context.sourceCode.visitorKeys);
44
+ environment = createTypeAliasEnvironment(node, context.sourceCode.visitorKeys, context.sourceCode.text);
45
45
  },
46
46
  ArrowFunctionExpression: checkReturnType,
47
47
  FunctionDeclaration: checkReturnType,
@@ -24,7 +24,7 @@ export const noUnknownTypeAliasesRule = defineRule({
24
24
  });
25
25
  return {
26
26
  Program(node) {
27
- environment = createTypeAliasEnvironment(node, context.sourceCode.visitorKeys);
27
+ environment = createTypeAliasEnvironment(node, context.sourceCode.visitorKeys, context.sourceCode.text);
28
28
  },
29
29
  TSTypeAliasDeclaration(node) {
30
30
  if (!resolvesToUnknown(node.typeAnnotation))
@@ -115,7 +115,7 @@ export const noUnsafeDictionaryTypeRule = defineRule({
115
115
  };
116
116
  return {
117
117
  Program(node) {
118
- environment = createTypeEnvironment(node, context.sourceCode.visitorKeys);
118
+ environment = createTypeEnvironment(node, context.sourceCode.visitorKeys, context.sourceCode.text);
119
119
  },
120
120
  TSTypeReference: reportIfUnsafe,
121
121
  TSTypeLiteral: reportIfUnsafe,
@@ -238,11 +238,13 @@ export const noWidenThenAssertRule = defineRule({
238
238
  },
239
239
  },
240
240
  createOnce(context) {
241
- let scopes = [];
241
+ let fileScopes = null;
242
242
  const checkAssertion = (node) => {
243
243
  const expression = assertedExpression(node);
244
244
  if (expression.type !== "Identifier")
245
245
  return;
246
+ // Scope analysis is costly; read it only once a candidate assertion exists.
247
+ const scopes = (fileScopes ??= context.sourceCode.scopeManager.scopes);
246
248
  const variable = resolvedVariableForIdentifier(scopes, expression);
247
249
  if (variable === null)
248
250
  return;
@@ -261,7 +263,7 @@ export const noWidenThenAssertRule = defineRule({
261
263
  };
262
264
  return {
263
265
  Program() {
264
- scopes = context.sourceCode.scopeManager.scopes;
266
+ fileScopes = null;
265
267
  },
266
268
  TSAsExpression: checkAssertion,
267
269
  TSTypeAssertion: checkAssertion,
@@ -12,7 +12,7 @@ export type TypeEnvironment = {
12
12
  readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
13
13
  readonly typeAliases: LexicalTypeAliasEnvironment;
14
14
  };
15
- export declare function createTypeEnvironment(program: ESTree.Program, visitorKeys: Readonly<Record<string, readonly string[]>>): TypeEnvironment;
15
+ export declare function createTypeEnvironment(program: ESTree.Program, visitorKeys: Readonly<Record<string, readonly string[]>>, sourceText: string): TypeEnvironment;
16
16
  export declare function classifyUnsafeDictionaryValue(valueType: ESTree.TSType, environment: TypeEnvironment): UnsafeDictionary | null;
17
17
  export declare function classifyUnsafeDictionary(type: ESTree.TSType, environment: TypeEnvironment): UnsafeDictionary | null;
18
18
  export declare function classifyWideningTarget(type: ESTree.TSType, environment: TypeEnvironment): WideningTarget | null;
@@ -16,7 +16,7 @@ function declaredStatement(statement) {
16
16
  ? (statement.declaration ?? null)
17
17
  : statement;
18
18
  }
19
- export function createTypeEnvironment(program, visitorKeys) {
19
+ export function createTypeEnvironment(program, visitorKeys, sourceText) {
20
20
  const interfaces = new Map();
21
21
  for (const statement of program.body) {
22
22
  const declaration = declaredStatement(statement);
@@ -28,7 +28,7 @@ export function createTypeEnvironment(program, visitorKeys) {
28
28
  }
29
29
  return {
30
30
  interfaces,
31
- typeAliases: createTypeAliasEnvironment(program, visitorKeys),
31
+ typeAliases: createTypeAliasEnvironment(program, visitorKeys, sourceText),
32
32
  };
33
33
  }
34
34
  function typeReferenceName(type) {
@@ -12,8 +12,8 @@ export type TypeAliasEnvironment = {
12
12
  readonly visitorKeys: VisitorKeys;
13
13
  };
14
14
  export type ResolvedTypeMatcher = (type: ESTree.TSType, matches: (child: ESTree.TSType) => boolean) => boolean;
15
- /** Collect every lexical type alias and competing type binding in a program. */
16
- export declare function createTypeAliasEnvironment(program: ESTree.Program, visitorKeys: VisitorKeys): TypeAliasEnvironment;
15
+ /** Collect every lexical type alias and competing type binding in a program, on first use. */
16
+ export declare function createTypeAliasEnvironment(program: ESTree.Program, visitorKeys: VisitorKeys, sourceText: string): TypeAliasEnvironment;
17
17
  /** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
18
18
  export declare function visibleTypeAlias(name: string, use: ESTree.Node, environment: TypeAliasEnvironment): ESTree.TSTypeAliasDeclaration | null;
19
19
  /** Return whether a local declaration shadows a built-in type at this use. */
@@ -37,41 +37,84 @@ function declaredTypeBinding(node) {
37
37
  }
38
38
  return null;
39
39
  }
40
- function collectTypeBindings(node, visitorKeys, bindingsByName, aliases) {
40
+ /**
41
+ * Every node that declares a type binding contains one of these keywords in its own source
42
+ * range. Import specifiers are the exception; they are reached through their ImportDeclaration.
43
+ */
44
+ const BINDING_KEYWORD = /\b(?:class|enum|import|interface|type)\b/g;
45
+ function bindingKeywordOffsets(sourceText) {
46
+ const offsets = [];
47
+ for (const match of sourceText.matchAll(BINDING_KEYWORD))
48
+ offsets.push(match.index);
49
+ return offsets;
50
+ }
51
+ /** Return whether a sorted offset list has an entry in `[start, end)`. */
52
+ function hasOffsetInRange(offsets, start, end) {
53
+ let low = 0;
54
+ let high = offsets.length;
55
+ while (low < high) {
56
+ const middle = (low + high) >>> 1;
57
+ if ((offsets[middle] ?? 0) < start)
58
+ low = middle + 1;
59
+ else
60
+ high = middle;
61
+ }
62
+ return low < offsets.length && (offsets[low] ?? end) < end;
63
+ }
64
+ function collectTypeBindings(node, visitorKeys, offsets, visitAll, collection) {
41
65
  const declared = declaredTypeBinding(node);
42
66
  if (declared !== null) {
43
- const bindings = bindingsByName.get(declared.name) ?? [];
67
+ const bindings = collection.bindingsByName.get(declared.name) ?? [];
44
68
  bindings.push({ ...declared, scope: enclosingTypeScope(node) });
45
- bindingsByName.set(declared.name, bindings);
69
+ collection.bindingsByName.set(declared.name, bindings);
46
70
  if (declared.alias !== null)
47
- aliases.push(declared.alias);
71
+ collection.aliases.push(declared.alias);
48
72
  }
73
+ const visitChildren = visitAll || node.type === "ImportDeclaration";
49
74
  // SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
50
75
  const fields = node;
51
76
  for (const key of visitorKeys[node.type] ?? []) {
52
77
  const value = fields[key];
53
78
  if (isNode(value)) {
54
- collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
79
+ if (visitChildren || hasOffsetInRange(offsets, value.start, value.end)) {
80
+ collectTypeBindings(value, visitorKeys, offsets, visitChildren, collection);
81
+ }
55
82
  continue;
56
83
  }
57
84
  if (!Array.isArray(value))
58
85
  continue;
59
86
  for (const child of value) {
60
- if (isNode(child)) {
61
- collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
87
+ if (isNode(child) &&
88
+ (visitChildren || hasOffsetInRange(offsets, child.start, child.end))) {
89
+ collectTypeBindings(child, visitorKeys, offsets, visitChildren, collection);
62
90
  }
63
91
  }
64
92
  }
65
93
  }
66
- /** Collect every lexical type alias and competing type binding in a program. */
67
- export function createTypeAliasEnvironment(program, visitorKeys) {
94
+ function collectProgramTypeBindings(program, visitorKeys, sourceText) {
95
+ const collection = { aliases: [], bindingsByName: new Map() };
96
+ const offsets = bindingKeywordOffsets(sourceText);
97
+ if (offsets.length > 0) {
98
+ collectTypeBindings(program, visitorKeys, offsets, false, collection);
99
+ }
100
+ return collection;
101
+ }
102
+ /** Collect every lexical type alias and competing type binding in a program, on first use. */
103
+ export function createTypeAliasEnvironment(program, visitorKeys, sourceText) {
68
104
  const cached = environmentsByProgram.get(program);
69
105
  if (cached !== undefined)
70
106
  return cached;
71
- const bindingsByName = new Map();
72
- const aliases = [];
73
- collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
74
- const environment = { aliases, bindingsByName, visitorKeys };
107
+ let collection = null;
108
+ const collected = () => (collection ??= collectProgramTypeBindings(program, visitorKeys, sourceText));
109
+ const environment = {
110
+ get aliases() {
111
+ return collected().aliases;
112
+ },
113
+ get bindingsByName() {
114
+ return collected().bindingsByName;
115
+ },
116
+ visitorKeys,
117
+ };
75
118
  environmentsByProgram.set(program, environment);
76
119
  return environment;
77
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-deslop",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
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",