@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
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
function isStateSetterName(name: string | null): boolean {
|
|
8
|
+
return Boolean(name) && /^set[A-Z0-9]/.test(name ?? "")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function scanRenderBody(node: Record<string, unknown>, findings: GuardRawFinding[]) {
|
|
12
|
+
const body = Array.isArray(node.body) ? node.body : []
|
|
13
|
+
|
|
14
|
+
for (const statement of body) {
|
|
15
|
+
if (!statement || typeof statement !== "object") {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const current = statement as Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
current.type === "FunctionDeclaration" ||
|
|
23
|
+
current.type === "FunctionExpression" ||
|
|
24
|
+
current.type === "ArrowFunctionExpression"
|
|
25
|
+
) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (current.type === "ExpressionStatement") {
|
|
30
|
+
const expression = current.expression as Record<string, unknown> | undefined
|
|
31
|
+
if (expression?.type === "CallExpression") {
|
|
32
|
+
const callee = expression.callee as Record<string, unknown> | undefined
|
|
33
|
+
if (callee?.type === "Identifier" && isStateSetterName(callee.name as string)) {
|
|
34
|
+
findings.push({
|
|
35
|
+
start: expression.start as number,
|
|
36
|
+
end: expression.end as number,
|
|
37
|
+
message:
|
|
38
|
+
"Do not call state setters during render. Derive values in render or move the write to an event or effect.",
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const consequent = current.consequent as Record<string, unknown> | undefined
|
|
45
|
+
if (consequent && Array.isArray(consequent.body)) {
|
|
46
|
+
scanRenderBody(consequent, findings)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const alternate = current.alternate as Record<string, unknown> | undefined
|
|
50
|
+
if (alternate && Array.isArray(alternate.body)) {
|
|
51
|
+
scanRenderBody(alternate, findings)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(current.body)) {
|
|
55
|
+
scanRenderBody(current, findings)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const createNoSetStateInRenderGuard: GuardFactory = () => [
|
|
61
|
+
"guard/no-set-state-in-render",
|
|
62
|
+
{
|
|
63
|
+
fileExtensions: [".jsx", ".tsx"],
|
|
64
|
+
tokenPrefilter(source) {
|
|
65
|
+
return source.includes("set")
|
|
66
|
+
},
|
|
67
|
+
evaluate({ helpers, program }) {
|
|
68
|
+
const findings: GuardRawFinding[] = []
|
|
69
|
+
|
|
70
|
+
helpers.walk(program, (node, parent) => {
|
|
71
|
+
if (!helpers.isFunctionLike(node)) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const name = helpers.getFunctionName(node, parent)
|
|
76
|
+
if (!isPascalCase(name)) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body = node.body as Record<string, unknown> | undefined
|
|
81
|
+
if (!body || body.type !== "BlockStatement") {
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
scanRenderBody(body, findings)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return findings
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const EXCLUDED_PATHS = new Set([
|
|
4
|
+
"packages/guards/",
|
|
5
|
+
"packages/hooks/src/use-local-storage.ts",
|
|
6
|
+
"packages/utils/src/index.ts",
|
|
7
|
+
"scripts/",
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
function isExcludedPath(relativeFilePath: string): boolean {
|
|
11
|
+
const normalized = relativeFilePath.split("\\").join("/")
|
|
12
|
+
return [...EXCLUDED_PATHS].some((entry) => normalized === entry || normalized.startsWith(entry))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const createNoUnsafeTypeAssertionGuard: GuardFactory = () => [
|
|
16
|
+
"guard/no-unsafe-type-assertion",
|
|
17
|
+
{
|
|
18
|
+
fileExtensions: [".ts", ".tsx", ".mts", ".cts"],
|
|
19
|
+
tokenPrefilter(source) {
|
|
20
|
+
return source.includes(" as ")
|
|
21
|
+
},
|
|
22
|
+
evaluate({ helpers, program, relativeFilePath, source }) {
|
|
23
|
+
if (relativeFilePath.endsWith(".d.ts") || isExcludedPath(relativeFilePath)) {
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const findings: GuardRawFinding[] = []
|
|
28
|
+
|
|
29
|
+
helpers.walk(program, (node) => {
|
|
30
|
+
if (node.type !== "TSAsExpression") {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const expressionText = source.slice(node.start as number, node.end as number)
|
|
35
|
+
if (expressionText.trimEnd().endsWith(" as const")) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
findings.push({
|
|
40
|
+
start: node.start as number,
|
|
41
|
+
end: node.end as number,
|
|
42
|
+
message:
|
|
43
|
+
"Unsafe type assertions are not allowed here. Prefer narrowing, validation, or better typing.",
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return findings
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const STORAGE_NAMES = new Set([
|
|
4
|
+
"localStorage",
|
|
5
|
+
"sessionStorage",
|
|
6
|
+
"window.localStorage",
|
|
7
|
+
"window.sessionStorage",
|
|
8
|
+
"globalThis.localStorage",
|
|
9
|
+
"globalThis.sessionStorage",
|
|
10
|
+
])
|
|
11
|
+
const ALLOWED_WRAPPER_PATHS = new Set(["packages/hooks/src/use-local-storage.ts"])
|
|
12
|
+
|
|
13
|
+
export const createNoWebStorageGuard: GuardFactory = () => [
|
|
14
|
+
"guard/no-web-storage",
|
|
15
|
+
{
|
|
16
|
+
fileExtensions: [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"],
|
|
17
|
+
tokenPrefilter(source) {
|
|
18
|
+
return source.includes("localStorage") || source.includes("sessionStorage")
|
|
19
|
+
},
|
|
20
|
+
evaluate({ filePath, helpers, program }) {
|
|
21
|
+
const normalizedFilePath = filePath.split("\\").join("/")
|
|
22
|
+
if (
|
|
23
|
+
[...ALLOWED_WRAPPER_PATHS].some((allowedPath) => normalizedFilePath.endsWith(allowedPath))
|
|
24
|
+
) {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const findings: GuardRawFinding[] = []
|
|
29
|
+
|
|
30
|
+
helpers.walk(program, (node) => {
|
|
31
|
+
if (node.type !== "Identifier" && node.type !== "MemberExpression") {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const name =
|
|
36
|
+
node.type === "Identifier"
|
|
37
|
+
? helpers.getCalleeName(node)
|
|
38
|
+
: helpers.getCalleeName(node.object)
|
|
39
|
+
|
|
40
|
+
if (!name || !STORAGE_NAMES.has(name)) {
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
findings.push({
|
|
45
|
+
start: node.start as number,
|
|
46
|
+
end: node.end as number,
|
|
47
|
+
message:
|
|
48
|
+
"Direct web storage access is not allowed in ox0 primitives. Use a storage abstraction or inject persistence.",
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return findings
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect"])
|
|
4
|
+
const OBSERVER_NAMES = new Set(["MutationObserver", "ResizeObserver"])
|
|
5
|
+
|
|
6
|
+
type SetupKind = "event-listener" | "observer" | "timeout" | "interval"
|
|
7
|
+
|
|
8
|
+
function getFunctionBody(node: Record<string, unknown>): Record<string, unknown> | null {
|
|
9
|
+
const body = node.body
|
|
10
|
+
return body && typeof body === "object" && !Array.isArray(body)
|
|
11
|
+
? (body as Record<string, unknown>)
|
|
12
|
+
: null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getReturnCleanupFunction(node: Record<string, unknown>) {
|
|
16
|
+
const body = getFunctionBody(node)
|
|
17
|
+
if (!body || body.type !== "BlockStatement") {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const statements = Array.isArray(body.body) ? body.body : []
|
|
22
|
+
for (const statement of statements) {
|
|
23
|
+
if (!statement || typeof statement !== "object") {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const current = statement as Record<string, unknown>
|
|
28
|
+
if (current.type !== "ReturnStatement") {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const argument = current.argument
|
|
33
|
+
if (!argument || typeof argument !== "object") {
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
(argument as { type?: string }).type === "FunctionExpression" ||
|
|
39
|
+
(argument as { type?: string }).type === "ArrowFunctionExpression"
|
|
40
|
+
) {
|
|
41
|
+
return argument as Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectSetups(
|
|
49
|
+
body: Record<string, unknown>,
|
|
50
|
+
helpers: {
|
|
51
|
+
getCalleeName: (node: unknown) => string | null
|
|
52
|
+
walk: (
|
|
53
|
+
node: unknown,
|
|
54
|
+
visitor: (node: Record<string, unknown>, parent: Record<string, unknown> | null) => void,
|
|
55
|
+
parent?: Record<string, unknown> | null,
|
|
56
|
+
) => void
|
|
57
|
+
},
|
|
58
|
+
) {
|
|
59
|
+
const setups: Array<{ end: number; kind: SetupKind; start: number }> = []
|
|
60
|
+
|
|
61
|
+
helpers.walk(body, (node) => {
|
|
62
|
+
if (node.type === "CallExpression") {
|
|
63
|
+
const calleeName = helpers.getCalleeName(node.callee)
|
|
64
|
+
|
|
65
|
+
if (calleeName === "setInterval" || calleeName === "window.setInterval") {
|
|
66
|
+
setups.push({
|
|
67
|
+
kind: "interval",
|
|
68
|
+
start: node.start as number,
|
|
69
|
+
end: node.end as number,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (calleeName === "setTimeout" || calleeName === "window.setTimeout") {
|
|
74
|
+
setups.push({
|
|
75
|
+
kind: "timeout",
|
|
76
|
+
start: node.start as number,
|
|
77
|
+
end: node.end as number,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (calleeName?.endsWith(".addEventListener") || calleeName === "addEventListener") {
|
|
82
|
+
setups.push({
|
|
83
|
+
kind: "event-listener",
|
|
84
|
+
start: node.start as number,
|
|
85
|
+
end: node.end as number,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (node.type !== "NewExpression") {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const calleeName = helpers.getCalleeName(node.callee)
|
|
97
|
+
if (!calleeName || !OBSERVER_NAMES.has(calleeName)) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setups.push({
|
|
102
|
+
kind: "observer",
|
|
103
|
+
start: node.start as number,
|
|
104
|
+
end: node.end as number,
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return setups
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function collectCleanupKinds(
|
|
112
|
+
cleanupFn: Record<string, unknown>,
|
|
113
|
+
helpers: {
|
|
114
|
+
getCalleeName: (node: unknown) => string | null
|
|
115
|
+
walk: (
|
|
116
|
+
node: unknown,
|
|
117
|
+
visitor: (node: Record<string, unknown>, parent: Record<string, unknown> | null) => void,
|
|
118
|
+
parent?: Record<string, unknown> | null,
|
|
119
|
+
) => void
|
|
120
|
+
},
|
|
121
|
+
) {
|
|
122
|
+
const kinds = new Set<SetupKind>()
|
|
123
|
+
const body = getFunctionBody(cleanupFn)
|
|
124
|
+
if (!body) {
|
|
125
|
+
return kinds
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
helpers.walk(body, (node) => {
|
|
129
|
+
if (node.type !== "CallExpression") {
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const calleeName = helpers.getCalleeName(node.callee)
|
|
134
|
+
if (calleeName === "clearInterval" || calleeName === "window.clearInterval") {
|
|
135
|
+
kinds.add("interval")
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (calleeName === "clearTimeout" || calleeName === "window.clearTimeout") {
|
|
140
|
+
kinds.add("timeout")
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (calleeName?.endsWith(".removeEventListener") || calleeName === "removeEventListener") {
|
|
145
|
+
kinds.add("event-listener")
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (calleeName?.endsWith(".disconnect") || calleeName === "disconnect") {
|
|
150
|
+
kinds.add("observer")
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
return kinds
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getSetupLabel(kind: SetupKind): string {
|
|
158
|
+
if (kind === "event-listener") {
|
|
159
|
+
return "event listeners"
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (kind === "observer") {
|
|
163
|
+
return "observers"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (kind === "timeout") {
|
|
167
|
+
return "timeouts"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return "intervals"
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const createRequireEffectCleanupGuard: GuardFactory = () => [
|
|
174
|
+
"guard/require-effect-cleanup",
|
|
175
|
+
{
|
|
176
|
+
fileExtensions: [".jsx", ".tsx", ".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"],
|
|
177
|
+
tokenPrefilter(source) {
|
|
178
|
+
return (
|
|
179
|
+
(source.includes("useEffect") || source.includes("useLayoutEffect")) &&
|
|
180
|
+
(source.includes("addEventListener") ||
|
|
181
|
+
source.includes("setInterval") ||
|
|
182
|
+
source.includes("setTimeout") ||
|
|
183
|
+
source.includes("MutationObserver") ||
|
|
184
|
+
source.includes("ResizeObserver"))
|
|
185
|
+
)
|
|
186
|
+
},
|
|
187
|
+
evaluate({ helpers, program }) {
|
|
188
|
+
const findings: GuardRawFinding[] = []
|
|
189
|
+
|
|
190
|
+
helpers.walk(program, (node) => {
|
|
191
|
+
if (node.type !== "CallExpression") {
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const hookName = helpers.getCalleeName(node.callee)
|
|
196
|
+
if (!hookName || !EFFECT_HOOKS.has(hookName)) {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const [callback] = Array.isArray(node.arguments) ? node.arguments : []
|
|
201
|
+
if (!callback || typeof callback !== "object" || !helpers.isFunctionLike(callback)) {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const callbackBody = getFunctionBody(callback as Record<string, unknown>)
|
|
206
|
+
if (!callbackBody) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const setups = collectSetups(callbackBody, helpers)
|
|
211
|
+
if (setups.length === 0) {
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const cleanupFn = getReturnCleanupFunction(callback as Record<string, unknown>)
|
|
216
|
+
const cleanupKinds = cleanupFn ? collectCleanupKinds(cleanupFn, helpers) : new Set()
|
|
217
|
+
|
|
218
|
+
for (const setup of setups) {
|
|
219
|
+
if (cleanupKinds.has(setup.kind)) {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
findings.push({
|
|
224
|
+
start: setup.start,
|
|
225
|
+
end: setup.end,
|
|
226
|
+
message: `Effects that create ${getSetupLabel(setup.kind)} must return a matching cleanup.`,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return findings
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { GuardFactory, GuardRawFinding } from "../types"
|
|
2
|
+
|
|
3
|
+
function isInlineUnstableExpression(node: Record<string, unknown>): boolean {
|
|
4
|
+
return (
|
|
5
|
+
node.type === "ObjectExpression" ||
|
|
6
|
+
node.type === "ArrayExpression" ||
|
|
7
|
+
node.type === "ArrowFunctionExpression" ||
|
|
8
|
+
node.type === "FunctionExpression" ||
|
|
9
|
+
node.type === "NewExpression"
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const createStableProviderValuesGuard: GuardFactory = () => [
|
|
14
|
+
"guard/stable-provider-values",
|
|
15
|
+
{
|
|
16
|
+
fileExtensions: [".jsx", ".tsx"],
|
|
17
|
+
tokenPrefilter(source) {
|
|
18
|
+
return source.includes("Provider") && source.includes("value=")
|
|
19
|
+
},
|
|
20
|
+
evaluate({ helpers, program }) {
|
|
21
|
+
const findings: GuardRawFinding[] = []
|
|
22
|
+
|
|
23
|
+
helpers.walk(program, (node, parent) => {
|
|
24
|
+
if (node.type !== "JSXAttribute") {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const attributeName = helpers.getJsxElementName(node.name)
|
|
29
|
+
if (attributeName !== "value") {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
!parent ||
|
|
35
|
+
parent.type !== "JSXOpeningElement" ||
|
|
36
|
+
!helpers.getJsxElementName(parent.name)?.endsWith("Provider")
|
|
37
|
+
) {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const valueNode = node.value
|
|
42
|
+
if (!valueNode || (valueNode as { type?: string }).type !== "JSXExpressionContainer") {
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const expression = (valueNode as { expression?: unknown }).expression
|
|
47
|
+
if (!expression || typeof expression !== "object") {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!isInlineUnstableExpression(expression as Record<string, unknown>)) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
findings.push({
|
|
56
|
+
start: node.start as number,
|
|
57
|
+
end: node.end as number,
|
|
58
|
+
message:
|
|
59
|
+
"Provider values should be referentially stable. Hoist or memoize the value before passing it to a Provider.",
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return findings
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
]
|
package/src/guards.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GUARD_FACTORIES } from "./guards/index"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export * from "./check"
|
|
2
|
+
export * from "./types"
|
|
3
|
+
export * from "./guards"
|
|
4
|
+
|
|
5
|
+
import type { GuardRuleId } from "./types"
|
|
6
|
+
|
|
7
|
+
export interface Ox0GuardRule {
|
|
8
|
+
readonly id: GuardRuleId
|
|
9
|
+
readonly severity: "error"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Ox0GuardsConfig {
|
|
13
|
+
readonly rules: readonly Ox0GuardRule[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function defineGuardsConfig(config: Ox0GuardsConfig): Ox0GuardsConfig {
|
|
17
|
+
return config
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const recommendedRules: readonly Ox0GuardRule[] = [
|
|
21
|
+
{ id: "guard/no-async-react-component", severity: "error" },
|
|
22
|
+
{ id: "guard/no-css-import", severity: "error" },
|
|
23
|
+
{ id: "guard/no-effect-set-state-chain", severity: "error" },
|
|
24
|
+
{ id: "guard/no-set-state-in-render", severity: "error" },
|
|
25
|
+
{ id: "guard/no-unsafe-type-assertion", severity: "error" },
|
|
26
|
+
{ id: "guard/stable-provider-values", severity: "error" },
|
|
27
|
+
{ id: "guard/no-web-storage", severity: "error" },
|
|
28
|
+
{ id: "guard/missing-label", severity: "error" },
|
|
29
|
+
{ id: "guard/require-effect-cleanup", severity: "error" },
|
|
30
|
+
]
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type GuardRuleId = `guard/${string}`
|
|
2
|
+
|
|
3
|
+
export type GuardFinding = {
|
|
4
|
+
ruleId: GuardRuleId
|
|
5
|
+
message: string
|
|
6
|
+
filePath: string
|
|
7
|
+
relativeFilePath: string
|
|
8
|
+
line: number
|
|
9
|
+
column: number
|
|
10
|
+
endLine: number
|
|
11
|
+
endColumn: number
|
|
12
|
+
severity: "error"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type GuardRawFinding = {
|
|
16
|
+
message: string
|
|
17
|
+
start: number
|
|
18
|
+
end?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type GuardAst = Record<string, unknown>
|
|
22
|
+
|
|
23
|
+
export type GuardHelperBundle = {
|
|
24
|
+
walk: (
|
|
25
|
+
node: unknown,
|
|
26
|
+
visitor: (node: Record<string, unknown>, parent: Record<string, unknown> | null) => void,
|
|
27
|
+
parent?: Record<string, unknown> | null,
|
|
28
|
+
) => void
|
|
29
|
+
getCalleeName: (node: unknown) => string | null
|
|
30
|
+
getJsxElementName: (node: unknown) => string | null
|
|
31
|
+
getStringLiteralValue: (node: unknown) => string | null
|
|
32
|
+
isFunctionLike: (node: unknown) => boolean
|
|
33
|
+
getFunctionName: (node: unknown, parent: unknown) => string | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type GuardRuleContext = {
|
|
37
|
+
ruleId: GuardRuleId
|
|
38
|
+
projectRoot: string
|
|
39
|
+
filePath: string
|
|
40
|
+
relativeFilePath: string
|
|
41
|
+
source: string
|
|
42
|
+
helpers: GuardHelperBundle
|
|
43
|
+
program: GuardAst
|
|
44
|
+
comments: Array<Record<string, unknown>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type GuardTextRuleContext = {
|
|
48
|
+
ruleId: GuardRuleId
|
|
49
|
+
projectRoot: string
|
|
50
|
+
filePath: string
|
|
51
|
+
relativeFilePath: string
|
|
52
|
+
source: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type GuardRuleDefinition = {
|
|
56
|
+
fileExtensions: string[]
|
|
57
|
+
tokenPrefilter: (source: string) => boolean
|
|
58
|
+
evaluate?: (context: GuardRuleContext) => GuardRawFinding[]
|
|
59
|
+
evaluateText?: (context: GuardTextRuleContext) => GuardRawFinding[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type GuardFactory = () => [GuardRuleId, GuardRuleDefinition]
|
|
63
|
+
|
|
64
|
+
export type RunGuardsOptions = {
|
|
65
|
+
cwd?: string
|
|
66
|
+
projectRoot: string
|
|
67
|
+
requestedRules?: string[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type RunGuardsResult = {
|
|
71
|
+
findings: GuardFinding[]
|
|
72
|
+
ruleIds: GuardRuleId[]
|
|
73
|
+
}
|