Escriba código para reescribir su código: jscodeshift

Publicado: 2022-03-11

Codemods con jscodeshift

¿Cuántas veces ha utilizado la funcionalidad de buscar y reemplazar en un directorio para realizar cambios en los archivos fuente de JavaScript? Si es bueno, se ha vuelto elegante y ha usado expresiones regulares con grupos de captura, porque vale la pena el esfuerzo si su base de código es considerable. Sin embargo, Regex tiene límites. Para cambios no triviales, necesita un desarrollador que comprenda el código en contexto y que también esté dispuesto a asumir el proceso largo, tedioso y propenso a errores.

Aquí es donde entran los "codemods".

Los Codemods son scripts que se utilizan para reescribir otros scripts. Piense en ellos como una funcionalidad de buscar y reemplazar que puede leer y escribir código. Puede usarlos para actualizar el código fuente para que se ajuste a las convenciones de codificación de un equipo, realizar cambios generalizados cuando se modifica una API o incluso corregir automáticamente el código existente cuando su paquete público realiza un cambio importante.

El kit de herramientas jscodeshift es ideal para trabajar con codemods.

Piense en los mods de código como una función de búsqueda y reemplazo con secuencias de comandos que puede leer y escribir código.
Pío

En este artículo, vamos a explorar un conjunto de herramientas para codemods llamado "jscodeshift" mientras creamos tres codemods de complejidad creciente. Al final, tendrá una amplia exposición a los aspectos importantes de jscodeshift y estará listo para comenzar a escribir sus propios codemods. Revisaremos tres ejercicios que cubren algunos usos básicos, pero asombrosos, de las modificaciones de código, y puede ver el código fuente de estos ejercicios en mi proyecto de github.

¿Qué es jscodeshift?

El kit de herramientas jscodeshift le permite bombear un montón de archivos fuente a través de una transformación y reemplazarlos con lo que sale del otro extremo. Dentro de la transformación, analiza la fuente en un árbol de sintaxis abstracta (AST), explora para realizar los cambios y luego regenera la fuente a partir del AST alterado.

La interfaz que proporciona jscodeshift es un contenedor de paquetes recast y ast-types . recast maneja la conversión de origen a AST y viceversa, mientras que ast-types maneja la interacción de bajo nivel con los nodos AST.

Configuración

Para comenzar, instale jscodeshift globalmente desde npm.

 npm i -g jscodeshift

Hay opciones de ejecución que puede usar y una configuración de prueba obstinada que hace que ejecutar un conjunto de pruebas a través de Jest (un marco de prueba de JavaScript de código abierto) sea realmente fácil, pero vamos a pasar por alto eso por ahora a favor de la simplicidad:

jscodeshift -t some-transform.js input-file.js -d -p

Esto ejecutará input-file.js a través de transform some-transform.js e imprimirá los resultados sin alterar el archivo.

Sin embargo, antes de comenzar, es importante comprender tres tipos de objetos principales con los que trata la API jscodeshift: nodos, rutas de nodos y colecciones.

Nodos

Los nodos son los componentes básicos del AST, a menudo denominados "nodos AST". Esto es lo que ve cuando explora su código con AST Explorer. Son objetos simples y no proporcionan ningún método.

Rutas de nodo

Las rutas de nodo son envoltorios alrededor de un nodo AST proporcionado por ast-types como una forma de atravesar el árbol de sintaxis abstracta (AST, ¿recuerdas?). De forma aislada, los nodos no tienen ninguna información sobre su padre o alcance, por lo que las rutas de los nodos se encargan de eso. Puede acceder al nodo envuelto a través de la propiedad del node y hay varios métodos disponibles para cambiar el nodo subyacente. Las rutas de los nodos a menudo se denominan simplemente "rutas".

Colecciones

Las colecciones son grupos de cero o más rutas de nodo que la API jscodeshift devuelve cuando consulta el AST. Tienen todo tipo de métodos útiles, algunos de los cuales exploraremos.

Las colecciones contienen rutas de nodos, las rutas de nodos contienen nodos y los nodos son de lo que está hecho el AST. Tenga eso en cuenta y será fácil de entender la API de consulta de jscodeshift.

Puede ser difícil hacer un seguimiento de las diferencias entre estos objetos y sus respectivas capacidades de API, por lo que existe una herramienta ingeniosa llamada jscodeshift-helper que registra el tipo de objeto y brinda otra información clave.

