adamantite 0.42.0 → 0.43.1

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.
@@ -0,0 +1,110 @@
1
+ import { findImportedCalls } from "../imports.js";
2
+ //#region presets/lint/plugin/rules/query-from-loader.ts
3
+ const QUERY_MODULE = "@tanstack/react-query";
4
+ const ROUTER_MODULE = "@tanstack/react-router";
5
+ const QUERY_HOOKS = /* @__PURE__ */ new Set([
6
+ "useInfiniteQuery",
7
+ "useQuery",
8
+ "useSuspenseInfiniteQuery",
9
+ "useSuspenseQuery"
10
+ ]);
11
+ const INFINITE_QUERY_HOOKS = /* @__PURE__ */ new Set(["useInfiniteQuery", "useSuspenseInfiniteQuery"]);
12
+ const OPTIONS_FACTORIES = /* @__PURE__ */ new Set(["infiniteQueryOptions", "queryOptions"]);
13
+ /**
14
+ * A file that calls one of these defines a route, in file-based or code-based routing. Only route
15
+ * files have a loader that can preload a query, so the rule checks only them.
16
+ */
17
+ const ROUTE_FACTORIES = /* @__PURE__ */ new Set([
18
+ "createFileRoute",
19
+ "createLazyFileRoute",
20
+ "createLazyRoute",
21
+ "createRootRoute",
22
+ "createRootRouteWithContext",
23
+ "createRoute"
24
+ ]);
25
+ function findVariable(context, identifier) {
26
+ let scope = context.sourceCode.getScope(identifier);
27
+ while (scope !== null && !scope.set.has(identifier.name)) scope = scope.upper;
28
+ return scope?.set.get(identifier.name);
29
+ }
30
+ /**
31
+ * The initializer of a `const` that declares `variable` with a plain identifier, such as `const
32
+ * options = { ... }`. Destructured, `let`, and imported bindings have none.
33
+ */
34
+ function getConstInit(variable) {
35
+ const [definition] = variable.defs;
36
+ if (variable.defs.length !== 1 || definition?.type !== "Variable") return;
37
+ const { node, parent } = definition;
38
+ if (node.type !== "VariableDeclarator" || node.id.type !== "Identifier" || parent?.type !== "VariableDeclaration" || parent.kind !== "const") return;
39
+ return node.init ?? void 0;
40
+ }
41
+ function isModuleScope(variable) {
42
+ return variable.scope.type === "module" || variable.scope.type === "global";
43
+ }
44
+ const rule = {
45
+ create(context) {
46
+ const hookCalls = [];
47
+ const factoryCalls = /* @__PURE__ */ new Set();
48
+ let isRouteFile = false;
49
+ /**
50
+ * Why `options` does not come from a shared definition, or `undefined` when it does or when the
51
+ * rule cannot tell. `inModule` is true when the expression sits at module scope.
52
+ */
53
+ function classify(options, inModule) {
54
+ if (options.type === "TSAsExpression" || options.type === "TSNonNullExpression" || options.type === "TSSatisfiesExpression" || options.type === "TSTypeAssertion") return classify(options.expression, inModule);
55
+ if (options.type === "ObjectExpression") return options.properties.some((property) => property.type === "SpreadElement") ? void 0 : "plainObject";
56
+ if (options.type === "CallExpression" && factoryCalls.has(options)) return inModule ? void 0 : "localOptions";
57
+ if (options.type === "Identifier") {
58
+ const variable = findVariable(context, options);
59
+ const init = variable === void 0 ? void 0 : getConstInit(variable);
60
+ return variable === void 0 || init === void 0 ? void 0 : classify(init, isModuleScope(variable));
61
+ }
62
+ }
63
+ return {
64
+ ImportDeclaration(node) {
65
+ if (node.source.value === ROUTER_MODULE) {
66
+ isRouteFile ||= findImportedCalls(context, node, ROUTE_FACTORIES).length > 0;
67
+ return;
68
+ }
69
+ if (node.source.value !== QUERY_MODULE) return;
70
+ hookCalls.push(...findImportedCalls(context, node, QUERY_HOOKS));
71
+ for (const { call } of findImportedCalls(context, node, OPTIONS_FACTORIES)) factoryCalls.add(call);
72
+ },
73
+ "Program:exit"() {
74
+ if (!isRouteFile) return;
75
+ for (const { call, name } of hookCalls) {
76
+ const [options] = call.arguments;
77
+ if (options === void 0) continue;
78
+ const messageId = classify(options, false);
79
+ if (messageId !== void 0) {
80
+ const data = INFINITE_QUERY_HOOKS.has(name) ? {
81
+ factory: "infiniteQueryOptions",
82
+ hook: name,
83
+ preload: "ensureInfiniteQueryData"
84
+ } : {
85
+ factory: "queryOptions",
86
+ hook: name,
87
+ preload: "ensureQueryData"
88
+ };
89
+ context.report({
90
+ data,
91
+ messageId,
92
+ node: options
93
+ });
94
+ }
95
+ }
96
+ }
97
+ };
98
+ },
99
+ meta: {
100
+ docs: { description: "Require TanStack Query hooks in route files to take options defined once with `queryOptions()` or `infiniteQueryOptions()`, so the route loader can preload the same query." },
101
+ messages: {
102
+ localOptions: "`{{hook}}` takes query options created inside a function, so the route loader cannot preload the same query. Move the `{{factory}}()` call to module scope, or into an exported factory such as `userQuery(id)`, preload it in this route's loader with `queryClient.{{preload}}()`, and pass the same options here.",
103
+ plainObject: "`{{hook}}` takes a plain options object instead of options defined with `{{factory}}()`. Define the options once with `{{factory}}()` at module scope or in a shared module, preload them in this route's loader with `queryClient.{{preload}}()`, and pass the same options here."
104
+ },
105
+ schema: [],
106
+ type: "suggestion"
107
+ }
108
+ };
109
+ //#endregion
110
+ export { rule as default };
@@ -0,0 +1,5 @@
1
+ import { OxlintConfig } from "oxlint";
2
+ //#region presets/lint/tanstack.d.ts
3
+ declare const config: OxlintConfig;
4
+ //#endregion
5
+ export { config as default };
@@ -0,0 +1,14 @@
1
+ //#region presets/lint/tanstack.ts
2
+ const PLUGIN_EXTENSION = import.meta.url.endsWith(".ts") ? "ts" : "js";
3
+ const config = {
4
+ jsPlugins: [{
5
+ name: "adamantite",
6
+ specifier: new URL(`plugin/index.${PLUGIN_EXTENSION}`, import.meta.url).href
7
+ }],
8
+ rules: {
9
+ "adamantite/no-query-data-in-state": "error",
10
+ "adamantite/query-from-loader": "error"
11
+ }
12
+ };
13
+ //#endregion
14
+ export { config as default };
@@ -15,28 +15,50 @@ const config = {
15
15
  "vitest/no-disabled-tests": "error",
16
16
  "vitest/no-duplicate-hooks": "error",
17
17
  "vitest/no-focused-tests": "error",
18
- "vitest/no-hooks": "error",
18
+ "vitest/no-hooks": "off",
19
19
  "vitest/no-identical-title": "error",
20
20
  "vitest/no-import-node-test": "error",
21
- "vitest/no-importing-vitest-globals": "error",
21
+ "vitest/no-importing-vitest-globals": "off",
22
22
  "vitest/no-interpolation-in-snapshots": "error",
23
23
  "vitest/no-large-snapshots": "error",
24
24
  "vitest/no-mocks-import": "error",
25
25
  "vitest/no-restricted-matchers": "error",
26
- "vitest/no-standalone-expect": "error",
26
+ "vitest/no-standalone-expect": ["error", { additionalTestBlockFunctions: [
27
+ "it.effect",
28
+ "it.effect.each",
29
+ "it.effect.fails",
30
+ "it.effect.only",
31
+ "it.effect.prop",
32
+ "it.effect.runIf",
33
+ "it.effect.skip",
34
+ "it.effect.skipIf",
35
+ "it.live",
36
+ "it.live.each",
37
+ "it.live.fails",
38
+ "it.live.only",
39
+ "it.live.prop",
40
+ "it.live.runIf",
41
+ "it.live.skip",
42
+ "it.live.skipIf",
43
+ "it.prop"
44
+ ] }],
27
45
  "vitest/no-test-prefixes": "error",
28
46
  "vitest/no-test-return-statement": "error",
29
47
  "vitest/no-unneeded-async-expect-function": "error",
30
48
  "vitest/padding-around-test-blocks": "error",
31
49
  "vitest/prefer-called-exactly-once-with": "error",
32
50
  "vitest/prefer-called-once": "error",
33
- "vitest/prefer-called-times": "error",
51
+ "vitest/prefer-called-times": "off",
34
52
  "vitest/prefer-called-with": "error",
35
53
  "vitest/prefer-comparison-matcher": "error",
36
- "vitest/prefer-describe-function-title": "error",
54
+ "vitest/prefer-describe-function-title": "off",
37
55
  "vitest/prefer-each": "error",
38
56
  "vitest/prefer-equality-matcher": "error",
39
- "vitest/prefer-expect-assertions": "error",
57
+ "vitest/prefer-expect-assertions": ["error", {
58
+ onlyFunctionsWithAsyncKeyword: true,
59
+ onlyFunctionsWithExpectInCallback: true,
60
+ onlyFunctionsWithExpectInLoop: true
61
+ }],
40
62
  "vitest/prefer-expect-resolves": "error",
41
63
  "vitest/prefer-expect-type-of": "error",
42
64
  "vitest/prefer-hooks-in-order": "error",
@@ -0,0 +1,9 @@
1
+ import { Context, Diagnostic, ESTree, Fix, Fixer, Node, Options, Plugin, Rule, RuleMeta, SourceCode, Visitor, VisitorWithHooks, defineRule } from "@oxlint/plugins";
2
+ //#region presets/rules/index.d.ts
3
+ /**
4
+ * Import every rule file in a rules folder and return them as one Oxlint plugin. The entry module
5
+ * that `custom()` writes calls this function.
6
+ */
7
+ export declare function loadRules(dir: string, name?: string): Promise<Plugin>;
8
+ //#endregion
9
+ export { type Context, type Diagnostic, type ESTree, type Fix, type Fixer, type Node, type Options, type Rule, type RuleMeta, type SourceCode, type Visitor, type VisitorWithHooks, defineRule };
@@ -0,0 +1,29 @@
1
+ import { DEFAULT_PLUGIN_NAME, getRuleName, listRuleFiles } from "../lint/custom.js";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { defineRule } from "@oxlint/plugins";
5
+ //#region presets/rules/index.ts
6
+ async function importRule(path) {
7
+ let module;
8
+ try {
9
+ module = await import(pathToFileURL(path).href);
10
+ } catch (error) {
11
+ const reason = error instanceof Error ? error.message : String(error);
12
+ throw new Error(`Custom rule ${path} failed to load: ${reason}\nRule files run with type stripping, so use erasable TypeScript only: no enum, namespace, or parameter properties.`, { cause: error });
13
+ }
14
+ if (module.default === void 0) throw new Error(`Custom rule ${path} has no default export. Export the rule as default.`);
15
+ return module.default;
16
+ }
17
+ /**
18
+ * Import every rule file in a rules folder and return them as one Oxlint plugin. The entry module
19
+ * that `custom()` writes calls this function.
20
+ */
21
+ async function loadRules(dir, name = DEFAULT_PLUGIN_NAME) {
22
+ const rules = await Promise.all(listRuleFiles(dir).map(async (file) => [getRuleName(file), await importRule(join(dir, file))]));
23
+ return {
24
+ meta: { name },
25
+ rules: Object.fromEntries(rules)
26
+ };
27
+ }
28
+ //#endregion
29
+ export { defineRule, loadRules };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adamantite",
3
- "version": "0.42.0",
3
+ "version": "0.43.1",
4
4
  "description": "An strict and opinionated set of presets for modern TypeScript applications",
5
5
  "keywords": [
6
6
  "adamantite",
@@ -54,40 +54,46 @@
54
54
  "types": "./dist/presets/lint/*.d.ts",
55
55
  "default": "./dist/presets/lint/*.js"
56
56
  },
57
+ "./rules": {
58
+ "types": "./dist/presets/rules/index.d.ts",
59
+ "default": "./dist/presets/rules/index.js"
60
+ },
57
61
  "./typescript": "./dist/presets/tsconfig.json"
58
62
  },
