@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,321 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { isNodeLike } from "../../utils";
|
|
5
|
+
|
|
6
|
+
const HOOK_NAMES = new Set([
|
|
7
|
+
"useQuery",
|
|
8
|
+
"useInfiniteQuery",
|
|
9
|
+
"useSuspenseQuery",
|
|
10
|
+
"useSuspenseInfiniteQuery",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const PREFIX_UNSAFE_METHODS = new Set([
|
|
14
|
+
"setQueryData",
|
|
15
|
+
"getQueryData",
|
|
16
|
+
"cancelQueries",
|
|
17
|
+
"removeQueries",
|
|
18
|
+
"resetQueries",
|
|
19
|
+
"prefetchQuery",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const METHODS_ALLOWING_PREFIX_FILTER = new Set([
|
|
23
|
+
"cancelQueries",
|
|
24
|
+
"removeQueries",
|
|
25
|
+
"resetQueries",
|
|
26
|
+
"prefetchQuery",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const SKIP_TRAVERSE_KEYS = new Set([
|
|
30
|
+
"parent",
|
|
31
|
+
"tokens",
|
|
32
|
+
"comments",
|
|
33
|
+
"loc",
|
|
34
|
+
"range",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
type MessageIds = "useMatcherApi";
|
|
38
|
+
|
|
39
|
+
function unwrapExpression(node: TSESTree.Expression): TSESTree.Expression {
|
|
40
|
+
let current: TSESTree.Expression = node;
|
|
41
|
+
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (current.type === AST_NODE_TYPES.TSAsExpression) {
|
|
44
|
+
current = current.expression;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (current.type === AST_NODE_TYPES.TSNonNullExpression) {
|
|
49
|
+
current = current.expression;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getSpreadPrefixText(
|
|
58
|
+
node: TSESTree.Expression,
|
|
59
|
+
getText: (n: TSESTree.Node) => string
|
|
60
|
+
): string | null {
|
|
61
|
+
const inner = unwrapExpression(node);
|
|
62
|
+
|
|
63
|
+
if (inner.type !== AST_NODE_TYPES.ArrayExpression) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { elements } = inner;
|
|
68
|
+
|
|
69
|
+
if (elements.length < 2) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const firstElement = elements[0];
|
|
74
|
+
|
|
75
|
+
if (firstElement?.type !== AST_NODE_TYPES.SpreadElement) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { argument } = firstElement;
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
argument.type !== AST_NODE_TYPES.Identifier &&
|
|
83
|
+
argument.type !== AST_NODE_TYPES.MemberExpression
|
|
84
|
+
) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return getText(argument);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function findQueryKeyProperty(
|
|
92
|
+
obj: TSESTree.ObjectExpression
|
|
93
|
+
): TSESTree.Property | undefined {
|
|
94
|
+
return obj.properties.find(
|
|
95
|
+
(p): p is TSESTree.Property =>
|
|
96
|
+
p.type === AST_NODE_TYPES.Property &&
|
|
97
|
+
!p.computed &&
|
|
98
|
+
p.key.type === AST_NODE_TYPES.Identifier &&
|
|
99
|
+
p.key.name === "queryKey"
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function walkAst(
|
|
104
|
+
node: TSESTree.Node,
|
|
105
|
+
visitor: (n: TSESTree.Node) => void
|
|
106
|
+
): void {
|
|
107
|
+
visitor(node);
|
|
108
|
+
|
|
109
|
+
for (const key of Object.keys(node)) {
|
|
110
|
+
if (SKIP_TRAVERSE_KEYS.has(key)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const child = Object.getOwnPropertyDescriptor(node, key)?.value;
|
|
115
|
+
|
|
116
|
+
if (child === null || child === undefined) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(child)) {
|
|
121
|
+
for (const c of child) {
|
|
122
|
+
if (isNodeLike(c)) {
|
|
123
|
+
walkAst(c, visitor);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else if (isNodeLike(child)) {
|
|
127
|
+
walkAst(child, visitor);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function collectExtendedPrefixes(
|
|
133
|
+
program: TSESTree.Program,
|
|
134
|
+
getText: (n: TSESTree.Node) => string
|
|
135
|
+
): ReadonlySet<string> {
|
|
136
|
+
const out = new Set<string>();
|
|
137
|
+
|
|
138
|
+
walkAst(program, (node) => {
|
|
139
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { callee } = node;
|
|
144
|
+
|
|
145
|
+
if (
|
|
146
|
+
callee.type !== AST_NODE_TYPES.Identifier ||
|
|
147
|
+
!HOOK_NAMES.has(callee.name) ||
|
|
148
|
+
node.arguments[0]?.type !== AST_NODE_TYPES.ObjectExpression
|
|
149
|
+
) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const prop = findQueryKeyProperty(node.arguments[0]);
|
|
154
|
+
|
|
155
|
+
if (prop === undefined) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// prop.value might be a Pattern node; only process Expressions
|
|
160
|
+
if (
|
|
161
|
+
prop.value.type === AST_NODE_TYPES.AssignmentPattern ||
|
|
162
|
+
prop.value.type === AST_NODE_TYPES.ArrayPattern ||
|
|
163
|
+
prop.value.type === AST_NODE_TYPES.ObjectPattern ||
|
|
164
|
+
prop.value.type === AST_NODE_TYPES.TSEmptyBodyFunctionExpression
|
|
165
|
+
) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const prefix = getSpreadPrefixText(prop.value, getText);
|
|
170
|
+
|
|
171
|
+
if (prefix !== null) {
|
|
172
|
+
out.add(prefix);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function queryFilterAllowsPrefixMatch(
|
|
180
|
+
arg: TSESTree.CallExpressionArgument | undefined
|
|
181
|
+
): boolean {
|
|
182
|
+
if (arg?.type !== AST_NODE_TYPES.ObjectExpression) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const prop of arg.properties) {
|
|
187
|
+
if (
|
|
188
|
+
prop.type !== AST_NODE_TYPES.Property ||
|
|
189
|
+
prop.computed ||
|
|
190
|
+
prop.key.type !== AST_NODE_TYPES.Identifier
|
|
191
|
+
) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (prop.key.name === "predicate") {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
prop.key.name === "exact" &&
|
|
201
|
+
prop.value.type === AST_NODE_TYPES.Literal &&
|
|
202
|
+
prop.value.value === false
|
|
203
|
+
) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getFirstArgQueryKeyText(
|
|
212
|
+
args: readonly TSESTree.CallExpressionArgument[],
|
|
213
|
+
getText: (n: TSESTree.Node) => string
|
|
214
|
+
): string | null {
|
|
215
|
+
const first = args[0];
|
|
216
|
+
|
|
217
|
+
if (first === undefined) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (first.type === AST_NODE_TYPES.ObjectExpression) {
|
|
222
|
+
const prop = findQueryKeyProperty(first);
|
|
223
|
+
|
|
224
|
+
if (prop === undefined) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// prop.value might be a Pattern node; only process Expressions
|
|
229
|
+
if (
|
|
230
|
+
prop.value.type === AST_NODE_TYPES.AssignmentPattern ||
|
|
231
|
+
prop.value.type === AST_NODE_TYPES.ArrayPattern ||
|
|
232
|
+
prop.value.type === AST_NODE_TYPES.ObjectPattern ||
|
|
233
|
+
prop.value.type === AST_NODE_TYPES.TSEmptyBodyFunctionExpression
|
|
234
|
+
) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return getText(unwrapExpression(prop.value));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (first.type === AST_NODE_TYPES.SpreadElement) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return getText(unwrapExpression(first));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const prefixQueryKeyMustUseSetQueriesDataRule = createRule<
|
|
249
|
+
[],
|
|
250
|
+
MessageIds
|
|
251
|
+
>({
|
|
252
|
+
name: "prefix-query-key-must-use-set-queries-data",
|
|
253
|
+
meta: {
|
|
254
|
+
type: "problem",
|
|
255
|
+
docs: {
|
|
256
|
+
description:
|
|
257
|
+
"When a hook uses `queryKey: [...prefix, extra]`, do not call `setQueryData(prefix, …)`, `cancelQueries({ queryKey: prefix })`, etc. — those only touch one cache entry. Use `setQueriesData({ queryKey: prefix }, …)` and matcher-style `cancelQueries` / `invalidateQueries` so every variant is covered.",
|
|
258
|
+
},
|
|
259
|
+
schema: [],
|
|
260
|
+
messages: {
|
|
261
|
+
useMatcherApi:
|
|
262
|
+
"Query key spreads `{{prefix}}` with extra segments in this file. Use `setQueriesData` / predicate or `{ queryKey: prefix, exact: false }`-style APIs instead of `{{method}}` with the bare prefix (stale cache for other key variants).",
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
defaultOptions: [],
|
|
266
|
+
create(context) {
|
|
267
|
+
const sourceCode = context.sourceCode;
|
|
268
|
+
const getText = (n: TSESTree.Node) => sourceCode.getText(n);
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
"Program:exit"(program: TSESTree.Program): void {
|
|
272
|
+
const extendedPrefixes = collectExtendedPrefixes(program, getText);
|
|
273
|
+
|
|
274
|
+
if (extendedPrefixes.size === 0) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
walkAst(program, (node) => {
|
|
279
|
+
if (node.type !== AST_NODE_TYPES.CallExpression) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const { callee } = node;
|
|
284
|
+
|
|
285
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const method = callee.property.name;
|
|
294
|
+
|
|
295
|
+
if (!PREFIX_UNSAFE_METHODS.has(method)) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (
|
|
300
|
+
METHODS_ALLOWING_PREFIX_FILTER.has(method) &&
|
|
301
|
+
queryFilterAllowsPrefixMatch(node.arguments[0])
|
|
302
|
+
) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const keyText = getFirstArgQueryKeyText(node.arguments, getText);
|
|
307
|
+
|
|
308
|
+
if (keyText === null || !extendedPrefixes.has(keyText)) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
context.report({
|
|
313
|
+
node: callee.property,
|
|
314
|
+
messageId: "useMatcherApi",
|
|
315
|
+
data: { prefix: keyText, method },
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { noFocusedTestsRule } from "./rules/no-focused-tests";
|
|
4
|
+
import { testFileMirrorsSourceRule } from "./rules/test-file-mirrors-source";
|
|
5
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
6
|
+
|
|
7
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
8
|
+
"no-focused-tests": noFocusedTestsRule,
|
|
9
|
+
"test-file-mirrors-source": testFileMirrorsSourceRule,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const testConventionsPack: IRulePack = {
|
|
13
|
+
id: "test-conventions",
|
|
14
|
+
description:
|
|
15
|
+
"Testing patterns and file structure for vitest, jest, or Bun tests",
|
|
16
|
+
rules,
|
|
17
|
+
rulesConfig: {
|
|
18
|
+
"no-focused-tests": "error",
|
|
19
|
+
"test-file-mirrors-source": "error",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default testConventionsPack;
|
|
@@ -0,0 +1,170 @@
|
|
|
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 = "no-focused-tests";
|
|
7
|
+
|
|
8
|
+
export interface NoFocusedTestsOptions {
|
|
9
|
+
readonly testGlobals?: readonly string[];
|
|
10
|
+
readonly focusedAliases?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type RuleOptions = [NoFocusedTestsOptions];
|
|
14
|
+
type MessageIds = "focusedTest";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TEST_GLOBALS: readonly string[] = [
|
|
17
|
+
"test",
|
|
18
|
+
"it",
|
|
19
|
+
"describe",
|
|
20
|
+
"suite",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const DEFAULT_FOCUSED_ALIASES: readonly string[] = [
|
|
24
|
+
"fdescribe",
|
|
25
|
+
"fit",
|
|
26
|
+
"fcontext",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const optionSchema: JSONSchema4 = {
|
|
30
|
+
type: "object",
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: {
|
|
33
|
+
testGlobals: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "string" },
|
|
36
|
+
uniqueItems: true,
|
|
37
|
+
minItems: 1,
|
|
38
|
+
},
|
|
39
|
+
focusedAliases: {
|
|
40
|
+
type: "array",
|
|
41
|
+
items: { type: "string" },
|
|
42
|
+
uniqueItems: true,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function getMemberPropertyName(
|
|
48
|
+
member: TSESTree.MemberExpression
|
|
49
|
+
): string | null {
|
|
50
|
+
if (!member.computed && member.property.type === AST_NODE_TYPES.Identifier) {
|
|
51
|
+
return member.property.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
member.computed &&
|
|
56
|
+
member.property.type === AST_NODE_TYPES.Literal &&
|
|
57
|
+
typeof member.property.value === "string"
|
|
58
|
+
) {
|
|
59
|
+
return member.property.value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getRootIdentifierName(node: TSESTree.Node): string | null {
|
|
66
|
+
let current: TSESTree.Node = node;
|
|
67
|
+
|
|
68
|
+
while (current.type === AST_NODE_TYPES.MemberExpression) {
|
|
69
|
+
current = current.object;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (current.type === AST_NODE_TYPES.Identifier) {
|
|
73
|
+
return current.name;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns true when any segment in the chain of MemberExpression
|
|
81
|
+
* properties is `only`. Catches `test.only`, `test.skip.only`,
|
|
82
|
+
* `describe.each.only`, etc., as well as the computed-key form
|
|
83
|
+
* `test["only"]`.
|
|
84
|
+
*/
|
|
85
|
+
function chainHasOnly(node: TSESTree.Node): boolean {
|
|
86
|
+
let current: TSESTree.Node = node;
|
|
87
|
+
|
|
88
|
+
while (current.type === AST_NODE_TYPES.MemberExpression) {
|
|
89
|
+
const name = getMemberPropertyName(current);
|
|
90
|
+
|
|
91
|
+
if (name === "only") {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
current = current.object;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const noFocusedTestsRule = createRule<RuleOptions, MessageIds>({
|
|
102
|
+
name: RULE_NAME,
|
|
103
|
+
meta: {
|
|
104
|
+
type: "problem",
|
|
105
|
+
docs: {
|
|
106
|
+
description:
|
|
107
|
+
"Disallow focused tests (`test.only`, `it.only`, `fdescribe`, ...) — the canonical 'I forgot to remove this before committing' leak.",
|
|
108
|
+
},
|
|
109
|
+
schema: [optionSchema],
|
|
110
|
+
messages: {
|
|
111
|
+
focusedTest:
|
|
112
|
+
"Focused test '{{name}}' left in source — this skips every other test in CI. Remove the `.only` / `f`-prefix before committing.",
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
defaultOptions: [
|
|
116
|
+
{
|
|
117
|
+
testGlobals: [...DEFAULT_TEST_GLOBALS],
|
|
118
|
+
focusedAliases: [...DEFAULT_FOCUSED_ALIASES],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
create(context, [options]) {
|
|
122
|
+
const testGlobals = new Set(options.testGlobals ?? DEFAULT_TEST_GLOBALS);
|
|
123
|
+
const focusedAliases = new Set(
|
|
124
|
+
options.focusedAliases ?? DEFAULT_FOCUSED_ALIASES
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
CallExpression(node) {
|
|
129
|
+
const callee = node.callee;
|
|
130
|
+
|
|
131
|
+
// Bare `fdescribe(...)` / `fit(...)`.
|
|
132
|
+
if (
|
|
133
|
+
callee.type === AST_NODE_TYPES.Identifier &&
|
|
134
|
+
focusedAliases.has(callee.name)
|
|
135
|
+
) {
|
|
136
|
+
context.report({
|
|
137
|
+
node: callee,
|
|
138
|
+
messageId: "focusedTest",
|
|
139
|
+
data: { name: callee.name },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `test.only(...)`, `test.skip.only(...)`, `test["only"](...)`.
|
|
146
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const root = getRootIdentifierName(callee);
|
|
151
|
+
|
|
152
|
+
if (root === null || !testGlobals.has(root)) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!chainHasOnly(callee)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const sourceText = context.sourceCode.getText(callee);
|
|
161
|
+
|
|
162
|
+
context.report({
|
|
163
|
+
node: callee,
|
|
164
|
+
messageId: "focusedTest",
|
|
165
|
+
data: { name: sourceText },
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
|
|
5
|
+
|
|
6
|
+
import { createRule } from "../../create-rule";
|
|
7
|
+
import { toPosixRelative } from "../../utils";
|
|
8
|
+
|
|
9
|
+
export const RULE_NAME = "test-file-mirrors-source";
|
|
10
|
+
|
|
11
|
+
export interface TestFileMirrorsSourceOptions {
|
|
12
|
+
readonly testRoot?: string;
|
|
13
|
+
readonly sourceRoot?: string;
|
|
14
|
+
readonly testSuffix?: string;
|
|
15
|
+
readonly additionalSourceRoots?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type RuleOptions = [TestFileMirrorsSourceOptions];
|
|
19
|
+
type MessageIds = "orphanedTest";
|
|
20
|
+
|
|
21
|
+
const DEFAULT_TEST_ROOT = "tests";
|
|
22
|
+
const DEFAULT_SOURCE_ROOT = "src";
|
|
23
|
+
const DEFAULT_TEST_SUFFIX = ".test.ts";
|
|
24
|
+
const DEFAULT_ADDITIONAL_SOURCE_ROOTS: readonly string[] = [];
|
|
25
|
+
|
|
26
|
+
const optionSchema: JSONSchema4 = {
|
|
27
|
+
type: "object",
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
properties: {
|
|
30
|
+
testRoot: { type: "string", minLength: 1 },
|
|
31
|
+
sourceRoot: { type: "string", minLength: 1 },
|
|
32
|
+
testSuffix: { type: "string", minLength: 1 },
|
|
33
|
+
additionalSourceRoots: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "string" },
|
|
36
|
+
uniqueItems: true,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Indirection so test suites can stub fs lookups without spinning up a
|
|
43
|
+
* real filesystem. Defaults to the real `fs.existsSync`.
|
|
44
|
+
*/
|
|
45
|
+
export type FileExistsFn = (absolutePath: string) => boolean;
|
|
46
|
+
|
|
47
|
+
let fileExists: FileExistsFn = (p) => fs.existsSync(p);
|
|
48
|
+
|
|
49
|
+
export function setFileExistsForTesting(fn: FileExistsFn | null): void {
|
|
50
|
+
fileExists = fn ?? ((p) => fs.existsSync(p));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const testFileMirrorsSourceRule = createRule<RuleOptions, MessageIds>({
|
|
54
|
+
name: RULE_NAME,
|
|
55
|
+
meta: {
|
|
56
|
+
type: "problem",
|
|
57
|
+
docs: {
|
|
58
|
+
description:
|
|
59
|
+
"Every test file under `tests/` must mirror a source file under `src/`. Catches orphaned tests left behind after refactors and renames.",
|
|
60
|
+
},
|
|
61
|
+
schema: [optionSchema],
|
|
62
|
+
messages: {
|
|
63
|
+
orphanedTest:
|
|
64
|
+
"Test file '{{file}}' has no matching source — expected '{{expected}}'. Either rename the test or delete it.",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
defaultOptions: [
|
|
68
|
+
{
|
|
69
|
+
testRoot: DEFAULT_TEST_ROOT,
|
|
70
|
+
sourceRoot: DEFAULT_SOURCE_ROOT,
|
|
71
|
+
testSuffix: DEFAULT_TEST_SUFFIX,
|
|
72
|
+
additionalSourceRoots: [...DEFAULT_ADDITIONAL_SOURCE_ROOTS],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
create(context, [options]) {
|
|
76
|
+
const testRoot = options.testRoot ?? DEFAULT_TEST_ROOT;
|
|
77
|
+
const sourceRoot = options.sourceRoot ?? DEFAULT_SOURCE_ROOT;
|
|
78
|
+
const testSuffix = options.testSuffix ?? DEFAULT_TEST_SUFFIX;
|
|
79
|
+
const additionalSourceRoots =
|
|
80
|
+
options.additionalSourceRoots ?? DEFAULT_ADDITIONAL_SOURCE_ROOTS;
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
Program(program) {
|
|
84
|
+
const filename = context.filename;
|
|
85
|
+
const cwd = context.cwd;
|
|
86
|
+
const relative = toPosixRelative(filename, cwd);
|
|
87
|
+
|
|
88
|
+
const testRootPrefix = `${testRoot.replace(/\/$/, "")}/`;
|
|
89
|
+
|
|
90
|
+
if (!relative.startsWith(testRootPrefix)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!relative.endsWith(testSuffix)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const innerPath = relative.slice(
|
|
99
|
+
testRootPrefix.length,
|
|
100
|
+
relative.length - testSuffix.length
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const candidateRoots = [sourceRoot, ...additionalSourceRoots];
|
|
104
|
+
const candidates = candidateRoots.map((root) =>
|
|
105
|
+
path.resolve(cwd, `${root.replace(/\/$/, "")}/${innerPath}.ts`)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
if (fileExists(candidate)) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const expectedRelative = `${sourceRoot.replace(/\/$/, "")}/${innerPath}.ts`;
|
|
115
|
+
|
|
116
|
+
context.report({
|
|
117
|
+
node: program,
|
|
118
|
+
messageId: "orphanedTest",
|
|
119
|
+
data: {
|
|
120
|
+
file: relative,
|
|
121
|
+
expected: expectedRelative,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
});
|