Es importante conocer la diferencia entre nodos, rutas de nodos y colecciones.

Es importante conocer la diferencia entre nodos, rutas de nodos y colecciones.

Ejercicio 1: eliminar llamadas a la consola

Para mojarnos los pies, comencemos eliminando las llamadas a todos los métodos de la consola en nuestra base de código. Si bien puede hacer esto con buscar y reemplazar y un poco de expresiones regulares, comienza a ser complicado con declaraciones de varias líneas, literales de plantilla y llamadas más complejas, por lo que es un ejemplo ideal para comenzar.

Primero, cree dos archivos, remove-consoles.js y remove-consoles.input.js :

 //remove-consoles.js export default (fileInfo, api) => { };
 //remove-consoles.input.js export const sum = (a, b) => { console.log('calling sum with', arguments); return a + b; }; export const multiply = (a, b) => { console.warn('calling multiply with', arguments); return a * b; }; export const divide = (a, b) => { console.error(`calling divide with ${ arguments }`); return a / b; }; export const average = (a, b) => { console.log('calling average with ' + arguments); return divide(sum(a, b), 2); };

Este es el comando que usaremos en la terminal para enviarlo a través de jscodeshift:

jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p

Si todo está configurado correctamente, cuando lo ejecute debería ver algo como esto.

 Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 0 unmodified 1 skipped 0 ok Time elapsed: 0.514seconds

Bien, eso fue un poco anticlimático ya que nuestra transformación en realidad aún no hace nada, pero al menos sabemos que todo está funcionando. Si no se ejecuta en absoluto, asegúrese de haber instalado jscodeshift globalmente. Si el comando para ejecutar la transformación es incorrecto, verá el mensaje "El archivo de transformación de ERROR... no existe" o "Error de tipo: la ruta debe ser una cadena o un búfer" si no se puede encontrar el archivo de entrada. Si tiene algo con los dedos gordos, debería ser fácil de detectar con los errores de transformación muy descriptivos.

Relacionado: Hoja de trucos de JavaScript rápida y práctica de Toptal: ES6 y más allá

Sin embargo, nuestro objetivo final, después de una transformación exitosa, es ver esta fuente:

 export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); };

Para llegar allí, necesitamos convertir la fuente en un AST, encontrar las consolas, eliminarlas y luego convertir el AST alterado nuevamente en la fuente. El primer y último paso son fáciles, es solo:

 remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };

Pero, ¿cómo encontramos las consolas y las eliminamos? A menos que tenga un conocimiento excepcional de la API de Mozilla Parser, probablemente necesitará una herramienta que lo ayude a comprender cómo se ve el AST. Para eso puedes usar el AST Explorer. Pegue el contenido de remove-consoles.input.js en él y verá el AST. Hay una gran cantidad de datos, incluso en el código más simple, por lo que ayuda a ocultar los datos y métodos de ubicación. Puede alternar la visibilidad de las propiedades en AST Explorer con las casillas de verificación sobre el árbol.

Podemos ver que las llamadas a los métodos de la consola se conocen como CallExpressions , entonces, ¿cómo las encontramos en nuestra transformación? Usamos las consultas de jscodeshift, recordando nuestra discusión anterior sobre las diferencias entre colecciones, rutas de nodos y nodos en sí:

 //remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };

La línea const root = j(fileInfo.source); devuelve una colección de una ruta de nodo, que envuelve el nodo AST raíz. Podemos usar el método de búsqueda de la colección para find nodos descendientes de cierto tipo, así:

 const callExpressions = root.find(j.CallExpression);

Esto devuelve otra colección de rutas de nodos que contienen solo los nodos que son CallExpressions. A primera vista, esto parece ser lo que queremos, pero es demasiado amplio. Podríamos terminar ejecutando cientos o miles de archivos a través de nuestras transformaciones, por lo que debemos ser precisos para tener la confianza de que funcionará según lo previsto. El find ingenuo anterior no solo encontraría las expresiones de llamada de la consola, sino que encontraría todas las expresiones de llamada en la fuente, incluidas

 require('foo') bar() setTimeout(() => {}, 0)

