@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/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ox0/guards",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"bin": {
|
|
5
|
+
"ox0-guards": "./bin/ox0-guards.js"
|
|
6
|
+
},
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src",
|
|
10
|
+
"bin"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"oxc-parser": "^0.134.0",
|
|
24
|
+
"tsx": "^4.19.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "rslib build -c ../../rslib.config.ts",
|
|
28
|
+
"check": "node ./bin/ox0-guards.js --project ../..",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
30
|
+
"lint": "oxlint .",
|
|
31
|
+
"test": "vitest run"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
function isNode(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return (
|
|
3
|
+
Boolean(value) &&
|
|
4
|
+
typeof value === "object" &&
|
|
5
|
+
typeof (value as { type?: unknown }).type === "string"
|
|
6
|
+
)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function walkChild(
|
|
10
|
+
child: unknown,
|
|
11
|
+
visitor: (node: Record<string, unknown>, parent: Record<string, unknown> | null) => void,
|
|
12
|
+
parent: Record<string, unknown> | null,
|
|
13
|
+
) {
|
|
14
|
+
if (Array.isArray(child)) {
|
|
15
|
+
for (const entry of child) {
|
|
16
|
+
walk(entry, visitor, parent)
|
|
17
|
+
}
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
walk(child, visitor, parent)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function walk(
|
|
25
|
+
node: unknown,
|
|
26
|
+
visitor: (node: Record<string, unknown>, parent: Record<string, unknown> | null) => void,
|
|
27
|
+
parent: Record<string, unknown> | null = null,
|
|
28
|
+
) {
|
|
29
|
+
if (!isNode(node)) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
visitor(node, parent)
|
|
34
|
+
|
|
35
|
+
for (const [key, value] of Object.entries(node)) {
|
|
36
|
+
if (key === "parent") {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
walkChild(value, visitor, node)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getIdentifierName(node: unknown): string | null {
|
|
45
|
+
if (!isNode(node)) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (node.type === "Identifier" || node.type === "JSXIdentifier") {
|
|
50
|
+
return typeof node.name === "string" ? node.name : null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getCalleeName(node: unknown): string | null {
|
|
57
|
+
if (!isNode(node)) {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (node.type === "Identifier") {
|
|
62
|
+
return typeof node.name === "string" ? node.name : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (node.type === "MemberExpression") {
|
|
66
|
+
const objectName = getCalleeName(node.object)
|
|
67
|
+
const propertyName = getIdentifierName(node.property)
|
|
68
|
+
return objectName && propertyName ? `${objectName}.${propertyName}` : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getJsxElementName(node: unknown): string | null {
|
|
75
|
+
if (!isNode(node)) {
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (node.type === "JSXIdentifier") {
|
|
80
|
+
return typeof node.name === "string" ? node.name : null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (node.type === "JSXMemberExpression") {
|
|
84
|
+
const objectName = getJsxElementName(node.object)
|
|
85
|
+
const propertyName = getJsxElementName(node.property)
|
|
86
|
+
return objectName && propertyName ? `${objectName}.${propertyName}` : null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getStringLiteralValue(node: unknown): string | null {
|
|
93
|
+
if (!isNode(node)) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (node.type === "Literal" && typeof node.value === "string") {
|
|
98
|
+
return node.value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
node.type === "TemplateLiteral" &&
|
|
103
|
+
Array.isArray(node.expressions) &&
|
|
104
|
+
node.expressions.length === 0
|
|
105
|
+
) {
|
|
106
|
+
const quasis = Array.isArray(node.quasis) ? node.quasis : []
|
|
107
|
+
return quasis
|
|
108
|
+
.map((quasi) => {
|
|
109
|
+
if (!isNode(quasi)) {
|
|
110
|
+
return ""
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const value = quasi.value
|
|
114
|
+
if (
|
|
115
|
+
!value ||
|
|
116
|
+
typeof value !== "object" ||
|
|
117
|
+
typeof (value as { cooked?: unknown }).cooked !== "string"
|
|
118
|
+
) {
|
|
119
|
+
return ""
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (value as { cooked: string }).cooked
|
|
123
|
+
})
|
|
124
|
+
.join("")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isFunctionLike(node: unknown): boolean {
|
|
131
|
+
return (
|
|
132
|
+
isNode(node) &&
|
|
133
|
+
(node.type === "FunctionDeclaration" ||
|
|
134
|
+
node.type === "FunctionExpression" ||
|
|
135
|
+
node.type === "ArrowFunctionExpression")
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getFunctionName(node: unknown, parent: unknown): string | null {
|
|
140
|
+
if (!isNode(node)) {
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if ("id" in node && isNode(node.id) && typeof node.id.name === "string") {
|
|
145
|
+
return node.id.name
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
isNode(parent) &&
|
|
150
|
+
parent.type === "VariableDeclarator" &&
|
|
151
|
+
isNode(parent.id) &&
|
|
152
|
+
parent.id.type === "Identifier" &&
|
|
153
|
+
typeof parent.id.name === "string"
|
|
154
|
+
) {
|
|
155
|
+
return parent.id.name
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const GUARD_HELPERS = {
|
|
162
|
+
walk,
|
|
163
|
+
getCalleeName,
|
|
164
|
+
getJsxElementName,
|
|
165
|
+
getStringLiteralValue,
|
|
166
|
+
isFunctionLike,
|
|
167
|
+
getFunctionName,
|
|
168
|
+
}
|
package/src/check.ts
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import { parseSync } from "oxc-parser"
|
|
5
|
+
|
|
6
|
+
import { GUARD_HELPERS } from "./ast-helpers"
|
|
7
|
+
import { GUARD_FACTORIES } from "./guards"
|
|
8
|
+
import type {
|
|
9
|
+
GuardFinding,
|
|
10
|
+
GuardRawFinding,
|
|
11
|
+
GuardRuleDefinition,
|
|
12
|
+
GuardRuleId,
|
|
13
|
+
RunGuardsOptions,
|
|
14
|
+
RunGuardsResult,
|
|
15
|
+
} from "./types"
|
|
16
|
+
|
|
17
|
+
const INTERNAL_INVALID_DISABLE_RULE = "guard/invalid-disable-comment" as const
|
|
18
|
+
const INTERNAL_PARSE_ERROR_RULE = "guard/parse-error" as const
|
|
19
|
+
const SCRIPT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"])
|
|
20
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
21
|
+
".git",
|
|
22
|
+
".idea",
|
|
23
|
+
".next",
|
|
24
|
+
".rspress",
|
|
25
|
+
".turbo",
|
|
26
|
+
"coverage",
|
|
27
|
+
"dist",
|
|
28
|
+
"doc_build",
|
|
29
|
+
"node_modules",
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
type Suppression = {
|
|
33
|
+
ruleId: GuardRuleId
|
|
34
|
+
startLine: number
|
|
35
|
+
endLine: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type InvalidDisable = {
|
|
39
|
+
line: number
|
|
40
|
+
column: number
|
|
41
|
+
message: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type ParsedDisableDirective = {
|
|
45
|
+
type: "next-line" | "start" | "end"
|
|
46
|
+
ruleId: string
|
|
47
|
+
rationale: string
|
|
48
|
+
line: number
|
|
49
|
+
column: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isScriptFile(filePath: string): boolean {
|
|
53
|
+
return SCRIPT_EXTENSIONS.has(path.extname(filePath))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function shouldExcludeDirectory(relativeDirPath: string): boolean {
|
|
57
|
+
return relativeDirPath
|
|
58
|
+
.split(path.sep)
|
|
59
|
+
.some((segment) => segment.length > 0 && EXCLUDED_DIRECTORIES.has(segment))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectProjectFiles(projectRoot: string, allowedExtensions: Set<string>): string[] {
|
|
63
|
+
const files: string[] = []
|
|
64
|
+
|
|
65
|
+
function walkDirectory(currentDir: string): void {
|
|
66
|
+
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const fullPath = path.join(currentDir, entry.name)
|
|
70
|
+
const relativePath = path.relative(projectRoot, fullPath)
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
if (shouldExcludeDirectory(relativePath)) {
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
walkDirectory(fullPath)
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (allowedExtensions.has(path.extname(entry.name))) {
|
|
82
|
+
files.push(fullPath)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
walkDirectory(projectRoot)
|
|
88
|
+
return files.toSorted()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createLineStarts(source: string): number[] {
|
|
92
|
+
const starts = [0]
|
|
93
|
+
|
|
94
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
95
|
+
if (source[index] === "\n") {
|
|
96
|
+
starts.push(index + 1)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return starts
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getLocationFromOffset(lineStarts: number[], offset: number) {
|
|
104
|
+
let low = 0
|
|
105
|
+
let high = lineStarts.length - 1
|
|
106
|
+
|
|
107
|
+
while (low <= high) {
|
|
108
|
+
const mid = Math.floor((low + high) / 2)
|
|
109
|
+
const start = lineStarts[mid]
|
|
110
|
+
const nextStart = lineStarts[mid + 1] ?? Number.POSITIVE_INFINITY
|
|
111
|
+
|
|
112
|
+
if (offset < start) {
|
|
113
|
+
high = mid - 1
|
|
114
|
+
} else if (offset >= nextStart) {
|
|
115
|
+
low = mid + 1
|
|
116
|
+
} else {
|
|
117
|
+
return {
|
|
118
|
+
line: mid + 1,
|
|
119
|
+
column: offset - lineStarts[mid] + 1,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
line: 1,
|
|
126
|
+
column: 1,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildFinding(
|
|
131
|
+
ruleId: GuardRuleId,
|
|
132
|
+
rawFinding: GuardRawFinding,
|
|
133
|
+
filePath: string,
|
|
134
|
+
relativeFilePath: string,
|
|
135
|
+
lineStarts: number[],
|
|
136
|
+
): GuardFinding {
|
|
137
|
+
const startLocation = getLocationFromOffset(lineStarts, rawFinding.start)
|
|
138
|
+
const endLocation = getLocationFromOffset(lineStarts, rawFinding.end ?? rawFinding.start)
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ruleId,
|
|
142
|
+
message: rawFinding.message,
|
|
143
|
+
filePath,
|
|
144
|
+
relativeFilePath,
|
|
145
|
+
line: startLocation.line,
|
|
146
|
+
column: startLocation.column,
|
|
147
|
+
endLine: endLocation.line,
|
|
148
|
+
endColumn: endLocation.column,
|
|
149
|
+
severity: "error",
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseDisableDirective(line: string, lineNumber: number): ParsedDisableDirective | null {
|
|
154
|
+
const match = line.match(
|
|
155
|
+
/^\s*\/\/\s*guard-disable-(next-line|start|end)\s+(\S+)(?:\s+(.*\S))?\s*$/,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if (!match) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
type: match[1] as ParsedDisableDirective["type"],
|
|
164
|
+
ruleId: match[2],
|
|
165
|
+
rationale: match[3] ?? "",
|
|
166
|
+
line: lineNumber,
|
|
167
|
+
column: line.indexOf("//") + 1,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseSuppressions(source: string, knownRuleIds: Set<GuardRuleId>) {
|
|
172
|
+
const suppressions: Suppression[] = []
|
|
173
|
+
const invalid: InvalidDisable[] = []
|
|
174
|
+
const openBlocks = new Map<string, ParsedDisableDirective>()
|
|
175
|
+
const lines = source.split(/\r?\n/)
|
|
176
|
+
|
|
177
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
178
|
+
const lineNumber = index + 1
|
|
179
|
+
const directive = parseDisableDirective(lines[index], lineNumber)
|
|
180
|
+
|
|
181
|
+
if (!directive) {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!knownRuleIds.has(directive.ruleId as GuardRuleId)) {
|
|
186
|
+
invalid.push({
|
|
187
|
+
line: directive.line,
|
|
188
|
+
column: directive.column,
|
|
189
|
+
message: `Disable comment references unknown rule "${directive.ruleId}".`,
|
|
190
|
+
})
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
(directive.type === "next-line" || directive.type === "start") &&
|
|
196
|
+
directive.rationale.trim() === ""
|
|
197
|
+
) {
|
|
198
|
+
invalid.push({
|
|
199
|
+
line: directive.line,
|
|
200
|
+
column: directive.column,
|
|
201
|
+
message: `Disable comment for ${directive.ruleId} must include a rationale.`,
|
|
202
|
+
})
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (directive.type === "next-line") {
|
|
207
|
+
suppressions.push({
|
|
208
|
+
ruleId: directive.ruleId as GuardRuleId,
|
|
209
|
+
startLine: directive.line + 1,
|
|
210
|
+
endLine: directive.line + 1,
|
|
211
|
+
})
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (directive.type === "start") {
|
|
216
|
+
openBlocks.set(directive.ruleId, directive)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const startDirective = openBlocks.get(directive.ruleId)
|
|
221
|
+
if (!startDirective) {
|
|
222
|
+
invalid.push({
|
|
223
|
+
line: directive.line,
|
|
224
|
+
column: directive.column,
|
|
225
|
+
message: `guard-disable-end for ${directive.ruleId} has no matching start.`,
|
|
226
|
+
})
|
|
227
|
+
continue
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
suppressions.push({
|
|
231
|
+
ruleId: directive.ruleId as GuardRuleId,
|
|
232
|
+
startLine: startDirective.line + 1,
|
|
233
|
+
endLine: Math.max(startDirective.line + 1, directive.line - 1),
|
|
234
|
+
})
|
|
235
|
+
openBlocks.delete(directive.ruleId)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const directive of openBlocks.values()) {
|
|
239
|
+
invalid.push({
|
|
240
|
+
line: directive.line,
|
|
241
|
+
column: directive.column,
|
|
242
|
+
message: `guard-disable-start for ${directive.ruleId} is missing a matching end.`,
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { suppressions, invalid }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isSuppressed(finding: GuardFinding, suppressions: Suppression[]): boolean {
|
|
250
|
+
return suppressions.some(
|
|
251
|
+
(suppression) =>
|
|
252
|
+
suppression.ruleId === finding.ruleId &&
|
|
253
|
+
finding.line >= suppression.startLine &&
|
|
254
|
+
finding.line <= suppression.endLine,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function sortFindings(findings: GuardFinding[]): void {
|
|
259
|
+
const sortedFindings = findings.toSorted((left, right) => {
|
|
260
|
+
if (left.relativeFilePath !== right.relativeFilePath) {
|
|
261
|
+
return left.relativeFilePath.localeCompare(right.relativeFilePath)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (left.line !== right.line) {
|
|
265
|
+
return left.line - right.line
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (left.column !== right.column) {
|
|
269
|
+
return left.column - right.column
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return left.ruleId.localeCompare(right.ruleId)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
findings.splice(0, findings.length, ...sortedFindings)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function getRuleRegistry(): Map<GuardRuleId, GuardRuleDefinition> {
|
|
279
|
+
const registry = new Map<GuardRuleId, GuardRuleDefinition>()
|
|
280
|
+
|
|
281
|
+
for (const factory of GUARD_FACTORIES) {
|
|
282
|
+
const [ruleId, definition] = factory()
|
|
283
|
+
registry.set(ruleId, definition)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return registry
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function listGuardRuleIds(): GuardRuleId[] {
|
|
290
|
+
return [...getRuleRegistry().keys()].toSorted()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function runGuards(options: RunGuardsOptions): RunGuardsResult {
|
|
294
|
+
const projectRoot = path.resolve(options.cwd ?? process.cwd(), options.projectRoot)
|
|
295
|
+
const registry = getRuleRegistry()
|
|
296
|
+
const knownRuleIds = new Set<GuardRuleId>([
|
|
297
|
+
...registry.keys(),
|
|
298
|
+
INTERNAL_INVALID_DISABLE_RULE,
|
|
299
|
+
INTERNAL_PARSE_ERROR_RULE,
|
|
300
|
+
])
|
|
301
|
+
const requestedRuleIds = options.requestedRules?.length
|
|
302
|
+
? options.requestedRules.map((ruleId) => ruleId as GuardRuleId)
|
|
303
|
+
: [...registry.keys()]
|
|
304
|
+
|
|
305
|
+
for (const ruleId of requestedRuleIds) {
|
|
306
|
+
if (!registry.has(ruleId)) {
|
|
307
|
+
throw new Error(`Unknown guard rule: ${ruleId}`)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const selectedEntries = [...registry.entries()].filter(([ruleId]) =>
|
|
312
|
+
requestedRuleIds.includes(ruleId),
|
|
313
|
+
)
|
|
314
|
+
const allowedExtensions = new Set<string>()
|
|
315
|
+
|
|
316
|
+
for (const [, definition] of selectedEntries) {
|
|
317
|
+
for (const extension of definition.fileExtensions) {
|
|
318
|
+
allowedExtensions.add(extension)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const files = collectProjectFiles(projectRoot, allowedExtensions)
|
|
323
|
+
const findings: GuardFinding[] = []
|
|
324
|
+
|
|
325
|
+
for (const filePath of files) {
|
|
326
|
+
const relativeFilePath = path.relative(projectRoot, filePath)
|
|
327
|
+
const source = fs.readFileSync(filePath, "utf8")
|
|
328
|
+
const lineStarts = createLineStarts(source)
|
|
329
|
+
const { suppressions, invalid } = parseSuppressions(source, knownRuleIds)
|
|
330
|
+
|
|
331
|
+
for (const invalidFinding of invalid) {
|
|
332
|
+
findings.push({
|
|
333
|
+
ruleId: INTERNAL_INVALID_DISABLE_RULE,
|
|
334
|
+
message: invalidFinding.message,
|
|
335
|
+
filePath,
|
|
336
|
+
relativeFilePath,
|
|
337
|
+
line: invalidFinding.line,
|
|
338
|
+
column: invalidFinding.column,
|
|
339
|
+
endLine: invalidFinding.line,
|
|
340
|
+
endColumn: invalidFinding.column,
|
|
341
|
+
severity: "error",
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const extension = path.extname(filePath)
|
|
346
|
+
const applicableRules = selectedEntries.filter(
|
|
347
|
+
([, definition]) =>
|
|
348
|
+
definition.fileExtensions.includes(extension) && definition.tokenPrefilter(source),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if (applicableRules.length === 0) {
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const [ruleId, definition] of applicableRules.filter(
|
|
356
|
+
([, candidate]) => typeof candidate.evaluateText === "function",
|
|
357
|
+
)) {
|
|
358
|
+
const rawFindings =
|
|
359
|
+
definition.evaluateText?.({
|
|
360
|
+
ruleId,
|
|
361
|
+
projectRoot,
|
|
362
|
+
filePath,
|
|
363
|
+
relativeFilePath,
|
|
364
|
+
source,
|
|
365
|
+
}) ?? []
|
|
366
|
+
|
|
367
|
+
for (const rawFinding of rawFindings) {
|
|
368
|
+
const finding = buildFinding(ruleId, rawFinding, filePath, relativeFilePath, lineStarts)
|
|
369
|
+
if (!isSuppressed(finding, suppressions)) {
|
|
370
|
+
findings.push(finding)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const astRules = applicableRules.filter(
|
|
376
|
+
([, definition]) => typeof definition.evaluate === "function" && isScriptFile(filePath),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if (astRules.length === 0) {
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const parseResult = parseSync(relativeFilePath, source, { astType: "ts" })
|
|
384
|
+
|
|
385
|
+
if (Array.isArray(parseResult.errors) && parseResult.errors.length > 0) {
|
|
386
|
+
for (const error of parseResult.errors) {
|
|
387
|
+
const start = "start" in error && typeof error.start === "number" ? error.start : 0
|
|
388
|
+
findings.push(
|
|
389
|
+
buildFinding(
|
|
390
|
+
INTERNAL_PARSE_ERROR_RULE,
|
|
391
|
+
{
|
|
392
|
+
start,
|
|
393
|
+
end: start,
|
|
394
|
+
message: `Parse error: ${String(error.message ?? "unknown parser error")}`,
|
|
395
|
+
},
|
|
396
|
+
filePath,
|
|
397
|
+
relativeFilePath,
|
|
398
|
+
lineStarts,
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const [ruleId, definition] of astRules) {
|
|
406
|
+
const rawFindings =
|
|
407
|
+
definition.evaluate?.({
|
|
408
|
+
ruleId,
|
|
409
|
+
projectRoot,
|
|
410
|
+
filePath,
|
|
411
|
+
relativeFilePath,
|
|
412
|
+
source,
|
|
413
|
+
helpers: GUARD_HELPERS,
|
|
414
|
+
program: parseResult.program as unknown as Record<string, unknown>,
|
|
415
|
+
comments: (parseResult.comments as unknown as Array<Record<string, unknown>>) ?? [],
|
|
416
|
+
}) ?? []
|
|
417
|
+
|
|
418
|
+
for (const rawFinding of rawFindings) {
|
|
419
|
+
const finding = buildFinding(ruleId, rawFinding, filePath, relativeFilePath, lineStarts)
|
|
420
|
+
if (!isSuppressed(finding, suppressions)) {
|
|
421
|
+
findings.push(finding)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
sortFindings(findings)
|
|
428
|
+
return { findings, ruleIds: listGuardRuleIds() }
|
|
429
|
+
}
|