diff --git a/apps/oxlint/package.json b/apps/oxlint/package.json index 83ed83305ec88..fc74a2f158ac1 100644 --- a/apps/oxlint/package.json +++ b/apps/oxlint/package.json @@ -39,6 +39,7 @@ "@arethetypeswrong/core": "catalog:", "@babel/code-frame": "^7.28.6", "@napi-rs/cli": "catalog:", + "@oxc-project/types": "^0.118.0", "@type-challenges/utils": "^0.1.1", "@types/babel__code-frame": "^7.27.0", "@types/esquery": "^1.5.4", diff --git a/apps/oxlint/tsdown.config.ts b/apps/oxlint/tsdown.config.ts index 21c8181fed155..2eaf8168ce558 100644 --- a/apps/oxlint/tsdown.config.ts +++ b/apps/oxlint/tsdown.config.ts @@ -1,12 +1,9 @@ -import fs from "node:fs"; -import { join as pathJoin, relative as pathRelative, dirname } from "node:path"; import { defineConfig } from "tsdown"; -import { parseSync, Visitor } from "oxc-parser"; // oxlint-disable-next-line typescript/ban-ts-comment // @ts-ignore - file is generated and not checked in to git import ruleNames from "./src-js/generated/plugin-eslint/rule_names.ts"; - -import type { Plugin } from "rolldown"; +import replaceGlobalsPlugin from "./tsdown_plugins/replace_globals.ts"; +import replaceAssertsPlugin from "./tsdown_plugins/replace_asserts.ts"; const { env } = process; const isEnabled = (env: string | undefined) => env === "true" || env === "1"; @@ -89,8 +86,8 @@ for (const ruleName of ruleNames) { // Plugins. // Only remove debug assertions in release build. -const plugins = [createReplaceGlobalsPlugin()]; -if (!DEBUG) plugins.push(createReplaceAssertsPlugin()); +const plugins = [replaceGlobalsPlugin]; +if (!DEBUG) plugins.push(replaceAssertsPlugin); // All build configs export default defineConfig([ @@ -156,317 +153,3 @@ export default defineConfig([ }, }, ]); - -/** - * Create a plugin to remove imports of `debugAssert*` / `typeAssert*` functions from `src-js/utils/asserts.ts`, - * and all their call sites. - * - * ```ts - * // Original code - * import { debugAssertIsNonNull } from '../utils/asserts.ts'; - * const foo = getFoo(); - * debugAssertIsNonNull(foo.bar); - * - * // After transform - * const foo = getFoo(); - * ``` - * - * This solves 2 problems: - * - * # 1. Minifier works chunk-by-chunk - * - * Minifier can already remove all calls to these functions as dead code, but only if the functions are defined - * in the same file as the call sites. - * - * Problem is that `asserts.ts` is imported by files which end up in all output chunks. - * So without this transform, TSDown creates a shared chunk for `asserts.ts`. Minifier works chunk-by-chunk, - * so can't see that these functions are no-ops, and doesn't remove the function calls. - * - * # 2. Not entirely removed - * - * Even if minifier does remove all calls to these functions, it can't prove that expressions *inside* the calls - * don't have side effects. - * - * In example above, it can't know if `foo` has a getter for `bar` property. - * So it removes the call to `debugAssertIsNonNull`, but leaves behind the `foo.bar` expression. - * - * ```ts - * const foo = getFoo(); - * foo.bar; - * ``` - * - * This plugin visits AST and removes all calls to `debugAssert*` / `typeAssert*` functions entirely, - * *including* the expressions inside the calls. - * - * This makes these debug assertion functions act like `debug_assert!` in Rust. - * - * @returns Plugin - */ -function createReplaceAssertsPlugin(): Plugin { - const ASSERTS_PATH = pathJoin(import.meta.dirname, "src-js/utils/asserts.ts"); - - return { - name: "replace-asserts", - transform: { - // Only process TS files in `src-js` directory - filter: { id: /\/src-js\/.+(? = new Set(), - idents = new Set(); - for (const stmt of program.body) { - if (stmt.type !== "ImportDeclaration") continue; - - // Check if import is from `utils/asserts.ts`. - // `endsWith` check is just a shortcut to avoid resolving the specifier to a full path for most imports. - const source = stmt.source.value; - if (!source.endsWith("/asserts.ts") && !source.endsWith("/asserts.js")) continue; - // oxlint-disable-next-line no-await-in-loop - const importedId = await this.resolve(source, path); - if (importedId === null || importedId.id !== ASSERTS_PATH) continue; - - // Remove `import` statement - for (const specifier of stmt.specifiers) { - if (specifier.type !== "ImportSpecifier") { - throw new Error(`Only use named imports when importing from \`asserts.ts\`: ${path}`); - } - idents.add(specifier.local); - if (specifier.imported.type === "Identifier") idents.add(specifier.imported); - assertFnNames.add(specifier.local.name); - } - magicString.remove(stmt.start, stmt.end); - } - - if (assertFnNames.size === 0) return; - - // Visit AST and remove all calls to assertion functions - const visitor = new Visitor({ - // Replace `debugAssert(...)` calls with `null`. Minifier will remove the `null`. - CallExpression(node) { - const { callee } = node; - if (callee.type !== "Identifier") return; - if (assertFnNames.has(callee.name)) { - idents.add(callee); - magicString.overwrite(node.start, node.end, "null"); - } - }, - // Error if assertion functions are used in any other way. We lack logic to deal with that. - Identifier(node) { - const { name } = node; - if (assertFnNames.has(name) && !idents.has(node)) { - throw new Error( - `Do not use \`${name}\` imported from \`asserts.ts\` except in function calls: ${path}`, - ); - } - }, - }); - visitor.visit(program); - - return { code: magicString }; - }, - }, - }; -} - -// prettier-ignore -const GLOBALS = new Set([ - "Object", "Array", "Math", "JSON", "Reflect", "Symbol", "Function", "Number", "Boolean", "String", "Date", "Promise", - "RegExp", "BigInt", "Map", "Set", "Error", "AggregateError", "EvalError", "RangeError", "ReferenceError", - "SyntaxError", "TypeError", "URIError", "Buffer", "ArrayBuffer", "SharedArrayBuffer", "Atomics", "Uint8Array", - "Int8Array", "Uint16Array", "Int16Array", "Uint32Array", "Int32Array", "BigUint64Array", "BigInt64Array", - "Uint8ClampedArray", "Float32Array", "Float64Array", "Float16Array", "DataView", "WebAssembly", "Iterator", - "WeakMap", "WeakSet", "Proxy", "FinalizationRegistry", "WeakRef", "URL", "URLSearchParams", "TextEncoder", - "TextDecoder", "BroadcastChannel", "MessageChannel", "MessagePort", "Blob", "File" -]); - -// Global properties which cannot be converted to top-level vars, because they're methods which use `this`. -// e.g. `const r = Promise.resolve; r(1);` throws "TypeError: PromiseResolve called on non-object". -const SKIP_GLOBALS = new Set(["Promise.resolve", "Promise.allSettled"]); - -/** - * Create a plugin to replace usage of properties of globals with global vars defined in `utils/globals.ts`. - * - * This is more performant, due to reduced property lookups, and minifies better. - * - * ```ts - * // Original code - * const keys = Object.keys(obj); - * - * // After transform - * import { ObjectKeys } from "../utils/globals.ts"; - * const keys = ObjectKeys(obj); - * ``` - * - * If TSDown produces any errors about missing imports, likely you need to add the missing global(s) - * to `utils/globals.ts`. - */ -function createReplaceGlobalsPlugin(): Plugin { - // Path to file which exports global vars - const GLOBALS_PATH = pathJoin(import.meta.dirname, "src-js/utils/globals.ts"); - - // Parse the file to get the list of global vars it exports - const availableGlobals = getAvailableGlobals(GLOBALS_PATH); - - return { - name: "replace-globals", - transform: { - // Only process TS files in `src-js` directory - filter: { id: /\/src-js\/.+(?(), - visitedMemberExpressions = new Set(), - missingGlobalVars = new Set(); - - const visitor = new Visitor({ - MemberExpression(node) { - // Skip nested `MemberExpression`s e.g. `Object.prototype` in `Object.prototype.toString` - if (visitedMemberExpressions.has(node)) return; - - // Exit if computed (`obj[prop]`) or private property (`obj.#prop`). - let { object, property } = node; - if (node.computed || property.type !== "Identifier") return; - - // Gather all properties in reverse order. - // e.g. `Object.prototype.toString` -> `propNames = ["toString", "prototype"]`. - const propNames: string[] = [property.name]; - while (true) { - // If `object` is an identifier, `node` is a member expression of form `a.b`, `a.b.c`, etc. - if (object.type === "Identifier") break; - - // If `object` is not a member expression, exit e.g. `foo().x` - if (object.type !== "MemberExpression") return; - - // We can't handle deep nesting yet - // oxlint-disable-next-line no-constant-condition - if (1) return; - - // Avoid processing the nested member expression again when it's visited later - visitedMemberExpressions.add(object); - - // Exit if computed (`obj[prop]`) or private property (`obj.#prop`). - property = object.property; - if (object.computed || property.type !== "Identifier") return; - - // `node` of form `.a.b` or `.a.b.c`. - // Loop round to process the `` part. - propNames.push(property.name); - - object = object.object; - } - - // Found a member expression of form `obj.a`, or `obj.a.b`, `obj.a.b.c`, etc. - // Exit if `obj` is not a global. - const globalName = object.name; - if (!GLOBALS.has(globalName)) return; - - const propName = propNames.reverse().join("."); - - const fullName = `${object.name}.${propName}`; - if (SKIP_GLOBALS.has(fullName)) return; - - const mapping = availableGlobals.get(globalName); - if (!mapping) { - missingGlobalVars.add(`\`${fullName}\``); - return; - } - - const varName = mapping.get(propName); - if (!varName) { - missingGlobalVars.add(`\`${fullName}\``); - return; - } - - // Add var name (e.g. `ObjectHasOwn`) to set of vars to import - varNames.add(varName); - - // Replace `Object.hasOwn` with `ObjectHasOwn` - magicString.overwrite(node.start, node.end, varName); - }, - }); - visitor.visit(program); - - // Log any globals that were not converted because `utils/globals.ts` has no export for them - if (missingGlobalVars.size > 0) { - // oxlint-disable-next-line no-console - console.error( - "--------------------------------------------------------------------------------\n" + - `WARNING: Unable to convert ${[...missingGlobalVars].join(" or ")} to global vars.\n` + - `Add exports to \`utils/globals.ts\` for them.\n` + - "--------------------------------------------------------------------------------", - ); - } - - if (varNames.size === 0) return; - - // Some globals were found. Import them from `utils/globals.ts`. - let relativePath = pathRelative(dirname(path), GLOBALS_PATH); - relativePath = relativePath.replace(/\\/g, "/"); - relativePath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`; - const importStmt = `import { ${[...varNames].join(", ")} } from ${JSON.stringify(relativePath)};\n`; - - magicString.prepend(importStmt); - - return { code: magicString }; - }, - }, - }; -} - -/** - * Parse `utils/globals.ts` and return a list of globals and global vars it exports. - * @param path - Path to `utils/globals.ts` - * @returns Mapping from global name (e.g. `Object`) to mapping of properties of that global to var names - * (e.g. `hasOwn` -> `ObjectHasOwn`). - */ -function getAvailableGlobals(path: string): Map> { - const code = fs.readFileSync(path, "utf8"); - const { program, errors } = parseSync(path, code); - if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`); - - const globals = new Map>(); - - const visitor = new Visitor({ - ExportNamedDeclaration(node) { - const { declaration } = node; - if (declaration == null || declaration.type !== "VariableDeclaration") return; - const declarator = declaration.declarations[0]; - if (!declarator) return; - const { init } = declarator; - if (!init || init.type !== "Identifier") return; - - const obj = declarator.id; - if (obj.type !== "ObjectPattern") return; - - const globalName = init.name; - let mapping = globals.get(globalName); - if (!mapping) { - mapping = new Map(); - globals.set(globalName, mapping); - } - - for (const prop of obj.properties) { - if (prop.type !== "Property" || prop.method || prop.computed) continue; - - const { key, value } = prop; - if (key.type !== "Identifier" || value.type !== "Identifier") continue; - - mapping.set(key.name, value.name); - } - }, - }); - visitor.visit(program); - - return globals; -} diff --git a/apps/oxlint/tsdown_plugins/replace_asserts.ts b/apps/oxlint/tsdown_plugins/replace_asserts.ts new file mode 100644 index 0000000000000..3e2c572082a03 --- /dev/null +++ b/apps/oxlint/tsdown_plugins/replace_asserts.ts @@ -0,0 +1,120 @@ +import { join as pathJoin } from "node:path"; +import { Visitor } from "oxc-parser"; +import { parse } from "./utils.ts"; + +import type { Plugin } from "rolldown"; + +// Path to file which defines assertion functions +const ASSERTS_PATH = pathJoin(import.meta.dirname, "../src-js/utils/asserts.ts"); + +/** + * Plugin to remove imports of `debugAssert*` / `typeAssert*` functions from `src-js/utils/asserts.ts`, + * and all their call sites. + * + * ```ts + * // Original code + * import { debugAssertIsNonNull } from '../utils/asserts.ts'; + * const foo = getFoo(); + * debugAssertIsNonNull(foo.bar); + * + * // After transform + * const foo = getFoo(); + * ``` + * + * This solves 2 problems: + * + * # 1. Minifier works chunk-by-chunk + * + * Minifier can already remove all calls to these functions as dead code, but only if the functions are defined + * in the same file as the call sites. + * + * Problem is that `asserts.ts` is imported by files which end up in all output chunks. + * So without this transform, TSDown creates a shared chunk for `asserts.ts`. Minifier works chunk-by-chunk, + * so can't see that these functions are no-ops, and doesn't remove the function calls. + * + * # 2. Not entirely removed + * + * Even if minifier does remove all calls to these functions, it can't prove that expressions *inside* the calls + * don't have side effects. + * + * In example above, it can't know if `foo` has a getter for `bar` property. + * So it removes the call to `debugAssertIsNonNull`, but leaves behind the `foo.bar` expression. + * + * ```ts + * const foo = getFoo(); + * foo.bar; + * ``` + * + * This plugin visits AST and removes all calls to `debugAssert*` / `typeAssert*` functions entirely, + * *including* the expressions inside the calls. + * + * This makes these debug assertion functions act like `debug_assert!` in Rust. + */ +const plugin: Plugin = { + name: "replace-asserts", + transform: { + // Only process TS files in `src-js` directory + filter: { id: /\/src-js\/.+(? = new Set(), + idents = new Set(); + for (const stmt of program.body) { + if (stmt.type !== "ImportDeclaration") continue; + + // Check if import is from `utils/asserts.ts`. + // `endsWith` check is just a shortcut to avoid resolving the specifier to a full path for most imports. + const source = stmt.source.value; + if (!source.endsWith("/asserts.ts") && !source.endsWith("/asserts.js")) continue; + // oxlint-disable-next-line no-await-in-loop + const importedId = await this.resolve(source, path); + if (importedId === null || importedId.id !== ASSERTS_PATH) continue; + + // Remove `import` statement + for (const specifier of stmt.specifiers) { + if (specifier.type !== "ImportSpecifier") { + throw new Error(`Only use named imports when importing from \`asserts.ts\`: ${path}`); + } + idents.add(specifier.local); + if (specifier.imported.type === "Identifier") idents.add(specifier.imported); + assertFnNames.add(specifier.local.name); + } + magicString.remove(stmt.start, stmt.end); + } + + if (assertFnNames.size === 0) return; + + // Visit AST and remove all calls to assertion functions + const visitor = new Visitor({ + // Replace `debugAssert(...)` calls with `null`. Minifier will remove the `null`. + CallExpression(node) { + const { callee } = node; + if (callee.type !== "Identifier") return; + if (assertFnNames.has(callee.name)) { + idents.add(callee); + magicString.overwrite(node.start, node.end, "null"); + } + }, + // Error if assertion functions are used in any other way. We lack logic to deal with that. + Identifier(node) { + const { name } = node; + if (assertFnNames.has(name) && !idents.has(node)) { + throw new Error( + `Do not use \`${name}\` imported from \`asserts.ts\` except in function calls: ${path}`, + ); + } + }, + }); + visitor.visit(program); + + return { code: magicString }; + }, + }, +}; + +export default plugin; diff --git a/apps/oxlint/tsdown_plugins/replace_globals.ts b/apps/oxlint/tsdown_plugins/replace_globals.ts new file mode 100644 index 0000000000000..8b0f5e8a028e1 --- /dev/null +++ b/apps/oxlint/tsdown_plugins/replace_globals.ts @@ -0,0 +1,202 @@ +import fs from "node:fs"; +import { join as pathJoin, relative as pathRelative, dirname } from "node:path"; +import { Visitor } from "oxc-parser"; +import { parse } from "./utils.ts"; + +import type { Plugin } from "rolldown"; + +// Globals. +// prettier-ignore +const GLOBALS = new Set([ + "Object", "Array", "Math", "JSON", "Reflect", "Symbol", "Function", "Number", "Boolean", "String", "Date", "Promise", + "RegExp", "BigInt", "Map", "Set", "Error", "AggregateError", "EvalError", "RangeError", "ReferenceError", + "SyntaxError", "TypeError", "URIError", "Buffer", "ArrayBuffer", "SharedArrayBuffer", "Atomics", "Uint8Array", + "Int8Array", "Uint16Array", "Int16Array", "Uint32Array", "Int32Array", "BigUint64Array", "BigInt64Array", + "Uint8ClampedArray", "Float32Array", "Float64Array", "Float16Array", "DataView", "WebAssembly", "Iterator", + "WeakMap", "WeakSet", "Proxy", "FinalizationRegistry", "WeakRef", "URL", "URLSearchParams", "TextEncoder", + "TextDecoder", "BroadcastChannel", "MessageChannel", "MessagePort", "Blob", "File" +]); + +// Global properties which cannot be converted to top-level vars, because they're methods which use `this`. +// e.g. `const r = Promise.resolve; r(1);` throws "TypeError: PromiseResolve called on non-object". +const SKIP_GLOBALS = new Set(["Promise.resolve", "Promise.allSettled"]); + +// Path to file which exports global vars +const GLOBALS_PATH = pathJoin(import.meta.dirname, "../src-js/utils/globals.ts"); + +// Parse the file to get the list of global vars it exports +const availableGlobals = getAvailableGlobals(GLOBALS_PATH); + +/** + * Plugin to replace usage of properties of globals with global vars defined in `utils/globals.ts`. + * + * This is more performant, due to reduced property lookups, and minifies better. + * + * ```ts + * // Original code + * const keys = Object.keys(obj); + * + * // After transform + * import { ObjectKeys } from "../utils/globals.ts"; + * const keys = ObjectKeys(obj); + * ``` + * + * If TSDown produces any errors about missing imports, likely you need to add the missing global(s) + * to `utils/globals.ts`. + */ +const plugin: Plugin = { + name: "replace-globals", + transform: { + // Only process TS files in `src-js` directory + filter: { id: /\/src-js\/.+(?(), + visitedMemberExpressions = new Set(), + missingGlobalVars = new Set(); + + const visitor = new Visitor({ + MemberExpression(node) { + // Skip nested `MemberExpression`s e.g. `Object.prototype` in `Object.prototype.toString` + if (visitedMemberExpressions.has(node)) return; + + // Exit if computed (`obj[prop]`) or private property (`obj.#prop`). + let { object, property } = node; + if (node.computed || property.type !== "Identifier") return; + + // Gather all properties in reverse order. + // e.g. `Object.prototype.toString` -> `propNames = ["toString", "prototype"]`. + const propNames: string[] = [property.name]; + while (true) { + // If `object` is an identifier, `node` is a member expression of form `a.b`, `a.b.c`, etc. + if (object.type === "Identifier") break; + + // If `object` is not a member expression, exit e.g. `foo().x` + if (object.type !== "MemberExpression") return; + + // We can't handle deep nesting yet + // oxlint-disable-next-line no-constant-condition + if (1) return; + + // Avoid processing the nested member expression again when it's visited later + visitedMemberExpressions.add(object); + + // Exit if computed (`obj[prop]`) or private property (`obj.#prop`). + property = object.property; + if (object.computed || property.type !== "Identifier") return; + + // `node` of form `.a.b` or `.a.b.c`. + // Loop round to process the `` part. + propNames.push(property.name); + + object = object.object; + } + + // Found a member expression of form `obj.a`, or `obj.a.b`, `obj.a.b.c`, etc. + // Exit if `obj` is not a global. + const globalName = object.name; + if (!GLOBALS.has(globalName)) return; + + const propName = propNames.reverse().join("."); + + const fullName = `${object.name}.${propName}`; + if (SKIP_GLOBALS.has(fullName)) return; + + const mapping = availableGlobals.get(globalName); + if (!mapping) { + missingGlobalVars.add(`\`${fullName}\``); + return; + } + + const varName = mapping.get(propName); + if (!varName) { + missingGlobalVars.add(`\`${fullName}\``); + return; + } + + // Add var name (e.g. `ObjectHasOwn`) to set of vars to import + varNames.add(varName); + + // Replace `Object.hasOwn` with `ObjectHasOwn` + magicString.overwrite(node.start, node.end, varName); + }, + }); + visitor.visit(program); + + // Log any globals that were not converted because `utils/globals.ts` has no export for them + if (missingGlobalVars.size > 0) { + // oxlint-disable-next-line no-console + console.error( + "--------------------------------------------------------------------------------\n" + + `WARNING: Unable to convert ${[...missingGlobalVars].join(" or ")} to global vars.\n` + + `Add exports to \`utils/globals.ts\` for them.\n` + + "--------------------------------------------------------------------------------", + ); + } + + if (varNames.size === 0) return; + + // Some globals were found. Import them from `utils/globals.ts`. + let relativePath = pathRelative(dirname(path), GLOBALS_PATH); + relativePath = relativePath.replace(/\\/g, "/"); + relativePath = relativePath.startsWith(".") ? relativePath : `./${relativePath}`; + const importStmt = `import { ${[...varNames].join(", ")} } from ${JSON.stringify(relativePath)};\n`; + + magicString.prepend(importStmt); + + return { code: magicString }; + }, + }, +}; + +export default plugin; + +/** + * Parse `utils/globals.ts` and return a list of globals and global vars it exports. + * @param path - Path to `utils/globals.ts` + * @returns Mapping from global name (e.g. `Object`) to mapping of properties of that global to var names + * (e.g. `hasOwn` -> `ObjectHasOwn`). + */ +function getAvailableGlobals(path: string): Map> { + const code = fs.readFileSync(path, "utf8"); + const program = parse(path, code); + + const globals = new Map>(); + + const visitor = new Visitor({ + ExportNamedDeclaration(node) { + const { declaration } = node; + if (declaration == null || declaration.type !== "VariableDeclaration") return; + const declarator = declaration.declarations[0]; + if (!declarator) return; + const { init } = declarator; + if (!init || init.type !== "Identifier") return; + + const obj = declarator.id; + if (obj.type !== "ObjectPattern") return; + + const globalName = init.name; + let mapping = globals.get(globalName); + if (!mapping) { + mapping = new Map(); + globals.set(globalName, mapping); + } + + for (const prop of obj.properties) { + if (prop.type !== "Property" || prop.method || prop.computed) continue; + + const { key, value } = prop; + if (key.type !== "Identifier" || value.type !== "Identifier") continue; + + mapping.set(key.name, value.name); + } + }, + }); + visitor.visit(program); + + return globals; +} diff --git a/apps/oxlint/tsdown_plugins/utils.ts b/apps/oxlint/tsdown_plugins/utils.ts new file mode 100644 index 0000000000000..ef2c1dc66f827 --- /dev/null +++ b/apps/oxlint/tsdown_plugins/utils.ts @@ -0,0 +1,16 @@ +import { parseSync } from "oxc-parser"; + +import type { Program } from "@oxc-project/types"; + +/** + * Parse code and return the AST. + * @param path - Path to file + * @param code - Source code + * @returns AST + * @throws Error if parsing fails + */ +export function parse(path: string, code: string): Program { + const { program, errors } = parseSync(path, code); + if (errors.length !== 0) throw new Error(`Failed to parse ${path}: ${errors[0].message}`); + return program; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c0fc2a19d301..002b250fdfaf1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@napi-rs/cli': specifier: 'catalog:' version: 3.5.1(@emnapi/runtime@1.8.1)(@types/node@24.1.0) + '@oxc-project/types': + specifier: ^0.118.0 + version: 0.118.0 '@type-challenges/utils': specifier: ^0.1.1 version: 0.1.1