Para forzar una mayor especificidad, proporcionamos un segundo argumento para .find : un objeto de parámetros adicionales, cada nodo debe incluirse en los resultados. Podemos mirar el AST Explorer para ver que nuestra consola.* Las llamadas tienen la forma de:

 { "type": "CallExpression", "callee": { "type": "MemberExpression", "object": { "type": "Identifier", "name": "console" } } }

Con ese conocimiento, sabemos refinar nuestra consulta con un especificador que devolverá solo el tipo de CallExpressions que nos interesa:

 const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, });

Ahora que tenemos una colección precisa de los sitios de llamadas, eliminémoslos del AST. Convenientemente, el tipo de objeto de colección tiene un método de remove que hará precisamente eso. Nuestro archivo remove-consoles.js ahora se verá así:

 //remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source) const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ); callExpressions.remove(); return root.toSource(); };

Ahora, si ejecutamos nuestra transformación desde la línea de comando usando jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p , deberíamos ver:

 Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); }; All done. Results: 0 errors 0 unmodified 0 skipped 1 ok Time elapsed: 0.604seconds

Se ve bien. Ahora que nuestra transformación altera el AST subyacente, el uso de .toSource() genera una cadena diferente de la original. La opción -p de nuestro comando muestra el resultado, y en la parte inferior se muestra un recuento de disposiciones para cada archivo procesado. Eliminar la opción -d de nuestro comando reemplazaría el contenido de remove-consoles.input.js con el resultado de la transformación.

Nuestro primer ejercicio está completo... casi. El código tiene un aspecto extraño y probablemente muy ofensivo para cualquier purista funcional, por lo que para hacer que el código de transformación fluya mejor, jscodeshift ha hecho que la mayoría de las cosas se puedan encadenar. Esto nos permite reescribir nuestra transformación así:

 // remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; return j(fileInfo.source) .find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ) .remove() .toSource(); };

Mucho mejor. Para recapitular el ejercicio 1, envolvimos la fuente, consultamos una colección de rutas de nodos, cambiamos el AST y luego regeneramos esa fuente. Nos hemos mojado los pies con un ejemplo bastante simple y hemos tocado los aspectos más importantes. Ahora, hagamos algo más interesante.

Ejercicio 2: Reemplazo de llamadas a métodos importados

Para este escenario, tenemos un módulo de "geometría" con un método llamado "circleArea" que hemos descartado en favor de "getCircleArea". Podríamos encontrarlos y reemplazarlos fácilmente con /geometry\.circleArea/g , pero ¿qué pasa si el usuario importó el módulo y le asignó un nombre diferente? Por ejemplo:

 import g from 'geometry'; const area = g.circleArea(radius);

¿Cómo sabríamos reemplazar g.circleArea en lugar de geometry.circleArea ? Ciertamente no podemos asumir que todas las llamadas circleArea son las que estamos buscando, necesitamos algo de contexto. Aquí es donde los codemods comienzan a mostrar su valor. Comencemos creando dos archivos, deprecated.js y deprecated.input.js .

 //deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
 deprecated.input.js import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.circleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));

Ahora ejecute este comando para ejecutar el codemod.

jscodeshift -t ./deprecated.js ./deprecated.input.js -d -p

Debería ver un resultado que indica que se ejecutó la transformación, pero aún no ha cambiado nada.

 Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 1 unmodified 0 skipped 0 ok Time elapsed: 0.892seconds

Necesitamos saber cómo se ha importado nuestro módulo de geometry . Miremos el AST Explorer y descubramos lo que estamos buscando. Nuestra importación toma esta forma.

 { "type": "ImportDeclaration", "specifiers": [ { "type": "ImportDefaultSpecifier", "local": { "type": "Identifier", "name": "g" } } ], "source": { "type": "Literal", "value": "geometry" } }

Podemos especificar un tipo de objeto para encontrar una colección de nodos como este:

 const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, });

Esto nos da la ImportDeclaration utilizada para importar "geometría". A partir de ahí, profundice para encontrar el nombre local utilizado para contener el módulo importado. Dado que esta es la primera vez que lo hacemos, señalemos un punto importante y confuso al comenzar por primera vez.

