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