@ox0/guards 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/README.md +24 -0
- package/bin/ox0-guards.js +22 -0
- package/dist/index.js +1023 -0
- package/package.json +33 -0
- package/src/ast-helpers.ts +168 -0
- package/src/check.ts +429 -0
- package/src/cli.ts +160 -0
- package/src/guards/index.ts +24 -0
- package/src/guards/missing-label.ts +79 -0
- package/src/guards/no-async-react-component.ts +42 -0
- package/src/guards/no-bare-intl.ts +54 -0
- package/src/guards/no-css-import.ts +102 -0
- package/src/guards/no-effect-set-state-chain.ts +189 -0
- package/src/guards/no-set-state-in-render.ts +91 -0
- package/src/guards/no-unsafe-type-assertion.ts +50 -0
- package/src/guards/no-web-storage.ts +55 -0
- package/src/guards/require-effect-cleanup.ts +234 -0
- package/src/guards/stable-provider-values.ts +66 -0
- package/src/guards.ts +1 -0
- package/src/index.ts +30 -0
- package/src/types.ts +73 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import process from "node:process"
|
|
3
|
+
|
|
4
|
+
import { listGuardRuleIds, runGuards } from "./check"
|
|
5
|
+
|
|
6
|
+
type CliOptions = {
|
|
7
|
+
color: boolean
|
|
8
|
+
projectRoot: string
|
|
9
|
+
quiet: boolean
|
|
10
|
+
requestedRules: string[]
|
|
11
|
+
showRules: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv: string[]): CliOptions {
|
|
15
|
+
const options: CliOptions = {
|
|
16
|
+
color: process.stdout.isTTY,
|
|
17
|
+
projectRoot: ".",
|
|
18
|
+
quiet: false,
|
|
19
|
+
requestedRules: [],
|
|
20
|
+
showRules: false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
24
|
+
const arg = argv[index]
|
|
25
|
+
|
|
26
|
+
if (arg === "--all") {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (arg === "--quiet") {
|
|
31
|
+
options.quiet = true
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (arg === "--rules") {
|
|
36
|
+
options.showRules = true
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (arg === "--color") {
|
|
41
|
+
options.color = true
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (arg === "--no-color") {
|
|
46
|
+
options.color = false
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (arg === "--rule") {
|
|
51
|
+
const value = argv[index + 1]
|
|
52
|
+
if (!value) {
|
|
53
|
+
throw new Error("--rule requires a value.")
|
|
54
|
+
}
|
|
55
|
+
options.requestedRules.push(value)
|
|
56
|
+
index += 1
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (arg.startsWith("--rule=")) {
|
|
61
|
+
options.requestedRules.push(arg.slice("--rule=".length))
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (arg === "--project") {
|
|
66
|
+
const value = argv[index + 1]
|
|
67
|
+
if (!value) {
|
|
68
|
+
throw new Error("--project requires a value.")
|
|
69
|
+
}
|
|
70
|
+
options.projectRoot = value
|
|
71
|
+
index += 1
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (arg.startsWith("--project=")) {
|
|
76
|
+
options.projectRoot = arg.slice("--project=".length)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!arg.startsWith("-")) {
|
|
81
|
+
options.projectRoot = arg
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new Error(`Unknown argument: ${arg}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return options
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function paint(value: string, color: boolean, code: number): string {
|
|
92
|
+
return color ? `\u001B[${code}m${value}\u001B[0m` : value
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatFinding(
|
|
96
|
+
finding: {
|
|
97
|
+
relativeFilePath: string
|
|
98
|
+
line: number
|
|
99
|
+
column: number
|
|
100
|
+
ruleId: string
|
|
101
|
+
message: string
|
|
102
|
+
},
|
|
103
|
+
color: boolean,
|
|
104
|
+
): string {
|
|
105
|
+
const location = `${finding.relativeFilePath}:${finding.line}:${finding.column}`
|
|
106
|
+
return `${paint(location, color, 36)} ${paint(finding.ruleId, color, 31)} ${finding.message}`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function main(): void {
|
|
110
|
+
const options = parseArgs(process.argv.slice(2))
|
|
111
|
+
|
|
112
|
+
if (options.showRules) {
|
|
113
|
+
for (const ruleId of listGuardRuleIds()) {
|
|
114
|
+
console.log(ruleId)
|
|
115
|
+
}
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const projectRoot = path.resolve(process.cwd(), options.projectRoot)
|
|
120
|
+
const result = runGuards({
|
|
121
|
+
cwd: process.cwd(),
|
|
122
|
+
projectRoot: options.projectRoot,
|
|
123
|
+
requestedRules: options.requestedRules,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
if (result.findings.length === 0) {
|
|
127
|
+
if (!options.quiet) {
|
|
128
|
+
console.log(`No guard findings in ${path.basename(projectRoot)}.`)
|
|
129
|
+
}
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const counts = new Map<string, number>()
|
|
134
|
+
for (const finding of result.findings) {
|
|
135
|
+
counts.set(finding.ruleId, (counts.get(finding.ruleId) ?? 0) + 1)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(paint(`Guard findings: ${result.findings.length}`, options.color, 31))
|
|
139
|
+
for (const [ruleId, count] of [...counts.entries()].toSorted((left, right) =>
|
|
140
|
+
left[0].localeCompare(right[0]),
|
|
141
|
+
)) {
|
|
142
|
+
console.log(` ${paint(ruleId, options.color, 33)}: ${count}`)
|
|
143
|
+
}
|
|
144
|
+
console.log("")
|
|
145
|
+
|
|
146
|
+
for (const finding of result.findings) {
|
|
147
|
+
console.log(formatFinding(finding, options.color))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
process.exitCode = 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
main()
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
157
|
+
console.error(message)
|
|
158
|
+
console.error("Usage: ox0-guards [--project <path>] [--rule <rule-id>] [--quiet] [--rules]")
|
|
159
|
+
process.exitCode = 1
|
|
160
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GuardFactory } from "../types"
|
|
2
|
+
import { createMissingLabelGuard } from "./missing-label"
|
|
3
|
+
import { createNoAsyncReactComponentGuard } from "./no-async-react-component"
|
|
4
|
+
import { createNoBareIntlGuard } from "./no-bare-intl"
|
|
5
|
+
import { createNoCssImportGuard } from "./no-css-import"
|
|
6
|
+
import { createNoEffectSetStateChainGuard } from "./no-effect-set-state-chain"
|
|
7
|
+
import { createNoSetStateInRenderGuard } from "./no-set-state-in-render"
|
|
8
|
+
import { createNoUnsafeTypeAssertionGuard } from "./no-unsafe-type-assertion"
|
|
9
|
+
import { createNoWebStorageGuard } from "./no-web-storage"
|
|
10
|
+
import { createRequireEffectCleanupGuard } from "./require-effect-cleanup"
|
|
11
|
+
import { createStableProviderValuesGuard } from "./stable-provider-values"
|
|
12
|
+
|
|
13
|
+
export const GUARD_FACTORIES: GuardFactory[] = [
|
|
14
|
+
createNoAsyncReactComponentGuard,
|
|
15
|
+
createNoBareIntlGuard,
|
|
16
|
+
createNoCssImportGuard,
|
|
17
|
+
createNoEffectSetStateChainGuard,
|
|
18
|
+
createNoSetStateInRenderGuard,
|
|
19
|
+
createNoUnsafeTypeAssertionGuard,
|
|
20
|
+
createStableProviderValuesGuard,
|
|
21
|
+
createNoWebStorageGuard,
|
|
22
|
+
createMissingLabelGuard,
|
|
23
|
+
createRequireEffectCleanupGuard,
|
|
24
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const LABELABLE_ELEMENTS = new Set(["input", "select", "textarea"])
|
|
4
|
+
const LABEL_ATTRIBUTES = new Set(["aria-label", "aria-labelledby"])
|
|
5
|
+
const ALLOWED_COMPONENT_PATHS = new Set(["packages/ui/src/components/text-field.tsx"])
|
|
6
|
+
|
|
7
|
+
function hasUsefulAttribute(
|
|
8
|
+
node: Record<string, unknown>,
|
|
9
|
+
helpers: { getJsxElementName: (node: unknown) => string | null },
|
|
10
|
+
) {
|
|
11
|
+
const attributes = Array.isArray(node.attributes) ? node.attributes : []
|
|
12
|
+
for (const attribute of attributes) {
|
|
13
|
+
if (!attribute || typeof attribute !== "object") {
|
|
14
|
+
continue
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const name = helpers.getJsxElementName((attribute as Record<string, unknown>).name)
|
|
18
|
+
if (name && LABEL_ATTRIBUTES.has(name)) {
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const createMissingLabelGuard: GuardFactory = () => [
|
|
27
|
+
"guard/missing-label",
|
|
28
|
+
{
|
|
29
|
+
fileExtensions: [".jsx", ".tsx"],
|
|
30
|
+
tokenPrefilter(source) {
|
|
31
|
+
return source.includes("<input") || source.includes("<select") || source.includes("<textarea")
|
|
32
|
+
},
|
|
33
|
+
evaluate({ helpers, program, relativeFilePath }) {
|
|
34
|
+
if (ALLOWED_COMPONENT_PATHS.has(relativeFilePath.split("\\").join("/"))) {
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const findings: GuardRawFinding[] = []
|
|
39
|
+
|
|
40
|
+
helpers.walk(program, (node, parent) => {
|
|
41
|
+
if (node.type !== "JSXElement") {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const openingElement = node.openingElement as Record<string, unknown> | undefined
|
|
46
|
+
if (!openingElement) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const elementName = helpers.getJsxElementName(openingElement.name)
|
|
51
|
+
if (!elementName || !LABELABLE_ELEMENTS.has(elementName)) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (hasUsefulAttribute(openingElement, helpers)) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const wrappedByLabel =
|
|
60
|
+
parent?.type === "JSXElement" &&
|
|
61
|
+
helpers.getJsxElementName((parent.openingElement as Record<string, unknown>).name) ===
|
|
62
|
+
"label"
|
|
63
|
+
|
|
64
|
+
if (wrappedByLabel) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
findings.push({
|
|
69
|
+
start: openingElement.start as number,
|
|
70
|
+
end: openingElement.end as number,
|
|
71
|
+
message:
|
|
72
|
+
"Form controls must have a visible label or an accessible label via aria-label or aria-labelledby.",
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
return findings
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
function isPascalCase(name: string | null): boolean {
|
|
4
|
+
return Boolean(name) && /^[A-Z]/.test(name ?? "")
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const createNoAsyncReactComponentGuard: GuardFactory = () => [
|
|
8
|
+
"guard/no-async-react-component",
|
|
9
|
+
{
|
|
10
|
+
fileExtensions: [".jsx", ".tsx"],
|
|
11
|
+
tokenPrefilter(source) {
|
|
12
|
+
return source.includes("async")
|
|
13
|
+
},
|
|
14
|
+
evaluate({ helpers, program }) {
|
|
15
|
+
const findings: GuardRawFinding[] = []
|
|
16
|
+
|
|
17
|
+
helpers.walk(program, (node, parent) => {
|
|
18
|
+
if (!helpers.isFunctionLike(node)) {
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (node.async !== true) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const name = helpers.getFunctionName(node, parent)
|
|
27
|
+
if (!isPascalCase(name)) {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
findings.push({
|
|
32
|
+
start: node.start as number,
|
|
33
|
+
end: node.end as number,
|
|
34
|
+
message:
|
|
35
|
+
"React components should not be async. Move async work into loaders, actions, or effects.",
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
return findings
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const INTL_CONSTRUCTORS = new Set([
|
|
4
|
+
"Intl.DateTimeFormat",
|
|
5
|
+
"Intl.NumberFormat",
|
|
6
|
+
"Intl.RelativeTimeFormat",
|
|
7
|
+
"Intl.PluralRules",
|
|
8
|
+
"Intl.Collator",
|
|
9
|
+
"Intl.ListFormat",
|
|
10
|
+
"Intl.Segmenter",
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
const ALLOWED_PATHS = ["packages/i18n/"]
|
|
14
|
+
|
|
15
|
+
function isAllowedPath(relativeFilePath: string): boolean {
|
|
16
|
+
const normalized = relativeFilePath.split("\\").join("/")
|
|
17
|
+
return ALLOWED_PATHS.some((prefix) => normalized.startsWith(prefix))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createNoBareIntlGuard: GuardFactory = () => [
|
|
21
|
+
"guard/no-bare-intl",
|
|
22
|
+
{
|
|
23
|
+
fileExtensions: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
24
|
+
tokenPrefilter(source) {
|
|
25
|
+
return source.includes("new Intl.")
|
|
26
|
+
},
|
|
27
|
+
evaluate({ helpers, program, relativeFilePath }) {
|
|
28
|
+
if (isAllowedPath(relativeFilePath)) {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const findings: GuardRawFinding[] = []
|
|
33
|
+
|
|
34
|
+
helpers.walk(program, (node) => {
|
|
35
|
+
if (node.type !== "NewExpression") {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const calleeName = helpers.getCalleeName(node.callee)
|
|
40
|
+
if (!calleeName || !INTL_CONSTRUCTORS.has(calleeName)) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
findings.push({
|
|
45
|
+
start: node.start as number,
|
|
46
|
+
end: node.end as number,
|
|
47
|
+
message: `Do not construct bare ${calleeName} instances. Intl constructors are slow; use the caching helpers from the i18n package instead.`,
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return findings
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const CSS_PATTERN = /\.(css|scss|less)$/i
|
|
4
|
+
const ALLOWED_SPECIFIERS = new Set(["@ox0/tokens/styles.css"])
|
|
5
|
+
const ALLOWED_FILE_PATHS = new Set(["packages/ui/src/providers/ox0-provider.tsx"])
|
|
6
|
+
|
|
7
|
+
function isAllowedImport(relativeFilePath: string, sourceValue: string | null): boolean {
|
|
8
|
+
const normalizedPath = relativeFilePath.split("\\").join("/")
|
|
9
|
+
return (
|
|
10
|
+
Boolean(sourceValue) &&
|
|
11
|
+
ALLOWED_SPECIFIERS.has(sourceValue ?? "") &&
|
|
12
|
+
ALLOWED_FILE_PATHS.has(normalizedPath)
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isCssSpecifier(value: string | null): boolean {
|
|
17
|
+
return Boolean(value && CSS_PATTERN.test(value))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createNoCssImportGuard: GuardFactory = () => [
|
|
21
|
+
"guard/no-css-import",
|
|
22
|
+
{
|
|
23
|
+
fileExtensions: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
24
|
+
tokenPrefilter(source) {
|
|
25
|
+
return source.includes(".css") || source.includes(".scss") || source.includes(".less")
|
|
26
|
+
},
|
|
27
|
+
evaluate({ helpers, program, relativeFilePath }) {
|
|
28
|
+
const findings: GuardRawFinding[] = []
|
|
29
|
+
|
|
30
|
+
helpers.walk(program, (node) => {
|
|
31
|
+
if (node.type === "ImportDeclaration" || node.type === "ExportAllDeclaration") {
|
|
32
|
+
const sourceValue = helpers.getStringLiteralValue(node.source)
|
|
33
|
+
if (isAllowedImport(relativeFilePath, sourceValue) || !isCssSpecifier(sourceValue)) {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
findings.push({
|
|
38
|
+
start: node.start as number,
|
|
39
|
+
end: node.end as number,
|
|
40
|
+
message:
|
|
41
|
+
"CSS imports are not allowed here. Route shared styling through approved package entrypoints.",
|
|
42
|
+
})
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (node.type === "ExportNamedDeclaration" && node.source) {
|
|
47
|
+
const sourceValue = helpers.getStringLiteralValue(node.source)
|
|
48
|
+
if (isAllowedImport(relativeFilePath, sourceValue) || !isCssSpecifier(sourceValue)) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
findings.push({
|
|
53
|
+
start: node.start as number,
|
|
54
|
+
end: node.end as number,
|
|
55
|
+
message:
|
|
56
|
+
"CSS re-exports are not allowed here. Route shared styling through approved package entrypoints.",
|
|
57
|
+
})
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (node.type === "CallExpression") {
|
|
62
|
+
const calleeName = helpers.getCalleeName(node.callee)
|
|
63
|
+
if (calleeName !== "require") {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const [firstArg] = Array.isArray(node.arguments) ? node.arguments : []
|
|
68
|
+
const sourceValue = helpers.getStringLiteralValue(firstArg)
|
|
69
|
+
if (isAllowedImport(relativeFilePath, sourceValue) || !isCssSpecifier(sourceValue)) {
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
findings.push({
|
|
74
|
+
start: node.start as number,
|
|
75
|
+
end: node.end as number,
|
|
76
|
+
message:
|
|
77
|
+
"CSS require calls are not allowed here. Route shared styling through approved package entrypoints.",
|
|
78
|
+
})
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (node.type !== "ImportExpression") {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sourceValue = helpers.getStringLiteralValue(node.source)
|
|
87
|
+
if (isAllowedImport(relativeFilePath, sourceValue) || !isCssSpecifier(sourceValue)) {
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
findings.push({
|
|
92
|
+
start: node.start as number,
|
|
93
|
+
end: node.end as number,
|
|
94
|
+
message:
|
|
95
|
+
"Dynamic CSS imports are not allowed here. Route shared styling through approved package entrypoints.",
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return findings
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"])
|
|
4
|
+
|
|
5
|
+
function isNode(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isIdentifier(node: unknown): node is { name: string; type: "Identifier" } {
|
|
10
|
+
return isNode(node) && node.type === "Identifier" && typeof node.name === "string"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getFunctionBody(node: unknown): Record<string, unknown> | null {
|
|
14
|
+
return isNode(node) && isNode(node.body) ? node.body : null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function collectStatePairs(body: Record<string, unknown>): Map<string, string> {
|
|
18
|
+
const setterToState = new Map<string, string>()
|
|
19
|
+
const statements = Array.isArray(body.body) ? body.body : []
|
|
20
|
+
|
|
21
|
+
for (const statement of statements) {
|
|
22
|
+
if (!isNode(statement) || statement.type !== "VariableDeclaration") {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const declarations = Array.isArray(statement.declarations) ? statement.declarations : []
|
|
27
|
+
|
|
28
|
+
for (const declaration of declarations) {
|
|
29
|
+
if (!isNode(declaration)) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const id = declaration.id
|
|
34
|
+
const init = declaration.init
|
|
35
|
+
if (!isNode(id) || !isNode(init) || init.type !== "CallExpression") {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const callee = init.callee
|
|
40
|
+
if (!isIdentifier(callee) || callee.name !== "useState") {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const elements = Array.isArray(id.elements) ? id.elements : []
|
|
45
|
+
const [stateNode, setterNode] = elements
|
|
46
|
+
if (!isIdentifier(stateNode) || !isIdentifier(setterNode)) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setterToState.set(setterNode.name, stateNode.name)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return setterToState
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function collectDependencyNames(node: Record<string, unknown>): Set<string> {
|
|
58
|
+
const names = new Set<string>()
|
|
59
|
+
const elements = Array.isArray(node.elements) ? node.elements : []
|
|
60
|
+
|
|
61
|
+
for (const element of elements) {
|
|
62
|
+
if (isIdentifier(element)) {
|
|
63
|
+
names.add(element.name)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return names
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function referencesIdentifier(
|
|
71
|
+
node: unknown,
|
|
72
|
+
identifierName: string,
|
|
73
|
+
helpers: {
|
|
74
|
+
walk: (
|
|
75
|
+
node: unknown,
|
|
76
|
+
visitor: (node: Record<string, unknown>, parent: Record<string, unknown> | null) => void,
|
|
77
|
+
parent?: Record<string, unknown> | null,
|
|
78
|
+
) => void
|
|
79
|
+
},
|
|
80
|
+
): boolean {
|
|
81
|
+
let found = false
|
|
82
|
+
|
|
83
|
+
helpers.walk(node, (current) => {
|
|
84
|
+
if (found) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (current.type === "Identifier" && current.name === identifierName) {
|
|
89
|
+
found = true
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
return found
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const createNoEffectSetStateChainGuard: GuardFactory = () => [
|
|
97
|
+
"guard/no-effect-set-state-chain",
|
|
98
|
+
{
|
|
99
|
+
fileExtensions: [".jsx", ".tsx"],
|
|
100
|
+
tokenPrefilter(source) {
|
|
101
|
+
return (
|
|
102
|
+
(source.includes("useEffect") || source.includes("useLayoutEffect")) &&
|
|
103
|
+
source.includes("useState") &&
|
|
104
|
+
source.includes("set")
|
|
105
|
+
)
|
|
106
|
+
},
|
|
107
|
+
evaluate({ helpers, program }) {
|
|
108
|
+
const findings: GuardRawFinding[] = []
|
|
109
|
+
|
|
110
|
+
helpers.walk(program, (node) => {
|
|
111
|
+
if (!helpers.isFunctionLike(node)) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const functionBody = getFunctionBody(node)
|
|
116
|
+
if (!functionBody || functionBody.type !== "BlockStatement") {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const setterToState = collectStatePairs(functionBody)
|
|
121
|
+
if (setterToState.size === 0) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
helpers.walk(functionBody, (innerNode) => {
|
|
126
|
+
if (innerNode.type !== "CallExpression") {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hookName = helpers.getCalleeName(innerNode.callee)
|
|
131
|
+
if (!hookName || !EFFECT_HOOKS.has(hookName)) {
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const args = Array.isArray(innerNode.arguments) ? innerNode.arguments : []
|
|
136
|
+
const callback = args[0]
|
|
137
|
+
const deps = args[1]
|
|
138
|
+
if (
|
|
139
|
+
!callback ||
|
|
140
|
+
!helpers.isFunctionLike(callback) ||
|
|
141
|
+
!isNode(deps) ||
|
|
142
|
+
deps.type !== "ArrayExpression"
|
|
143
|
+
) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const depNames = collectDependencyNames(deps)
|
|
148
|
+
if (depNames.size === 0) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const callbackBody = getFunctionBody(callback)
|
|
153
|
+
if (!callbackBody) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
helpers.walk(callbackBody, (effectNode) => {
|
|
158
|
+
if (effectNode.type !== "CallExpression") {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const callee = effectNode.callee
|
|
163
|
+
if (!isIdentifier(callee)) {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stateName = setterToState.get(callee.name)
|
|
168
|
+
if (!stateName || !depNames.has(stateName)) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const [firstArgument] = Array.isArray(effectNode.arguments) ? effectNode.arguments : []
|
|
173
|
+
if (!firstArgument || !referencesIdentifier(firstArgument, stateName, helpers)) {
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
findings.push({
|
|
178
|
+
start: effectNode.start as number,
|
|
179
|
+
end: effectNode.end as number,
|
|
180
|
+
message: `Avoid deriving state in effects via ${callee.name} from the dependency state "${stateName}".`,
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return findings
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
]
|