Nota: es importante saber que root.find() devuelve una colección de rutas de nodos. A partir de ahí, el .get(n) devuelve la ruta del nodo en el índice n de esa colección y, para obtener el nodo real, usamos .node . El nodo es básicamente lo que vemos en AST Explorer. Recuerde, la ruta del nodo es principalmente información sobre el alcance y las relaciones del nodo, no el nodo en sí.

 // find the Identifiers const identifierCollection = importDeclaration.find(j.Identifier); // get the first NodePath from the Collection const nodePath = identifierCollection.get(0); // get the Node in the NodePath and grab its "name" const localName = nodePath.node.name;

Esto nos permite averiguar dinámicamente cómo se ha importado nuestro módulo de geometry . A continuación, encontramos los lugares en los que se está utilizando y los cambiamos. Al observar AST Explorer, podemos ver que necesitamos encontrar MemberExpressions que se vean así:

 { "type": "MemberExpression", "object": { "name": "geometry" }, "property": { "name": "circleArea" } }

Recuerde, sin embargo, que nuestro módulo puede haber sido importado con un nombre diferente, por lo que debemos tenerlo en cuenta haciendo que nuestra consulta se vea así:

 j.MemberExpression, { object: { name: localName, }, property: { name: "circleArea", }, })

Ahora que tenemos una consulta, podemos obtener una colección de todos los sitios de llamadas a nuestro método anterior y luego usar el método replaceWith() de la colección para intercambiarlos. El método replaceWith() itera a través de la colección, pasando cada ruta de nodo a una función de devolución de llamada. Luego, el nodo AST se reemplaza con cualquier nodo que devuelva de la devolución de llamada.

Codemods le permite escribir consideraciones "inteligentes" para la refactorización.

Nuevamente, es necesario comprender la diferencia entre colecciones, rutas de nodos y nodos para que esto tenga sentido.

Una vez que hayamos terminado con el reemplazo, generamos la fuente como de costumbre. Aquí está nuestra transformación terminada:

 //deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "geometry" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, }); // get the local name for the imported module const localName = // find the Identifiers importDeclaration.find(j.Identifier) // get the first NodePath from the Collection .get(0) // get the Node in the NodePath and grab its "name" .node.name; return root.find(j.MemberExpression, { object: { name: localName, }, property: { name: 'circleArea', }, }) .replaceWith(nodePath => { // get the underlying Node const { node } = nodePath; // change to our new prop node.property.name = 'getCircleArea'; // replaceWith should return a Node, not a NodePath return node; }) .toSource(); };

Cuando ejecutamos la fuente a través de la transformación, vemos que la llamada al método en desuso en el módulo de geometry se cambió, pero el resto se dejó sin cambios, así:

 import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.getCircleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));

Ejercicio 3: cambiar la firma de un método

En los ejercicios anteriores cubrimos la consulta de colecciones para tipos específicos de nodos, la eliminación de nodos y la modificación de nodos, pero ¿qué pasa con la creación de nodos completamente nuevos? Eso es lo que abordaremos en este ejercicio.

En este escenario, tenemos una firma de método que se salió de control con argumentos individuales a medida que el software creció, por lo que se decidió que sería mejor aceptar un objeto que contuviera esos argumentos.

En lugar de car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);

nos gustaría ver

 const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, });

Comencemos por hacer la transformación y un archivo de entrada para probar con:

 //signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
 //signature-change.input.js import car from 'car'; const suv = car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true); const truck = car.factory('silver', 'Toyota', 'Tacoma', 2006, 100000, true, true);

Nuestro comando para ejecutar la transformación será jscodeshift -t signature-change.js signature-change.input.js -d -p y los pasos que necesitamos para realizar esta transformación son:

  • Encuentre el nombre local para el módulo importado
  • Encuentra todos los sitios de llamadas al método .factory
  • Leer todos los argumentos que se pasan en
  • Reemplace esa llamada con un solo argumento que contiene un objeto con los valores originales

Usando AST Explorer y el proceso que usamos en los ejercicios anteriores, los primeros dos pasos son fáciles:

 //signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .toSource(); };

Para leer todos los argumentos que se pasan actualmente, usamos el método replaceWith() en nuestra colección de CallExpressions para intercambiar cada uno de los nodos. Los nuevos nodos reemplazarán node.arguments con un nuevo argumento único, un objeto.

¡Intercambie fácilmente argumentos de método con jscodeshift!

Cambie las firmas de los métodos con 'replacewith()' e intercambie nodos completos.

