@agjs/tsforge 0.1.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.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,93 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "forwardref-display-name";
6
+
7
+ type RuleOptions = [];
8
+ type MessageIds = "missingDisplayName";
9
+
10
+ export const forwardrefDisplayNameRule = createRule<RuleOptions, MessageIds>({
11
+ name: RULE_NAME,
12
+ meta: {
13
+ type: "problem",
14
+ docs: {
15
+ description: "forwardRef components must have displayName set",
16
+ },
17
+ schema: [],
18
+ messages: {
19
+ missingDisplayName:
20
+ 'Component using forwardRef must have displayName set: {{name}}.displayName = "..."',
21
+ },
22
+ },
23
+ defaultOptions: [],
24
+ create(context) {
25
+ const forwardRefVars = new Set<string>();
26
+
27
+ return {
28
+ "VariableDeclarator > CallExpression"(node: TSESTree.CallExpression) {
29
+ // Check if calling forwardRef or React.forwardRef
30
+ const callee = node.callee;
31
+ let isForwardRef = false;
32
+
33
+ if (callee.type === AST_NODE_TYPES.Identifier) {
34
+ isForwardRef = callee.name === "forwardRef";
35
+ } else if (
36
+ callee.type === AST_NODE_TYPES.MemberExpression &&
37
+ callee.object.type === AST_NODE_TYPES.Identifier &&
38
+ callee.object.name === "React" &&
39
+ callee.property.type === AST_NODE_TYPES.Identifier &&
40
+ callee.property.name === "forwardRef"
41
+ ) {
42
+ isForwardRef = true;
43
+ }
44
+
45
+ if (
46
+ isForwardRef &&
47
+ node.parent?.type === AST_NODE_TYPES.VariableDeclarator
48
+ ) {
49
+ const decl = node.parent;
50
+
51
+ if (decl.id.type === AST_NODE_TYPES.Identifier) {
52
+ forwardRefVars.add(decl.id.name);
53
+ }
54
+ }
55
+ },
56
+
57
+ "Program:exit"(program) {
58
+ const displayNameAssignments = new Set<string>();
59
+
60
+ // Find all displayName assignments
61
+ for (const stmt of program.body) {
62
+ if (stmt.type === AST_NODE_TYPES.ExpressionStatement) {
63
+ const expr = stmt.expression;
64
+
65
+ if (expr.type === AST_NODE_TYPES.AssignmentExpression) {
66
+ const left = expr.left;
67
+
68
+ if (
69
+ left.type === AST_NODE_TYPES.MemberExpression &&
70
+ left.object.type === AST_NODE_TYPES.Identifier &&
71
+ left.property.type === AST_NODE_TYPES.Identifier &&
72
+ left.property.name === "displayName"
73
+ ) {
74
+ displayNameAssignments.add(left.object.name);
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ // Report missing displayName for forwardRef vars
81
+ for (const varName of forwardRefVars) {
82
+ if (!displayNameAssignments.has(varName)) {
83
+ context.report({
84
+ node: program,
85
+ messageId: "missingDisplayName",
86
+ data: { name: varName },
87
+ });
88
+ }
89
+ }
90
+ },
91
+ };
92
+ },
93
+ });
@@ -0,0 +1,123 @@
1
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
2
+ import { existsSync } from "fs";
3
+ import { dirname, join } from "path";
4
+
5
+ import { createRule } from "../../create-rule";
6
+
7
+ export const RULE_NAME = "index-must-reexport-default";
8
+
9
+ type RuleOptions = [];
10
+ type MessageIds = "mustReexport" | "unexpectedStatement";
11
+
12
+ const isIndexFile = (filename: string): boolean => {
13
+ const basename = filename.split("/").pop();
14
+
15
+ return basename === "index.ts" || basename === "index.tsx";
16
+ };
17
+
18
+ const getParentComponentName = (filename: string): string | null => {
19
+ const dir = dirname(filename);
20
+ const basename = dir.split("/").pop();
21
+
22
+ if (!basename || !/^[A-Z]/.test(basename)) {
23
+ return null;
24
+ }
25
+
26
+ return basename;
27
+ };
28
+
29
+ const componentFileExists = (dir: string, name: string): boolean => {
30
+ return existsSync(join(dir, `${name}.tsx`));
31
+ };
32
+
33
+ export const indexMustReexportDefaultRule = createRule<RuleOptions, MessageIds>(
34
+ {
35
+ name: RULE_NAME,
36
+ meta: {
37
+ type: "problem",
38
+ docs: {
39
+ description:
40
+ "index.ts in component folders must re-export the component default export and types",
41
+ },
42
+ schema: [],
43
+ messages: {
44
+ mustReexport:
45
+ 'index.ts must contain re-export: export {{ default as {{name}} }} from "./{{name}}";',
46
+ unexpectedStatement:
47
+ "Unexpected statement in index.ts - only re-exports are allowed",
48
+ },
49
+ },
50
+ defaultOptions: [],
51
+ create(context) {
52
+ const filename = context.filename;
53
+
54
+ if (!isIndexFile(filename)) {
55
+ return {};
56
+ }
57
+
58
+ const componentName = getParentComponentName(filename);
59
+
60
+ if (!componentName) {
61
+ return {};
62
+ }
63
+
64
+ const dir = dirname(filename);
65
+
66
+ if (!componentFileExists(dir, componentName)) {
67
+ return {};
68
+ }
69
+
70
+ return {
71
+ "Program:exit"(program) {
72
+ let foundDefaultExport = false;
73
+
74
+ for (const stmt of program.body) {
75
+ if (
76
+ stmt.type === AST_NODE_TYPES.ExportNamedDeclaration &&
77
+ stmt.declaration === null
78
+ ) {
79
+ // Check for default re-export
80
+ if (stmt.specifiers.length > 0) {
81
+ const spec = stmt.specifiers[0];
82
+
83
+ if (
84
+ spec?.type === AST_NODE_TYPES.ExportSpecifier &&
85
+ spec.exported?.type === AST_NODE_TYPES.Identifier &&
86
+ spec.exported.name === componentName &&
87
+ spec.local?.type === AST_NODE_TYPES.Identifier &&
88
+ spec.local.name === "default"
89
+ ) {
90
+ foundDefaultExport = true;
91
+ }
92
+ }
93
+
94
+ // Type re-exports are OK too
95
+ continue;
96
+ }
97
+
98
+ if (stmt.type === AST_NODE_TYPES.ExportAllDeclaration) {
99
+ // Type re-exports like export * from "./<Name>.types"
100
+ continue;
101
+ }
102
+
103
+ // Any other statement is not allowed
104
+ if (stmt.type !== AST_NODE_TYPES.ExportNamedDeclaration) {
105
+ context.report({
106
+ node: stmt,
107
+ messageId: "unexpectedStatement",
108
+ });
109
+ }
110
+ }
111
+
112
+ if (!foundDefaultExport) {
113
+ context.report({
114
+ node: program,
115
+ messageId: "mustReexport",
116
+ data: { name: componentName },
117
+ });
118
+ }
119
+ },
120
+ };
121
+ },
122
+ }
123
+ );
@@ -0,0 +1,122 @@
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
+
6
+ export const RULE_NAME = "max-hooks-per-file";
7
+
8
+ export interface MaxHooksPerFileOptions {
9
+ readonly threshold?: number;
10
+ readonly filePattern?: string;
11
+ }
12
+
13
+ type RuleOptions = [MaxHooksPerFileOptions];
14
+ type MessageIds = "tooManyHooks";
15
+
16
+ const DEFAULT_THRESHOLD = 4;
17
+ const DEFAULT_FILE_PATTERN = "\\.(queries|hooks|mutations)\\.tsx?$";
18
+
19
+ const optionSchema: JSONSchema4 = {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ properties: {
23
+ threshold: { type: "integer", minimum: 1 },
24
+ filePattern: { type: "string", minLength: 1 },
25
+ },
26
+ };
27
+
28
+ function isHookName(name: string): boolean {
29
+ if (name.length < 4 || !name.startsWith("use")) {
30
+ return false;
31
+ }
32
+
33
+ const fourth = name[3];
34
+
35
+ return fourth !== undefined && /^[A-Z]/.test(fourth);
36
+ }
37
+
38
+ function collectExportedHookName(
39
+ statement: TSESTree.ProgramStatement
40
+ ): string | null {
41
+ if (statement.type !== AST_NODE_TYPES.ExportNamedDeclaration) {
42
+ return null;
43
+ }
44
+
45
+ const decl = statement.declaration;
46
+
47
+ if (decl === null || decl === undefined) {
48
+ return null;
49
+ }
50
+
51
+ if (decl.type === AST_NODE_TYPES.FunctionDeclaration) {
52
+ if (decl.id !== null && isHookName(decl.id.name)) {
53
+ return decl.id.name;
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ if (decl.type === AST_NODE_TYPES.VariableDeclaration) {
60
+ for (const declarator of decl.declarations) {
61
+ if (
62
+ declarator.id.type === AST_NODE_TYPES.Identifier &&
63
+ isHookName(declarator.id.name)
64
+ ) {
65
+ return declarator.id.name;
66
+ }
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ export const maxHooksPerFileRule = createRule<RuleOptions, MessageIds>({
74
+ name: RULE_NAME,
75
+ meta: {
76
+ type: "suggestion",
77
+ docs: {
78
+ description:
79
+ "Flag query/hook modules that export more than N hooks. Same-kind modules pass the single-semantic-module rule but still grow into god files; this rule sets a hard ceiling so the split conversation happens early.",
80
+ },
81
+ schema: [optionSchema],
82
+ messages: {
83
+ tooManyHooks:
84
+ "This file exports {{count}} hooks ({{names}}), exceeding the threshold of {{threshold}}. Split into focused modules (e.g. *.list.queries.ts + *.mutations.ts).",
85
+ },
86
+ },
87
+ defaultOptions: [{}],
88
+ create(context, [options]) {
89
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
90
+ const pattern = new RegExp(options.filePattern ?? DEFAULT_FILE_PATTERN);
91
+
92
+ if (!pattern.test(context.filename)) {
93
+ return {};
94
+ }
95
+
96
+ return {
97
+ Program(program) {
98
+ const hookNames: string[] = [];
99
+
100
+ for (const statement of program.body) {
101
+ const name = collectExportedHookName(statement);
102
+
103
+ if (name !== null) {
104
+ hookNames.push(name);
105
+ }
106
+ }
107
+
108
+ if (hookNames.length > threshold) {
109
+ context.report({
110
+ node: program,
111
+ messageId: "tooManyHooks",
112
+ data: {
113
+ count: String(hookNames.length),
114
+ threshold: String(threshold),
115
+ names: hookNames.join(", "),
116
+ },
117
+ });
118
+ }
119
+ },
120
+ };
121
+ },
122
+ });
@@ -0,0 +1,170 @@
1
+ import type { TSESTree } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+ import * as path from "path";
4
+
5
+ import { createRule } from "../../create-rule";
6
+
7
+ export const RULE_NAME = "no-cross-feature-imports";
8
+
9
+ export interface NoCrossFeatureImportsOptions {
10
+ readonly featuresDir?: string;
11
+ readonly allowSiblingTypes?: boolean;
12
+ readonly allowList?: readonly (readonly [string, string])[];
13
+ }
14
+
15
+ type RuleOptions = [NoCrossFeatureImportsOptions];
16
+ type MessageIds = "crossFeatureImport";
17
+
18
+ const optionSchema: JSONSchema4 = {
19
+ type: "object",
20
+ additionalProperties: false,
21
+ properties: {
22
+ featuresDir: {
23
+ type: "string",
24
+ },
25
+ allowSiblingTypes: {
26
+ type: "boolean",
27
+ },
28
+ allowList: {
29
+ type: "array",
30
+ items: {
31
+ type: "array",
32
+ minItems: 2,
33
+ maxItems: 2,
34
+ items: { type: "string" },
35
+ },
36
+ },
37
+ },
38
+ };
39
+
40
+ function extractFeatureName(
41
+ filename: string,
42
+ featuresDir: string
43
+ ): string | null {
44
+ const normalized = filename.replace(/\\/g, "/");
45
+ const featuresDirNorm = featuresDir.replace(/\\/g, "/");
46
+
47
+ // Match: <anything>/src/features/<featureName>/...
48
+ const pattern = new RegExp(
49
+ `(^|/)${featuresDirNorm.split("/").join("/")}[/]([^/]+)[/]`
50
+ );
51
+ const match = normalized.match(pattern);
52
+
53
+ return match?.[2] ?? null;
54
+ }
55
+
56
+ function resolveImportSource(
57
+ importSource: string,
58
+ currentDir: string
59
+ ): string | null {
60
+ // Handle @/ alias
61
+ if (importSource.startsWith("@/")) {
62
+ return importSource;
63
+ }
64
+
65
+ // Handle relative imports
66
+ if (importSource.startsWith(".")) {
67
+ let resolved = path.resolve(currentDir, importSource);
68
+
69
+ resolved = resolved.replace(/\\/g, "/");
70
+
71
+ // Normalize to @/ format if it resolves to features
72
+ if (resolved.includes("/src/features/")) {
73
+ const match = /^(.*)\/src\/features\/([^/]+)(\/.*)?$/.exec(resolved);
74
+
75
+ if (match?.[2]) {
76
+ const suffix = match[3] ?? "";
77
+
78
+ return `@/features/${match[2]}${suffix}`;
79
+ }
80
+ }
81
+
82
+ return resolved;
83
+ }
84
+
85
+ return importSource;
86
+ }
87
+
88
+ export const noCrossFeatureImportsRule = createRule<RuleOptions, MessageIds>({
89
+ name: RULE_NAME,
90
+ meta: {
91
+ type: "problem",
92
+ docs: {
93
+ description: "Prevent imports across different features",
94
+ },
95
+ schema: [optionSchema],
96
+ messages: {
97
+ crossFeatureImport:
98
+ 'Feature "{{from}}" must not import from feature "{{to}}". Shared code belongs in @/lib or @/components.',
99
+ },
100
+ },
101
+ defaultOptions: [
102
+ {
103
+ featuresDir: "src/features",
104
+ allowSiblingTypes: true,
105
+ allowList: [],
106
+ },
107
+ ],
108
+ create(context, [options]) {
109
+ const featuresDir = options.featuresDir ?? "src/features";
110
+ const allowSiblingTypes = options.allowSiblingTypes ?? true;
111
+ const allowList = new Set(
112
+ (options.allowList ?? []).map((pair) => pair.join("→"))
113
+ );
114
+
115
+ const filename = context.filename;
116
+ const currentFeature = extractFeatureName(filename, featuresDir);
117
+
118
+ // Only check files inside features
119
+ if (!currentFeature) {
120
+ return {};
121
+ }
122
+
123
+ return {
124
+ ImportDeclaration(node: TSESTree.ImportDeclaration) {
125
+ // If allowSiblingTypes is true and this is a type-only import, skip
126
+ if (allowSiblingTypes && node.importKind === "type") {
127
+ return;
128
+ }
129
+
130
+ const importSource = node.source.value;
131
+
132
+ // Check if this is a feature import
133
+ if (!importSource.includes("/features/")) {
134
+ return;
135
+ }
136
+
137
+ const currentDir = path.dirname(filename);
138
+ const resolved = resolveImportSource(importSource, currentDir);
139
+
140
+ if (!resolved?.includes("/features/")) {
141
+ return;
142
+ }
143
+
144
+ // Extract feature name from import
145
+ const match = /\/features\/([^/]+)/.exec(resolved);
146
+ const importedFeature = match ? match[1] : null;
147
+
148
+ if (!importedFeature || importedFeature === currentFeature) {
149
+ return;
150
+ }
151
+
152
+ // Check if this is in the allow list
153
+ const allowKey = `${currentFeature}→${importedFeature}`;
154
+
155
+ if (allowList.has(allowKey)) {
156
+ return;
157
+ }
158
+
159
+ context.report({
160
+ node,
161
+ messageId: "crossFeatureImport",
162
+ data: {
163
+ from: currentFeature,
164
+ to: importedFeature,
165
+ },
166
+ });
167
+ },
168
+ };
169
+ },
170
+ });
@@ -0,0 +1,66 @@
1
+ import 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-inline-jsx-functions";
8
+
9
+ export interface NoInlineJsxFunctionsOptions {
10
+ readonly allowSpreadPassthrough?: boolean;
11
+ }
12
+
13
+ type RuleOptions = [NoInlineJsxFunctionsOptions];
14
+ type MessageIds = "noInlineFunction";
15
+
16
+ const optionSchema: JSONSchema4 = {
17
+ type: "object",
18
+ additionalProperties: false,
19
+ properties: {
20
+ allowSpreadPassthrough: {
21
+ type: "boolean",
22
+ },
23
+ },
24
+ };
25
+
26
+ export const noInlineJsxFunctionsRule = createRule<RuleOptions, MessageIds>({
27
+ name: RULE_NAME,
28
+ meta: {
29
+ type: "suggestion",
30
+ docs: {
31
+ description: "Disallow inline function expressions in JSX attributes",
32
+ },
33
+ schema: [optionSchema],
34
+ messages: {
35
+ noInlineFunction:
36
+ "Use a named function reference instead of an inline function for event handlers",
37
+ },
38
+ },
39
+ defaultOptions: [{ allowSpreadPassthrough: false }],
40
+ create(context) {
41
+ const filename = context.filename;
42
+
43
+ if (isStoryFile(filename)) {
44
+ return {};
45
+ }
46
+
47
+ return {
48
+ "JSXAttribute > JSXExpressionContainer > ArrowFunctionExpression"(
49
+ node: TSESTree.ArrowFunctionExpression
50
+ ) {
51
+ context.report({
52
+ node,
53
+ messageId: "noInlineFunction",
54
+ });
55
+ },
56
+ "JSXAttribute > JSXExpressionContainer > FunctionExpression"(
57
+ node: TSESTree.FunctionExpression
58
+ ) {
59
+ context.report({
60
+ node,
61
+ messageId: "noInlineFunction",
62
+ });
63
+ },
64
+ };
65
+ },
66
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Detect if a file is a component file (.tsx with uppercase name, not test/story)
3
+ */
4
+ export function isComponentFile(filename: string): boolean {
5
+ if (!filename.endsWith(".tsx")) {
6
+ return false;
7
+ }
8
+
9
+ if (filename.includes(".test.tsx") || filename.includes(".stories.tsx")) {
10
+ return false;
11
+ }
12
+
13
+ const basename = getBasename(filename);
14
+
15
+ return /^[A-Z]/.test(basename);
16
+ }
17
+
18
+ /**
19
+ * Detect if a file is a story file
20
+ */
21
+ export function isStoryFile(filename: string): boolean {
22
+ return filename.includes(".stories.tsx");
23
+ }
24
+
25
+ /**
26
+ * Detect if path is in shadcn/ui components folder
27
+ */
28
+ export function isInShadcnUi(filename: string): boolean {
29
+ return filename.includes("/components/ui/");
30
+ }
31
+
32
+ /**
33
+ * Extract component name from filename (e.g., Button.tsx → Button)
34
+ */
35
+ export function getComponentName(filename: string): string | null {
36
+ const basename = getBasename(filename);
37
+ const match = /^([A-Z][a-zA-Z0-9]*)\.tsx$/.exec(basename);
38
+
39
+ return match ? (match[1] ?? null) : null;
40
+ }
41
+
42
+ /**
43
+ * Get the basename without directory
44
+ */
45
+ function getBasename(filename: string): string {
46
+ return filename.split("/").pop() ?? "";
47
+ }
@@ -0,0 +1,18 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ /** A single ESLint rule pack, bundling multiple related rules with their configuration. */
4
+ export interface IRulePack {
5
+ /** Unique identifier for this pack; must match a PACK_REGISTRY id from ../stack-detection. */
6
+ readonly id: string;
7
+
8
+ /** Human-readable description of what this pack enforces. */
9
+ readonly description: string;
10
+
11
+ /** ESLint rule module definitions, indexed by unprefixed rule name. */
12
+ readonly rules: Readonly<
13
+ Record<string, TSESLint.RuleModule<string, readonly unknown[]>>
14
+ >;
15
+
16
+ /** Default severity for each rule in the pack, indexed by unprefixed rule name. */
17
+ readonly rulesConfig: Readonly<Record<string, "error" | "warn">>;
18
+ }
@@ -0,0 +1,26 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { maskPiiFieldsRule } from "./rules/mask-pii-fields";
4
+ import { noErrorStringifyRule } from "./rules/no-error-stringify";
5
+ import { requireEventFieldRule } from "./rules/require-event-field";
6
+ import type { IRulePack } from "../rule-packs.types";
7
+
8
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
9
+ "mask-pii-fields": maskPiiFieldsRule,
10
+ "no-error-stringify": noErrorStringifyRule,
11
+ "require-event-field": requireEventFieldRule,
12
+ };
13
+
14
+ export const structuredLoggingPack: IRulePack = {
15
+ id: "structured-logging",
16
+ description:
17
+ "Structured logging best practices: PII masking, error handling, and event field requirements",
18
+ rules,
19
+ rulesConfig: {
20
+ "mask-pii-fields": "error",
21
+ "no-error-stringify": "error",
22
+ "require-event-field": "error",
23
+ },
24
+ };
25
+
26
+ export default structuredLoggingPack;