59
63
  "dependencies": {
60
- "@clack/prompts": "1.7.0",
64
+ "@clack/prompts": "1.8.1",
61
65
  "@effect/platform-node": "4.0.0",
66
+ "@effect/platform-node-shared": "4.0.0",
67
+ "@oxlint/plugins": "1.86.0",
62
68
  "defu": "6.1.7",
63
69
  "effect": "4.0.0",
64
70
  "jsonc-parser": "3.3.1",
65
- "nypm": "0.6.9",
66
- "oxc-parser": "0.150.0"
71
+ "nypm": "0.6.10",
72
+ "oxc-parser": "0.152.0"
67
73
  },
68
74
  "devDependencies": {
69
- "@changesets/changelog-github": "1.0.0",
70
- "@changesets/cli": "3.0.1",
75
+ "@changesets/changelog-github": "1.0.1",
76
+ "@changesets/cli": "3.0.3",
77
+ "@effect/tsgo": "0.48.0",
71
78
  "@effect/vitest": "4.0.0",
72
- "@oxlint/plugins": "1.83.0",
73
- "@shadcn/lint": "0.1.5",
74
- "@tanstack/intent": "0.3.7",
79
+ "@shadcn/lint": "0.2.0",
80
+ "@tanstack/intent": "0.5.0",
75
81
  "@types/figlet": "1.7.0",
76
- "@types/node": "22.20.1",
77
- "@vitest/coverage-v8": "5.0.1",
78
- "@vitest/ui": "5.0.1",
79
- "figlet": "1.11.4",
80
- "knip": "6.32.2",
81
- "oxfmt": "0.65.0",
82
- "oxlint": "1.83.0",
82
+ "@types/node": "22.20.5",
83
+ "@vitest/coverage-v8": "5.0.3",
84
+ "@vitest/ui": "5.0.3",
85
+ "figlet": "1.12.0",
86
+ "knip": "6.39.0",
87
+ "oxfmt": "0.71.0",
88
+ "oxlint": "1.86.0",
83
89
  "oxlint-plugin-react-doctor": "0.9.14",
84
- "oxlint-tsgolint": "7.0.2001",
90
+ "oxlint-tsgolint": "7.0.2003",
85
91
  "sherif": "1.13.0",
86
- "tsdown": "0.22.14",
87
- "type-fest": "5.8.0",
92
+ "tsdown": "0.23.0",
93
+ "type-fest": "5.10.0",
88
94
  "typescript": "7.0.2",
89
95
  "unplugin-macros": "0.23.2",
90
- "vitest": "5.0.1"
96
+ "vitest": "5.0.3"
91
97
  },
