@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.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- 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;
|