oxlint-plugin-arch 0.2.1
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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/package.json +64 -0
- package/src/index.ts +57 -0
- package/src/rules/export-file-prefix.ts +70 -0
- package/src/rules/export-name-pattern.ts +71 -0
- package/src/rules/filename-export-name.ts +86 -0
- package/src/rules/filename-match.ts +47 -0
- package/src/rules/folder-prefix.ts +73 -0
- package/src/rules/no-extra-exports.ts +60 -0
- package/src/rules/no-extra-factory-keys.ts +58 -0
- package/src/rules/no-file-level-helpers.ts +58 -0
- package/src/rules/no-imported-type-alias.ts +83 -0
- package/src/rules/no-inline-schema-elements.ts +70 -0
- package/src/rules/no-local-schema-construction.ts +162 -0
- package/src/rules/no-rederive-schema.ts +66 -0
- package/src/rules/no-restricted-files.ts +40 -0
- package/src/rules/no-restricted-token.ts +71 -0
- package/src/rules/no-runtime-in-types.ts +72 -0
- package/src/rules/no-single-use-scalar-schema.ts +164 -0
- package/src/rules/no-top-level-functions.ts +77 -0
- package/src/rules/no-trivial-functions.ts +56 -0
- package/src/rules/no-unescaped-like.ts +67 -0
- package/src/rules/only-export-components.ts +78 -0
- package/src/rules/require-file-factory.ts +67 -0
- package/src/rules/require-object-params.ts +32 -0
- package/src/rules/require-orpc-output.ts +64 -0
- package/src/rules/require-paired-call.ts +71 -0
- package/src/rules/route-surface.ts +62 -0
- package/src/utils/helpers/ast.ts +101 -0
- package/src/utils/helpers/declarations.ts +203 -0
- package/src/utils/helpers/exports.ts +158 -0
- package/src/utils/helpers/factories.ts +67 -0
- package/src/utils/helpers/index.ts +11 -0
- package/src/utils/helpers/naming.ts +89 -0
- package/src/utils/helpers/options.ts +5 -0
- package/src/utils/helpers/orpc.ts +31 -0
- package/src/utils/helpers/react-components.ts +146 -0
- package/src/utils/helpers/routes.ts +31 -0
- package/src/utils/helpers/sanitizers.ts +33 -0
- package/src/utils/helpers/schemas.ts +82 -0
- package/src/utils/index.ts +1 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import {
|
|
3
|
+
astStaticMemberName,
|
|
4
|
+
exportsCollect,
|
|
5
|
+
optionsFirst,
|
|
6
|
+
schemasElements,
|
|
7
|
+
schemasIsAllowedElement,
|
|
8
|
+
schemasIsCall,
|
|
9
|
+
} from '../utils/index.ts'
|
|
10
|
+
import type { ESTree } from '@oxlint/plugins'
|
|
11
|
+
|
|
12
|
+
interface NoSingleUseScalarSchemaOptions {
|
|
13
|
+
namespaces: string[]
|
|
14
|
+
methods: string[]
|
|
15
|
+
structuralMethods: string[]
|
|
16
|
+
allowZodScalars: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isScalarSchema(
|
|
20
|
+
node: ESTree.Expression,
|
|
21
|
+
namespaces: Set<string>,
|
|
22
|
+
structuralMethods: Set<string>,
|
|
23
|
+
): node is ESTree.CallExpression {
|
|
24
|
+
if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (schemasIsCall(node, namespaces)) {
|
|
29
|
+
const method = astStaticMemberName(node.callee)
|
|
30
|
+
return Boolean(method && !structuralMethods.has(method))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const modifier = astStaticMemberName(node.callee)
|
|
34
|
+
return Boolean(
|
|
35
|
+
modifier
|
|
36
|
+
&& !structuralMethods.has(modifier)
|
|
37
|
+
&& node.callee.object.type === 'CallExpression'
|
|
38
|
+
&& isScalarSchema(node.callee.object, namespaces, structuralMethods),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isDirectObjectField(
|
|
43
|
+
identifier: ESTree.IdentifierReference,
|
|
44
|
+
ancestors: ESTree.Node[],
|
|
45
|
+
namespaces: Set<string>,
|
|
46
|
+
structuralMethods: Set<string>,
|
|
47
|
+
): boolean {
|
|
48
|
+
const property = ancestors.at(-1)
|
|
49
|
+
const object = ancestors.at(-2)
|
|
50
|
+
const call = ancestors.at(-3)
|
|
51
|
+
|
|
52
|
+
if (property?.type !== 'Property' || property.value !== identifier || object?.type !== 'ObjectExpression'
|
|
53
|
+
|| call?.type !== 'CallExpression' || !schemasIsCall(call, namespaces)) {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const method = call.callee.type === 'MemberExpression' ? astStaticMemberName(call.callee) : undefined
|
|
58
|
+
return Boolean(method === 'object' && structuralMethods.has(method) && call.arguments.includes(object))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isAllowedStructuralElement(
|
|
62
|
+
identifier: ESTree.IdentifierReference,
|
|
63
|
+
initializer: ESTree.CallExpression,
|
|
64
|
+
ancestors: ESTree.Node[],
|
|
65
|
+
options: NoSingleUseScalarSchemaOptions,
|
|
66
|
+
namespaces: Set<string>,
|
|
67
|
+
structuralMethods: Set<string>,
|
|
68
|
+
): boolean {
|
|
69
|
+
if (!options.allowZodScalars || !schemasIsAllowedElement(initializer, true, namespaces, structuralMethods)) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const parent = ancestors.at(-1)
|
|
74
|
+
const call = parent?.type === 'ArrayExpression' ? ancestors.at(-2) : parent
|
|
75
|
+
|
|
76
|
+
if (call?.type !== 'CallExpression' || !schemasIsCall(call, namespaces)) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const method = call.callee.type === 'MemberExpression' ? astStaticMemberName(call.callee) : undefined
|
|
81
|
+
return Boolean(method && options.methods.includes(method) && schemasElements(call).includes(identifier))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reports a local scalar Zod schema binding used exactly once where the scalar can be written inline.
|
|
86
|
+
*
|
|
87
|
+
* Example: `const userId = z.number(); z.object({ id: userId })` fails; exported, reused, or inferred bindings pass.
|
|
88
|
+
*/
|
|
89
|
+
export const noSingleUseScalarSchema = defineRule({
|
|
90
|
+
meta: {
|
|
91
|
+
type: 'suggestion',
|
|
92
|
+
schema: [{
|
|
93
|
+
type: 'object',
|
|
94
|
+
additionalProperties: false,
|
|
95
|
+
properties: {
|
|
96
|
+
namespaces: { type: 'array', items: { type: 'string' } },
|
|
97
|
+
methods: { type: 'array', items: { type: 'string' } },
|
|
98
|
+
structuralMethods: { type: 'array', items: { type: 'string' } },
|
|
99
|
+
allowZodScalars: { type: 'boolean' },
|
|
100
|
+
},
|
|
101
|
+
}],
|
|
102
|
+
defaultOptions: [{
|
|
103
|
+
namespaces: ['z'],
|
|
104
|
+
methods: ['array', 'union', 'record', 'tuple'],
|
|
105
|
+
structuralMethods: ['and', 'array', 'discriminatedUnion', 'intersection', 'lazy', 'object', 'or', 'pipe', 'record', 'transform', 'tuple', 'union'],
|
|
106
|
+
allowZodScalars: true,
|
|
107
|
+
}],
|
|
108
|
+
messages: { inline: 'Inline single-use scalar schema {{name}} at its only structural use.' },
|
|
109
|
+
},
|
|
110
|
+
create(context) {
|
|
111
|
+
const candidates: ESTree.VariableDeclarator[] = []
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
VariableDeclaration(node) {
|
|
115
|
+
if (node.kind !== 'const') {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const declarator of node.declarations) {
|
|
120
|
+
if (declarator.id.type === 'Identifier' && declarator.init) {
|
|
121
|
+
candidates.push(declarator)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
'Program:exit'(program) {
|
|
126
|
+
const options = optionsFirst<NoSingleUseScalarSchemaOptions>(context)
|
|
127
|
+
const namespaces = new Set(options.namespaces)
|
|
128
|
+
const structuralMethods = new Set(options.structuralMethods)
|
|
129
|
+
const exportedNames = new Set(exportsCollect(program).flatMap((binding) => binding.localName ? [binding.localName] : []))
|
|
130
|
+
|
|
131
|
+
for (const declarator of candidates) {
|
|
132
|
+
const name = declarator.id.type === 'Identifier' ? declarator.id.name : undefined
|
|
133
|
+
const initializer = declarator.init
|
|
134
|
+
|
|
135
|
+
if (!name || !initializer || exportedNames.has(name) || !isScalarSchema(initializer, namespaces, structuralMethods)) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const variable = context.sourceCode.getDeclaredVariables(declarator).find((candidate) => candidate.name === name)
|
|
140
|
+
const references = variable?.references.filter((reference) => reference.isRead()) ?? []
|
|
141
|
+
|
|
142
|
+
if (references.length !== 1) {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const reference = references[0]
|
|
147
|
+
const identifier = reference.identifier as ESTree.IdentifierReference
|
|
148
|
+
const ancestors = context.sourceCode.getAncestors(identifier) as unknown as ESTree.Node[]
|
|
149
|
+
const inlineable = isDirectObjectField(identifier, ancestors, namespaces, structuralMethods)
|
|
150
|
+
|| isAllowedStructuralElement(identifier, initializer, ancestors, options, namespaces, structuralMethods)
|
|
151
|
+
|
|
152
|
+
if (inlineable) {
|
|
153
|
+
// Deliberately report-only: moving construction can cross comments or observable statements.
|
|
154
|
+
context.report({
|
|
155
|
+
node: declarator.id,
|
|
156
|
+
messageId: 'inline',
|
|
157
|
+
data: { name },
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import { declarationsFunctionName, optionsFirst } from '../utils/index.ts'
|
|
3
|
+
|
|
4
|
+
interface NoTopLevelFunctionsOptions {
|
|
5
|
+
banReExports: boolean
|
|
6
|
+
allowPattern?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rejects top-level functions and function-valued variables, and can also reject re-exports.
|
|
11
|
+
*
|
|
12
|
+
* Example: A data-only constants file passes; `export const load = () => 1` fails.
|
|
13
|
+
*/
|
|
14
|
+
export const noTopLevelFunctions = defineRule({
|
|
15
|
+
meta: {
|
|
16
|
+
type: 'problem',
|
|
17
|
+
schema: [{
|
|
18
|
+
type: 'object',
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
properties: {
|
|
21
|
+
banReExports: { type: 'boolean' },
|
|
22
|
+
allowPattern: { type: 'string' },
|
|
23
|
+
},
|
|
24
|
+
}],
|
|
25
|
+
defaultOptions: [{ banReExports: true }],
|
|
26
|
+
messages: {
|
|
27
|
+
noFunction: 'Files in this scope must not contain top-level function {{name}}.',
|
|
28
|
+
noReExport: 'Files in this scope must not re-export values.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
createOnce(context) {
|
|
32
|
+
return {
|
|
33
|
+
Program(program) {
|
|
34
|
+
const { banReExports, allowPattern } = optionsFirst<NoTopLevelFunctionsOptions>(context)
|
|
35
|
+
const allowed = allowPattern ? new RegExp(allowPattern) : null
|
|
36
|
+
|
|
37
|
+
for (const statement of program.body) {
|
|
38
|
+
if (banReExports && (statement.type === 'ExportAllDeclaration'
|
|
39
|
+
|| (statement.type === 'ExportNamedDeclaration' && statement.source))) {
|
|
40
|
+
context.report({ node: statement, messageId: 'noReExport' })
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const declaration = statement.type === 'ExportNamedDeclaration' || statement.type === 'ExportDefaultDeclaration'
|
|
45
|
+
? statement.declaration
|
|
46
|
+
: statement
|
|
47
|
+
|
|
48
|
+
if (declaration?.type === 'FunctionDeclaration') {
|
|
49
|
+
if (!allowed?.test(declarationsFunctionName(declaration))) {
|
|
50
|
+
context.report({
|
|
51
|
+
node: declaration,
|
|
52
|
+
messageId: 'noFunction',
|
|
53
|
+
data: { name: declarationsFunctionName(declaration) },
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (declaration?.type !== 'VariableDeclaration') {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const item of declaration.declarations) {
|
|
64
|
+
if ((item.init?.type === 'FunctionExpression' || item.init?.type === 'ArrowFunctionExpression')
|
|
65
|
+
&& !allowed?.test(declarationsFunctionName(item))) {
|
|
66
|
+
context.report({
|
|
67
|
+
node: item,
|
|
68
|
+
messageId: 'noFunction',
|
|
69
|
+
data: { name: declarationsFunctionName(item) },
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import {
|
|
3
|
+
declarationsIsTrivialFunction,
|
|
4
|
+
declarationsTopLevelUnexportedFunctions,
|
|
5
|
+
exportsCollectFunctions,
|
|
6
|
+
optionsFirst,
|
|
7
|
+
} from '../utils/index.ts'
|
|
8
|
+
|
|
9
|
+
interface NoTrivialFunctionsOptions {
|
|
10
|
+
allowPattern?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rejects top-level functions that are empty or only forward a lookup, identifier, or other call.
|
|
15
|
+
*
|
|
16
|
+
* Example: `export function loadUser(id: string) { return fetchUser(id) }` fails; a function with branching or extra statements passes.
|
|
17
|
+
*/
|
|
18
|
+
export const noTrivialFunctions = defineRule({
|
|
19
|
+
meta: {
|
|
20
|
+
type: 'problem',
|
|
21
|
+
schema: [{
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
properties: {
|
|
25
|
+
allowPattern: { type: 'string' },
|
|
26
|
+
},
|
|
27
|
+
}],
|
|
28
|
+
messages: {
|
|
29
|
+
trivial: 'Function {{name}} does nothing useful; inline it at the call site.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
createOnce(context) {
|
|
33
|
+
return {
|
|
34
|
+
Program(program) {
|
|
35
|
+
const { allowPattern } = optionsFirst<NoTrivialFunctionsOptions>(context, {})
|
|
36
|
+
const allowed = allowPattern ? new RegExp(allowPattern) : null
|
|
37
|
+
const seen = new Set<string>()
|
|
38
|
+
|
|
39
|
+
for (const item of [...exportsCollectFunctions(program), ...declarationsTopLevelUnexportedFunctions(program)]) {
|
|
40
|
+
const key = `${item.name}:${item.node.start}:${item.node.end}`
|
|
41
|
+
|
|
42
|
+
if (seen.has(key) || allowed?.test(item.name) || !declarationsIsTrivialFunction(item.node)) {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
seen.add(key)
|
|
47
|
+
context.report({
|
|
48
|
+
node: item.node,
|
|
49
|
+
messageId: 'trivial',
|
|
50
|
+
data: { name: item.name },
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import {
|
|
3
|
+
astStaticMemberName,
|
|
4
|
+
optionsFirst,
|
|
5
|
+
sanitizersCallsConfigured,
|
|
6
|
+
sanitizersConfiguredBindings,
|
|
7
|
+
} from '../utils/index.ts'
|
|
8
|
+
|
|
9
|
+
interface NoUnescapedLikeOptions {
|
|
10
|
+
methods: string[]
|
|
11
|
+
sanitizers: string[]
|
|
12
|
+
allowSanitizedBindings?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Requires values passed to configured member methods to use a configured sanitizer call or sanitized binding.
|
|
17
|
+
*
|
|
18
|
+
* Example: `query.ilike(column, escape(term))` passes; passing `term` directly fails.
|
|
19
|
+
*/
|
|
20
|
+
export const noUnescapedLike = defineRule({
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'problem',
|
|
23
|
+
schema: [{
|
|
24
|
+
type: 'object',
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: {
|
|
27
|
+
methods: { type: 'array', items: { type: 'string' } },
|
|
28
|
+
sanitizers: { type: 'array', items: { type: 'string' } },
|
|
29
|
+
allowSanitizedBindings: { type: 'boolean' },
|
|
30
|
+
},
|
|
31
|
+
required: ['methods', 'sanitizers'],
|
|
32
|
+
}],
|
|
33
|
+
messages: { unescaped: '.{{method}}() values must use a configured sanitizer.' },
|
|
34
|
+
},
|
|
35
|
+
createOnce(context) {
|
|
36
|
+
let sanitized = new Set<string>()
|
|
37
|
+
return {
|
|
38
|
+
Program(program) {
|
|
39
|
+
const { sanitizers, allowSanitizedBindings = true } = optionsFirst<NoUnescapedLikeOptions>(context)
|
|
40
|
+
sanitized = allowSanitizedBindings ? sanitizersConfiguredBindings(program, new Set(sanitizers)) : new Set()
|
|
41
|
+
},
|
|
42
|
+
CallExpression(node) {
|
|
43
|
+
if (node.callee.type !== 'MemberExpression') {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { methods, sanitizers } = optionsFirst<NoUnescapedLikeOptions>(context)
|
|
48
|
+
const method = astStaticMemberName(node.callee)
|
|
49
|
+
|
|
50
|
+
if (!method || !methods.includes(method)) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const value = node.arguments.at(-1)
|
|
55
|
+
const allowedIdentifier = value?.type === 'Identifier' && sanitized.has(value.name)
|
|
56
|
+
|
|
57
|
+
if (!sanitizersCallsConfigured(value, new Set(sanitizers)) && !allowedIdentifier) {
|
|
58
|
+
context.report({
|
|
59
|
+
node: value ?? node,
|
|
60
|
+
messageId: 'unescaped',
|
|
61
|
+
data: { method },
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import {
|
|
3
|
+
namingFileBasename,
|
|
4
|
+
optionsFirst,
|
|
5
|
+
reactComponentsExportCandidates,
|
|
6
|
+
reactComponentsIsLike,
|
|
7
|
+
reactComponentsPascalFromBasename,
|
|
8
|
+
} from '../utils/index.ts'
|
|
9
|
+
|
|
10
|
+
interface OnlyExportComponentsOptions {
|
|
11
|
+
matchFileName?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Allows only React components and type exports, with optional matching between component names and filenames.
|
|
16
|
+
*
|
|
17
|
+
* Example: `button.tsx` exporting `Button` passes; exporting a plain `buttonConfig` value fails.
|
|
18
|
+
*/
|
|
19
|
+
export const onlyExportComponents = defineRule({
|
|
20
|
+
meta: {
|
|
21
|
+
type: 'problem',
|
|
22
|
+
schema: [{
|
|
23
|
+
type: 'object',
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
properties: {
|
|
26
|
+
matchFileName: { type: 'boolean' },
|
|
27
|
+
},
|
|
28
|
+
}],
|
|
29
|
+
messages: {
|
|
30
|
+
nonComponent: "Export '{{name}}' must be a React component or type.",
|
|
31
|
+
nameMismatch: "Component export '{{name}}' must match '{{expected}}' or its prefix.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
createOnce(context) {
|
|
35
|
+
return {
|
|
36
|
+
Program(program) {
|
|
37
|
+
const { matchFileName = true } = optionsFirst<OnlyExportComponentsOptions>(context, {})
|
|
38
|
+
const expected = reactComponentsPascalFromBasename(namingFileBasename(context.filename))
|
|
39
|
+
const components = []
|
|
40
|
+
|
|
41
|
+
for (const candidate of reactComponentsExportCandidates(program)) {
|
|
42
|
+
if (candidate.typeOnly) {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!reactComponentsIsLike(program, candidate)) {
|
|
47
|
+
context.report({
|
|
48
|
+
node: candidate.node,
|
|
49
|
+
messageId: 'nonComponent',
|
|
50
|
+
data: { name: candidate.name, expected },
|
|
51
|
+
})
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
components.push(candidate)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (components.length === 0 || !matchFileName) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const expectedKey = expected.toLowerCase()
|
|
63
|
+
const exact = components.some((candidate) => candidate.name.toLowerCase() === expectedKey)
|
|
64
|
+
const allPrefixed = components.every((candidate) => candidate.name.toLowerCase().startsWith(expectedKey))
|
|
65
|
+
|
|
66
|
+
if (!exact && !allPrefixed) {
|
|
67
|
+
for (const candidate of components) {
|
|
68
|
+
context.report({
|
|
69
|
+
node: candidate.node,
|
|
70
|
+
messageId: 'nameMismatch',
|
|
71
|
+
data: { name: candidate.name, expected },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import {
|
|
3
|
+
declarationsCollectFunctions,
|
|
4
|
+
exportsCollectFunctions,
|
|
5
|
+
namingFileBasename,
|
|
6
|
+
namingPascalCase,
|
|
7
|
+
optionsFirst,
|
|
8
|
+
} from '../utils/index.ts'
|
|
9
|
+
|
|
10
|
+
interface RequireFileFactoryOptions {
|
|
11
|
+
factory: string
|
|
12
|
+
allDeclarations?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Derives one expected factory name from the filename and requires that function to exist.
|
|
17
|
+
*
|
|
18
|
+
* Example: With `make{Stem}`, `email-renderer.ts` requires `makeEmailRenderer`.
|
|
19
|
+
*/
|
|
20
|
+
export const requireFileFactory = defineRule({
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'problem',
|
|
23
|
+
schema: [{
|
|
24
|
+
type: 'object',
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: {
|
|
27
|
+
factory: { type: 'string' },
|
|
28
|
+
allDeclarations: { type: 'boolean' },
|
|
29
|
+
},
|
|
30
|
+
required: ['factory'],
|
|
31
|
+
}],
|
|
32
|
+
messages: {
|
|
33
|
+
factory: "Function '{{actual}}' must be named '{{expected}}'.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
createOnce(context) {
|
|
37
|
+
return {
|
|
38
|
+
Program(program) {
|
|
39
|
+
const { factory, allDeclarations = false } = optionsFirst<RequireFileFactoryOptions>(context)
|
|
40
|
+
const stem = namingFileBasename(context.filename).replace(/\.(tsx?|jsx?)$/, '')
|
|
41
|
+
const expected = factory
|
|
42
|
+
.replaceAll('{Stem}', namingPascalCase(stem))
|
|
43
|
+
.replaceAll('{stem}', stem)
|
|
44
|
+
const functions = allDeclarations ? declarationsCollectFunctions(program) : exportsCollectFunctions(program)
|
|
45
|
+
|
|
46
|
+
if (functions.length === 0) {
|
|
47
|
+
context.report({
|
|
48
|
+
node: program,
|
|
49
|
+
messageId: 'factory',
|
|
50
|
+
data: { actual: '<none>', expected },
|
|
51
|
+
})
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const item of functions) {
|
|
56
|
+
if (item.name !== expected) {
|
|
57
|
+
context.report({
|
|
58
|
+
node: item.node,
|
|
59
|
+
messageId: 'factory',
|
|
60
|
+
data: { actual: item.name, expected },
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import { declarationsIsObjectParam, exportsCollectFunctions } from '../utils/index.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Requires exported functions to take at most one object-shaped parameter instead of positional arguments.
|
|
6
|
+
*
|
|
7
|
+
* Example: `createUser({ name })` passes; `createUser(name, email)` fails.
|
|
8
|
+
*/
|
|
9
|
+
export const requireObjectParams = defineRule({
|
|
10
|
+
meta: {
|
|
11
|
+
type: 'problem',
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
objectParams: 'Exported function {{name}} must accept at most one object parameter.',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
createOnce(context) {
|
|
18
|
+
return {
|
|
19
|
+
Program(program) {
|
|
20
|
+
for (const item of exportsCollectFunctions(program)) {
|
|
21
|
+
if (item.node.params.length > 1 || item.node.params.some((param) => !declarationsIsObjectParam(param))) {
|
|
22
|
+
context.report({
|
|
23
|
+
node: item.node,
|
|
24
|
+
messageId: 'objectParams',
|
|
25
|
+
data: { name: item.name },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import {
|
|
3
|
+
astStaticMemberName,
|
|
4
|
+
optionsFirst,
|
|
5
|
+
orpcFindChainCall,
|
|
6
|
+
orpcIsNamedOrComposedArgument,
|
|
7
|
+
} from '../utils/index.ts'
|
|
8
|
+
|
|
9
|
+
interface RequireOrpcOutputOptions {
|
|
10
|
+
composers: string[]
|
|
11
|
+
handlerMethod?: string
|
|
12
|
+
outputMethod?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Checks oRPC procedure chains for a configured output method before the configured handler method.
|
|
17
|
+
*
|
|
18
|
+
* Example: `.output(userSchema).handler(...)` passes; `.handler(...)` without output fails.
|
|
19
|
+
*/
|
|
20
|
+
export const requireOrpcOutput = defineRule({
|
|
21
|
+
meta: {
|
|
22
|
+
type: 'problem',
|
|
23
|
+
schema: [{
|
|
24
|
+
type: 'object',
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
properties: {
|
|
27
|
+
composers: { type: 'array', items: { type: 'string' } },
|
|
28
|
+
handlerMethod: { type: 'string' },
|
|
29
|
+
outputMethod: { type: 'string' },
|
|
30
|
+
},
|
|
31
|
+
required: ['composers'],
|
|
32
|
+
}],
|
|
33
|
+
messages: {
|
|
34
|
+
missing: 'oRPC procedures must declare .output() before .handler().',
|
|
35
|
+
invalid: 'oRPC .output() must use a named schema or configured composer with a named schema.',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
createOnce(context) {
|
|
39
|
+
return {
|
|
40
|
+
CallExpression(node) {
|
|
41
|
+
if (node.callee.type !== 'MemberExpression') {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { composers, handlerMethod = 'handler', outputMethod = 'output' } = optionsFirst<RequireOrpcOutputOptions>(context)
|
|
46
|
+
|
|
47
|
+
if (astStaticMemberName(node.callee) !== handlerMethod) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const output = orpcFindChainCall(node.callee.object, outputMethod)
|
|
52
|
+
|
|
53
|
+
if (!output) {
|
|
54
|
+
context.report({ node, messageId: 'missing' })
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!orpcIsNamedOrComposedArgument(output.arguments[0], new Set(composers))) {
|
|
59
|
+
context.report({ node: output, messageId: 'invalid' })
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { defineRule } from '@oxlint/plugins'
|
|
2
|
+
import { astImportedCallAliases, astResolvedCallName, optionsFirst } from '../utils/index.ts'
|
|
3
|
+
import type { ESTree } from '@oxlint/plugins'
|
|
4
|
+
|
|
5
|
+
interface RequirePairedCallOptions {
|
|
6
|
+
when: string
|
|
7
|
+
require: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Requires a second configured call whenever the trigger call appears anywhere in the same file.
|
|
12
|
+
*
|
|
13
|
+
* Example: A file calling `useForm` can require a call to `standardSchemaResolver`.
|
|
14
|
+
*/
|
|
15
|
+
export const requirePairedCall = defineRule({
|
|
16
|
+
meta: {
|
|
17
|
+
type: 'problem',
|
|
18
|
+
schema: [{
|
|
19
|
+
type: 'object',
|
|
20
|
+
additionalProperties: false,
|
|
21
|
+
properties: {
|
|
22
|
+
when: { type: 'string' },
|
|
23
|
+
require: { type: 'string' },
|
|
24
|
+
},
|
|
25
|
+
required: ['when', 'require'],
|
|
26
|
+
}],
|
|
27
|
+
messages: {
|
|
28
|
+
paired: '{{when}} requires a call to {{require}} in the same file.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
createOnce(context) {
|
|
32
|
+
let aliases = new Map<string, string>()
|
|
33
|
+
let firstWhen: ESTree.CallExpression | null = null
|
|
34
|
+
let sawRequired = false
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
before() {
|
|
38
|
+
aliases = new Map()
|
|
39
|
+
firstWhen = null
|
|
40
|
+
sawRequired = false
|
|
41
|
+
},
|
|
42
|
+
Program(program) {
|
|
43
|
+
aliases = astImportedCallAliases(program)
|
|
44
|
+
},
|
|
45
|
+
CallExpression(node) {
|
|
46
|
+
const { when, require } = optionsFirst<RequirePairedCallOptions>(context)
|
|
47
|
+
const name = astResolvedCallName(node, aliases)
|
|
48
|
+
|
|
49
|
+
if (name === when && !firstWhen) {
|
|
50
|
+
firstWhen = node
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (name === require) {
|
|
54
|
+
sawRequired = true
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
after() {
|
|
58
|
+
if (!firstWhen || sawRequired) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const { when, require } = optionsFirst<RequirePairedCallOptions>(context)
|
|
63
|
+
context.report({
|
|
64
|
+
node: firstWhen,
|
|
65
|
+
messageId: 'paired',
|
|
66
|
+
data: { when, require },
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
})
|