@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.
@@ -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
+ }