92
98
  "peerDependencies": {
93
99
  "typescript": ">=7"
@@ -99,6 +105,7 @@
99
105
  "scripts": {
100
106
  "analyze": "knip",
101
107
  "build": "tsdown",
108
+ "build:verify": "pnpm run build && pnpm run test:build",
102
109
  "bump:deps": "pnpm update --interactive --latest",
103
110
  "check": "oxlint",
104
111
  "dev": "tsdown --watch",
@@ -8,6 +8,7 @@ metadata:
8
8
  sources:
9
9
  - "adelrodriguez/adamantite:src/cli.ts"
10
10
  - "adelrodriguez/adamantite:src/commands/*.ts"
11
+ - "adelrodriguez/adamantite:src/lib/workspace/custom-rules.ts"
11
12
  ---
12
13
 
13
14
  # Adamantite
@@ -28,8 +29,8 @@ npx adamantite init --non-interactive --script check --script fix --typescript -
28
29
  Repeat `--script`, `--preset`, and `--editor` for multiple values. Available values are:
29
30
 
30
31
  - Scripts: `check`, `fix`, `analyze`. In a detected monorepo, `analyze` also installs Sherif.
31
- - Presets: `react`, `react-strict`, `react-doctor`, `nextjs`, `vue`, `jest`, `vitest`, `node`,
32
- `strict`, `antislop`, `shadcn`;
32
+ - Presets: `react`, `react-strict`, `react-doctor`, `tanstack`, `nextjs`, `vue`, `effect`, `jest`,
33
+ `vitest`, `node`, `strict`, `antislop`, `shadcn`;
33
34
  editors: `vscode`, `zed`
34
35
  - Optional flags: `--typescript`, `--install-extensions`, `--github-actions`, `--agents`,
35
36
  `--overwrite-scripts`
@@ -46,6 +47,17 @@ properties; keep the object and read its members, or split the pattern. Do not r
46
47
  `maxDepth` or `maxProperties` only to silence a report. The preset also bans type assertions
47
48
  outside tests.
48
49
 
50
+ The `tanstack` preset needs no extra package. Its `adamantite/query-from-loader` rule reports
51
+ TanStack Query hooks in route files (files that call `createFileRoute`, `createRoute`, or another
52
+ route factory from `@tanstack/react-router`) that take a plain options object or a `queryOptions()` call made inside a
53
+ function; define the query once with `queryOptions()` at module scope or in an exported factory,
54
+ preload it in the route loader with `ensureQueryData()`, and pass the same options to the hook. For
55
+ `useInfiniteQuery` and `useSuspenseInfiniteQuery`, use `infiniteQueryOptions()` and
56
+ `ensureInfiniteQueryData()`. Its
57
+ `adamantite/no-query-data-in-state` rule reports `useState` initialized from query data; read the
58
+ value from the query result and keep only the user's edits in state. Do not wrap options in a
59
+ spread object, or move the hook out of the route file, only to silence `query-from-loader`.
60
+
49
61
  The `react-doctor` preset requires the `react` preset and the `oxlint-plugin-react-doctor`
50
62
  package, which `init` installs at a pinned version and doctor and `update` keep in sync. It
51
63
  enables curated React Doctor rules for React state and effect misuse, such as
@@ -54,6 +66,18 @@ a modified MIT license: using it as machine learning training data or selling it
54
66
  hosted product needs written permission from the copyright holder. Tell the user before you
55
67
  select the preset.
56
68
 
69
+ The `effect` preset needs the `@effect/tsgo` package, which `init` installs at a pinned
70
+ version and doctor and `update` keep in sync. Its rules exist only in Oxlint binaries that
71
+ `adamantite prepare` patched, so the `prepare` script in `package.json` must run
72
+ `adamantite prepare`; without the patch, Oxlint stops with `Unknown plugin: 'effecttsgo'`.
73
+ Do not remove the patch or the preset to get past that error: run `adamantite prepare`. If a
74
+ production-only install fails at `adamantite prepare`, add `--ignore-scripts` to that
75
+ install instead of changing the script. `tsconfig.json` needs
76
+ `{ "name": "@effect/language-service", "diagnostics": false }` in `compilerOptions.plugins`,
77
+ and `knip.config.ts` needs `ignoreDependencies.effect`. To allow an unstable Effect module
78
+ on purpose, add it to `allowedUnstableApis` in that tsconfig entry. Do not turn off
79
+ `effecttsgo/unstable-api-usage`.
80
+
57
81
  The `shadcn` preset needs Tailwind v4 and the `@shadcn/lint` package, which `init` installs
58
82
  at a pinned version and doctor and `update` keep in sync. The plugin finds components and
59
83
  the theme through `components.json`. Without that file it looks in `components/ui` or
@@ -133,8 +157,48 @@ adamantite update
133
157
  adamantite doctor
134
158
  ```
135
159
 
136
- `update` updates Adamantite-managed dependencies. It then reports any remaining doctor
137
- findings. Follow those findings and review the resulting diff.
160
+ `update` updates Adamantite-managed dependencies and, with the effect preset, patches Oxlint
161
+ and TypeScript again. It then reports any remaining doctor findings. Follow those findings and review the resulting diff.
162
+
163
+ ## Custom rules
164
+
165
+ <!-- Keep this section the same as the AGENTS.md text in src/lib/workspace/custom-rules.ts. -->
166
+
167
+ Each file in the rules folder, `.adamantite/rules/` by default, is an Oxlint rule that this
168
+ project owns. `custom()` from `adamantite/lint/custom` loads the folder and enables each
169
+ rule as `error` under the `project` plugin: `no-process-env.ts` becomes
170
+ `project/no-process-env`. Add a file to add a rule. Delete the file to remove the rule.
171
+ `adamantite rule add <name>` writes a correct stub.
172
+
173
+ - A file that starts with `_`, such as `_helpers.ts`, is a helper, not a rule. Subfolders are
174
+ not read.
175
+ - Export the rule as default with `defineRule` from `adamantite/rules`. The same module exports
176
+ the `Context`, `Rule`, `Visitor`, and `ESTree` node types.
177
+ - Use `createOnce`. It runs one time for each lint run, not one time for each file. Reset
178
+ per-file state in `before`, and report collected results in `after`. `context.filename`,
179
+ `context.sourceCode`, and `context.settings` are not available in the body of
180
+ `createOnce`. Read them in `before` or in a visitor.
181
+ - Write erasable TypeScript only. Oxlint loads rule files with Node.js type stripping, so
182
+ `enum`, a `namespace` with values, parameter properties, and `import x = require()` stop
183
+ the lint run.
184
+ - Rules get no type information. Use only the AST and `context.sourceCode`.
185
+ - Report with a `messageId`, and put each message in `meta.messages`. Describe the options in
186
+ `meta.schema`, and read them from `context.options`.
187
+ - A rule that throws prints an error for each file, and the lint run still exits 0. After you
188
+ change a rule, run the lint and read all of its output.
189
+ - To find node names, run `adamantite rule ast <file>` on real code. It prints the same
190
+ ESTree-shaped AST that rules visit.
191
+ - To test a rule, use `RuleTester` from `oxlint/plugins-dev`. Adamantite does not run rule
192
+ tests.
193
+ - Oxlint caches the rules for each run. Restart the editor language server after you change a
194
+ rule.
195
+ - TypeScript does not check `.adamantite/rules/` by default, because its name starts with a dot. Add
196
+ `".adamantite/rules"` to `include` in `tsconfig.json` to check it.
197
+ - In a monorepo, give each rules folder its own plugin name, such as `custom({ name: "web" })`:
198
+ Oxlint rejects two plugins with the same name in one run. A relative `dir` resolves from the
199
+ file that calls `custom()`, so a shared tooling package can own the rules with
200
+ `custom({ dir: "rules", name: "acme" })`. A nested Oxlint config replaces the root config, so
201
+ a package that needs the root rules extends the root config.
138
202
 
139
203
  ## Decision guide
140
204
 
@@ -143,4 +207,6 @@ findings. Follow those findings and review the resulting diff.
143
207
  - Existing project upgrading Adamantite: `update`, then the doctor sequence.
144
208
  - Code-quality failure: choose `check` or `analyze` based on the failing subsystem; do not
145
209
  reinitialize the project.
210
+ - Project-specific lint rule: add a custom rule with `adamantite rule add <name>`; do not
211
+ edit Adamantite presets.
146
212
  - Unknown option or behavior: run `adamantite <command> --help` before guessing.