@longzai-intelligence-oxlint/nestjs-route-plugin 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 ADDED
@@ -0,0 +1,17 @@
1
+ # @longzai-intelligence-oxlint/nestjs-route-plugin
2
+
3
+ ## 意图
4
+
5
+ 在静态检查阶段守护 NestJS 路由层的规范性与类型安全,避免路由重复、顺序混乱与响应类型缺失。
6
+
7
+ ## 定位
8
+
9
+ Oxlint 生态的 NestJS 路由专属插件,通过 `eslintCompatPlugin` 包装以同时兼容 Oxlint 与 ESLint 运行时。
10
+
11
+ ## 职能
12
+
13
+ 提供路由顺序约束、路由去重、要求路由常量化、推荐类型化 Controller、要求类型化响应、强制类型化 handler 返回值等共六条规则;同时导出路由信息抽取、重复路由查找、Controller/ApiTags/Route 装饰器识别、handler 类型化判断等工具函数与类型供外部复用。
14
+
15
+ ## 实现情况
16
+
17
+ 六条规则及配套工具函数均已实现并按规则目录组织,规则、工具与类型一并导出;当前规则集合覆盖 NestJS 路由层主要规范与类型约束。
@@ -0,0 +1,200 @@
1
+ import { ESTree } from "@oxlint/plugins";
2
+ //#region src/rules/no-duplicates/index.d.ts
3
+ /**
4
+ * no-duplicates 规则定义
5
+ *
6
+ * 对重复组中除第一个之外的所有路由进行报告
7
+ */
8
+ declare const noDuplicatesRule: import("@oxlint/plugins").Rule;
9
+ //#endregion
10
+ //#region src/rules/order/index.d.ts
11
+ /**
12
+ * order 规则定义
13
+ *
14
+ * 检测同一 HTTP 方法组内静态路由出现在参数化路由之后的违规情况
15
+ */
16
+ declare const orderRule: import("@oxlint/plugins").Rule;
17
+ //#endregion
18
+ //#region src/rules/prefer-typed-controller/index.d.ts
19
+ /**
20
+ * prefer-typed-controller 规则定义
21
+ *
22
+ * 检测同时使用 Controller 和 ApiTags 装饰器的类,建议使用 TypedController
23
+ */
24
+ declare const preferTypedControllerRule: import("@oxlint/plugins").Rule;
25
+ //#endregion
26
+ //#region src/rules/require-route-constant/index.d.ts
27
+ /**
28
+ * require-route-constant 规则定义
29
+ *
30
+ * 检测 Controller 装饰器参数是否使用了禁止的表达式类型
31
+ */
32
+ declare const requireRouteConstantRule: import("@oxlint/plugins").Rule;
33
+ //#endregion
34
+ //#region src/rules/enforce-typed-handler-return/index.d.ts
35
+ /**
36
+ * enforce-typed-handler-return 规则定义
37
+ */
38
+ declare const enforceTypedHandlerReturnRule: import("@oxlint/plugins").Rule;
39
+ //#endregion
40
+ //#region src/rules/require-typed-response/index.d.ts
41
+ /**
42
+ * require-typed-response 规则定义
43
+ *
44
+ * 检测 defineHandler 解构是否包含响应函数,以及 TypedHandle 方法的 return 语句是否使用包装函数
45
+ */
46
+ declare const requireTypedResponseRule: import("@oxlint/plugins").Rule;
47
+ //#endregion
48
+ //#region src/types.d.ts
49
+ /**
50
+ * NestJS 路由管理插件类型定义
51
+ *
52
+ * 定义了路由信息、装饰器检测等核心类型
53
+ */
54
+ /**
55
+ * 路由信息
56
+ */
57
+ type RouteInfo = {
58
+ /**
59
+ * 路由路径
60
+ */
61
+ path: string;
62
+ /**
63
+ * HTTP 方法名称
64
+ */
65
+ method: string;
66
+ /**
67
+ * 是否为静态路由
68
+ */
69
+ isStatic: boolean;
70
+ };
71
+ /**
72
+ * require-route-constant 规则选项
73
+ */
74
+ type RequireRouteConstantOptions = {
75
+ /**
76
+ * 允许的路由常量标识符前缀
77
+ */
78
+ allowedIdentifiers?: string[];
79
+ /**
80
+ * 是否允许空参数
81
+ */
82
+ allowEmpty?: boolean;
83
+ };
84
+ /**
85
+ * prefer-typed-controller 规则选项
86
+ */
87
+ type PreferTypedControllerOptions = {
88
+ /**
89
+ * 是否检查 ApiTags 装饰器
90
+ */
91
+ checkApiTags?: boolean;
92
+ };
93
+ /**
94
+ * require-typed-response 规则选项
95
+ */
96
+ type RequireTypedResponseOptions = {
97
+ /**
98
+ * 有效的响应包装函数名列表
99
+ */
100
+ responseFunctions?: string[];
101
+ /**
102
+ * 是否允许直接返回(不经过包装函数)
103
+ */
104
+ allowDirectReturn?: boolean;
105
+ };
106
+ //#endregion
107
+ //#region src/utils/route.utils.d.ts
108
+ /**
109
+ * 判断给定路径是否为静态路由(不包含参数占位符)
110
+ *
111
+ * @param path - 路由路径字符串
112
+ * @returns 如果是静态路由返回 true
113
+ */
114
+ declare function isStaticRoute(path: string): boolean;
115
+ /**
116
+ * 判断装饰器表达式是否为路由装饰器(Get、Post 等)
117
+ *
118
+ * @param expression - 装饰器表达式节点
119
+ * @returns 是否为路由装饰器
120
+ */
121
+ declare function isRouteDecorator(expression: ESTree.Node): boolean;
122
+ /**
123
+ * 从类主体中提取所有路由信息
124
+ *
125
+ * @param body - 类主体成员数组
126
+ * @returns 路由信息数组
127
+ */
128
+ declare function extractRouteInfos(body: ESTree.ClassBody['body']): RouteInfo[];
129
+ /**
130
+ * 查找重复路由,按 方法|路径 分组
131
+ *
132
+ * @param routeInfos - 路由信息数组
133
+ * @returns 重复路由分组 Map,键为 "方法|路径"
134
+ */
135
+ declare function findDuplicateRoutes(routeInfos: RouteInfo[]): Map<string, RouteInfo[]>;
136
+ /**
137
+ * 检查装饰器表达式是否为 Controller 装饰器
138
+ *
139
+ * @param expression - 装饰器表达式节点
140
+ * @returns 是否为 Controller 装饰器
141
+ */
142
+ declare function isControllerDecorator(expression: ESTree.Node): boolean;
143
+ /**
144
+ * 检查装饰器表达式是否为 ApiTags 装饰器
145
+ *
146
+ * @param expression - 装饰器表达式节点
147
+ * @returns 是否为 ApiTags 装饰器
148
+ */
149
+ declare function isApiTagsDecorator(expression: ESTree.Node): boolean;
150
+ //#endregion
151
+ //#region src/utils/handler.utils.d.ts
152
+ /**
153
+ * 判断节点是否为 defineHandler() 调用表达式
154
+ *
155
+ * @param node - AST 节点
156
+ * @returns 是否为 defineHandler() 调用
157
+ */
158
+ declare function isDefineHandlerCall(node: ESTree.Node): boolean;
159
+ /**
160
+ * 从 ObjectPattern 中提取所有解构属性名
161
+ *
162
+ * @param node - AST 节点,预期为 ObjectPattern
163
+ * @returns 解构属性名数组
164
+ */
165
+ declare function extractDestructuredNames(node: ESTree.Node): string[];
166
+ /**
167
+ * 判断 MethodDefinition 是否有 @TypedHandle() 装饰器
168
+ *
169
+ * @param node - AST 节点,预期为 MethodDefinition
170
+ * @returns 是否有 TypedHandle 装饰器
171
+ */
172
+ declare function hasTypedHandleDecorator(node: ESTree.Node): boolean;
173
+ /**
174
+ * 判断节点是否为有效的响应包装函数调用
175
+ *
176
+ * @param node - AST 节点
177
+ * @param responseFunctions - 有效的响应包装函数名列表
178
+ * @returns 是否为响应包装函数调用
179
+ */
180
+ declare function isResponseFunctionCall(node: ESTree.Node | null | undefined, responseFunctions: string[]): boolean;
181
+ /**
182
+ * 判断方法体中是否存在 return 语句
183
+ *
184
+ * 递归遍历 BlockStatement 中的语句,检查是否有 ReturnStatement 且 argument 不为 null
185
+ *
186
+ * @param node - AST 节点,预期为 BlockStatement 或 FunctionBody
187
+ * @returns 是否存在 return 语句
188
+ */
189
+ declare function hasReturnStatement(node: ESTree.Node): boolean;
190
+ //#endregion
191
+ //#region src/index.d.ts
192
+ /**
193
+ * nestjs-route 插件
194
+ *
195
+ * 提供 NestJS 路由管理相关的 Oxlint 规则,
196
+ * 通过 eslintCompatPlugin 包装以同时支持 Oxlint 和 ESLint 运行时
197
+ */
198
+ declare const nestjsRoutePlugin: import("@oxlint/plugins").Plugin;
199
+ //#endregion
200
+ export { type PreferTypedControllerOptions, type RequireRouteConstantOptions, type RequireTypedResponseOptions, type RouteInfo, nestjsRoutePlugin as default, nestjsRoutePlugin, enforceTypedHandlerReturnRule, extractDestructuredNames, extractRouteInfos, findDuplicateRoutes, hasReturnStatement, hasTypedHandleDecorator, isApiTagsDecorator, isControllerDecorator, isDefineHandlerCall, isResponseFunctionCall, isRouteDecorator, isStaticRoute, noDuplicatesRule, orderRule, preferTypedControllerRule, requireRouteConstantRule, requireTypedResponseRule };
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{defineRule as e,eslintCompatPlugin as t}from"@oxlint/plugins";const n=new Set([`Get`,`Post`,`Put`,`Delete`,`Patch`,`All`,`Options`,`Head`]);function r(e){return!e.includes(`:`)}function i(e){let t=e.arguments;if(!t.length)return`/`;let n=t[0];if(!n)return null;if(n.type===`Literal`){let e=`value`in n?n.value:void 0;if(typeof e==`string`)return e}return null}function a(e){return e.callee.type===`Identifier`?e.callee.name:null}function o(e){return e.type===`CallExpression`&&e.callee.type===`Identifier`&&n.has(e.callee.name)}function s(e){let t=[];for(let n of e)if(n.type===`MethodDefinition`&&!(!(`decorators`in n)||!n.decorators))for(let e of n.decorators){let n=e.expression;if(!o(n)||n.type!==`CallExpression`)continue;let s=i(n),c=a(n);s!==null&&c&&t.push({path:s,method:c,isStatic:r(s)})}return t}function c(e){let t=new Map;for(let n of e){let e=`${n.method}|${n.path}`,r=t.get(e);r?r.push(n):t.set(e,[n])}let n=new Map;for(let[e,r]of t)r.length>1&&n.set(e,r);return n}function l(e){return e.type===`CallExpression`?e.callee.type===`Identifier`&&e.callee.name===`Controller`:e.type===`Identifier`&&e.name===`Controller`}function u(e){return e.type===`CallExpression`?e.callee.type===`Identifier`&&e.callee.name===`ApiTags`:e.type===`Identifier`&&e.name===`ApiTags`}function d(e){switch(e.type){case`Literal`:return typeof(`value`in e?e.value:void 0)==`string`;case`TemplateLiteral`:return!0;case`BinaryExpression`:return`operator`in e&&e.operator===`+`;default:return!1}}function f(e,t){if(!e)return t;switch(e.type){case`Identifier`:case`MemberExpression`:case`CallExpression`:return!0;default:return!1}}const p=e({meta:{type:`problem`,docs:{description:`防止 NestJS 控制器中出现重复的路由定义`}},createOnce(e){return{ClassBody(t){let n=c(s(t.body));for(let[,r]of n){let n=r[0];if(!n)continue;let i=`${n.method}('${n.path}')`;for(let n=1;n<r.length;n++)e.report({node:t,message:`发现重复路由 ${i}。控制器内的每个路由应该是唯一的,请检查并移除重复定义。`})}}}}}),m=e({meta:{type:`problem`,docs:{description:`确保 NestJS 控制器中静态路由位于参数化路由之前`}},createOnce(e){return{ClassBody(t){let n=s(t.body);if(n.length<2)return;let r=new Map;for(let e of n){let t=r.get(e.method);t?t.push(e):r.set(e.method,[e])}for(let[,n]of r){let r=!1;for(let i of n)if(!i.isStatic)r=!0;else if(r){let r=n.find(e=>!e.isStatic);r&&e.report({node:t,message:`静态路由('${i.path}')应该位于参数化路由('${r.path}')之前。参数化路由会拦截静态路由的请求。`});return}}}}}});function h(e){return!(typeof e!=`object`||!e)}const g=e({meta:{type:`suggestion`,docs:{description:`推荐使用 TypedController 装饰器替代 Controller + ApiTags 组合`},schema:[{type:`object`,properties:{checkApiTags:{type:`boolean`,description:`是否检查同时存在 ApiTags 装饰器`}},additionalProperties:!1}]},createOnce(e){let t=e.options?.[0]??{},n=h(t)?t:{},r=n.checkApiTags===void 0||n.checkApiTags;return{ClassDeclaration(t){!(`decorators`in t)||!t.decorators||t.decorators.length===0||t.decorators.some(e=>l(e.expression))&&(r&&!t.decorators.some(e=>u(e.expression))||e.report({node:t,message:`推荐使用 @TypedController() 装饰器替代 @Controller() + @ApiTags() 组合。TypedController 可以从路由定义中自动提取 API 标签。`}))}}}});function _(e){return!(typeof e!=`object`||!e)}const v=e({meta:{type:`problem`,docs:{description:`Controller 装饰器必须使用路由常量,禁止使用字符串字面量`},schema:[{type:`object`,properties:{allowedIdentifiers:{type:`array`,items:{type:`string`},description:`允许的路由常量标识符前缀列表`},allowEmpty:{type:`boolean`,description:`是否允许 Controller 装饰器无参数`}},additionalProperties:!1}]},createOnce(e){let t=e.options?.[0]??{},n=_(t)?t:{},r=n.allowEmpty===void 0||n.allowEmpty;return{Decorator(t){let n=t.expression;if(!l(n)||n.type!==`CallExpression`)return;let i=n.arguments[0];if(!i){r||e.report({node:t,message:`Controller 装饰器必须使用路由常量,禁止使用字符串字面量。请使用 clientRoutes.xxx.prefix 或 manageRoutes.xxx.prefix 替代。`});return}if(d(i)){e.report({node:i,message:`Controller 装饰器必须使用路由常量,禁止使用字符串字面量。请使用 clientRoutes.xxx.prefix 或 manageRoutes.xxx.prefix 替代。`});return}f(i,r)||e.report({node:i,message:`Controller 装饰器必须使用路由常量,禁止使用字符串字面量。请使用 clientRoutes.xxx.prefix 或 manageRoutes.xxx.prefix 替代。`})}}}});function y(e){return e.type!==`CallExpression`||e.callee.type!==`Identifier`?!1:e.callee.name===`defineHandler`}function b(e){if(e.type!==`ObjectPattern`)return[];let t=[];for(let n of e.properties)n.type===`Property`?n.key.type===`Identifier`&&t.push(n.key.name):n.type===`RestElement`&&n.argument.type===`Identifier`&&t.push(n.argument.name);return t}function x(e){if(e.type!==`MethodDefinition`||!(`decorators`in e)||!e.decorators)return!1;for(let t of e.decorators){let e=t.expression;if(e.type===`CallExpression`&&e.callee.type===`Identifier`&&e.callee.name===`TypedHandle`)return!0}return!1}function S(e,t){return!e||e.type!==`CallExpression`||e.callee.type!==`Identifier`?!1:t.includes(e.callee.name)}function C(e){if(e.type!==`BlockStatement`)return!1;for(let t of e.body)if(t.type===`ReturnStatement`&&t.argument!==null)return!0;return!1}const w=[`typedResponse`,`typedEmpty`];function T(e){if(typeof e!=`object`||!e)return!1;let t=e.typeAnnotation;if(t&&typeof t==`object`){let e=t.typeName;if(e&&typeof e==`object`){let t=e.name;if(typeof t==`string`&&t===`InferRouteReturn`)return!0}let n=t.typeParameters;if(n&&typeof n==`object`){let e=n.params;if(Array.isArray(e)){for(let t of e)if(t&&typeof t==`object`&&t.typeName&&typeof t.typeName==`object`&&t.typeName.name===`InferRouteReturn`)return!0}}}return!1}function E(e){if(typeof e!=`object`||!e)return`unknown`;let t=e.typeAnnotation;if(t&&typeof t==`object`){let e=t.typeName;if(e&&typeof e==`object`){let t=e.name;if(typeof t==`string`)return t;let n=e.left,r=e.right;if(n&&typeof n==`object`&&r&&typeof r==`object`){let e=n.name,t=r.name;if(typeof e==`string`&&typeof t==`string`)return`${e}.${t}`}}}return`unknown`}function D(e){if(!e||typeof e!=`object`)return!1;let t=e.typeAnnotation;if(!t||typeof t!=`object`)return!1;let n=t,r=n.typeName;if(!r||typeof r!=`object`||r.name!==`Parameters`)return!1;let i=n.typeParameters;if(!i||typeof i!=`object`)return!1;let a=i.params;if(!Array.isArray(a)||a.length===0)return!1;let o=a[0];if(o.type===`TSTypeQuery`){let e=o.exprName;if(e&&typeof e==`object`)return e.name===`typedResponse`}return!1}function O(e){if(e.type!==`ObjectExpression`)return!1;for(let t of e.properties)if(t.type===`Property`&&t.key.type===`Identifier`&&t.key.name===`success`)return!0;return!1}function k(e,t,n){e.init&&y(e.init)&&e.id.type===`ObjectPattern`&&b(e.id).includes(`TypedHandle`)&&n()}function A(e,t){if(!x(e))return;let n=e.value;if(n.type!==`FunctionExpression`)return;let r=`returnType`in n?n.returnType:void 0;if(r&&!T(r)){let n=E(r);t.report({node:e,message:`handle 方法的返回类型必须使用 InferRouteReturn<typeof route>,当前使用了自定义类型 "${n}"。这会导致路由定义与实现的类型约束脱节。`})}let i=n.body;if(i)for(let e of i.body){if(e.type!==`ReturnStatement`)continue;let n=e.argument;n!=null&&(S(n,w)||O(n)&&t.report({node:e,message:`禁止在 @TypedHandle 方法中直接返回包含 success/error 字段的对象。请使用 typedResponse() 包装成功响应,通过抛出异常处理错误情况。`}))}}const j=e({meta:{type:`suggestion`,docs:{description:`强制 TypedHandler 的 handle 方法使用 InferRouteReturn 作为返回类型`}},createOnce(e){let t=!1;return{VariableDeclarator(n){k(n,e,()=>{t=!0})},TSTypeAliasDeclaration(n){t&&D(n)&&e.report({node:n,message:`禁止使用 Parameters<typeof typedResponse> 推断响应类型。请直接使用 InferRouteReturn<typeof route> 作为 handle 方法的返回类型。`})},MethodDefinition(n){t&&A(n,e)}}}}),M=[`typedResponse`,`typedEmpty`,`typedStream`];function N(e){return!(typeof e!=`object`||!e)}function P(e,t,n,r){let i=e.init;if(!i||!y(i))return;let a=e.id;if(a.type!==`ObjectPattern`)return;let o=b(a),s=o.includes(`TypedHandle`),c=o.some(e=>t.includes(e));s&&r(),s&&!c&&n.report({node:e,message:`使用 defineHandler 且解构 TypedHandle 时,必须同时解构 typedResponse 或 typedEmpty`})}function F(e,t,n,r){if(!x(e)||n)return;let i=e.value;if(i.type!==`FunctionExpression`)return;let a=i.body;if(a&&C(a))for(let e of a.body){if(e.type!==`ReturnStatement`)continue;let n=e.argument;n!=null&&(S(n,t)||r.report({node:e,message:`@TypedHandle 方法的 return 语句必须使用 typedResponse() 或 typedEmpty() 包装返回值`}))}}const I=e({meta:{type:`problem`,docs:{description:`使用 defineHandler 且解构 TypedHandle 时,必须同时解构 typedResponse、typedEmpty 或 typedStream,且 TypedHandle 方法的 return 语句必须使用包装函数`},schema:[{type:`object`,properties:{responseFunctions:{type:`array`,items:{type:`string`},description:`覆盖默认的响应包装函数名列表`},allowDirectReturn:{type:`boolean`,description:`是否允许 TypedHandle 方法直接返回值(不经过包装函数)`}},additionalProperties:!1}]},createOnce(e){let t=e.options?.[0]??{},n=N(t)?t:{},r=n.responseFunctions??M,i=n.allowDirectReturn??!1,a=!1;return{VariableDeclarator(t){P(t,r,e,()=>{a=!0})},MethodDefinition(t){a&&F(t,r,i,e)}}}}),L=t({meta:{name:`@longzai-intelligence-oxlint/nestjs-route-plugin`},rules:{order:m,"no-duplicates":p,"require-route-constant":v,"prefer-typed-controller":g,"require-typed-response":I,"enforce-typed-handler-return":j}});export{L as default,L as nestjsRoutePlugin,j as enforceTypedHandlerReturnRule,b as extractDestructuredNames,s as extractRouteInfos,c as findDuplicateRoutes,C as hasReturnStatement,x as hasTypedHandleDecorator,u as isApiTagsDecorator,l as isControllerDecorator,y as isDefineHandlerCall,S as isResponseFunctionCall,o as isRouteDecorator,r as isStaticRoute,p as noDuplicatesRule,m as orderRule,g as preferTypedControllerRule,v as requireRouteConstantRule,I as requireTypedResponseRule};
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@longzai-intelligence-oxlint/nestjs-route-plugin",
3
+ "version": "0.1.0",
4
+ "license": "UNLICENSED",
5
+ "files": [
6
+ "dist"
7
+ ],
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "lzi-builder",
19
+ "build:prod": "NODE_ENV=production lzi-builder",
20
+ "prepublishOnly": "bun run build:prod",
21
+ "dev": "lzi-builder --watch",
22
+ "clean": "rimraf dist",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest",
25
+ "test:coverage": "vitest run --coverage",
26
+ "lint": "oxlint && oxfmt --check",
27
+ "lint:fix": "oxlint --fix && oxfmt",
28
+ "typecheck": "bun run typecheck:app && bun run typecheck:node && bun run typecheck:test",
29
+ "typecheck:app": "tsgo --noEmit -p tsconfig/app.json",
30
+ "typecheck:node": "tsgo --noEmit -p tsconfig/node.json",
31
+ "typecheck:test": "tsgo --noEmit -p tsconfig/test.json"
32
+ },
33
+ "dependencies": {
34
+ "@oxlint/plugins": "^1.74.0"
35
+ },
36
+ "devDependencies": {
37
+ "@vitest/coverage-v8": "^4.1.10",
38
+ "typescript": "^6.0.3"
39
+ },
40
+ "peerDependencies": {
41
+ "oxlint": ">=1.0.0"
42
+ }
43
+ }