@agjs/tsforge 0.1.16 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.1.16",
4
+ "version": "0.1.17",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -68,17 +68,22 @@ function tokenCapField(cfg: IOpenAICompatibleConfig): Record<string, number> {
68
68
  : { max_tokens: max };
69
69
  }
70
70
 
71
- /** Tool-choice clamped for provider constraints: DeepSeek's thinking mode rejects
72
- * `tool_choice: "required"`, so downgrade it to `"auto"` there. */
73
- function toolChoiceFor(
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
- requested: "auto" | "required" | "none"
76
- ): "auto" | "required" | "none" {
77
- if (style(cfg) === "deepseek" && requested === "required") {
78
- return "auto";
76
+ opts: ICompleteOptions
77
+ ): Record<string, unknown> {
78
+ if (opts.tools === undefined) {
79
+ return {};
79
80
  }
80
81
 
81
- return requested;
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.tools === undefined
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 } }
@@ -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
  */