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.
Files changed (43) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/LICENSE +21 -0
  3. package/README.md +101 -0
  4. package/package.json +64 -0
  5. package/src/index.ts +57 -0
  6. package/src/rules/export-file-prefix.ts +70 -0
  7. package/src/rules/export-name-pattern.ts +71 -0
  8. package/src/rules/filename-export-name.ts +86 -0
  9. package/src/rules/filename-match.ts +47 -0
  10. package/src/rules/folder-prefix.ts +73 -0
  11. package/src/rules/no-extra-exports.ts +60 -0
  12. package/src/rules/no-extra-factory-keys.ts +58 -0
  13. package/src/rules/no-file-level-helpers.ts +58 -0
  14. package/src/rules/no-imported-type-alias.ts +83 -0
  15. package/src/rules/no-inline-schema-elements.ts +70 -0
  16. package/src/rules/no-local-schema-construction.ts +162 -0
  17. package/src/rules/no-rederive-schema.ts +66 -0
  18. package/src/rules/no-restricted-files.ts +40 -0
  19. package/src/rules/no-restricted-token.ts +71 -0
  20. package/src/rules/no-runtime-in-types.ts +72 -0
  21. package/src/rules/no-single-use-scalar-schema.ts +164 -0
  22. package/src/rules/no-top-level-functions.ts +77 -0
  23. package/src/rules/no-trivial-functions.ts +56 -0
  24. package/src/rules/no-unescaped-like.ts +67 -0
  25. package/src/rules/only-export-components.ts +78 -0
  26. package/src/rules/require-file-factory.ts +67 -0
  27. package/src/rules/require-object-params.ts +32 -0
  28. package/src/rules/require-orpc-output.ts +64 -0
  29. package/src/rules/require-paired-call.ts +71 -0
  30. package/src/rules/route-surface.ts +62 -0
  31. package/src/utils/helpers/ast.ts +101 -0
  32. package/src/utils/helpers/declarations.ts +203 -0
  33. package/src/utils/helpers/exports.ts +158 -0
  34. package/src/utils/helpers/factories.ts +67 -0
  35. package/src/utils/helpers/index.ts +11 -0
  36. package/src/utils/helpers/naming.ts +89 -0
  37. package/src/utils/helpers/options.ts +5 -0
  38. package/src/utils/helpers/orpc.ts +31 -0
  39. package/src/utils/helpers/react-components.ts +146 -0
  40. package/src/utils/helpers/routes.ts +31 -0
  41. package/src/utils/helpers/sanitizers.ts +33 -0
  42. package/src/utils/helpers/schemas.ts +82 -0
  43. 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
+ })