@agjs/tsforge 0.1.16 → 0.1.18
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/inference/request.ts +14 -14
- package/src/loop/feedback/rule-docs.ts +15 -0
- package/src/loop/rule-docs.generated.json +10 -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/inference/request.ts
CHANGED
|
@@ -68,17 +68,22 @@ function tokenCapField(cfg: IOpenAICompatibleConfig): Record<string, number> {
|
|
|
68
68
|
: { max_tokens: max };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
73
|
-
|
|
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(
|
|
74
75
|
cfg: IOpenAICompatibleConfig,
|
|
75
|
-
|
|
76
|
-
):
|
|
77
|
-
if (
|
|
78
|
-
return
|
|
76
|
+
opts: ICompleteOptions
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
if (opts.tools === undefined) {
|
|
79
|
+
return {};
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
if (style(cfg) === "deepseek") {
|
|
83
|
+
return { tools: opts.tools };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { tools: opts.tools, tool_choice: opts.toolChoice ?? "auto" };
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/** Build the request body object (pure). Field order keeps the qwen default
|
|
@@ -102,12 +107,7 @@ export function buildRequestBody(
|
|
|
102
107
|
...(cfg.repetitionPenalty === undefined
|
|
103
108
|
? {}
|
|
104
109
|
: { repetition_penalty: cfg.repetitionPenalty }),
|
|
105
|
-
...(opts
|
|
106
|
-
? {}
|
|
107
|
-
: {
|
|
108
|
-
tools: opts.tools,
|
|
109
|
-
tool_choice: toolChoiceFor(cfg, opts.toolChoice ?? "auto"),
|
|
110
|
-
}),
|
|
110
|
+
...toolsBlock(cfg, opts),
|
|
111
111
|
...reasoningFields(cfg, opts),
|
|
112
112
|
...(streaming
|
|
113
113
|
? { stream: true, stream_options: { include_usage: true } }
|
|
@@ -139,6 +139,21 @@ const RULE_DOCS: Record<string, IRuleDoc> = {
|
|
|
139
139
|
bad: "items.map((it, i) => <li key={i}>{it.text}</li>)",
|
|
140
140
|
good: "items.map((it) => <li key={it.id}>{it.text}</li>)",
|
|
141
141
|
},
|
|
142
|
+
"tsforge/no-jsx-computation": {
|
|
143
|
+
what: "No `.map()`/`.filter()`/arithmetic/chained logic inside JSX `{…}` — extract to a hook or pre-prep variable first.",
|
|
144
|
+
bad: "<ul>{items.filter((i) => i.visible).map((i) => <li key={i.id}>{i.label}</li>)}</ul>",
|
|
145
|
+
good: "const rows = useMemo(() => items.filter(...).map(...), [items]); return <ul>{rows}</ul>;",
|
|
146
|
+
},
|
|
147
|
+
"tsforge/no-state-in-component-body": {
|
|
148
|
+
what: "State hooks (`useState`, `useEffect`, `useMemo`, …) belong in `Component.hooks.ts`, not in the `.tsx` component body.",
|
|
149
|
+
bad: "export function Button() { const [open, setOpen] = useState(false); return <button />; }",
|
|
150
|
+
good: "export function useButton() { const [open, setOpen] = useState(false); return { open }; }",
|
|
151
|
+
},
|
|
152
|
+
"tsforge/no-inline-jsx-functions": {
|
|
153
|
+
what: "No inline arrow/function expressions in JSX attributes — bind handlers in the hook and pass a reference.",
|
|
154
|
+
bad: "<button onClick={() => doThing(id)} />",
|
|
155
|
+
good: "const onClickRow = useCallback(() => doThing(id), [id]); <button onClick={onClickRow} />",
|
|
156
|
+
},
|
|
142
157
|
};
|
|
143
158
|
|
|
144
159
|
/**
|
|
@@ -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",
|
|
@@ -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
|
*/
|