adamantite 0.42.0 → 0.43.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 +231 -4
- package/dist/index.js +51 -23
- package/dist/presets/analyze.d.ts +6 -2
- package/dist/presets/analyze.js +7 -1
- package/dist/presets/lint/core.d.ts +5 -3
- package/dist/presets/lint/core.js +1 -0
- package/dist/presets/lint/custom.d.ts +60 -0
- package/dist/presets/lint/custom.js +142 -0
- package/dist/presets/lint/effect.d.ts +5 -0
- package/dist/presets/lint/effect.js +106 -0
- package/dist/presets/lint/plugin/imports.d.ts +19 -0
- package/dist/presets/lint/plugin/imports.js +53 -0
- package/dist/presets/lint/plugin/index.d.ts +2 -0
- package/dist/presets/lint/plugin/index.js +6 -2
- package/dist/presets/lint/plugin/options.d.ts +3 -4
- package/dist/presets/lint/plugin/rules/no-query-data-in-state.d.ts +5 -0
- package/dist/presets/lint/plugin/rules/no-query-data-in-state.js +85 -0
- package/dist/presets/lint/plugin/rules/no-react-state-hooks.d.ts +2 -2
- package/dist/presets/lint/plugin/rules/no-react-state-hooks.js +7 -39
- package/dist/presets/lint/plugin/rules/query-from-loader.d.ts +5 -0
- package/dist/presets/lint/plugin/rules/query-from-loader.js +110 -0
- package/dist/presets/lint/tanstack.d.ts +5 -0
- package/dist/presets/lint/tanstack.js +14 -0
- package/dist/presets/rules/index.d.ts +9 -0
- package/dist/presets/rules/index.js +29 -0
- package/package.json +25 -20
- package/skills/adamantite/SKILL.md +70 -4
|
@@ -13,7 +13,9 @@ import { OxlintConfig } from "oxlint";
|
|
|
13
13
|
* })
|
|
14
14
|
* ```
|
|
15
15
|
*/
|
|
16
|
-
declare const ignorePatterns: string[];
|
|
17
|
-
declare const config: OxlintConfig
|
|
16
|
+
export declare const ignorePatterns: string[];
|
|
17
|
+
declare const config: OxlintConfig & {
|
|
18
|
+
ignorePatterns: string[];
|
|
19
|
+
};
|
|
18
20
|
//#endregion
|
|
19
|
-
export { config as default
|
|
21
|
+
export { config as default };
|
|
@@ -308,6 +308,7 @@ const config = {
|
|
|
308
308
|
"typescript/no-extraneous-class": "error",
|
|
309
309
|
"typescript/no-floating-promises": "error",
|
|
310
310
|
"typescript/no-for-in-array": "error",
|
|
311
|
+
"typescript/no-generated-empty-object-type": "error",
|
|
311
312
|
"typescript/no-implied-eval": "error",
|
|
312
313
|
"typescript/no-import-type-side-effects": "error",
|
|
313
314
|
"typescript/no-inferrable-types": "error",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DummyRule, OxlintConfig } from "oxlint";
|
|
2
|
+
//#region presets/lint/custom.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The plugin name that `custom()` uses when no `name` is given.
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_PLUGIN_NAME = "project";
|
|
7
|
+
/**
|
|
8
|
+
* The rule name that a rule file defines: its file name without the extension.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getRuleName(file: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Whether a file in a rules folder is a rule: it has a JavaScript or TypeScript extension, is not a
|
|
13
|
+
* declaration file, and does not start with `_`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function isRuleFile(name: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The rule files in a rules folder. Subfolders are not read, so helpers can also live there.
|
|
18
|
+
*/
|
|
19
|
+
export declare function listRuleFiles(dir: string): string[];
|
|
20
|
+
export interface CustomRulesOptions {
|
|
21
|
+
/**
|
|
22
|
+
* The rules folder. A relative path resolves from the file that calls `custom()`, not from the
|
|
23
|
+
* working directory. Default: `.adamantite/rules`.
|
|
24
|
+
*/
|
|
25
|
+
readonly dir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* The plugin name, which prefixes every rule, as in `project/no-process-env`. Oxlint rejects two
|
|
28
|
+
* plugins with the same name in one run, so give each rules folder in a monorepo its own name.
|
|
29
|
+
* Default: `project`.
|
|
30
|
+
*/
|
|
31
|
+
readonly name?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Severity or options for a rule, by rule name without the plugin prefix. Every rule is enabled
|
|
34
|
+
* as `"error"` by default.
|
|
35
|
+
*/
|
|
36
|
+
readonly rules?: Readonly<Record<string, DummyRule>>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The first file in a stack trace that is not `ownFile`: the module that called into `ownFile`.
|
|
40
|
+
* Undefined when the stack names no such file.
|
|
41
|
+
*/
|
|
42
|
+
export declare function findCallerFile(stack: string, ownFile: string): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Load the custom rules in a rules folder and enable each one as `error`. A rule file
|
|
45
|
+
* `no-process-env.ts` becomes the rule `project/no-process-env`. Files that start with `_` are
|
|
46
|
+
* helpers, not rules.
|
|
47
|
+
*
|
|
48
|
+
* Without a rules folder, or with an empty one, the result is an empty config, so every project can
|
|
49
|
+
* extend it.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { defineConfig } from "oxlint"
|
|
53
|
+
* import core from "adamantite/lint"
|
|
54
|
+
* import custom from "adamantite/lint/custom"
|
|
55
|
+
*
|
|
56
|
+
* export default defineConfig({ extends: [core, custom()] })
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export default function custom(options?: CustomRulesOptions): OxlintConfig;
|
|
60
|
+
//#endregion
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
//#region presets/lint/custom.ts
|
|
7
|
+
const MODULE_EXTENSION = import.meta.url.endsWith(".ts") ? "ts" : "js";
|
|
8
|
+
const RULES_MODULE_URL = new URL(`../rules/index.${MODULE_EXTENSION}`, import.meta.url).href;
|
|
9
|
+
const DEFAULT_DIRECTORY = ".adamantite/rules";
|
|
10
|
+
const CACHE_DIRECTORY = join("node_modules", ".cache", "adamantite");
|
|
11
|
+
/**
|
|
12
|
+
* The plugin name that `custom()` uses when no `name` is given.
|
|
13
|
+
*/
|
|
14
|
+
const DEFAULT_PLUGIN_NAME = "project";
|
|
15
|
+
const RULE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
16
|
+
".js",
|
|
17
|
+
".mjs",
|
|
18
|
+
".mts",
|
|
19
|
+
".ts"
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* The rule name that a rule file defines: its file name without the extension.
|
|
23
|
+
*/
|
|
24
|
+
function getRuleName(file) {
|
|
25
|
+
return file.slice(0, -extname(file).length);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether a file in a rules folder is a rule: it has a JavaScript or TypeScript extension, is not a
|
|
29
|
+
* declaration file, and does not start with `_`.
|
|
30
|
+
*/
|
|
31
|
+
function isRuleFile(name) {
|
|
32
|
+
return RULE_FILE_EXTENSIONS.has(extname(name)) && !name.startsWith("_") && !/\.d\.m?ts$/.test(name);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The rule files in a rules folder. Subfolders are not read, so helpers can also live there.
|
|
36
|
+
*/
|
|
37
|
+
function listRuleFiles(dir) {
|
|
38
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && isRuleFile(entry.name)).map((entry) => entry.name);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a file from a stack frame: drop the query string and hash, which Oxlint adds to bypass
|
|
42
|
+
* the module cache, and convert a file URL to a path.
|
|
43
|
+
*/
|
|
44
|
+
function toFramePath(location) {
|
|
45
|
+
if (location.startsWith("file://")) {
|
|
46
|
+
const url = new URL(location);
|
|
47
|
+
url.search = "";
|
|
48
|
+
url.hash = "";
|
|
49
|
+
return fileURLToPath(url);
|
|
50
|
+
}
|
|
51
|
+
return location.replace(/[?#].*$/, "");
|
|
52
|
+
}
|
|
53
|
+
const OWN_FILE = toFramePath(import.meta.url);
|
|
54
|
+
const FRAME_LOCATION = /(?:\(|at )((?:file:\/\/|\/|[A-Za-z]:[\\/]).+?):\d+:\d+\)?\s*$/;
|
|
55
|
+
/**
|
|
56
|
+
* The first file in a stack trace that is not `ownFile`: the module that called into `ownFile`.
|
|
57
|
+
* Undefined when the stack names no such file.
|
|
58
|
+
*/
|
|
59
|
+
function findCallerFile(stack, ownFile) {
|
|
60
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
61
|
+
const location = FRAME_LOCATION.exec(line)?.[1];
|
|
62
|
+
if (location === void 0) continue;
|
|
63
|
+
const file = toFramePath(location);
|
|
64
|
+
if (file !== ownFile) return file;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function resolveRulesDirectory(dir, stack) {
|
|
68
|
+
if (isAbsolute(dir)) return dir;
|
|
69
|
+
const caller = findCallerFile(stack, OWN_FILE);
|
|
70
|
+
if (caller === void 0) throw new Error(`custom() could not find the file that called it, so it cannot resolve "${dir}". Pass an absolute \`dir\`, such as \`join(import.meta.dirname, "${dir}")\`.`);
|
|
71
|
+
return resolve(dirname(caller), dir);
|
|
72
|
+
}
|
|
73
|
+
function findNodeModules(from) {
|
|
74
|
+
for (let current = from;; current = dirname(current)) {
|
|
75
|
+
if (existsSync(join(current, "node_modules"))) return current;
|
|
76
|
+
if (dirname(current) === current) return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function writeIfChanged(path, content) {
|
|
80
|
+
if (existsSync(path) && readFileSync(path, "utf8") === content) return;
|
|
81
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
82
|
+
writeFileSync(path, content);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Write the entry module for a rules folder and return its file URL. The module goes to
|
|
86
|
+
* `node_modules/.cache/adamantite` above the folder, or to the OS temporary directory when no
|
|
87
|
+
* `node_modules` folder is found or it cannot be written.
|
|
88
|
+
*/
|
|
89
|
+
function writeEntryModule(dir, name) {
|
|
90
|
+
const fileName = `rules-${createHash("sha256").update(`${dir}\0${name}`).digest("hex").slice(0, 16)}.mjs`;
|
|
91
|
+
const content = [
|
|
92
|
+
`import { loadRules } from ${JSON.stringify(RULES_MODULE_URL)}`,
|
|
93
|
+
"",
|
|
94
|
+
`export default await loadRules(${JSON.stringify(dir)}, ${JSON.stringify(name)})`,
|
|
95
|
+
""
|
|
96
|
+
].join("\n");
|
|
97
|
+
const root = findNodeModules(dir);
|
|
98
|
+
const candidates = [...root === void 0 ? [] : [join(root, CACHE_DIRECTORY, fileName)], join(tmpdir(), "adamantite", fileName)];
|
|
99
|
+
const failures = [];
|
|
100
|
+
for (const path of candidates) try {
|
|
101
|
+
writeIfChanged(path, content);
|
|
102
|
+
return pathToFileURL(path).href;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
failures.push(error);
|
|
105
|
+
}
|
|
106
|
+
throw new AggregateError(failures, `custom() could not write the entry module for ${dir}.`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Load the custom rules in a rules folder and enable each one as `error`. A rule file
|
|
110
|
+
* `no-process-env.ts` becomes the rule `project/no-process-env`. Files that start with `_` are
|
|
111
|
+
* helpers, not rules.
|
|
112
|
+
*
|
|
113
|
+
* Without a rules folder, or with an empty one, the result is an empty config, so every project can
|
|
114
|
+
* extend it.
|
|
115
|
+
*
|
|
116
|
+
* ```ts
|
|
117
|
+
* import { defineConfig } from "oxlint"
|
|
118
|
+
* import core from "adamantite/lint"
|
|
119
|
+
* import custom from "adamantite/lint/custom"
|
|
120
|
+
*
|
|
121
|
+
* export default defineConfig({ extends: [core, custom()] })
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
function custom(options = {}) {
|
|
125
|
+
const name = options.name ?? "project";
|
|
126
|
+
const dir = resolveRulesDirectory(options.dir ?? DEFAULT_DIRECTORY, (/* @__PURE__ */ new Error("custom()")).stack ?? "");
|
|
127
|
+
if (!existsSync(dir)) return {};
|
|
128
|
+
const files = listRuleFiles(dir);
|
|
129
|
+
if (files.length === 0) return {};
|
|
130
|
+
const rules = {};
|
|
131
|
+
for (const file of files) rules[`${name}/${getRuleName(file)}`] = "error";
|
|
132
|
+
for (const [rule, setting] of Object.entries(options.rules ?? {})) rules[`${name}/${rule}`] = setting;
|
|
133
|
+
return {
|
|
134
|
+
jsPlugins: [{
|
|
135
|
+
name,
|
|
136
|
+
specifier: writeEntryModule(dir, name)
|
|
137
|
+
}],
|
|
138
|
+
rules
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
export { DEFAULT_PLUGIN_NAME, custom as default, findCallerFile, getRuleName, isRuleFile, listRuleFiles };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
//#region presets/lint/effect.ts
|
|
2
|
+
const config = {
|
|
3
|
+
options: { typeAware: true },
|
|
4
|
+
plugins: ["effecttsgo"],
|
|
5
|
+
rules: {
|
|
6
|
+
"effecttsgo/any-unknown-in-error-context": "error",
|
|
7
|
+
"effecttsgo/class-self-mismatch": "error",
|
|
8
|
+
"effecttsgo/duplicate-package": "error",
|
|
9
|
+
"effecttsgo/effect-fn-implicit-any": "error",
|
|
10
|
+
"effecttsgo/experimental-api-usage": "error",
|
|
11
|
+
"effecttsgo/floating-effect": "error",
|
|
12
|
+
"effecttsgo/floating-effect-in-vitest": "error",
|
|
13
|
+
"effecttsgo/generic-effect-services": "error",
|
|
14
|
+
"effecttsgo/missing-effect-context": "error",
|
|
15
|
+
"effecttsgo/missing-effect-error": "error",
|
|
16
|
+
"effecttsgo/missing-layer-context": "error",
|
|
17
|
+
"effecttsgo/missing-return-yield-star": "error",
|
|
18
|
+
"effecttsgo/missing-star-in-yield-effect-gen": "error",
|
|
19
|
+
"effecttsgo/non-object-effect-service-type": "error",
|
|
20
|
+
"effecttsgo/obsolete-match-import": "error",
|
|
21
|
+
"effecttsgo/obsolete-schema-import": "error",
|
|
22
|
+
"effecttsgo/outdated-api": "error",
|
|
23
|
+
"effecttsgo/overridden-schema-constructor": "error",
|
|
24
|
+
"effecttsgo/promise-in-effect-success": "error",
|
|
25
|
+
"effecttsgo/schema-literal-non-finite": "error",
|
|
26
|
+
"effecttsgo/schema-opaque-instance-member": "error",
|
|
27
|
+
"effecttsgo/unsafe-effect-type-assertion": "error",
|
|
28
|
+
"effecttsgo/unstable-api-usage": "error",
|
|
29
|
+
"effecttsgo/catch-unfailable-effect": "error",
|
|
30
|
+
"effecttsgo/effect-fn-iife": "error",
|
|
31
|
+
"effecttsgo/effect-gen-uses-adapter": "error",
|
|
32
|
+
"effecttsgo/effect-in-failure": "error",
|
|
33
|
+
"effecttsgo/effect-in-void-success": "error",
|
|
34
|
+
"effecttsgo/global-error-in-effect-catch": "error",
|
|
35
|
+
"effecttsgo/global-error-in-effect-failure": "error",
|
|
36
|
+
"effecttsgo/layer-merge-all-with-dependencies": "error",
|
|
37
|
+
"effecttsgo/lazy-effect": "error",
|
|
38
|
+
"effecttsgo/lazy-promise-in-effect-sync": "error",
|
|
39
|
+
"effecttsgo/leaking-requirements": "error",
|
|
40
|
+
"effecttsgo/multiple-effect-provide": "error",
|
|
41
|
+
"effecttsgo/prefer-unsafe-constructor": "error",
|
|
42
|
+
"effecttsgo/return-effect-in-gen": "error",
|
|
43
|
+
"effecttsgo/run-effect-inside-effect": "error",
|
|
44
|
+
"effecttsgo/schema-sync-in-effect": "error",
|
|
45
|
+
"effecttsgo/scope-in-layer-effect": "error",
|
|
46
|
+
"effecttsgo/try-catch-in-effect-gen": "error",
|
|
47
|
+
"effecttsgo/unknown-in-effect-catch": "error",
|
|
48
|
+
"effecttsgo/abort-controller-in-effect": "error",
|
|
49
|
+
"effecttsgo/crypto-random-uuid-in-effect": "error",
|
|
50
|
+
"effecttsgo/global-console-in-effect": "error",
|
|
51
|
+
"effecttsgo/global-date-in-effect": "error",
|
|
52
|
+
"effecttsgo/global-fetch-in-effect": "error",
|
|
53
|
+
"effecttsgo/global-random-in-effect": "error",
|
|
54
|
+
"effecttsgo/global-timers-in-effect": "error",
|
|
55
|
+
"effecttsgo/instance-of-schema": "error",
|
|
56
|
+
"effecttsgo/process-env-in-effect": "error",
|
|
57
|
+
"effecttsgo/acquire-release-disposable": "error",
|
|
58
|
+
"effecttsgo/all-of-map-to-for-each": "error",
|
|
59
|
+
"effecttsgo/catch-all-tag-dispatch-to-catch-tag": "error",
|
|
60
|
+
"effecttsgo/catch-all-to-map-error": "error",
|
|
61
|
+
"effecttsgo/catch-chain-to-first-success-of": "error",
|
|
62
|
+
"effecttsgo/catch-conditional-refail-to-catch-if": "error",
|
|
63
|
+
"effecttsgo/catch-die-to-or-die": "error",
|
|
64
|
+
"effecttsgo/catch-if-tag-to-catch-tag": "error",
|
|
65
|
+
"effecttsgo/catch-refail-to-tap-error": "error",
|
|
66
|
+
"effecttsgo/catch-tag-to-catch-reason": "error",
|
|
67
|
+
"effecttsgo/catch-to-ignore": "error",
|
|
68
|
+
"effecttsgo/catch-to-or-else-succeed": "error",
|
|
69
|
+
"effecttsgo/effect-do-notation": "error",
|
|
70
|
+
"effecttsgo/effect-fn-opportunity": "error",
|
|
71
|
+
"effecttsgo/effect-map-flatten": "error",
|
|
72
|
+
"effecttsgo/effect-map-void": "error",
|
|
73
|
+
"effecttsgo/effect-succeed-with-void": "error",
|
|
74
|
+
"effecttsgo/flat-map-conditional-to-filter-or-fail": "error",
|
|
75
|
+
"effecttsgo/flat-map-ignored-param-to-and-then": "error",
|
|
76
|
+
"effecttsgo/flat-map-to-map": "error",
|
|
77
|
+
"effecttsgo/map-some-to-as-some": "error",
|
|
78
|
+
"effecttsgo/match-effect-to-map-both": "error",
|
|
79
|
+
"effecttsgo/match-effect-to-match": "error",
|
|
80
|
+
"effecttsgo/multiple-catch-tag": "error",
|
|
81
|
+
"effecttsgo/nested-effect-gen-yield": "error",
|
|
82
|
+
"effecttsgo/option-match-to-from-option": "error",
|
|
83
|
+
"effecttsgo/prefer-schema-type-property": "error",
|
|
84
|
+
"effecttsgo/prefer-succeed-some-or-none": "error",
|
|
85
|
+
"effecttsgo/prefer-typed-schema-decoder": "error",
|
|
86
|
+
"effecttsgo/provide-layer-succeed-to-provide-service": "error",
|
|
87
|
+
"effecttsgo/race-first-with-sleep-to-timeout": "error",
|
|
88
|
+
"effecttsgo/redundant-map-error": "error",
|
|
89
|
+
"effecttsgo/redundant-or-die": "error",
|
|
90
|
+
"effecttsgo/redundant-schema-tag-identifier": "error",
|
|
91
|
+
"effecttsgo/run-of-exit-to-run-exit": "error",
|
|
92
|
+
"effecttsgo/schema-number": "error",
|
|
93
|
+
"effecttsgo/schema-struct-with-tag": "error",
|
|
94
|
+
"effecttsgo/schema-union-of-literals": "error",
|
|
95
|
+
"effecttsgo/service-not-as-class": "error",
|
|
96
|
+
"effecttsgo/sync-to-succeed": "error",
|
|
97
|
+
"effecttsgo/timeout-catch-tag-to-timeout-or-else": "error",
|
|
98
|
+
"effecttsgo/unnecessary-effect-gen": "error",
|
|
99
|
+
"effecttsgo/unnecessary-fail-yieldable-error": "error",
|
|
100
|
+
"effecttsgo/unnecessary-pipe": "error",
|
|
101
|
+
"effecttsgo/unnecessary-pipe-chain": "error",
|
|
102
|
+
"effecttsgo/unnecessary-typeof-type": "error"
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
//#endregion
|
|
106
|
+
export { config as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Context, ESTree } from "@oxlint/plugins";
|
|
2
|
+
//#region presets/lint/plugin/imports.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A call of a module export, such as `useState(0)` or `React.useState(0)`.
|
|
5
|
+
*/
|
|
6
|
+
export interface ImportedCall {
|
|
7
|
+
readonly call: ESTree.CallExpression;
|
|
8
|
+
/**
|
|
9
|
+
* The export name, which stays the same when the import is aliased.
|
|
10
|
+
*/
|
|
11
|
+
readonly name: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Find the calls of the exports in `names` that `node` imports: direct calls through a named
|
|
15
|
+
* import, including aliased ones, and member calls such as `React.useState(0)` through a default or
|
|
16
|
+
* namespace import. Type-only imports give no calls.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findImportedCalls(context: Context, node: ESTree.ImportDeclaration, names: ReadonlySet<string>): ImportedCall[];
|
|
19
|
+
//#endregion
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
//#region presets/lint/plugin/imports.ts
|
|
2
|
+
function getImportedName(specifier) {
|
|
3
|
+
return specifier.imported.type === "Literal" ? specifier.imported.value : specifier.imported.name;
|
|
4
|
+
}
|
|
5
|
+
function isStringLiteral(node) {
|
|
6
|
+
return node.type === "Literal" && typeof node.value === "string";
|
|
7
|
+
}
|
|
8
|
+
function getPropertyName(member) {
|
|
9
|
+
if (!member.computed) return member.property.type === "Identifier" ? member.property.name : void 0;
|
|
10
|
+
return isStringLiteral(member.property) ? member.property.value : void 0;
|
|
11
|
+
}
|
|
12
|
+
function getCall(node) {
|
|
13
|
+
const { parent } = node;
|
|
14
|
+
return parent?.type === "CallExpression" && parent.callee === node ? parent : void 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Find the calls of the exports in `names` that `node` imports: direct calls through a named
|
|
18
|
+
* import, including aliased ones, and member calls such as `React.useState(0)` through a default or
|
|
19
|
+
* namespace import. Type-only imports give no calls.
|
|
20
|
+
*/
|
|
21
|
+
function findImportedCalls(context, node, names) {
|
|
22
|
+
if (node.importKind === "type") return [];
|
|
23
|
+
const calls = [];
|
|
24
|
+
for (const specifier of node.specifiers) {
|
|
25
|
+
if (specifier.type === "ImportSpecifier" && specifier.importKind === "type") continue;
|
|
26
|
+
const identifiers = context.sourceCode.getDeclaredVariables(specifier).flatMap((variable) => variable.references).map((reference) => reference.identifier);
|
|
27
|
+
if (specifier.type === "ImportSpecifier") {
|
|
28
|
+
const name = getImportedName(specifier);
|
|
29
|
+
if (!names.has(name)) continue;
|
|
30
|
+
for (const identifier of identifiers) {
|
|
31
|
+
const call = getCall(identifier);
|
|
32
|
+
if (call !== void 0) calls.push({
|
|
33
|
+
call,
|
|
34
|
+
name
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (const identifier of identifiers) {
|
|
40
|
+
const member = identifier.parent;
|
|
41
|
+
if (member.type !== "MemberExpression" || member.object !== identifier) continue;
|
|
42
|
+
const name = getPropertyName(member);
|
|
43
|
+
const call = getCall(member);
|
|
44
|
+
if (name !== void 0 && names.has(name) && call !== void 0) calls.push({
|
|
45
|
+
call,
|
|
46
|
+
name
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return calls;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
53
|
+
export { findImportedCalls };
|
|
@@ -3,7 +3,9 @@ import { Plugin, Rule } from "@oxlint/plugins";
|
|
|
3
3
|
interface AdamantitePlugin extends Plugin {
|
|
4
4
|
readonly rules: {
|
|
5
5
|
readonly "no-overzealous-destructuring": Rule;
|
|
6
|
+
readonly "no-query-data-in-state": Rule;
|
|
6
7
|
readonly "no-react-state-hooks": Rule;
|
|
8
|
+
readonly "query-from-loader": Rule;
|
|
7
9
|
};
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import rule from "./rules/no-overzealous-destructuring.js";
|
|
2
|
-
import rule$1 from "./rules/no-
|
|
2
|
+
import rule$1 from "./rules/no-query-data-in-state.js";
|
|
3
|
+
import rule$2 from "./rules/no-react-state-hooks.js";
|
|
4
|
+
import rule$3 from "./rules/query-from-loader.js";
|
|
3
5
|
//#region presets/lint/plugin/index.ts
|
|
4
6
|
/**
|
|
5
7
|
* Adamantite's first-party Oxlint plugin. Presets load it by file URL, so target projects install
|
|
@@ -9,7 +11,9 @@ const plugin = {
|
|
|
9
11
|
meta: { name: "adamantite" },
|
|
10
12
|
rules: {
|
|
11
13
|
"no-overzealous-destructuring": rule,
|
|
12
|
-
"no-
|
|
14
|
+
"no-query-data-in-state": rule$1,
|
|
15
|
+
"no-react-state-hooks": rule$2,
|
|
16
|
+
"query-from-loader": rule$3
|
|
13
17
|
}
|
|
14
18
|
};
|
|
15
19
|
//#endregion
|
|
@@ -3,10 +3,9 @@ import { Options } from "@oxlint/plugins";
|
|
|
3
3
|
/**
|
|
4
4
|
* Read a string array from the rule's first option object.
|
|
5
5
|
*/
|
|
6
|
-
declare function readStringArrayOption(options: Readonly<Options>, key: string): string[] | undefined;
|
|
6
|
+
export declare function readStringArrayOption(options: Readonly<Options>, key: string): string[] | undefined;
|
|
7
7
|
/**
|
|
8
8
|
* Read a positive integer from the rule's first option object.
|
|
9
9
|
*/
|
|
10
|
-
declare function readPositiveIntegerOption(options: Readonly<Options>, key: string): number | undefined;
|
|
11
|
-
//#endregion
|
|
12
|
-
export { readPositiveIntegerOption, readStringArrayOption };
|
|
10
|
+
export declare function readPositiveIntegerOption(options: Readonly<Options>, key: string): number | undefined;
|
|
11
|
+
//#endregion
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { findImportedCalls } from "../imports.js";
|
|
2
|
+
//#region presets/lint/plugin/rules/no-query-data-in-state.ts
|
|
3
|
+
const QUERY_MODULE = "@tanstack/react-query";
|
|
4
|
+
const REACT_MODULE = "react";
|
|
5
|
+
const QUERY_HOOKS = /* @__PURE__ */ new Set([
|
|
6
|
+
"useInfiniteQuery",
|
|
7
|
+
"useQuery",
|
|
8
|
+
"useSuspenseInfiniteQuery",
|
|
9
|
+
"useSuspenseQuery"
|
|
10
|
+
]);
|
|
11
|
+
const STATE_HOOKS = /* @__PURE__ */ new Set(["useState"]);
|
|
12
|
+
function isDataKey(property) {
|
|
13
|
+
return !property.computed && property.key.type === "Identifier" && property.key.name === "data";
|
|
14
|
+
}
|
|
15
|
+
function getReferences(context, declarator, name) {
|
|
16
|
+
return context.sourceCode.getDeclaredVariables(declarator).filter((variable) => variable.name === name).flatMap((variable) => variable.references).map((reference) => reference.identifier);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The expressions that read the data of the query result that `call` returns: `query.data` for
|
|
20
|
+
* `const query = useQuery(...)`, and each `data` read for `const { data } = useQuery(...)`.
|
|
21
|
+
*/
|
|
22
|
+
function findDataReads(context, call) {
|
|
23
|
+
const declarator = call.parent;
|
|
24
|
+
if (declarator.type !== "VariableDeclarator" || declarator.init !== call) return [];
|
|
25
|
+
const { id } = declarator;
|
|
26
|
+
if (id.type === "Identifier") return getReferences(context, declarator, id.name).flatMap((identifier) => {
|
|
27
|
+
const member = identifier.parent;
|
|
28
|
+
return member.type === "MemberExpression" && member.object === identifier && !member.computed && member.property.name === "data" ? [member] : [];
|
|
29
|
+
});
|
|
30
|
+
if (id.type !== "ObjectPattern") return [];
|
|
31
|
+
return id.properties.flatMap((property) => {
|
|
32
|
+
if (property.type !== "Property" || !isDataKey(property)) return [];
|
|
33
|
+
const binding = property.value.type === "AssignmentPattern" ? property.value.left : property.value;
|
|
34
|
+
return binding.type === "Identifier" ? getReferences(context, declarator, binding.name) : [];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Whether `node` evaluates to query data or to a value inside it, such as `query.data`,
|
|
39
|
+
* `data.user.name`, `data ?? []`, or `() => data`.
|
|
40
|
+
*/
|
|
41
|
+
function readsData(node, dataReads) {
|
|
42
|
+
if (dataReads.has(node)) return true;
|
|
43
|
+
switch (node.type) {
|
|
44
|
+
case "ArrowFunctionExpression": return node.expression && node.body.type !== "BlockStatement" && readsData(node.body, dataReads);
|
|
45
|
+
case "ChainExpression":
|
|
46
|
+
case "TSAsExpression":
|
|
47
|
+
case "TSNonNullExpression":
|
|
48
|
+
case "TSSatisfiesExpression": return readsData(node.expression, dataReads);
|
|
49
|
+
case "LogicalExpression": return readsData(node.left, dataReads) || readsData(node.right, dataReads);
|
|
50
|
+
case "MemberExpression": return readsData(node.object, dataReads);
|
|
51
|
+
default: return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const rule = {
|
|
55
|
+
create(context) {
|
|
56
|
+
const queryCalls = [];
|
|
57
|
+
const stateCalls = [];
|
|
58
|
+
return {
|
|
59
|
+
ImportDeclaration(node) {
|
|
60
|
+
if (node.source.value === QUERY_MODULE) queryCalls.push(...findImportedCalls(context, node, QUERY_HOOKS));
|
|
61
|
+
else if (node.source.value === REACT_MODULE) stateCalls.push(...findImportedCalls(context, node, STATE_HOOKS));
|
|
62
|
+
},
|
|
63
|
+
"Program:exit"() {
|
|
64
|
+
if (queryCalls.length === 0 || stateCalls.length === 0) return;
|
|
65
|
+
const dataReads = new Set(queryCalls.flatMap(({ call }) => findDataReads(context, call)));
|
|
66
|
+
for (const { call, name } of stateCalls) {
|
|
67
|
+
const [initial] = call.arguments;
|
|
68
|
+
if (initial !== void 0 && readsData(initial, dataReads)) context.report({
|
|
69
|
+
data: { hook: name },
|
|
70
|
+
messageId: "copiedData",
|
|
71
|
+
node: call
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
meta: {
|
|
78
|
+
docs: { description: "Disallow copying TanStack Query data into React state, where the copy stops updating when the query refetches." },
|
|
79
|
+
messages: { copiedData: "`{{hook}}` copies query data into local state, so the copy stops updating when the query refetches or another component updates the cache. Read the value from the query result where you use it. To edit it, keep only the user's changes in state and merge them with the query data during render." },
|
|
80
|
+
schema: [],
|
|
81
|
+
type: "problem"
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
//#endregion
|
|
85
|
+
export { rule as default };
|
|
@@ -4,7 +4,7 @@ import { Rule } from "@oxlint/plugins";
|
|
|
4
4
|
* Convert a glob to a regular expression that matches a whole path with forward slashes. Supports
|
|
5
5
|
* `**`, `*`, `?`, `[a-z]` and `[!a-z]` character classes, and `{a,b}` alternation.
|
|
6
6
|
*/
|
|
7
|
-
declare function globToRegExp(glob: string): RegExp;
|
|
7
|
+
export declare function globToRegExp(glob: string): RegExp;
|
|
8
8
|
declare const rule: Rule;
|
|
9
9
|
//#endregion
|
|
10
|
-
export { rule as default
|
|
10
|
+
export { rule as default };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findImportedCalls } from "../imports.js";
|
|
1
2
|
import { readStringArrayOption } from "../options.js";
|
|
2
3
|
//#region presets/lint/plugin/rules/no-react-state-hooks.ts
|
|
3
4
|
const HOOK_MESSAGES = /* @__PURE__ */ new Map([
|
|
@@ -78,52 +79,19 @@ function getMatchPath(context) {
|
|
|
78
79
|
const cwd = toPosixPath(context.cwd).replace(/\/$/, "");
|
|
79
80
|
return filename.startsWith(`${cwd}/`) ? filename.slice(cwd.length + 1) : filename;
|
|
80
81
|
}
|
|
81
|
-
function getImportedName(specifier) {
|
|
82
|
-
return specifier.imported.type === "Literal" ? specifier.imported.value : specifier.imported.name;
|
|
83
|
-
}
|
|
84
|
-
function isStringLiteral(node) {
|
|
85
|
-
return node.type === "Literal" && typeof node.value === "string";
|
|
86
|
-
}
|
|
87
|
-
function getPropertyName(member) {
|
|
88
|
-
if (!member.computed) return member.property.type === "Identifier" ? member.property.name : void 0;
|
|
89
|
-
return isStringLiteral(member.property) ? member.property.value : void 0;
|
|
90
|
-
}
|
|
91
|
-
function isCallee(node) {
|
|
92
|
-
const { parent } = node;
|
|
93
|
-
return parent?.type === "CallExpression" && parent.callee === node;
|
|
94
|
-
}
|
|
95
82
|
const rule = {
|
|
96
83
|
create(context) {
|
|
97
84
|
const hooks = new Set(readStringArrayOption(context.options, "hooks") ?? DEFAULT_HOOKS);
|
|
98
85
|
const allow = (readStringArrayOption(context.options, "allow") ?? DEFAULT_ALLOW).map((glob) => globToRegExp(glob));
|
|
99
86
|
const matchPath = getMatchPath(context);
|
|
100
87
|
if (allow.some((pattern) => pattern.test(matchPath))) return {};
|
|
101
|
-
function report(node, hook) {
|
|
102
|
-
context.report({
|
|
103
|
-
data: { hook },
|
|
104
|
-
messageId: HOOK_MESSAGES.get(hook) ?? "banned",
|
|
105
|
-
node
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
function getReferences(specifier) {
|
|
109
|
-
return context.sourceCode.getDeclaredVariables(specifier).flatMap((variable) => variable.references).map((reference) => reference.identifier);
|
|
110
|
-
}
|
|
111
88
|
return { ImportDeclaration(node) {
|
|
112
|
-
if (node.source.value !== REACT_MODULE
|
|
113
|
-
for (const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
for (const identifier of getReferences(specifier)) {
|
|
121
|
-
const member = identifier.parent;
|
|
122
|
-
if (member.type !== "MemberExpression" || member.object !== identifier) continue;
|
|
123
|
-
const hook = getPropertyName(member);
|
|
124
|
-
if (hook !== void 0 && hooks.has(hook) && isCallee(member)) report(member.parent, hook);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
89
|
+
if (node.source.value !== REACT_MODULE) return;
|
|
90
|
+
for (const { call, name } of findImportedCalls(context, node, hooks)) context.report({
|
|
91
|
+
data: { hook: name },
|
|
92
|
+
messageId: HOOK_MESSAGES.get(name) ?? "banned",
|
|
93
|
+
node: call
|
|
94
|
+
});
|
|
127
95
|
} };
|
|
128
96
|
},
|
|
129
97
|
meta: {
|