@agjs/tsforge 0.1.15 → 0.1.17
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/package.json +1 -1
- package/src/cli.ts +39 -1
- package/src/inference/inference.types.ts +20 -0
- package/src/inference/openai-compatible.ts +11 -34
- package/src/inference/request.ts +148 -0
- package/src/loop/rule-docs.generated.json +10 -0
- package/src/models-config.ts +13 -0
- package/src/rule-packs/react-component-architecture/index.ts +6 -0
- package/src/rule-packs/react-component-architecture/rules/no-jsx-computation.ts +110 -0
- package/src/rule-packs/react-component-architecture/rules/no-state-in-component-body.ts +114 -0
- package/src/rule-packs/react-component-architecture/utils.ts +53 -0
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./loop";
|
|
12
12
|
import {
|
|
13
13
|
PROVIDER_LIMITS,
|
|
14
|
+
PROVIDER_DEFAULTS,
|
|
14
15
|
OpenAICompatibleProvider,
|
|
15
16
|
type IOpenAICompatibleConfig,
|
|
16
17
|
} from "./inference";
|
|
@@ -261,7 +262,12 @@ async function detectContextWindow(
|
|
|
261
262
|
|
|
262
263
|
const entries = data.data.filter(isRecord);
|
|
263
264
|
const match = entries.find((e) => e.id === entry.model) ?? entries[0];
|
|
264
|
-
|
|
265
|
+
// vLLM uses `max_model_len`; other servers expose `context_window` or
|
|
266
|
+
// `max_position_embeddings` — accept whichever is present.
|
|
267
|
+
const len =
|
|
268
|
+
match?.max_model_len ??
|
|
269
|
+
match?.context_window ??
|
|
270
|
+
match?.max_position_embeddings;
|
|
265
271
|
|
|
266
272
|
return typeof len === "number" && Number.isFinite(len) ? len : undefined;
|
|
267
273
|
} catch {
|
|
@@ -328,6 +334,16 @@ export function providerConfig(entry: IModelEntry): IOpenAICompatibleConfig {
|
|
|
328
334
|
// instead of emitting tool calls (→ no files written). The StreamGuard is
|
|
329
335
|
// the targeted loop protection. Opt in only to experiment.
|
|
330
336
|
...(repetitionPenalty === undefined ? {} : { repetitionPenalty }),
|
|
337
|
+
// Provider dialect + escape hatches — passed straight through so any
|
|
338
|
+
// OpenAI-ish endpoint (DeepSeek, OpenAI o-series, custom gateways) works.
|
|
339
|
+
...(entry.reasoning === undefined ? {} : { reasoning: entry.reasoning }),
|
|
340
|
+
...(entry.reasoningEffort === undefined
|
|
341
|
+
? {}
|
|
342
|
+
: { reasoningEffort: entry.reasoningEffort }),
|
|
343
|
+
...(entry.extraBody === undefined ? {} : { extraBody: entry.extraBody }),
|
|
344
|
+
...(entry.extraHeaders === undefined
|
|
345
|
+
? {}
|
|
346
|
+
: { extraHeaders: entry.extraHeaders }),
|
|
331
347
|
};
|
|
332
348
|
}
|
|
333
349
|
|
|
@@ -335,6 +351,26 @@ function makeProvider(entry: IModelEntry): OpenAICompatibleProvider {
|
|
|
335
351
|
return new OpenAICompatibleProvider(providerConfig(entry));
|
|
336
352
|
}
|
|
337
353
|
|
|
354
|
+
/** Catch the common footgun: a cloud baseUrl paired with the leftover qwen
|
|
355
|
+
* default `model`, which then 400s ("model not supported") on that host. */
|
|
356
|
+
function warnDefaultModelOnRemote(entry: IModelEntry): void {
|
|
357
|
+
let host: string;
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
host = new URL(entry.baseUrl).hostname;
|
|
361
|
+
} catch {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const remote = host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
|
|
366
|
+
|
|
367
|
+
if (remote && entry.model === PROVIDER_DEFAULTS.model) {
|
|
368
|
+
process.stdout.write(
|
|
369
|
+
` ⚠ models.json: model is still "${PROVIDER_DEFAULTS.model}" (the default) but baseUrl is ${host} — set the entry's "model" to a name that host supports.\n`
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
338
374
|
/** Print the model registry with ★ on the active one (the `/model` listing). */
|
|
339
375
|
async function listModels(
|
|
340
376
|
provider: OpenAICompatibleProvider,
|
|
@@ -784,6 +820,8 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
784
820
|
const provider = makeProvider(activeModel.entry);
|
|
785
821
|
let activeName = activeModel.name;
|
|
786
822
|
|
|
823
|
+
warnDefaultModelOnRemote(activeModel.entry);
|
|
824
|
+
|
|
787
825
|
// Best-effort cleanup of stale sessions on every launch.
|
|
788
826
|
await pruneSessions();
|
|
789
827
|
|
|
@@ -118,6 +118,26 @@ export interface IOpenAICompatibleConfig {
|
|
|
118
118
|
* correctness. Omitted (1.0 = off) by default; set it on code-gen providers.
|
|
119
119
|
*/
|
|
120
120
|
repetitionPenalty?: number;
|
|
121
|
+
/**
|
|
122
|
+
* How this provider wants reasoning/thinking expressed on the wire:
|
|
123
|
+
* - `qwen` (default): `chat_template_kwargs.enable_thinking` + `thinking_token_budget` (vLLM).
|
|
124
|
+
* - `deepseek`: top-level `thinking: { type }` + `reasoning_effort`; never sends
|
|
125
|
+
* `tool_choice: "required"` (DeepSeek's thinking mode rejects it).
|
|
126
|
+
* - `openai`: `reasoning_effort`; uses `max_completion_tokens` and omits `temperature` (o-series).
|
|
127
|
+
* - `none`: no reasoning fields.
|
|
128
|
+
*/
|
|
129
|
+
reasoning?: ReasoningStyle;
|
|
130
|
+
/** Reasoning effort for `deepseek`/`openai` styles (maps to `reasoning_effort`). */
|
|
131
|
+
reasoningEffort?: "low" | "medium" | "high";
|
|
132
|
+
/** Arbitrary fields merged into the request body LAST (override anything above) —
|
|
133
|
+
* the escape hatch for any provider-specific param. */
|
|
134
|
+
extraBody?: Record<string, unknown>;
|
|
135
|
+
/** Arbitrary request headers (e.g. Azure `api-key`, Anthropic `x-api-key`).
|
|
136
|
+
* `${VAR}` in values is interpolated from the environment. */
|
|
137
|
+
extraHeaders?: Record<string, string>;
|
|
121
138
|
/** Injectable for tests; defaults to global fetch. */
|
|
122
139
|
fetch?: typeof fetch;
|
|
123
140
|
}
|
|
141
|
+
|
|
142
|
+
/** Provider reasoning-param dialect. */
|
|
143
|
+
export type ReasoningStyle = "qwen" | "deepseek" | "openai" | "none";
|
|
@@ -7,8 +7,13 @@ import type {
|
|
|
7
7
|
} from "./inference.types";
|
|
8
8
|
import { PROVIDER_LIMITS } from "./inference.constants";
|
|
9
9
|
import { fetchWithRetry } from "./transport";
|
|
10
|
-
import {
|
|
10
|
+
import { parseResponse } from "./wire";
|
|
11
11
|
import { streamResponse } from "./stream";
|
|
12
|
+
import {
|
|
13
|
+
buildRequestBody,
|
|
14
|
+
buildRequestHeaders,
|
|
15
|
+
chatCompletionsUrl,
|
|
16
|
+
} from "./request";
|
|
12
17
|
|
|
13
18
|
export { salvageToolCalls } from "./wire";
|
|
14
19
|
|
|
@@ -40,38 +45,10 @@ export class OpenAICompatibleProvider implements IProvider {
|
|
|
40
45
|
): Promise<IModelResponse> {
|
|
41
46
|
const doFetch = this.cfg.fetch ?? fetch;
|
|
42
47
|
const streaming = opts.onToken !== undefined;
|
|
43
|
-
const headers
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (this.cfg.apiKey !== undefined) {
|
|
48
|
-
headers.authorization = `Bearer ${this.cfg.apiKey}`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const body = JSON.stringify({
|
|
52
|
-
model: this.cfg.model,
|
|
53
|
-
messages: messages.map(toWire),
|
|
54
|
-
max_tokens: this.cfg.maxTokens ?? PROVIDER_LIMITS.maxTokens,
|
|
55
|
-
temperature: opts.temperature,
|
|
56
|
-
...(this.cfg.repetitionPenalty === undefined
|
|
57
|
-
? {}
|
|
58
|
-
: { repetition_penalty: this.cfg.repetitionPenalty }),
|
|
59
|
-
...(opts.tools === undefined
|
|
60
|
-
? {}
|
|
61
|
-
: { tools: opts.tools, tool_choice: opts.toolChoice ?? "auto" }),
|
|
62
|
-
...(opts.enableThinking === undefined
|
|
63
|
-
? {}
|
|
64
|
-
: { chat_template_kwargs: { enable_thinking: opts.enableThinking } }),
|
|
65
|
-
...(opts.thinkingTokenBudget === undefined
|
|
66
|
-
? {}
|
|
67
|
-
: { thinking_token_budget: opts.thinkingTokenBudget }),
|
|
68
|
-
// include_usage → the stream emits a final chunk carrying token `usage`
|
|
69
|
-
// (otherwise a streamed response reports none). Non-stream replies carry it
|
|
70
|
-
// by default.
|
|
71
|
-
...(streaming
|
|
72
|
-
? { stream: true, stream_options: { include_usage: true } }
|
|
73
|
-
: {}),
|
|
74
|
-
});
|
|
48
|
+
const headers = buildRequestHeaders(this.cfg);
|
|
49
|
+
const body = JSON.stringify(
|
|
50
|
+
buildRequestBody(this.cfg, messages, opts, streaming)
|
|
51
|
+
);
|
|
75
52
|
|
|
76
53
|
// Retry transient CONNECTION blips (socket close / unable-to-connect) — the
|
|
77
54
|
// connect happens before any stream starts, so retrying is safe for both
|
|
@@ -79,7 +56,7 @@ export class OpenAICompatibleProvider implements IProvider {
|
|
|
79
56
|
// a network hiccup from wrecking an eval run.
|
|
80
57
|
const res = await fetchWithRetry(
|
|
81
58
|
doFetch,
|
|
82
|
-
|
|
59
|
+
chatCompletionsUrl(this.cfg.baseUrl),
|
|
83
60
|
headers,
|
|
84
61
|
body,
|
|
85
62
|
this.cfg.timeoutMs ?? PROVIDER_LIMITS.requestTimeoutMs,
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IChatMessage,
|
|
3
|
+
ICompleteOptions,
|
|
4
|
+
IOpenAICompatibleConfig,
|
|
5
|
+
ReasoningStyle,
|
|
6
|
+
} from "./inference.types";
|
|
7
|
+
import { PROVIDER_LIMITS } from "./inference.constants";
|
|
8
|
+
import { toWire } from "./wire";
|
|
9
|
+
|
|
10
|
+
/** Interpolate `${VAR}` references from `env` into a string (missing → ""). */
|
|
11
|
+
function interpolateEnv(
|
|
12
|
+
value: string,
|
|
13
|
+
env: Readonly<Record<string, string | undefined>>
|
|
14
|
+
): string {
|
|
15
|
+
return value.replace(
|
|
16
|
+
/\$\{([A-Za-z0-9_]+)\}/g,
|
|
17
|
+
(_m: string, name: string) => env[name] ?? ""
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function style(cfg: IOpenAICompatibleConfig): ReasoningStyle {
|
|
22
|
+
return cfg.reasoning ?? "qwen";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Provider-specific reasoning/thinking fields for the request body. */
|
|
26
|
+
function reasoningFields(
|
|
27
|
+
cfg: IOpenAICompatibleConfig,
|
|
28
|
+
opts: ICompleteOptions
|
|
29
|
+
): Record<string, unknown> {
|
|
30
|
+
switch (style(cfg)) {
|
|
31
|
+
case "qwen":
|
|
32
|
+
return {
|
|
33
|
+
...(opts.enableThinking === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { chat_template_kwargs: { enable_thinking: opts.enableThinking } }),
|
|
36
|
+
...(opts.thinkingTokenBudget === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: { thinking_token_budget: opts.thinkingTokenBudget }),
|
|
39
|
+
};
|
|
40
|
+
case "deepseek":
|
|
41
|
+
return {
|
|
42
|
+
...(opts.enableThinking === undefined
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
thinking: {
|
|
46
|
+
type: opts.enableThinking ? "enabled" : "disabled",
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
...(cfg.reasoningEffort === undefined
|
|
50
|
+
? {}
|
|
51
|
+
: { reasoning_effort: cfg.reasoningEffort }),
|
|
52
|
+
};
|
|
53
|
+
case "openai":
|
|
54
|
+
return cfg.reasoningEffort === undefined
|
|
55
|
+
? {}
|
|
56
|
+
: { reasoning_effort: cfg.reasoningEffort };
|
|
57
|
+
case "none":
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The output-token cap field — o-series renamed `max_tokens` → `max_completion_tokens`. */
|
|
63
|
+
function tokenCapField(cfg: IOpenAICompatibleConfig): Record<string, number> {
|
|
64
|
+
const max = cfg.maxTokens ?? PROVIDER_LIMITS.maxTokens;
|
|
65
|
+
|
|
66
|
+
return style(cfg) === "openai"
|
|
67
|
+
? { max_completion_tokens: max }
|
|
68
|
+
: { max_tokens: max };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The `tools` (+ `tool_choice`) request fields, with provider constraints
|
|
72
|
+
* applied: DeepSeek's thinking mode rejects an explicit `tool_choice`, so omit
|
|
73
|
+
* it entirely there (the model still gets the tools and decides). */
|
|
74
|
+
function toolsBlock(
|
|
75
|
+
cfg: IOpenAICompatibleConfig,
|
|
76
|
+
opts: ICompleteOptions
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
if (opts.tools === undefined) {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (style(cfg) === "deepseek") {
|
|
83
|
+
return { tools: opts.tools };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { tools: opts.tools, tool_choice: opts.toolChoice ?? "auto" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build the request body object (pure). Field order keeps the qwen default
|
|
90
|
+
* byte-for-byte identical; `extraBody` is merged last so it can override
|
|
91
|
+
* anything for a fully custom provider. */
|
|
92
|
+
export function buildRequestBody(
|
|
93
|
+
cfg: IOpenAICompatibleConfig,
|
|
94
|
+
messages: IChatMessage[],
|
|
95
|
+
opts: ICompleteOptions,
|
|
96
|
+
streaming: boolean
|
|
97
|
+
): Record<string, unknown> {
|
|
98
|
+
// o-series rejects `temperature` entirely; everywhere else send it only when set.
|
|
99
|
+
const omitTemperature =
|
|
100
|
+
style(cfg) === "openai" || opts.temperature === undefined;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
model: cfg.model,
|
|
104
|
+
messages: messages.map(toWire),
|
|
105
|
+
...tokenCapField(cfg),
|
|
106
|
+
...(omitTemperature ? {} : { temperature: opts.temperature }),
|
|
107
|
+
...(cfg.repetitionPenalty === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: { repetition_penalty: cfg.repetitionPenalty }),
|
|
110
|
+
...toolsBlock(cfg, opts),
|
|
111
|
+
...reasoningFields(cfg, opts),
|
|
112
|
+
...(streaming
|
|
113
|
+
? { stream: true, stream_options: { include_usage: true } }
|
|
114
|
+
: {}),
|
|
115
|
+
...(cfg.extraBody ?? {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Build request headers: JSON + Bearer auth (when a key is set) + any
|
|
120
|
+
* `extraHeaders` (with `${VAR}` interpolation), which can override the defaults. */
|
|
121
|
+
export function buildRequestHeaders(
|
|
122
|
+
cfg: IOpenAICompatibleConfig,
|
|
123
|
+
env: Readonly<Record<string, string | undefined>> = process.env
|
|
124
|
+
): Record<string, string> {
|
|
125
|
+
const headers: Record<string, string> = {
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (cfg.apiKey !== undefined) {
|
|
130
|
+
headers.authorization = `Bearer ${cfg.apiKey}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const [key, value] of Object.entries(cfg.extraHeaders ?? {})) {
|
|
134
|
+
headers[key] = interpolateEnv(value, env);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return headers;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Normalize the chat-completions URL: trim trailing slashes and don't
|
|
141
|
+
* double-append when the baseUrl already ends with the path. */
|
|
142
|
+
export function chatCompletionsUrl(baseUrl: string): string {
|
|
143
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
144
|
+
|
|
145
|
+
return trimmed.endsWith("/chat/completions")
|
|
146
|
+
? trimmed
|
|
147
|
+
: `${trimmed}/chat/completions`;
|
|
148
|
+
}
|
|
@@ -359,6 +359,16 @@
|
|
|
359
359
|
"bad": "// Example that violates the rule",
|
|
360
360
|
"good": "// Corrected version"
|
|
361
361
|
},
|
|
362
|
+
"tsforge/no-jsx-computation": {
|
|
363
|
+
"what": "Move complex computations out of JSX into hooks or helper functions",
|
|
364
|
+
"bad": "// Example that violates the rule",
|
|
365
|
+
"good": "// Corrected version"
|
|
366
|
+
},
|
|
367
|
+
"tsforge/no-state-in-component-body": {
|
|
368
|
+
"what": "State hooks must be in .hooks.ts files, not directly in components",
|
|
369
|
+
"bad": "// Example that violates the rule",
|
|
370
|
+
"good": "// Corrected version"
|
|
371
|
+
},
|
|
362
372
|
"tsforge/mask-pii-fields": {
|
|
363
373
|
"what": "Disallow unmasked PII (email, phone, password, token, ...) in structured-logger payloads — the #1 way data leaks quietly.",
|
|
364
374
|
"bad": "// Example that violates the rule",
|
package/src/models-config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
|
|
4
4
|
import { isRecord } from "./lib/guards";
|
|
5
5
|
import { PROVIDER_DEFAULTS } from "./inference/inference.constants";
|
|
6
|
+
import type { ReasoningStyle } from "./inference/inference.types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The model registry — `~/.tsforge/models.json`, the central place a user
|
|
@@ -28,6 +29,18 @@ export interface IModelEntry {
|
|
|
28
29
|
thinking?: boolean;
|
|
29
30
|
/** Per-response token cap override. */
|
|
30
31
|
maxTokens?: number;
|
|
32
|
+
/** Provider reasoning dialect: how thinking/reasoning is expressed on the wire.
|
|
33
|
+
* `qwen` (default) | `deepseek` | `openai` | `none`. Set `deepseek` for the
|
|
34
|
+
* DeepSeek API, `openai` for OpenAI o-series. */
|
|
35
|
+
reasoning?: ReasoningStyle;
|
|
36
|
+
/** Reasoning effort for `deepseek`/`openai` styles. */
|
|
37
|
+
reasoningEffort?: "low" | "medium" | "high";
|
|
38
|
+
/** Arbitrary fields merged into the request body (override built-ins) — the
|
|
39
|
+
* escape hatch for any provider-specific param. */
|
|
40
|
+
extraBody?: Record<string, unknown>;
|
|
41
|
+
/** Arbitrary request headers (e.g. a non-Bearer auth scheme); `${VAR}` values
|
|
42
|
+
* are interpolated from the environment. */
|
|
43
|
+
extraHeaders?: Record<string, string>;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
46
|
export interface IModelsConfig {
|
|
@@ -6,6 +6,8 @@ import { indexMustReexportDefaultRule } from "./rules/index-must-reexport-defaul
|
|
|
6
6
|
import { maxHooksPerFileRule } from "./rules/max-hooks-per-file";
|
|
7
7
|
import { noCrossFeatureImportsRule } from "./rules/no-cross-feature-imports";
|
|
8
8
|
import { noInlineJsxFunctionsRule } from "./rules/no-inline-jsx-functions";
|
|
9
|
+
import { noJsxComputationRule } from "./rules/no-jsx-computation";
|
|
10
|
+
import { noStateInComponentBodyRule } from "./rules/no-state-in-component-body";
|
|
9
11
|
import type { IRulePack } from "../rule-packs.types";
|
|
10
12
|
|
|
11
13
|
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
@@ -15,6 +17,8 @@ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
|
15
17
|
"max-hooks-per-file": maxHooksPerFileRule,
|
|
16
18
|
"no-cross-feature-imports": noCrossFeatureImportsRule,
|
|
17
19
|
"no-inline-jsx-functions": noInlineJsxFunctionsRule,
|
|
20
|
+
"no-jsx-computation": noJsxComputationRule,
|
|
21
|
+
"no-state-in-component-body": noStateInComponentBodyRule,
|
|
18
22
|
};
|
|
19
23
|
|
|
20
24
|
export const reactComponentArchitecturePack: IRulePack = {
|
|
@@ -29,6 +33,8 @@ export const reactComponentArchitecturePack: IRulePack = {
|
|
|
29
33
|
"max-hooks-per-file": "warn",
|
|
30
34
|
"no-cross-feature-imports": "error",
|
|
31
35
|
"no-inline-jsx-functions": "warn",
|
|
36
|
+
"no-jsx-computation": "error",
|
|
37
|
+
"no-state-in-component-body": "error",
|
|
32
38
|
},
|
|
33
39
|
};
|
|
34
40
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
3
|
+
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
import { isStoryFile } from "../utils";
|
|
6
|
+
|
|
7
|
+
export const RULE_NAME = "no-jsx-computation";
|
|
8
|
+
|
|
9
|
+
export interface INoJsxComputationOptions {
|
|
10
|
+
readonly allowSimpleTernary?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type RuleOptions = [INoJsxComputationOptions];
|
|
14
|
+
type MessageIds = "noComputation" | "noChainedLogic";
|
|
15
|
+
|
|
16
|
+
const ARRAY_METHODS = ["map", "filter", "reduce", "sort", "find"];
|
|
17
|
+
const ARITHMETIC_OPERATORS = ["+", "-", "*", "/"];
|
|
18
|
+
|
|
19
|
+
const optionSchema: JSONSchema4 = {
|
|
20
|
+
type: "object",
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
properties: {
|
|
23
|
+
allowSimpleTernary: {
|
|
24
|
+
type: "boolean",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const noJsxComputationRule = createRule<RuleOptions, MessageIds>({
|
|
30
|
+
name: RULE_NAME,
|
|
31
|
+
meta: {
|
|
32
|
+
type: "suggestion",
|
|
33
|
+
docs: {
|
|
34
|
+
description:
|
|
35
|
+
"Move complex computations out of JSX into hooks or helper functions",
|
|
36
|
+
},
|
|
37
|
+
schema: [optionSchema],
|
|
38
|
+
messages: {
|
|
39
|
+
noComputation: "Extract this computation into a hook or helper function",
|
|
40
|
+
noChainedLogic:
|
|
41
|
+
"Complex logical expressions should be extracted into variables or hooks",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
defaultOptions: [{ allowSimpleTernary: true }],
|
|
45
|
+
create(context, [options]) {
|
|
46
|
+
const filename = context.filename;
|
|
47
|
+
|
|
48
|
+
if (isStoryFile(filename)) {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const allowSimpleTernary = options.allowSimpleTernary ?? true;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"JSXExpressionContainer > CallExpression"(node: TSESTree.CallExpression) {
|
|
56
|
+
if (node.callee.type === AST_NODE_TYPES.MemberExpression) {
|
|
57
|
+
const prop = node.callee.property;
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
prop.type === AST_NODE_TYPES.Identifier &&
|
|
61
|
+
ARRAY_METHODS.includes(prop.name)
|
|
62
|
+
) {
|
|
63
|
+
context.report({
|
|
64
|
+
node,
|
|
65
|
+
messageId: "noComputation",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"JSXExpressionContainer > ConditionalExpression"(
|
|
71
|
+
node: TSESTree.ConditionalExpression
|
|
72
|
+
) {
|
|
73
|
+
if (!allowSimpleTernary) {
|
|
74
|
+
context.report({
|
|
75
|
+
node,
|
|
76
|
+
messageId: "noComputation",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
"JSXExpressionContainer > LogicalExpression"(
|
|
81
|
+
node: TSESTree.LogicalExpression
|
|
82
|
+
) {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let current: TSESTree.Node = node;
|
|
85
|
+
|
|
86
|
+
while (current.type === AST_NODE_TYPES.LogicalExpression) {
|
|
87
|
+
depth += 1;
|
|
88
|
+
current = current.left;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (depth > 1) {
|
|
92
|
+
context.report({
|
|
93
|
+
node,
|
|
94
|
+
messageId: "noChainedLogic",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"JSXExpressionContainer > BinaryExpression"(
|
|
99
|
+
node: TSESTree.BinaryExpression
|
|
100
|
+
) {
|
|
101
|
+
if (ARITHMETIC_OPERATORS.includes(node.operator)) {
|
|
102
|
+
context.report({
|
|
103
|
+
node,
|
|
104
|
+
messageId: "noComputation",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
3
|
+
|
|
4
|
+
import { createRule } from "../../create-rule";
|
|
5
|
+
import {
|
|
6
|
+
isComponentFile,
|
|
7
|
+
isJsxReturningFunction,
|
|
8
|
+
isStoryFile,
|
|
9
|
+
isTestFile,
|
|
10
|
+
} from "../utils";
|
|
11
|
+
|
|
12
|
+
export const RULE_NAME = "no-state-in-component-body";
|
|
13
|
+
|
|
14
|
+
const REACT_HOOKS = [
|
|
15
|
+
"useState",
|
|
16
|
+
"useReducer",
|
|
17
|
+
"useEffect",
|
|
18
|
+
"useMemo",
|
|
19
|
+
"useCallback",
|
|
20
|
+
"useLayoutEffect",
|
|
21
|
+
"useRef",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const DEFAULT_ALLOWED_HOOKS = ["useId", "useTransition", "useDeferredValue"];
|
|
25
|
+
|
|
26
|
+
export interface INoStateInComponentBodyOptions {
|
|
27
|
+
readonly allowedHooks?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type RuleOptions = [INoStateInComponentBodyOptions];
|
|
31
|
+
type MessageIds = "noStateInComponent";
|
|
32
|
+
|
|
33
|
+
const optionSchema: JSONSchema4 = {
|
|
34
|
+
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
properties: {
|
|
37
|
+
allowedHooks: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: { type: "string" },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const noStateInComponentBodyRule = createRule<RuleOptions, MessageIds>({
|
|
45
|
+
name: RULE_NAME,
|
|
46
|
+
meta: {
|
|
47
|
+
type: "suggestion",
|
|
48
|
+
docs: {
|
|
49
|
+
description:
|
|
50
|
+
"State hooks must be in .hooks.ts files, not directly in components",
|
|
51
|
+
},
|
|
52
|
+
schema: [optionSchema],
|
|
53
|
+
messages: {
|
|
54
|
+
noStateInComponent:
|
|
55
|
+
"Hook '{{hookName}}' must be in a custom hook (.hooks.ts), not in component body",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultOptions: [{ allowedHooks: DEFAULT_ALLOWED_HOOKS }],
|
|
59
|
+
create(context, [options]) {
|
|
60
|
+
const filename = context.filename;
|
|
61
|
+
|
|
62
|
+
if (!isComponentFile(filename)) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isStoryFile(filename) || isTestFile(filename)) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const allowedHooks = new Set(options.allowedHooks ?? DEFAULT_ALLOWED_HOOKS);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
74
|
+
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hookName = node.callee.name;
|
|
79
|
+
|
|
80
|
+
if (!REACT_HOOKS.includes(hookName)) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (allowedHooks.has(hookName)) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let parent: TSESTree.Node | undefined = node.parent;
|
|
89
|
+
let inComponent = false;
|
|
90
|
+
|
|
91
|
+
while (parent) {
|
|
92
|
+
if (
|
|
93
|
+
(parent.type === AST_NODE_TYPES.FunctionDeclaration ||
|
|
94
|
+
parent.type === AST_NODE_TYPES.ArrowFunctionExpression) &&
|
|
95
|
+
isJsxReturningFunction(parent)
|
|
96
|
+
) {
|
|
97
|
+
inComponent = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
parent = parent.parent;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (inComponent) {
|
|
105
|
+
context.report({
|
|
106
|
+
node,
|
|
107
|
+
messageId: "noStateInComponent",
|
|
108
|
+
data: { hookName },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Detect if a file is a component file (.tsx with uppercase name, not test/story)
|
|
3
5
|
*/
|
|
@@ -22,6 +24,57 @@ export function isStoryFile(filename: string): boolean {
|
|
|
22
24
|
return filename.includes(".stories.tsx");
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Detect if a file is a test file
|
|
29
|
+
*/
|
|
30
|
+
export function isTestFile(filename: string): boolean {
|
|
31
|
+
return filename.includes(".test.ts") || filename.includes(".test.tsx");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* True when a function returns JSX directly or via a block `return`.
|
|
36
|
+
*/
|
|
37
|
+
export function isJsxReturningFunction(
|
|
38
|
+
node: TSESTree.FunctionDeclaration | TSESTree.ArrowFunctionExpression
|
|
39
|
+
): boolean {
|
|
40
|
+
const fnBody = node.body;
|
|
41
|
+
|
|
42
|
+
if (!fnBody) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
fnBody.type === AST_NODE_TYPES.JSXElement ||
|
|
48
|
+
fnBody.type === AST_NODE_TYPES.JSXFragment
|
|
49
|
+
) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (fnBody.type === AST_NODE_TYPES.BlockStatement) {
|
|
54
|
+
return containsReturnOfJsx(fnBody);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function containsReturnOfJsx(block: TSESTree.BlockStatement): boolean {
|
|
61
|
+
for (const stmt of block.body) {
|
|
62
|
+
if (stmt.type === AST_NODE_TYPES.ReturnStatement) {
|
|
63
|
+
const arg = stmt.argument;
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
arg &&
|
|
67
|
+
(arg.type === AST_NODE_TYPES.JSXElement ||
|
|
68
|
+
arg.type === AST_NODE_TYPES.JSXFragment)
|
|
69
|
+
) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
25
78
|
/**
|
|
26
79
|
* Detect if path is in shadcn/ui components folder
|
|
27
80
|
*/
|