@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/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
+ ]