@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,193 @@
|
|
|
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
|
+
DEFAULT_REDIS_METHODS,
|
|
7
|
+
DEFAULT_STATE_FILES,
|
|
8
|
+
matchesAnyGlobPattern,
|
|
9
|
+
matchRedisCall,
|
|
10
|
+
toPosixRelative,
|
|
11
|
+
} from "../utils";
|
|
12
|
+
|
|
13
|
+
export const RULE_NAME = "state-must-be-redis-backed";
|
|
14
|
+
|
|
15
|
+
export interface StateMustBeRedisBackedOptions {
|
|
16
|
+
readonly stateFiles?: readonly string[];
|
|
17
|
+
readonly redisMethodNames?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RuleOptions = [StateMustBeRedisBackedOptions];
|
|
21
|
+
type MessageIds = "missingRedisWrite" | "stateInCookie";
|
|
22
|
+
|
|
23
|
+
const optionSchema: JSONSchema4 = {
|
|
24
|
+
type: "object",
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: {
|
|
27
|
+
stateFiles: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: { type: "string" },
|
|
30
|
+
uniqueItems: true,
|
|
31
|
+
minItems: 1,
|
|
32
|
+
},
|
|
33
|
+
redisMethodNames: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "string" },
|
|
36
|
+
uniqueItems: true,
|
|
37
|
+
minItems: 1,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function isCookieLikeReceiver(name: string): boolean {
|
|
43
|
+
const lower = name.toLowerCase();
|
|
44
|
+
|
|
45
|
+
return lower.includes("cookie") || lower.includes("reply") || lower === "res";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractReceiverIdentifier(obj: TSESTree.Node): string | null {
|
|
49
|
+
let receiver = obj;
|
|
50
|
+
|
|
51
|
+
while (receiver.type === AST_NODE_TYPES.MemberExpression) {
|
|
52
|
+
receiver = receiver.object;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (receiver.type !== AST_NODE_TYPES.Identifier) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return receiver.name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isCookieSetMethod(name: string): boolean {
|
|
63
|
+
return name === "set" || name === "setCookie";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function argumentContainsState(arg: TSESTree.CallExpressionArgument): boolean {
|
|
67
|
+
if (
|
|
68
|
+
arg.type === AST_NODE_TYPES.Identifier &&
|
|
69
|
+
/^(oauth_?)?state$/i.test(arg.name)
|
|
70
|
+
) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (arg.type === AST_NODE_TYPES.ObjectExpression) {
|
|
75
|
+
for (const prop of arg.properties) {
|
|
76
|
+
if (prop.type !== AST_NODE_TYPES.Property || prop.computed) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (
|
|
81
|
+
prop.value.type === AST_NODE_TYPES.Identifier &&
|
|
82
|
+
/^(oauth_?)?state$/i.test(prop.value.name)
|
|
83
|
+
) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
arg.type === AST_NODE_TYPES.Literal &&
|
|
91
|
+
typeof arg.value === "string" &&
|
|
92
|
+
/^(oauth_?)?state$/i.test(arg.value)
|
|
93
|
+
) {
|
|
94
|
+
// `setCookie("state", value, ...)` — pattern with the state stuffed
|
|
95
|
+
// into a cookie of that name.
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isCookieSetWithState(node: TSESTree.CallExpression): boolean {
|
|
103
|
+
const callee = node.callee;
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
callee.type !== AST_NODE_TYPES.MemberExpression ||
|
|
107
|
+
callee.computed ||
|
|
108
|
+
callee.property.type !== AST_NODE_TYPES.Identifier
|
|
109
|
+
) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!isCookieSetMethod(callee.property.name)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const receiver = extractReceiverIdentifier(callee.object);
|
|
118
|
+
|
|
119
|
+
if (receiver === null || !isCookieLikeReceiver(receiver)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Look for a `state` or `oauth_state` reference among the args.
|
|
124
|
+
return node.arguments.some((arg) => argumentContainsState(arg));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const stateMustBeRedisBackedRule = createRule<RuleOptions, MessageIds>({
|
|
128
|
+
name: RULE_NAME,
|
|
129
|
+
meta: {
|
|
130
|
+
type: "problem",
|
|
131
|
+
docs: {
|
|
132
|
+
description:
|
|
133
|
+
"OAuth state must be persisted to Redis and not stuffed into a cookie. Cookie-backed state lets attackers replay forged state across sessions.",
|
|
134
|
+
},
|
|
135
|
+
schema: [optionSchema],
|
|
136
|
+
messages: {
|
|
137
|
+
missingRedisWrite:
|
|
138
|
+
"OAuth state file does not call any of `{{methods}}` on a Redis-shaped client — state must be persisted server-side.",
|
|
139
|
+
stateInCookie:
|
|
140
|
+
"Found a cookie write that appears to carry OAuth state — store state in Redis, not in a cookie.",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
defaultOptions: [
|
|
144
|
+
{
|
|
145
|
+
stateFiles: [...DEFAULT_STATE_FILES],
|
|
146
|
+
redisMethodNames: [...DEFAULT_REDIS_METHODS],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
create(context, [options]) {
|
|
150
|
+
const stateFiles = options.stateFiles ?? DEFAULT_STATE_FILES;
|
|
151
|
+
const redisMethodNames = new Set(
|
|
152
|
+
options.redisMethodNames ?? DEFAULT_REDIS_METHODS
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const relative = toPosixRelative(context.filename, context.cwd);
|
|
156
|
+
|
|
157
|
+
if (!matchesAnyGlobPattern(relative, stateFiles)) {
|
|
158
|
+
return {};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let redisWriteCount = 0;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
CallExpression(node) {
|
|
165
|
+
const match = matchRedisCall(node, redisMethodNames);
|
|
166
|
+
|
|
167
|
+
if (match !== null) {
|
|
168
|
+
redisWriteCount += 1;
|
|
169
|
+
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (isCookieSetWithState(node)) {
|
|
174
|
+
context.report({
|
|
175
|
+
node,
|
|
176
|
+
messageId: "stateInCookie",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
"Program:exit"(program) {
|
|
181
|
+
if (redisWriteCount === 0) {
|
|
182
|
+
context.report({
|
|
183
|
+
node: program,
|
|
184
|
+
messageId: "missingRedisWrite",
|
|
185
|
+
data: {
|
|
186
|
+
methods: [...redisMethodNames].join(", "),
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
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
|
+
DEFAULT_REDIS_METHODS,
|
|
7
|
+
DEFAULT_STATE_FILES,
|
|
8
|
+
matchesAnyGlobPattern,
|
|
9
|
+
matchRedisCall,
|
|
10
|
+
toPosixRelative,
|
|
11
|
+
} from "../utils";
|
|
12
|
+
|
|
13
|
+
export const RULE_NAME = "state-ttl-bounded";
|
|
14
|
+
|
|
15
|
+
export interface StateTtlBoundedOptions {
|
|
16
|
+
readonly stateFiles?: readonly string[];
|
|
17
|
+
readonly maxTtlSeconds?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RuleOptions = [StateTtlBoundedOptions];
|
|
21
|
+
type MessageIds = "stateTtlTooLong";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MAX_TTL = 600;
|
|
24
|
+
|
|
25
|
+
const optionSchema: JSONSchema4 = {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
properties: {
|
|
29
|
+
stateFiles: {
|
|
30
|
+
type: "array",
|
|
31
|
+
items: { type: "string" },
|
|
32
|
+
uniqueItems: true,
|
|
33
|
+
minItems: 1,
|
|
34
|
+
},
|
|
35
|
+
maxTtlSeconds: { type: "number", minimum: 1 },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function getNumericLiteral(node: TSESTree.Node | undefined): number | null {
|
|
40
|
+
if (node?.type === AST_NODE_TYPES.Literal && typeof node.value === "number") {
|
|
41
|
+
return node.value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveConstant(
|
|
48
|
+
name: string,
|
|
49
|
+
constants: Map<string, number>
|
|
50
|
+
): number | null {
|
|
51
|
+
return constants.get(name) ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const stateTtlBoundedRule = createRule<RuleOptions, MessageIds>({
|
|
55
|
+
name: RULE_NAME,
|
|
56
|
+
meta: {
|
|
57
|
+
type: "problem",
|
|
58
|
+
docs: {
|
|
59
|
+
description:
|
|
60
|
+
"OAuth state writes to Redis must use a short TTL — long-lived state widens the replay window.",
|
|
61
|
+
},
|
|
62
|
+
schema: [optionSchema],
|
|
63
|
+
messages: {
|
|
64
|
+
stateTtlTooLong:
|
|
65
|
+
"State TTL of {{found}}s exceeds maximum {{max}}s — long-lived state widens the replay window.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
defaultOptions: [
|
|
69
|
+
{
|
|
70
|
+
stateFiles: [...DEFAULT_STATE_FILES],
|
|
71
|
+
maxTtlSeconds: DEFAULT_MAX_TTL,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
create(context, [options]) {
|
|
75
|
+
const stateFiles = options.stateFiles ?? DEFAULT_STATE_FILES;
|
|
76
|
+
const maxTtlSeconds = options.maxTtlSeconds ?? DEFAULT_MAX_TTL;
|
|
77
|
+
const redisMethods = new Set(DEFAULT_REDIS_METHODS);
|
|
78
|
+
|
|
79
|
+
const relative = toPosixRelative(context.filename, context.cwd);
|
|
80
|
+
|
|
81
|
+
if (!matchesAnyGlobPattern(relative, stateFiles)) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const constants = new Map<string, number>();
|
|
86
|
+
|
|
87
|
+
function checkTtlNode(
|
|
88
|
+
node: TSESTree.CallExpressionArgument,
|
|
89
|
+
reportNode: TSESTree.Node
|
|
90
|
+
): void {
|
|
91
|
+
let value: number | null = getNumericLiteral(node);
|
|
92
|
+
|
|
93
|
+
if (value === null && node.type === AST_NODE_TYPES.Identifier) {
|
|
94
|
+
value = resolveConstant(node.name, constants);
|
|
95
|
+
|
|
96
|
+
if (value === null) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (value === null) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (value <= maxTtlSeconds) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
context.report({
|
|
110
|
+
node: reportNode,
|
|
111
|
+
messageId: "stateTtlTooLong",
|
|
112
|
+
data: { found: String(value), max: String(maxTtlSeconds) },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function handleSetexCall(node: TSESTree.CallExpression): void {
|
|
117
|
+
const ttl = node.arguments[1];
|
|
118
|
+
|
|
119
|
+
if (ttl === undefined) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
checkTtlNode(ttl, ttl);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function handleSetCallWithFlagArg(node: TSESTree.CallExpression): void {
|
|
127
|
+
// ioredis: set(key, value, "EX", ttl) — find "EX" then take next.
|
|
128
|
+
for (let i = 2; i < node.arguments.length - 1; i++) {
|
|
129
|
+
const flag = node.arguments[i];
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
flag?.type === AST_NODE_TYPES.Literal &&
|
|
133
|
+
typeof flag.value === "string" &&
|
|
134
|
+
flag.value.toUpperCase() === "EX"
|
|
135
|
+
) {
|
|
136
|
+
const ttl = node.arguments[i + 1];
|
|
137
|
+
|
|
138
|
+
if (ttl === undefined) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
checkTtlNode(ttl, ttl);
|
|
143
|
+
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleSetCallWithObjectArg(node: TSESTree.CallExpression): void {
|
|
150
|
+
// node-redis v4: set(key, value, { EX: ttl })
|
|
151
|
+
const last = node.arguments[node.arguments.length - 1];
|
|
152
|
+
|
|
153
|
+
if (last?.type !== AST_NODE_TYPES.ObjectExpression) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const prop of last.properties) {
|
|
158
|
+
if (prop.type !== AST_NODE_TYPES.Property || prop.computed) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const keyName =
|
|
163
|
+
prop.key.type === AST_NODE_TYPES.Identifier
|
|
164
|
+
? prop.key.name
|
|
165
|
+
: prop.key.type === AST_NODE_TYPES.Literal &&
|
|
166
|
+
typeof prop.key.value === "string"
|
|
167
|
+
? prop.key.value
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
if (keyName === "EX" || keyName === "PX") {
|
|
171
|
+
if (
|
|
172
|
+
prop.value.type === AST_NODE_TYPES.Literal ||
|
|
173
|
+
prop.value.type === AST_NODE_TYPES.Identifier
|
|
174
|
+
) {
|
|
175
|
+
checkTtlNode(prop.value, prop.value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
VariableDeclarator(node) {
|
|
185
|
+
if (
|
|
186
|
+
node.id.type === AST_NODE_TYPES.Identifier &&
|
|
187
|
+
node.init !== null &&
|
|
188
|
+
node.init.type === AST_NODE_TYPES.Literal &&
|
|
189
|
+
typeof node.init.value === "number"
|
|
190
|
+
) {
|
|
191
|
+
constants.set(node.id.name, node.init.value);
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
CallExpression(node) {
|
|
195
|
+
const match = matchRedisCall(node, redisMethods);
|
|
196
|
+
|
|
197
|
+
if (match === null) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const method = match.method;
|
|
202
|
+
|
|
203
|
+
if (method === "setex" || method === "setEx" || method === "SETEX") {
|
|
204
|
+
handleSetexCall(node);
|
|
205
|
+
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (method === "set" || method === "SET") {
|
|
210
|
+
handleSetCallWithFlagArg(node);
|
|
211
|
+
|
|
212
|
+
if (node.arguments.length > 0) {
|
|
213
|
+
handleSetCallWithObjectArg(node);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
import {
|
|
3
|
+
matchesAnyGlobPattern,
|
|
4
|
+
toPosixRelative as sharedToPosixRelative,
|
|
5
|
+
} from "../utils";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REDIS_METHODS: readonly string[] = [
|
|
8
|
+
"set",
|
|
9
|
+
"setex",
|
|
10
|
+
"setEx",
|
|
11
|
+
"SETEX",
|
|
12
|
+
"SET",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_OIDC_PROVIDERS: readonly string[] = [
|
|
16
|
+
"Google",
|
|
17
|
+
"Apple",
|
|
18
|
+
"Microsoft",
|
|
19
|
+
"MicrosoftEntraId",
|
|
20
|
+
"Auth0",
|
|
21
|
+
"Okta",
|
|
22
|
+
"Cognito",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_VERIFIER_FN_NAMES: readonly string[] = [
|
|
26
|
+
"generateCodeVerifier",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_STATE_FILES: readonly string[] = [
|
|
30
|
+
"**/oauth/state.ts",
|
|
31
|
+
"**/oauth/oauth.state.ts",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_PROVIDERS_GLOB = "**/oauth/providers/**";
|
|
35
|
+
|
|
36
|
+
export { sharedToPosixRelative as toPosixRelative, matchesAnyGlobPattern };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns true when the receiver of `<id>.<method>(...)` looks like a Redis
|
|
40
|
+
* client by name heuristic — identifier ends with `redis`, `client`, or
|
|
41
|
+
* exactly `redis`/`client` (case-insensitive).
|
|
42
|
+
*/
|
|
43
|
+
export function looksLikeRedisIdentifier(name: string): boolean {
|
|
44
|
+
const lower = name.toLowerCase();
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
lower === "redis" ||
|
|
48
|
+
lower === "client" ||
|
|
49
|
+
lower.endsWith("redis") ||
|
|
50
|
+
lower.endsWith("client")
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns true when an identifier name suggests a Redis-client accessor —
|
|
56
|
+
* `getClient`, `getRedis`, `getOAuthStateRedis`, etc.
|
|
57
|
+
*/
|
|
58
|
+
function looksLikeRedisAccessor(name: string): boolean {
|
|
59
|
+
const lower = name.toLowerCase();
|
|
60
|
+
|
|
61
|
+
if (!lower.startsWith("get")) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return lower.endsWith("redis") || lower.endsWith("client");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the matched method name when `node` is `<id>.<method>(...)` with
|
|
70
|
+
* a Redis-shaped receiver and a method in `methods`. The receiver may be
|
|
71
|
+
* either an identifier (`redis.setex(...)`) or a call to a Redis-shaped
|
|
72
|
+
* accessor (`getClient().setex(...)`).
|
|
73
|
+
*/
|
|
74
|
+
export function matchRedisCall(
|
|
75
|
+
node: TSESTree.CallExpression,
|
|
76
|
+
methods: ReadonlySet<string>
|
|
77
|
+
): { method: string; node: TSESTree.CallExpression } | null {
|
|
78
|
+
const callee = node.callee;
|
|
79
|
+
|
|
80
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!methods.has(callee.property.name)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Walk through chained MemberExpressions to the leftmost segment.
|
|
93
|
+
let receiver: TSESTree.Node = callee.object;
|
|
94
|
+
|
|
95
|
+
while (receiver.type === AST_NODE_TYPES.MemberExpression) {
|
|
96
|
+
receiver = receiver.object;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (
|
|
100
|
+
receiver.type === AST_NODE_TYPES.Identifier &&
|
|
101
|
+
looksLikeRedisIdentifier(receiver.name)
|
|
102
|
+
) {
|
|
103
|
+
return { method: callee.property.name, node };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (receiver.type === AST_NODE_TYPES.CallExpression) {
|
|
107
|
+
const inner = receiver.callee;
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
inner.type === AST_NODE_TYPES.Identifier &&
|
|
111
|
+
looksLikeRedisAccessor(inner.name)
|
|
112
|
+
) {
|
|
113
|
+
return { method: callee.property.name, node };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (
|
|
117
|
+
inner.type === AST_NODE_TYPES.MemberExpression &&
|
|
118
|
+
!inner.computed &&
|
|
119
|
+
inner.property.type === AST_NODE_TYPES.Identifier &&
|
|
120
|
+
looksLikeRedisAccessor(inner.property.name)
|
|
121
|
+
) {
|
|
122
|
+
return { method: callee.property.name, node };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { componentFolderStructureRule } from "./rules/component-folder-structure";
|
|
4
|
+
import { forwardrefDisplayNameRule } from "./rules/forwardref-display-name";
|
|
5
|
+
import { indexMustReexportDefaultRule } from "./rules/index-must-reexport-default";
|
|
6
|
+
import { maxHooksPerFileRule } from "./rules/max-hooks-per-file";
|
|
7
|
+
import { noCrossFeatureImportsRule } from "./rules/no-cross-feature-imports";
|
|
8
|
+
import { noInlineJsxFunctionsRule } from "./rules/no-inline-jsx-functions";
|
|
9
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
10
|
+
|
|
11
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
12
|
+
"component-folder-structure": componentFolderStructureRule,
|
|
13
|
+
"forwardref-display-name": forwardrefDisplayNameRule,
|
|
14
|
+
"index-must-reexport-default": indexMustReexportDefaultRule,
|
|
15
|
+
"max-hooks-per-file": maxHooksPerFileRule,
|
|
16
|
+
"no-cross-feature-imports": noCrossFeatureImportsRule,
|
|
17
|
+
"no-inline-jsx-functions": noInlineJsxFunctionsRule,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const reactComponentArchitecturePack: IRulePack = {
|
|
21
|
+
id: "react-component-architecture",
|
|
22
|
+
description:
|
|
23
|
+
"Component structure, composition, and file organization for React",
|
|
24
|
+
rules,
|
|
25
|
+
rulesConfig: {
|
|
26
|
+
"component-folder-structure": "error",
|
|
27
|
+
"forwardref-display-name": "error",
|
|
28
|
+
"index-must-reexport-default": "error",
|
|
29
|
+
"max-hooks-per-file": "warn",
|
|
30
|
+
"no-cross-feature-imports": "error",
|
|
31
|
+
"no-inline-jsx-functions": "warn",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default reactComponentArchitecturePack;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
4
|
+
|
|
5
|
+
import { createRule } from "../../create-rule";
|
|
6
|
+
import { getComponentName, isComponentFile, isInShadcnUi } from "../utils";
|
|
7
|
+
|
|
8
|
+
export const RULE_NAME = "component-folder-structure";
|
|
9
|
+
|
|
10
|
+
export interface ComponentFolderStructureOptions {
|
|
11
|
+
readonly requiredSiblings?: readonly string[];
|
|
12
|
+
readonly ignorePaths?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type RuleOptions = [ComponentFolderStructureOptions];
|
|
16
|
+
type MessageIds = "missingSiblings";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_SIBLINGS = [
|
|
19
|
+
"<Name>.hooks.ts",
|
|
20
|
+
"<Name>.types.ts",
|
|
21
|
+
"<Name>.stories.tsx",
|
|
22
|
+
"<Name>.test.ts",
|
|
23
|
+
"index.ts",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const DEFAULT_IGNORE_PATHS = [
|
|
27
|
+
"src/components/ui/",
|
|
28
|
+
"tests/",
|
|
29
|
+
"e2e/",
|
|
30
|
+
".storybook/",
|
|
31
|
+
"node_modules",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const optionSchema: JSONSchema4 = {
|
|
35
|
+
type: "object",
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
properties: {
|
|
38
|
+
requiredSiblings: {
|
|
39
|
+
type: "array",
|
|
40
|
+
items: { type: "string" },
|
|
41
|
+
},
|
|
42
|
+
ignorePaths: {
|
|
43
|
+
type: "array",
|
|
44
|
+
items: { type: "string" },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const componentFolderStructureRule = createRule<RuleOptions, MessageIds>(
|
|
50
|
+
{
|
|
51
|
+
name: RULE_NAME,
|
|
52
|
+
meta: {
|
|
53
|
+
type: "problem",
|
|
54
|
+
docs: {
|
|
55
|
+
description:
|
|
56
|
+
"Enforce required sibling files in component folders (hooks, types, stories, test, index)",
|
|
57
|
+
},
|
|
58
|
+
schema: [optionSchema],
|
|
59
|
+
messages: {
|
|
60
|
+
missingSiblings:
|
|
61
|
+
"Component '{{name}}' is missing required siblings: {{missing}}",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
defaultOptions: [
|
|
65
|
+
{
|
|
66
|
+
requiredSiblings: DEFAULT_SIBLINGS,
|
|
67
|
+
ignorePaths: DEFAULT_IGNORE_PATHS,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
create(context, [options]) {
|
|
71
|
+
const filename = context.filename;
|
|
72
|
+
|
|
73
|
+
if (!isComponentFile(filename)) {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ignorePaths = options.ignorePaths ?? DEFAULT_IGNORE_PATHS;
|
|
78
|
+
|
|
79
|
+
if (ignorePaths.some((p) => filename.includes(p))) {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isInShadcnUi(filename)) {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const componentName = getComponentName(filename);
|
|
88
|
+
|
|
89
|
+
if (!componentName) {
|
|
90
|
+
return {};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"Program:exit"(node) {
|
|
95
|
+
const dir = dirname(filename);
|
|
96
|
+
const requiredSiblings = options.requiredSiblings ?? DEFAULT_SIBLINGS;
|
|
97
|
+
|
|
98
|
+
const missing: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const sibling of requiredSiblings) {
|
|
101
|
+
const siblingPath = sibling.replace("<Name>", componentName);
|
|
102
|
+
const fullPath = join(dir, siblingPath);
|
|
103
|
+
|
|
104
|
+
if (!existsSync(fullPath)) {
|
|
105
|
+
missing.push(siblingPath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (missing.length > 0) {
|
|
110
|
+
context.report({
|
|
111
|
+
node,
|
|
112
|
+
messageId: "missingSiblings",
|
|
113
|
+
data: {
|
|
114
|
+
name: componentName,
|
|
115
|
+
missing: missing.join(", "),
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
);
|