@agjs/tsforge 0.1.19 → 0.2.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/package.json +4 -1
- package/scripts/build-rules-md.ts +78 -21
- package/scripts/sweep.ts +25 -20
- package/scripts/web-sweep.ts +292 -0
- package/src/cli.ts +9 -3
- package/src/config/index.ts +8 -0
- package/src/config/profiles.ts +150 -0
- package/src/config/tsforge-config.ts +64 -5
- package/src/detect-gate.ts +34 -1
- package/src/loop/feedback/meta-rule-docs.ts +48 -0
- package/src/loop/feedback/rule-docs.ts +150 -0
- package/src/loop/rule-docs.generated.json +131 -1
- package/src/loop/ttsr-defaults.ts +175 -4
- package/src/meta-rules/registry.ts +32 -0
- package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
- package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
- package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
- package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
- package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
- package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
- package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
- package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
- package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
- package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
- package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
- package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
- package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
- package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
- package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
- package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
- package/src/meta-rules/utils/lockfiles.ts +105 -0
- package/src/meta-rules/utils/workflow-yaml.ts +86 -0
- package/src/rule-packs/authorization/index.ts +26 -0
- package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
- package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
- package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
- package/src/rule-packs/authorization/utils.ts +285 -0
- package/src/rule-packs/boundary-utils.ts +13 -0
- package/src/rule-packs/code-flow/index.ts +4 -1
- package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
- package/src/rule-packs/drizzle/index.ts +7 -0
- package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
- package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
- package/src/rule-packs/drizzle/utils.ts +133 -1
- package/src/rule-packs/fastify/index.ts +38 -0
- package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
- package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
- package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
- package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
- package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
- package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
- package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
- package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
- package/src/rule-packs/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
- package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
- package/src/rule-packs/module-boundaries/index.ts +3 -0
- package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
- package/src/rule-packs/nextjs/index.ts +32 -0
- package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
- package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
- package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
- package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
- package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
- package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
- package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
- package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
- package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
- package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
- package/src/rule-packs/nextjs/utils.ts +18 -0
- package/src/rule-packs/react-component-architecture/index.ts +18 -0
- package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
- package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
- package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
- package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
- package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
- package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
- package/src/rule-packs/rule-catalog.types.ts +21 -0
- package/src/rule-packs/rule-metadata.ts +163 -0
- package/src/rule-packs/runtime-boundaries/index.ts +33 -0
- package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
- package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
- package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
- package/src/rule-packs/security/index.ts +35 -0
- package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
- package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
- package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
- package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
- package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
- package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
- package/src/rule-packs/structured-logging/index.ts +6 -0
- package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
- package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
- package/src/rule-packs/test-conventions/index.ts +9 -0
- package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
- package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
- package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
- package/src/rule-packs/typescript-core/index.ts +30 -0
- package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
- package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
- package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
- package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
- package/src/stack-detection/packs.ts +57 -0
- package/strict.web.eslint.config.mjs +32 -1
|
@@ -0,0 +1,174 @@
|
|
|
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 { toPosixRelative } from "../../utils";
|
|
6
|
+
|
|
7
|
+
export const RULE_NAME = "no-real-network-in-unit-tests";
|
|
8
|
+
|
|
9
|
+
export interface INoRealNetworkInUnitTestsOptions {
|
|
10
|
+
readonly testFileSuffixes?: readonly string[];
|
|
11
|
+
readonly integrationMarkers?: readonly string[];
|
|
12
|
+
readonly networkCallees?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type RuleOptions = [INoRealNetworkInUnitTestsOptions];
|
|
16
|
+
type MessageIds = "realNetworkInUnitTest";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TEST_FILE_SUFFIXES = [
|
|
19
|
+
".test.ts",
|
|
20
|
+
".test.tsx",
|
|
21
|
+
".spec.ts",
|
|
22
|
+
".spec.tsx",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_INTEGRATION_MARKERS = [
|
|
26
|
+
".integration.test.",
|
|
27
|
+
".integration.spec.",
|
|
28
|
+
"/integration/",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
const DEFAULT_NETWORK_CALLEES = ["fetch"] as const;
|
|
32
|
+
|
|
33
|
+
const optionSchema: JSONSchema4 = {
|
|
34
|
+
type: "object",
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
properties: {
|
|
37
|
+
testFileSuffixes: {
|
|
38
|
+
type: "array",
|
|
39
|
+
items: { type: "string" },
|
|
40
|
+
},
|
|
41
|
+
integrationMarkers: {
|
|
42
|
+
type: "array",
|
|
43
|
+
items: { type: "string" },
|
|
44
|
+
},
|
|
45
|
+
networkCallees: {
|
|
46
|
+
type: "array",
|
|
47
|
+
items: { type: "string" },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function isUnitTestFile(relPath: string, suffixes: readonly string[]): boolean {
|
|
53
|
+
return suffixes.some((suffix) => relPath.endsWith(suffix));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isIntegrationTestFile(
|
|
57
|
+
relPath: string,
|
|
58
|
+
markers: readonly string[]
|
|
59
|
+
): boolean {
|
|
60
|
+
return markers.some((marker) => relPath.includes(marker));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getCalleeName(callee: TSESTree.Node): string | null {
|
|
64
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
65
|
+
return callee.name;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
70
|
+
!callee.computed &&
|
|
71
|
+
callee.property.type === AST_NODE_TYPES.Identifier
|
|
72
|
+
) {
|
|
73
|
+
return callee.property.name;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isAxiosCall(node: TSESTree.CallExpression): boolean {
|
|
80
|
+
const callee = node.callee;
|
|
81
|
+
|
|
82
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.computed) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
callee.object.type !== AST_NODE_TYPES.Identifier ||
|
|
88
|
+
callee.object.name !== "axios"
|
|
89
|
+
) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (callee.property.type !== AST_NODE_TYPES.Identifier) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const method = callee.property.name;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
method === "get" ||
|
|
101
|
+
method === "post" ||
|
|
102
|
+
method === "put" ||
|
|
103
|
+
method === "patch" ||
|
|
104
|
+
method === "delete" ||
|
|
105
|
+
method === "request"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const noRealNetworkInUnitTestsRule = createRule<RuleOptions, MessageIds>(
|
|
110
|
+
{
|
|
111
|
+
name: RULE_NAME,
|
|
112
|
+
meta: {
|
|
113
|
+
type: "suggestion",
|
|
114
|
+
docs: {
|
|
115
|
+
description:
|
|
116
|
+
"Unit tests should not perform real network I/O — mock HTTP clients or move the test to an integration suite.",
|
|
117
|
+
},
|
|
118
|
+
schema: [optionSchema],
|
|
119
|
+
messages: {
|
|
120
|
+
realNetworkInUnitTest:
|
|
121
|
+
"Avoid real network calls in unit tests — mock `{{callee}}` or move this test to an integration file.",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
defaultOptions: [
|
|
125
|
+
{
|
|
126
|
+
testFileSuffixes: [...DEFAULT_TEST_FILE_SUFFIXES],
|
|
127
|
+
integrationMarkers: [...DEFAULT_INTEGRATION_MARKERS],
|
|
128
|
+
networkCallees: [...DEFAULT_NETWORK_CALLEES],
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
create(context, [options]) {
|
|
132
|
+
const testSuffixes =
|
|
133
|
+
options.testFileSuffixes ?? DEFAULT_TEST_FILE_SUFFIXES;
|
|
134
|
+
const integrationMarkers =
|
|
135
|
+
options.integrationMarkers ?? DEFAULT_INTEGRATION_MARKERS;
|
|
136
|
+
const networkCallees = new Set(
|
|
137
|
+
options.networkCallees ?? DEFAULT_NETWORK_CALLEES
|
|
138
|
+
);
|
|
139
|
+
const relPath = toPosixRelative(context.filename, context.cwd);
|
|
140
|
+
|
|
141
|
+
if (!isUnitTestFile(relPath, testSuffixes)) {
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isIntegrationTestFile(relPath, integrationMarkers)) {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
CallExpression(node) {
|
|
151
|
+
const name = getCalleeName(node.callee);
|
|
152
|
+
|
|
153
|
+
if (name !== null && networkCallees.has(name)) {
|
|
154
|
+
context.report({
|
|
155
|
+
node,
|
|
156
|
+
messageId: "realNetworkInUnitTest",
|
|
157
|
+
data: { callee: name },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (isAxiosCall(node)) {
|
|
164
|
+
context.report({
|
|
165
|
+
node,
|
|
166
|
+
messageId: "realNetworkInUnitTest",
|
|
167
|
+
data: { callee: "axios" },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { exportedFunctionsRequireReturnTypeRule } from "./rules/exported-functions-require-return-type";
|
|
4
|
+
import { fetchMustCheckOkRule } from "./rules/fetch-must-check-ok";
|
|
5
|
+
import { jsonParseMustValidateRule } from "./rules/json-parse-must-validate";
|
|
6
|
+
import { noUnsafeBoundaryCastRule } from "./rules/no-unsafe-boundary-cast";
|
|
7
|
+
import type { IRulePack } from "../rule-packs.types";
|
|
8
|
+
|
|
9
|
+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
|
|
10
|
+
"exported-functions-require-return-type":
|
|
11
|
+
exportedFunctionsRequireReturnTypeRule,
|
|
12
|
+
"fetch-must-check-ok": fetchMustCheckOkRule,
|
|
13
|
+
"json-parse-must-validate": jsonParseMustValidateRule,
|
|
14
|
+
"no-unsafe-boundary-cast": noUnsafeBoundaryCastRule,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const typescriptCorePack: IRulePack = {
|
|
18
|
+
id: "typescript-core",
|
|
19
|
+
description:
|
|
20
|
+
"Cross-cutting TypeScript boundary safety: fetch status checks, JSON validation, and module export typing.",
|
|
21
|
+
rules,
|
|
22
|
+
rulesConfig: {
|
|
23
|
+
"exported-functions-require-return-type": "warn",
|
|
24
|
+
"fetch-must-check-ok": "error",
|
|
25
|
+
"json-parse-must-validate": "error",
|
|
26
|
+
"no-unsafe-boundary-cast": "error",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default typescriptCorePack;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "exported-functions-require-return-type";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "missingReturnType";
|
|
8
|
+
|
|
9
|
+
function isExported(node: TSESTree.Node): boolean {
|
|
10
|
+
return (
|
|
11
|
+
node.type === AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
12
|
+
node.type === AST_NODE_TYPES.ExportDefaultDeclaration
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const exportedFunctionsRequireReturnTypeRule = createRule<
|
|
17
|
+
[],
|
|
18
|
+
MessageIds
|
|
19
|
+
>({
|
|
20
|
+
name: RULE_NAME,
|
|
21
|
+
meta: {
|
|
22
|
+
type: "suggestion",
|
|
23
|
+
docs: {
|
|
24
|
+
description:
|
|
25
|
+
"Exported functions should declare an explicit return type at module boundaries.",
|
|
26
|
+
},
|
|
27
|
+
schema: [],
|
|
28
|
+
messages: {
|
|
29
|
+
missingReturnType:
|
|
30
|
+
"Exported function `{{name}}` should declare an explicit return type.",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultOptions: [],
|
|
34
|
+
create(context) {
|
|
35
|
+
function checkFunction(
|
|
36
|
+
node: TSESTree.FunctionDeclaration,
|
|
37
|
+
exported: boolean
|
|
38
|
+
): void {
|
|
39
|
+
if (!exported || node.returnType !== undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const name = node.id?.name ?? "anonymous";
|
|
44
|
+
|
|
45
|
+
context.report({
|
|
46
|
+
node,
|
|
47
|
+
messageId: "missingReturnType",
|
|
48
|
+
data: { name },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration) {
|
|
54
|
+
const decl = node.declaration;
|
|
55
|
+
|
|
56
|
+
if (decl?.type === AST_NODE_TYPES.FunctionDeclaration) {
|
|
57
|
+
checkFunction(decl, true);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
|
|
61
|
+
if (node.parent !== undefined && isExported(node.parent)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
node.parent?.type === AST_NODE_TYPES.ExportNamedDeclaration ||
|
|
67
|
+
node.parent?.type === AST_NODE_TYPES.ExportDefaultDeclaration
|
|
68
|
+
) {
|
|
69
|
+
checkFunction(node, true);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
import { walkSome } from "../../utils";
|
|
5
|
+
|
|
6
|
+
export const RULE_NAME = "fetch-must-check-ok";
|
|
7
|
+
|
|
8
|
+
type MessageIds = "missingOkCheck";
|
|
9
|
+
|
|
10
|
+
function fetchCallHasOkCheck(fetchCall: TSESTree.CallExpression): boolean {
|
|
11
|
+
let parent: TSESTree.Node | null | undefined = fetchCall.parent;
|
|
12
|
+
|
|
13
|
+
while (parent !== undefined && parent !== null) {
|
|
14
|
+
if (parent.type === AST_NODE_TYPES.AwaitExpression) {
|
|
15
|
+
parent = parent.parent;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (parent.type === AST_NODE_TYPES.VariableDeclarator) {
|
|
20
|
+
const init = parent.init;
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
init?.type === AST_NODE_TYPES.AwaitExpression &&
|
|
24
|
+
init.argument === fetchCall
|
|
25
|
+
) {
|
|
26
|
+
const binding = parent.id;
|
|
27
|
+
|
|
28
|
+
if (binding.type === AST_NODE_TYPES.Identifier) {
|
|
29
|
+
const name = binding.name;
|
|
30
|
+
|
|
31
|
+
return walkSome(parent.parent ?? fetchCall, (node) => {
|
|
32
|
+
if (
|
|
33
|
+
node.type !== AST_NODE_TYPES.MemberExpression ||
|
|
34
|
+
node.computed
|
|
35
|
+
) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
node.object.type === AST_NODE_TYPES.Identifier &&
|
|
41
|
+
node.object.name === name &&
|
|
42
|
+
node.property.type === AST_NODE_TYPES.Identifier &&
|
|
43
|
+
node.property.name === "ok"
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (
|
|
51
|
+
parent.type === AST_NODE_TYPES.MemberExpression &&
|
|
52
|
+
!parent.computed &&
|
|
53
|
+
parent.property.type === AST_NODE_TYPES.Identifier &&
|
|
54
|
+
parent.property.name === "json"
|
|
55
|
+
) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const fetchMustCheckOkRule = createRule<[], MessageIds>({
|
|
66
|
+
name: RULE_NAME,
|
|
67
|
+
meta: {
|
|
68
|
+
type: "problem",
|
|
69
|
+
docs: {
|
|
70
|
+
description:
|
|
71
|
+
"HTTP fetch responses must check `.ok` or status before calling `.json()`.",
|
|
72
|
+
},
|
|
73
|
+
schema: [],
|
|
74
|
+
messages: {
|
|
75
|
+
missingOkCheck:
|
|
76
|
+
"Check `response.ok` (or status) before calling `.json()` on a fetch response.",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
defaultOptions: [],
|
|
80
|
+
create(context) {
|
|
81
|
+
return {
|
|
82
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
83
|
+
const callee = node.callee;
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
callee.type !== AST_NODE_TYPES.Identifier ||
|
|
87
|
+
callee.name !== "fetch"
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parent = node.parent;
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
parent?.type === AST_NODE_TYPES.MemberExpression &&
|
|
96
|
+
!parent.computed &&
|
|
97
|
+
parent.property.type === AST_NODE_TYPES.Identifier &&
|
|
98
|
+
parent.property.name === "json" &&
|
|
99
|
+
!fetchCallHasOkCheck(node)
|
|
100
|
+
) {
|
|
101
|
+
context.report({ node: parent, messageId: "missingOkCheck" });
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "json-parse-must-validate";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "bareJsonParse";
|
|
8
|
+
|
|
9
|
+
const VALIDATOR_IMPORTS = new Set([
|
|
10
|
+
"zod",
|
|
11
|
+
"valibot",
|
|
12
|
+
"@effect/schema",
|
|
13
|
+
"effect/Schema",
|
|
14
|
+
"arktype",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function fileHasValidatorImport(program: TSESTree.Program): boolean {
|
|
18
|
+
for (const stmt of program.body) {
|
|
19
|
+
if (stmt.type !== AST_NODE_TYPES.ImportDeclaration) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const source = stmt.source.value;
|
|
24
|
+
|
|
25
|
+
if (typeof source !== "string") {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const base = source.split("/")[0];
|
|
30
|
+
|
|
31
|
+
if (base !== undefined && VALIDATOR_IMPORTS.has(base)) {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (VALIDATOR_IMPORTS.has(source)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isTestFile(filename: string): boolean {
|
|
44
|
+
return (
|
|
45
|
+
filename.includes(".test.") ||
|
|
46
|
+
filename.includes(".spec.") ||
|
|
47
|
+
filename.includes("/__tests__/")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const jsonParseMustValidateRule = createRule<[], MessageIds>({
|
|
52
|
+
name: RULE_NAME,
|
|
53
|
+
meta: {
|
|
54
|
+
type: "problem",
|
|
55
|
+
docs: {
|
|
56
|
+
description:
|
|
57
|
+
"Disallow bare JSON.parse on untrusted input — validate through a schema library.",
|
|
58
|
+
},
|
|
59
|
+
schema: [],
|
|
60
|
+
messages: {
|
|
61
|
+
bareJsonParse:
|
|
62
|
+
"Do not use bare `JSON.parse` on external input — parse through Zod, Valibot, or Effect Schema.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultOptions: [],
|
|
66
|
+
create(context) {
|
|
67
|
+
if (isTestFile(context.filename)) {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let hasValidator = false;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
Program(node: TSESTree.Program) {
|
|
75
|
+
hasValidator = fileHasValidatorImport(node);
|
|
76
|
+
},
|
|
77
|
+
CallExpression(node: TSESTree.CallExpression) {
|
|
78
|
+
if (hasValidator) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const callee = node.callee;
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
86
|
+
!callee.computed &&
|
|
87
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
88
|
+
callee.object.name === "JSON" &&
|
|
89
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
90
|
+
callee.property.name === "parse"
|
|
91
|
+
) {
|
|
92
|
+
context.report({ node, messageId: "bareJsonParse" });
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
|
|
2
|
+
|
|
3
|
+
import { createRule } from "../../create-rule";
|
|
4
|
+
|
|
5
|
+
export const RULE_NAME = "no-unsafe-boundary-cast";
|
|
6
|
+
|
|
7
|
+
type MessageIds = "unsafeBoundaryCast";
|
|
8
|
+
|
|
9
|
+
const BOUNDARY_CALLEES = new Set([
|
|
10
|
+
"parse",
|
|
11
|
+
"json",
|
|
12
|
+
"get",
|
|
13
|
+
"getAll",
|
|
14
|
+
"text",
|
|
15
|
+
"formData",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function isBoundarySource(node: TSESTree.Node): boolean {
|
|
19
|
+
if (node.type === AST_NODE_TYPES.CallExpression) {
|
|
20
|
+
const callee = node.callee;
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
24
|
+
!callee.computed &&
|
|
25
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
26
|
+
BOUNDARY_CALLEES.has(callee.property.name)
|
|
27
|
+
) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
33
|
+
!callee.computed &&
|
|
34
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
35
|
+
callee.object.name === "JSON" &&
|
|
36
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
37
|
+
callee.property.name === "parse"
|
|
38
|
+
) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const noUnsafeBoundaryCastRule = createRule<[], MessageIds>({
|
|
47
|
+
name: RULE_NAME,
|
|
48
|
+
meta: {
|
|
49
|
+
type: "problem",
|
|
50
|
+
docs: {
|
|
51
|
+
description:
|
|
52
|
+
"Disallow type assertions immediately after parsing untrusted boundary input.",
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
messages: {
|
|
56
|
+
unsafeBoundaryCast:
|
|
57
|
+
"Do not cast untrusted parsed input with `as` — validate with a runtime schema instead.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
defaultOptions: [],
|
|
61
|
+
create(context) {
|
|
62
|
+
return {
|
|
63
|
+
TSAsExpression(node: TSESTree.TSAsExpression) {
|
|
64
|
+
if (isBoundarySource(node.expression)) {
|
|
65
|
+
context.report({ node, messageId: "unsafeBoundaryCast" });
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
});
|
|
@@ -61,6 +61,17 @@ export const PACK_REGISTRY = {
|
|
|
61
61
|
guidance: "Follow Elysia patterns for HTTP routing and middleware.",
|
|
62
62
|
} as const satisfies IRulePackDescriptor,
|
|
63
63
|
|
|
64
|
+
fastify: {
|
|
65
|
+
id: "fastify",
|
|
66
|
+
label: "Fastify",
|
|
67
|
+
description:
|
|
68
|
+
"Schema-first Fastify routing, plugin encapsulation, and test hygiene",
|
|
69
|
+
category: "framework",
|
|
70
|
+
appliesWhen: { anyDeps: ["fastify"] },
|
|
71
|
+
guidance:
|
|
72
|
+
"Use schema-driven routes, fastify-plugin for shared decorators, and fastify.inject with app.close() in tests.",
|
|
73
|
+
} as const satisfies IRulePackDescriptor,
|
|
74
|
+
|
|
64
75
|
nextjs: {
|
|
65
76
|
id: "nextjs",
|
|
66
77
|
label: "Next.js",
|
|
@@ -132,6 +143,50 @@ export const PACK_REGISTRY = {
|
|
|
132
143
|
guidance: "Write comments that explain intent, not what the code does.",
|
|
133
144
|
} as const satisfies IRulePackDescriptor,
|
|
134
145
|
|
|
146
|
+
security: {
|
|
147
|
+
id: "security",
|
|
148
|
+
label: "Security",
|
|
149
|
+
description:
|
|
150
|
+
"Application security guardrails: command injection, ReDoS, DOM XSS, and silent error masking",
|
|
151
|
+
category: "infra",
|
|
152
|
+
appliesWhen: { always: true },
|
|
153
|
+
guidance:
|
|
154
|
+
"Avoid shell execution, dynamic regex, innerHTML assignment, and catch blocks that silently mask failures.",
|
|
155
|
+
} as const satisfies IRulePackDescriptor,
|
|
156
|
+
|
|
157
|
+
"runtime-boundaries": {
|
|
158
|
+
id: "runtime-boundaries",
|
|
159
|
+
label: "Runtime Boundaries",
|
|
160
|
+
description:
|
|
161
|
+
"Runtime boundary safety: open redirects, SSRF, prototype pollution, webhook verification, and upload limits",
|
|
162
|
+
category: "infra",
|
|
163
|
+
appliesWhen: { always: true },
|
|
164
|
+
guidance:
|
|
165
|
+
"Use literal redirect/fetch URLs, avoid merging request fields into objects, verify webhooks before parsing, and cap multipart uploads.",
|
|
166
|
+
} as const satisfies IRulePackDescriptor,
|
|
167
|
+
|
|
168
|
+
"typescript-core": {
|
|
169
|
+
id: "typescript-core",
|
|
170
|
+
label: "TypeScript Core",
|
|
171
|
+
description:
|
|
172
|
+
"Cross-cutting TypeScript boundary safety: fetch status checks, JSON validation, and export typing",
|
|
173
|
+
category: "language",
|
|
174
|
+
appliesWhen: { always: false },
|
|
175
|
+
guidance:
|
|
176
|
+
"Validate HTTP responses, parse JSON through schemas, and annotate exported function return types.",
|
|
177
|
+
} as const satisfies IRulePackDescriptor,
|
|
178
|
+
|
|
179
|
+
authorization: {
|
|
180
|
+
id: "authorization",
|
|
181
|
+
label: "Authorization",
|
|
182
|
+
description:
|
|
183
|
+
"Experimental authorization heuristics for routes, server actions, and object-level access",
|
|
184
|
+
category: "infra",
|
|
185
|
+
appliesWhen: { always: false },
|
|
186
|
+
guidance:
|
|
187
|
+
"Mutating handlers should call authorization helpers before writes; opt in via the security profile.",
|
|
188
|
+
} as const satisfies IRulePackDescriptor,
|
|
189
|
+
|
|
135
190
|
"structured-logging": {
|
|
136
191
|
id: "structured-logging",
|
|
137
192
|
label: "Structured Logging",
|
|
@@ -176,6 +231,8 @@ export const ALWAYS_ON_PACKS = [
|
|
|
176
231
|
"module-boundaries",
|
|
177
232
|
"code-flow",
|
|
178
233
|
"comment-hygiene",
|
|
234
|
+
"security",
|
|
235
|
+
"runtime-boundaries",
|
|
179
236
|
] as const;
|
|
180
237
|
|
|
181
238
|
/** Type-safe record of all pack descriptors. */
|
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
// pack IDs), allowing the gate to inject stack-specific rules dynamically.
|
|
11
11
|
import tseslint from "typescript-eslint";
|
|
12
12
|
import stylistic from "@stylistic/eslint-plugin";
|
|
13
|
+
import pluginReact from "eslint-plugin-react";
|
|
14
|
+
import pluginReactHooks from "eslint-plugin-react-hooks";
|
|
15
|
+
import pluginJsxA11y from "eslint-plugin-jsx-a11y";
|
|
13
16
|
|
|
14
17
|
// Load stack-aware packs if TSFORGE_PACKS env var is set
|
|
15
18
|
let packConfig = [];
|
|
16
19
|
const packIds = (process.env.TSFORGE_PACKS ?? "").split(",").filter(Boolean);
|
|
20
|
+
const isWebStack = packIds.length > 0;
|
|
17
21
|
if (packIds.length > 0) {
|
|
18
22
|
try {
|
|
19
23
|
const { buildPackEslintConfig } = await import(
|
|
@@ -106,6 +110,8 @@ export default tseslint.config(
|
|
|
106
110
|
plugins: {
|
|
107
111
|
"@typescript-eslint": tseslint.plugin,
|
|
108
112
|
"@stylistic": stylistic,
|
|
113
|
+
react: pluginReact,
|
|
114
|
+
"react-hooks": pluginReactHooks,
|
|
109
115
|
boringstack: { rules: { "one-component-per-file": oneComponentPerFile } },
|
|
110
116
|
...packConfig
|
|
111
117
|
.filter(
|
|
@@ -137,6 +143,10 @@ export default tseslint.config(
|
|
|
137
143
|
// rule defined above — eslint-plugin-react/no-multi-comp crashes on ESLint 10
|
|
138
144
|
// and @eslint-react has no equivalent, so we ship our own.
|
|
139
145
|
"boringstack/one-component-per-file": "error",
|
|
146
|
+
"react/jsx-key": "error",
|
|
147
|
+
"react/no-array-index-key": "error",
|
|
148
|
+
"react-hooks/rules-of-hooks": "error",
|
|
149
|
+
"react-hooks/exhaustive-deps": "warn",
|
|
140
150
|
"prefer-const": "error",
|
|
141
151
|
"prefer-template": "error",
|
|
142
152
|
"no-var": "error",
|
|
@@ -180,6 +190,27 @@ export default tseslint.config(
|
|
|
180
190
|
],
|
|
181
191
|
...packConfig.reduce((acc, cfg) => ({ ...acc, ...(cfg.rules ?? {}) }), {}),
|
|
182
192
|
},
|
|
193
|
+
settings: {
|
|
194
|
+
react: { version: "detect" },
|
|
195
|
+
},
|
|
183
196
|
},
|
|
184
|
-
...packConfig
|
|
197
|
+
...packConfig,
|
|
198
|
+
...(isWebStack
|
|
199
|
+
? [
|
|
200
|
+
{
|
|
201
|
+
files: ["**/*.tsx"],
|
|
202
|
+
plugins: { "jsx-a11y": pluginJsxA11y },
|
|
203
|
+
rules: {
|
|
204
|
+
"jsx-a11y/alt-text": "error",
|
|
205
|
+
"jsx-a11y/anchor-is-valid": "warn",
|
|
206
|
+
"jsx-a11y/aria-props": "error",
|
|
207
|
+
"jsx-a11y/click-events-have-key-events": "warn",
|
|
208
|
+
"jsx-a11y/no-static-element-interactions": "warn",
|
|
209
|
+
"jsx-a11y/label-has-associated-control": "error",
|
|
210
|
+
"jsx-a11y/button-has-type": "error",
|
|
211
|
+
"jsx-a11y/no-noninteractive-tabindex": "error",
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
]
|
|
215
|
+
: [])
|
|
185
216
|
);
|