Probémoslo con un objeto simple para asegurarnos de que sabemos cómo funciona antes de usar los valores adecuados:

 .replaceWith(nodePath => { const { node } = nodePath; node.arguments = [{ foo: 'bar' }]; return node; })

Cuando ejecutamos esto ( jscodeshift -t signature-change.js signature-change.input.js -d -p ), la transformación explotará con:

 ERR signature-change.input.js Transformation error Error: {foo: bar} does not match type Printable

Resulta que no podemos simplemente meter objetos simples en nuestros nodos AST. En cambio, necesitamos usar constructores para crear nodos adecuados.

Relacionado: Contrate al 3 % de los mejores desarrolladores independientes de Javascript.

Constructores de nodos

Los constructores nos permiten crear nuevos nodos correctamente; son proporcionados por ast-types y aparecen a través de jscodeshift. Verifican rigurosamente que los diferentes tipos de nodos se creen correctamente, lo que puede ser frustrante cuando estás pirateando, pero en última instancia, esto es algo bueno. Para comprender cómo usar los constructores, hay dos cosas que debe tener en cuenta:

Todos los tipos de nodos AST disponibles están definidos en la carpeta def del proyecto github de tipos ast, principalmente en core.js. Hay constructores para todos los tipos de nodos AST, pero usan una versión en caja camel del tipo de nodo, no pascal. -caso. (Esto no se indica explícitamente, pero puede ver que este es el caso en la fuente de ast-types

Si usamos AST Explorer con un ejemplo de lo que queremos que sea el resultado, podemos reconstruirlo con bastante facilidad. En nuestro caso, queremos que el nuevo argumento único sea una expresión de objeto con un montón de propiedades. Mirando las definiciones de tipo mencionadas anteriormente, podemos ver lo que esto implica:

 def("ObjectExpression") .bases("Expression") .build("properties") .field("properties", [def("Property")]); def("Property") .bases("Node") .build("kind", "key", "value") .field("kind", or("init", "get", "set")) .field("key", or(def("Literal"), def("Identifier"))) .field("value", def("Expression"));

Entonces, el código para construir un nodo AST para { foo: 'bar' } se vería así:

 j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]);

Tome ese código y conéctelo a nuestra transformación así:

 .replaceWith(nodePath => { const { node } = nodePath; const object = j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]); node.arguments = [object]; return node; })

Ejecutar esto nos da el resultado:

 import car from 'car'; const suv = car.factory({ foo: "bar" }); const truck = car.factory({ foo: "bar" });

Ahora que sabemos cómo crear un nodo AST adecuado, es fácil recorrer los argumentos antiguos y generar un nuevo objeto para usar en su lugar. Así es como se ve nuestro archivo signature-change.js ahora:

 //signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // current order of arguments const argKeys = [ 'color', 'make', 'model', 'year', 'miles', 'bedliner', 'alarm', ]; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .replaceWith(nodePath => { const { node } = nodePath; // use a builder to create the ObjectExpression const argumentsAsObject = j.objectExpression( // map the arguments to an Array of Property Nodes node.arguments.map((arg, i) => j.property( 'init', j.identifier(argKeys[i]), j.literal(arg.value) ) ) ); // replace the arguments with our new ObjectExpression node.arguments = [argumentsAsObject]; return node; }) // specify print options for recast .toSource({ quote: 'single', trailingComma: true }); };

Ejecute la transformación ( jscodeshift -t signature-change.js signature-change.input.js -d -p ) y veremos que las firmas se han actualizado como se esperaba:

 import car from 'car'; const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, }); const truck = car.factory({ color: 'silver', make: 'Toyota', model: 'Tacoma', year: 2006, miles: 100000, bedliner: true, alarm: true, });

Codemods con jscodeshift Resumen

Tomó un poco de tiempo y esfuerzo llegar a este punto, pero los beneficios son enormes cuando se enfrenta a una refactorización masiva. Distribuir grupos de archivos a diferentes procesos y ejecutarlos en paralelo es algo en lo que destaca jscodeshift, lo que le permite ejecutar transformaciones complejas en una gran base de código en segundos. A medida que se vuelva más competente con los codemods, comenzará a reutilizar los scripts existentes (como el repositorio github de react-codemod o escribir los suyos propios para todo tipo de tareas, y eso hará que usted, su equipo y los usuarios de su paquete sean más eficientes .